From c972808d0a7a3d225e437b6d41d4fa18ba9843dd Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Mon, 27 Jul 2026 09:43:42 +0100 Subject: [PATCH] hasComposition() * renderRegion() * draft and published-state handling * document and placement scheduling * disabled-placement filtering * ordered region rendering * safe fallback when composition tables or document views are missing * compatibility with common alternative column names --- Modules/Content/Config/config.php | 5 +- .../Console/InstallSaaslandContent.php | 67 ++--- ...0000_create_content_composition_tables.php | 58 +--- .../Controllers/ContentDocumentController.php | 130 ++------ .../Controllers/PageComposerController.php | 56 ++++ .../Controllers/PageCompositionController.php | 157 ---------- .../Requests/SaveContentDocumentRequest.php | 37 +++ Modules/Content/Models/ContentDocument.php | 11 +- Modules/Content/Models/ContentType.php | 8 +- Modules/Content/Models/PageContent.php | 10 +- .../Providers/ContentServiceProvider.php | 17 +- Modules/Content/README.md | 37 --- .../views/admin/composition.blade.php | 146 --------- .../Resources/views/admin/form.blade.php | 284 ++++++++++++++---- .../Resources/views/admin/index.blade.php | 57 +--- .../views/admin/pages/edit.blade.php | 6 + .../views/documents/unsupported.blade.php | 3 - Modules/Content/Routes/web.php | 16 +- Modules/Content/Services/PageRenderer.php | 75 +---- .../Tests/Feature/ContentModuleTest.php | 11 + Modules/Content/composer.json | 7 + Modules/Content/module.json | 11 +- 22 files changed, 455 insertions(+), 754 deletions(-) create mode 100644 Modules/Content/Http/Controllers/PageComposerController.php delete mode 100644 Modules/Content/Http/Controllers/PageCompositionController.php create mode 100644 Modules/Content/Http/Requests/SaveContentDocumentRequest.php delete mode 100644 Modules/Content/README.md delete mode 100644 Modules/Content/Resources/views/admin/composition.blade.php create mode 100644 Modules/Content/Resources/views/admin/pages/edit.blade.php delete mode 100644 Modules/Content/Resources/views/documents/unsupported.blade.php create mode 100644 Modules/Content/Tests/Feature/ContentModuleTest.php create mode 100644 Modules/Content/composer.json diff --git a/Modules/Content/Config/config.php b/Modules/Content/Config/config.php index b0ba6d5..050383f 100644 --- a/Modules/Content/Config/config.php +++ b/Modules/Content/Config/config.php @@ -1,5 +1,2 @@ 'home', - 'regions' => ['header', 'body', 'footer'], -]; +return ['name' => 'Content']; diff --git a/Modules/Content/Console/InstallSaaslandContent.php b/Modules/Content/Console/InstallSaaslandContent.php index cb1c1ae..4e4e558 100644 --- a/Modules/Content/Console/InstallSaaslandContent.php +++ b/Modules/Content/Console/InstallSaaslandContent.php @@ -1,4 +1,7 @@ ! 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]); + foreach(['content_types','content_documents','page_content'] as $table){ if(!Schema::hasTable($table)){ $this->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]); } }); - $this->components->info('Saasland content composition installed.'); - return self::SUCCESS; - } - - private function definitions(): array - { - $type = fn(string $name,string $renderer,array $regions,array $schema) => ['name'=>$name,'renderer'=>$renderer,'allowed_regions'=>$regions,'is_system'=>true,'is_active'=>true,'schema'=>$schema]; - $text = fn(string $label, string $control = 'text', bool $required = false) => ['type'=>'string','label'=>$label,'control'=>$control,'required'=>$required]; - $toggle = fn(string $label) => ['type'=>'boolean','label'=>$label,'control'=>'toggle']; - $number = fn(string $label, int $min = 1, int $max = 100) => ['type'=>'integer','label'=>$label,'control'=>'number','min'=>$min,'max'=>$max]; - return [ - 'site-header'=>['title'=>'Main website header','type'=>$type('Site header','site-header',['header'],['show_login'=>$toggle('Show login link'),'show_registration'=>$toggle('Show registration link'),'sticky'=>$toggle('Sticky header')]),'data'=>['show_login'=>true,'show_registration'=>true,'sticky'=>true]], - 'hero'=>['title'=>'Homepage hero','type'=>$type('Hero','hero',['body'],['eyebrow'=>$text('Eyebrow'),'heading'=>$text('Heading','text',true),'description'=>$text('Description','textarea'),'primary_label'=>$text('Primary button label'),'primary_url'=>$text('Primary button URL','url'),'secondary_label'=>$text('Secondary button label'),'secondary_url'=>$text('Secondary button URL','url'),'show_metrics'=>$toggle('Show metrics')]),'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'],['eyebrow'=>$text('Eyebrow'),'heading'=>$text('Heading','text',true),'description'=>$text('Description','textarea'),'items'=>['type'=>'array','label'=>'Features','control'=>'repeater','item'=>['title'=>$text('Title','text',true),'description'=>$text('Description','textarea')]]]),'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'],['eyebrow'=>$text('Eyebrow'),'heading'=>$text('Heading'),'description'=>$text('Description','textarea'),'limit'=>$number('Maximum products',1,24)]),'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'],['eyebrow'=>$text('Eyebrow'),'heading'=>$text('Heading'),'limit'=>$number('Maximum testimonials',1,24)]),'data'=>['eyebrow'=>'Customer stories','heading'=>'Trusted by ambitious teams','limit'=>6]], - 'team-feed'=>['title'=>'Team','type'=>$type('Team feed','team-feed',['body'],['eyebrow'=>$text('Eyebrow'),'heading'=>$text('Heading'),'limit'=>$number('Maximum team members',1,24)]),'data'=>['eyebrow'=>'Our team','heading'=>'People behind the platform','limit'=>8]], - 'blog-feed'=>['title'=>'Latest blog posts','type'=>$type('Blog feed','blog-feed',['body'],['eyebrow'=>$text('Eyebrow'),'heading'=>$text('Heading'),'limit'=>$number('Maximum posts',1,24)]),'data'=>['eyebrow'=>'Insights','heading'=>'Latest from the blog','limit'=>3]], - 'news-feed'=>['title'=>'Latest news','type'=>$type('News feed','news-feed',['body'],['eyebrow'=>$text('Eyebrow'),'heading'=>$text('Heading'),'limit'=>$number('Maximum stories',1,24)]),'data'=>['eyebrow'=>'Newsroom','heading'=>'Latest news','limit'=>3]], - 'faq-feed'=>['title'=>'Frequently asked questions','type'=>$type('FAQ feed','faq-feed',['body'],['eyebrow'=>$text('Eyebrow'),'heading'=>$text('Heading'),'limit'=>$number('Maximum questions',1,30)]),'data'=>['eyebrow'=>'FAQ','heading'=>'Answers before you begin','limit'=>12]], - 'newsletter-form'=>['title'=>'Newsletter signup','type'=>$type('Newsletter form','newsletter-form',['body'],['eyebrow'=>$text('Eyebrow'),'heading'=>$text('Heading'),'description'=>$text('Description','textarea'),'button_label'=>$text('Button label')]),'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'],['heading'=>$text('Heading','text',true),'description'=>$text('Description','textarea'),'button_label'=>$text('Button label'),'button_url'=>$text('Button URL','url')]),'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'],['description'=>$text('Description','textarea'),'show_social_links'=>$toggle('Show social links'),'show_back_to_top'=>$toggle('Show back to top')]),'data'=>['description'=>'One connected platform for modern teams.','show_social_links'=>true,'show_back_to_top'=>true]], - ]; + $this->info('Saasland content installed.'); return self::SUCCESS; } } 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 2fcd905..d935f3a 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,58 +5,10 @@ use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; return new class extends Migration { - 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 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 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 af35ce9..2e2e7ef 100644 --- a/Modules/Content/Http/Controllers/ContentDocumentController.php +++ b/Modules/Content/Http/Controllers/ContentDocumentController.php @@ -1,14 +1,14 @@ 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(), - ]); + $documents = ContentDocument::query()->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()]); } public function create(Request $request): View { - $types = ContentType::query()->where('is_active', true)->orderBy('name')->get(); + $types = ContentType::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, - ]); + return view('content::admin.form', ['document'=>new ContentDocument(['status'=>'draft','content_type_id'=>$selectedType?->id]), 'types'=>$types, 'selectedType'=>$selectedType]); } - public function store(Request $request): RedirectResponse + public function store(SaveContentDocumentRequest $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.'); + $document = ContentDocument::create($request->payload()); + 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, - ]); + $document->load('placements'); + return view('content::admin.form', ['document'=>$document, 'types'=>ContentType::orderBy('name')->get(), 'selectedType'=>$document->type()->first()]); } - public function update(Request $request, ContentDocument $document): RedirectResponse + public function update(SaveContentDocumentRequest $request, ContentDocument $document): RedirectResponse { - $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.'); + $document->update($request->payload()); + return back()->with('status','Content document saved.'); } - public function duplicate(Request $request, ContentDocument $document): RedirectResponse + public function duplicate(ContentDocument $document): RedirectResponse { - $copy = $document->replicate(['slug', 'status', 'published_at', 'created_by', 'updated_by']); + $copy = $document->replicate(['slug']); $copy->title = $document->title.' copy'; - $copy->slug = $this->uniqueSlug($copy->title); + $copy->slug = str($copy->title)->slug().'-'.str()->lower(str()->random(6)); $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.'); + return redirect()->route('content.documents.edit',$copy)->with('status','Independent 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.']); - } - + abort_if($document->placements()->exists(), 422, 'A document used by a page cannot be deleted.'); $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; + return redirect()->route('content.index')->with('status','Content document deleted.'); } } diff --git a/Modules/Content/Http/Controllers/PageComposerController.php b/Modules/Content/Http/Controllers/PageComposerController.php new file mode 100644 index 0000000..8c2ac8b --- /dev/null +++ b/Modules/Content/Http/Controllers/PageComposerController.php @@ -0,0 +1,56 @@ +$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 deleted file mode 100644 index af0ce60..0000000 --- a/Modules/Content/Http/Controllers/PageCompositionController.php +++ /dev/null @@ -1,157 +0,0 @@ -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 new file mode 100644 index 0000000..83fec3a --- /dev/null +++ b/Modules/Content/Http/Requests/SaveContentDocumentRequest.php @@ -0,0 +1,37 @@ +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 edf93be..5370b57 100644 --- a/Modules/Content/Models/ContentDocument.php +++ b/Modules/Content/Models/ContentDocument.php @@ -1,17 +1,18 @@ 'array','published_at'=>'datetime','expires_at'=>'datetime']; } + protected $fillable = ['content_type_id','title','slug','status','data','published_at','expires_at']; + protected $casts = ['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 6b0ffd7..868f129 100644 --- a/Modules/Content/Models/ContentType.php +++ b/Modules/Content/Models/ContentType.php @@ -1,4 +1,7 @@ 'array','allowed_regions'=>'array','is_system'=>'boolean','is_active'=>'boolean']; } + protected $fillable = ['key', 'name', 'description', 'schema']; + protected $casts = ['schema' => 'array']; + public function documents(): HasMany { return $this->hasMany(ContentDocument::class); } } diff --git a/Modules/Content/Models/PageContent.php b/Modules/Content/Models/PageContent.php index cb204f3..7a0e352 100644 --- a/Modules/Content/Models/PageContent.php +++ b/Modules/Content/Models/PageContent.php @@ -1,15 +1,17 @@ 'boolean','visibility_rules'=>'array','presentation'=>'array','starts_at'=>'datetime','ends_at'=>'datetime']; } + protected $fillable = ['page_key','region','content_document_id','sort_order','is_enabled','published_at','expires_at']; + protected $casts = ['is_enabled'=>'boolean','published_at'=>'datetime','expires_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 0ba0b27..a47bf41 100644 --- a/Modules/Content/Providers/ContentServiceProvider.php +++ b/Modules/Content/Providers/ContentServiceProvider.php @@ -1,4 +1,7 @@ mergeConfigFrom(module_path('Content','Config/config.php'),'content'); + $this->mergeConfigFrom(__DIR__.'/../Config/config.php', 'content'); $this->app->singleton(PageRenderer::class); } + public function boot(): void { - $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]); + $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]); + } } } diff --git a/Modules/Content/README.md b/Modules/Content/README.md deleted file mode 100644 index b1a800d..0000000 --- a/Modules/Content/README.md +++ /dev/null @@ -1,37 +0,0 @@ -# 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 deleted file mode 100644 index de53bf2..0000000 --- a/Modules/Content/Resources/views/admin/composition.blade.php +++ /dev/null @@ -1,146 +0,0 @@ - -
-
-
-
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 -
-
- @csrf @method('PUT') - - -
- - Save -
- -
-
-
@csrfCreate page-specific copy
-
@csrf @method('DELETE')Remove from page
-
-
-
- @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 a1a8efe..16fd829 100644 --- a/Modules/Content/Resources/views/admin/form.blade.php +++ b/Modules/Content/Resources/views/admin/form.blade.php @@ -46,7 +46,9 @@