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:
@@ -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;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
use App\Models\Exercise;
|
use App\Models\Exercise;
|
||||||
|
use App\Models\Post;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\Workout;
|
use App\Models\Workout;
|
||||||
use App\Models\WorkoutSession;
|
use App\Models\WorkoutSession;
|
||||||
@@ -19,6 +20,7 @@ class AdminDashboardController extends Controller
|
|||||||
'sessions' => WorkoutSession::count(),
|
'sessions' => WorkoutSession::count(),
|
||||||
'exercises' => Exercise::count(),
|
'exercises' => Exercise::count(),
|
||||||
'yogaPoses' => YogaPose::count(),
|
'yogaPoses' => YogaPose::count(),
|
||||||
|
'posts' => Post::count(),
|
||||||
];
|
];
|
||||||
|
|
||||||
$recentUsers = User::latest()->limit(5)->get(['id','name','email','created_at']);
|
$recentUsers = User::latest()->limit(5)->get(['id','name','email','created_at']);
|
||||||
|
|||||||
@@ -2,9 +2,48 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\Post;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
|
||||||
class BlogController extends Controller
|
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'));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
|
class Post extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'title',
|
||||||
|
'slug',
|
||||||
|
'excerpt',
|
||||||
|
'body',
|
||||||
|
'cover_image',
|
||||||
|
'author_id',
|
||||||
|
'published_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'published_at' => '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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Post>
|
||||||
|
*/
|
||||||
|
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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('posts', function (Blueprint $table) {
|
||||||
|
$table->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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -15,9 +15,13 @@ class DatabaseSeeder extends Seeder
|
|||||||
{
|
{
|
||||||
// User::factory(10)->create();
|
// User::factory(10)->create();
|
||||||
|
|
||||||
User::factory()->create([
|
$admin = User::factory()->create([
|
||||||
'name' => 'Test User',
|
'name' => 'Test User',
|
||||||
'email' => 'test@example.com',
|
'email' => 'test@example.com',
|
||||||
]);
|
]);
|
||||||
|
$admin->is_admin = true;
|
||||||
|
$admin->save();
|
||||||
|
|
||||||
|
$this->call(PostSeeder::class);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Seeders;
|
||||||
|
|
||||||
|
use App\Models\Post;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Database\Seeder;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class PostSeeder extends Seeder
|
||||||
|
{
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
$authorId = User::query()->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'],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -246,6 +246,7 @@ return [
|
|||||||
'sessions' => 'Sessions',
|
'sessions' => 'Sessions',
|
||||||
'exercises' => 'Exercises',
|
'exercises' => 'Exercises',
|
||||||
'yoga_poses' => 'Yoga poses',
|
'yoga_poses' => 'Yoga poses',
|
||||||
|
'posts' => 'Blog posts',
|
||||||
],
|
],
|
||||||
'recent_users' => 'Recent users',
|
'recent_users' => 'Recent users',
|
||||||
'recent_workouts' => 'Recent workouts',
|
'recent_workouts' => 'Recent workouts',
|
||||||
|
|||||||
@@ -246,6 +246,7 @@ return [
|
|||||||
'sessions' => 'Sesiuni',
|
'sessions' => 'Sesiuni',
|
||||||
'exercises' => 'Exerciții',
|
'exercises' => 'Exerciții',
|
||||||
'yoga_poses' => 'Poziții yoga',
|
'yoga_poses' => 'Poziții yoga',
|
||||||
|
'posts' => 'Articole blog',
|
||||||
],
|
],
|
||||||
'recent_users' => 'Utilizatori recenți',
|
'recent_users' => 'Utilizatori recenți',
|
||||||
'recent_workouts' => 'Antrenamente recente',
|
'recent_workouts' => 'Antrenamente recente',
|
||||||
|
|||||||
Generated
+1
-7
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "html",
|
"name": "fitness",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
@@ -1435,7 +1435,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.8.9",
|
"baseline-browser-mapping": "^2.8.9",
|
||||||
"caniuse-lite": "^1.0.30001746",
|
"caniuse-lite": "^1.0.30001746",
|
||||||
@@ -2920,7 +2919,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.11",
|
"nanoid": "^3.3.11",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
@@ -3454,7 +3452,6 @@
|
|||||||
"integrity": "sha512-6A2rnmW5xZMdw11LYjhcI5846rt9pbLSabY5XPxo+XWdxwZaFEn47Go4NzFiHu9sNNmr/kXivP1vStfvMaK1GQ==",
|
"integrity": "sha512-6A2rnmW5xZMdw11LYjhcI5846rt9pbLSabY5XPxo+XWdxwZaFEn47Go4NzFiHu9sNNmr/kXivP1vStfvMaK1GQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@alloc/quick-lru": "^5.2.0",
|
"@alloc/quick-lru": "^5.2.0",
|
||||||
"arg": "^5.0.2",
|
"arg": "^5.0.2",
|
||||||
@@ -3592,7 +3589,6 @@
|
|||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -3681,7 +3677,6 @@
|
|||||||
"integrity": "sha512-VbA8ScMvAISJNJVbRDTJdCwqQoAareR/wutevKanhR2/1EkoXVZVkkORaYm/tNVCjP/UDTKtcw3bAkwOUdedmA==",
|
"integrity": "sha512-VbA8ScMvAISJNJVbRDTJdCwqQoAareR/wutevKanhR2/1EkoXVZVkkORaYm/tNVCjP/UDTKtcw3bAkwOUdedmA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.25.0",
|
"esbuild": "^0.25.0",
|
||||||
"fdir": "^6.5.0",
|
"fdir": "^6.5.0",
|
||||||
@@ -3786,7 +3781,6 @@
|
|||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
|
|||||||
+2
-1
@@ -22,7 +22,8 @@
|
|||||||
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
|
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
|
||||||
<env name="BCRYPT_ROUNDS" value="4"/>
|
<env name="BCRYPT_ROUNDS" value="4"/>
|
||||||
<env name="CACHE_STORE" value="array"/>
|
<env name="CACHE_STORE" value="array"/>
|
||||||
<env name="DB_DATABASE" value="testing"/>
|
<env name="DB_CONNECTION" value="sqlite"/>
|
||||||
|
<env name="DB_DATABASE" value=":memory:"/>
|
||||||
<env name="MAIL_MAILER" value="array"/>
|
<env name="MAIL_MAILER" value="array"/>
|
||||||
<env name="QUEUE_CONNECTION" value="sync"/>
|
<env name="QUEUE_CONNECTION" value="sync"/>
|
||||||
<env name="SESSION_DRIVER" value="array"/>
|
<env name="SESSION_DRIVER" value="array"/>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-6">
|
<section class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-6">
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-6">
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-6 gap-6">
|
||||||
<div class="rounded-2xl ring-1 ring-gray-200 dark:ring-gray-700 bg-white dark:bg-gray-800 p-5">
|
<div class="rounded-2xl ring-1 ring-gray-200 dark:ring-gray-700 bg-white dark:bg-gray-800 p-5">
|
||||||
<div class="text-sm text-gray-500">{{ __('app.admin.metrics.users') }}</div>
|
<div class="text-sm text-gray-500">{{ __('app.admin.metrics.users') }}</div>
|
||||||
<div class="mt-2 text-3xl font-bold text-gray-900 dark:text-white">{{ $metrics['users'] }}</div>
|
<div class="mt-2 text-3xl font-bold text-gray-900 dark:text-white">{{ $metrics['users'] }}</div>
|
||||||
@@ -32,6 +32,10 @@
|
|||||||
<div class="text-sm text-gray-500">{{ __('app.admin.metrics.yoga_poses') }}</div>
|
<div class="text-sm text-gray-500">{{ __('app.admin.metrics.yoga_poses') }}</div>
|
||||||
<div class="mt-2 text-3xl font-bold text-gray-900 dark:text-white">{{ $metrics['yogaPoses'] }}</div>
|
<div class="mt-2 text-3xl font-bold text-gray-900 dark:text-white">{{ $metrics['yogaPoses'] }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="rounded-2xl ring-1 ring-gray-200 dark:ring-gray-700 bg-white dark:bg-gray-800 p-5">
|
||||||
|
<div class="text-sm text-gray-500">{{ __('app.admin.metrics.posts') }}</div>
|
||||||
|
<div class="mt-2 text-3xl font-bold text-gray-900 dark:text-white">{{ $metrics['posts'] }}</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-8 grid grid-cols-1 lg:grid-cols-12 gap-6">
|
<div class="mt-8 grid grid-cols-1 lg:grid-cols-12 gap-6">
|
||||||
@@ -73,6 +77,7 @@
|
|||||||
<div class="mt-6 flex items-center gap-3">
|
<div class="mt-6 flex items-center gap-3">
|
||||||
<a href="{{ route('workouts.index') }}" class="inline-flex items-center rounded-xl bg-gradient-to-r from-fuchsia-600 to-indigo-600 px-4 py-2 text-sm font-semibold text-white shadow hover:from-fuchsia-500 hover:to-indigo-500">{{ __('app.admin.manage_workouts') }}</a>
|
<a href="{{ route('workouts.index') }}" class="inline-flex items-center rounded-xl bg-gradient-to-r from-fuchsia-600 to-indigo-600 px-4 py-2 text-sm font-semibold text-white shadow hover:from-fuchsia-500 hover:to-indigo-500">{{ __('app.admin.manage_workouts') }}</a>
|
||||||
<a href="{{ route('admin.exercises.index') }}" class="inline-flex items-center rounded-xl bg-gradient-to-r from-indigo-600 to-fuchsia-600 px-4 py-2 text-sm font-semibold text-white shadow hover:from-indigo-500 hover:to-fuchsia-500">Manage exercises</a>
|
<a href="{{ route('admin.exercises.index') }}" class="inline-flex items-center rounded-xl bg-gradient-to-r from-indigo-600 to-fuchsia-600 px-4 py-2 text-sm font-semibold text-white shadow hover:from-indigo-500 hover:to-fuchsia-500">Manage exercises</a>
|
||||||
|
<a href="{{ route('admin.posts.index') }}" class="inline-flex items-center rounded-xl bg-gradient-to-r from-indigo-600 to-fuchsia-600 px-4 py-2 text-sm font-semibold text-white shadow hover:from-indigo-500 hover:to-fuchsia-500">Manage posts</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
@csrf
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Title</label>
|
||||||
|
<input type="text" name="title" value="{{ old('title', $post->title ?? '') }}" required class="mt-1 w-full rounded-xl border-gray-300 dark:border-gray-700 bg-white/80 dark:bg-gray-900 text-gray-900 dark:text-gray-100 focus:border-indigo-500 focus:ring-indigo-500 px-3 py-2">
|
||||||
|
<x-input-error :messages="$errors->get('title')" class="mt-1" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Slug (optional)</label>
|
||||||
|
<input type="text" name="slug" value="{{ old('slug', $post->slug ?? '') }}" class="mt-1 w-full rounded-xl border-gray-300 dark:border-gray-700 bg-white/80 dark:bg-gray-900 text-gray-900 dark:text-gray-100 focus:border-indigo-500 focus:ring-indigo-500 px-3 py-2">
|
||||||
|
<p class="mt-1 text-xs text-gray-500">Leave empty to auto-generate from the title.</p>
|
||||||
|
<x-input-error :messages="$errors->get('slug')" class="mt-1" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Excerpt</label>
|
||||||
|
<textarea name="excerpt" rows="2" maxlength="500" class="mt-1 w-full rounded-xl border-gray-300 dark:border-gray-700 bg-white/80 dark:bg-gray-900 text-gray-900 dark:text-gray-100 focus:border-indigo-500 focus:ring-indigo-500 px-3 py-2">{{ old('excerpt', $post->excerpt ?? '') }}</textarea>
|
||||||
|
<p class="mt-1 text-xs text-gray-500">Short summary shown on the blog listing page.</p>
|
||||||
|
<x-input-error :messages="$errors->get('excerpt')" class="mt-1" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Cover image</label>
|
||||||
|
<div class="mt-1 grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<input type="file" name="cover_image_file" accept="image/*" class="block w-full text-sm text-gray-900 dark:text-gray-100 file:mr-4 file:rounded-md file:border-0 file:bg-indigo-50 file:px-3 file:py-2 file:text-sm file:font-semibold file:text-indigo-700 hover:file:bg-indigo-100 dark:file:bg-indigo-900/30 dark:file:text-indigo-200" />
|
||||||
|
<p class="mt-1 text-xs text-gray-500">Upload a new image (JPG, PNG, WebP, up to 8MB). If provided, it will override the URL below.</p>
|
||||||
|
<x-input-error :messages="$errors->get('cover_image_file')" class="mt-1" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400">Or use an external URL</label>
|
||||||
|
<input type="url" name="cover_image" value="{{ old('cover_image', $post->cover_image ?? '') }}" class="mt-1 w-full rounded-xl border-gray-300 dark:border-gray-700 bg-white/80 dark:bg-gray-900 text-gray-900 dark:text-gray-100 focus:border-indigo-500 focus:ring-indigo-500 px-3 py-2" placeholder="https://...">
|
||||||
|
<x-input-error :messages="$errors->get('cover_image')" class="mt-1" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@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))
|
||||||
|
<div class="mt-2">
|
||||||
|
<img src="{{ $src }}" alt="Cover preview" class="h-32 w-auto rounded-lg border border-gray-200 dark:border-gray-700 object-cover">
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Body</label>
|
||||||
|
<textarea name="body" rows="12" required class="mt-1 w-full rounded-xl border-gray-300 dark:border-gray-700 bg-white/80 dark:bg-gray-900 text-gray-900 dark:text-gray-100 focus:border-indigo-500 focus:ring-indigo-500 px-3 py-2">{{ old('body', $post->body ?? '') }}</textarea>
|
||||||
|
<p class="mt-1 text-xs text-gray-500">Plain text, one paragraph per line.</p>
|
||||||
|
<x-input-error :messages="$errors->get('body')" class="mt-1" />
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<input id="published" name="published" type="checkbox" value="1" @checked(old('published', $post->is_published ?? false)) class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500">
|
||||||
|
<label for="published" class="ml-2 block text-sm text-gray-700 dark:text-gray-300">Published</label>
|
||||||
|
<x-input-error :messages="$errors->get('published')" class="mt-2" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-6 flex items-center gap-3">
|
||||||
|
<a href="{{ route('admin.posts.index') }}" class="text-sm text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white">Cancel</a>
|
||||||
|
<button type="submit" class="inline-flex items-center rounded-xl bg-gradient-to-r from-fuchsia-600 to-indigo-600 px-4 py-2 text-sm font-semibold text-white shadow hover:from-fuchsia-500 hover:to-indigo-500">Save</button>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<x-app-layout>
|
||||||
|
<section class="bg-white dark:bg-gray-900 border-b border-gray-100 dark:border-gray-800">
|
||||||
|
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
<h1 class="text-2xl sm:text-3xl font-extrabold tracking-tight text-gray-900 dark:text-white">New Post</h1>
|
||||||
|
<p class="mt-2 text-gray-600 dark:text-gray-300">Write a new blog post.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="mx-auto max-w-3xl px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
@if ($errors->any())
|
||||||
|
<div class="mb-4 rounded-xl bg-red-50 text-red-700 px-4 py-3">
|
||||||
|
<ul class="list-disc ms-5 text-sm">
|
||||||
|
@foreach ($errors->all() as $error)
|
||||||
|
<li>{{ $error }}</li>
|
||||||
|
@endforeach
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
<form action="{{ route('admin.posts.store') }}" method="POST" enctype="multipart/form-data" class="space-y-5 bg-white dark:bg-gray-800 rounded-2xl p-6 ring-1 ring-gray-200 dark:ring-gray-700">
|
||||||
|
@include('admin.posts._form')
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</x-app-layout>
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<x-app-layout>
|
||||||
|
<section class="bg-white dark:bg-gray-900 border-b border-gray-100 dark:border-gray-800">
|
||||||
|
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
<h1 class="text-2xl sm:text-3xl font-extrabold tracking-tight text-gray-900 dark:text-white">Edit Post</h1>
|
||||||
|
<p class="mt-2 text-gray-600 dark:text-gray-300">Update post details.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="mx-auto max-w-3xl px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
@if ($errors->any())
|
||||||
|
<div class="mb-4 rounded-xl bg-red-50 text-red-700 px-4 py-3">
|
||||||
|
<ul class="list-disc ms-5 text-sm">
|
||||||
|
@foreach ($errors->all() as $error)
|
||||||
|
<li>{{ $error }}</li>
|
||||||
|
@endforeach
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
<form action="{{ route('admin.posts.update', $post) }}" method="POST" enctype="multipart/form-data" class="space-y-5 bg-white dark:bg-gray-800 rounded-2xl p-6 ring-1 ring-gray-200 dark:ring-gray-700">
|
||||||
|
@method('PATCH')
|
||||||
|
@include('admin.posts._form')
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</x-app-layout>
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
<x-app-layout>
|
||||||
|
<section class="bg-white dark:bg-gray-900 border-b border-gray-100 dark:border-gray-800">
|
||||||
|
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl sm:text-3xl font-extrabold tracking-tight text-gray-900 dark:text-white">Manage Posts</h1>
|
||||||
|
<p class="mt-2 text-gray-600 dark:text-gray-300">Write, edit, and publish blog posts.</p>
|
||||||
|
</div>
|
||||||
|
<a href="{{ route('admin.posts.create') }}" class="inline-flex items-center rounded-xl bg-gradient-to-r from-fuchsia-600 to-indigo-600 px-4 py-2 text-sm font-semibold text-white shadow hover:from-fuchsia-500 hover:to-indigo-500">New Post</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-6">
|
||||||
|
@if(session('status'))
|
||||||
|
<div class="mb-4 rounded-xl bg-emerald-50 text-emerald-700 px-4 py-3">{{ session('status') }}</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<form action="{{ route('admin.posts.index') }}" method="GET" class="mb-4 flex items-end gap-3 bg-white dark:bg-gray-800 rounded-2xl p-4 ring-1 ring-gray-200 dark:ring-gray-700">
|
||||||
|
<div class="w-full sm:w-80">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Search</label>
|
||||||
|
<input type="text" name="q" value="{{ $q }}" placeholder="Search by title or slug" class="mt-1 w-full rounded-xl border-gray-300 dark:border-gray-700 bg-white/80 dark:bg-gray-900 text-gray-900 dark:text-gray-100 placeholder-gray-400 focus:border-indigo-500 focus:ring-indigo-500 px-3 py-2">
|
||||||
|
</div>
|
||||||
|
<div class="ml-auto">
|
||||||
|
<button type="submit" class="inline-flex items-center rounded-xl bg-indigo-600 px-4 py-2 text-sm font-semibold text-white shadow hover:bg-indigo-500">Apply</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="overflow-hidden rounded-2xl ring-1 ring-gray-200 dark:ring-gray-700 bg-white dark:bg-gray-800">
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="min-w-full text-sm">
|
||||||
|
<thead class="bg-gray-50 dark:bg-gray-900/50">
|
||||||
|
<tr>
|
||||||
|
<th class="px-4 py-3 text-left font-semibold text-gray-700 dark:text-gray-300">Title</th>
|
||||||
|
<th class="px-4 py-3 text-left font-semibold text-gray-700 dark:text-gray-300">Slug</th>
|
||||||
|
<th class="px-4 py-3 text-left font-semibold text-gray-700 dark:text-gray-300">Status</th>
|
||||||
|
<th class="px-4 py-3 text-left font-semibold text-gray-700 dark:text-gray-300">Author</th>
|
||||||
|
<th class="px-4 py-3 text-right font-semibold text-gray-700 dark:text-gray-300">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
@forelse($posts as $post)
|
||||||
|
<tr>
|
||||||
|
<td class="px-4 py-3 text-gray-900 dark:text-gray-100">{{ $post->title }}</td>
|
||||||
|
<td class="px-4 py-3 text-gray-600 dark:text-gray-300">{{ $post->slug }}</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
@if($post->is_published)
|
||||||
|
<span class="inline-flex items-center rounded-full bg-emerald-100 text-emerald-700 px-2.5 py-1 text-xs font-medium">Published</span>
|
||||||
|
@else
|
||||||
|
<span class="inline-flex items-center rounded-full bg-gray-100 text-gray-700 px-2.5 py-1 text-xs font-medium">Draft</span>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-gray-600 dark:text-gray-300">{{ optional($post->author)->name ?? '—' }}</td>
|
||||||
|
<td class="px-4 py-3 text-right">
|
||||||
|
<a href="{{ route('admin.posts.edit', $post) }}" class="text-indigo-600 hover:text-indigo-500">Edit</a>
|
||||||
|
<form method="POST" action="{{ route('admin.posts.destroy', $post) }}" class="inline" onsubmit="return confirm('Delete this post?');">
|
||||||
|
@csrf
|
||||||
|
@method('DELETE')
|
||||||
|
<button type="submit" class="ms-3 text-red-600 hover:text-red-500">Delete</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@empty
|
||||||
|
<tr><td colspan="5" class="px-4 py-6 text-center text-gray-600 dark:text-gray-300">No posts found.</td></tr>
|
||||||
|
@endforelse
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="px-4 py-3 border-t border-gray-100 dark:border-gray-800">
|
||||||
|
{{ $posts->links() }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</x-app-layout>
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<x-app-layout>
|
||||||
|
<section class="bg-white dark:bg-gray-900 border-b border-gray-100 dark:border-gray-800">
|
||||||
|
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl sm:text-3xl font-extrabold tracking-tight text-gray-900 dark:text-white">{{ __('app.nav.blog') }}</h1>
|
||||||
|
<p class="mt-2 text-gray-600 dark:text-gray-300">Tips, guides, and news from the FitAtlas team.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-6">
|
||||||
|
<form action="{{ route('blog.index') }}" method="GET" class="mb-6 flex items-end gap-3 bg-white dark:bg-gray-800 rounded-2xl p-4 ring-1 ring-gray-200 dark:ring-gray-700">
|
||||||
|
<div class="w-full sm:w-80">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Search</label>
|
||||||
|
<input type="text" name="q" value="{{ $filters['q'] ?? '' }}" placeholder="Search posts" class="mt-1 w-full rounded-xl border-gray-300 dark:border-gray-700 bg-white/80 dark:bg-gray-900 text-gray-900 dark:text-gray-100 placeholder-gray-400 focus:border-indigo-500 focus:ring-indigo-500 px-3 py-2">
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="inline-flex items-center rounded-xl bg-gradient-to-r from-fuchsia-600 to-indigo-600 px-4 py-2 text-sm font-semibold text-white shadow hover:from-fuchsia-500 hover:to-indigo-500">Search</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
@if($posts->count() === 0)
|
||||||
|
<div class="rounded-2xl border border-dashed border-gray-300 dark:border-gray-700 p-8 text-center text-gray-600 dark:text-gray-300">No posts yet. Check back soon.</div>
|
||||||
|
@else
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
@foreach($posts as $post)
|
||||||
|
<a href="{{ route('blog.show', $post->slug) }}" class="group rounded-3xl overflow-hidden bg-white dark:bg-gray-800 ring-1 ring-gray-200 dark:ring-gray-700 shadow-sm hover:shadow-xl transition flex flex-col">
|
||||||
|
<div class="relative aspect-[16/9] overflow-hidden">
|
||||||
|
<img src="{{ $post->cover_url ?: 'https://images.unsplash.com/photo-1517836357463-d25dfeac3438?q=80&w=1200&auto=format&fit=crop' }}" alt="{{ $post->title }}" class="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105">
|
||||||
|
</div>
|
||||||
|
<div class="p-5 flex flex-col flex-1">
|
||||||
|
<div class="text-xs text-gray-500">{{ $post->published_at?->format('M j, Y') }}@if($post->author) · {{ $post->author->name }} @endif</div>
|
||||||
|
<h2 class="mt-2 text-lg font-bold text-gray-900 dark:text-white">{{ $post->title }}</h2>
|
||||||
|
@if($post->excerpt)
|
||||||
|
<p class="mt-2 text-sm text-gray-600 dark:text-gray-300 line-clamp-3">{{ $post->excerpt }}</p>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6">{{ $posts->links() }}</div>
|
||||||
|
@endif
|
||||||
|
</section>
|
||||||
|
</x-app-layout>
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<x-app-layout>
|
||||||
|
<section class="bg-white dark:bg-gray-900 border-b border-gray-100 dark:border-gray-800">
|
||||||
|
<div class="mx-auto max-w-3xl px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
<a href="{{ route('blog.index') }}" class="text-sm text-indigo-600 hover:text-indigo-500">← Back to blog</a>
|
||||||
|
<h1 class="mt-3 text-2xl sm:text-3xl font-extrabold tracking-tight text-gray-900 dark:text-white">{{ $post->title }}</h1>
|
||||||
|
<div class="mt-2 text-sm text-gray-500">{{ $post->published_at?->format('M j, Y') }}@if($post->author) · {{ $post->author->name }} @endif</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="mx-auto max-w-3xl px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
@if($post->cover_url)
|
||||||
|
<img src="{{ $post->cover_url }}" alt="{{ $post->title }}" class="w-full rounded-2xl object-cover aspect-[16/9] mb-8">
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<div class="space-y-4 text-gray-800 dark:text-gray-200 leading-relaxed">
|
||||||
|
@php $body = trim((string) $post->body); @endphp
|
||||||
|
@foreach(preg_split('/\r?\n+/', $body) as $line)
|
||||||
|
@if(trim($line) !== '')<p>{{ $line }}</p>@endif
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if($relatedPosts->count())
|
||||||
|
<div class="mt-12 border-t border-gray-100 dark:border-gray-800 pt-8">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">More posts</h2>
|
||||||
|
<div class="mt-4 grid grid-cols-1 sm:grid-cols-3 gap-6">
|
||||||
|
@foreach($relatedPosts as $related)
|
||||||
|
<a href="{{ route('blog.show', $related->slug) }}" class="group rounded-2xl overflow-hidden bg-white dark:bg-gray-800 ring-1 ring-gray-200 dark:ring-gray-700 hover:shadow-lg transition">
|
||||||
|
<div class="aspect-[16/9] overflow-hidden">
|
||||||
|
<img src="{{ $related->cover_url ?: 'https://images.unsplash.com/photo-1517836357463-d25dfeac3438?q=80&w=1200&auto=format&fit=crop' }}" alt="{{ $related->title }}" class="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105">
|
||||||
|
</div>
|
||||||
|
<div class="p-3">
|
||||||
|
<div class="text-sm font-semibold text-gray-900 dark:text-white">{{ $related->title }}</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</section>
|
||||||
|
</x-app-layout>
|
||||||
@@ -14,6 +14,8 @@ Route::prefix('admin')->middleware(['auth','admin'])->group(function(){
|
|||||||
Route::get('/', [\App\Http\Controllers\AdminDashboardController::class, 'index'])->name('admin.dashboard');
|
Route::get('/', [\App\Http\Controllers\AdminDashboardController::class, 'index'])->name('admin.dashboard');
|
||||||
// Admin Exercises CRUD
|
// Admin Exercises CRUD
|
||||||
Route::resource('exercises', \App\Http\Controllers\Admin\ExerciseController::class)->except(['show'])->names('admin.exercises');
|
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 () {
|
Route::middleware('auth')->group(function () {
|
||||||
@@ -60,6 +62,7 @@ Route::middleware('auth')->group(function () {
|
|||||||
|
|
||||||
Route::prefix('/blog')->group(function () {
|
Route::prefix('/blog')->group(function () {
|
||||||
Route::get('/', [\App\Http\Controllers\BlogController::class, 'index'])->name('blog.index');
|
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
|
// Yoga poses
|
||||||
|
|||||||
@@ -2,11 +2,13 @@
|
|||||||
|
|
||||||
namespace Tests\Feature;
|
namespace Tests\Feature;
|
||||||
|
|
||||||
// use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
class ExampleTest extends TestCase
|
class ExampleTest extends TestCase
|
||||||
{
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A basic test example.
|
* A basic test example.
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user