diff --git a/Modules/Content/Config/config.php b/Modules/Content/Config/config.php
index 050383f..b0ba6d5 100644
--- a/Modules/Content/Config/config.php
+++ b/Modules/Content/Config/config.php
@@ -1,2 +1,5 @@
'Content'];
+return [
+ 'default_page_key' => 'home',
+ 'regions' => ['header', 'body', 'footer'],
+];
diff --git a/Modules/Content/Console/InstallSaaslandContent.php b/Modules/Content/Console/InstallSaaslandContent.php
index 4e4e558..67fd9e0 100644
--- a/Modules/Content/Console/InstallSaaslandContent.php
+++ b/Modules/Content/Console/InstallSaaslandContent.php
@@ -1,7 +1,4 @@
error("Missing {$table}. Run php artisan migrate first."); return self::FAILURE; }}
- DB::transaction(function(){
- $types=[
- 'hero'=>['name'=>'Hero','schema'=>['eyebrow'=>['label'=>'Eyebrow','control'=>'text'],'title'=>['label'=>'Title','control'=>'text'],'text'=>['label'=>'Text','control'=>'textarea'],'primary_label'=>['label'=>'Primary button label','control'=>'text'],'primary_url'=>['label'=>'Primary button URL','control'=>'url']]],
- 'rich-text'=>['name'=>'Rich text','schema'=>['heading'=>['label'=>'Heading','control'=>'text'],'body'=>['label'=>'Body','control'=>'textarea']]],
- 'cta'=>['name'=>'Call to action','schema'=>['title'=>['label'=>'Title','control'=>'text'],'text'=>['label'=>'Text','control'=>'textarea'],'button_label'=>['label'=>'Button label','control'=>'text'],'button_url'=>['label'=>'Button URL','control'=>'url']]],
- ];
- foreach($types as $key=>$definition) ContentType::updateOrCreate(['key'=>$key],$definition+['description'=>'Saasland '.$definition['name'].' component']);
- if($this->option('force')) PageContent::where('page_key','home')->delete();
- if(!PageContent::where('page_key','home')->exists()){
- $type=ContentType::where('key','hero')->firstOrFail();
- $doc=ContentDocument::updateOrCreate(['slug'=>'home-hero'],['content_type_id'=>$type->id,'title'=>'Homepage hero','status'=>'published','data'=>['eyebrow'=>'Composable CMS','title'=>'Build your digital experience','text'=>'Edit this homepage from the Content Composer.','primary_label'=>'Get started','primary_url'=>'/admin/content']]);
- PageContent::create(['page_key'=>'home','region'=>'body','content_document_id'=>$doc->id,'sort_order'=>0,'is_enabled'=>true]);
+ $requiredTables = ['content_types', 'content_documents', 'page_content'];
+ $missingTables = array_values(array_filter(
+ $requiredTables,
+ static fn (string $table): bool => ! Schema::hasTable($table),
+ ));
+
+ if ($missingTables !== []) {
+ $this->components->error('Content composition tables are missing: '.implode(', ', $missingTables).'.');
+ $this->components->info('Run php artisan migrate before installing the Saasland composition.');
+
+ return self::FAILURE;
+ }
+
+ DB::transaction(function (): void {
+ if ($this->option('force')) PageContent::query()->where('page_key','home')->delete();
+ $definitions = $this->definitions();
+ $order = ['site-header'=>['header',10], 'hero'=>['body',10], 'feature-grid'=>['body',20], 'product-feed'=>['body',30], 'testimonial-feed'=>['body',40], 'team-feed'=>['body',50], 'blog-feed'=>['body',60], 'news-feed'=>['body',70], 'faq-feed'=>['body',80], 'newsletter-form'=>['body',90], 'call-to-action'=>['body',100], 'site-footer'=>['footer',10]];
+ foreach ($definitions as $key => $definition) {
+ $type = ContentType::query()->updateOrCreate(['key'=>$key], $definition['type']);
+ $document = ContentDocument::query()->updateOrCreate(['slug'=>'saasland-'.$key], ['content_type_id'=>$type->id,'title'=>$definition['title'],'data'=>$definition['data'],'status'=>'published','published_at'=>now()]);
+ [$region,$sort] = $order[$key];
+ PageContent::query()->updateOrCreate(['page_key'=>'home','region'=>$region,'sort_order'=>$sort], ['content_document_id'=>$document->id,'is_enabled'=>true]);
}
});
- $this->info('Saasland content installed.'); return self::SUCCESS;
+ $this->components->info('Saasland content composition installed.');
+ return self::SUCCESS;
+ }
+
+ private function definitions(): array
+ {
+ $type = fn(string $name,string $renderer,array $regions) => ['name'=>$name,'renderer'=>$renderer,'allowed_regions'=>$regions,'is_system'=>true,'is_active'=>true,'schema'=>[]];
+ return [
+ 'site-header'=>['title'=>'Main website header','type'=>$type('Site header','site-header',['header']),'data'=>['show_login'=>true,'show_registration'=>true,'sticky'=>true]],
+ 'hero'=>['title'=>'Homepage hero','type'=>$type('Hero','hero',['body']),'data'=>['eyebrow'=>'A better way to run your business','heading'=>'Transform Your Business With Our SaaS Solution','description'=>'Streamline operations, boost productivity, and improve customer satisfaction with one connected platform built for modern teams.','primary_label'=>'Get started free','primary_url'=>'/register','secondary_label'=>'Book demo','secondary_url'=>'#contact','show_metrics'=>true]],
+ 'feature-grid'=>['title'=>'Core features','type'=>$type('Feature grid','feature-grid',['body']),'data'=>['eyebrow'=>'Powerful features','heading'=>'Everything your team needs to grow','description'=>'Planning, collaboration, reporting, publishing, commerce and customer insight in one extensible platform.','items'=>[['title'=>'Smart automation','description'=>'Remove repetitive work and delays.'],['title'=>'Real-time analytics','description'=>'Turn activity into useful business insight.'],['title'=>'Content operations','description'=>'Create, review and publish confidently.'],['title'=>'Secure by design','description'=>'Protect users and data with dependable controls.'],['title'=>'Flexible integrations','description'=>'Connect the tools your organisation already uses.'],['title'=>'Modular architecture','description'=>'Activate only the capabilities each site needs.']]]],
+ 'product-feed'=>['title'=>'Pricing plans','type'=>$type('Product feed','product-feed',['body']),'data'=>['eyebrow'=>'Simple pricing','heading'=>'Choose a plan that fits','description'=>'Plans are managed from Products & Pricing in the CMS.','limit'=>6]],
+ 'testimonial-feed'=>['title'=>'Customer stories','type'=>$type('Testimonial feed','testimonial-feed',['body']),'data'=>['eyebrow'=>'Customer stories','heading'=>'Trusted by ambitious teams','limit'=>6]],
+ 'team-feed'=>['title'=>'Team','type'=>$type('Team feed','team-feed',['body']),'data'=>['eyebrow'=>'Our team','heading'=>'People behind the platform','limit'=>8]],
+ 'blog-feed'=>['title'=>'Latest blog posts','type'=>$type('Blog feed','blog-feed',['body']),'data'=>['eyebrow'=>'Insights','heading'=>'Latest from the blog','limit'=>3]],
+ 'news-feed'=>['title'=>'Latest news','type'=>$type('News feed','news-feed',['body']),'data'=>['eyebrow'=>'Newsroom','heading'=>'Latest news','limit'=>3]],
+ 'faq-feed'=>['title'=>'Frequently asked questions','type'=>$type('FAQ feed','faq-feed',['body']),'data'=>['eyebrow'=>'FAQ','heading'=>'Answers before you begin','limit'=>12]],
+ 'newsletter-form'=>['title'=>'Newsletter signup','type'=>$type('Newsletter form','newsletter-form',['body']),'data'=>['eyebrow'=>'Newsletter','heading'=>'Useful updates, no noise','description'=>'Receive product news, publishing guidance, and practical platform updates.','button_label'=>'Subscribe']],
+ 'call-to-action'=>['title'=>'Homepage final call to action','type'=>$type('Call to action','call-to-action',['body']),'data'=>['heading'=>'Ready to transform your business?','description'=>'Start building a faster, more connected operation today.','button_label'=>'Get started free','button_url'=>'/register']],
+ 'site-footer'=>['title'=>'Main website footer','type'=>$type('Site footer','site-footer',['footer']),'data'=>['description'=>'One connected platform for modern teams.','show_social_links'=>true,'show_back_to_top'=>true]],
+ ];
}
}
diff --git a/Modules/Content/Database/Migrations/2026_07_27_000000_create_content_composition_tables.php b/Modules/Content/Database/Migrations/2026_07_27_000000_create_content_composition_tables.php
index d935f3a..2fcd905 100644
--- a/Modules/Content/Database/Migrations/2026_07_27_000000_create_content_composition_tables.php
+++ b/Modules/Content/Database/Migrations/2026_07_27_000000_create_content_composition_tables.php
@@ -5,10 +5,58 @@ use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
- public function up(): void {
- if (!Schema::hasTable('content_types')) Schema::create('content_types', function(Blueprint $t){$t->id();$t->string('key')->unique();$t->string('name');$t->text('description')->nullable();$t->json('schema')->nullable();$t->timestamps();});
- if (!Schema::hasTable('content_documents')) Schema::create('content_documents', function(Blueprint $t){$t->id();$t->foreignId('content_type_id')->constrained('content_types')->cascadeOnDelete();$t->string('title');$t->string('slug')->unique();$t->string('status')->default('draft')->index();$t->json('data')->nullable();$t->timestamp('published_at')->nullable();$t->timestamp('expires_at')->nullable();$t->timestamps();});
- if (!Schema::hasTable('page_content')) Schema::create('page_content', function(Blueprint $t){$t->id();$t->string('page_key')->index();$t->string('region')->default('body')->index();$t->foreignId('content_document_id')->constrained('content_documents')->cascadeOnDelete();$t->unsignedInteger('sort_order')->default(0);$t->boolean('is_enabled')->default(true);$t->timestamp('published_at')->nullable();$t->timestamp('expires_at')->nullable();$t->timestamps();$t->index(['page_key','region','sort_order']);});
+ public function up(): void
+ {
+ Schema::create('content_types', function (Blueprint $table): void {
+ $table->id();
+ $table->string('name');
+ $table->string('key')->unique();
+ $table->text('description')->nullable();
+ $table->json('schema')->nullable();
+ $table->string('renderer');
+ $table->json('allowed_regions')->nullable();
+ $table->boolean('is_system')->default(false);
+ $table->boolean('is_active')->default(true);
+ $table->timestamps();
+ });
+
+ Schema::create('content_documents', function (Blueprint $table): void {
+ $table->id();
+ $table->foreignId('content_type_id')->constrained('content_types')->cascadeOnDelete();
+ $table->string('title');
+ $table->string('slug')->unique();
+ $table->json('data')->nullable();
+ $table->string('status')->default('draft')->index();
+ $table->timestamp('published_at')->nullable()->index();
+ $table->timestamp('expires_at')->nullable();
+ $table->string('language', 12)->nullable();
+ $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
+ $table->foreignId('updated_by')->nullable()->constrained('users')->nullOnDelete();
+ $table->timestamps();
+ $table->softDeletes();
+ });
+
+ Schema::create('page_content', function (Blueprint $table): void {
+ $table->id();
+ $table->foreignId('page_id')->nullable()->constrained('pages')->cascadeOnDelete();
+ $table->string('page_key')->nullable()->index();
+ $table->foreignId('content_document_id')->constrained('content_documents')->cascadeOnDelete();
+ $table->string('region')->index();
+ $table->unsignedInteger('sort_order')->default(0);
+ $table->boolean('is_enabled')->default(true);
+ $table->json('visibility_rules')->nullable();
+ $table->json('presentation')->nullable();
+ $table->timestamp('starts_at')->nullable();
+ $table->timestamp('ends_at')->nullable();
+ $table->timestamps();
+ $table->unique(['page_key', 'region', 'sort_order'], 'page_content_key_region_order_unique');
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('page_content');
+ Schema::dropIfExists('content_documents');
+ Schema::dropIfExists('content_types');
}
- public function down(): void { Schema::dropIfExists('page_content'); Schema::dropIfExists('content_documents'); Schema::dropIfExists('content_types'); }
};
diff --git a/Modules/Content/Http/Controllers/ContentDocumentController.php b/Modules/Content/Http/Controllers/ContentDocumentController.php
index 936456b..af35ce9 100644
--- a/Modules/Content/Http/Controllers/ContentDocumentController.php
+++ b/Modules/Content/Http/Controllers/ContentDocumentController.php
@@ -1,16 +1,14 @@
with(['type','placements'])
- ->when($request->filled('search'), fn ($q) => $q->where(fn ($q) => $q->where('title','like','%'.$request->string('search').'%')->orWhere('slug','like','%'.$request->string('search').'%')))
- ->when($request->filled('type'), fn ($q) => $q->whereHas('type', fn ($q) => $q->where('key',$request->string('type'))))
- ->when($request->filled('status'), fn ($q) => $q->where('status',$request->string('status')))
- ->latest()->paginate(25)->withQueryString();
- return view('content::admin.index', ['documents'=>$documents, 'types'=>ContentType::orderBy('name')->get()]);
+ $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::orderBy('name')->get();
+ $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','content_type_id'=>$selectedType?->id]), 'types'=>$types, 'selectedType'=>$selectedType]);
+
+ return view('content::admin.form', [
+ 'document' => new ContentDocument(['status' => 'draft', 'data' => []]),
+ 'types' => $types,
+ 'selectedType' => $selectedType,
+ ]);
}
- public function store(SaveContentDocumentRequest $request): RedirectResponse
+ public function store(Request $request): RedirectResponse
{
- $document = ContentDocument::create($request->payload());
- return redirect()->route('content.documents.edit',$document)->with('status','Content document created.');
+ $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('placements');
- return view('content::admin.form', ['document'=>$document, 'types'=>ContentType::orderBy('name')->get(), 'selectedType'=>$document->type()->first()]);
- }
+ $document->load('type', 'placements');
- public function update(SaveContentDocumentRequest $request, ContentDocument $document): RedirectResponse
- {
- $document->update($request->payload());
- return back()->with('status','Content document saved.');
- }
-
- public function autosave(Request $request, ContentDocument $document): JsonResponse
- {
- $validated = $request->validate([
- 'content_type_id' => ['required', 'integer', 'exists:content_types,id'],
- 'title' => ['required', 'string', 'max:255'],
- 'slug' => ['nullable', 'string', 'max:255'],
- 'status' => ['required', 'in:draft,published,archived'],
- 'data_json' => ['required', 'json'],
- 'published_at' => ['nullable', 'date'],
- 'expires_at' => ['nullable', 'date', 'after:published_at'],
+ 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 = $this->validateDocument($request, $document);
$document->update([
'content_type_id' => $validated['content_type_id'],
'title' => $validated['title'],
- 'slug' => filled($validated['slug'] ?? null) ? $validated['slug'] : str($validated['title'])->slug(),
+ 'slug' => $this->uniqueSlug($validated['slug'] ?: $validated['title'], $document),
'status' => $validated['status'],
- 'data' => json_decode($validated['data_json'], true, flags: JSON_THROW_ON_ERROR),
- 'published_at' => $validated['published_at'] ?? null,
+ '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 response()->json([
- 'saved' => true,
- 'saved_at' => now()->toIso8601String(),
- 'updated_at' => $document->fresh()->updated_at?->toIso8601String(),
- ]);
+ return back()->with('status', 'Content document updated.');
}
- public function preview(Request $request): JsonResponse
+ public function duplicate(Request $request, ContentDocument $document): RedirectResponse
{
- $validated = $request->validate([
- 'content_type_id' => ['required', 'integer', 'exists:content_types,id'],
- 'data_json' => ['required', 'json'],
- ]);
-
- $type = ContentType::query()->findOrFail($validated['content_type_id']);
- $view = 'stylist-theme-saasland::documents.'.$type->key;
-
- if (! ViewFacade::exists($view)) {
- return response()->json([
- 'html' => '
No Saasland renderer exists for this content type yet.
',
- 'view' => $view,
- ]);
- }
-
- $data = json_decode($validated['data_json'], true, flags: JSON_THROW_ON_ERROR);
-
- return response()->json([
- 'html' => ViewFacade::make($view, array_merge($data, [
- 'data' => $data,
- 'document' => new ContentDocument(['data' => $data]),
- 'placement' => null,
- 'preview' => true,
- ]))->render(),
- 'view' => $view,
- ]);
- }
-
- public function duplicate(ContentDocument $document): RedirectResponse
- {
- $copy = $document->replicate(['slug']);
+ $copy = $document->replicate(['slug', 'status', 'published_at', 'created_by', 'updated_by']);
$copy->title = $document->title.' copy';
- $copy->slug = str($copy->title)->slug().'-'.str()->lower(str()->random(6));
+ $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 copy created.');
+
+ return redirect()->route('content.documents.edit', $copy)->with('status', 'Independent draft copy created.');
}
public function destroy(ContentDocument $document): RedirectResponse
{
- abort_if($document->placements()->exists(), 422, 'A document used by a page cannot be deleted.');
+ 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.');
+
+ 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/PageComposerController.php b/Modules/Content/Http/Controllers/PageComposerController.php
deleted file mode 100644
index 8c2ac8b..0000000
--- a/Modules/Content/Http/Controllers/PageComposerController.php
+++ /dev/null
@@ -1,56 +0,0 @@
-$pageKey,
- 'placements'=>PageContent::with('document.type')->where('page_key',$pageKey)->orderBy('region')->orderBy('sort_order')->get()->groupBy('region'),
- 'documents'=>ContentDocument::with('type')->orderBy('title')->get(),
- ]);
- }
-
- public function store(Request $request, string $pageKey): RedirectResponse
- {
- $data=$request->validate(['content_document_id'=>['required','exists:content_documents,id'],'region'=>['required',Rule::in(['header','body','footer'])]]);
- $data['page_key']=$pageKey; $data['is_enabled']=true;
- $data['sort_order']=(int) PageContent::where('page_key',$pageKey)->where('region',$data['region'])->max('sort_order')+1;
- PageContent::create($data);
- return back()->with('status','Placement added.');
- }
-
- public function update(Request $request, string $pageKey, PageContent $placement): RedirectResponse
- {
- abort_unless($placement->page_key===$pageKey,404);
- $placement->update($request->validate(['region'=>['sometimes',Rule::in(['header','body','footer'])],'is_enabled'=>['sometimes','boolean'],'published_at'=>['nullable','date'],'expires_at'=>['nullable','date','after:published_at']]));
- return back()->with('status','Placement updated.');
- }
-
- public function destroy(string $pageKey, PageContent $placement): RedirectResponse
- {
- abort_unless($placement->page_key===$pageKey,404); $placement->delete();
- return back()->with('status','Placement removed.');
- }
-
- public function reorder(Request $request, string $pageKey): JsonResponse
- {
- $data=$request->validate(['region'=>['required',Rule::in(['header','body','footer'])],'ids'=>['required','array'],'ids.*'=>['integer']]);
- DB::transaction(function() use($data,$pageKey){ foreach($data['ids'] as $i=>$id){ PageContent::whereKey($id)->where('page_key',$pageKey)->where('region',$data['region'])->update(['sort_order'=>$i]); }});
- return response()->json(['saved'=>true]);
- }
-}
diff --git a/Modules/Content/Http/Controllers/PageCompositionController.php b/Modules/Content/Http/Controllers/PageCompositionController.php
new file mode 100644
index 0000000..af0ce60
--- /dev/null
+++ b/Modules/Content/Http/Controllers/PageCompositionController.php
@@ -0,0 +1,157 @@
+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([
+ '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/Http/Requests/SaveContentDocumentRequest.php b/Modules/Content/Http/Requests/SaveContentDocumentRequest.php
deleted file mode 100644
index 83fec3a..0000000
--- a/Modules/Content/Http/Requests/SaveContentDocumentRequest.php
+++ /dev/null
@@ -1,37 +0,0 @@
-route('document');
- return [
- 'content_type_id' => ['required','integer','exists:content_types,id'],
- 'title' => ['required','string','max:255'],
- 'slug' => ['nullable','string','max:255', Rule::unique('content_documents','slug')->ignore($document instanceof ContentDocument ? $document->id : null)],
- 'status' => ['required', Rule::in(['draft','published','archived'])],
- 'data_json' => ['required','json'],
- 'published_at' => ['nullable','date'],
- 'expires_at' => ['nullable','date','after:published_at'],
- ];
- }
-
- public function payload(): array
- {
- $validated = $this->validated();
- $validated['slug'] = filled($validated['slug'] ?? null) ? $validated['slug'] : str($validated['title'])->slug();
- $validated['data'] = json_decode($validated['data_json'], true, flags: JSON_THROW_ON_ERROR);
- unset($validated['data_json']);
- return $validated;
- }
-}
diff --git a/Modules/Content/Models/ContentDocument.php b/Modules/Content/Models/ContentDocument.php
index 5370b57..edf93be 100644
--- a/Modules/Content/Models/ContentDocument.php
+++ b/Modules/Content/Models/ContentDocument.php
@@ -1,18 +1,17 @@
'array','published_at'=>'datetime','expires_at'=>'datetime'];
-
+ use SoftDeletes;
+ protected $fillable = ['content_type_id','title','slug','data','status','published_at','expires_at','language','created_by','updated_by'];
+ protected function casts(): array { return ['data'=>'array','published_at'=>'datetime','expires_at'=>'datetime']; }
public function type(): BelongsTo { return $this->belongsTo(ContentType::class, 'content_type_id'); }
public function placements(): HasMany { return $this->hasMany(PageContent::class); }
+ public function scopePublished($query) { return $query->where('status','published')->where(fn($q) => $q->whereNull('published_at')->orWhere('published_at','<=',now()))->where(fn($q) => $q->whereNull('expires_at')->orWhere('expires_at','>',now())); }
}
diff --git a/Modules/Content/Models/ContentType.php b/Modules/Content/Models/ContentType.php
index 868f129..6b0ffd7 100644
--- a/Modules/Content/Models/ContentType.php
+++ b/Modules/Content/Models/ContentType.php
@@ -1,7 +1,4 @@
'array'];
-
+ protected $fillable = ['name','key','description','schema','renderer','allowed_regions','is_system','is_active'];
+ protected function casts(): array { return ['schema'=>'array','allowed_regions'=>'array','is_system'=>'boolean','is_active'=>'boolean']; }
public function documents(): HasMany { return $this->hasMany(ContentDocument::class); }
}
diff --git a/Modules/Content/Models/PageContent.php b/Modules/Content/Models/PageContent.php
index 7a0e352..cb204f3 100644
--- a/Modules/Content/Models/PageContent.php
+++ b/Modules/Content/Models/PageContent.php
@@ -1,17 +1,15 @@
'boolean','published_at'=>'datetime','expires_at'=>'datetime'];
-
+ protected $fillable = ['page_id','page_key','content_document_id','region','sort_order','is_enabled','visibility_rules','presentation','starts_at','ends_at'];
+ protected function casts(): array { return ['is_enabled'=>'boolean','visibility_rules'=>'array','presentation'=>'array','starts_at'=>'datetime','ends_at'=>'datetime']; }
public function document(): BelongsTo { return $this->belongsTo(ContentDocument::class, 'content_document_id'); }
+ public function page(): BelongsTo { return $this->belongsTo(Page::class); }
}
diff --git a/Modules/Content/Providers/ContentServiceProvider.php b/Modules/Content/Providers/ContentServiceProvider.php
index a47bf41..0ba0b27 100644
--- a/Modules/Content/Providers/ContentServiceProvider.php
+++ b/Modules/Content/Providers/ContentServiceProvider.php
@@ -1,7 +1,4 @@
mergeConfigFrom(__DIR__.'/../Config/config.php', 'content');
+ $this->mergeConfigFrom(module_path('Content','Config/config.php'),'content');
$this->app->singleton(PageRenderer::class);
}
-
public function boot(): void
{
- $this->loadRoutesFrom(__DIR__.'/../Routes/web.php');
- $this->loadViewsFrom(__DIR__.'/../Resources/views', 'content');
- $this->loadMigrationsFrom(__DIR__.'/../Database/Migrations');
-
- if ($this->app->runningInConsole()) {
- $this->commands([InstallSaaslandContent::class]);
- }
+ $this->loadMigrationsFrom(module_path('Content','Database/Migrations'));
+ $this->loadViewsFrom(module_path('Content','Resources/views'),'content');
+ $this->loadRoutesFrom(module_path('Content','Routes/web.php'));
+ if ($this->app->runningInConsole()) $this->commands([InstallSaaslandContent::class]);
}
}
diff --git a/Modules/Content/README.md b/Modules/Content/README.md
new file mode 100644
index 0000000..b1a800d
--- /dev/null
+++ b/Modules/Content/README.md
@@ -0,0 +1,37 @@
+# Content Composer
+
+The Content module provides reusable typed documents and ordered page regions.
+
+## 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
+```
+
+Reset the seeded homepage composition when required:
+
+```bash
+php artisan content:install-saasland --force
+```
+
+Administration:
+
+- `/admin/content`
+- `/admin/content/pages/home`
+- `/admin/content/pages/home/preview`
+
+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
new file mode 100644
index 0000000..de53bf2
--- /dev/null
+++ b/Modules/Content/Resources/views/admin/composition.blade.php
@@ -0,0 +1,146 @@
+
+
+
+
+
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
+
+
+
+
+
+
+@push('scripts')
+
+@endpush
+
diff --git a/Modules/Content/Resources/views/admin/form.blade.php b/Modules/Content/Resources/views/admin/form.blade.php
index 80c8e0a..ebaa180 100644
--- a/Modules/Content/Resources/views/admin/form.blade.php
+++ b/Modules/Content/Resources/views/admin/form.blade.php
@@ -1,640 +1,120 @@
-@php
- $builderSchema = $selectedType?->schema ?: [];
- $builderData = old('data_json', json_encode($document->data ?: [], JSON_UNESCAPED_SLASHES));
-@endphp
-
+
Content documents
/
-
{{ $document->exists ? 'Visual editor' : 'Create' }}
+
{{ $document->exists ? 'Edit' : 'Create' }}
{{ $document->exists ? $document->title : 'Create content document' }}
-
Build and edit structured content visually. The stored document format remains fully compatible with the existing renderer.
+
Structured, reusable content that can be placed into one or more page regions.
@if($document->exists)
-
-
+
+
@endif
- @if(session('status'))
{{ session('status') }}
@endif
- @if($errors->any())
-
The document could not be saved.@foreach($errors->all() as $error)- {{ $error }}
@endforeach
+ @if(session('status'))
+
{{ session('status') }}
@endif
- @if($document->exists && $document->placements->count() > 1)
-
Shared document. This document is used in {{ $document->placements->count() }} page placements. Changes appear everywhere it is published.
+ @if($errors->any())
+
+
The document could not be saved.
+
@foreach($errors->all() as $error)- {{ $error }}
@endforeach
+
@endif
-