From 4686dea609930c2ad98310583335c6ee647c16d9 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 10 Aug 2026 11:36:31 +0300 Subject: [PATCH] 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 --- app/Http/Controllers/Admin/PostController.php | 126 ++++++++++++++++++ .../Controllers/AdminDashboardController.php | 2 + app/Http/Controllers/BlogController.php | 41 +++++- app/Models/Post.php | 57 ++++++++ database/factories/PostFactory.php | 35 +++++ .../2026_08_10_082732_create_posts_table.php | 36 +++++ database/seeders/DatabaseSeeder.php | 6 +- database/seeders/PostSeeder.php | 115 ++++++++++++++++ lang/en/app.php | 1 + lang/ro/app.php | 1 + package-lock.json | 8 +- phpunit.xml | 3 +- resources/views/admin/dashboard.blade.php | 7 +- resources/views/admin/posts/_form.blade.php | 60 +++++++++ resources/views/admin/posts/create.blade.php | 23 ++++ resources/views/admin/posts/edit.blade.php | 24 ++++ resources/views/admin/posts/index.blade.php | 74 ++++++++++ resources/views/blog/index.blade.php | 43 ++++++ resources/views/blog/show.blade.php | 40 ++++++ routes/web.php | 3 + tests/Feature/ExampleTest.php | 4 +- 21 files changed, 697 insertions(+), 12 deletions(-) create mode 100644 app/Http/Controllers/Admin/PostController.php create mode 100644 app/Models/Post.php create mode 100644 database/factories/PostFactory.php create mode 100644 database/migrations/2026_08_10_082732_create_posts_table.php create mode 100644 database/seeders/PostSeeder.php create mode 100644 resources/views/admin/posts/_form.blade.php create mode 100644 resources/views/admin/posts/create.blade.php create mode 100644 resources/views/admin/posts/edit.blade.php create mode 100644 resources/views/admin/posts/index.blade.php create mode 100644 resources/views/blog/index.blade.php create mode 100644 resources/views/blog/show.blade.php diff --git a/app/Http/Controllers/Admin/PostController.php b/app/Http/Controllers/Admin/PostController.php new file mode 100644 index 0000000..18ea275 --- /dev/null +++ b/app/Http/Controllers/Admin/PostController.php @@ -0,0 +1,126 @@ +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; + } +} diff --git a/app/Http/Controllers/AdminDashboardController.php b/app/Http/Controllers/AdminDashboardController.php index c8e0e70..0f10029 100644 --- a/app/Http/Controllers/AdminDashboardController.php +++ b/app/Http/Controllers/AdminDashboardController.php @@ -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']); diff --git a/app/Http/Controllers/BlogController.php b/app/Http/Controllers/BlogController.php index 29b7c33..1e100f2 100644 --- a/app/Http/Controllers/BlogController.php +++ b/app/Http/Controllers/BlogController.php @@ -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')); + } } diff --git a/app/Models/Post.php b/app/Models/Post.php new file mode 100644 index 0000000..8c83000 --- /dev/null +++ b/app/Models/Post.php @@ -0,0 +1,57 @@ + 'datetime', + ]; + + public function getRouteKeyName(): string + { + return 'slug'; + } + + public function author(): BelongsTo + { + return $this->belongsTo(User::class, 'author_id'); + } + + public function scopePublished(Builder $query): Builder + { + return $query->whereNotNull('published_at')->where('published_at', '<=', now()); + } + + public function getIsPublishedAttribute(): bool + { + return $this->published_at !== null && $this->published_at->lte(now()); + } + + public function getCoverUrlAttribute(): ?string + { + $cover = (string) ($this->attributes['cover_image'] ?? ''); + if ($cover === '') { + return null; + } + if (preg_match('/^https?:\/\//i', $cover)) { + return $cover; + } + return Storage::url($cover); + } +} diff --git a/database/factories/PostFactory.php b/database/factories/PostFactory.php new file mode 100644 index 0000000..49a3d17 --- /dev/null +++ b/database/factories/PostFactory.php @@ -0,0 +1,35 @@ + + */ +class PostFactory extends Factory +{ + public function definition(): array + { + $title = rtrim(fake()->sentence(rand(4, 8)), '.'); + + return [ + 'title' => $title, + 'slug' => Str::slug($title) . '-' . fake()->unique()->numberBetween(1, 100000), + 'excerpt' => fake()->sentence(rand(15, 25)), + 'body' => implode("\n\n", fake()->paragraphs(rand(4, 7))), + 'cover_image' => null, + 'author_id' => User::query()->inRandomOrder()->value('id'), + 'published_at' => now()->subDays(fake()->numberBetween(0, 60)), + ]; + } + + public function draft(): static + { + return $this->state(fn (array $attributes) => [ + 'published_at' => null, + ]); + } +} diff --git a/database/migrations/2026_08_10_082732_create_posts_table.php b/database/migrations/2026_08_10_082732_create_posts_table.php new file mode 100644 index 0000000..1500f39 --- /dev/null +++ b/database/migrations/2026_08_10_082732_create_posts_table.php @@ -0,0 +1,36 @@ +id(); + $table->string('title'); + $table->string('slug')->unique(); + $table->string('excerpt', 500)->nullable(); + $table->text('body'); + $table->string('cover_image')->nullable(); + $table->foreignId('author_id')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamp('published_at')->nullable(); + $table->timestamps(); + + $table->index('published_at'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('posts'); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index d01a0ef..a6af2ae 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -15,9 +15,13 @@ class DatabaseSeeder extends Seeder { // User::factory(10)->create(); - User::factory()->create([ + $admin = User::factory()->create([ 'name' => 'Test User', 'email' => 'test@example.com', ]); + $admin->is_admin = true; + $admin->save(); + + $this->call(PostSeeder::class); } } diff --git a/database/seeders/PostSeeder.php b/database/seeders/PostSeeder.php new file mode 100644 index 0000000..83dfa64 --- /dev/null +++ b/database/seeders/PostSeeder.php @@ -0,0 +1,115 @@ +where('is_admin', true)->value('id') + ?? User::query()->value('id'); + + $posts = [ + [ + 'title' => 'Progressive Overload 101: How to Actually Get Stronger', + 'excerpt' => 'Lifting the same weight for the same reps every week is the most common reason strength progress stalls. Here is how progressive overload fixes that.', + 'body' => <<<'MD' +If you have been training for a while but your lifts have not moved in months, the problem is usually not effort. It is that your body has nothing new to adapt to. + +Progressive overload just means gradually increasing the demand you place on your muscles over time, whether that is more weight, more reps, more sets, or better technique that lets you work harder safely. + +Start small. Adding 2.5kg to a lift once it feels comfortable for all your sets is enough. You do not need to chase a new personal record every session. + +Track your workouts. It is easy to think you are progressing when you are not. Logging sets, reps, and weight (which is exactly what the Workouts feature here is for) removes the guesswork. + +Rotate the variable you push. Some weeks add weight, other weeks add a rep, other weeks slow the tempo down. Your joints will thank you for not always maxing out load. + +Recovery is part of the plan, not a break from it. Muscles grow during rest, not during the set. If you are not progressing, check your sleep and food before you check your program. +MD, + 'published_at' => now()->subDays(21), + ], + [ + 'title' => 'A Beginner’s Guide to Building Your First Weekly Workout Split', + 'excerpt' => 'Not sure how to structure your week? Here is a simple, sustainable approach for people just getting started.', + 'body' => <<<'MD' +The biggest mistake beginners make is copying an advanced athlete's six-day split in week one. You will burn out before you build the habit. + +For most beginners, three full-body sessions per week, with a rest day between each, is plenty. It hits every major muscle group often enough to drive progress without wrecking your recovery. + +A simple full-body session might include one push movement, one pull movement, one squat or hinge pattern, and one core exercise. That is it. You can build a workout like this in a couple of minutes using the workout builder. + +As you get more consistent, usually after eight to twelve weeks, you can start splitting sessions further, for example upper/lower, if your schedule and recovery support the extra volume. + +Consistency beats complexity every time. A simple plan you actually follow for three months will outperform a perfect plan you quit after two weeks. +MD, + 'published_at' => now()->subDays(14), + ], + [ + 'title' => 'Why Yoga Belongs in Every Training Program', + 'excerpt' => 'Yoga is not just for flexibility. Here is what it actually does for people who lift, run, or play sport.', + 'body' => <<<'MD' +Yoga tends to get filed under "recovery day filler," but the benefits go well beyond stretching. + +Mobility work through yoga poses improves the range of motion you can access under load, which means better depth on squats and safer overhead positions. + +Balance and single-leg poses build the small stabilizer muscles that heavy compound lifts do not always train directly, which helps prevent injuries. + +Controlled breathing during yoga carries over directly into bracing for heavy lifts and staying calm under fatigue in endurance sports. + +You do not need an hour-long session. Ten to fifteen minutes a couple of times a week, browsed right from the yoga library here, is enough to notice a difference within a month. +MD, + 'published_at' => now()->subDays(7), + ], + [ + 'title' => 'How Much Protein Do You Actually Need?', + 'excerpt' => 'Protein advice online ranges from "barely matters" to "eat a chicken breast every two hours." Here is what the evidence actually supports.', + 'body' => <<<'MD' +For most people training regularly, somewhere between 1.6 and 2.2 grams of protein per kilogram of bodyweight per day covers the range where muscle growth and recovery are well supported. + +Above that range, more protein does not appear to meaningfully speed up progress for most people. It is not harmful in normal amounts, it is just not doing extra work once you are already in the effective range. + +Spreading protein across three to four meals a day tends to be easier to hit consistently than trying to eat it all in one sitting, though total daily intake matters more than exact timing. + +Whole food sources are worth prioritizing, but if you are short on a busy day, a protein shake closing the gap is completely fine. + +The best protein target is the one you can hit consistently. A slightly lower number you actually eat every day beats a perfect number you hit twice a week. +MD, + 'published_at' => now()->subDays(2), + ], + [ + 'title' => 'Rest Days Are Not Optional: The Case for Recovery', + 'excerpt' => 'Training harder is not always the answer. Sometimes the fastest way to progress is a well-placed day off.', + 'body' => <<<'MD' +It is tempting to think more training always equals more progress, but the adaptation that makes you stronger, fitter, or faster happens during recovery, not during the workout itself. + +Sleep is the single highest-leverage recovery tool available, and it is free. Poor sleep reduces strength output, increases injury risk, and blunts the muscle-building response to training. + +Rest days do not have to mean total inactivity. A walk, light mobility work, or an easy yoga session keeps blood flowing without adding fatigue. + +Watch for warning signs of under-recovery: workouts that feel harder than they should, irritability, poor sleep, or a resting heart rate that is creeping up. These are signals to back off, not push through. + +Plan rest days into your week in advance instead of only taking one when you are too exhausted to train. A planned deload is far more productive than a forced one. +MD, + 'published_at' => now()->subDay(), + ], + ]; + + foreach ($posts as $post) { + Post::query()->updateOrCreate( + ['slug' => Str::slug($post['title'])], + [ + 'title' => $post['title'], + 'excerpt' => $post['excerpt'], + 'body' => $post['body'], + 'author_id' => $authorId, + 'published_at' => $post['published_at'], + ] + ); + } + } +} diff --git a/lang/en/app.php b/lang/en/app.php index fbf2284..6f1926f 100644 --- a/lang/en/app.php +++ b/lang/en/app.php @@ -246,6 +246,7 @@ return [ 'sessions' => 'Sessions', 'exercises' => 'Exercises', 'yoga_poses' => 'Yoga poses', + 'posts' => 'Blog posts', ], 'recent_users' => 'Recent users', 'recent_workouts' => 'Recent workouts', diff --git a/lang/ro/app.php b/lang/ro/app.php index e3932fb..8a31e49 100644 --- a/lang/ro/app.php +++ b/lang/ro/app.php @@ -246,6 +246,7 @@ return [ 'sessions' => 'Sesiuni', 'exercises' => 'Exerciții', 'yoga_poses' => 'Poziții yoga', + 'posts' => 'Articole blog', ], 'recent_users' => 'Utilizatori recenți', 'recent_workouts' => 'Antrenamente recente', diff --git a/package-lock.json b/package-lock.json index 4379bbd..2961063 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,5 +1,5 @@ { - "name": "html", + "name": "fitness", "lockfileVersion": 3, "requires": true, "packages": { @@ -1435,7 +1435,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.9", "caniuse-lite": "^1.0.30001746", @@ -2920,7 +2919,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -3454,7 +3452,6 @@ "integrity": "sha512-6A2rnmW5xZMdw11LYjhcI5846rt9pbLSabY5XPxo+XWdxwZaFEn47Go4NzFiHu9sNNmr/kXivP1vStfvMaK1GQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", @@ -3592,7 +3589,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -3681,7 +3677,6 @@ "integrity": "sha512-VbA8ScMvAISJNJVbRDTJdCwqQoAareR/wutevKanhR2/1EkoXVZVkkORaYm/tNVCjP/UDTKtcw3bAkwOUdedmA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -3786,7 +3781,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, diff --git a/phpunit.xml b/phpunit.xml index 6b25d0d..5fd5bcf 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -22,7 +22,8 @@ - + + diff --git a/resources/views/admin/dashboard.blade.php b/resources/views/admin/dashboard.blade.php index c3e12ae..e7b2d86 100644 --- a/resources/views/admin/dashboard.blade.php +++ b/resources/views/admin/dashboard.blade.php @@ -11,7 +11,7 @@
-
+
{{ __('app.admin.metrics.users') }}
{{ $metrics['users'] }}
@@ -32,6 +32,10 @@
{{ __('app.admin.metrics.yoga_poses') }}
{{ $metrics['yogaPoses'] }}
+
+
{{ __('app.admin.metrics.posts') }}
+
{{ $metrics['posts'] }}
+
diff --git a/resources/views/admin/posts/_form.blade.php b/resources/views/admin/posts/_form.blade.php new file mode 100644 index 0000000..78e588c --- /dev/null +++ b/resources/views/admin/posts/_form.blade.php @@ -0,0 +1,60 @@ +@csrf +
+
+ + + +
+
+ + +

Leave empty to auto-generate from the title.

+ +
+
+ + +

Short summary shown on the blog listing page.

+ +
+
+ +
+
+ +

Upload a new image (JPG, PNG, WebP, up to 8MB). If provided, it will override the URL below.

+ +
+
+ + + +
+
+ @php + $cover = old('cover_image', $post->cover_image ?? ''); + $isHttp = is_string($cover) && preg_match('/^https?:\/\//i', $cover); + $src = $isHttp ? $cover : ($cover ? \Illuminate\Support\Facades\Storage::url($cover) : ''); + @endphp + @if(!empty($src)) +
+ Cover preview +
+ @endif +
+
+ + +

Plain text, one paragraph per line.

+ +
+
+ is_published ?? false)) class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"> + + +
+
+
+ Cancel + +
diff --git a/resources/views/admin/posts/create.blade.php b/resources/views/admin/posts/create.blade.php new file mode 100644 index 0000000..fb51473 --- /dev/null +++ b/resources/views/admin/posts/create.blade.php @@ -0,0 +1,23 @@ + +
+
+

New Post

+

Write a new blog post.

+
+
+ +
+ @if ($errors->any()) +
+
    + @foreach ($errors->all() as $error) +
  • {{ $error }}
  • + @endforeach +
+
+ @endif +
+ @include('admin.posts._form') +
+
+
diff --git a/resources/views/admin/posts/edit.blade.php b/resources/views/admin/posts/edit.blade.php new file mode 100644 index 0000000..2a91812 --- /dev/null +++ b/resources/views/admin/posts/edit.blade.php @@ -0,0 +1,24 @@ + +
+
+

Edit Post

+

Update post details.

+
+
+ +
+ @if ($errors->any()) +
+
    + @foreach ($errors->all() as $error) +
  • {{ $error }}
  • + @endforeach +
+
+ @endif +
+ @method('PATCH') + @include('admin.posts._form') +
+
+
diff --git a/resources/views/admin/posts/index.blade.php b/resources/views/admin/posts/index.blade.php new file mode 100644 index 0000000..9f6d10c --- /dev/null +++ b/resources/views/admin/posts/index.blade.php @@ -0,0 +1,74 @@ + +
+
+
+
+

Manage Posts

+

Write, edit, and publish blog posts.

+
+ New Post +
+
+
+ +
+ @if(session('status')) +
{{ session('status') }}
+ @endif + +
+
+ + +
+
+ +
+
+ +
+
+ + + + + + + + + + + + @forelse($posts as $post) + + + + + + + + @empty + + @endforelse + +
TitleSlugStatusAuthorActions
{{ $post->title }}{{ $post->slug }} + @if($post->is_published) + Published + @else + Draft + @endif + {{ optional($post->author)->name ?? '—' }} + Edit +
+ @csrf + @method('DELETE') + +
+
No posts found.
+
+
+ {{ $posts->links() }} +
+
+
+
diff --git a/resources/views/blog/index.blade.php b/resources/views/blog/index.blade.php new file mode 100644 index 0000000..ffa6fb2 --- /dev/null +++ b/resources/views/blog/index.blade.php @@ -0,0 +1,43 @@ + +
+
+
+

{{ __('app.nav.blog') }}

+

Tips, guides, and news from the FitAtlas team.

+
+
+
+ +
+
+
+ + +
+ +
+ + @if($posts->count() === 0) +
No posts yet. Check back soon.
+ @else + + +
{{ $posts->links() }}
+ @endif +
+
diff --git a/resources/views/blog/show.blade.php b/resources/views/blog/show.blade.php new file mode 100644 index 0000000..9e06358 --- /dev/null +++ b/resources/views/blog/show.blade.php @@ -0,0 +1,40 @@ + +
+
+ ← Back to blog +

{{ $post->title }}

+
{{ $post->published_at?->format('M j, Y') }}@if($post->author) · {{ $post->author->name }} @endif
+
+
+ +
+ @if($post->cover_url) + {{ $post->title }} + @endif + +
+ @php $body = trim((string) $post->body); @endphp + @foreach(preg_split('/\r?\n+/', $body) as $line) + @if(trim($line) !== '')

{{ $line }}

@endif + @endforeach +
+ + @if($relatedPosts->count()) +
+

More posts

+
+ @foreach($relatedPosts as $related) + +
+ {{ $related->title }} +
+
+
{{ $related->title }}
+
+
+ @endforeach +
+
+ @endif +
+
diff --git a/routes/web.php b/routes/web.php index a412394..8c7bee5 100644 --- a/routes/web.php +++ b/routes/web.php @@ -14,6 +14,8 @@ Route::prefix('admin')->middleware(['auth','admin'])->group(function(){ Route::get('/', [\App\Http\Controllers\AdminDashboardController::class, 'index'])->name('admin.dashboard'); // Admin Exercises CRUD Route::resource('exercises', \App\Http\Controllers\Admin\ExerciseController::class)->except(['show'])->names('admin.exercises'); + // Admin Posts CRUD + Route::resource('posts', \App\Http\Controllers\Admin\PostController::class)->except(['show'])->names('admin.posts'); }); Route::middleware('auth')->group(function () { @@ -60,6 +62,7 @@ Route::middleware('auth')->group(function () { Route::prefix('/blog')->group(function () { Route::get('/', [\App\Http\Controllers\BlogController::class, 'index'])->name('blog.index'); + Route::get('/{post:slug}', [\App\Http\Controllers\BlogController::class, 'show'])->name('blog.show'); }); // Yoga poses diff --git a/tests/Feature/ExampleTest.php b/tests/Feature/ExampleTest.php index 8364a84..22ef0c5 100644 --- a/tests/Feature/ExampleTest.php +++ b/tests/Feature/ExampleTest.php @@ -2,11 +2,13 @@ namespace Tests\Feature; -// use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Foundation\Testing\RefreshDatabase; use Tests\TestCase; class ExampleTest extends TestCase { + use RefreshDatabase; + /** * A basic test example. */