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:
@@ -11,7 +11,7 @@
|
||||
</section>
|
||||
|
||||
<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="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>
|
||||
@@ -32,6 +32,10 @@
|
||||
<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>
|
||||
<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 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">
|
||||
<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.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>
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user