Visual header, body, and footer composition screen.

* Drag-and-drop section ordering with automatic saving.
* Add existing reusable documents to regions.
* Remove placements without deleting documents.
* Create independent page-specific copies.
* Shared-document usage warnings.
* Document creation, duplication, deletion protection and filtering.
* Draft and published document states.
* Document and placement scheduling.
* Authenticated homepage preview.
* Draft sections displayed in preview with a clear draft marker.
* Content Composer added to the administration sidebar.
* Public rendering now respects:
    * Publication status
    * Future publication dates
    * Expiry dates
    * Placement start and end dates
    * Enabled state
* Original Saasland homepage remains as the fallback when no composition exists.
This commit is contained in:
Mark Gregory
2026-07-27 08:28:52 +01:00
parent 3a93d4eaea
commit bfac7f555a
15 changed files with 686 additions and 57 deletions
@@ -1,20 +1,146 @@
<?php
namespace Modules\Content\Http\Controllers;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Illuminate\View\View;
use Modules\Content\Models\ContentDocument;
use Modules\Content\Models\ContentType;
final class ContentDocumentController extends Controller
{
public function index(): View { return view('content::admin.index',['documents'=>ContentDocument::query()->with('type')->withCount('placements')->orderBy('title')->paginate(30)]); }
public function edit(ContentDocument $document): View { $document->load('type','placements'); return view('content::admin.edit',['document'=>$document]); }
public function index(Request $request): View
{
$documents = ContentDocument::query()
->with('type')
->withCount('placements')
->when($request->string('type')->isNotEmpty(), fn ($query) => $query->whereHas('type', fn ($type) => $type->where('key', $request->string('type')->toString())))
->when($request->string('status')->isNotEmpty(), fn ($query) => $query->where('status', $request->string('status')->toString()))
->when($request->string('search')->isNotEmpty(), fn ($query) => $query->where('title', 'like', '%'.$request->string('search')->toString().'%'))
->orderBy('title')
->paginate(30)
->withQueryString();
return view('content::admin.index', [
'documents' => $documents,
'types' => ContentType::query()->where('is_active', true)->orderBy('name')->get(),
]);
}
public function create(Request $request): View
{
$types = ContentType::query()->where('is_active', true)->orderBy('name')->get();
$selectedType = $types->firstWhere('id', (int) $request->integer('type')) ?? $types->first();
return view('content::admin.form', [
'document' => new ContentDocument(['status' => 'draft', 'data' => []]),
'types' => $types,
'selectedType' => $selectedType,
]);
}
public function store(Request $request): RedirectResponse
{
$validated = $this->validateDocument($request);
$type = ContentType::query()->where('is_active', true)->findOrFail($validated['content_type_id']);
$document = ContentDocument::query()->create([
'content_type_id' => $type->id,
'title' => $validated['title'],
'slug' => $this->uniqueSlug($validated['slug'] ?: $validated['title']),
'status' => $validated['status'],
'published_at' => $validated['status'] === 'published' ? ($validated['published_at'] ?? now()) : null,
'expires_at' => $validated['expires_at'] ?? null,
'data' => json_decode($validated['data_json'], true, 512, JSON_THROW_ON_ERROR),
'created_by' => $request->user()?->id,
'updated_by' => $request->user()?->id,
]);
return redirect()->route('content.documents.edit', $document)->with('status', 'Content document created.');
}
public function edit(ContentDocument $document): View
{
$document->load('type', 'placements');
return view('content::admin.form', [
'document' => $document,
'types' => ContentType::query()->where('is_active', true)->orderBy('name')->get(),
'selectedType' => $document->type,
]);
}
public function update(Request $request, ContentDocument $document): RedirectResponse
{
$validated=$request->validate(['title'=>['required','string','max:255'],'status'=>['required','in:draft,published'],'data_json'=>['required','json']]);
$document->update(['title'=>$validated['title'],'status'=>$validated['status'],'published_at'=>$validated['status']==='published'?($document->published_at??now()):null,'data'=>json_decode($validated['data_json'],true,512,JSON_THROW_ON_ERROR),'updated_by'=>$request->user()?->id]);
return back()->with('status','Content document updated.');
$validated = $this->validateDocument($request, $document);
$document->update([
'content_type_id' => $validated['content_type_id'],
'title' => $validated['title'],
'slug' => $this->uniqueSlug($validated['slug'] ?: $validated['title'], $document),
'status' => $validated['status'],
'published_at' => $validated['status'] === 'published' ? ($validated['published_at'] ?? $document->published_at ?? now()) : null,
'expires_at' => $validated['expires_at'] ?? null,
'data' => json_decode($validated['data_json'], true, 512, JSON_THROW_ON_ERROR),
'updated_by' => $request->user()?->id,
]);
return back()->with('status', 'Content document updated.');
}
public function duplicate(Request $request, ContentDocument $document): RedirectResponse
{
$copy = $document->replicate(['slug', 'status', 'published_at', 'created_by', 'updated_by']);
$copy->title = $document->title.' copy';
$copy->slug = $this->uniqueSlug($copy->title);
$copy->status = 'draft';
$copy->published_at = null;
$copy->created_by = $request->user()?->id;
$copy->updated_by = $request->user()?->id;
$copy->save();
return redirect()->route('content.documents.edit', $copy)->with('status', 'Independent draft copy created.');
}
public function destroy(ContentDocument $document): RedirectResponse
{
if ($document->placements()->exists()) {
return back()->withErrors(['document' => 'Remove this document from every page before deleting it.']);
}
$document->delete();
return redirect()->route('content.index')->with('status', 'Content document deleted.');
}
private function validateDocument(Request $request, ?ContentDocument $document = null): array
{
return $request->validate([
'content_type_id' => ['required', 'integer', Rule::exists('content_types', 'id')->where('is_active', true)],
'title' => ['required', 'string', 'max:255'],
'slug' => ['nullable', 'string', 'max:255'],
'status' => ['required', Rule::in(['draft', 'published'])],
'published_at' => ['nullable', 'date'],
'expires_at' => ['nullable', 'date', 'after:published_at'],
'data_json' => ['required', 'json'],
]);
}
private function uniqueSlug(string $value, ?ContentDocument $except = null): string
{
$base = Str::slug($value) ?: 'content-document';
$slug = $base;
$suffix = 2;
while (ContentDocument::withTrashed()->where('slug', $slug)->when($except, fn ($query) => $query->whereKeyNot($except->getKey()))->exists()) {
$slug = $base.'-'.$suffix++;
}
return $slug;
}
}
@@ -1,19 +1,157 @@
<?php
namespace Modules\Content\Http\Controllers;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\Rule;
use Illuminate\View\View;
use Modules\Content\Models\ContentDocument;
use Modules\Content\Models\PageContent;
final class PageCompositionController extends Controller
{
public function edit(string $pageKey): View { return view('content::admin.composition',['pageKey'=>$pageKey,'placements'=>PageContent::query()->with('document.type')->where('page_key',$pageKey)->orderByRaw("CASE region WHEN 'header' THEN 1 WHEN 'body' THEN 2 ELSE 3 END")->orderBy('sort_order')->get()->groupBy('region')]); }
private const REGIONS = ['header', 'body', 'footer'];
public function edit(string $pageKey): View
{
$placements = PageContent::query()
->with('document.type')
->where('page_key', $pageKey)
->orderByRaw("CASE region WHEN 'header' THEN 1 WHEN 'body' THEN 2 ELSE 3 END")
->orderBy('sort_order')
->get()
->groupBy('region');
$documents = ContentDocument::query()
->with('type')
->withCount('placements')
->orderBy('title')
->get();
return view('content::admin.composition', compact('pageKey', 'placements', 'documents'));
}
public function preview(string $pageKey): View
{
abort_unless($pageKey === 'home', 404, 'This phase currently provides the Saasland homepage preview.');
return view('stylist-theme-saasland::home', ['pageKey' => $pageKey]);
}
public function store(Request $request, string $pageKey): RedirectResponse
{
$validated = $request->validate([
'content_document_id' => ['required', 'integer', Rule::exists('content_documents', 'id')->whereNull('deleted_at')],
'region' => ['required', Rule::in(self::REGIONS)],
]);
$document = ContentDocument::query()->with('type')->findOrFail($validated['content_document_id']);
abort_unless(in_array($validated['region'], $document->type?->allowed_regions ?? [], true), 422, 'This content type is not allowed in the selected region.');
$nextOrder = ((int) PageContent::query()->where('page_key', $pageKey)->where('region', $validated['region'])->max('sort_order')) + 10;
PageContent::query()->create([
'page_key' => $pageKey,
'content_document_id' => $document->id,
'region' => $validated['region'],
'sort_order' => $nextOrder,
'is_enabled' => true,
]);
return back()->with('status', 'Document added to the '.$validated['region'].' region.');
}
public function update(Request $request, PageContent $placement): RedirectResponse
{
$validated=$request->validate(['sort_order'=>['required','integer','min:0'],'is_enabled'=>['nullable','boolean']]);
$placement->update(['sort_order'=>$validated['sort_order'],'is_enabled'=>$request->boolean('is_enabled')]);
return back()->with('status','Page placement updated.');
$validated = $request->validate([
'is_enabled' => ['nullable', 'boolean'],
'starts_at' => ['nullable', 'date'],
'ends_at' => ['nullable', 'date', 'after:starts_at'],
]);
$placement->update([
'is_enabled' => $request->boolean('is_enabled'),
'starts_at' => $validated['starts_at'] ?? null,
'ends_at' => $validated['ends_at'] ?? null,
]);
return back()->with('status', 'Page placement updated.');
}
public function destroy(PageContent $placement): RedirectResponse
{
$placement->delete();
$this->normaliseRegion($placement->page_key, $placement->region);
return back()->with('status', 'Document removed from this page. The reusable document was not deleted.');
}
public function duplicate(Request $request, PageContent $placement): RedirectResponse
{
$source = $placement->document()->firstOrFail();
$copy = DB::transaction(function () use ($request, $placement, $source): ContentDocument {
$copy = $source->replicate(['slug', 'status', 'published_at', 'created_by', 'updated_by']);
$copy->title = $source->title.' copy';
$copy->slug = $source->slug.'-copy-'.strtolower(str()->random(6));
$copy->status = 'draft';
$copy->published_at = null;
$copy->created_by = $request->user()?->id;
$copy->updated_by = $request->user()?->id;
$copy->save();
PageContent::query()->create([
'page_id' => $placement->page_id,
'page_key' => $placement->page_key,
'content_document_id' => $copy->id,
'region' => $placement->region,
'sort_order' => $placement->sort_order + 5,
'is_enabled' => false,
'visibility_rules' => $placement->visibility_rules,
'presentation' => $placement->presentation,
]);
return $copy;
});
$this->normaliseRegion($placement->page_key, $placement->region);
return redirect()->route('content.documents.edit', $copy)->with('status', 'Page-specific draft copy created and added after the original.');
}
public function reorder(Request $request, string $pageKey): JsonResponse|RedirectResponse
{
$validated = $request->validate([
'region' => ['required', Rule::in(self::REGIONS)],
'placements' => ['required', 'array'],
'placements.*' => ['required', 'integer'],
]);
$ids = array_values(array_unique(array_map('intval', $validated['placements'])));
$owned = PageContent::query()->where('page_key', $pageKey)->where('region', $validated['region'])->whereIn('id', $ids)->pluck('id')->all();
abort_unless(count($owned) === count($ids), 422, 'One or more placements do not belong to this page region.');
DB::transaction(function () use ($ids): void {
PageContent::query()->whereIn('id', $ids)->increment('sort_order', 100000);
foreach ($ids as $index => $id) {
PageContent::query()->whereKey($id)->update(['sort_order' => ($index + 1) * 10]);
}
});
return $request->expectsJson()
? response()->json(['message' => 'Order saved.'])
: back()->with('status', 'Page order updated.');
}
private function normaliseRegion(?string $pageKey, string $region): void
{
$ids = PageContent::query()->where('page_key', $pageKey)->where('region', $region)->orderBy('sort_order')->pluck('id');
DB::transaction(function () use ($ids): void {
foreach ($ids as $index => $id) {
PageContent::query()->whereKey($id)->update(['sort_order' => ($index + 1) * 10]);
}
});
}
}
+22 -18
View File
@@ -1,33 +1,37 @@
# Content composition module
# Content Composer
This module separates public pages into reusable typed content documents placed in named regions.
The Content module provides reusable typed documents and ordered page regions.
## Install the baseline composition
## Phase 2 features
- Header, body and footer composition editor.
- Drag-and-drop ordering with server-side ownership validation.
- Add existing published or draft documents to a page.
- Remove a placement without deleting the reusable document.
- Create an independent page-specific draft copy.
- Shared-use warnings and deletion protection.
- Placement enable/disable and date scheduling.
- Content document creation, duplication, filtering and publishing controls.
- Authenticated Saasland homepage preview, including draft labels.
- Content Composer entry in the administration sidebar.
## Setup
```bash
php artisan migrate
php artisan content:install-saasland
```
Use `--force` to replace the existing `home` placements while retaining reusable documents.
Reset the seeded homepage composition when required:
```bash
php artisan content:install-saasland --force
```
## Administration
Administration:
After signing in, open:
- `/admin/content`
- `/admin/content/pages/home`
- `/admin/content/pages/home/preview`
- `/admin/content` for reusable content documents.
- `/admin/content/pages/home` for the homepage header/body/footer composition.
Documents contain structured JSON data and can be shared by several page placements. Placements own ordering, enabled state, scheduling, visibility and presentation metadata. The Saasland frontend falls back to its original hard-coded homepage until the migration and installer command have been run.
## Theme renderers
Saasland renderers are stored under:
`resources/themes/saasland/views/documents`
The renderer name is defined by each `content_types.renderer` value. Missing renderers fail safely through `content::documents.unsupported`.
The public theme remains backward-compatible: when no composition exists, the legacy Saasland homepage, header and footer are rendered.
@@ -1 +1,146 @@
<x-layouts.app :title="'Page composition: '.$pageKey"><div class="p-6 max-w-5xl space-y-6"><h1 class="text-2xl font-semibold">Page composition: {{ $pageKey }}</h1>@if(session('status'))<p>{{ session('status') }}</p>@endif @foreach(['header','body','footer'] as $region)<section><h2 class="mb-2 text-lg font-semibold uppercase">{{ $region }}</h2><div class="space-y-2">@forelse($placements->get($region,collect()) as $placement)<form method="POST" action="{{ route('content.placements.update',$placement) }}" class="flex items-center gap-3 rounded-xl border p-3">@csrf @method('PUT')<div class="flex-1"><b>{{ $placement->document?->title }}</b><div class="text-xs text-zinc-500">{{ $placement->document?->type?->name }}</div></div><input class="w-20 rounded border p-2" type="number" name="sort_order" value="{{ $placement->sort_order }}"><label><input type="checkbox" name="is_enabled" value="1" @checked($placement->is_enabled)> Enabled</label><a class="underline" href="{{ route('content.documents.edit',$placement->document) }}">Edit</a><button class="rounded border px-3 py-2">Update</button></form>@empty<p class="text-sm text-zinc-500">No documents assigned.</p>@endforelse</div></section>@endforeach</div></x-layouts.app>
<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>
@@ -1 +0,0 @@
<x-layouts.app :title="$document->title"><form method="POST" action="{{ route('content.documents.update',$document) }}" class="p-6 max-w-5xl space-y-5">@csrf @method('PUT')<h1 class="text-2xl font-semibold">{{ $document->title }}</h1>@if(session('status'))<p>{{ session('status') }}</p>@endif<label class="block">Title<input class="mt-1 w-full rounded border p-2" name="title" value="{{ old('title',$document->title) }}"></label><label class="block">Status<select class="mt-1 w-full rounded border p-2" name="status"><option @selected($document->status==='draft')>draft</option><option @selected($document->status==='published')>published</option></select></label><label class="block">Structured data (JSON)<textarea class="mt-1 min-h-96 w-full rounded border p-3 font-mono text-sm" name="data_json">{{ old('data_json',json_encode($document->data,JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES)) }}</textarea></label>@error('data_json')<p class="text-red-600">{{ $message }}</p>@enderror<button class="rounded bg-black px-4 py-2 text-white">Save document</button></form></x-layouts.app>
@@ -0,0 +1,120 @@
<x-layouts.app :title="$document->exists ? $document->title : __('Create content document')">
<div class="mx-auto max-w-6xl space-y-6">
<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>
</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>
</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 will appear everywhere it is published. Use “Create independent copy” for page-specific content.
</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]">
@csrf
@if($document->exists) @method('PUT') @endif
<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>
<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>
@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>
</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';
}
});
</script>
@endpush
</x-layouts.app>
@@ -1,5 +1,53 @@
<x-layouts.app :title="__('Content documents')">
<div class="p-6 space-y-6"><div><h1 class="text-2xl font-semibold">Content documents</h1><p class="text-sm text-zinc-500">Reusable typed documents used by page regions.</p></div>
<div><a class="underline" href="{{ route('content.pages.edit','home') }}">Edit homepage composition</a></div>
<div class="overflow-x-auto rounded-xl border"><table class="w-full text-sm"><thead><tr><th class="p-3 text-left">Document</th><th>Type</th><th>Status</th><th>Uses</th><th></th></tr></thead><tbody>@foreach($documents as $document)<tr class="border-t"><td class="p-3">{{ $document->title }}</td><td>{{ $document->type?->name }}</td><td>{{ ucfirst($document->status) }}</td><td>{{ $document->placements_count }}</td><td><a class="underline" href="{{ route('content.documents.edit',$document) }}">Edit</a></td></tr>@endforeach</tbody></table></div>{{ $documents->links() }}</div>
<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>
+17 -6
View File
@@ -1,12 +1,23 @@
<?php
use Illuminate\Support\Facades\Route;
use Modules\Content\Http\Controllers\ContentDocumentController;
use Modules\Content\Http\Controllers\PageCompositionController;
Route::middleware(['web','auth','verified'])->prefix('admin/content')->name('content.')->group(function (): void {
Route::get('/', [ContentDocumentController::class,'index'])->name('index');
Route::get('/documents/{document}/edit', [ContentDocumentController::class,'edit'])->name('documents.edit');
Route::put('/documents/{document}', [ContentDocumentController::class,'update'])->name('documents.update');
Route::get('/pages/{pageKey}', [PageCompositionController::class,'edit'])->name('pages.edit');
Route::put('/placements/{placement}', [PageCompositionController::class,'update'])->name('placements.update');
Route::middleware(['web', 'auth', 'verified'])->prefix('admin/content')->name('content.')->group(function (): void {
Route::get('/', [ContentDocumentController::class, 'index'])->name('index');
Route::get('/documents/create', [ContentDocumentController::class, 'create'])->name('documents.create');
Route::post('/documents', [ContentDocumentController::class, 'store'])->name('documents.store');
Route::get('/documents/{document}/edit', [ContentDocumentController::class, 'edit'])->name('documents.edit');
Route::put('/documents/{document}', [ContentDocumentController::class, 'update'])->name('documents.update');
Route::post('/documents/{document}/duplicate', [ContentDocumentController::class, 'duplicate'])->name('documents.duplicate');
Route::delete('/documents/{document}', [ContentDocumentController::class, 'destroy'])->name('documents.destroy');
Route::get('/pages/{pageKey}', [PageCompositionController::class, 'edit'])->name('pages.edit');
Route::get('/pages/{pageKey}/preview', [PageCompositionController::class, 'preview'])->name('pages.preview');
Route::post('/pages/{pageKey}/placements', [PageCompositionController::class, 'store'])->name('placements.store');
Route::put('/placements/{placement}', [PageCompositionController::class, 'update'])->name('placements.update');
Route::delete('/placements/{placement}', [PageCompositionController::class, 'destroy'])->name('placements.destroy');
Route::post('/placements/{placement}/duplicate', [PageCompositionController::class, 'duplicate'])->name('placements.duplicate');
Route::post('/pages/{pageKey}/reorder', [PageCompositionController::class, 'reorder'])->name('placements.reorder');
});
+55 -10
View File
@@ -1,8 +1,11 @@
<?php
namespace Modules\Content\Services;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\View;
use Modules\Content\Models\ContentDocument;
use Modules\Content\Models\PageContent;
use Throwable;
@@ -10,29 +13,71 @@ final class PageRenderer
{
public function hasComposition(string $pageKey): bool
{
try { return Schema::hasTable('page_content') && PageContent::query()->where('page_key',$pageKey)->where('is_enabled',true)->exists(); }
catch (Throwable) { return false; }
try {
return Schema::hasTable('page_content')
&& PageContent::query()->where('page_key', $pageKey)->where('is_enabled', true)->exists();
} catch (Throwable) {
return false;
}
}
public function renderRegion(string $pageKey, string $region): string
{
if (! $this->hasComposition($pageKey)) return '';
if (! $this->hasComposition($pageKey)) {
return '';
}
$preview = Route::currentRouteNamed('content.pages.preview');
$placements = PageContent::query()
->with('document.type')
->where('page_key', $pageKey)
->where('region', $region)
->where('is_enabled', true)
->where(fn($q) => $q->whereNull('starts_at')->orWhere('starts_at','<=',now()))
->where(fn($q) => $q->whereNull('ends_at')->orWhere('ends_at','>',now()))
->orderBy('sort_order')->get();
->where(fn ($query) => $query->whereNull('starts_at')->orWhere('starts_at', '<=', now()))
->where(fn ($query) => $query->whereNull('ends_at')->orWhere('ends_at', '>', now()))
->orderBy('sort_order')
->get();
return $placements->map(function (PageContent $placement): string {
return $placements->map(function (PageContent $placement) use ($preview): string {
$document = $placement->document;
if (! $document || ! $document->type || $document->status !== 'published') return '';
if (! $document || ! $document->type || ! $this->isRenderable($document, $preview)) {
return '';
}
$view = 'stylist-theme-saasland::documents.'.$document->type->renderer;
if (! View::exists($view)) $view = 'content::documents.unsupported';
return View::make($view, ['document'=>$document,'data'=>$document->data ?? [],'placement'=>$placement])->render();
if (! View::exists($view)) {
$view = 'content::documents.unsupported';
}
$rendered = View::make($view, [
'document' => $document,
'data' => $document->data ?? [],
'placement' => $placement,
'isCompositionPreview' => $preview,
])->render();
if ($preview && $document->status !== 'published') {
return '<div style="position:relative"><div style="position:absolute;z-index:1000;top:8px;right:8px;padding:5px 9px;border-radius:999px;background:#f59e0b;color:#111827;font:600 12px/1 sans-serif">Draft preview</div>'.$rendered.'</div>';
}
return $rendered;
})->implode("\n");
}
private function isRenderable(ContentDocument $document, bool $preview): bool
{
if ($preview) {
return true;
}
if ($document->status !== 'published') {
return false;
}
if ($document->published_at?->isFuture()) {
return false;
}
return ! $document->expires_at?->isPast();
}
}
@@ -26,6 +26,7 @@
<flux:sidebar.group :heading="__('Content')" expandable :expanded="request()->routeIs('administration.pages.*', 'administration.menus.*', 'administration.redirects.*', 'administration.forms.*', 'administration.documents.*', 'administration.banners.*', 'administration.faq.*', 'administration.team.*', 'administration.testimonials.*')" class="grid">
<flux:sidebar.item icon="document-text" :href="route('administration.pages.index')" :current="request()->routeIs('administration.pages.*')" wire:navigate>{{ __('Pages') }}</flux:sidebar.item>
<flux:sidebar.item icon="rectangle-stack" :href="route('content.index')" :current="request()->routeIs('content.*')" wire:navigate>{{ __('Content Composer') }}</flux:sidebar.item>
<flux:sidebar.item icon="bars-3-bottom-left" :href="route('administration.menus.index')" :current="request()->routeIs('administration.menus.*')" wire:navigate>{{ __('Menus') }}</flux:sidebar.item>
<flux:sidebar.item icon="arrow-uturn-right" :href="route('administration.redirects.index')" :current="request()->routeIs('administration.redirects.*')" wire:navigate>{{ __('Redirects') }}</flux:sidebar.item>
<flux:sidebar.item icon="rectangle-stack" :href="route('administration.forms.index')" :current="request()->routeIs('administration.forms.*')" wire:navigate>{{ __('Forms') }}</flux:sidebar.item>
@@ -120,6 +121,7 @@
<flux:toast.group><flux:toast /></flux:toast.group>
@endpersist
@stack('scripts')
@fluxScripts
</body>
</html>
-3
View File
@@ -1,3 +0,0 @@
*
!data/
!.gitignore
-2
View File
@@ -1,2 +0,0 @@
*
!.gitignore
-2
View File
@@ -1,2 +0,0 @@
*
!.gitignore
-2
View File
@@ -1,2 +0,0 @@
*
!.gitignore
View File