fixed content module ( well reverted)
This commit is contained in:
@@ -1,2 +1,5 @@
|
||||
<?php
|
||||
return ['name' => 'Content'];
|
||||
return [
|
||||
'default_page_key' => 'home',
|
||||
'regions' => ['header', 'body', 'footer'],
|
||||
];
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\Content\Console;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
@@ -13,26 +10,55 @@ use Modules\Content\Models\PageContent;
|
||||
|
||||
final class InstallSaaslandContent extends Command
|
||||
{
|
||||
protected $signature = 'content:install-saasland {--force : Replace existing homepage placements}';
|
||||
protected $description = 'Install editable Saasland homepage content.';
|
||||
protected $signature = 'content:install-saasland {--force : Replace the existing home composition}';
|
||||
protected $description = 'Install editable Saasland header, body and footer content documents.';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
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]);
|
||||
$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]],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
+53
-5
@@ -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'); }
|
||||
};
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\Content\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Illuminate\Support\Facades\View as ViewFacade;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\View\View;
|
||||
use Modules\Content\Http\Requests\SaveContentDocumentRequest;
|
||||
use Modules\Content\Models\ContentDocument;
|
||||
use Modules\Content\Models\ContentType;
|
||||
|
||||
@@ -18,112 +16,131 @@ final class ContentDocumentController extends Controller
|
||||
{
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$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()]);
|
||||
$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' => '<div style="padding:2rem;font-family:system-ui;color:#71717a">No Saasland renderer exists for this content type yet.</div>',
|
||||
'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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\Content\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\View\View;
|
||||
use Modules\Content\Models\ContentDocument;
|
||||
use Modules\Content\Models\PageContent;
|
||||
|
||||
final class PageComposerController extends Controller
|
||||
{
|
||||
public function edit(string $pageKey): View
|
||||
{
|
||||
return view('content::admin.pages.edit', [
|
||||
'pageKey'=>$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]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Content\Http\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\View\View;
|
||||
use Modules\Content\Models\ContentDocument;
|
||||
use Modules\Content\Models\PageContent;
|
||||
|
||||
final class PageCompositionController extends Controller
|
||||
{
|
||||
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([
|
||||
'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]);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\Content\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Modules\Content\Models\ContentDocument;
|
||||
|
||||
final class SaveContentDocumentRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool { return true; }
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
$document = $this->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;
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,17 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\Content\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
final class ContentDocument extends Model
|
||||
{
|
||||
protected $fillable = ['content_type_id','title','slug','status','data','published_at','expires_at'];
|
||||
protected $casts = ['data'=>'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())); }
|
||||
}
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\Content\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
@@ -9,8 +6,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
final class ContentType extends Model
|
||||
{
|
||||
protected $fillable = ['key', 'name', 'description', 'schema'];
|
||||
protected $casts = ['schema' => '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); }
|
||||
}
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\Content\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Modules\Pages\Models\Page;
|
||||
|
||||
final class PageContent extends Model
|
||||
{
|
||||
protected $table = 'page_content';
|
||||
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'];
|
||||
|
||||
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); }
|
||||
}
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\Content\Providers;
|
||||
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
@@ -12,18 +9,14 @@ final class ContentServiceProvider extends ServiceProvider
|
||||
{
|
||||
public function register(): void
|
||||
{
|
||||
$this->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]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1,146 @@
|
||||
<x-layouts.app :title="'Page composition: '.$pageKey">
|
||||
<div class="space-y-6" x-data="compositionEditor()">
|
||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<div class="flex items-center gap-2 text-sm text-zinc-500"><a href="{{ route('content.index') }}" class="hover:text-zinc-900 dark:hover:text-white">Content documents</a><span>/</span><span>Page composition</span></div>
|
||||
<h1 class="mt-1 text-2xl font-semibold">{{ ucfirst($pageKey) }} page</h1>
|
||||
<p class="mt-1 text-sm text-zinc-500">Drag documents to reorder each region. Reusing a document keeps content synchronised across pages.</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<flux:button :href="route('content.documents.create')">Create document</flux:button>
|
||||
<flux:button variant="primary" :href="route('content.pages.preview', $pageKey)" target="_blank">Open preview</flux:button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if(session('status'))<div class="rounded-xl border border-green-300 bg-green-50 p-4 text-sm text-green-800 dark:border-green-800 dark:bg-green-950 dark:text-green-200">{{ session('status') }}</div>@endif
|
||||
@if($errors->any())<div class="rounded-xl border border-red-300 bg-red-50 p-4 text-sm text-red-800"><ul class="list-disc pl-5">@foreach($errors->all() as $error)<li>{{ $error }}</li>@endforeach</ul></div>@endif
|
||||
|
||||
<div class="grid gap-6 2xl:grid-cols-[minmax(0,1fr)_25rem]">
|
||||
<div class="space-y-6">
|
||||
@foreach(['header', 'body', 'footer'] as $region)
|
||||
<section class="overflow-hidden rounded-2xl border border-zinc-200 bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-950">
|
||||
<div class="flex items-center justify-between border-b border-zinc-200 bg-white px-5 py-4 dark:border-zinc-700 dark:bg-zinc-900">
|
||||
<div><h2 class="text-lg font-semibold">{{ ucfirst($region) }}</h2><p class="text-xs text-zinc-500">{{ $region === 'body' ? 'Ordered content sections' : 'Page-level '.$region.' document' }}</p></div>
|
||||
<span class="rounded-full bg-zinc-100 px-2 py-1 text-xs text-zinc-600 dark:bg-zinc-800 dark:text-zinc-300">{{ $placements->get($region, collect())->count() }} document{{ $placements->get($region, collect())->count() === 1 ? '' : 's' }}</span>
|
||||
</div>
|
||||
<div class="min-h-24 space-y-3 p-4" data-region="{{ $region }}" @dragover.prevent @drop="drop($event, '{{ $region }}')">
|
||||
@forelse($placements->get($region, collect()) as $placement)
|
||||
<article draggable="true" data-placement-id="{{ $placement->id }}" @dragstart="startDrag($event)" class="group rounded-xl border border-zinc-200 bg-white p-4 shadow-sm transition hover:border-zinc-400 dark:border-zinc-700 dark:bg-zinc-900 dark:hover:border-zinc-500 {{ $placement->is_enabled ? '' : 'opacity-60' }}">
|
||||
<div class="flex items-start gap-3">
|
||||
<button type="button" title="Drag to reorder" class="mt-1 cursor-grab select-none text-xl leading-none text-zinc-400 active:cursor-grabbing">⋮⋮</button>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<h3 class="font-semibold">{{ $placement->document?->title }}</h3>
|
||||
<span class="rounded bg-zinc-100 px-2 py-0.5 text-xs text-zinc-600 dark:bg-zinc-800 dark:text-zinc-300">{{ $placement->document?->type?->name }}</span>
|
||||
@if(($placement->document?->placements()->count() ?? 0) > 1)<span class="rounded bg-blue-100 px-2 py-0.5 text-xs text-blue-700 dark:bg-blue-950 dark:text-blue-200">Shared</span>@endif
|
||||
@unless($placement->is_enabled)<span class="rounded bg-amber-100 px-2 py-0.5 text-xs text-amber-700 dark:bg-amber-950 dark:text-amber-200">Disabled</span>@endunless
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-zinc-500">Order {{ $placement->sort_order }} · {{ $placement->document?->status === 'published' ? 'Published' : 'Draft — hidden from public rendering' }}</p>
|
||||
</div>
|
||||
<a href="{{ route('content.documents.edit', $placement->document) }}" class="text-sm font-medium underline">Edit</a>
|
||||
</div>
|
||||
|
||||
<details class="mt-4 border-t border-zinc-100 pt-3 dark:border-zinc-800">
|
||||
<summary class="cursor-pointer text-sm text-zinc-600 dark:text-zinc-300">Placement settings</summary>
|
||||
<div class="mt-4 grid gap-4 lg:grid-cols-[1fr_1fr_auto]">
|
||||
<form method="POST" action="{{ route('content.placements.update', $placement) }}" class="contents">
|
||||
@csrf @method('PUT')
|
||||
<flux:input type="datetime-local" name="starts_at" label="Show from" value="{{ $placement->starts_at?->format('Y-m-d\TH:i') }}" />
|
||||
<flux:input type="datetime-local" name="ends_at" label="Hide after" value="{{ $placement->ends_at?->format('Y-m-d\TH:i') }}" />
|
||||
<div class="flex items-end gap-2">
|
||||
<label class="mb-2 flex items-center gap-2 text-sm"><input type="checkbox" name="is_enabled" value="1" @checked($placement->is_enabled)> Enabled</label>
|
||||
<flux:button type="submit" size="sm">Save</flux:button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="mt-4 flex flex-wrap gap-2">
|
||||
<form method="POST" action="{{ route('content.placements.duplicate', $placement) }}">@csrf<flux:button type="submit" size="sm">Create page-specific copy</flux:button></form>
|
||||
<form method="POST" action="{{ route('content.placements.destroy', $placement) }}" onsubmit="return confirm('Remove this document from the page? The reusable document itself will remain available.')">@csrf @method('DELETE')<flux:button type="submit" size="sm" variant="danger">Remove from page</flux:button></form>
|
||||
</div>
|
||||
</details>
|
||||
</article>
|
||||
@empty
|
||||
<div class="rounded-xl border-2 border-dashed border-zinc-300 p-8 text-center text-sm text-zinc-500 dark:border-zinc-700">No documents assigned to this region.</div>
|
||||
@endforelse
|
||||
</div>
|
||||
</section>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
<aside class="space-y-6 2xl:sticky 2xl:top-6 2xl:self-start">
|
||||
<flux:card class="space-y-4">
|
||||
<div><h2 class="text-lg font-semibold">Add existing document</h2><p class="text-sm text-zinc-500">Reuse published or draft content. Draft documents remain hidden from the public page.</p></div>
|
||||
<form method="POST" action="{{ route('content.placements.store', $pageKey) }}" class="space-y-4">
|
||||
@csrf
|
||||
<flux:select name="region" label="Region" required>
|
||||
<option value="header">Header</option><option value="body" selected>Body</option><option value="footer">Footer</option>
|
||||
</flux:select>
|
||||
<label class="block text-sm font-medium">Document
|
||||
<select name="content_document_id" required class="mt-2 w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 dark:border-zinc-700 dark:bg-zinc-950">
|
||||
<option value="">Select a document</option>
|
||||
@foreach($documents as $document)
|
||||
<option value="{{ $document->id }}" data-regions="{{ implode(',', $document->type?->allowed_regions ?? []) }}">{{ $document->title }} — {{ $document->type?->name }}{{ $document->status === 'draft' ? ' (draft)' : '' }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</label>
|
||||
<flux:button type="submit" variant="primary" class="w-full">Add to page</flux:button>
|
||||
</form>
|
||||
</flux:card>
|
||||
|
||||
<flux:card>
|
||||
<h2 class="text-lg font-semibold">Editing shared content</h2>
|
||||
<p class="mt-2 text-sm text-zinc-500">Editing a shared document changes every placement. “Create page-specific copy” makes a disabled draft copy beside the original so it can be reviewed before switching.</p>
|
||||
</flux:card>
|
||||
|
||||
<flux:card>
|
||||
<h2 class="text-lg font-semibold">Live order</h2>
|
||||
<p class="mt-2 text-sm text-zinc-500">Order is saved automatically after dropping a document. A confirmation appears here.</p>
|
||||
<p x-show="message" x-text="message" class="mt-3 rounded-lg bg-zinc-100 p-3 text-sm dark:bg-zinc-800"></p>
|
||||
</flux:card>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
function compositionEditor() {
|
||||
return {
|
||||
dragged: null,
|
||||
message: '',
|
||||
startDrag(event) {
|
||||
this.dragged = event.currentTarget;
|
||||
event.dataTransfer.effectAllowed = 'move';
|
||||
event.dataTransfer.setData('text/plain', this.dragged.dataset.placementId);
|
||||
},
|
||||
async drop(event, region) {
|
||||
if (!this.dragged) return;
|
||||
const container = event.currentTarget;
|
||||
const target = event.target.closest('[data-placement-id]');
|
||||
if (target && target !== this.dragged) {
|
||||
const rect = target.getBoundingClientRect();
|
||||
container.insertBefore(this.dragged, event.clientY < rect.top + rect.height / 2 ? target : target.nextSibling);
|
||||
} else if (!target) {
|
||||
container.appendChild(this.dragged);
|
||||
}
|
||||
const ids = [...container.querySelectorAll('[data-placement-id]')].map(item => Number(item.dataset.placementId));
|
||||
this.message = 'Saving order…';
|
||||
try {
|
||||
const response = await fetch(@js(route('content.placements.reorder', $pageKey)), {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json', 'Accept': 'application/json', 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content},
|
||||
body: JSON.stringify({region, placements: ids})
|
||||
});
|
||||
if (!response.ok) throw new Error((await response.json()).message || 'Unable to save order.');
|
||||
this.message = 'Order saved.';
|
||||
} catch (error) {
|
||||
this.message = error.message;
|
||||
window.setTimeout(() => window.location.reload(), 1200);
|
||||
} finally {
|
||||
this.dragged = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@endpush
|
||||
</x-layouts.app>
|
||||
@@ -1,640 +1,120 @@
|
||||
<x-layouts.app :title="$document->exists ? $document->title : __('Create content document')">
|
||||
@php
|
||||
$builderSchema = $selectedType?->schema ?: [];
|
||||
$builderData = old('data_json', json_encode($document->data ?: [], JSON_UNESCAPED_SLASHES));
|
||||
@endphp
|
||||
<div class="space-y-6" data-content-builder data-autosave-url="{{ $document->exists ? route('content.documents.autosave', $document) : '' }}" data-preview-url="{{ route('content.documents.preview') }}">
|
||||
<div class="mx-auto max-w-6xl space-y-6">
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<div class="flex items-center gap-2 text-sm text-zinc-500">
|
||||
<a href="{{ route('content.index') }}" class="hover:text-zinc-900 dark:hover:text-white">Content documents</a>
|
||||
<span>/</span>
|
||||
<span>{{ $document->exists ? 'Visual editor' : 'Create' }}</span>
|
||||
<span>{{ $document->exists ? 'Edit' : 'Create' }}</span>
|
||||
</div>
|
||||
<h1 class="mt-1 text-2xl font-semibold">{{ $document->exists ? $document->title : 'Create content document' }}</h1>
|
||||
<p class="mt-1 text-sm text-zinc-500">Build and edit structured content visually. The stored document format remains fully compatible with the existing renderer.</p>
|
||||
<p class="mt-1 text-sm text-zinc-500">Structured, reusable content that can be placed into one or more page regions.</p>
|
||||
</div>
|
||||
@if($document->exists)
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<form method="POST" action="{{ route('content.documents.duplicate', $document) }}">@csrf<flux:button type="submit" size="sm">Create independent copy</flux:button></form>
|
||||
<form method="POST" action="{{ route('content.documents.destroy', $document) }}" onsubmit="return confirm('Delete this document? This is only allowed when it is not used by any page.')">@csrf @method('DELETE')<flux:button type="submit" size="sm" variant="danger">Delete</flux:button></form>
|
||||
<form method="POST" action="{{ route('content.documents.duplicate', $document) }}">
|
||||
@csrf
|
||||
<flux:button type="submit" size="sm">Create independent copy</flux:button>
|
||||
</form>
|
||||
<form method="POST" action="{{ route('content.documents.destroy', $document) }}" onsubmit="return confirm('Delete this document? This is only allowed when it is not used by any page.')">
|
||||
@csrf @method('DELETE')
|
||||
<flux:button type="submit" size="sm" variant="danger">Delete</flux:button>
|
||||
</form>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if(session('status'))<div class="rounded-xl border border-green-300 bg-green-50 p-4 text-sm text-green-800 dark:border-green-800 dark:bg-green-950 dark:text-green-200">{{ session('status') }}</div>@endif
|
||||
@if($errors->any())
|
||||
<div class="rounded-xl border border-red-300 bg-red-50 p-4 text-sm text-red-800 dark:border-red-800 dark:bg-red-950 dark:text-red-200"><strong>The document could not be saved.</strong><ul class="mt-2 list-disc pl-5">@foreach($errors->all() as $error)<li>{{ $error }}</li>@endforeach</ul></div>
|
||||
@if(session('status'))
|
||||
<div class="rounded-xl border border-green-300 bg-green-50 p-4 text-sm text-green-800 dark:border-green-800 dark:bg-green-950 dark:text-green-200">{{ session('status') }}</div>
|
||||
@endif
|
||||
@if($document->exists && $document->placements->count() > 1)
|
||||
<div class="rounded-xl border border-amber-300 bg-amber-50 p-4 text-sm text-amber-900 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-100"><strong>Shared document.</strong> This document is used in {{ $document->placements->count() }} page placements. Changes appear everywhere it is published.</div>
|
||||
@if($errors->any())
|
||||
<div class="rounded-xl border border-red-300 bg-red-50 p-4 text-sm text-red-800 dark:border-red-800 dark:bg-red-950 dark:text-red-200">
|
||||
<strong>The document could not be saved.</strong>
|
||||
<ul class="mt-2 list-disc pl-5">@foreach($errors->all() as $error)<li>{{ $error }}</li>@endforeach</ul>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<form id="content-document-form" method="POST" action="{{ $document->exists ? route('content.documents.update', $document) : route('content.documents.store') }}" class="space-y-6">
|
||||
@if($document->exists && $document->placements->count() > 1)
|
||||
<div class="rounded-xl border border-amber-300 bg-amber-50 p-4 text-sm text-amber-900 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-100">
|
||||
<strong>Shared document.</strong> This document is used in {{ $document->placements->count() }} page placements. Changes will appear everywhere it is published. Use “Create independent copy” for page-specific content.
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<form method="POST" action="{{ $document->exists ? route('content.documents.update', $document) : route('content.documents.store') }}" class="grid gap-6 xl:grid-cols-[minmax(0,1fr)_20rem]">
|
||||
@csrf
|
||||
@if($document->exists) @method('PUT') @endif
|
||||
<input type="hidden" name="data_json" id="data-json" value="{{ $builderData }}">
|
||||
|
||||
<div class="grid gap-4 rounded-xl border border-zinc-200 bg-white p-4 md:grid-cols-4 dark:border-zinc-700 dark:bg-zinc-900">
|
||||
<flux:input name="title" label="Title" value="{{ old('title', $document->title) }}" required />
|
||||
<flux:input name="slug" label="Slug" value="{{ old('slug', $document->slug) }}" description="Generated from title when empty." />
|
||||
<flux:select id="content-type-select" name="content_type_id" label="Content type" required>
|
||||
@foreach($types as $type)<option value="{{ $type->id }}" @selected((int) old('content_type_id', $selectedType?->id) === $type->id)>{{ $type->name }}</option>@endforeach
|
||||
</flux:select>
|
||||
<flux:select name="status" label="Status"><option value="draft" @selected(old('status', $document->status ?: 'draft') === 'draft')>Draft</option><option value="published" @selected(old('status', $document->status) === 'published')>Published</option></flux:select>
|
||||
</div>
|
||||
<div class="space-y-6">
|
||||
<flux:card class="space-y-5">
|
||||
<h2 class="text-lg font-semibold">Document</h2>
|
||||
<flux:input name="title" label="Title" value="{{ old('title', $document->title) }}" required />
|
||||
<flux:input name="slug" label="Slug" value="{{ old('slug', $document->slug) }}" description="Leave empty to generate it from the title." />
|
||||
<flux:select name="content_type_id" label="Content type" required>
|
||||
@foreach($types as $type)
|
||||
<option value="{{ $type->id }}" @selected((int) old('content_type_id', $selectedType?->id) === $type->id)>{{ $type->name }}</option>
|
||||
@endforeach
|
||||
</flux:select>
|
||||
</flux:card>
|
||||
|
||||
<div class="grid min-h-[42rem] overflow-hidden rounded-2xl border border-zinc-200 bg-white xl:grid-cols-[16rem_minmax(0,1fr)_20rem] dark:border-zinc-700 dark:bg-zinc-900">
|
||||
<aside class="border-b border-zinc-200 p-4 xl:border-b-0 xl:border-r dark:border-zinc-700">
|
||||
<div class="mb-4"><h2 class="font-semibold">Components</h2><p class="text-xs text-zinc-500">Add layouts or document fields to the canvas.</p></div>
|
||||
<div id="layout-library" class="mb-5 grid grid-cols-2 gap-2"></div>
|
||||
<div class="mb-2 text-xs font-semibold uppercase tracking-wide text-zinc-500">Document fields</div>
|
||||
<div id="builder-library" class="space-y-2"></div>
|
||||
<div class="mt-6 border-t border-zinc-200 pt-4 dark:border-zinc-700">
|
||||
<button type="button" id="toggle-json" class="w-full rounded-lg border border-zinc-300 px-3 py-2 text-left text-sm hover:bg-zinc-50 dark:border-zinc-700 dark:hover:bg-zinc-800">Advanced JSON</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="min-w-0 bg-zinc-50 p-4 dark:bg-zinc-950/60 sm:p-6">
|
||||
<div class="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<div class="flex items-center gap-2 text-xs"><span id="save-state" class="rounded-full bg-zinc-200 px-2.5 py-1 text-zinc-600 dark:bg-zinc-800 dark:text-zinc-300">Saved</span><span id="save-time" class="text-zinc-400"></span></div>
|
||||
<div><h2 class="font-semibold">Canvas</h2><p class="text-xs text-zinc-500">{{ $selectedType?->name ?? 'Content document' }} · live structured preview</p></div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<div class="flex rounded-lg border border-zinc-300 bg-white p-1 text-xs dark:border-zinc-700 dark:bg-zinc-900">
|
||||
<button type="button" id="builder-undo" class="rounded px-2.5 py-1.5" title="Undo (⌘Z)">Undo</button>
|
||||
<button type="button" id="builder-redo" class="rounded px-2.5 py-1.5" title="Redo (⇧⌘Z)">Redo</button>
|
||||
<button type="button" id="builder-copy" class="rounded px-2.5 py-1.5" title="Copy selected (⌘C)">Copy</button>
|
||||
<button type="button" id="builder-paste" class="rounded px-2.5 py-1.5" title="Paste (⌘V)">Paste</button>
|
||||
</div>
|
||||
<div class="flex rounded-lg border border-zinc-300 bg-white p-1 text-xs dark:border-zinc-700 dark:bg-zinc-900"><button type="button" class="builder-width rounded px-3 py-1.5 font-medium" data-width="100%">Desktop</button><button type="button" class="builder-width rounded px-3 py-1.5" data-width="768px">Tablet</button><button type="button" class="builder-width rounded px-3 py-1.5" data-width="390px">Mobile</button></div>
|
||||
<flux:card class="space-y-4">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold">Structured data</h2>
|
||||
<p class="text-sm text-zinc-500">This phase retains JSON editing while adding validation, copies, placement controls and preview. Schema-generated field controls follow in the next checkpoint.</p>
|
||||
</div>
|
||||
<button type="button" id="format-json" class="rounded-lg border border-zinc-300 px-3 py-2 text-sm hover:bg-zinc-50 dark:border-zinc-700 dark:hover:bg-zinc-800">Format JSON</button>
|
||||
</div>
|
||||
<div class="mb-4 flex justify-end"><button type="button" id="toggle-theme-preview" class="rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm dark:border-zinc-700 dark:bg-zinc-900">Show theme preview</button></div>
|
||||
<div id="theme-preview-panel" class="mb-4 hidden overflow-hidden rounded-xl border border-zinc-200 bg-white dark:border-zinc-700"><div class="flex items-center justify-between border-b border-zinc-200 px-3 py-2 text-xs dark:border-zinc-700"><span id="preview-view-name" class="text-zinc-500">Saasland theme renderer</span><span id="preview-state" class="text-zinc-400">Ready</span></div><iframe id="theme-preview-frame" title="Theme preview" class="h-[34rem] w-full bg-white"></iframe></div>
|
||||
<div id="builder-viewport" class="mx-auto w-full transition-all duration-200">
|
||||
<div id="builder-canvas" class="min-h-[34rem] space-y-3 rounded-xl border border-dashed border-zinc-300 bg-white p-4 shadow-sm dark:border-zinc-700 dark:bg-zinc-900"></div>
|
||||
</div>
|
||||
<div id="json-panel" class="mt-4 hidden"><div class="mb-2 flex items-center justify-between"><label for="json-editor" class="text-sm font-medium">Document JSON</label><button type="button" id="apply-json" class="rounded-lg border border-zinc-300 px-3 py-1.5 text-sm dark:border-zinc-700">Apply JSON</button></div><textarea id="json-editor" class="min-h-72 w-full rounded-xl border border-zinc-300 bg-zinc-950 p-4 font-mono text-sm text-zinc-100" spellcheck="false"></textarea><p id="json-message" class="mt-2 text-sm"></p></div>
|
||||
</main>
|
||||
|
||||
<aside class="border-t border-zinc-200 p-4 xl:border-l xl:border-t-0 dark:border-zinc-700">
|
||||
<div class="mb-4"><h2 class="font-semibold">Inspector</h2><p id="inspector-help" class="text-xs text-zinc-500">Select a field on the canvas.</p></div>
|
||||
<div id="builder-inspector"></div>
|
||||
<div class="mt-6 space-y-3 border-t border-zinc-200 pt-4 dark:border-zinc-700">
|
||||
<flux:input type="datetime-local" name="published_at" label="Publish at" value="{{ old('published_at', $document->published_at?->format('Y-m-d\TH:i')) }}" />
|
||||
<flux:input type="datetime-local" name="expires_at" label="Expire at" value="{{ old('expires_at', $document->expires_at?->format('Y-m-d\TH:i')) }}" />
|
||||
<flux:button type="submit" variant="primary" class="w-full">{{ $document->exists ? 'Save document' : 'Create document' }}</flux:button>
|
||||
</div>
|
||||
@if($document->exists)
|
||||
<div class="mt-6 border-t border-zinc-200 pt-4 dark:border-zinc-700"><h3 class="text-sm font-semibold">Usage</h3><p class="mt-1 text-xs text-zinc-500">{{ $document->placements->count() }} placement{{ $document->placements->count() === 1 ? '' : 's' }}</p>@foreach($document->placements as $placement)<a class="mt-2 block rounded-lg border border-zinc-200 p-2 text-xs hover:bg-zinc-50 dark:border-zinc-700 dark:hover:bg-zinc-800" href="{{ route('content.pages.edit', $placement->page_key) }}"><strong>{{ ucfirst($placement->page_key ?: 'Page') }}</strong><span class="block uppercase text-zinc-500">{{ $placement->region }}</span></a>@endforeach</div>
|
||||
@endif
|
||||
</aside>
|
||||
<textarea id="data-json" class="min-h-[32rem] w-full rounded-xl border border-zinc-300 bg-white p-4 font-mono text-sm text-zinc-900 shadow-sm focus:border-zinc-500 focus:outline-none dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100" name="data_json" spellcheck="false">{{ old('data_json', json_encode($document->data ?: [], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)) }}</textarea>
|
||||
<p id="json-message" class="hidden text-sm"></p>
|
||||
</flux:card>
|
||||
</div>
|
||||
|
||||
<aside class="space-y-6">
|
||||
<flux:card class="space-y-4">
|
||||
<h2 class="text-lg font-semibold">Publishing</h2>
|
||||
<flux:select name="status" label="Status">
|
||||
<option value="draft" @selected(old('status', $document->status ?: 'draft') === 'draft')>Draft</option>
|
||||
<option value="published" @selected(old('status', $document->status) === 'published')>Published</option>
|
||||
</flux:select>
|
||||
<flux:input type="datetime-local" name="published_at" label="Publish at" value="{{ old('published_at', $document->published_at?->format('Y-m-d\TH:i')) }}" />
|
||||
<flux:input type="datetime-local" name="expires_at" label="Expire at" value="{{ old('expires_at', $document->expires_at?->format('Y-m-d\TH:i')) }}" />
|
||||
<flux:button type="submit" variant="primary" class="w-full">{{ $document->exists ? 'Save document' : 'Create document' }}</flux:button>
|
||||
</flux:card>
|
||||
|
||||
@if($document->exists)
|
||||
<flux:card>
|
||||
<h2 class="text-lg font-semibold">Usage</h2>
|
||||
<p class="mt-1 text-sm text-zinc-500">{{ $document->placements->count() }} placement{{ $document->placements->count() === 1 ? '' : 's' }}</p>
|
||||
<div class="mt-4 space-y-2">
|
||||
@forelse($document->placements as $placement)
|
||||
<a class="block rounded-lg border border-zinc-200 p-3 text-sm hover:bg-zinc-50 dark:border-zinc-700 dark:hover:bg-zinc-800" href="{{ route('content.pages.edit', $placement->page_key) }}">
|
||||
<strong>{{ ucfirst($placement->page_key ?: 'Page') }}</strong>
|
||||
<span class="block text-xs uppercase text-zinc-500">{{ $placement->region }}</span>
|
||||
</a>
|
||||
@empty
|
||||
<p class="text-sm text-zinc-500">Not currently assigned to a page.</p>
|
||||
@endforelse
|
||||
</div>
|
||||
</flux:card>
|
||||
@endif
|
||||
</aside>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
(() => {
|
||||
const schema = @json($builderSchema);
|
||||
let data;
|
||||
try { data = JSON.parse(document.getElementById('data-json').value || '{}'); } catch (_) { data = {}; }
|
||||
|
||||
const layoutTemplates = {
|
||||
section: { type: 'section', label: 'Section', children: [] },
|
||||
container: { type: 'container', label: 'Container', children: [] },
|
||||
columns2: { type: 'columns', label: 'Two columns', columns: [{ children: [] }, { children: [] }] },
|
||||
columns3: { type: 'columns', label: 'Three columns', columns: [{ children: [] }, { children: [] }, { children: [] }] },
|
||||
};
|
||||
|
||||
const uid = () => `node_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
const clone = value => JSON.parse(JSON.stringify(value));
|
||||
const escapeHtml = value => String(value ?? '').replace(/[&<>'"]/g, char => ({'&':'&','<':'<','>':'>',"'":''','"':'"'}[char]));
|
||||
const titleFor = (key, definition = {}) => definition.label || key.replaceAll('_', ' ').replace(/\b\w/g, char => char.toUpperCase());
|
||||
const controlFor = definition => definition.control || (definition.type === 'boolean' ? 'toggle' : definition.type === 'integer' ? 'number' : definition.type === 'image' ? 'image' : 'text');
|
||||
|
||||
function normaliseNode(node) {
|
||||
node.id ||= uid();
|
||||
node.type ||= 'field';
|
||||
node.styles ||= { desktop: {}, tablet: {}, mobile: {} };
|
||||
node.styles.desktop ||= {};
|
||||
node.styles.tablet ||= {};
|
||||
node.styles.mobile ||= {};
|
||||
if (node.type === 'columns') {
|
||||
node.columns ||= [{ children: [] }, { children: [] }];
|
||||
node.columns.forEach(column => {
|
||||
column.id ||= uid();
|
||||
column.children ||= [];
|
||||
column.children.forEach(normaliseNode);
|
||||
});
|
||||
} else {
|
||||
node.children ||= [];
|
||||
node.children.forEach(normaliseNode);
|
||||
}
|
||||
return node;
|
||||
document.getElementById('format-json')?.addEventListener('click', () => {
|
||||
const field = document.getElementById('data-json');
|
||||
const message = document.getElementById('json-message');
|
||||
try {
|
||||
field.value = JSON.stringify(JSON.parse(field.value), null, 2);
|
||||
message.textContent = 'Valid JSON.';
|
||||
message.className = 'text-sm text-green-700 dark:text-green-300';
|
||||
} catch (error) {
|
||||
message.textContent = error.message;
|
||||
message.className = 'text-sm text-red-700 dark:text-red-300';
|
||||
}
|
||||
|
||||
function defaultLayout() {
|
||||
return [{ id: uid(), type: 'section', label: 'Content', children: Object.keys(schema).map(key => ({ id: uid(), type: 'field', key })) }];
|
||||
}
|
||||
|
||||
let layout = Array.isArray(data._layout) ? data._layout : defaultLayout();
|
||||
layout.forEach(normaliseNode);
|
||||
let selectedId = layout[0]?.id || null;
|
||||
let dragId = null;
|
||||
let activeBreakpoint = 'desktop';
|
||||
let clipboardNode = null;
|
||||
let history = [];
|
||||
let historyIndex = -1;
|
||||
let isRestoringHistory = false;
|
||||
|
||||
|
||||
const styleDefaults = {
|
||||
width: '', minHeight: '', textAlign: '', paddingTop: '', paddingRight: '', paddingBottom: '', paddingLeft: '',
|
||||
marginTop: '', marginRight: '', marginBottom: '', marginLeft: '', backgroundColor: '', color: '',
|
||||
borderColor: '', borderWidth: '', borderRadius: '', display: '', alignItems: '', justifyContent: ''
|
||||
};
|
||||
|
||||
function resolvedStyles(node) {
|
||||
const desktop = node.styles?.desktop || {};
|
||||
const tablet = node.styles?.tablet || {};
|
||||
const mobile = node.styles?.mobile || {};
|
||||
if (activeBreakpoint === 'desktop') return { ...styleDefaults, ...desktop };
|
||||
if (activeBreakpoint === 'tablet') return { ...styleDefaults, ...desktop, ...tablet };
|
||||
return { ...styleDefaults, ...desktop, ...tablet, ...mobile };
|
||||
}
|
||||
|
||||
function cssValue(value, unit = 'px') {
|
||||
if (value === '' || value === null || value === undefined) return '';
|
||||
if (typeof value === 'number' || /^-?\d+(\.\d+)?$/.test(String(value))) return `${value}${unit}`;
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function nodeStyle(node) {
|
||||
const s = resolvedStyles(node);
|
||||
const declarations = {
|
||||
width: s.width, minHeight: cssValue(s.minHeight), textAlign: s.textAlign,
|
||||
paddingTop: cssValue(s.paddingTop), paddingRight: cssValue(s.paddingRight),
|
||||
paddingBottom: cssValue(s.paddingBottom), paddingLeft: cssValue(s.paddingLeft),
|
||||
marginTop: cssValue(s.marginTop), marginRight: cssValue(s.marginRight),
|
||||
marginBottom: cssValue(s.marginBottom), marginLeft: cssValue(s.marginLeft),
|
||||
backgroundColor: s.backgroundColor, color: s.color, borderColor: s.borderColor,
|
||||
borderWidth: cssValue(s.borderWidth), borderRadius: cssValue(s.borderRadius),
|
||||
borderStyle: s.borderWidth ? 'solid' : '', display: s.display, alignItems: s.alignItems,
|
||||
justifyContent: s.justifyContent
|
||||
};
|
||||
return Object.entries(declarations).filter(([, value]) => value !== '').map(([key, value]) => `${key.replace(/[A-Z]/g, m => '-' + m.toLowerCase())}:${value}`).join(';');
|
||||
}
|
||||
|
||||
function responsiveBadge(node) {
|
||||
const hasTablet = Object.values(node.styles?.tablet || {}).some(value => value !== '');
|
||||
const hasMobile = Object.values(node.styles?.mobile || {}).some(value => value !== '');
|
||||
return `${hasTablet ? '<span class="rounded bg-indigo-100 px-1.5 py-0.5 text-[10px] text-indigo-700 dark:bg-indigo-950 dark:text-indigo-300">T</span>' : ''}${hasMobile ? '<span class="rounded bg-violet-100 px-1.5 py-0.5 text-[10px] text-violet-700 dark:bg-violet-950 dark:text-violet-300">M</span>' : ''}`;
|
||||
}
|
||||
|
||||
const library = document.getElementById('builder-library');
|
||||
const layoutLibrary = document.getElementById('layout-library');
|
||||
const canvas = document.getElementById('builder-canvas');
|
||||
const inspector = document.getElementById('builder-inspector');
|
||||
const hidden = document.getElementById('data-json');
|
||||
const jsonEditor = document.getElementById('json-editor');
|
||||
const panel = document.getElementById('json-panel');
|
||||
const undoButton = document.getElementById('builder-undo');
|
||||
const redoButton = document.getElementById('builder-redo');
|
||||
const copyButton = document.getElementById('builder-copy');
|
||||
const pasteButton = document.getElementById('builder-paste');
|
||||
const builderRoot = document.querySelector('[data-content-builder]');
|
||||
const form = document.getElementById('content-document-form');
|
||||
const saveState = document.getElementById('save-state');
|
||||
const saveTime = document.getElementById('save-time');
|
||||
const previewPanel = document.getElementById('theme-preview-panel');
|
||||
const previewFrame = document.getElementById('theme-preview-frame');
|
||||
const previewState = document.getElementById('preview-state');
|
||||
const previewViewName = document.getElementById('preview-view-name');
|
||||
let dirty = false;
|
||||
let autosaveTimer = null;
|
||||
let previewTimer = null;
|
||||
let saveRequest = null;
|
||||
|
||||
function setSaveState(state, message = '') {
|
||||
saveState.textContent = state;
|
||||
saveState.className = 'rounded-full px-2.5 py-1 ' + ({
|
||||
Saved: 'bg-green-100 text-green-700 dark:bg-green-950 dark:text-green-300',
|
||||
Saving: 'bg-blue-100 text-blue-700 dark:bg-blue-950 dark:text-blue-300',
|
||||
Unsaved: 'bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-200',
|
||||
Error: 'bg-red-100 text-red-700 dark:bg-red-950 dark:text-red-300'
|
||||
}[state] || 'bg-zinc-200 text-zinc-600 dark:bg-zinc-800 dark:text-zinc-300');
|
||||
if (message) saveTime.textContent = message;
|
||||
}
|
||||
|
||||
function markDirty() {
|
||||
dirty = true;
|
||||
setSaveState('Unsaved', 'Changes pending');
|
||||
window.clearTimeout(autosaveTimer);
|
||||
window.clearTimeout(previewTimer);
|
||||
autosaveTimer = window.setTimeout(autosave, 1200);
|
||||
if (!previewPanel.classList.contains('hidden')) previewTimer = window.setTimeout(refreshThemePreview, 350);
|
||||
}
|
||||
|
||||
function formPayload() {
|
||||
sync(false);
|
||||
return new FormData(form);
|
||||
}
|
||||
|
||||
async function autosave() {
|
||||
const url = builderRoot.dataset.autosaveUrl;
|
||||
if (!url || !dirty) return;
|
||||
saveRequest?.abort();
|
||||
saveRequest = new AbortController();
|
||||
setSaveState('Saving', 'Saving automatically…');
|
||||
try {
|
||||
const response = await fetch(url, { method: 'POST', body: formPayload(), headers: { 'X-HTTP-Method-Override': 'PUT', 'Accept': 'application/json' }, signal: saveRequest.signal });
|
||||
if (!response.ok) throw new Error((await response.json().catch(() => ({}))).message || 'Autosave failed');
|
||||
dirty = false;
|
||||
const result = await response.json();
|
||||
const at = result.saved_at ? new Date(result.saved_at).toLocaleTimeString([], {hour:'2-digit', minute:'2-digit', second:'2-digit'}) : '';
|
||||
setSaveState('Saved', at ? `Saved at ${at}` : 'Saved');
|
||||
} catch (error) {
|
||||
if (error.name !== 'AbortError') setSaveState('Error', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshThemePreview() {
|
||||
if (previewPanel.classList.contains('hidden')) return;
|
||||
previewState.textContent = 'Rendering…';
|
||||
try {
|
||||
const response = await fetch(builderRoot.dataset.previewUrl, { method: 'POST', body: formPayload(), headers: { 'Accept': 'application/json' } });
|
||||
const result = await response.json();
|
||||
if (!response.ok) throw new Error(result.message || 'Preview failed');
|
||||
previewFrame.srcdoc = `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head><body style="margin:0">${result.html}</body></html>`;
|
||||
previewViewName.textContent = result.view || 'Saasland theme renderer';
|
||||
previewState.textContent = 'Current';
|
||||
} catch (error) {
|
||||
previewState.textContent = error.message;
|
||||
}
|
||||
}
|
||||
|
||||
const sync = (trackDirty = true) => {
|
||||
data._layout = layout;
|
||||
hidden.value = JSON.stringify(data);
|
||||
jsonEditor.value = JSON.stringify(data, null, 2);
|
||||
if (trackDirty) markDirty();
|
||||
};
|
||||
|
||||
|
||||
function snapshot() {
|
||||
return JSON.stringify({ data, layout, selectedId });
|
||||
}
|
||||
|
||||
function refreshHistoryButtons() {
|
||||
if (undoButton) undoButton.disabled = historyIndex <= 0;
|
||||
if (redoButton) redoButton.disabled = historyIndex >= history.length - 1;
|
||||
[undoButton, redoButton].forEach(button => button?.classList.toggle('opacity-40', button.disabled));
|
||||
if (copyButton) copyButton.disabled = !findNode(selectedId);
|
||||
if (pasteButton) pasteButton.disabled = !clipboardNode;
|
||||
[copyButton, pasteButton].forEach(button => button?.classList.toggle('opacity-40', button.disabled));
|
||||
}
|
||||
|
||||
function recordHistory() {
|
||||
if (isRestoringHistory) return;
|
||||
const state = snapshot();
|
||||
if (history[historyIndex] === state) return;
|
||||
history = history.slice(0, historyIndex + 1);
|
||||
history.push(state);
|
||||
if (history.length > 80) history.shift();
|
||||
historyIndex = history.length - 1;
|
||||
refreshHistoryButtons();
|
||||
}
|
||||
|
||||
function restoreHistory(index) {
|
||||
if (index < 0 || index >= history.length) return;
|
||||
isRestoringHistory = true;
|
||||
const state = JSON.parse(history[index]);
|
||||
data = state.data || {};
|
||||
layout = state.layout || [];
|
||||
selectedId = state.selectedId || layout[0]?.id || null;
|
||||
layout.forEach(normaliseNode);
|
||||
historyIndex = index;
|
||||
sync(); renderLibraries(); renderCanvas(); renderInspector();
|
||||
isRestoringHistory = false;
|
||||
refreshHistoryButtons();
|
||||
}
|
||||
|
||||
function renewIds(node) {
|
||||
node.id = uid();
|
||||
if (node.type === 'columns') node.columns.forEach(column => {
|
||||
column.id = uid();
|
||||
column.children.forEach(renewIds);
|
||||
}); else (node.children || []).forEach(renewIds);
|
||||
return node;
|
||||
}
|
||||
|
||||
function copySelected() {
|
||||
const found = findNode(selectedId);
|
||||
if (!found) return;
|
||||
clipboardNode = clone(found.node);
|
||||
refreshHistoryButtons();
|
||||
}
|
||||
|
||||
function pasteSelected() {
|
||||
if (!clipboardNode) return;
|
||||
const copy = renewIds(clone(clipboardNode));
|
||||
const found = findNode(selectedId);
|
||||
const collection = found?.collection || layout;
|
||||
const index = found ? found.index + 1 : collection.length;
|
||||
collection.splice(index, 0, copy);
|
||||
selectedId = copy.id;
|
||||
update();
|
||||
}
|
||||
|
||||
function walk(nodes, callback, parent = null, slot = null) {
|
||||
for (let index = 0; index < nodes.length; index++) {
|
||||
const node = nodes[index];
|
||||
if (callback(node, nodes, index, parent, slot) === false) return false;
|
||||
if (node.type === 'columns') {
|
||||
for (let columnIndex = 0; columnIndex < node.columns.length; columnIndex++) {
|
||||
if (walk(node.columns[columnIndex].children, callback, node, `column:${columnIndex}`) === false) return false;
|
||||
}
|
||||
} else if (walk(node.children || [], callback, node, 'children') === false) return false;
|
||||
}
|
||||
}
|
||||
|
||||
function findNode(id) {
|
||||
let found = null;
|
||||
walk(layout, (node, collection, index, parent, slot) => {
|
||||
if (node.id === id) { found = { node, collection, index, parent, slot }; return false; }
|
||||
});
|
||||
return found;
|
||||
}
|
||||
|
||||
function detachNode(id) {
|
||||
const found = findNode(id);
|
||||
if (!found) return null;
|
||||
return found.collection.splice(found.index, 1)[0];
|
||||
}
|
||||
|
||||
function containsNode(node, id) {
|
||||
if (node.id === id) return true;
|
||||
let result = false;
|
||||
const inspect = children => children?.some(child => containsNode(child, id));
|
||||
if (node.type === 'columns') result = node.columns.some(column => inspect(column.children));
|
||||
else result = inspect(node.children);
|
||||
return Boolean(result);
|
||||
}
|
||||
|
||||
function targetCollection(targetId, slot = 'children') {
|
||||
if (targetId === 'root') return layout;
|
||||
const target = findNode(targetId)?.node;
|
||||
if (!target) return layout;
|
||||
if (slot.startsWith('column:')) return target.columns[Number(slot.split(':')[1])].children;
|
||||
target.children ||= [];
|
||||
return target.children;
|
||||
}
|
||||
|
||||
function addNode(template, targetId = 'root', slot = 'children') {
|
||||
const node = normaliseNode(clone(template));
|
||||
targetCollection(targetId, slot).push(node);
|
||||
selectedId = node.id;
|
||||
update();
|
||||
}
|
||||
|
||||
function fieldNode(key) { return { id: uid(), type: 'field', key }; }
|
||||
|
||||
function renderLibraries() {
|
||||
layoutLibrary.innerHTML = Object.entries(layoutTemplates).map(([key, template]) => `<button type="button" data-add-layout="${key}" class="rounded-lg border border-zinc-200 px-2 py-2 text-left text-xs hover:bg-zinc-50 dark:border-zinc-700 dark:hover:bg-zinc-800"><strong class="block">${escapeHtml(template.label)}</strong><span class="text-zinc-500">Add</span></button>`).join('');
|
||||
layoutLibrary.querySelectorAll('[data-add-layout]').forEach(button => button.addEventListener('click', () => addNode(layoutTemplates[button.dataset.addLayout])));
|
||||
|
||||
library.innerHTML = Object.entries(schema).map(([key, definition]) => `<button type="button" data-add-field="${escapeHtml(key)}" class="w-full rounded-lg border border-zinc-200 px-3 py-2 text-left text-sm hover:bg-zinc-50 dark:border-zinc-700 dark:hover:bg-zinc-800"><span class="block font-medium">${escapeHtml(titleFor(key, definition))}</span><span class="block text-[11px] text-zinc-500">${escapeHtml(controlFor(definition))} · add to canvas</span></button>`).join('') || '<p class="rounded-lg border border-dashed border-zinc-300 p-3 text-xs text-zinc-500 dark:border-zinc-700">This content type has no schema yet.</p>';
|
||||
library.querySelectorAll('[data-add-field]').forEach(button => button.addEventListener('click', () => addNode(fieldNode(button.dataset.addField))));
|
||||
}
|
||||
|
||||
function nodeToolbar(node) {
|
||||
return `<div class="flex items-center gap-1"><button type="button" data-action="duplicate" data-id="${node.id}" class="rounded border border-zinc-300 px-2 py-1 text-[11px] dark:border-zinc-700">Duplicate</button><button type="button" data-action="delete" data-id="${node.id}" class="rounded border border-red-300 px-2 py-1 text-[11px] text-red-700 dark:border-red-800 dark:text-red-300">Delete</button></div>`;
|
||||
}
|
||||
|
||||
function renderDropZone(targetId, slot, label = 'Drop here') {
|
||||
return `<div data-drop-target="${targetId}" data-drop-slot="${slot}" class="my-2 rounded-lg border border-dashed border-zinc-300 px-3 py-2 text-center text-[11px] text-zinc-400 transition dark:border-zinc-700">${label}</div>`;
|
||||
}
|
||||
|
||||
function renderNode(node) {
|
||||
const selected = node.id === selectedId;
|
||||
const base = `relative rounded-xl border ${selected ? 'border-blue-500 ring-2 ring-blue-500/20' : 'border-zinc-200 dark:border-zinc-700'}`;
|
||||
if (node.type === 'field') {
|
||||
const definition = schema[node.key] || {};
|
||||
const value = data[node.key];
|
||||
const control = controlFor(definition);
|
||||
const isEditableText = ['text', 'textarea'].includes(control);
|
||||
const content = value === true ? 'Enabled' : value === false ? 'Disabled' : value || 'Click to add content';
|
||||
const preview = control === 'image'
|
||||
? `<img src="${escapeHtml(value || '')}" alt="" class="max-h-52 w-full rounded-lg object-cover" onerror="this.style.display='none'">`
|
||||
: `<div ${isEditableText ? `contenteditable="true" data-inline-field="${escapeHtml(node.key)}"` : ''} class="whitespace-pre-wrap text-sm text-zinc-700 outline-none dark:text-zinc-200 ${isEditableText ? 'rounded px-1 py-0.5 focus:ring-2 focus:ring-blue-500/30' : ''}">${control === 'textarea' ? String(content) : escapeHtml(content)}</div>`;
|
||||
return `<article draggable="true" data-node-id="${node.id}" style="${nodeStyle(node)}" class="${base} bg-white p-4 dark:bg-zinc-900"><div class="mb-2 flex items-start justify-between gap-2"><button type="button" data-select-node="${node.id}" class="text-left"><span class="block text-xs font-semibold uppercase tracking-wide text-zinc-500">${escapeHtml(titleFor(node.key, definition))}</span><span class="text-[11px] text-zinc-400">Field</span>${responsiveBadge(node)}</button>${nodeToolbar(node)}</div>${preview}</article>`;
|
||||
}
|
||||
if (node.type === 'columns') {
|
||||
return `<section draggable="true" data-node-id="${node.id}" style="${nodeStyle(node)}" class="${base} bg-zinc-50 p-3 dark:bg-zinc-950/50"><div class="mb-3 flex items-center justify-between"><button type="button" data-select-node="${node.id}" class="text-left"><strong class="text-sm">${escapeHtml(node.label || 'Columns')}</strong><span class="ml-2 text-[11px] text-zinc-500">${node.columns.length} columns</span>${responsiveBadge(node)}</button>${nodeToolbar(node)}</div><div class="grid gap-3" style="grid-template-columns:repeat(${node.columns.length},minmax(0,1fr))">${node.columns.map((column, index) => `<div class="min-h-28 rounded-lg border border-dashed border-zinc-300 bg-white p-2 dark:border-zinc-700 dark:bg-zinc-900">${renderDropZone(node.id, `column:${index}`, `Column ${index + 1}`)}<div class="space-y-2">${column.children.map(renderNode).join('')}</div></div>`).join('')}</div></section>`;
|
||||
}
|
||||
return `<section draggable="true" data-node-id="${node.id}" style="${nodeStyle(node)}" class="${base} ${node.type === 'section' ? 'bg-zinc-50 p-4 dark:bg-zinc-950/50' : 'bg-white p-3 dark:bg-zinc-900'}"><div class="mb-3 flex items-center justify-between"><button type="button" data-select-node="${node.id}" class="text-left"><strong class="text-sm">${escapeHtml(node.label || titleFor(node.type))}</strong><span class="ml-2 text-[11px] uppercase text-zinc-500">${node.type}</span>${responsiveBadge(node)}</button>${nodeToolbar(node)}</div>${renderDropZone(node.id, 'children')}<div class="space-y-3">${(node.children || []).map(renderNode).join('')}</div></section>`;
|
||||
}
|
||||
|
||||
function bindCanvas() {
|
||||
canvas.querySelectorAll('[data-select-node]').forEach(button => button.addEventListener('click', event => { event.stopPropagation(); selectedId = button.dataset.selectNode; renderCanvas(); renderInspector(); }));
|
||||
canvas.querySelectorAll('[data-inline-field]').forEach(element => {
|
||||
element.addEventListener('click', event => event.stopPropagation());
|
||||
element.addEventListener('input', event => {
|
||||
const key = element.dataset.inlineField;
|
||||
const definition = schema[key] || {};
|
||||
data[key] = controlFor(definition) === 'textarea' ? element.innerHTML : element.textContent;
|
||||
sync();
|
||||
});
|
||||
element.addEventListener('blur', () => { recordHistory(); renderInspector(); });
|
||||
});
|
||||
canvas.querySelectorAll('[data-action]').forEach(button => button.addEventListener('click', event => {
|
||||
event.stopPropagation();
|
||||
const found = findNode(button.dataset.id);
|
||||
if (!found) return;
|
||||
if (button.dataset.action === 'delete') {
|
||||
found.collection.splice(found.index, 1);
|
||||
selectedId = found.parent?.id || layout[0]?.id || null;
|
||||
} else {
|
||||
const copy = clone(found.node);
|
||||
const renew = node => { node.id = uid(); if (node.type === 'columns') node.columns.forEach(column => { column.id = uid(); column.children.forEach(renew); }); else (node.children || []).forEach(renew); };
|
||||
renew(copy);
|
||||
found.collection.splice(found.index + 1, 0, copy);
|
||||
selectedId = copy.id;
|
||||
}
|
||||
update();
|
||||
}));
|
||||
canvas.querySelectorAll('[data-node-id]').forEach(element => {
|
||||
element.addEventListener('dragstart', event => { dragId = element.dataset.nodeId; event.dataTransfer.effectAllowed = 'move'; event.stopPropagation(); });
|
||||
element.addEventListener('dragend', () => { dragId = null; });
|
||||
});
|
||||
canvas.querySelectorAll('[data-drop-target]').forEach(zone => {
|
||||
zone.addEventListener('dragover', event => { event.preventDefault(); event.stopPropagation(); zone.classList.add('border-blue-500','bg-blue-50','dark:bg-blue-950/30'); });
|
||||
zone.addEventListener('dragleave', () => zone.classList.remove('border-blue-500','bg-blue-50','dark:bg-blue-950/30'));
|
||||
zone.addEventListener('drop', event => {
|
||||
event.preventDefault(); event.stopPropagation();
|
||||
zone.classList.remove('border-blue-500','bg-blue-50','dark:bg-blue-950/30');
|
||||
const moving = findNode(dragId)?.node;
|
||||
const target = findNode(zone.dataset.dropTarget)?.node;
|
||||
if (!moving || moving.id === target?.id || (target && containsNode(moving, target.id))) return;
|
||||
const detached = detachNode(dragId);
|
||||
if (!detached) return;
|
||||
targetCollection(zone.dataset.dropTarget, zone.dataset.dropSlot).push(detached);
|
||||
selectedId = detached.id;
|
||||
update();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderCanvas() {
|
||||
canvas.innerHTML = `${renderDropZone('root', 'children', 'Drop at page root')}<div class="space-y-3">${layout.map(renderNode).join('')}</div>`;
|
||||
if (!layout.length) canvas.innerHTML += '<div class="flex min-h-64 items-center justify-center text-center text-sm text-zinc-500">Add a section, columns, container, or field from the component library.</div>';
|
||||
bindCanvas();
|
||||
}
|
||||
|
||||
function renderFieldInspector(node) {
|
||||
const definition = schema[node.key] || {};
|
||||
const control = controlFor(definition);
|
||||
const label = titleFor(node.key, definition);
|
||||
document.getElementById('inspector-help').textContent = label;
|
||||
if (control === 'toggle') {
|
||||
inspector.innerHTML = `<label class="flex items-center justify-between rounded-xl border border-zinc-200 p-3 text-sm dark:border-zinc-700"><span>${escapeHtml(label)}</span><input id="inspector-control" type="checkbox" class="h-5 w-5" ${data[node.key] ? 'checked' : ''}></label>`;
|
||||
} else if (control === 'textarea') {
|
||||
inspector.innerHTML = `<div class="space-y-2"><label class="block text-sm font-medium">${escapeHtml(label)}</label><div class="flex flex-wrap gap-1 rounded-lg border border-zinc-200 p-1 dark:border-zinc-700"><button type="button" data-rich-command="bold" class="rounded px-2 py-1 text-xs font-bold">B</button><button type="button" data-rich-command="italic" class="rounded px-2 py-1 text-xs italic">I</button><button type="button" data-rich-command="underline" class="rounded px-2 py-1 text-xs underline">U</button><button type="button" data-rich-command="insertUnorderedList" class="rounded px-2 py-1 text-xs">List</button><button type="button" id="rich-link" class="rounded px-2 py-1 text-xs">Link</button><button type="button" data-rich-command="removeFormat" class="rounded px-2 py-1 text-xs">Clear</button></div><div id="inspector-control" contenteditable="true" class="min-h-40 w-full rounded-xl border border-zinc-300 bg-white p-3 text-sm outline-none dark:border-zinc-700 dark:bg-zinc-950">${String(data[node.key] || '')}</div></div>`;
|
||||
} else if (control === 'image') {
|
||||
inspector.innerHTML = `<div class="space-y-3"><label class="block text-sm font-medium">${escapeHtml(label)}<input id="inspector-control" type="url" class="mt-2 w-full rounded-xl border border-zinc-300 bg-white p-3 text-sm dark:border-zinc-700 dark:bg-zinc-950" value="${escapeHtml(data[node.key] ?? '')}" placeholder="https://…"></label><label class="block rounded-xl border border-dashed border-zinc-300 p-3 text-sm dark:border-zinc-700">Choose image<input id="inspector-image-file" type="file" accept="image/*" class="mt-2 block w-full text-xs"></label>${data[node.key] ? `<img src="${escapeHtml(data[node.key])}" alt="" class="max-h-48 w-full rounded-xl object-cover">` : ''}</div>`;
|
||||
} else {
|
||||
const type = control === 'number' ? 'number' : control === 'url' ? 'url' : 'text';
|
||||
inspector.innerHTML = `<label class="block text-sm font-medium">${escapeHtml(label)}<input id="inspector-control" type="${type}" class="mt-2 w-full rounded-xl border border-zinc-300 bg-white p-3 text-sm dark:border-zinc-700 dark:bg-zinc-950" value="${escapeHtml(data[node.key] ?? '')}"></label>`;
|
||||
}
|
||||
const input = document.getElementById('inspector-control');
|
||||
const eventName = control === 'toggle' ? 'change' : 'input';
|
||||
input?.addEventListener(eventName, event => {
|
||||
data[node.key] = control === 'toggle' ? event.target.checked : control === 'number' ? Number(event.target.value) : control === 'textarea' ? event.target.innerHTML : event.target.value;
|
||||
sync(); renderCanvas();
|
||||
});
|
||||
input?.addEventListener('change', recordHistory);
|
||||
inspector.querySelectorAll('[data-rich-command]').forEach(button => button.addEventListener('click', () => {
|
||||
document.execCommand(button.dataset.richCommand, false, null);
|
||||
data[node.key] = input.innerHTML; sync(); renderCanvas(); recordHistory();
|
||||
}));
|
||||
document.getElementById('rich-link')?.addEventListener('click', () => {
|
||||
const url = window.prompt('Link URL');
|
||||
if (url) document.execCommand('createLink', false, url);
|
||||
data[node.key] = input.innerHTML; sync(); renderCanvas(); recordHistory();
|
||||
});
|
||||
document.getElementById('inspector-image-file')?.addEventListener('change', event => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
if (file.size > 2 * 1024 * 1024) return window.alert('Choose an image smaller than 2 MB.');
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => { data[node.key] = reader.result; update(); };
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
function renderInspector() {
|
||||
const found = findNode(selectedId);
|
||||
if (!found) { inspector.innerHTML = '<p class="text-sm text-zinc-500">Select a component on the canvas.</p>'; return; }
|
||||
const node = found.node;
|
||||
if (node.type === 'field') return renderFieldInspector(node);
|
||||
document.getElementById('inspector-help').textContent = node.label || titleFor(node.type);
|
||||
const current = node.styles[activeBreakpoint] || (node.styles[activeBreakpoint] = {});
|
||||
const input = (key, label, type = 'text', placeholder = '') => `<label class="block text-xs font-medium text-zinc-600 dark:text-zinc-300">${label}<input data-style-key="${key}" type="${type}" placeholder="${placeholder}" value="${escapeHtml(current[key] ?? '')}" class="mt-1 w-full rounded-lg border border-zinc-300 bg-white px-2.5 py-2 text-sm dark:border-zinc-700 dark:bg-zinc-950"></label>`;
|
||||
const select = (key, label, options) => `<label class="block text-xs font-medium text-zinc-600 dark:text-zinc-300">${label}<select data-style-key="${key}" class="mt-1 w-full rounded-lg border border-zinc-300 bg-white px-2.5 py-2 text-sm dark:border-zinc-700 dark:bg-zinc-950">${options.map(([value, text]) => `<option value="${value}" ${String(current[key] ?? '') === value ? 'selected' : ''}>${text}</option>`).join('')}</select></label>`;
|
||||
inspector.innerHTML = `<div class="space-y-5">
|
||||
<div class="rounded-lg bg-zinc-100 p-2 text-xs dark:bg-zinc-800"><strong>${activeBreakpoint[0].toUpperCase() + activeBreakpoint.slice(1)} styles</strong><span class="block text-zinc-500">Tablet and mobile inherit larger breakpoints until overridden.</span></div>
|
||||
<label class="block text-sm font-medium">Label<input id="node-label" class="mt-2 w-full rounded-xl border border-zinc-300 bg-white p-3 text-sm dark:border-zinc-700 dark:bg-zinc-950" value="${escapeHtml(node.label || '')}"></label>
|
||||
${node.type === 'columns' ? `<label class="block text-sm font-medium">Columns<select id="column-count" class="mt-2 w-full rounded-xl border border-zinc-300 bg-white p-3 text-sm dark:border-zinc-700 dark:bg-zinc-950"><option value="2" ${node.columns.length === 2 ? 'selected' : ''}>Two</option><option value="3" ${node.columns.length === 3 ? 'selected' : ''}>Three</option></select></label>` : ''}
|
||||
<details open class="rounded-xl border border-zinc-200 p-3 dark:border-zinc-700"><summary class="cursor-pointer text-sm font-semibold">Layout</summary><div class="mt-3 grid grid-cols-2 gap-2">${input('width','Width','text','100%')}${input('minHeight','Min height','number')}${select('display','Display',[['','Default'],['block','Block'],['flex','Flex']])}${select('textAlign','Text align',[['','Default'],['left','Left'],['center','Centre'],['right','Right']])}${select('alignItems','Align items',[['','Default'],['flex-start','Start'],['center','Centre'],['flex-end','End'],['stretch','Stretch']])}${select('justifyContent','Justify',[['','Default'],['flex-start','Start'],['center','Centre'],['space-between','Space between'],['flex-end','End']])}</div></details>
|
||||
<details open class="rounded-xl border border-zinc-200 p-3 dark:border-zinc-700"><summary class="cursor-pointer text-sm font-semibold">Spacing</summary><div class="mt-3 grid grid-cols-2 gap-2">${input('paddingTop','Padding top','number')}${input('paddingRight','Padding right','number')}${input('paddingBottom','Padding bottom','number')}${input('paddingLeft','Padding left','number')}${input('marginTop','Margin top','number')}${input('marginRight','Margin right','number')}${input('marginBottom','Margin bottom','number')}${input('marginLeft','Margin left','number')}</div></details>
|
||||
<details open class="rounded-xl border border-zinc-200 p-3 dark:border-zinc-700"><summary class="cursor-pointer text-sm font-semibold">Appearance</summary><div class="mt-3 grid grid-cols-2 gap-2">${input('backgroundColor','Background','color')}${input('color','Text colour','color')}${input('borderColor','Border colour','color')}${input('borderWidth','Border width','number')}${input('borderRadius','Corner radius','number')}</div></details>
|
||||
<button type="button" id="clear-breakpoint-styles" class="w-full rounded-lg border border-zinc-300 px-3 py-2 text-xs dark:border-zinc-700">Clear ${activeBreakpoint} overrides</button>
|
||||
</div>`;
|
||||
document.getElementById('node-label')?.addEventListener('input', event => { node.label = event.target.value; sync(); renderCanvas(); });
|
||||
inspector.querySelectorAll('[data-style-key]').forEach(control => control.addEventListener('input', event => {
|
||||
node.styles[activeBreakpoint][control.dataset.styleKey] = event.target.value;
|
||||
sync(); renderCanvas();
|
||||
}));
|
||||
document.getElementById('clear-breakpoint-styles')?.addEventListener('click', () => { node.styles[activeBreakpoint] = {}; update(); });
|
||||
document.getElementById('column-count')?.addEventListener('change', event => {
|
||||
const count = Number(event.target.value);
|
||||
while (node.columns.length < count) node.columns.push({ id: uid(), children: [] });
|
||||
while (node.columns.length > count) {
|
||||
const removed = node.columns.pop();
|
||||
node.columns[node.columns.length - 1].children.push(...removed.children);
|
||||
}
|
||||
update();
|
||||
});
|
||||
}
|
||||
|
||||
function update() { sync(); renderLibraries(); renderCanvas(); renderInspector(); recordHistory(); refreshHistoryButtons(); }
|
||||
|
||||
undoButton?.addEventListener('click', () => restoreHistory(historyIndex - 1));
|
||||
redoButton?.addEventListener('click', () => restoreHistory(historyIndex + 1));
|
||||
copyButton?.addEventListener('click', copySelected);
|
||||
pasteButton?.addEventListener('click', pasteSelected);
|
||||
document.addEventListener('keydown', event => {
|
||||
const modifier = event.metaKey || event.ctrlKey;
|
||||
const tag = document.activeElement?.tagName?.toLowerCase();
|
||||
const editing = ['input', 'textarea', 'select'].includes(tag) || document.activeElement?.isContentEditable;
|
||||
if (modifier && event.key.toLowerCase() === 'z') {
|
||||
event.preventDefault();
|
||||
restoreHistory(historyIndex + (event.shiftKey ? 1 : -1));
|
||||
} else if (modifier && event.key.toLowerCase() === 'c' && !editing) {
|
||||
event.preventDefault(); copySelected();
|
||||
} else if (modifier && event.key.toLowerCase() === 'v' && !editing) {
|
||||
event.preventDefault(); pasteSelected();
|
||||
} else if ((event.key === 'Delete' || event.key === 'Backspace') && !editing) {
|
||||
const found = findNode(selectedId);
|
||||
if (found && window.confirm('Delete the selected component?')) {
|
||||
found.collection.splice(found.index, 1);
|
||||
selectedId = found.parent?.id || layout[0]?.id || null;
|
||||
update();
|
||||
}
|
||||
}
|
||||
});
|
||||
document.getElementById('toggle-json').addEventListener('click', () => { panel.classList.toggle('hidden'); jsonEditor.value = JSON.stringify(data, null, 2); });
|
||||
document.getElementById('apply-json').addEventListener('click', () => {
|
||||
const message = document.getElementById('json-message');
|
||||
try {
|
||||
data = JSON.parse(jsonEditor.value);
|
||||
layout = Array.isArray(data._layout) ? data._layout : defaultLayout();
|
||||
layout.forEach(normaliseNode);
|
||||
selectedId = layout[0]?.id || null;
|
||||
message.textContent = 'JSON applied to the visual editor.';
|
||||
message.className = 'mt-2 text-sm text-green-700 dark:text-green-300';
|
||||
update();
|
||||
} catch (error) {
|
||||
message.textContent = error.message;
|
||||
message.className = 'mt-2 text-sm text-red-700 dark:text-red-300';
|
||||
}
|
||||
});
|
||||
document.querySelectorAll('.builder-width').forEach(button => button.addEventListener('click', () => {
|
||||
activeBreakpoint = button.textContent.trim().toLowerCase();
|
||||
document.getElementById('builder-viewport').style.maxWidth = button.dataset.width;
|
||||
document.querySelectorAll('.builder-width').forEach(item => item.classList.remove('font-medium','bg-zinc-100','dark:bg-zinc-800'));
|
||||
button.classList.add('font-medium','bg-zinc-100','dark:bg-zinc-800');
|
||||
renderCanvas(); renderInspector();
|
||||
}));
|
||||
form.addEventListener('submit', () => { dirty = false; sync(false); });
|
||||
form.querySelectorAll('input, select, textarea').forEach(control => {
|
||||
if (control.id !== 'data-json' && control.id !== 'json-editor') control.addEventListener('input', markDirty);
|
||||
});
|
||||
document.getElementById('toggle-theme-preview')?.addEventListener('click', event => {
|
||||
previewPanel.classList.toggle('hidden');
|
||||
event.currentTarget.textContent = previewPanel.classList.contains('hidden') ? 'Show theme preview' : 'Hide theme preview';
|
||||
if (!previewPanel.classList.contains('hidden')) refreshThemePreview();
|
||||
});
|
||||
window.addEventListener('beforeunload', event => { if (dirty) { event.preventDefault(); event.returnValue = ''; } });
|
||||
document.getElementById('content-type-select')?.addEventListener('change', event => {
|
||||
if (!@json($document->exists)) window.location.href = `{{ route('content.documents.create') }}?type=${event.target.value}`;
|
||||
});
|
||||
|
||||
update();
|
||||
dirty = false;
|
||||
setSaveState('Saved', 'All changes saved');
|
||||
})();
|
||||
});
|
||||
</script>
|
||||
@endpush
|
||||
</x-layouts.app>
|
||||
|
||||
@@ -1,6 +1,53 @@
|
||||
<x-layouts.app title="Content documents">
|
||||
<div class="space-y-6"><div class="flex items-center justify-between"><div><h1 class="text-2xl font-semibold">Content documents</h1><p class="text-sm text-zinc-500">Reusable structured content used by page compositions.</p></div><flux:button href="{{ route('content.documents.create') }}" variant="primary">Create document</flux:button></div>
|
||||
@if(session('status'))<div class="rounded-xl border border-green-300 bg-green-50 p-4 text-sm text-green-800">{{ session('status') }}</div>@endif
|
||||
<form class="grid gap-3 md:grid-cols-4"><flux:input name="search" value="{{ request('search') }}" placeholder="Search"/><flux:select name="type"><option value="">All types</option>@foreach($types as $type)<option value="{{ $type->key }}" @selected(request('type')===$type->key)>{{ $type->name }}</option>@endforeach</flux:select><flux:select name="status"><option value="">All statuses</option>@foreach(['draft','published','archived'] as $status)<option value="{{ $status }}" @selected(request('status')===$status)>{{ ucfirst($status) }}</option>@endforeach</flux:select><flux:button type="submit">Filter</flux:button></form>
|
||||
<div class="overflow-hidden rounded-xl border border-zinc-200 dark:border-zinc-700"><table class="w-full text-sm"><thead class="bg-zinc-50 dark:bg-zinc-900"><tr><th class="p-3 text-left">Title</th><th class="p-3 text-left">Type</th><th class="p-3 text-left">Status</th><th class="p-3 text-left">Usage</th></tr></thead><tbody>@forelse($documents as $document)<tr class="border-t border-zinc-200 dark:border-zinc-700"><td class="p-3"><a class="font-medium hover:underline" href="{{ route('content.documents.edit',$document) }}">{{ $document->title }}</a><div class="text-xs text-zinc-500">{{ $document->slug }}</div></td><td class="p-3">{{ $document->type?->name }}</td><td class="p-3">{{ ucfirst($document->status) }}</td><td class="p-3">{{ $document->placements->count() }}</td></tr>@empty<tr><td colspan="4" class="p-6 text-center text-zinc-500">No documents found.</td></tr>@endforelse</tbody></table></div>{{ $documents->links() }}</div>
|
||||
<x-layouts.app :title="__('Content documents')">
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold">Content documents</h1>
|
||||
<p class="mt-1 text-sm text-zinc-500">Create and reuse typed content across page regions.</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<flux:button :href="route('content.pages.edit', 'home')">Compose homepage</flux:button>
|
||||
<flux:button variant="primary" :href="route('content.documents.create')">Create document</flux:button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if(session('status'))
|
||||
<div class="rounded-xl border border-green-300 bg-green-50 p-4 text-sm text-green-800 dark:border-green-800 dark:bg-green-950 dark:text-green-200">{{ session('status') }}</div>
|
||||
@endif
|
||||
@error('document')<div class="rounded-xl border border-red-300 bg-red-50 p-4 text-sm text-red-800">{{ $message }}</div>@enderror
|
||||
|
||||
<form method="GET" class="grid gap-3 rounded-xl border border-zinc-200 bg-white p-4 md:grid-cols-[minmax(12rem,1fr)_14rem_10rem_auto] dark:border-zinc-700 dark:bg-zinc-900">
|
||||
<flux:input name="search" label="Search" value="{{ request('search') }}" placeholder="Document title" />
|
||||
<flux:select name="type" label="Content type">
|
||||
<option value="">All types</option>
|
||||
@foreach($types as $type)<option value="{{ $type->key }}" @selected(request('type') === $type->key)>{{ $type->name }}</option>@endforeach
|
||||
</flux:select>
|
||||
<flux:select name="status" label="Status">
|
||||
<option value="">All statuses</option>
|
||||
<option value="draft" @selected(request('status') === 'draft')>Draft</option>
|
||||
<option value="published" @selected(request('status') === 'published')>Published</option>
|
||||
</flux:select>
|
||||
<div class="flex items-end gap-2"><flux:button type="submit">Filter</flux:button><flux:button :href="route('content.index')" variant="ghost">Reset</flux:button></div>
|
||||
</form>
|
||||
|
||||
<div class="overflow-hidden rounded-xl border border-zinc-200 bg-white dark:border-zinc-700 dark:bg-zinc-900">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-zinc-50 text-left text-xs uppercase text-zinc-500 dark:bg-zinc-950"><tr><th class="p-3">Document</th><th class="p-3">Type</th><th class="p-3">Status</th><th class="p-3">Uses</th><th class="p-3 text-right">Actions</th></tr></thead>
|
||||
<tbody class="divide-y divide-zinc-200 dark:divide-zinc-800">
|
||||
@forelse($documents as $document)
|
||||
<tr>
|
||||
<td class="p-3"><div class="font-medium">{{ $document->title }}</div><div class="text-xs text-zinc-500">{{ $document->slug }}</div></td>
|
||||
<td class="p-3">{{ $document->type?->name }}</td>
|
||||
<td class="p-3"><span class="rounded-full px-2 py-1 text-xs {{ $document->status === 'published' ? 'bg-green-100 text-green-800 dark:bg-green-950 dark:text-green-200' : 'bg-zinc-100 text-zinc-700 dark:bg-zinc-800 dark:text-zinc-200' }}">{{ ucfirst($document->status) }}</span></td>
|
||||
<td class="p-3">{{ $document->placements_count }}</td>
|
||||
<td class="p-3 text-right"><flux:button size="sm" :href="route('content.documents.edit', $document)">Edit</flux:button></td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr><td colspan="5" class="p-10 text-center text-zinc-500">No content documents match the current filters.</td></tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{ $documents->links() }}
|
||||
</div>
|
||||
</x-layouts.app>
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
<x-layouts.app :title="'Compose '.ucfirst($pageKey)">
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<a
|
||||
class="text-sm text-zinc-500 hover:underline"
|
||||
href="{{ route('content.index') }}"
|
||||
>
|
||||
Content documents
|
||||
</a>
|
||||
|
||||
<h1 class="text-2xl font-semibold">
|
||||
{{ ucfirst($pageKey) }} composition
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
@if (session('status'))
|
||||
<div class="rounded-xl border border-green-300 bg-green-50 p-4 text-sm text-green-800">
|
||||
{{ session('status') }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<form
|
||||
method="POST"
|
||||
action="{{ route('content.pages.placements.store', $pageKey) }}"
|
||||
class="grid gap-3 rounded-xl border border-zinc-200 p-4 md:grid-cols-3 dark:border-zinc-700"
|
||||
>
|
||||
@csrf
|
||||
|
||||
<flux:select name="content_document_id" label="Document" required>
|
||||
@foreach ($documents as $document)
|
||||
<option value="{{ $document->id }}">
|
||||
{{ $document->title }} ({{ $document->type?->name ?? 'Unknown type' }})
|
||||
</option>
|
||||
@endforeach
|
||||
</flux:select>
|
||||
|
||||
<flux:select name="region" label="Region" required>
|
||||
@foreach (['header', 'body', 'footer'] as $region)
|
||||
<option value="{{ $region }}">
|
||||
{{ ucfirst($region) }}
|
||||
</option>
|
||||
@endforeach
|
||||
</flux:select>
|
||||
|
||||
<div class="self-end">
|
||||
<flux:button type="submit" variant="primary">
|
||||
Add placement
|
||||
</flux:button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@foreach (['header', 'body', 'footer'] as $region)
|
||||
<section class="rounded-xl border border-zinc-200 p-4 dark:border-zinc-700">
|
||||
<h2 class="mb-3 text-lg font-semibold">
|
||||
{{ ucfirst($region) }}
|
||||
</h2>
|
||||
|
||||
<div class="space-y-2">
|
||||
@forelse ($placements->get($region, collect()) as $placement)
|
||||
<div class="flex items-center justify-between gap-4 rounded-lg border border-zinc-200 p-3 dark:border-zinc-700">
|
||||
<div class="min-w-0">
|
||||
<a
|
||||
class="block truncate font-medium hover:underline"
|
||||
href="{{ route('content.documents.edit', $placement->document) }}"
|
||||
>
|
||||
{{ $placement->document->title }}
|
||||
</a>
|
||||
|
||||
<div class="text-xs text-zinc-500">
|
||||
{{ $placement->is_enabled ? 'Enabled' : 'Disabled' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form
|
||||
method="POST"
|
||||
action="{{ route('content.pages.placements.destroy', [$pageKey, $placement]) }}"
|
||||
>
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
|
||||
<flux:button type="submit" size="sm" variant="danger">
|
||||
Remove
|
||||
</flux:button>
|
||||
</form>
|
||||
</div>
|
||||
@empty
|
||||
<p class="text-sm text-zinc-500">
|
||||
No placements.
|
||||
</p>
|
||||
@endforelse
|
||||
</div>
|
||||
</section>
|
||||
@endforeach
|
||||
</div>
|
||||
</x-layouts.app>
|
||||
@@ -0,0 +1,3 @@
|
||||
@if(app()->hasDebugModeEnabled())
|
||||
<!-- Unsupported content renderer: {{ $document->type?->renderer }} -->
|
||||
@endif
|
||||
@@ -2,22 +2,22 @@
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Modules\Content\Http\Controllers\ContentDocumentController;
|
||||
use Modules\Content\Http\Controllers\PageComposerController;
|
||||
use Modules\Content\Http\Controllers\PageCompositionController;
|
||||
|
||||
Route::middleware(['web', 'auth'])->prefix('admin/content')->name('content.')->group(function (): void {
|
||||
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::put('/documents/{document}/autosave', [ContentDocumentController::class, 'autosave'])->name('documents.autosave');
|
||||
Route::post('/documents/preview', [ContentDocumentController::class, 'preview'])->name('documents.preview');
|
||||
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}', [PageComposerController::class, 'edit'])->name('pages.edit');
|
||||
Route::post('/pages/{pageKey}/placements', [PageComposerController::class, 'store'])->name('pages.placements.store');
|
||||
Route::patch('/pages/{pageKey}/placements/{placement}', [PageComposerController::class, 'update'])->name('pages.placements.update');
|
||||
Route::delete('/pages/{pageKey}/placements/{placement}', [PageComposerController::class, 'destroy'])->name('pages.placements.destroy');
|
||||
Route::post('/pages/{pageKey}/reorder', [PageComposerController::class, 'reorder'])->name('pages.reorder');
|
||||
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');
|
||||
});
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\Content\Services;
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Facades\View;
|
||||
use Modules\Content\Models\ContentDocument;
|
||||
use Modules\Content\Models\PageContent;
|
||||
use Throwable;
|
||||
|
||||
@@ -13,22 +13,71 @@ final class PageRenderer
|
||||
{
|
||||
public function hasComposition(string $pageKey): bool
|
||||
{
|
||||
return Schema::hasTable('page_content') && PageContent::where('page_key',$pageKey)->exists();
|
||||
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, bool $preview=false): string
|
||||
public function renderRegion(string $pageKey, string $region): string
|
||||
{
|
||||
if(!Schema::hasTable('page_content')) return '';
|
||||
return PageContent::query()->with('document.type')->where('page_key',$pageKey)->where('region',$region)->where('is_enabled',true)->orderBy('sort_order')->get()
|
||||
->filter(fn(PageContent $p)=>$preview || ($this->active($p) && $p->document && $this->active($p->document) && $p->document->status==='published'))
|
||||
->map(function(PageContent $p) use($preview){
|
||||
try { $view='stylist-theme-saasland::documents.'.$p->document->type->key; if(!View::exists($view)) return ''; $data=$p->document->data ?? []; return View::make($view, array_merge($data,['data'=>$data,'document'=>$p->document,'placement'=>$p,'preview'=>$preview]))->render(); }
|
||||
catch(Throwable $e){ report($e); return ''; }
|
||||
})->implode('');
|
||||
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 ($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) use ($preview): string {
|
||||
$document = $placement->document;
|
||||
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';
|
||||
}
|
||||
|
||||
$rendered = View::make($view, [
|
||||
'document' => $document,
|
||||
'data' => $document->data ?? [],
|
||||
'placement' => $placement,
|
||||
'isCompositionPreview' => $preview,
|
||||
])->render();
|
||||
|
||||
if ($preview && $document->status !== 'published') {
|
||||
return '<div style="position:relative"><div style="position:absolute;z-index:1000;top:8px;right:8px;padding:5px 9px;border-radius:999px;background:#f59e0b;color:#111827;font:600 12px/1 sans-serif">Draft preview</div>'.$rendered.'</div>';
|
||||
}
|
||||
|
||||
return $rendered;
|
||||
})->implode("\n");
|
||||
}
|
||||
|
||||
private function active(object $model): bool
|
||||
private function isRenderable(ContentDocument $document, bool $preview): bool
|
||||
{
|
||||
return (!$model->published_at || $model->published_at->lte(now())) && (!$model->expires_at || $model->expires_at->gt(now()));
|
||||
if ($preview) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($document->status !== 'published') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($document->published_at?->isFuture()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return ! $document->expires_at?->isPast();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Modules\Content\Models\ContentDocument;
|
||||
use Modules\Content\Models\ContentType;
|
||||
use Modules\Content\Services\PageRenderer;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('registers the renderer', function(){ expect(app(PageRenderer::class))->toBeInstanceOf(PageRenderer::class); });
|
||||
it('stores structured document data', function(){ $type=ContentType::create(['key'=>'test','name'=>'Test','schema'=>[]]); $doc=ContentDocument::create(['content_type_id'=>$type->id,'title'=>'Test','slug'=>'test','status'=>'draft','data'=>['title'=>'Hello']]); expect($doc->fresh()->data)->toBe(['title'=>'Hello']); });
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"name": "modules/content",
|
||||
"description": "Composable content module",
|
||||
"type": "laravel-module",
|
||||
"autoload": {"psr-4": {"Modules\\Content\\": ""}},
|
||||
"extra": {"laravel": {"providers": ["Modules\\Content\\Providers\\ContentServiceProvider"]}}
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
{
|
||||
"name": "Content",
|
||||
"alias": "content",
|
||||
"description": "Composable content documents and page regions.",
|
||||
"keywords": ["content", "cms", "composer"],
|
||||
"priority": 0,
|
||||
"version": "1.0.0",
|
||||
"description": "Composable typed content documents and page regions.",
|
||||
"keywords": ["cms", "content", "regions", "composition"],
|
||||
"priority": 22,
|
||||
"providers": ["Modules\\Content\\Providers\\ContentServiceProvider"],
|
||||
"files": []
|
||||
"aliases": {},
|
||||
"files": [],
|
||||
"requires": ["Core", "Pages", "Stylist"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user