Professional searchable page library.
* Clear Live, Draft and Homepage indicators.
* Direct Edit page, Edit content and View page actions.
* Page editor divided into:
* Page details
* SEO and URL
* Publishing
* Automatic, unique URL slugs.
* Publish and expiry scheduling.
* Page templates: Default, Landing and Full Width.
* Homepage replacement handled automatically.
* Current homepage protected from accidental deletion.
* Page duplication includes its complete content structure.
* Content composer now works from the actual page record and page ID.
* Clearer section-based terminology for editors.
* Reusable content remains available as an advanced content library rather than dominating the workflow.
The package contains the eight complete changed files, preserving their project paths. It is not a patch collection.
This commit is contained in:
@@ -11,6 +11,7 @@ use Illuminate\Validation\Rule;
|
||||
use Illuminate\View\View;
|
||||
use Modules\Content\Models\ContentDocument;
|
||||
use Modules\Content\Models\PageContent;
|
||||
use Modules\Pages\Models\Page;
|
||||
|
||||
final class PageCompositionController extends Controller
|
||||
{
|
||||
@@ -35,6 +36,35 @@ final class PageCompositionController extends Controller
|
||||
return view('content::admin.composition', compact('pageKey', 'placements', 'documents'));
|
||||
}
|
||||
|
||||
|
||||
public function editPage(Page $page): View
|
||||
{
|
||||
$placements = PageContent::query()
|
||||
->with('document.type')
|
||||
->where(fn ($query) => $query->where('page_id', $page->id)->orWhere('page_key', $page->slug))
|
||||
->orderByRaw("CASE region WHEN 'header' THEN 1 WHEN 'body' THEN 2 ELSE 3 END")
|
||||
->orderBy('sort_order')
|
||||
->get();
|
||||
|
||||
PageContent::query()
|
||||
->whereNull('page_id')
|
||||
->where('page_key', $page->slug)
|
||||
->update(['page_id' => $page->id]);
|
||||
|
||||
$documents = ContentDocument::query()
|
||||
->with('type')
|
||||
->withCount('placements')
|
||||
->orderBy('title')
|
||||
->get();
|
||||
|
||||
return view('content::admin.composition', [
|
||||
'pageKey' => $page->slug,
|
||||
'page' => $page,
|
||||
'placements' => $placements->groupBy('region'),
|
||||
'documents' => $documents,
|
||||
]);
|
||||
}
|
||||
|
||||
public function preview(string $pageKey): View
|
||||
{
|
||||
abort_unless($pageKey === 'home', 404, 'This phase currently provides the Saasland homepage preview.');
|
||||
@@ -52,8 +82,10 @@ final class PageCompositionController extends Controller
|
||||
$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;
|
||||
$page = Page::query()->where('slug', $pageKey)->first();
|
||||
$nextOrder = ((int) PageContent::query()->where(fn ($query) => $query->where('page_key', $pageKey)->when($page, fn ($q) => $q->orWhere('page_id', $page->id)))->where('region', $validated['region'])->max('sort_order')) + 10;
|
||||
PageContent::query()->create([
|
||||
'page_id' => $page?->id,
|
||||
'page_key' => $pageKey,
|
||||
'content_document_id' => $document->id,
|
||||
'region' => $validated['region'],
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
<x-layouts.app :title="'Page composition: '.$pageKey">
|
||||
<x-layouts.app :title="isset($page) ? 'Edit content: '.$page->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 class="flex items-center gap-2 text-sm text-zinc-500"><a href="{{ route('administration.pages.index') }}" class="hover:text-zinc-900 dark:hover:text-white">Pages</a><span>/</span><span>Content composer</span></div>
|
||||
<h1 class="mt-1 text-3xl font-semibold tracking-tight">{{ isset($page) ? $page->title : ucfirst($pageKey).' page' }}</h1>
|
||||
<p class="mt-1 text-sm text-zinc-500">Add sections, edit their content and drag them into the order visitors should see.</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>
|
||||
@isset($page)<flux:button :href="route('administration.pages.edit', $page)" variant="ghost">Page settings</flux:button>@endisset
|
||||
<flux:button :href="route('content.documents.create')">Create section</flux:button>
|
||||
@if(isset($page) && $page->status === 'published')<flux:button variant="primary" :href="route('pages.public.show', $page->slug)" target="_blank">Open live page</flux:button>@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -69,21 +70,21 @@
|
||||
|
||||
<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>
|
||||
<div><h2 class="text-lg font-semibold">Add content section</h2><p class="text-sm text-zinc-500">Choose an existing section from the content library. Draft sections remain hidden from visitors.</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
|
||||
<label class="block text-sm font-medium">Content section
|
||||
<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>
|
||||
<option value="">Select a content section</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>
|
||||
<flux:button type="submit" variant="primary" class="w-full">Add section</flux:button>
|
||||
</form>
|
||||
</flux:card>
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ Route::middleware(['web', 'auth', 'verified'])->prefix('admin/content')->name('c
|
||||
Route::post('/documents/{document}/duplicate', [ContentDocumentController::class, 'duplicate'])->name('documents.duplicate');
|
||||
Route::delete('/documents/{document}', [ContentDocumentController::class, 'destroy'])->name('documents.destroy');
|
||||
|
||||
Route::get('/page/{page}/compose', [PageCompositionController::class, 'editPage'])->name('pages.page.edit');
|
||||
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');
|
||||
|
||||
@@ -5,60 +5,147 @@ 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(): View
|
||||
public function index(Request $request): View
|
||||
{
|
||||
return view('pages::admin.index', ['records' => Page::query()->latest()->paginate(20)]);
|
||||
$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');
|
||||
return view('pages::admin.form', ['record' => new Page(['status' => 'draft', 'template' => 'default'])]);
|
||||
}
|
||||
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$data = $request->validate(['title' => ['required', 'string', 'max:255'],
|
||||
'slug' => ['required', 'string', 'max:255'],
|
||||
'summary' => ['nullable', 'string'],
|
||||
'content' => ['nullable', 'string'],
|
||||
'status' => ['required', 'string', 'max:255'],
|
||||
'published_at' => ['nullable', 'date'],
|
||||
'is_home' => ['sometimes', 'boolean']]);
|
||||
$data = $this->validated($request);
|
||||
$data['slug'] = $this->uniqueSlug($data['slug'] ?: $data['title']);
|
||||
$data['is_home'] = $request->boolean('is_home');
|
||||
Page::create($data);
|
||||
$data['published_at'] = $data['status'] === 'published' ? ($data['published_at'] ?? now()) : null;
|
||||
|
||||
return redirect()->route('administration.pages.index')->with('status', 'Page created.');
|
||||
$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
|
||||
{
|
||||
$data = $request->validate(['title' => ['required', 'string', 'max:255'],
|
||||
'slug' => ['required', 'string', 'max:255'],
|
||||
'summary' => ['nullable', 'string'],
|
||||
'content' => ['nullable', 'string'],
|
||||
'status' => ['required', 'string', 'max:255'],
|
||||
'published_at' => ['nullable', 'date'],
|
||||
'is_home' => ['sometimes', 'boolean']]);
|
||||
$oldSlug = $page->slug;
|
||||
$data = $this->validated($request, $page);
|
||||
$data['slug'] = $this->uniqueSlug($data['slug'] ?: $data['title'], $page);
|
||||
$data['is_home'] = $request->boolean('is_home');
|
||||
$page->update($data);
|
||||
$data['published_at'] = $data['status'] === 'published' ? ($data['published_at'] ?? $page->published_at ?? now()) : null;
|
||||
|
||||
return redirect()->route('administration.pages.index')->with('status', 'Page updated.');
|
||||
$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 back()->with('status', 'Page deleted.');
|
||||
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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,9 @@ 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;
|
||||
@@ -15,13 +17,19 @@ final class Page extends Model
|
||||
|
||||
protected $table = 'pages';
|
||||
|
||||
protected $fillable = ['title', 'slug', 'summary', 'content', 'status', 'published_at', 'is_home'];
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,87 @@
|
||||
<x-layouts.app :title="isset($record) ? __('Edit Page') : __('Create Page')">
|
||||
<div class="mx-auto max-w-4xl space-y-6"><div><h1 class="text-2xl font-semibold">{{ isset($record) ? 'Edit' : 'Create' }} Page</h1></div><form method="POST" action="{{ isset($record) ? route('administration.pages.update', $record) : route('administration.pages.store') }}" class="space-y-5 rounded-xl border border-zinc-200 bg-white p-6 dark:border-zinc-700 dark:bg-zinc-900">@csrf @isset($record) @method('PUT') @endisset
|
||||
<div><label class="mb-1 block text-sm font-medium">Title</label><input type="text" name="title" value="{{ old('title', $record->title ?? '') }}" class="w-full rounded-lg border-zinc-300 dark:border-zinc-700 dark:bg-zinc-900">@error('title')<p class="text-sm text-red-600">{{ $message }}</p>@enderror</div>
|
||||
<div><label class="mb-1 block text-sm font-medium">Slug</label><input type="text" name="slug" value="{{ old('slug', $record->slug ?? '') }}" class="w-full rounded-lg border-zinc-300 dark:border-zinc-700 dark:bg-zinc-900">@error('slug')<p class="text-sm text-red-600">{{ $message }}</p>@enderror</div>
|
||||
<div><label class="mb-1 block text-sm font-medium">Summary</label><textarea name="summary" rows="4" class="w-full rounded-lg border-zinc-300 dark:border-zinc-700 dark:bg-zinc-900">{{ old('summary', $record->summary ?? '') }}</textarea>@error('summary')<p class="text-sm text-red-600">{{ $message }}</p>@enderror</div>
|
||||
<div><label class="mb-1 block text-sm font-medium">Content</label><textarea name="content" rows="8" class="w-full rounded-lg border-zinc-300 dark:border-zinc-700 dark:bg-zinc-900">{{ old('content', $record->content ?? '') }}</textarea>@error('content')<p class="text-sm text-red-600">{{ $message }}</p>@enderror</div>
|
||||
<div><label class="mb-1 block text-sm font-medium">Status</label><input type="text" name="status" value="{{ old('status', $record->status ?? '') }}" class="w-full rounded-lg border-zinc-300 dark:border-zinc-700 dark:bg-zinc-900">@error('status')<p class="text-sm text-red-600">{{ $message }}</p>@enderror</div>
|
||||
<div><label class="mb-1 block text-sm font-medium">Published At</label><input type="datetime-local" name="published_at" value="{{ old('published_at', $record->published_at ?? '') }}" class="w-full rounded-lg border-zinc-300 dark:border-zinc-700 dark:bg-zinc-900">@error('published_at')<p class="text-sm text-red-600">{{ $message }}</p>@enderror</div>
|
||||
<label class="flex items-center gap-2"><input type="checkbox" name="is_home" value="1" @checked(old('is_home', $record->is_home ?? false))><span>Is Home</span></label>
|
||||
<div class="flex gap-3"><button class="rounded-lg bg-zinc-900 px-4 py-2 text-white dark:bg-white dark:text-zinc-900">Save</button><a href="{{ route('administration.pages.index') }}" class="rounded-lg border px-4 py-2">Cancel</a></div></form></div>
|
||||
<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>
|
||||
|
||||
@@ -1,5 +1,75 @@
|
||||
<x-layouts.app :title="__('Pages')">
|
||||
<div class="space-y-6"><div class="flex items-center justify-between"><div><h1 class="text-2xl font-semibold">Pages</h1><p class="text-sm text-zinc-500">Manage pages content.</p></div><a href="{{ route('administration.pages.create') }}" class="rounded-lg bg-zinc-900 px-4 py-2 text-sm text-white dark:bg-white dark:text-zinc-900">Add new</a></div>
|
||||
@if(session('status'))<div class="rounded-lg bg-emerald-50 p-3 text-emerald-800">{{ session('status') }}</div>@endif
|
||||
<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 dark:bg-zinc-800"><tr><th class="px-4 py-3 text-left">Title</th><th class="px-4 py-3 text-left">Slug</th><th class="px-4 py-3 text-left">Summary</th><th class="px-4 py-3 text-right">Actions</th></tr></thead><tbody class="divide-y divide-zinc-200 dark:divide-zinc-800">@forelse($records as $record)<tr><td class="px-4 py-3">{{ is_array($record->title) ? json_encode($record->title) : Str::limit((string) $record->title, 70) }}</td><td class="px-4 py-3">{{ is_array($record->slug) ? json_encode($record->slug) : Str::limit((string) $record->slug, 70) }}</td><td class="px-4 py-3">{{ is_array($record->summary) ? json_encode($record->summary) : Str::limit((string) $record->summary, 70) }}</td><td class="px-4 py-3 text-right"><a class="mr-3 underline" href="{{ route('administration.pages.edit', $record) }}">Edit</a><form class="inline" method="POST" action="{{ route('administration.pages.destroy', $record) }}">@csrf @method('DELETE')<button class="text-red-600" onclick="return confirm('Delete this record?')">Delete</button></form></td></tr>@empty<tr><td colspan="4" class="px-4 py-10 text-center text-zinc-500">No records yet.</td></tr>@endforelse</tbody></table></div>{{ $records->links() }}</div>
|
||||
<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>
|
||||
|
||||
@@ -10,6 +10,7 @@ Route::middleware(['auth', 'verified'])->prefix('administration/pages')->name('a
|
||||
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');
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user