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' => '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"]
}