Add blog feature and fix broken test/build infra

BlogController was an empty stub with no view, so /blog 500'd on every
visit. Adds a real Post model, public listing/detail pages, and an admin
CRUD screen mirroring the existing Exercise admin pattern (image upload
or URL, auto-slug, publish toggle). Seeds 5 real posts so it's not empty
on launch.

Also fixes two things that were silently broken: phpunit.xml pointed
DB_DATABASE at a non-existent "testing" file with no DB_CONNECTION set,
and the stock ExampleTest had RefreshDatabase commented out - both meant
the test suite never actually ran successfully.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-10 11:36:31 +03:00
co-authored by Claude Sonnet 5
parent beeda8139b
commit 4686dea609
21 changed files with 697 additions and 12 deletions
@@ -0,0 +1,126 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Post;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Str;
use Illuminate\View\View;
class PostController extends Controller
{
public function index(Request $request): View
{
$q = trim((string) $request->query('q', ''));
$posts = Post::query()
->when($q !== '', function ($qb) use ($q) {
$qb->where('title', 'like', "%{$q}%")
->orWhere('slug', 'like', "%{$q}%");
})
->latest('created_at')
->paginate(20)
->withQueryString();
return view('admin.posts.index', compact('posts', 'q'));
}
public function create(): View
{
return view('admin.posts.create');
}
public function store(Request $request): RedirectResponse
{
$data = $this->validateData($request);
$slug = trim((string) ($data['slug'] ?? ''));
if ($slug === '') {
$slug = $this->uniqueSlug($data['title']);
}
$coverImageValue = $data['cover_image'] ?? null;
if ($request->hasFile('cover_image_file')) {
$coverImageValue = $request->file('cover_image_file')->store('posts/covers', 'public');
}
Post::create([
'title' => $data['title'],
'slug' => $slug,
'excerpt' => $data['excerpt'] ?? null,
'body' => $data['body'],
'cover_image' => $coverImageValue,
'author_id' => Auth::id(),
'published_at' => $request->boolean('published') ? now() : null,
]);
return redirect()->route('admin.posts.index')->with('status', 'Post created');
}
public function edit(Post $post): View
{
return view('admin.posts.edit', compact('post'));
}
public function update(Request $request, Post $post): RedirectResponse
{
$data = $this->validateData($request, $post->id);
$slug = trim((string) ($data['slug'] ?? ''));
if ($slug === '') {
$slug = $this->uniqueSlug($data['title'], $post->id);
}
$coverImageValue = $data['cover_image'] ?? $post->cover_image;
if ($request->hasFile('cover_image_file')) {
$coverImageValue = $request->file('cover_image_file')->store('posts/covers', 'public');
}
$post->update([
'title' => $data['title'],
'slug' => $slug,
'excerpt' => $data['excerpt'] ?? null,
'body' => $data['body'],
'cover_image' => $coverImageValue,
'published_at' => $request->boolean('published') ? ($post->published_at ?? now()) : null,
]);
return redirect()->route('admin.posts.index')->with('status', 'Post updated');
}
public function destroy(Post $post): RedirectResponse
{
$post->delete();
return redirect()->route('admin.posts.index')->with('status', 'Post deleted');
}
protected function validateData(Request $request, ?int $ignoreId = null): array
{
return $request->validate([
'title' => ['required', 'string', 'max:255'],
'slug' => ['nullable', 'string', 'max:255', 'unique:posts,slug' . ($ignoreId ? ',' . $ignoreId : '')],
'excerpt' => ['nullable', 'string', 'max:500'],
'body' => ['required', 'string'],
'cover_image' => ['nullable', 'string', 'max:2048'],
'cover_image_file' => ['nullable', 'image', 'mimes:jpeg,jpg,png,webp,avif,gif', 'max:8192'],
'published' => ['nullable', 'boolean'],
]);
}
protected function uniqueSlug(string $title, ?int $ignoreId = null): string
{
$base = Str::slug($title);
if ($base === '') { $base = 'post'; }
$slug = $base;
$i = 1;
while (Post::where('slug', $slug)
->when($ignoreId, fn($q) => $q->where('id', '!=', $ignoreId))
->exists()) {
$slug = $base . '-' . (++$i);
}
return $slug;
}
}
@@ -3,6 +3,7 @@
namespace App\Http\Controllers;
use App\Models\Exercise;
use App\Models\Post;
use App\Models\User;
use App\Models\Workout;
use App\Models\WorkoutSession;
@@ -19,6 +20,7 @@ class AdminDashboardController extends Controller
'sessions' => WorkoutSession::count(),
'exercises' => Exercise::count(),
'yogaPoses' => YogaPose::count(),
'posts' => Post::count(),
];
$recentUsers = User::latest()->limit(5)->get(['id','name','email','created_at']);
+40 -1
View File
@@ -2,9 +2,48 @@
namespace App\Http\Controllers;
use App\Models\Post;
use Illuminate\Http\Request;
use Illuminate\View\View;
class BlogController extends Controller
{
//
public function index(Request $request): View
{
$q = trim((string) $request->query('q', ''));
$posts = Post::query()
->published()
->with('author')
->when($q !== '', function ($qb) use ($q) {
$qb->where(function ($sub) use ($q) {
$sub->where('title', 'like', "%{$q}%")
->orWhere('excerpt', 'like', "%{$q}%");
});
})
->latest('published_at')
->paginate(9)
->withQueryString();
return view('blog.index', [
'posts' => $posts,
'filters' => ['q' => $q],
]);
}
public function show(Post $post): View
{
abort_unless($post->is_published, 404);
$post->load('author');
$relatedPosts = Post::query()
->published()
->where('id', '!=', $post->id)
->latest('published_at')
->limit(3)
->get();
return view('blog.show', compact('post', 'relatedPosts'));
}
}