This commit is contained in:
Mark Gregory
2026-07-24 10:30:36 +01:00
parent 10e9c25d6c
commit 395e4dc545
94 changed files with 619 additions and 160 deletions
+2
View File
@@ -0,0 +1,2 @@
<?php
return ['name' => 'Authors'];
@@ -0,0 +1,3 @@
<?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('authors', function(Blueprint $table): void { $table->id(); $table->foreignId('user_id')->nullable()->constrained()->nullOnDelete(); $table->string('name'); $table->string('slug')->unique(); $table->string('job_title')->nullable(); $table->text('biography')->nullable(); $table->string('avatar_path')->nullable(); $table->json('social_links')->nullable(); $table->string('meta_title')->nullable(); $table->text('meta_description')->nullable(); $table->boolean('is_active')->default(true); $table->timestamps(); }); } public function down(): void { Schema::dropIfExists('authors'); } };
@@ -0,0 +1,4 @@
<?php
namespace Modules\Authors\Http\Controllers;
use App\Http\Controllers\Controller; use App\Models\User; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\View\View; use Modules\Authors\Models\Author;
final class AuthorController extends Controller { public function index(): View { return view('authors::admin.index',['records'=>Author::with('user')->latest()->paginate(25)]); } public function create(): View { return view('authors::admin.form',['users'=>User::orderBy('name')->get()]); } public function store(Request $r): RedirectResponse { Author::create($this->data($r)); return redirect()->route('administration.authors.index')->with('status','Author created.'); } public function edit(Author $author): View { return view('authors::admin.form',['record'=>$author,'users'=>User::orderBy('name')->get()]); } public function update(Request $r, Author $author): RedirectResponse { $author->update($this->data($r)); return redirect()->route('administration.authors.index')->with('status','Author updated.'); } public function destroy(Author $author): RedirectResponse { $author->delete(); return back()->with('status','Author deleted.'); } private function data(Request $r): array { $d=$r->validate(['user_id'=>['nullable','exists:users,id'],'name'=>['required','string','max:255'],'slug'=>['required','string','max:255'],'job_title'=>['nullable','string','max:255'],'biography'=>['nullable','string'],'avatar_path'=>['nullable','string','max:255'],'meta_title'=>['nullable','string','max:255'],'meta_description'=>['nullable','string'],'is_active'=>['sometimes','boolean']]); $d['is_active']=$r->boolean('is_active'); return $d; } }
+3
View File
@@ -0,0 +1,3 @@
<?php
namespace Modules\Authors\Models; use App\Models\User; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo;
final class Author extends Model { protected $fillable=['user_id','name','slug','job_title','biography','avatar_path','social_links','meta_title','meta_description','is_active']; protected function casts(): array { return ['social_links'=>'array','is_active'=>'boolean']; } public function user(): BelongsTo { return $this->belongsTo(User::class); } }
@@ -0,0 +1,16 @@
<?php
namespace Modules\Authors\Providers;
use Illuminate\Support\ServiceProvider;
final class AuthorsServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->register(RouteServiceProvider::class);
$this->mergeConfigFrom(module_path('Authors', 'Config/config.php'), 'authors');
}
public function boot(): void
{
$this->loadMigrationsFrom(module_path('Authors', 'Database/Migrations'));
$this->loadViewsFrom(module_path('Authors', 'Resources/views'), 'authors');
}
}
@@ -0,0 +1,12 @@
<?php
namespace Modules\Authors\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();
Route::middleware('web')->group(module_path('Authors', 'Routes/web.php'));
}
}
@@ -0,0 +1 @@
<x-layouts.app :title="isset($record) ? 'Edit author' : 'Create author'"><form method="POST" action="{{ isset($record) ? route('administration.authors.update',$record) : route('administration.authors.store') }}" class="max-w-3xl space-y-5">@csrf @isset($record) @method('PUT') @endisset <flux:select name="user_id" label="Linked user"><option value="">None</option>@foreach($users as $user)<option value="{{ $user->id }}" @selected(old('user_id',$record->user_id ?? null)==$user->id)>{{ $user->name }}</option>@endforeach</flux:select><flux:input name="name" label="Display name" value="{{ old('name',$record->name ?? '') }}" required/><flux:input name="slug" label="Slug" value="{{ old('slug',$record->slug ?? '') }}" required/><flux:input name="job_title" label="Job title" value="{{ old('job_title',$record->job_title ?? '') }}"/><flux:textarea name="biography" label="Biography">{{ old('biography',$record->biography ?? '') }}</flux:textarea><flux:input name="avatar_path" label="Avatar path" value="{{ old('avatar_path',$record->avatar_path ?? '') }}"/><flux:checkbox name="is_active" value="1" label="Active" :checked="old('is_active',$record->is_active ?? true)"/><flux:button type="submit" variant="primary">Save</flux:button></form></x-layouts.app>
@@ -0,0 +1 @@
<x-layouts.app :title="__('Authors')"><div class="flex items-center justify-between mb-6"><div><h1 class="text-2xl font-semibold">Authors</h1><p class="text-sm text-zinc-500">Manage publishing authors.</p></div><flux:button variant="primary" :href="route('administration.authors.create')" wire:navigate>Create</flux:button></div>@if(session('status'))<div class="mb-4 rounded border border-green-300 bg-green-50 p-3 text-green-800">{{ session('status') }}</div>@endif<div class="overflow-hidden rounded-xl border border-zinc-200 bg-white"><table class="w-full text-sm"><thead class="bg-zinc-50"><tr><th class="p-3 text-left">Name / title</th><th class="p-3 text-left">Status</th><th class="p-3 text-right">Actions</th></tr></thead><tbody>@forelse($records as $record)<tr class="border-t"><td class="p-3 font-medium">{{ $record->title ?? $record->name }}</td><td class="p-3">{{ $record->status ?? ($record->is_active ? 'Active' : 'Inactive') }}</td><td class="p-3 text-right"><flux:button size="sm" :href="route('administration.authors.edit', $record)" wire:navigate>Edit</flux:button><form class="inline" method="POST" action="{{ route('administration.authors.destroy', $record) }}">@csrf @method('DELETE')<flux:button size="sm" variant="danger" type="submit">Delete</flux:button></form></td></tr>@empty<tr><td colspan="3" class="p-6 text-center text-zinc-500">No records yet.</td></tr>@endforelse</tbody></table></div><div class="mt-4">{{ $records->links() }}</div></x-layouts.app>
+3
View File
@@ -0,0 +1,3 @@
<?php
use Illuminate\Support\Facades\Route; use Modules\Authors\Http\Controllers\AuthorController;
Route::middleware(['auth','verified'])->prefix('administration/authors')->name('administration.authors.')->group(function (): void { Route::resource('/', AuthorController::class)->parameters([''=>'author'])->except('show'); });
@@ -0,0 +1,10 @@
<?php
namespace Modules\Authors\Tests\Feature;
use Tests\TestCase;
final class ModuleBootTest extends TestCase
{
public function test_module_configuration_is_available(): void
{
$this->assertSame('Authors', config('authors.name'));
}
}
+12
View File
@@ -0,0 +1,12 @@
{
"name": "Authors",
"alias": "authors",
"version": "1.0.0",
"description": "Authors publishing module.",
"keywords": ["publishing", "authors"],
"priority": 30,
"providers": ["Modules\\Authors\\Providers\\AuthorsServiceProvider"],
"aliases": {},
"files": [],
"requires": ["Core", "Administration"]
}
@@ -1,16 +0,0 @@
<?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('banners', function (Blueprint $table): void { $table->id();
$table->string('title');
$table->text('content')->nullable();
$table->string('link_url')->nullable();
$table->string('position')->default('default');
$table->boolean('enabled')->default(true);
$table->timestamps(); $table->softDeletes(); }); }
public function down(): void { Schema::dropIfExists('banners'); }
};
+2
View File
@@ -0,0 +1,2 @@
<?php
return ['name' => 'Blog'];
@@ -0,0 +1,3 @@
<?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('blog_posts', function(Blueprint $table): void { $table->id(); $table->foreignId('author_id')->nullable()->constrained('authors')->nullOnDelete(); $table->foreignId('category_id')->nullable()->constrained('categories')->nullOnDelete(); $table->string('title'); $table->string('slug')->unique(); $table->text('excerpt')->nullable(); $table->longText('content')->nullable(); $table->string('featured_image')->nullable(); $table->string('status')->default('draft')->index(); $table->boolean('is_featured')->default(false); $table->timestamp('published_at')->nullable()->index(); $table->timestamp('expires_at')->nullable(); $table->string('meta_title')->nullable(); $table->text('meta_description')->nullable(); $table->timestamps(); }); Schema::create('blog_post_tag', function(Blueprint $table): void { $table->foreignId('blog_post_id')->constrained('blog_posts')->cascadeOnDelete(); $table->foreignId('tag_id')->constrained('tags')->cascadeOnDelete(); $table->primary(['blog_post_id','tag_id']); }); } public function down(): void { Schema::dropIfExists('blog_post_tag'); Schema::dropIfExists('blog_posts'); } };
@@ -0,0 +1,4 @@
<?php
namespace Modules\Blog\Http\Controllers;
use App\Http\Controllers\Controller; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\View\View; use Modules\Authors\Models\Author; use Modules\Categories\Models\Category; use Modules\Tags\Models\Tag; use Modules\Blog\Models\Blog;
final class BlogController extends Controller { public function index(): View { return view('blog::admin.index',['records'=>Blog::with(['author','category'])->latest()->paginate(25)]); } public function create(): View { return view('blog::admin.form',$this->lookups()); } public function store(Request $r): RedirectResponse { $record=Blog::create($this->data($r)); $record->tags()->sync($r->input('tag_ids',[])); return redirect()->route('administration.blog.index')->with('status','Blog created.'); } public function edit(Blog $blog_post): View { return view('blog::admin.form',array_merge($this->lookups(),['record'=>$blog_post])); } public function update(Request $r, Blog $blog_post): RedirectResponse { $blog_post->update($this->data($r)); $blog_post->tags()->sync($r->input('tag_ids',[])); return redirect()->route('administration.blog.index')->with('status','Blog updated.'); } public function destroy(Blog $blog_post): RedirectResponse { $blog_post->delete(); return back()->with('status','Blog deleted.'); } private function lookups(): array { return ['authors'=>Author::where('is_active',true)->orderBy('name')->get(),'categories'=>Category::where('is_active',true)->orderBy('name')->get(),'tags'=>Tag::orderBy('name')->get()]; } private function data(Request $r): array { $d=$r->validate(['author_id'=>['nullable','exists:authors,id'],'category_id'=>['nullable','exists:categories,id'],'title'=>['required','string','max:255'],'slug'=>['required','string','max:255'],'excerpt'=>['nullable','string'],'content'=>['nullable','string'],'featured_image'=>['nullable','string','max:255'],'status'=>['required','in:draft,review,published,archived'],'is_featured'=>['sometimes','boolean'],'published_at'=>['nullable','date'],'expires_at'=>['nullable','date','after:published_at'],'meta_title'=>['nullable','string','max:255'],'meta_description'=>['nullable','string']]); $d['is_featured']=$r->boolean('is_featured'); return $d; } }
@@ -0,0 +1,3 @@
<?php
namespace Modules\Blog\Http\Controllers; use App\Http\Controllers\Controller; use Illuminate\View\View; use Modules\Blog\Models\Blog;
final class PublicBlogController extends Controller { public function index(): View { return view('blog::public.index',['records'=>Blog::published()->with(['author','category'])->latest('published_at')->paginate(12)]); } public function show(string $slug): View { return view('blog::public.show',['record'=>Blog::published()->with(['author','category','tags'])->where('slug',$slug)->firstOrFail()]); } }
+4
View File
@@ -0,0 +1,4 @@
<?php
namespace Modules\Blog\Models;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Modules\Authors\Models\Author; use Modules\Categories\Models\Category; use Modules\Tags\Models\Tag;
final class Blog extends Model { protected $table='blog_posts'; protected $fillable=['author_id','category_id','title','slug','excerpt','content','featured_image','status','is_featured','published_at','expires_at','meta_title','meta_description']; protected function casts(): array { return ['is_featured'=>'boolean','published_at'=>'datetime','expires_at'=>'datetime']; } public function author(): BelongsTo { return $this->belongsTo(Author::class); } public function category(): BelongsTo { return $this->belongsTo(Category::class); } public function tags(): BelongsToMany { return $this->belongsToMany(Tag::class, 'blog_post_tag'); } public function scopePublished(Builder $query): Builder { return $query->where('status','published')->where(fn(Builder $q)=>$q->whereNull('published_at')->orWhere('published_at','<=',now()))->where(fn(Builder $q)=>$q->whereNull('expires_at')->orWhere('expires_at','>',now())); } }
@@ -0,0 +1,16 @@
<?php
namespace Modules\Blog\Providers;
use Illuminate\Support\ServiceProvider;
final class BlogServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->register(RouteServiceProvider::class);
$this->mergeConfigFrom(module_path('Blog', 'Config/config.php'), 'blog');
}
public function boot(): void
{
$this->loadMigrationsFrom(module_path('Blog', 'Database/Migrations'));
$this->loadViewsFrom(module_path('Blog', 'Resources/views'), 'blog');
}
}
@@ -0,0 +1,12 @@
<?php
namespace Modules\Blog\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();
Route::middleware('web')->group(module_path('Blog', 'Routes/web.php'));
}
}
@@ -0,0 +1 @@
<x-layouts.app :title="isset($record) ? 'Edit Blog' : 'Create Blog'"><form method="POST" action="{{ isset($record) ? route('administration.blog.update',$record) : route('administration.blog.store') }}" class="space-y-5">@csrf @isset($record) @method('PUT') @endisset <div class="grid gap-5 md:grid-cols-2"><flux:input name="title" label="Title" value="{{ old('title',$record->title ?? '') }}" required/><flux:input name="slug" label="Slug" value="{{ old('slug',$record->slug ?? '') }}" required/><flux:select name="author_id" label="Author"><option value="">None</option>@foreach($authors as $author)<option value="{{ $author->id }}" @selected(old('author_id',$record->author_id ?? null)==$author->id)>{{ $author->name }}</option>@endforeach</flux:select><flux:select name="category_id" label="Category"><option value="">None</option>@foreach($categories as $category)<option value="{{ $category->id }}" @selected(old('category_id',$record->category_id ?? null)==$category->id)>{{ $category->name }}</option>@endforeach</flux:select><flux:select name="status" label="Status">@foreach(['draft','review','published','archived'] as $status)<option @selected(old('status',$record->status ?? 'draft')===$status)>{{ $status }}</option>@endforeach</flux:select><flux:input type="datetime-local" name="published_at" label="Publish at" value="{{ old('published_at',isset($record->published_at)?$record->published_at->format('Y-m-d\TH:i'):'') }}"/><flux:input type="datetime-local" name="expires_at" label="Expires at" value="{{ old('expires_at',isset($record->expires_at)?$record->expires_at->format('Y-m-d\TH:i'):'') }}"/><flux:input name="featured_image" label="Featured image path" value="{{ old('featured_image',$record->featured_image ?? '') }}"/></div><flux:textarea name="excerpt" label="Excerpt">{{ old('excerpt',$record->excerpt ?? '') }}</flux:textarea><flux:textarea name="content" label="Content" rows="16">{{ old('content',$record->content ?? '') }}</flux:textarea><div><label class="text-sm font-medium">Tags</label><div class="mt-2 grid gap-2 md:grid-cols-3">@foreach($tags as $tag)<label><input type="checkbox" name="tag_ids[]" value="{{ $tag->id }}" @checked(in_array($tag->id,old('tag_ids',isset($record)?$record->tags->pluck('id')->all():[])))> {{ $tag->name }}</label>@endforeach</div></div><flux:checkbox name="is_featured" value="1" label="Featured" :checked="old('is_featured',$record->is_featured ?? false)"/><flux:input name="meta_title" label="SEO title" value="{{ old('meta_title',$record->meta_title ?? '') }}"/><flux:textarea name="meta_description" label="SEO description">{{ old('meta_description',$record->meta_description ?? '') }}</flux:textarea><flux:button type="submit" variant="primary">Save</flux:button></form></x-layouts.app>
@@ -0,0 +1 @@
<x-layouts.app :title="__('Blog')"><div class="flex items-center justify-between mb-6"><div><h1 class="text-2xl font-semibold">Blog</h1><p class="text-sm text-zinc-500">Manage publishing blog.</p></div><flux:button variant="primary" :href="route('administration.blog.create')" wire:navigate>Create</flux:button></div>@if(session('status'))<div class="mb-4 rounded border border-green-300 bg-green-50 p-3 text-green-800">{{ session('status') }}</div>@endif<div class="overflow-hidden rounded-xl border border-zinc-200 bg-white"><table class="w-full text-sm"><thead class="bg-zinc-50"><tr><th class="p-3 text-left">Name / title</th><th class="p-3 text-left">Status</th><th class="p-3 text-right">Actions</th></tr></thead><tbody>@forelse($records as $record)<tr class="border-t"><td class="p-3 font-medium">{{ $record->title ?? $record->name }}</td><td class="p-3">{{ $record->status ?? ($record->is_active ? 'Active' : 'Inactive') }}</td><td class="p-3 text-right"><flux:button size="sm" :href="route('administration.blog.edit', $record)" wire:navigate>Edit</flux:button><form class="inline" method="POST" action="{{ route('administration.blog.destroy', $record) }}">@csrf @method('DELETE')<flux:button size="sm" variant="danger" type="submit">Delete</flux:button></form></td></tr>@empty<tr><td colspan="3" class="p-6 text-center text-zinc-500">No records yet.</td></tr>@endforelse</tbody></table></div><div class="mt-4">{{ $records->links() }}</div></x-layouts.app>
@@ -0,0 +1 @@
<x-layouts.app :title="__('Blog')"><div class="mx-auto max-w-5xl"><h1 class="mb-8 text-4xl font-bold">Blog</h1><div class="grid gap-6 md:grid-cols-2">@foreach($records as $record)<article class="rounded-xl border p-6"><h2 class="text-xl font-semibold"><a href="{{ route('blog.public.show',$record->slug) }}">{{ $record->title }}</a></h2><p class="mt-2 text-zinc-600">{{ $record->excerpt }}</p></article>@endforeach</div><div class="mt-8">{{ $records->links() }}</div></div></x-layouts.app>
@@ -0,0 +1 @@
<x-layouts.app :title="$record->title"><article class="mx-auto max-w-3xl"><h1 class="text-4xl font-bold">{{ $record->title }}</h1><p class="mt-3 text-zinc-500">{{ optional($record->published_at)->format('j F Y') }}</p><div class="prose mt-8 max-w-none">{!! nl2br(e($record->content)) !!}</div></article></x-layouts.app>
+4
View File
@@ -0,0 +1,4 @@
<?php
use Illuminate\Support\Facades\Route; use Modules\Blog\Http\Controllers\BlogController; use Modules\Blog\Http\Controllers\PublicBlogController;
Route::middleware(['auth','verified'])->prefix('administration/blog')->name('administration.blog.')->group(function (): void { Route::resource('/', BlogController::class)->parameters([''=>'blog_post'])->except('show'); });
Route::get('/blog',[PublicBlogController::class,'index'])->name('blog.public.index'); Route::get('/blog/{slug}',[PublicBlogController::class,'show'])->name('blog.public.show');
@@ -0,0 +1,10 @@
<?php
namespace Modules\Blog\Tests\Feature;
use Tests\TestCase;
final class ModuleBootTest extends TestCase
{
public function test_module_configuration_is_available(): void
{
$this->assertSame('Blog', config('blog.name'));
}
}
+12
View File
@@ -0,0 +1,12 @@
{
"name": "Blog",
"alias": "blog",
"version": "1.0.0",
"description": "Blog publishing module.",
"keywords": ["publishing", "blog"],
"priority": 30,
"providers": ["Modules\\Blog\\Providers\\BlogServiceProvider"],
"aliases": {},
"files": [],
"requires": ["Core", "Administration"]
}
+2
View File
@@ -0,0 +1,2 @@
<?php
return ['name' => 'Categories'];
@@ -0,0 +1,8 @@
<?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('categories', function (Blueprint $table): void { $table->id(); $table->foreignId('parent_id')->nullable()->constrained('categories')->nullOnDelete(); $table->string('name'); $table->string('slug')->unique(); $table->text('description')->nullable(); $table->string('type')->default('content')->index(); $table->unsignedInteger('sort_order')->default(0); $table->boolean('is_active')->default(true); $table->timestamps(); }); }
public function down(): void { Schema::dropIfExists('categories'); }
};
@@ -0,0 +1,17 @@
<?php
namespace Modules\Categories\Http\Controllers;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
use Modules\Categories\Models\Category;
final class CategoryController extends Controller
{
public function index(): View { return view('categories::admin.index', ['records'=>Category::with('parent')->orderBy('type')->orderBy('sort_order')->paginate(30)]); }
public function create(): View { return view('categories::admin.form', ['parents'=>Category::orderBy('name')->get()]); }
public function store(Request $request): RedirectResponse { Category::create($this->validated($request)); return redirect()->route('administration.categories.index')->with('status','Category created.'); }
public function edit(Category $category): View { return view('categories::admin.form', ['record'=>$category,'parents'=>Category::whereKeyNot($category->getKey())->orderBy('name')->get()]); }
public function update(Request $request, Category $category): RedirectResponse { $category->update($this->validated($request)); return redirect()->route('administration.categories.index')->with('status','Category updated.'); }
public function destroy(Category $category): RedirectResponse { $category->delete(); return back()->with('status','Category deleted.'); }
private function validated(Request $request): array { $data=$request->validate(['parent_id'=>['nullable','exists:categories,id'],'name'=>['required','string','max:255'],'slug'=>['required','string','max:255'],'description'=>['nullable','string'],'type'=>['required','string','max:50'],'sort_order'=>['nullable','integer','min:0'],'is_active'=>['sometimes','boolean']]); $data['is_active']=$request->boolean('is_active'); return $data; }
}
+12
View File
@@ -0,0 +1,12 @@
<?php
namespace Modules\Categories\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
final class Category extends Model
{
protected $fillable = ['parent_id','name','slug','description','type','sort_order','is_active'];
protected function casts(): array { return ['is_active'=>'boolean','sort_order'=>'integer']; }
public function parent(): BelongsTo { return $this->belongsTo(self::class, 'parent_id'); }
public function children(): HasMany { return $this->hasMany(self::class, 'parent_id')->orderBy('sort_order'); }
}
@@ -0,0 +1,16 @@
<?php
namespace Modules\Categories\Providers;
use Illuminate\Support\ServiceProvider;
final class CategoriesServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->register(RouteServiceProvider::class);
$this->mergeConfigFrom(module_path('Categories', 'Config/config.php'), 'categories');
}
public function boot(): void
{
$this->loadMigrationsFrom(module_path('Categories', 'Database/Migrations'));
$this->loadViewsFrom(module_path('Categories', 'Resources/views'), 'categories');
}
}
@@ -0,0 +1,12 @@
<?php
namespace Modules\Categories\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();
Route::middleware('web')->group(module_path('Categories', 'Routes/web.php'));
}
}
@@ -0,0 +1 @@
<x-layouts.app :title="isset($record) ? 'Edit category' : 'Create category'"><form method="POST" action="{{ isset($record) ? route('administration.categories.update',$record) : route('administration.categories.store') }}" class="max-w-3xl space-y-5">@csrf @isset($record) @method('PUT') @endisset <flux:input name="name" label="Name" value="{{ old('name',$record->name ?? '') }}" required/><flux:input name="slug" label="Slug" value="{{ old('slug',$record->slug ?? '') }}" required/><flux:select name="parent_id" label="Parent"><option value="">None</option>@foreach($parents as $parent)<option value="{{ $parent->id }}" @selected(old('parent_id',$record->parent_id ?? null)==$parent->id)>{{ $parent->name }}</option>@endforeach</flux:select><flux:input name="type" label="Type" value="{{ old('type',$record->type ?? 'content') }}"/><flux:textarea name="description" label="Description">{{ old('description',$record->description ?? '') }}</flux:textarea><flux:input name="sort_order" type="number" label="Sort order" value="{{ old('sort_order',$record->sort_order ?? 0) }}"/><flux:checkbox name="is_active" value="1" label="Active" :checked="old('is_active',$record->is_active ?? true)"/><flux:button type="submit" variant="primary">Save</flux:button></form></x-layouts.app>
@@ -0,0 +1 @@
<x-layouts.app :title="__('Categories')"><div class="flex items-center justify-between mb-6"><div><h1 class="text-2xl font-semibold">Categories</h1><p class="text-sm text-zinc-500">Manage publishing categories.</p></div><flux:button variant="primary" :href="route('administration.categories.create')" wire:navigate>Create</flux:button></div>@if(session('status'))<div class="mb-4 rounded border border-green-300 bg-green-50 p-3 text-green-800">{{ session('status') }}</div>@endif<div class="overflow-hidden rounded-xl border border-zinc-200 bg-white"><table class="w-full text-sm"><thead class="bg-zinc-50"><tr><th class="p-3 text-left">Name / title</th><th class="p-3 text-left">Status</th><th class="p-3 text-right">Actions</th></tr></thead><tbody>@forelse($records as $record)<tr class="border-t"><td class="p-3 font-medium">{{ $record->title ?? $record->name }}</td><td class="p-3">{{ $record->status ?? ($record->is_active ? 'Active' : 'Inactive') }}</td><td class="p-3 text-right"><flux:button size="sm" :href="route('administration.categories.edit', $record)" wire:navigate>Edit</flux:button><form class="inline" method="POST" action="{{ route('administration.categories.destroy', $record) }}">@csrf @method('DELETE')<flux:button size="sm" variant="danger" type="submit">Delete</flux:button></form></td></tr>@empty<tr><td colspan="3" class="p-6 text-center text-zinc-500">No records yet.</td></tr>@endforelse</tbody></table></div><div class="mt-4">{{ $records->links() }}</div></x-layouts.app>
+4
View File
@@ -0,0 +1,4 @@
<?php
use Illuminate\Support\Facades\Route;
use Modules\Categories\Http\Controllers\CategoryController;
Route::middleware(['auth','verified'])->prefix('administration/categories')->name('administration.categories.')->group(function (): void { Route::resource('/', CategoryController::class)->parameters([''=>'category'])->except('show'); });
@@ -0,0 +1,10 @@
<?php
namespace Modules\Categories\Tests\Feature;
use Tests\TestCase;
final class ModuleBootTest extends TestCase
{
public function test_module_configuration_is_available(): void
{
$this->assertSame('Categories', config('categories.name'));
}
}
+12
View File
@@ -0,0 +1,12 @@
{
"name": "Categories",
"alias": "categories",
"version": "1.0.0",
"description": "Categories publishing module.",
"keywords": ["publishing", "categories"],
"priority": 30,
"providers": ["Modules\\Categories\\Providers\\CategoriesServiceProvider"],
"aliases": {},
"files": [],
"requires": ["Core", "Administration"]
}
+2
View File
@@ -0,0 +1,2 @@
<?php
return ['name' => 'Comments'];
@@ -0,0 +1,3 @@
<?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('comments', function(Blueprint $table): void { $table->id(); $table->nullableMorphs('commentable'); $table->foreignId('parent_id')->nullable()->constrained('comments')->cascadeOnDelete(); $table->foreignId('user_id')->nullable()->constrained()->nullOnDelete(); $table->string('author_name'); $table->string('author_email'); $table->text('body'); $table->string('status')->default('pending')->index(); $table->string('ip_address',45)->nullable(); $table->text('moderation_notes')->nullable(); $table->timestamp('approved_at')->nullable(); $table->timestamps(); }); } public function down(): void { Schema::dropIfExists('comments'); } };
@@ -0,0 +1,3 @@
<?php
namespace Modules\Comments\Http\Controllers; use App\Http\Controllers\Controller; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\View\View; use Modules\Comments\Models\Comment;
final class CommentController extends Controller { public function index(Request $r): View { $q=Comment::latest(); if($r->filled('status')) $q->where('status',$r->string('status')); return view('comments::admin.index',['records'=>$q->paginate(30)]); } public function update(Request $r, Comment $comment): RedirectResponse { $data=$r->validate(['status'=>['required','in:pending,approved,spam,rejected'],'moderation_notes'=>['nullable','string']]); $data['approved_at']=$data['status']==='approved' ? now() : null; $comment->update($data); return back()->with('status','Comment moderated.'); } public function destroy(Comment $comment): RedirectResponse { $comment->delete(); return back()->with('status','Comment deleted.'); } }
+3
View File
@@ -0,0 +1,3 @@
<?php
namespace Modules\Comments\Models; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\MorphTo;
final class Comment extends Model { protected $fillable=['commentable_type','commentable_id','parent_id','user_id','author_name','author_email','body','status','ip_address','moderation_notes','approved_at']; protected function casts(): array { return ['approved_at'=>'datetime']; } public function commentable(): MorphTo { return $this->morphTo(); } }
@@ -0,0 +1,16 @@
<?php
namespace Modules\Comments\Providers;
use Illuminate\Support\ServiceProvider;
final class CommentsServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->register(RouteServiceProvider::class);
$this->mergeConfigFrom(module_path('Comments', 'Config/config.php'), 'comments');
}
public function boot(): void
{
$this->loadMigrationsFrom(module_path('Comments', 'Database/Migrations'));
$this->loadViewsFrom(module_path('Comments', 'Resources/views'), 'comments');
}
}
@@ -0,0 +1,12 @@
<?php
namespace Modules\Comments\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();
Route::middleware('web')->group(module_path('Comments', 'Routes/web.php'));
}
}
@@ -0,0 +1 @@
<x-layouts.app :title="__('Comments')"><h1 class="mb-6 text-2xl font-semibold">Comment moderation</h1>@if(session('status'))<div class="mb-4 rounded border border-green-300 bg-green-50 p-3">{{ session('status') }}</div>@endif<div class="space-y-4">@forelse($records as $comment)<div class="rounded-xl border bg-white p-5"><div class="flex justify-between"><strong>{{ $comment->author_name }}</strong><span>{{ $comment->status }}</span></div><p class="my-3">{{ $comment->body }}</p><form method="POST" action="{{ route('administration.comments.update',$comment) }}" class="flex gap-3">@csrf @method('PUT')<select name="status" class="rounded border"><option>pending</option><option @selected($comment->status==='approved')>approved</option><option @selected($comment->status==='spam')>spam</option><option @selected($comment->status==='rejected')>rejected</option></select><input name="moderation_notes" value="{{ $comment->moderation_notes }}" class="flex-1 rounded border" placeholder="Moderation notes"><flux:button type="submit">Update</flux:button></form></div>@empty<p>No comments awaiting moderation.</p>@endforelse</div><div class="mt-4">{{ $records->links() }}</div></x-layouts.app>
+3
View File
@@ -0,0 +1,3 @@
<?php
use Illuminate\Support\Facades\Route; use Modules\Comments\Http\Controllers\CommentController;
Route::middleware(['auth','verified'])->prefix('administration/comments')->name('administration.comments.')->group(function (): void { Route::get('/',[CommentController::class,'index'])->name('index'); Route::put('/{comment}',[CommentController::class,'update'])->name('update'); Route::delete('/{comment}',[CommentController::class,'destroy'])->name('destroy'); });
@@ -0,0 +1,10 @@
<?php
namespace Modules\Comments\Tests\Feature;
use Tests\TestCase;
final class ModuleBootTest extends TestCase
{
public function test_module_configuration_is_available(): void
{
$this->assertSame('Comments', config('comments.name'));
}
}
+12
View File
@@ -0,0 +1,12 @@
{
"name": "Comments",
"alias": "comments",
"version": "1.0.0",
"description": "Comments publishing module.",
"keywords": ["publishing", "comments"],
"priority": 30,
"providers": ["Modules\\Comments\\Providers\\CommentsServiceProvider"],
"aliases": {},
"files": [],
"requires": ["Core", "Administration"]
}
+2
View File
@@ -0,0 +1,2 @@
<?php
return ['name' => 'Feeds'];
@@ -0,0 +1,3 @@
<?php
namespace Modules\Feeds\Http\Controllers; use App\Http\Controllers\Controller; use Illuminate\Http\Response; use Modules\Blog\Models\Blog; use Modules\News\Models\News;
final class FeedController extends Controller { public function blog(): Response { return response()->view('feeds::rss',['title'=>config('app.name').' Blog','records'=>Blog::published()->latest('published_at')->limit(50)->get(),'routeName'=>'blog.public.show'])->header('Content-Type','application/rss+xml; charset=UTF-8'); } public function news(): Response { return response()->view('feeds::rss',['title'=>config('app.name').' News','records'=>News::published()->latest('published_at')->limit(50)->get(),'routeName'=>'news.public.show'])->header('Content-Type','application/rss+xml; charset=UTF-8'); } }
@@ -0,0 +1,16 @@
<?php
namespace Modules\Feeds\Providers;
use Illuminate\Support\ServiceProvider;
final class FeedsServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->register(RouteServiceProvider::class);
$this->mergeConfigFrom(module_path('Feeds', 'Config/config.php'), 'feeds');
}
public function boot(): void
{
$this->loadMigrationsFrom(module_path('Feeds', 'Database/Migrations'));
$this->loadViewsFrom(module_path('Feeds', 'Resources/views'), 'feeds');
}
}
@@ -0,0 +1,12 @@
<?php
namespace Modules\Feeds\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();
Route::middleware('web')->group(module_path('Feeds', 'Routes/web.php'));
}
}
@@ -0,0 +1 @@
{!! '<?xml version="1.0" encoding="UTF-8"?>' !!}<rss version="2.0"><channel><title>{{ $title }}</title><link>{{ url('/') }}</link><description>{{ $title }}</description>@foreach($records as $record)<item><title>{{ $record->title }}</title><link>{{ route($routeName, $record->slug) }}</link><guid>{{ route($routeName, $record->slug) }}</guid><description><![CDATA[{{ $record->excerpt }}]]></description><pubDate>{{ optional($record->published_at)->toRssString() }}</pubDate></item>@endforeach</channel></rss>
+3
View File
@@ -0,0 +1,3 @@
<?php
use Illuminate\Support\Facades\Route; use Modules\Feeds\Http\Controllers\FeedController;
Route::get('/feeds/blog.xml',[FeedController::class,'blog'])->name('feeds.blog'); Route::get('/feeds/news.xml',[FeedController::class,'news'])->name('feeds.news');
@@ -0,0 +1,10 @@
<?php
namespace Modules\Feeds\Tests\Feature;
use Tests\TestCase;
final class ModuleBootTest extends TestCase
{
public function test_module_configuration_is_available(): void
{
$this->assertSame('Feeds', config('feeds.name'));
}
}
+12
View File
@@ -0,0 +1,12 @@
{
"name": "Feeds",
"alias": "feeds",
"version": "1.0.0",
"description": "Feeds publishing module.",
"keywords": ["publishing", "feeds"],
"priority": 30,
"providers": ["Modules\\Feeds\\Providers\\FeedsServiceProvider"],
"aliases": {},
"files": [],
"requires": ["Core", "Administration"]
}
+2
View File
@@ -0,0 +1,2 @@
<?php
return ['name' => 'News'];
@@ -0,0 +1,3 @@
<?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('news_articles', function(Blueprint $table): void { $table->id(); $table->foreignId('author_id')->nullable()->constrained('authors')->nullOnDelete(); $table->foreignId('category_id')->nullable()->constrained('categories')->nullOnDelete(); $table->string('title'); $table->string('slug')->unique(); $table->text('excerpt')->nullable(); $table->longText('content')->nullable(); $table->string('featured_image')->nullable(); $table->string('status')->default('draft')->index(); $table->boolean('is_featured')->default(false); $table->timestamp('published_at')->nullable()->index(); $table->timestamp('expires_at')->nullable(); $table->string('meta_title')->nullable(); $table->text('meta_description')->nullable(); $table->timestamps(); }); Schema::create('news_article_tag', function(Blueprint $table): void { $table->foreignId('news_article_id')->constrained('news_articles')->cascadeOnDelete(); $table->foreignId('tag_id')->constrained('tags')->cascadeOnDelete(); $table->primary(['news_article_id','tag_id']); }); } public function down(): void { Schema::dropIfExists('news_article_tag'); Schema::dropIfExists('news_articles'); } };
@@ -0,0 +1,4 @@
<?php
namespace Modules\News\Http\Controllers;
use App\Http\Controllers\Controller; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\View\View; use Modules\Authors\Models\Author; use Modules\Categories\Models\Category; use Modules\Tags\Models\Tag; use Modules\News\Models\News;
final class NewsController extends Controller { public function index(): View { return view('news::admin.index',['records'=>News::with(['author','category'])->latest()->paginate(25)]); } public function create(): View { return view('news::admin.form',$this->lookups()); } public function store(Request $r): RedirectResponse { $record=News::create($this->data($r)); $record->tags()->sync($r->input('tag_ids',[])); return redirect()->route('administration.news.index')->with('status','News created.'); } public function edit(News $news_article): View { return view('news::admin.form',array_merge($this->lookups(),['record'=>$news_article])); } public function update(Request $r, News $news_article): RedirectResponse { $news_article->update($this->data($r)); $news_article->tags()->sync($r->input('tag_ids',[])); return redirect()->route('administration.news.index')->with('status','News updated.'); } public function destroy(News $news_article): RedirectResponse { $news_article->delete(); return back()->with('status','News deleted.'); } private function lookups(): array { return ['authors'=>Author::where('is_active',true)->orderBy('name')->get(),'categories'=>Category::where('is_active',true)->orderBy('name')->get(),'tags'=>Tag::orderBy('name')->get()]; } private function data(Request $r): array { $d=$r->validate(['author_id'=>['nullable','exists:authors,id'],'category_id'=>['nullable','exists:categories,id'],'title'=>['required','string','max:255'],'slug'=>['required','string','max:255'],'excerpt'=>['nullable','string'],'content'=>['nullable','string'],'featured_image'=>['nullable','string','max:255'],'status'=>['required','in:draft,review,published,archived'],'is_featured'=>['sometimes','boolean'],'published_at'=>['nullable','date'],'expires_at'=>['nullable','date','after:published_at'],'meta_title'=>['nullable','string','max:255'],'meta_description'=>['nullable','string']]); $d['is_featured']=$r->boolean('is_featured'); return $d; } }
@@ -0,0 +1,3 @@
<?php
namespace Modules\News\Http\Controllers; use App\Http\Controllers\Controller; use Illuminate\View\View; use Modules\News\Models\News;
final class PublicNewsController extends Controller { public function index(): View { return view('news::public.index',['records'=>News::published()->with(['author','category'])->latest('published_at')->paginate(12)]); } public function show(string $slug): View { return view('news::public.show',['record'=>News::published()->with(['author','category','tags'])->where('slug',$slug)->firstOrFail()]); } }
+4
View File
@@ -0,0 +1,4 @@
<?php
namespace Modules\News\Models;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Modules\Authors\Models\Author; use Modules\Categories\Models\Category; use Modules\Tags\Models\Tag;
final class News extends Model { protected $table='news_articles'; protected $fillable=['author_id','category_id','title','slug','excerpt','content','featured_image','status','is_featured','published_at','expires_at','meta_title','meta_description']; protected function casts(): array { return ['is_featured'=>'boolean','published_at'=>'datetime','expires_at'=>'datetime']; } public function author(): BelongsTo { return $this->belongsTo(Author::class); } public function category(): BelongsTo { return $this->belongsTo(Category::class); } public function tags(): BelongsToMany { return $this->belongsToMany(Tag::class, 'news_article_tag'); } public function scopePublished(Builder $query): Builder { return $query->where('status','published')->where(fn(Builder $q)=>$q->whereNull('published_at')->orWhere('published_at','<=',now()))->where(fn(Builder $q)=>$q->whereNull('expires_at')->orWhere('expires_at','>',now())); } }
@@ -0,0 +1,16 @@
<?php
namespace Modules\News\Providers;
use Illuminate\Support\ServiceProvider;
final class NewsServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->register(RouteServiceProvider::class);
$this->mergeConfigFrom(module_path('News', 'Config/config.php'), 'news');
}
public function boot(): void
{
$this->loadMigrationsFrom(module_path('News', 'Database/Migrations'));
$this->loadViewsFrom(module_path('News', 'Resources/views'), 'news');
}
}
@@ -0,0 +1,12 @@
<?php
namespace Modules\News\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();
Route::middleware('web')->group(module_path('News', 'Routes/web.php'));
}
}
@@ -0,0 +1 @@
<x-layouts.app :title="isset($record) ? 'Edit News' : 'Create News'"><form method="POST" action="{{ isset($record) ? route('administration.news.update',$record) : route('administration.news.store') }}" class="space-y-5">@csrf @isset($record) @method('PUT') @endisset <div class="grid gap-5 md:grid-cols-2"><flux:input name="title" label="Title" value="{{ old('title',$record->title ?? '') }}" required/><flux:input name="slug" label="Slug" value="{{ old('slug',$record->slug ?? '') }}" required/><flux:select name="author_id" label="Author"><option value="">None</option>@foreach($authors as $author)<option value="{{ $author->id }}" @selected(old('author_id',$record->author_id ?? null)==$author->id)>{{ $author->name }}</option>@endforeach</flux:select><flux:select name="category_id" label="Category"><option value="">None</option>@foreach($categories as $category)<option value="{{ $category->id }}" @selected(old('category_id',$record->category_id ?? null)==$category->id)>{{ $category->name }}</option>@endforeach</flux:select><flux:select name="status" label="Status">@foreach(['draft','review','published','archived'] as $status)<option @selected(old('status',$record->status ?? 'draft')===$status)>{{ $status }}</option>@endforeach</flux:select><flux:input type="datetime-local" name="published_at" label="Publish at" value="{{ old('published_at',isset($record->published_at)?$record->published_at->format('Y-m-d\TH:i'):'') }}"/><flux:input type="datetime-local" name="expires_at" label="Expires at" value="{{ old('expires_at',isset($record->expires_at)?$record->expires_at->format('Y-m-d\TH:i'):'') }}"/><flux:input name="featured_image" label="Featured image path" value="{{ old('featured_image',$record->featured_image ?? '') }}"/></div><flux:textarea name="excerpt" label="Excerpt">{{ old('excerpt',$record->excerpt ?? '') }}</flux:textarea><flux:textarea name="content" label="Content" rows="16">{{ old('content',$record->content ?? '') }}</flux:textarea><div><label class="text-sm font-medium">Tags</label><div class="mt-2 grid gap-2 md:grid-cols-3">@foreach($tags as $tag)<label><input type="checkbox" name="tag_ids[]" value="{{ $tag->id }}" @checked(in_array($tag->id,old('tag_ids',isset($record)?$record->tags->pluck('id')->all():[])))> {{ $tag->name }}</label>@endforeach</div></div><flux:checkbox name="is_featured" value="1" label="Featured" :checked="old('is_featured',$record->is_featured ?? false)"/><flux:input name="meta_title" label="SEO title" value="{{ old('meta_title',$record->meta_title ?? '') }}"/><flux:textarea name="meta_description" label="SEO description">{{ old('meta_description',$record->meta_description ?? '') }}</flux:textarea><flux:button type="submit" variant="primary">Save</flux:button></form></x-layouts.app>
@@ -0,0 +1 @@
<x-layouts.app :title="__('News')"><div class="flex items-center justify-between mb-6"><div><h1 class="text-2xl font-semibold">News</h1><p class="text-sm text-zinc-500">Manage publishing news.</p></div><flux:button variant="primary" :href="route('administration.news.create')" wire:navigate>Create</flux:button></div>@if(session('status'))<div class="mb-4 rounded border border-green-300 bg-green-50 p-3 text-green-800">{{ session('status') }}</div>@endif<div class="overflow-hidden rounded-xl border border-zinc-200 bg-white"><table class="w-full text-sm"><thead class="bg-zinc-50"><tr><th class="p-3 text-left">Name / title</th><th class="p-3 text-left">Status</th><th class="p-3 text-right">Actions</th></tr></thead><tbody>@forelse($records as $record)<tr class="border-t"><td class="p-3 font-medium">{{ $record->title ?? $record->name }}</td><td class="p-3">{{ $record->status ?? ($record->is_active ? 'Active' : 'Inactive') }}</td><td class="p-3 text-right"><flux:button size="sm" :href="route('administration.news.edit', $record)" wire:navigate>Edit</flux:button><form class="inline" method="POST" action="{{ route('administration.news.destroy', $record) }}">@csrf @method('DELETE')<flux:button size="sm" variant="danger" type="submit">Delete</flux:button></form></td></tr>@empty<tr><td colspan="3" class="p-6 text-center text-zinc-500">No records yet.</td></tr>@endforelse</tbody></table></div><div class="mt-4">{{ $records->links() }}</div></x-layouts.app>
@@ -0,0 +1 @@
<x-layouts.app :title="__('News')"><div class="mx-auto max-w-5xl"><h1 class="mb-8 text-4xl font-bold">News</h1><div class="grid gap-6 md:grid-cols-2">@foreach($records as $record)<article class="rounded-xl border p-6"><h2 class="text-xl font-semibold"><a href="{{ route('news.public.show',$record->slug) }}">{{ $record->title }}</a></h2><p class="mt-2 text-zinc-600">{{ $record->excerpt }}</p></article>@endforeach</div><div class="mt-8">{{ $records->links() }}</div></div></x-layouts.app>
@@ -0,0 +1 @@
<x-layouts.app :title="$record->title"><article class="mx-auto max-w-3xl"><h1 class="text-4xl font-bold">{{ $record->title }}</h1><p class="mt-3 text-zinc-500">{{ optional($record->published_at)->format('j F Y') }}</p><div class="prose mt-8 max-w-none">{!! nl2br(e($record->content)) !!}</div></article></x-layouts.app>
+4
View File
@@ -0,0 +1,4 @@
<?php
use Illuminate\Support\Facades\Route; use Modules\News\Http\Controllers\NewsController; use Modules\News\Http\Controllers\PublicNewsController;
Route::middleware(['auth','verified'])->prefix('administration/news')->name('administration.news.')->group(function (): void { Route::resource('/', NewsController::class)->parameters([''=>'news_article'])->except('show'); });
Route::get('/news',[PublicNewsController::class,'index'])->name('news.public.index'); Route::get('/news/{slug}',[PublicNewsController::class,'show'])->name('news.public.show');
@@ -0,0 +1,10 @@
<?php
namespace Modules\News\Tests\Feature;
use Tests\TestCase;
final class ModuleBootTest extends TestCase
{
public function test_module_configuration_is_available(): void
{
$this->assertSame('News', config('news.name'));
}
}
+12
View File
@@ -0,0 +1,12 @@
{
"name": "News",
"alias": "news",
"version": "1.0.0",
"description": "News publishing module.",
"keywords": ["publishing", "news"],
"priority": 30,
"providers": ["Modules\\News\\Providers\\NewsServiceProvider"],
"aliases": {},
"files": [],
"requires": ["Core", "Administration"]
}
@@ -4,8 +4,12 @@ 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();
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();
@@ -13,6 +17,13 @@ return new class extends Migration {
$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'); }
$table->timestamps();
$table->softDeletes();
});
}
public function down(): void
{
Schema::dropIfExists('pages');
}
};
@@ -0,0 +1,3 @@
<?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']); }); } };
-5
View File
@@ -1,5 +0,0 @@
<?php
namespace Modules\Pages\Support;
final class ModuleInfo { public static function name(): string { return 'Pages'; } public static function nameLower(): string { return 'pages'; } }
@@ -39,6 +39,12 @@ final class RolePermissionSeeder extends Seeder
'faq.manage',
'team.manage',
'testimonials.manage',
'blog.manage',
'news.manage',
'categories.manage',
'tags.manage',
'authors.manage',
'comments.moderate',
];
foreach ($permissions as $permission) {
+2
View File
@@ -0,0 +1,2 @@
<?php
return ['name' => 'Sitemap'];
@@ -0,0 +1,3 @@
<?php
namespace Modules\Sitemap\Http\Controllers; use App\Http\Controllers\Controller; use Illuminate\Http\Response; use Modules\Blog\Models\Blog; use Modules\News\Models\News; use Modules\Pages\Models\Page;
final class SitemapController extends Controller { public function index(): Response { $urls=collect(); $urls->push(['loc'=>url('/'),'updated_at'=>now()]); Page::query()->where('status','published')->get()->each(fn($r)=>$urls->push(['loc'=>route('pages.public.show',$r->slug),'updated_at'=>$r->updated_at])); Blog::published()->get()->each(fn($r)=>$urls->push(['loc'=>route('blog.public.show',$r->slug),'updated_at'=>$r->updated_at])); News::published()->get()->each(fn($r)=>$urls->push(['loc'=>route('news.public.show',$r->slug),'updated_at'=>$r->updated_at])); return response()->view('sitemap::index',compact('urls'))->header('Content-Type','application/xml; charset=UTF-8'); } }
@@ -0,0 +1,12 @@
<?php
namespace Modules\Sitemap\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();
Route::middleware('web')->group(module_path('Sitemap', 'Routes/web.php'));
}
}
@@ -0,0 +1,16 @@
<?php
namespace Modules\Sitemap\Providers;
use Illuminate\Support\ServiceProvider;
final class SitemapServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->register(RouteServiceProvider::class);
$this->mergeConfigFrom(module_path('Sitemap', 'Config/config.php'), 'sitemap');
}
public function boot(): void
{
$this->loadMigrationsFrom(module_path('Sitemap', 'Database/Migrations'));
$this->loadViewsFrom(module_path('Sitemap', 'Resources/views'), 'sitemap');
}
}
@@ -0,0 +1 @@
{!! '<?xml version="1.0" encoding="UTF-8"?>' !!}<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">@foreach($urls as $url)<url><loc>{{ $url['loc'] }}</loc><lastmod>{{ $url['updated_at']->toAtomString() }}</lastmod></url>@endforeach</urlset>
+2
View File
@@ -0,0 +1,2 @@
<?php
use Illuminate\Support\Facades\Route; use Modules\Sitemap\Http\Controllers\SitemapController; Route::get('/sitemap.xml',[SitemapController::class,'index'])->name('sitemap.index');
@@ -0,0 +1,10 @@
<?php
namespace Modules\Sitemap\Tests\Feature;
use Tests\TestCase;
final class ModuleBootTest extends TestCase
{
public function test_module_configuration_is_available(): void
{
$this->assertSame('Sitemap', config('sitemap.name'));
}
}
+12
View File
@@ -0,0 +1,12 @@
{
"name": "Sitemap",
"alias": "sitemap",
"version": "1.0.0",
"description": "Sitemap publishing module.",
"keywords": ["publishing", "sitemap"],
"priority": 30,
"providers": ["Modules\\Sitemap\\Providers\\SitemapServiceProvider"],
"aliases": {},
"files": [],
"requires": ["Core", "Administration"]
}
+2
View File
@@ -0,0 +1,2 @@
<?php
return ['name' => 'Tags'];
@@ -0,0 +1,3 @@
<?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('tags', function(Blueprint $table): void { $table->id(); $table->string('name'); $table->string('slug')->unique(); $table->text('description')->nullable(); $table->unsignedInteger('usage_count')->default(0); $table->timestamps(); }); } public function down(): void { Schema::dropIfExists('tags'); } };
@@ -0,0 +1,4 @@
<?php
namespace Modules\Tags\Http\Controllers;
use App\Http\Controllers\Controller; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\View\View; use Modules\Tags\Models\Tag;
final class TagController extends Controller { public function index(): View { return view('tags::admin.index',['records'=>Tag::orderByDesc('usage_count')->paginate(30)]); } public function create(): View { return view('tags::admin.form'); } public function store(Request $request): RedirectResponse { Tag::create($request->validate(['name'=>['required','string','max:255'],'slug'=>['required','string','max:255','unique:tags,slug'],'description'=>['nullable','string']])); return redirect()->route('administration.tags.index')->with('status','Tag created.'); } public function edit(Tag $tag): View { return view('tags::admin.form',['record'=>$tag]); } public function update(Request $request, Tag $tag): RedirectResponse { $tag->update($request->validate(['name'=>['required','string','max:255'],'slug'=>['required','string','max:255','unique:tags,slug,'.$tag->id],'description'=>['nullable','string']])); return redirect()->route('administration.tags.index')->with('status','Tag updated.'); } public function destroy(Tag $tag): RedirectResponse { $tag->delete(); return back()->with('status','Tag deleted.'); } }
+3
View File
@@ -0,0 +1,3 @@
<?php
namespace Modules\Tags\Models; use Illuminate\Database\Eloquent\Model;
final class Tag extends Model { protected $fillable=['name','slug','description','usage_count']; protected function casts(): array { return ['usage_count'=>'integer']; } }
@@ -0,0 +1,12 @@
<?php
namespace Modules\Tags\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();
Route::middleware('web')->group(module_path('Tags', 'Routes/web.php'));
}
}
@@ -0,0 +1,16 @@
<?php
namespace Modules\Tags\Providers;
use Illuminate\Support\ServiceProvider;
final class TagsServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->register(RouteServiceProvider::class);
$this->mergeConfigFrom(module_path('Tags', 'Config/config.php'), 'tags');
}
public function boot(): void
{
$this->loadMigrationsFrom(module_path('Tags', 'Database/Migrations'));
$this->loadViewsFrom(module_path('Tags', 'Resources/views'), 'tags');
}
}
@@ -0,0 +1 @@
<x-layouts.app :title="isset($record) ? 'Edit tag' : 'Create tag'"><form method="POST" action="{{ isset($record) ? route('administration.tags.update',$record) : route('administration.tags.store') }}" class="max-w-3xl space-y-5">@csrf @isset($record) @method('PUT') @endisset <flux:input name="name" label="Name" value="{{ old('name',$record->name ?? '') }}" required/><flux:input name="slug" label="Slug" value="{{ old('slug',$record->slug ?? '') }}" required/><flux:textarea name="description" label="Description">{{ old('description',$record->description ?? '') }}</flux:textarea><flux:button type="submit" variant="primary">Save</flux:button></form></x-layouts.app>
@@ -0,0 +1 @@
<x-layouts.app :title="__('Tags')"><div class="flex items-center justify-between mb-6"><div><h1 class="text-2xl font-semibold">Tags</h1><p class="text-sm text-zinc-500">Manage publishing tags.</p></div><flux:button variant="primary" :href="route('administration.tags.create')" wire:navigate>Create</flux:button></div>@if(session('status'))<div class="mb-4 rounded border border-green-300 bg-green-50 p-3 text-green-800">{{ session('status') }}</div>@endif<div class="overflow-hidden rounded-xl border border-zinc-200 bg-white"><table class="w-full text-sm"><thead class="bg-zinc-50"><tr><th class="p-3 text-left">Name / title</th><th class="p-3 text-left">Status</th><th class="p-3 text-right">Actions</th></tr></thead><tbody>@forelse($records as $record)<tr class="border-t"><td class="p-3 font-medium">{{ $record->title ?? $record->name }}</td><td class="p-3">{{ $record->status ?? ($record->is_active ? 'Active' : 'Inactive') }}</td><td class="p-3 text-right"><flux:button size="sm" :href="route('administration.tags.edit', $record)" wire:navigate>Edit</flux:button><form class="inline" method="POST" action="{{ route('administration.tags.destroy', $record) }}">@csrf @method('DELETE')<flux:button size="sm" variant="danger" type="submit">Delete</flux:button></form></td></tr>@empty<tr><td colspan="3" class="p-6 text-center text-zinc-500">No records yet.</td></tr>@endforelse</tbody></table></div><div class="mt-4">{{ $records->links() }}</div></x-layouts.app>
+3
View File
@@ -0,0 +1,3 @@
<?php
use Illuminate\Support\Facades\Route; use Modules\Tags\Http\Controllers\TagController;
Route::middleware(['auth','verified'])->prefix('administration/tags')->name('administration.tags.')->group(function (): void { Route::resource('/', TagController::class)->parameters([''=>'tag'])->except('show'); });
@@ -0,0 +1,10 @@
<?php
namespace Modules\Tags\Tests\Feature;
use Tests\TestCase;
final class ModuleBootTest extends TestCase
{
public function test_module_configuration_is_available(): void
{
$this->assertSame('Tags', config('tags.name'));
}
}
+12
View File
@@ -0,0 +1,12 @@
{
"name": "Tags",
"alias": "tags",
"version": "1.0.0",
"description": "Tags publishing module.",
"keywords": ["publishing", "tags"],
"priority": 30,
"providers": ["Modules\\Tags\\Providers\\TagsServiceProvider"],
"aliases": {},
"files": [],
"requires": ["Core", "Administration"]
}
+3 -19
View File
@@ -1,21 +1,5 @@
{
"Stylist": true,
"Core": true,
"Administration": true,
"Users": true,
"Roles": true,
"Settings": true,
"Audit": true,
"Media": true,
"Seo": true,
"Search": true,
"Pages": true,
"Menus": true,
"Redirects": true,
"Forms": true,
"Documents": true,
"Banners": true,
"Faq": true,
"Team": true,
"Testimonials": true
"Stylist": true, "Core": true, "Administration": true, "Users": true, "Roles": true, "Settings": true, "Audit": true, "Media": true, "Seo": true, "Search": true,
"Pages": true, "Menus": true, "Redirects": true, "Forms": true, "Documents": true, "Banners": true, "Faq": true, "Team": true, "Testimonials": true,
"Categories": true, "Tags": true, "Authors": true, "Blog": true, "News": true, "Comments": true, "Feeds": true, "Sitemap": true
}
@@ -1,118 +1,35 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="dark">
<head>
@include('stylist-theme-common::partials.head')
</head>
<body class="min-h-screen bg-white dark:bg-zinc-800">
<flux:sidebar sticky collapsible="mobile" class="border-e border-zinc-200 bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-900">
<flux:sidebar.header>
<x-stylist-theme-common::app-logo :sidebar="true" href="{{ route('dashboard') }}" wire:navigate />
<flux:sidebar.collapse class="lg:hidden" />
</flux:sidebar.header>
<flux:sidebar.nav>
<flux:sidebar.group :heading="__('Content')" class="grid">
<flux:sidebar.item icon="document-text" :href="route('administration.pages.index')" :current="request()->routeIs('administration.pages.*')" wire:navigate>{{ __('Pages') }}</flux:sidebar.item>
<flux:sidebar.item icon="bars-3-bottom-left" :href="route('administration.menus.index')" :current="request()->routeIs('administration.menus.*')" wire:navigate>{{ __('Menus') }}</flux:sidebar.item>
<flux:sidebar.item icon="arrow-uturn-right" :href="route('administration.redirects.index')" :current="request()->routeIs('administration.redirects.*')" wire:navigate>{{ __('Redirects') }}</flux:sidebar.item>
<flux:sidebar.item icon="rectangle-stack" :href="route('administration.forms.index')" :current="request()->routeIs('administration.forms.*')" wire:navigate>{{ __('Forms') }}</flux:sidebar.item>
<flux:sidebar.item icon="folder" :href="route('administration.documents.index')" :current="request()->routeIs('administration.documents.*')" wire:navigate>{{ __('Documents') }}</flux:sidebar.item>
<flux:sidebar.item icon="megaphone" :href="route('administration.banners.index')" :current="request()->routeIs('administration.banners.*')" wire:navigate>{{ __('Banners') }}</flux:sidebar.item>
<flux:sidebar.item icon="question-mark-circle" :href="route('administration.faq.index')" :current="request()->routeIs('administration.faq.*')" wire:navigate>{{ __('FAQ') }}</flux:sidebar.item>
<flux:sidebar.item icon="user-group" :href="route('administration.team.index')" :current="request()->routeIs('administration.team.*')" wire:navigate>{{ __('Team') }}</flux:sidebar.item>
<flux:sidebar.item icon="chat-bubble-left-right" :href="route('administration.testimonials.index')" :current="request()->routeIs('administration.testimonials.*')" wire:navigate>{{ __('Testimonials') }}</flux:sidebar.item>
</flux:sidebar.group>
<flux:sidebar.group :heading="__('Platform')" class="grid">
<flux:sidebar.item icon="home" :href="route('dashboard')" :current="request()->routeIs('dashboard')" wire:navigate>{{ __('Dashboard') }}</flux:sidebar.item>
<flux:sidebar.item icon="squares-2x2" :href="route('administration.dashboard')" :current="request()->routeIs('administration.dashboard')" wire:navigate>{{ __('Administration') }}</flux:sidebar.item>
</flux:sidebar.group>
<flux:sidebar.group :heading="__('Management')" class="grid">
<flux:sidebar.item icon="users" :href="route('administration.users.index')" :current="request()->routeIs('administration.users.*')" wire:navigate>{{ __('Users') }}</flux:sidebar.item>
<flux:sidebar.item icon="shield-check" :href="route('administration.roles.index')" :current="request()->routeIs('administration.roles.*')" wire:navigate>{{ __('Roles & Permissions') }}</flux:sidebar.item>
<flux:sidebar.item icon="photo" :href="route('administration.media.index')" :current="request()->routeIs('administration.media.*')" wire:navigate>{{ __('Media') }}</flux:sidebar.item>
<flux:sidebar.item icon="magnifying-glass" :href="route('administration.search.index')" :current="request()->routeIs('administration.search.*')" wire:navigate>{{ __('Search') }}</flux:sidebar.item>
</flux:sidebar.group>
<flux:sidebar.group :heading="__('Configuration')" class="grid">
<flux:sidebar.item icon="globe-alt" :href="route('administration.seo.index')" :current="request()->routeIs('administration.seo.*')" wire:navigate>{{ __('SEO') }}</flux:sidebar.item>
<flux:sidebar.item icon="cog-6-tooth" :href="route('administration.settings.index')" :current="request()->routeIs('administration.settings.*')" wire:navigate>{{ __('Settings') }}</flux:sidebar.item>
<flux:sidebar.item icon="cube" :href="route('administration.modules.index')" :current="request()->routeIs('administration.modules.*')" wire:navigate>{{ __('Modules') }}</flux:sidebar.item>
<flux:sidebar.item icon="clipboard-document-list" :href="route('administration.audit.index')" :current="request()->routeIs('administration.audit.*')" wire:navigate>{{ __('Audit Log') }}</flux:sidebar.item>
</flux:sidebar.group>
</flux:sidebar.nav>
<flux:spacer />
<x-stylist-theme-common::desktop-user-menu class="hidden lg:block" :name="auth()->user()->name" />
</flux:sidebar>
<!-- Mobile User Menu -->
<flux:header class="lg:hidden">
<flux:sidebar.toggle class="lg:hidden" icon="bars-2" inset="left" />
<flux:spacer />
<flux:dropdown position="top" align="end">
<flux:profile
:initials="auth()->user()->initials()"
icon-trailing="chevron-down"
/>
<flux:menu>
<flux:menu.radio.group>
<div class="p-0 text-sm font-normal">
<div class="flex items-center gap-2 px-1 py-1.5 text-start text-sm">
<flux:avatar
:name="auth()->user()->name"
:initials="auth()->user()->initials()"
/>
<div class="grid flex-1 text-start text-sm leading-tight">
<flux:heading class="truncate">{{ auth()->user()->name }}</flux:heading>
<flux:text class="truncate">{{ auth()->user()->email }}</flux:text>
</div>
</div>
</div>
</flux:menu.radio.group>
<flux:menu.separator />
<flux:menu.radio.group>
<flux:menu.item :href="route('profile.edit')" icon="cog" wire:navigate>
{{ __('Settings') }}
</flux:menu.item>
</flux:menu.radio.group>
<flux:menu.separator />
<form method="POST" action="{{ route('logout') }}" class="w-full">
@csrf
<flux:menu.item
as="button"
type="submit"
icon="arrow-right-start-on-rectangle"
class="w-full cursor-pointer"
data-test="logout-button"
>
{{ __('Log out') }}
</flux:menu.item>
</form>
</flux:menu>
</flux:dropdown>
</flux:header>
{{ $slot }}
@persist('toast')
<flux:toast.group>
<flux:toast />
</flux:toast.group>
@endpersist
@fluxScripts
</body>
</html>
<head>@include('stylist-theme-common::partials.head')</head>
<body class="min-h-screen bg-white dark:bg-zinc-800">
<flux:sidebar sticky collapsible="mobile" class="border-e border-zinc-200 bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-900">
<flux:sidebar.header><x-stylist-theme-common::app-logo :sidebar="true" href="{{ route('dashboard') }}" wire:navigate /><flux:sidebar.collapse class="lg:hidden" /></flux:sidebar.header>
<flux:sidebar.nav>
<flux:sidebar.group :heading="__('Publishing')" class="grid">
<flux:sidebar.item icon="newspaper" :href="route('administration.blog.index')" :current="request()->routeIs('administration.blog.*')" wire:navigate>{{ __('Blog') }}</flux:sidebar.item>
<flux:sidebar.item icon="megaphone" :href="route('administration.news.index')" :current="request()->routeIs('administration.news.*')" wire:navigate>{{ __('News') }}</flux:sidebar.item>
<flux:sidebar.item icon="folder" :href="route('administration.categories.index')" :current="request()->routeIs('administration.categories.*')" wire:navigate>{{ __('Categories') }}</flux:sidebar.item>
<flux:sidebar.item icon="tag" :href="route('administration.tags.index')" :current="request()->routeIs('administration.tags.*')" wire:navigate>{{ __('Tags') }}</flux:sidebar.item>
<flux:sidebar.item icon="pencil-square" :href="route('administration.authors.index')" :current="request()->routeIs('administration.authors.*')" wire:navigate>{{ __('Authors') }}</flux:sidebar.item>
<flux:sidebar.item icon="chat-bubble-oval-left" :href="route('administration.comments.index')" :current="request()->routeIs('administration.comments.*')" wire:navigate>{{ __('Comments') }}</flux:sidebar.item>
</flux:sidebar.group>
<flux:sidebar.group :heading="__('Content')" class="grid">
<flux:sidebar.item icon="document-text" :href="route('administration.pages.index')" :current="request()->routeIs('administration.pages.*')" wire:navigate>{{ __('Pages') }}</flux:sidebar.item>
<flux:sidebar.item icon="bars-3-bottom-left" :href="route('administration.menus.index')" :current="request()->routeIs('administration.menus.*')" wire:navigate>{{ __('Menus') }}</flux:sidebar.item>
<flux:sidebar.item icon="arrow-uturn-right" :href="route('administration.redirects.index')" :current="request()->routeIs('administration.redirects.*')" wire:navigate>{{ __('Redirects') }}</flux:sidebar.item>
<flux:sidebar.item icon="rectangle-stack" :href="route('administration.forms.index')" :current="request()->routeIs('administration.forms.*')" wire:navigate>{{ __('Forms') }}</flux:sidebar.item>
<flux:sidebar.item icon="folder" :href="route('administration.documents.index')" :current="request()->routeIs('administration.documents.*')" wire:navigate>{{ __('Documents') }}</flux:sidebar.item>
<flux:sidebar.item icon="megaphone" :href="route('administration.banners.index')" :current="request()->routeIs('administration.banners.*')" wire:navigate>{{ __('Banners') }}</flux:sidebar.item>
<flux:sidebar.item icon="question-mark-circle" :href="route('administration.faq.index')" :current="request()->routeIs('administration.faq.*')" wire:navigate>{{ __('FAQ') }}</flux:sidebar.item>
<flux:sidebar.item icon="user-group" :href="route('administration.team.index')" :current="request()->routeIs('administration.team.*')" wire:navigate>{{ __('Team') }}</flux:sidebar.item>
<flux:sidebar.item icon="chat-bubble-left-right" :href="route('administration.testimonials.index')" :current="request()->routeIs('administration.testimonials.*')" wire:navigate>{{ __('Testimonials') }}</flux:sidebar.item>
</flux:sidebar.group>
<flux:sidebar.group :heading="__('Platform')" class="grid"><flux:sidebar.item icon="home" :href="route('dashboard')" :current="request()->routeIs('dashboard')" wire:navigate>{{ __('Dashboard') }}</flux:sidebar.item><flux:sidebar.item icon="squares-2x2" :href="route('administration.dashboard')" :current="request()->routeIs('administration.dashboard')" wire:navigate>{{ __('Administration') }}</flux:sidebar.item></flux:sidebar.group>
<flux:sidebar.group :heading="__('Management')" class="grid"><flux:sidebar.item icon="users" :href="route('administration.users.index')" :current="request()->routeIs('administration.users.*')" wire:navigate>{{ __('Users') }}</flux:sidebar.item><flux:sidebar.item icon="shield-check" :href="route('administration.roles.index')" :current="request()->routeIs('administration.roles.*')" wire:navigate>{{ __('Roles & Permissions') }}</flux:sidebar.item><flux:sidebar.item icon="photo" :href="route('administration.media.index')" :current="request()->routeIs('administration.media.*')" wire:navigate>{{ __('Media') }}</flux:sidebar.item><flux:sidebar.item icon="magnifying-glass" :href="route('administration.search.index')" :current="request()->routeIs('administration.search.*')" wire:navigate>{{ __('Search') }}</flux:sidebar.item></flux:sidebar.group>
<flux:sidebar.group :heading="__('Configuration')" class="grid"><flux:sidebar.item icon="globe-alt" :href="route('administration.seo.index')" :current="request()->routeIs('administration.seo.*')" wire:navigate>{{ __('SEO') }}</flux:sidebar.item><flux:sidebar.item icon="cog-6-tooth" :href="route('administration.settings.index')" :current="request()->routeIs('administration.settings.*')" wire:navigate>{{ __('Settings') }}</flux:sidebar.item><flux:sidebar.item icon="cube" :href="route('administration.modules.index')" :current="request()->routeIs('administration.modules.*')" wire:navigate>{{ __('Modules') }}</flux:sidebar.item><flux:sidebar.item icon="clipboard-document-list" :href="route('administration.audit.index')" :current="request()->routeIs('administration.audit.*')" wire:navigate>{{ __('Audit Log') }}</flux:sidebar.item></flux:sidebar.group>
</flux:sidebar.nav><flux:spacer /><x-stylist-theme-common::desktop-user-menu class="hidden lg:block" :name="auth()->user()->name" /></flux:sidebar>
<flux:header class="lg:hidden"><flux:sidebar.toggle class="lg:hidden" icon="bars-2" inset="left" /><flux:spacer /><flux:dropdown position="top" align="end"><flux:profile :initials="auth()->user()->initials()" icon-trailing="chevron-down" /><flux:menu><flux:menu.radio.group><div class="p-0 text-sm font-normal"><div class="flex items-center gap-2 px-1 py-1.5 text-start text-sm"><flux:avatar :name="auth()->user()->name" :initials="auth()->user()->initials()" /><div class="grid flex-1 text-start text-sm leading-tight"><flux:heading class="truncate">{{ auth()->user()->name }}</flux:heading><flux:text class="truncate">{{ auth()->user()->email }}</flux:text></div></div></div></flux:menu.radio.group><flux:menu.separator /><flux:menu.radio.group><flux:menu.item :href="route('profile.edit')" icon="cog" wire:navigate>{{ __('Settings') }}</flux:menu.item></flux:menu.radio.group><flux:menu.separator /><form method="POST" action="{{ route('logout') }}" class="w-full">@csrf<flux:menu.item as="button" type="submit" icon="arrow-right-start-on-rectangle" class="w-full cursor-pointer" data-test="logout-button">{{ __('Log out') }}</flux:menu.item></form></flux:menu></flux:dropdown></flux:header>
{{ $slot }}
@persist('toast')<flux:toast.group><flux:toast /></flux:toast.group>@endpersist
@fluxScripts
</body></html>