* Clear Live, Draft and Homepage indicators.
* Direct Edit page, Edit content and View page actions.
* Page editor divided into:
* Page details
* SEO and URL
* Publishing
* Automatic, unique URL slugs.
* Publish and expiry scheduling.
* Page templates: Default, Landing and Full Width.
* Homepage replacement handled automatically.
* Current homepage protected from accidental deletion.
* Page duplication includes its complete content structure.
* Content composer now works from the actual page record and page ID.
* Clearer section-based terminology for editors.
* Reusable content remains available as an advanced content library rather than dominating the workflow.
The package contains the eight complete changed files, preserving their project paths. It is not a patch collection.
148 lines
12 KiB
PHP
148 lines
12 KiB
PHP
<x-layouts.app :title="isset($page) ? 'Edit content: '.$page->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('administration.pages.index') }}" class="hover:text-zinc-900 dark:hover:text-white">Pages</a><span>/</span><span>Content composer</span></div>
|
|
<h1 class="mt-1 text-3xl font-semibold tracking-tight">{{ isset($page) ? $page->title : ucfirst($pageKey).' page' }}</h1>
|
|
<p class="mt-1 text-sm text-zinc-500">Add sections, edit their content and drag them into the order visitors should see.</p>
|
|
</div>
|
|
<div class="flex flex-wrap gap-2">
|
|
@isset($page)<flux:button :href="route('administration.pages.edit', $page)" variant="ghost">Page settings</flux:button>@endisset
|
|
<flux:button :href="route('content.documents.create')">Create section</flux:button>
|
|
@if(isset($page) && $page->status === 'published')<flux:button variant="primary" :href="route('pages.public.show', $page->slug)" target="_blank">Open live page</flux:button>@endif
|
|
</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 content section</h2><p class="text-sm text-zinc-500">Choose an existing section from the content library. Draft sections remain hidden from visitors.</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">Content section
|
|
<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 content section</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 section</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>
|