move into sonardesk

This commit is contained in:
Mark Gregory
2026-10-12 14:50:13 +01:00
parent 5f0d2ceb72
commit 38b4a7406f
879 changed files with 24021 additions and 1 deletions
+3
View File
@@ -0,0 +1,3 @@
<?php
return ['name' => 'Pages'];
@@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('pages', function (Blueprint $table): void {
$table->id();
$table->string('title');
$table->string('slug');
$table->text('summary')->nullable();
$table->longText('content')->nullable();
$table->string('status')->default('draft');
$table->timestamp('published_at')->nullable();
$table->boolean('is_home')->default(false);
$table->timestamps();
$table->softDeletes();
});
}
public function down(): void
{
Schema::dropIfExists('pages');
}
};
@@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('pages', function (Blueprint $table): void {
$table->string('template')->default('default');
$table->timestamp('expires_at')->nullable();
$table->string('preview_token', 64)->nullable()->unique();
$table->foreignId('locked_by')->nullable()->constrained('users')->nullOnDelete();
$table->timestamp('locked_at')->nullable();
});
Schema::create('page_revisions', function (Blueprint $table): void {
$table->id();
$table->foreignId('page_id')->constrained('pages')->cascadeOnDelete();
$table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
$table->string('title');
$table->longText('content')->nullable();
$table->json('snapshot');
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('page_revisions');
Schema::table('pages', function (Blueprint $table): void {
$table->dropConstrainedForeignId('locked_by');
$table->dropColumn(['template', 'expires_at', 'preview_token', 'locked_at']);
});
}
};
+151
View File
@@ -0,0 +1,151 @@
<?php
namespace Modules\Pages\Http\Controllers;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Illuminate\View\View;
use Modules\Content\Models\PageContent;
use Modules\Pages\Models\Page;
final class PageController extends Controller
{
public function index(Request $request): View
{
$records = Page::query()
->withCount('contentPlacements')
->when($request->string('search')->isNotEmpty(), function ($query) use ($request): void {
$search = $request->string('search')->toString();
$query->where(fn ($page) => $page
->where('title', 'like', "%{$search}%")
->orWhere('slug', 'like', "%{$search}%")
->orWhere('summary', 'like', "%{$search}%"));
})
->when($request->string('status')->isNotEmpty(), fn ($query) => $query->where('status', $request->string('status')->toString()))
->orderByDesc('is_home')
->orderBy('title')
->paginate(24)
->withQueryString();
return view('pages::admin.index', compact('records'));
}
public function create(): View
{
return view('pages::admin.form', ['record' => new Page(['status' => 'draft', 'template' => 'default'])]);
}
public function store(Request $request): RedirectResponse
{
$data = $this->validated($request);
$data['slug'] = $this->uniqueSlug($data['slug'] ?: $data['title']);
$data['is_home'] = $request->boolean('is_home');
$data['published_at'] = $data['status'] === 'published' ? ($data['published_at'] ?? now()) : null;
$page = Page::query()->create($data);
$this->enforceSingleHomepage($page);
return redirect()->route('administration.pages.edit', $page)->with('status', 'Page created. Add content sections when you are ready.');
}
public function edit(Page $page): View
{
$page->loadCount('contentPlacements');
return view('pages::admin.form', ['record' => $page]);
}
public function update(Request $request, Page $page): RedirectResponse
{
$oldSlug = $page->slug;
$data = $this->validated($request, $page);
$data['slug'] = $this->uniqueSlug($data['slug'] ?: $data['title'], $page);
$data['is_home'] = $request->boolean('is_home');
$data['published_at'] = $data['status'] === 'published' ? ($data['published_at'] ?? $page->published_at ?? now()) : null;
$page->update($data);
$this->enforceSingleHomepage($page);
if ($oldSlug !== $page->slug) {
PageContent::query()->where('page_id', $page->id)->orWhere('page_key', $oldSlug)->update(['page_key' => $page->slug, 'page_id' => $page->id]);
}
return back()->with('status', 'Page saved.');
}
public function duplicate(Request $request, Page $page): RedirectResponse
{
$copy = $page->replicate(['slug', 'status', 'published_at', 'preview_token', 'locked_by', 'locked_at', 'is_home']);
$copy->title = $page->title.' copy';
$copy->slug = $this->uniqueSlug($copy->title);
$copy->status = 'draft';
$copy->published_at = null;
$copy->is_home = false;
$copy->save();
foreach ($page->contentPlacements()->orderBy('region')->orderBy('sort_order')->get() as $placement) {
$copy->contentPlacements()->create([
'page_key' => $copy->slug,
'content_document_id' => $placement->content_document_id,
'region' => $placement->region,
'sort_order' => $placement->sort_order,
'is_enabled' => $placement->is_enabled,
'visibility_rules' => $placement->visibility_rules,
'presentation' => $placement->presentation,
'starts_at' => $placement->starts_at,
'ends_at' => $placement->ends_at,
]);
}
return redirect()->route('administration.pages.edit', $copy)->with('status', 'Draft copy created with the same content structure.');
}
public function destroy(Page $page): RedirectResponse
{
if ($page->is_home) {
return back()->withErrors(['page' => 'Choose another homepage before deleting the current homepage.']);
}
$page->delete();
return redirect()->route('administration.pages.index')->with('status', 'Page moved to the bin.');
}
private function validated(Request $request, ?Page $page = null): array
{
return $request->validate([
'title' => ['required', 'string', 'max:255'],
'slug' => ['nullable', 'string', 'max:255', Rule::unique('pages', 'slug')->ignore($page?->id)->whereNull('deleted_at')],
'summary' => ['nullable', 'string', 'max:1000'],
'content' => ['nullable', 'string'],
'status' => ['required', Rule::in(['draft', 'published'])],
'template' => ['required', Rule::in(['default', 'landing', 'full-width'])],
'published_at' => ['nullable', 'date'],
'expires_at' => ['nullable', 'date', 'after:published_at'],
'is_home' => ['sometimes', 'boolean'],
]);
}
private function uniqueSlug(string $value, ?Page $except = null): string
{
$base = Str::slug($value) ?: 'page';
$slug = $base;
$suffix = 2;
while (Page::withTrashed()->where('slug', $slug)->when($except, fn ($query) => $query->whereKeyNot($except->getKey()))->exists()) {
$slug = $base.'-'.$suffix++;
}
return $slug;
}
private function enforceSingleHomepage(Page $page): void
{
if ($page->is_home) {
Page::query()->whereKeyNot($page->id)->where('is_home', true)->update(['is_home' => false]);
}
}
}
@@ -0,0 +1,17 @@
<?php
namespace Modules\Pages\Http\Controllers;
use App\Http\Controllers\Controller;
use Illuminate\View\View;
use Modules\Pages\Models\Page;
final class PublicPageController extends Controller
{
public function show(string $slug): View
{
$page = Page::query()->where('slug', $slug)->where('status', 'published')->where(fn ($q) => $q->whereNull('published_at')->orWhere('published_at', '<=', now()))->firstOrFail();
return view('pages::public.show', compact('page'));
}
}
+35
View File
@@ -0,0 +1,35 @@
<?php
namespace Modules\Pages\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Modules\Content\Models\PageContent;
use Modules\Localization\Concerns\HasTranslations;
use Modules\Sites\Concerns\BelongsToSites;
use Modules\Workflow\Concerns\HasWorkflow;
final class Page extends Model
{
use BelongsToSites, HasFactory, HasTranslations, HasWorkflow, SoftDeletes;
protected $table = 'pages';
protected $fillable = ['title', 'slug', 'summary', 'content', 'status', 'published_at', 'expires_at', 'template', 'is_home'];
protected function casts(): array
{
return [
'published_at' => 'datetime',
'expires_at' => 'datetime',
'is_home' => 'boolean',
];
}
public function contentPlacements(): HasMany
{
return $this->hasMany(PageContent::class)->orderBy('region')->orderBy('sort_order');
}
}
+20
View File
@@ -0,0 +1,20 @@
<?php
namespace Modules\Pages\Providers;
use Illuminate\Support\ServiceProvider;
final class PagesServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->register(RouteServiceProvider::class);
$this->mergeConfigFrom(module_path('Pages', 'Config/config.php'), 'pages');
}
public function boot(): void
{
$this->loadMigrationsFrom(module_path('Pages', 'Database/Migrations'));
$this->loadViewsFrom(module_path('Pages', 'Resources/views'), 'pages');
}
}
+15
View File
@@ -0,0 +1,15 @@
<?php
namespace Modules\Pages\Providers;
use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider;
use Illuminate\Support\Facades\Route;
final class RouteServiceProvider extends ServiceProvider
{
public function boot(): void
{
parent::boot();
$this->routes(fn () => Route::middleware('web')->group(module_path('Pages', 'Routes/web.php')));
}
}
@@ -0,0 +1,87 @@
<x-layouts.app :title="$record->exists ? $record->title : __('Create page')">
<div class="mx-auto max-w-7xl space-y-6" x-data="pageEditor()">
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div>
<a href="{{ route('administration.pages.index') }}" class="text-sm text-zinc-500 hover:text-zinc-900 dark:hover:text-white">← Pages</a>
<div class="mt-2 flex flex-wrap items-center gap-3">
<h1 class="text-3xl font-semibold tracking-tight">{{ $record->exists ? $record->title : 'New page' }}</h1>
@if($record->exists && $record->is_home)<span class="rounded-full bg-blue-100 px-2.5 py-1 text-xs font-medium text-blue-700 dark:bg-blue-950 dark:text-blue-200">Homepage</span>@endif
</div>
<p class="mt-2 text-sm text-zinc-500">Page details, search metadata, publishing and content structure.</p>
</div>
@if($record->exists)
<div class="flex flex-wrap gap-2">
@if($record->status === 'published')<flux:button :href="route('pages.public.show', $record->slug)" target="_blank" variant="ghost">Open live page</flux:button>@endif
<flux:button :href="route('content.pages.page.edit', $record)">Edit content</flux:button>
<form method="POST" action="{{ route('administration.pages.duplicate', $record) }}">@csrf<flux:button type="submit" variant="ghost">Duplicate</flux:button></form>
</div>
@endif
</div>
@if(session('status'))<div class="rounded-xl border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-800 dark:border-emerald-900 dark:bg-emerald-950 dark:text-emerald-200">{{ session('status') }}</div>@endif
@if($errors->any())<div class="rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-800 dark:border-red-900 dark:bg-red-950 dark:text-red-200"><ul class="list-disc pl-5">@foreach($errors->all() as $error)<li>{{ $error }}</li>@endforeach</ul></div>@endif
<div class="border-b border-zinc-200 dark:border-zinc-800">
<nav class="flex gap-6 overflow-x-auto" aria-label="Page editor sections">
<button type="button" @click="tab='content'" :class="tab==='content' ? 'border-zinc-900 text-zinc-900 dark:border-white dark:text-white' : 'border-transparent text-zinc-500'" class="border-b-2 px-1 py-3 text-sm font-medium">Page details</button>
<button type="button" @click="tab='seo'" :class="tab==='seo' ? 'border-zinc-900 text-zinc-900 dark:border-white dark:text-white' : 'border-transparent text-zinc-500'" class="border-b-2 px-1 py-3 text-sm font-medium">SEO & URL</button>
<button type="button" @click="tab='publishing'" :class="tab==='publishing' ? 'border-zinc-900 text-zinc-900 dark:border-white dark:text-white' : 'border-transparent text-zinc-500'" class="border-b-2 px-1 py-3 text-sm font-medium">Publishing</button>
</nav>
</div>
<form method="POST" action="{{ $record->exists ? route('administration.pages.update', $record) : route('administration.pages.store') }}" class="grid gap-6 xl:grid-cols-[minmax(0,1fr)_22rem]" id="page-form">
@csrf @if($record->exists) @method('PUT') @endif
<div>
<section x-show="tab==='content'" class="space-y-6">
<flux:card class="space-y-5">
<div><h2 class="text-lg font-semibold">Page identity</h2><p class="mt-1 text-sm text-zinc-500">Use a clear title editors and visitors can recognise.</p></div>
<flux:input name="title" label="Page title" value="{{ old('title', $record->title) }}" required x-on:input="suggestSlug($event.target.value)" />
<flux:textarea name="summary" label="Summary" rows="4" description="Used in page listings, search results and social previews when supported.">{{ old('summary', $record->summary) }}</flux:textarea>
</flux:card>
<flux:card class="space-y-5">
<div class="flex items-start justify-between gap-4"><div><h2 class="text-lg font-semibold">Content structure</h2><p class="mt-1 text-sm text-zinc-500">Build the page with ordered, reusable sections in the visual composer.</p></div>@if($record->exists)<span class="rounded-full bg-zinc-100 px-3 py-1 text-xs text-zinc-600 dark:bg-zinc-800 dark:text-zinc-300">{{ $record->content_placements_count ?? 0 }} sections</span>@endif</div>
@if($record->exists)
<div class="rounded-xl border border-zinc-200 bg-zinc-50 p-5 dark:border-zinc-800 dark:bg-zinc-950"><h3 class="font-medium">Page composer</h3><p class="mt-1 text-sm text-zinc-500">Add hero areas, text, feeds, calls to action and shared site sections, then drag them into order.</p><flux:button class="mt-4" :href="route('content.pages.page.edit', $record)" variant="primary">Open content composer</flux:button></div>
@else
<div class="rounded-xl border-2 border-dashed border-zinc-300 p-6 text-sm text-zinc-500 dark:border-zinc-700">Save the page first, then add and arrange its content sections.</div>
@endif
<details class="rounded-xl border border-zinc-200 p-4 dark:border-zinc-800"><summary class="cursor-pointer text-sm font-medium">Legacy body content</summary><p class="mt-2 text-xs text-zinc-500">Kept for compatibility with older templates. New pages should use the content composer.</p><textarea name="content" rows="10" class="mt-4 w-full rounded-xl border border-zinc-300 bg-white p-4 text-sm dark:border-zinc-700 dark:bg-zinc-950">{{ old('content', $record->content) }}</textarea></details>
</flux:card>
</section>
<section x-show="tab==='seo'" x-cloak class="space-y-6">
<flux:card class="space-y-5">
<div><h2 class="text-lg font-semibold">URL and presentation</h2><p class="mt-1 text-sm text-zinc-500">Keep URLs short, readable and stable.</p></div>
<div><label class="mb-2 block text-sm font-medium">Page URL</label><div class="flex rounded-xl border border-zinc-300 bg-white focus-within:ring-2 focus-within:ring-zinc-400 dark:border-zinc-700 dark:bg-zinc-950"><span class="border-r border-zinc-200 px-3 py-2 text-sm text-zinc-500 dark:border-zinc-800">/</span><input name="slug" x-model="slug" value="{{ old('slug', $record->slug) }}" class="min-w-0 flex-1 border-0 bg-transparent px-3 py-2 text-sm focus:ring-0" placeholder="about-us"></div></div>
<flux:select name="template" label="Page template"><option value="default" @selected(old('template', $record->template ?: 'default') === 'default')>Default</option><option value="landing" @selected(old('template', $record->template) === 'landing')>Landing page</option><option value="full-width" @selected(old('template', $record->template) === 'full-width')>Full width</option></flux:select>
</flux:card>
<flux:card><h2 class="text-lg font-semibold">Search preview</h2><div class="mt-4 rounded-xl border border-zinc-200 p-5 dark:border-zinc-800"><div class="text-xs text-emerald-700">{{ url('/') }}/<span x-text="slug || 'page-url'"></span></div><div class="mt-1 text-xl text-blue-700">{{ old('title', $record->title ?: 'Page title') }}</div><p class="mt-1 text-sm text-zinc-600 dark:text-zinc-300">{{ Str::limit(old('summary', $record->summary ?: 'Add a concise summary to improve how this page appears in search and content listings.'), 160) }}</p></div></flux:card>
</section>
<section x-show="tab==='publishing'" x-cloak class="space-y-6">
<flux:card class="space-y-5"><div><h2 class="text-lg font-semibold">Publishing schedule</h2><p class="mt-1 text-sm text-zinc-500">Publish immediately or define a controlled availability window.</p></div><flux:select name="status" label="Status"><option value="draft" @selected(old('status', $record->status ?: 'draft') === 'draft')>Draft</option><option value="published" @selected(old('status', $record->status) === 'published')>Published</option></flux:select><div class="grid gap-4 md:grid-cols-2"><flux:input type="datetime-local" name="published_at" label="Publish from" value="{{ old('published_at', $record->published_at?->format('Y-m-d\TH:i')) }}" /><flux:input type="datetime-local" name="expires_at" label="Unpublish after" value="{{ old('expires_at', $record->expires_at?->format('Y-m-d\TH:i')) }}" /></div><label class="flex items-start gap-3 rounded-xl border border-zinc-200 p-4 dark:border-zinc-800"><input type="checkbox" name="is_home" value="1" class="mt-1" @checked(old('is_home', $record->is_home))><span><strong class="block text-sm">Use as site homepage</strong><span class="mt-1 block text-xs text-zinc-500">This automatically replaces the previous homepage.</span></span></label></flux:card>
</section>
</div>
<aside class="space-y-5 xl:sticky xl:top-6 xl:self-start">
<flux:card class="space-y-4"><div class="flex items-center justify-between"><h2 class="font-semibold">Page status</h2><span class="rounded-full px-2.5 py-1 text-xs {{ $record->status === 'published' ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-200' : 'bg-zinc-100 text-zinc-600 dark:bg-zinc-800 dark:text-zinc-300' }}">{{ ucfirst($record->status ?: 'draft') }}</span></div>@if($record->exists)<dl class="space-y-3 text-sm"><div class="flex justify-between gap-4"><dt class="text-zinc-500">Sections</dt><dd>{{ $record->content_placements_count ?? 0 }}</dd></div><div class="flex justify-between gap-4"><dt class="text-zinc-500">Updated</dt><dd>{{ $record->updated_at?->diffForHumans() }}</dd></div><div class="flex justify-between gap-4"><dt class="text-zinc-500">Template</dt><dd>{{ ucfirst($record->template ?? 'default') }}</dd></div></dl>@endif<flux:button type="submit" variant="primary" class="w-full">{{ $record->exists ? 'Save page' : 'Create page' }}</flux:button></flux:card>
@if($record->exists && !$record->is_home)<flux:card><h2 class="font-semibold">Danger zone</h2><p class="mt-2 text-sm text-zinc-500">Deleting a page also removes its page placements, but reusable content documents remain available.</p><form method="POST" action="{{ route('administration.pages.destroy', $record) }}" class="mt-4" onsubmit="return confirm('Move this page to the bin?')">@csrf @method('DELETE')<flux:button type="submit" variant="danger" class="w-full">Delete page</flux:button></form></flux:card>@endif
</aside>
</form>
</div>
<script>
function pageEditor() {
return {
tab: 'content',
slug: @js(old('slug', $record->slug)),
slugTouched: Boolean(@js(old('slug', $record->slug))),
suggestSlug(title) {
if (this.slugTouched || @js($record->exists)) return;
this.slug = title.toLowerCase().trim().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
}
}
}
</script>
</x-layouts.app>
@@ -0,0 +1,75 @@
<x-layouts.app :title="__('Pages')">
<div class="space-y-6">
<div class="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-zinc-500">Website</p>
<h1 class="mt-1 text-3xl font-semibold tracking-tight">Pages</h1>
<p class="mt-2 max-w-2xl text-sm text-zinc-500">Create, organise, compose and publish every page from one place.</p>
</div>
<div class="flex flex-wrap gap-2">
<flux:button :href="route('content.index')" variant="ghost">Content library</flux:button>
<flux:button :href="route('administration.pages.create')" variant="primary">New page</flux:button>
</div>
</div>
@if(session('status'))
<div class="rounded-xl border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-800 dark:border-emerald-900 dark:bg-emerald-950 dark:text-emerald-200">{{ session('status') }}</div>
@endif
@if($errors->any())
<div class="rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-800 dark:border-red-900 dark:bg-red-950 dark:text-red-200">{{ $errors->first() }}</div>
@endif
<form method="GET" class="grid gap-3 rounded-2xl border border-zinc-200 bg-white p-4 md:grid-cols-[minmax(15rem,1fr)_13rem_auto] dark:border-zinc-800 dark:bg-zinc-900">
<flux:input name="search" label="Search pages" value="{{ request('search') }}" placeholder="Title, URL or summary" />
<flux:select name="status" label="Publishing status">
<option value="">All pages</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('administration.pages.index')" variant="ghost">Reset</flux:button></div>
</form>
<div class="grid gap-4 xl:grid-cols-2">
@forelse($records as $record)
@php
$isLive = $record->status === 'published' && (!$record->published_at || $record->published_at->isPast()) && (!$record->expires_at || $record->expires_at->isFuture());
@endphp
<article class="group rounded-2xl border border-zinc-200 bg-white p-5 shadow-sm transition hover:border-zinc-400 hover:shadow-md dark:border-zinc-800 dark:bg-zinc-900 dark:hover:border-zinc-600">
<div class="flex items-start justify-between gap-4">
<div class="min-w-0">
<div class="flex flex-wrap items-center gap-2">
<h2 class="truncate text-lg font-semibold">{{ $record->title }}</h2>
@if($record->is_home)<span class="rounded-full bg-blue-100 px-2.5 py-1 text-xs font-medium text-blue-700 dark:bg-blue-950 dark:text-blue-200">Homepage</span>@endif
<span class="rounded-full px-2.5 py-1 text-xs font-medium {{ $isLive ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-200' : 'bg-zinc-100 text-zinc-600 dark:bg-zinc-800 dark:text-zinc-300' }}">{{ $isLive ? 'Live' : ucfirst($record->status) }}</span>
</div>
<p class="mt-1 truncate text-sm text-zinc-500">/{{ $record->slug }}</p>
</div>
<div class="rounded-xl bg-zinc-100 px-3 py-2 text-center dark:bg-zinc-800">
<div class="text-lg font-semibold">{{ $record->content_placements_count }}</div>
<div class="text-[10px] uppercase tracking-wide text-zinc-500">sections</div>
</div>
</div>
<p class="mt-4 min-h-10 text-sm leading-6 text-zinc-600 dark:text-zinc-300">{{ Str::limit($record->summary ?: 'No page summary has been added yet.', 150) }}</p>
<div class="mt-5 flex flex-wrap items-center justify-between gap-3 border-t border-zinc-100 pt-4 dark:border-zinc-800">
<div class="text-xs text-zinc-500">Updated {{ $record->updated_at?->diffForHumans() }} · {{ ucfirst($record->template ?? 'default') }}</div>
<div class="flex flex-wrap gap-2">
@if($record->status === 'published')<flux:button size="sm" :href="route('pages.public.show', $record->slug)" target="_blank" variant="ghost">View</flux:button>@endif
<flux:button size="sm" :href="route('content.pages.page.edit', $record)">Content</flux:button>
<flux:button size="sm" :href="route('administration.pages.edit', $record)" variant="primary">Edit page</flux:button>
</div>
</div>
</article>
@empty
<div class="col-span-full rounded-2xl border-2 border-dashed border-zinc-300 px-6 py-16 text-center dark:border-zinc-700">
<h2 class="text-lg font-semibold">No pages found</h2>
<p class="mt-2 text-sm text-zinc-500">Create your first page or clear the current filters.</p>
<flux:button class="mt-5" :href="route('administration.pages.create')" variant="primary">Create page</flux:button>
</div>
@endforelse
</div>
{{ $records->links() }}
</div>
</x-layouts.app>
@@ -0,0 +1,22 @@
@extends('stylist-theme-saasland::layouts.app')
@php
$title = $page->title;
$description = $page->summary;
$eyebrow = 'Page';
$breadcrumbs = [$page->title => null];
@endphp
@section('content')
<section class="content-section">
<div class="wrap content-layout">
<article class="content-card prose-content">
@if ($page->summary)
<p class="content-lead">{{ $page->summary }}</p>
@endif
<div class="cms-content">{!! $page->content !!}</div>
</article>
</div>
</section>
@endsection
+17
View File
@@ -0,0 +1,17 @@
<?php
use Illuminate\Support\Facades\Route;
use Modules\Pages\Http\Controllers\PageController;
use Modules\Pages\Http\Controllers\PublicPageController;
Route::middleware(['auth', 'verified'])->prefix('administration/pages')->name('administration.pages.')->group(function (): void {
Route::get('/', [PageController::class, 'index'])->name('index');
Route::get('/create', [PageController::class, 'create'])->name('create');
Route::post('/', [PageController::class, 'store'])->name('store');
Route::get('/{page}/edit', [PageController::class, 'edit'])->name('edit');
Route::match(['put', 'patch'], '/{page}', [PageController::class, 'update'])->name('update');
Route::post('/{page}/duplicate', [PageController::class, 'duplicate'])->name('duplicate');
Route::delete('/{page}', [PageController::class, 'destroy'])->name('destroy');
});
Route::get('/pages/{slug}', [PublicPageController::class, 'show'])->name('pages.public.show');
+7
View File
@@ -0,0 +1,7 @@
<?php
use Illuminate\Support\Facades\Route;
it('registers the Pages administration route', function (): void {
expect(Route::has('administration.pages.index'))->toBeTrue();
});
+20
View File
@@ -0,0 +1,20 @@
{
"name": "Pages",
"alias": "pages",
"version": "1.0.0",
"description": "Pages CMS module.",
"keywords": [
"cms",
"pages"
],
"priority": 21,
"providers": [
"Modules\\Pages\\Providers\\PagesServiceProvider"
],
"aliases": {},
"files": [],
"requires": [
"Core",
"Administration"
]
}