Compare commits

..
Author SHA1 Message Date
c6de984145 Merge pull request '[MTM] Add admin-editable About page' (#131) from develop into main
Reviewed-on: #131
2026-08-28 16:01:29 +02:00
9e9fbdc127
Add admin-editable About page
Mirrors the existing Bio singleton pattern: a new `about` table/model,
admin CRUD at /admin/about, and a public page at /about linked from
both the header nav and the admin homepage. Content is wrapped in
.e-content so it lays out correctly within the site's CSS grid.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013bzYwaDD8p3XNDMYKvXKrs
2026-08-28 14:49:53 +01:00
12 changed files with 252 additions and 0 deletions

View file

@ -0,0 +1,16 @@
<?php
namespace App\Http\Controllers;
use App\Models\About;
use Illuminate\View\View;
class AboutPageController extends Controller
{
public function show(): View
{
return view('about', [
'about' => About::first()?->content,
]);
}
}

View file

@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\About;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class AboutController extends Controller
{
public function show(): View
{
$about = About::first();
return view('admin.about.show', [
'aboutEntry' => $about,
]);
}
public function update(Request $request): RedirectResponse
{
$about = About::firstOrNew();
$about->content = $request->input('content');
$about->save();
return redirect()->route('admin.about.show');
}
}

13
app/Models/About.php Normal file
View file

@ -0,0 +1,13 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class About extends Model
{
use HasFactory;
protected $table = 'about';
}

View file

@ -0,0 +1,24 @@
<?php
namespace Database\Factories;
use App\Models\About;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<About>
*/
class AboutFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'content' => $this->faker->paragraph,
];
}
}

View file

@ -0,0 +1,28 @@
<?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('about', function (Blueprint $table) {
$table->id();
$table->text('content');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('about');
}
};

View file

@ -0,0 +1,9 @@
@extends('master')
@section('title')About « @stop
@section('content')
<h2>About</h2>
<div class="e-content">
{!! $about !!}
</div>
@stop

View file

@ -0,0 +1,19 @@
@extends('master')
@section('title')Edit About « Admin CP « @stop
@section('content')
<h1>Edit About</h1>
<form action="/admin/about" method="post" accept-charset="utf-8" class="admin-form form">
{{ csrf_field() }}
{{ method_field('PUT') }}
<div>
<label for="content">Content:</label>
<br>
<textarea name="content" id="content" rows="10" cols="50">{{ old('content', $aboutEntry?->content) }}</textarea>
</div>
<div>
<button type="submit" name="save">Save</button>
</div>
</form>
@stop

View file

@ -57,6 +57,11 @@
Edit your <a href="/admin/bio">bio</a>. Edit your <a href="/admin/bio">bio</a>.
</p> </p>
<h2>About</h2>
<p>
Edit your <a href="/admin/about">about page</a>.
</p>
<h2>Passkeys</h2> <h2>Passkeys</h2>
<p> <p>
Manager <a href="/admin/passkeys">your passkeys</a>. Manager <a href="/admin/passkeys">your passkeys</a>.

View file

@ -36,6 +36,7 @@
<a href="/likes">Likes</a> <a href="/likes">Likes</a>
<a href="/contacts">Contacts</a> <a href="/contacts">Contacts</a>
<a href="/projects">Projects</a> <a href="/projects">Projects</a>
<a href="/about">About</a>
<a href="{{ route('feed.notes.json') }}" class="rss-icon">@include('icons.json-feed', ['title' => 'JSON Feed'])</a> <a href="{{ route('feed.notes.json') }}" class="rss-icon">@include('icons.json-feed', ['title' => 'JSON Feed'])</a>
</nav> </nav>
<div id="theme-selector" role="region" aria-label="Theme switcher"> <div id="theme-selector" role="region" aria-label="Theme switcher">

View file

@ -1,5 +1,7 @@
<?php <?php
use App\Http\Controllers\AboutPageController;
use App\Http\Controllers\Admin\AboutController;
use App\Http\Controllers\Admin\ArticlesController as AdminArticlesController; use App\Http\Controllers\Admin\ArticlesController as AdminArticlesController;
use App\Http\Controllers\Admin\BioController; use App\Http\Controllers\Admin\BioController;
use App\Http\Controllers\Admin\ClientsController; use App\Http\Controllers\Admin\ClientsController;
@ -51,6 +53,9 @@ Route::view('projects', 'projects');
// Static colophon page // Static colophon page
Route::view('colophon', 'colophon'); Route::view('colophon', 'colophon');
// About page
Route::get('about', [AboutPageController::class, 'show']);
// The login routes to get authd for admin // The login routes to get authd for admin
Route::get('login', [AuthController::class, 'showLogin'])->name('login'); Route::get('login', [AuthController::class, 'showLogin'])->name('login');
Route::post('login', [AuthController::class, 'login']); Route::post('login', [AuthController::class, 'login']);
@ -167,6 +172,12 @@ Route::middleware(MyAuthMiddleware::class)->prefix('admin')->group(function () {
Route::put('/', [SettingsController::class, 'update']); Route::put('/', [SettingsController::class, 'update']);
}); });
// About
Route::prefix('about')->group(function () {
Route::get('/', [AboutController::class, 'show'])->name('admin.about.show');
Route::put('/', [AboutController::class, 'update']);
});
// Passkeys // Passkeys
Route::prefix('passkeys')->group(function () { Route::prefix('passkeys')->group(function () {
Route::get('/', [PasskeysController::class, 'index']); Route::get('/', [PasskeysController::class, 'index']);

View file

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace Tests\Feature;
use App\Models\About;
use Illuminate\Foundation\Testing\RefreshDatabase;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
class AboutPageTest extends TestCase
{
use RefreshDatabase;
#[Test]
public function about_page_shows_content(): void
{
About::factory()->create([
'content' => 'This is the about page content.',
]);
$this->get('/about')
->assertSee('This is the about page content.');
}
}

View file

@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Admin;
use App\Models\About;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
class AboutTest extends TestCase
{
use RefreshDatabase;
#[Test]
public function admin_about_page_loads(): void
{
$user = User::factory()->make();
$response = $this->actingAs($user)
->get('/admin/about');
$response->assertSeeText('Edit About');
}
#[Test]
public function admin_can_create_about(): void
{
$user = User::factory()->make();
$this->actingAs($user)
->post('/admin/about', [
'_method' => 'PUT',
'content' => 'About content',
]);
$this->assertDatabaseHas('about', ['content' => 'About content']);
}
#[Test]
public function admin_can_load_existing_about(): void
{
$user = User::factory()->make();
$about = About::factory()->create([
'content' => 'This is <em>my</em> about page. It uses <strong>HTML</strong>.',
]);
$response = $this->actingAs($user)
->get('/admin/about');
$response->assertSeeText('This is <em>my</em> about page. It uses <strong>HTML</strong>.');
}
#[Test]
public function admin_can_edit_about(): void
{
$user = User::factory()->make();
$about = About::factory()->create();
$this->actingAs($user)
->post('/admin/about', [
'_method' => 'PUT',
'content' => 'This about page has been edited',
]);
$this->assertDatabaseHas('about', [
'content' => 'This about page has been edited',
]);
}
}