diff --git a/Modules/Content/Http/Controllers/ContentDocumentController.php b/Modules/Content/Http/Controllers/ContentDocumentController.php
index 587c03f..af35ce9 100644
--- a/Modules/Content/Http/Controllers/ContentDocumentController.php
+++ b/Modules/Content/Http/Controllers/ContentDocumentController.php
@@ -1,20 +1,146 @@
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;
}
}
diff --git a/Modules/Content/Http/Controllers/PageCompositionController.php b/Modules/Content/Http/Controllers/PageCompositionController.php
index 2b6a0de..af0ce60 100644
--- a/Modules/Content/Http/Controllers/PageCompositionController.php
+++ b/Modules/Content/Http/Controllers/PageCompositionController.php
@@ -1,19 +1,157 @@
$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]);
+ }
+ });
}
}
diff --git a/Modules/Content/README.md b/Modules/Content/README.md
index d2e7942..b1a800d 100644
--- a/Modules/Content/README.md
+++ b/Modules/Content/README.md
@@ -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.
diff --git a/Modules/Content/Resources/views/admin/composition.blade.php b/Modules/Content/Resources/views/admin/composition.blade.php
index 67c433b..de53bf2 100644
--- a/Modules/Content/Resources/views/admin/composition.blade.php
+++ b/Modules/Content/Resources/views/admin/composition.blade.php
@@ -1 +1,146 @@
-Page composition: {{ $pageKey }} @if(session('status'))
{{ session('status') }}
@endif @foreach(['header','body','footer'] as $region)
{{ $region }} @forelse($placements->get($region,collect()) as $placement)
@empty
No documents assigned.
@endforelse
@endforeach
+
+
+
+
+
Content documents / Page composition
+
{{ ucfirst($pageKey) }} page
+
Drag documents to reorder each region. Reusing a document keeps content synchronised across pages.
+
+
+ Create document
+ Open preview
+
+
+
+ @if(session('status'))
{{ session('status') }}
@endif
+ @if($errors->any())
@foreach($errors->all() as $error){{ $error }} @endforeach @endif
+
+
+
+ @foreach(['header', 'body', 'footer'] as $region)
+
+
+
{{ ucfirst($region) }} {{ $region === 'body' ? 'Ordered content sections' : 'Page-level '.$region.' document' }}
+
{{ $placements->get($region, collect())->count() }} document{{ $placements->get($region, collect())->count() === 1 ? '' : 's' }}
+
+
+ @forelse($placements->get($region, collect()) as $placement)
+
+
+
⋮⋮
+
+
+
{{ $placement->document?->title }}
+ {{ $placement->document?->type?->name }}
+ @if(($placement->document?->placements()->count() ?? 0) > 1)Shared @endif
+ @unless($placement->is_enabled)Disabled @endunless
+
+
Order {{ $placement->sort_order }} · {{ $placement->document?->status === 'published' ? 'Published' : 'Draft — hidden from public rendering' }}
+
+
Edit
+
+
+
+ Placement settings
+
+
+
+
+
+
+
+ @empty
+
No documents assigned to this region.
+ @endforelse
+
+
+ @endforeach
+
+
+
+
+ Add existing document Reuse published or draft content. Draft documents remain hidden from the public page.
+
+
+
+
+ Editing shared content
+ 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.
+
+
+
+ Live order
+ Order is saved automatically after dropping a document. A confirmation appears here.
+
+
+
+
+
+
+@push('scripts')
+
+@endpush
+
diff --git a/Modules/Content/Resources/views/admin/edit.blade.php b/Modules/Content/Resources/views/admin/edit.blade.php
deleted file mode 100644
index e7ec84d..0000000
--- a/Modules/Content/Resources/views/admin/edit.blade.php
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/Modules/Content/Resources/views/admin/form.blade.php b/Modules/Content/Resources/views/admin/form.blade.php
new file mode 100644
index 0000000..ebaa180
--- /dev/null
+++ b/Modules/Content/Resources/views/admin/form.blade.php
@@ -0,0 +1,120 @@
+
+
+
+
+
+
Content documents
+
/
+
{{ $document->exists ? 'Edit' : 'Create' }}
+
+
{{ $document->exists ? $document->title : 'Create content document' }}
+
Structured, reusable content that can be placed into one or more page regions.
+
+ @if($document->exists)
+
+
+ @csrf
+ Create independent copy
+
+
+ @csrf @method('DELETE')
+ Delete
+
+
+ @endif
+
+
+ @if(session('status'))
+
{{ session('status') }}
+ @endif
+ @if($errors->any())
+
+
The document could not be saved.
+
@foreach($errors->all() as $error){{ $error }} @endforeach
+
+ @endif
+
+ @if($document->exists && $document->placements->count() > 1)
+
+ Shared document. 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.
+
+ @endif
+
+
+ @csrf
+ @if($document->exists) @method('PUT') @endif
+
+
+
+ Document
+
+
+
+ @foreach($types as $type)
+ id) === $type->id)>{{ $type->name }}
+ @endforeach
+
+
+
+
+
+
+
Structured data
+
This phase retains JSON editing while adding validation, copies, placement controls and preview. Schema-generated field controls follow in the next checkpoint.
+
+
Format JSON
+
+ {{ old('data_json', json_encode($document->data ?: [], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)) }}
+
+
+
+
+
+
+
+
+@push('scripts')
+
+@endpush
+
diff --git a/Modules/Content/Resources/views/admin/index.blade.php b/Modules/Content/Resources/views/admin/index.blade.php
index e19be1e..c219cb4 100644
--- a/Modules/Content/Resources/views/admin/index.blade.php
+++ b/Modules/Content/Resources/views/admin/index.blade.php
@@ -1,5 +1,53 @@
-Content documents Reusable typed documents used by page regions.
-
-
Document Type Status Uses @foreach($documents as $document){{ $document->title }} {{ $document->type?->name }} {{ ucfirst($document->status) }} {{ $document->placements_count }} Edit @endforeach
{{ $documents->links() }}
+
+
+
+
Content documents
+
Create and reuse typed content across page regions.
+
+
+ Compose homepage
+ Create document
+
+
+
+ @if(session('status'))
+
{{ session('status') }}
+ @endif
+ @error('document')
{{ $message }}
@enderror
+
+
+
+
+ All types
+ @foreach($types as $type)key)>{{ $type->name }} @endforeach
+
+
+ All statuses
+ Draft
+ Published
+
+ Filter Reset
+
+
+
+
+ Document Type Status Uses Actions
+
+ @forelse($documents as $document)
+
+ {{ $document->title }}
{{ $document->slug }}
+ {{ $document->type?->name }}
+ {{ ucfirst($document->status) }}
+ {{ $document->placements_count }}
+ Edit
+
+ @empty
+ No content documents match the current filters.
+ @endforelse
+
+
+
+ {{ $documents->links() }}
+
diff --git a/Modules/Content/Routes/web.php b/Modules/Content/Routes/web.php
index 6f0a304..803e350 100644
--- a/Modules/Content/Routes/web.php
+++ b/Modules/Content/Routes/web.php
@@ -1,12 +1,23 @@
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');
});
diff --git a/Modules/Content/Services/PageRenderer.php b/Modules/Content/Services/PageRenderer.php
index a13c588..d02c898 100644
--- a/Modules/Content/Services/PageRenderer.php
+++ b/Modules/Content/Services/PageRenderer.php
@@ -1,8 +1,11 @@
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 '
Draft preview
'.$rendered.'
';
+ }
+
+ 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();
+ }
}
diff --git a/resources/themes/common/views/components/layouts/app/sidebar.blade.php b/resources/themes/common/views/components/layouts/app/sidebar.blade.php
index 364dd92..c848c25 100644
--- a/resources/themes/common/views/components/layouts/app/sidebar.blade.php
+++ b/resources/themes/common/views/components/layouts/app/sidebar.blade.php
@@ -26,6 +26,7 @@
{{ __('Pages') }}
+ {{ __('Content Composer') }}
{{ __('Menus') }}
{{ __('Redirects') }}
{{ __('Forms') }}
@@ -120,6 +121,7 @@
@endpersist
+@stack('scripts')
@fluxScripts