Compare commits

..
42 changed files with 150 additions and 896 deletions

View file

@ -1,16 +0,0 @@
<?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

@ -1,32 +0,0 @@
<?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');
}
}

View file

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

View file

@ -6,9 +6,7 @@ namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\MicropubToken;
use App\Services\TokenService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class TokensController extends Controller
@ -23,36 +21,6 @@ class TokensController extends Controller
return view('admin.tokens.index', compact('tokens'));
}
/**
* Show the form to manually generate a new Micropub token.
*
* This is for clients (e.g. iA Writer) that don't support the IndieAuth
* PKCE flow and instead expect to be given a token directly.
*/
public function create(): View
{
return view('admin.tokens.create');
}
/**
* Manually generate a new Micropub token.
*/
public function store(Request $request): RedirectResponse
{
$validated = $request->validate([
'client_id' => 'required|string',
'scope' => 'required|array|min:1',
]);
$token = resolve(TokenService::class)->getNewToken([
'me' => config('app.url'),
'client_id' => $validated['client_id'],
'scope' => implode(' ', $validated['scope']),
]);
return redirect('/admin/tokens')->with('new_token', $token);
}
/**
* Revoke a Micropub token.
*/

View file

@ -70,9 +70,7 @@ class MicropubController extends Controller
'error' => 'invalid_request',
'error_description' => 'No known note with given ID',
], 404);
} catch (MicropubUnsupportedModelException $e) {
report($e);
} catch (MicropubUnsupportedModelException) {
return response()->json([
'error' => 'invalid',
'error_description' => 'This implementation currently only supports the updating of notes',
@ -82,16 +80,12 @@ class MicropubController extends Controller
'error' => 'invalid_request',
'error_description' => $e->getMessage(),
], 400);
} catch (MicropubHandlerException $e) {
report($e);
} catch (MicropubHandlerException) {
return response()->json([
'error' => 'unsupported_operation',
'error_description' => 'The request could not be processed by this server',
], 500);
} catch (\Throwable $e) {
report($e);
} catch (\Exception $e) {
return response()->json([
'error' => 'server_error',
'error_description' => 'An error occurred processing the request',

View file

@ -1,13 +0,0 @@
<?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

@ -40,7 +40,6 @@ class Article extends Model
return [
'titleurl' => [
'source' => 'title',
'includeTrashed' => true,
],
];
}
@ -94,13 +93,6 @@ class Article extends Model
);
}
protected function uri(): Attribute
{
return Attribute::get(
get: fn () => config('app.url').$this->link,
);
}
/**
* Scope a query to only include articles from a particular year/month.
*/

View file

@ -1,18 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Setting extends Model
{
use HasFactory;
/**
* @var array<string, string>
*/
protected $casts = [
'winter_effect_enabled' => 'boolean',
];
}

View file

@ -2,12 +2,10 @@
namespace App\Providers;
use App\Models\Setting;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\Facades\View;
use Illuminate\Support\ServiceProvider;
use Symfony\Component\HtmlSanitizer\HtmlSanitizer;
use Symfony\Component\HtmlSanitizer\HtmlSanitizerConfig;
@ -65,10 +63,5 @@ class AppServiceProvider extends ServiceProvider
// Force HTTPS URL generation in production
URL::forceHttps($this->app->isProduction());
// Share whether the winter snow effect is enabled with the base layout
View::composer('master', function ($view) {
$view->with('winterEffectEnabled', Setting::first()?->winter_effect_enabled ?? false);
});
}
}

View file

@ -8,29 +8,12 @@ use App\Models\Article;
class ArticleService
{
/**
* @throws \InvalidArgumentException if a published article already has this title
*/
public function create(array $data): Article
{
$attributes = [
return Article::create([
'title' => $data['name'],
'main' => $data['content'],
'published' => ($data['post-status'] ?? null) !== 'draft',
];
$existing = Article::where('title', $data['name'])->first();
if ($existing !== null) {
if ($existing->published) {
throw new \InvalidArgumentException("An article titled \"{$data['name']}\" has already been published");
}
$existing->update($attributes);
return $existing;
}
return Article::create($attributes);
]);
}
}

View file

@ -37,7 +37,7 @@ class EntryHandler implements MicropubHandlerInterface
$location = match (true) {
isset($dataArray['like-of']) => resolve(LikeService::class)->create($dataArray)->url,
isset($dataArray['bookmark-of']) => resolve(BookmarkService::class)->create($dataArray)->uri,
isset($dataArray['name']) => resolve(ArticleService::class)->create($dataArray)->uri,
isset($dataArray['name']) => resolve(ArticleService::class)->create($dataArray)->link,
default => resolve(NoteService::class)->create($dataArray)->uri,
};

View file

@ -1,24 +0,0 @@
<?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

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

View file

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

View file

@ -1,28 +0,0 @@
<?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');
}
};

File diff suppressed because one or more lines are too long

Binary file not shown.

File diff suppressed because one or more lines are too long

Binary file not shown.

View file

@ -1,2 +1,2 @@
(()=>{var l=class{constructor(){}async register(){let t=await this.getCreateOptions(),e={challenge:this.base64URLStringToBuffer(t.challenge),rp:{id:t.rp.id,name:t.rp.name},user:{id:new TextEncoder().encode(window.atob(t.user.id)),name:t.user.name,displayName:t.user.displayName},pubKeyCredParams:t.pubKeyCredParams,excludeCredentials:[],authenticatorSelection:t.authenticatorSelection,timeout:6e4},r=await navigator.credentials.create({publicKey:e});if(!r)throw new Error("Error generating a passkey");let s={id:r.id?r.id:null,type:r.type?r.type:null,rawId:r.rawId?this.bufferToBase64URLString(r.rawId):null,response:{attestationObject:r.response.attestationObject?this.bufferToBase64URLString(r.response.attestationObject):null,clientDataJSON:r.response.clientDataJSON?this.bufferToBase64URLString(r.response.clientDataJSON):null}};if(!(await window.fetch("/admin/passkeys/register",{method:"POST",body:JSON.stringify(s),cache:"no-cache",headers:{"Content-Type":"application/json","X-CSRF-TOKEN":document.querySelector('meta[name="csrf-token"]').getAttribute("content")}})).ok)throw new Error("Error saving the passkey");window.location.reload()}async getCreateOptions(){return await(await fetch("/admin/passkeys/register",{method:"GET"})).json()}async login(){let t=await this.getLoginData(),e=await navigator.credentials.get({publicKey:{challenge:this.base64URLStringToBuffer(t.challenge),userVerification:t.userVerification,timeout:6e4}});if(!e)throw new Error("Authentication failed");let r={id:e.id?e.id:"",type:e.type?e.type:"",rawId:e.rawId?this.bufferToBase64URLString(e.rawId):"",response:{authenticatorData:e.response.authenticatorData?this.bufferToBase64URLString(e.response.authenticatorData):"",clientDataJSON:e.response.clientDataJSON?this.bufferToBase64URLString(e.response.clientDataJSON):"",signature:e.response.signature?this.bufferToBase64URLString(e.response.signature):"",userHandle:e.response.userHandle?this.bufferToBase64URLString(e.response.userHandle):""}};if(!(await window.fetch("/login/passkey",{method:"POST",body:JSON.stringify(r),headers:{"Content-Type":"application/json","X-CSRF-TOKEN":document.querySelector('meta[name="csrf-token"]').getAttribute("content")}})).ok)throw new Error("Login failed");window.location.assign("/admin")}async getLoginData(){return await(await fetch("/login/passkey",{method:"GET"})).json()}base64URLStringToBuffer(t){let e=t.replace(/-/g,"+").replace(/_/g,"/"),r=(4-e.length%4)%4,s=e.padEnd(e.length+r,"="),n=window.atob(s),a=new ArrayBuffer(n.length),o=new Uint8Array(a);for(let c=0;c<n.length;c++)o[c]=n.charCodeAt(c);return a}bufferToBase64URLString(t){let e=new Uint8Array(t),r="";for(let n of e)r+=String.fromCharCode(n);return btoa(r).replace(/\+/g,"-").replace(/\//g,"_").replace(/=/g,"")}};var d=class{constructor(){this.widget=document.querySelector("#theme-selector"),this.btnLight=this.widget.querySelector("#btn-light"),this.btnDark=this.widget.querySelector("#btn-dark"),this.status=this.widget.querySelector("#theme-status"),this.currentTheme="system"}setupEventListeners(){this.btnLight.addEventListener("click",()=>{this.applyTheme(this.currentTheme==="light"?"system":"light")}),this.btnDark.addEventListener("click",()=>{this.applyTheme(this.currentTheme==="dark"?"system":"dark")})}applyTheme(t){let e=t==="light"||t==="dark"?t:"light dark",r=window.matchMedia&&window.matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light",s=document.documentElement,n=s.style.getPropertyValue("color-scheme");n===""&&(n="light dark");let a=!1;e!==n&&(e==="light dark"?a=n!==r:n==="light dark"?a=e!==r:a=!0);let o=()=>{this.currentTheme=t,s.style.setProperty("color-scheme",e),this.btnLight.setAttribute("aria-pressed",String(t==="light")),this.btnDark.setAttribute("aria-pressed",String(t==="dark")),this.status.textContent=t==="system"?"Theme set to system default":""};s.style.viewTransitionName="changing-theme",a&&document.startViewTransition?document.startViewTransition(o):o()}};var h=new l;document.querySelectorAll(".add-passkey").forEach(i=>{i.addEventListener("click",()=>{h.register()})});document.querySelectorAll(".login-passkey").forEach(i=>{i.addEventListener("click",()=>{h.login()})});var u=new d;u.setupEventListeners();})();
(()=>{var l=class{constructor(){}async register(){let r=await this.getCreateOptions(),e={challenge:this.base64URLStringToBuffer(r.challenge),rp:{id:r.rp.id,name:r.rp.name},user:{id:new TextEncoder().encode(window.atob(r.user.id)),name:r.user.name,displayName:r.user.displayName},pubKeyCredParams:r.pubKeyCredParams,excludeCredentials:[],authenticatorSelection:r.authenticatorSelection,timeout:6e4},t=await navigator.credentials.create({publicKey:e});if(!t)throw new Error("Error generating a passkey");let n={id:t.id?t.id:null,type:t.type?t.type:null,rawId:t.rawId?this.bufferToBase64URLString(t.rawId):null,response:{attestationObject:t.response.attestationObject?this.bufferToBase64URLString(t.response.attestationObject):null,clientDataJSON:t.response.clientDataJSON?this.bufferToBase64URLString(t.response.clientDataJSON):null}};if(!(await window.fetch("/admin/passkeys/register",{method:"POST",body:JSON.stringify(n),cache:"no-cache",headers:{"Content-Type":"application/json","X-CSRF-TOKEN":document.querySelector('meta[name="csrf-token"]').getAttribute("content")}})).ok)throw new Error("Error saving the passkey");window.location.reload()}async getCreateOptions(){return await(await fetch("/admin/passkeys/register",{method:"GET"})).json()}async login(){let r=await this.getLoginData(),e=await navigator.credentials.get({publicKey:{challenge:this.base64URLStringToBuffer(r.challenge),userVerification:r.userVerification,timeout:6e4}});if(!e)throw new Error("Authentication failed");let t={id:e.id?e.id:"",type:e.type?e.type:"",rawId:e.rawId?this.bufferToBase64URLString(e.rawId):"",response:{authenticatorData:e.response.authenticatorData?this.bufferToBase64URLString(e.response.authenticatorData):"",clientDataJSON:e.response.clientDataJSON?this.bufferToBase64URLString(e.response.clientDataJSON):"",signature:e.response.signature?this.bufferToBase64URLString(e.response.signature):"",userHandle:e.response.userHandle?this.bufferToBase64URLString(e.response.userHandle):""}};if(!(await window.fetch("/login/passkey",{method:"POST",body:JSON.stringify(t),headers:{"Content-Type":"application/json","X-CSRF-TOKEN":document.querySelector('meta[name="csrf-token"]').getAttribute("content")}})).ok)throw new Error("Login failed");window.location.assign("/admin")}async getLoginData(){return await(await fetch("/login/passkey",{method:"GET"})).json()}base64URLStringToBuffer(r){let e=r.replace(/-/g,"+").replace(/_/g,"/"),t=(4-e.length%4)%4,n=e.padEnd(e.length+t,"="),a=window.atob(n),o=new ArrayBuffer(a.length),i=new Uint8Array(o);for(let s=0;s<a.length;s++)i[s]=a.charCodeAt(s);return o}bufferToBase64URLString(r){let e=new Uint8Array(r),t="";for(let a of e)t+=String.fromCharCode(a);return btoa(t).replace(/\+/g,"-").replace(/\//g,"_").replace(/=/g,"")}};var d=class{constructor(){this.widget=document.querySelector("#theme-selector")}setupEventListeners(){this.widget.querySelectorAll("#theme-selector-dropdown input").forEach(r=>r.addEventListener("input",e=>{let t=e.target.value;this.widget.querySelectorAll(".toggle svg").forEach(u=>{u.classList.contains(t)?u.style.display="":u.style.display="none"});let n;switch(t){case"dark":case"light":n=t;break;default:n="light dark"}let a=window.matchMedia&&window.matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light",o=document.querySelector("html"),i=o.style.getPropertyValue("color-scheme");i===""&&(i="light dark");let s=!1;n!==i&&(n==="light dark"?s=i!==a:i==="light dark"?s=n!==a:s=!0),o.style.viewTransitionName="changing-theme",s&&document.startViewTransition?document.startViewTransition(()=>{document.querySelector("#theme-selector-dropdown").togglePopover(),o.style.setProperty("color-scheme",n)}):(document.querySelector("#theme-selector-dropdown").togglePopover(),o.style.setProperty("color-scheme",n))}))}};var h=new l;document.querySelectorAll(".add-passkey").forEach(c=>{c.addEventListener("click",()=>{h.register()})});document.querySelectorAll(".login-passkey").forEach(c=>{c.addEventListener("click",()=>{h.login()})});var p=new d;p.setupEventListeners();})();
//# sourceMappingURL=app.js.map

Binary file not shown.

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -134,30 +134,4 @@
.token-list button.revoke:hover {
background: light-dark(oklch(80% 0.2 25deg), oklch(45% 0.18 25deg));
}
.token-reveal {
margin-block-end: 1em;
padding: 1em 1.2em;
border: 1px solid var(--clr-border);
border-radius: 16px;
background: light-dark(
oklch(96% 0.08 145deg),
oklch(28% 0.08 145deg)
);
input {
width: 100%;
font-family: monospace;
padding: 0.5em 0.7em;
border-radius: 8px;
border: 1px solid var(--clr-border);
}
}
.scope-checkboxes {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 1em;
}
}

View file

@ -1,66 +1,70 @@
@layer components {
#theme-selector {
display: flex;
justify-self: start;
align-items: center;
gap: .25rem;
padding: .25rem;
border-radius: 999px;
background-color: color-mix(in oklch, var(--clr-border) 40%, transparent);
button {
display: grid;
place-items: center;
appearance: none;
border: none;
border-radius: 999px;
padding: .375rem;
background: transparent;
color: inherit;
transition: background-color .2s, color .2s;
background: none;
&:hover {
cursor: pointer;
}
}
&:focus-visible {
outline: 2px solid var(--clr-text);
outline-offset: 2px;
}
/* This is the element with the [popover] attribute */
#theme-selector-dropdown {
position: absolute;
position-area: block-end span-inline-start;
margin: 0;
border: 2px solid var(--clr-border);
background-color: var(--clr-background);
color: var(--clr-text);
svg {
width: 1.5rem;
height: 1.5rem;
fill: currentcolor;
}
fieldset {
border: 0;
display: flex;
flex-direction: row;
gap: 1ch;
@media (hover: hover) {
&:hover {
background-color: color-mix(in oklch, var(--clr-border) 70%, transparent);
input {
display: none;
}
&[aria-pressed="true"]:hover {
background-color: var(--clr-text);
}
}
&[aria-pressed="true"] {
background-color: var(--clr-text);
color: var(--clr-background);
}
}
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
/*
* This is the element with the [popover] attribute
* Here we are styling the open and closing animations
*/
@media (prefers-reduced-motion: no-preference) {
#theme-selector-dropdown {
&:popover-open {
transform: translateY(0) scale(1);
opacity: 1;
/*
* Start styles for the opening transition.
* Added to :popover-open, but after the opened styles.
*/
@starting-style {
transform: translateY(30px) scale(0);
opacity: 0;
}
}
/*
* End styles for the closing transition.
*/
transform: translateY(0) scale(0);
opacity: 0;
/*
* Enumerate transitioning properties, including display and overlay.
*/
transition: transform, opacity, display allow-discrete, overlay allow-discrete;
transition-duration: 0.5s;
transform-origin: top right;
}
}
}
::view-transition-group(changing-theme) {

View file

@ -1,60 +1,69 @@
class ThemeSelector {
constructor() {
this.widget = document.querySelector('#theme-selector');
this.btnLight = this.widget.querySelector('#btn-light');
this.btnDark = this.widget.querySelector('#btn-dark');
this.status = this.widget.querySelector('#theme-status');
this.currentTheme = 'system';
}
setupEventListeners() {
this.btnLight.addEventListener('click', () => {
this.applyTheme(this.currentTheme === 'light' ? 'system' : 'light');
});
this.widget.querySelectorAll('#theme-selector-dropdown input').forEach((radioInput) => radioInput.addEventListener('input', (e) => {
let theme = e.target.value;
this.btnDark.addEventListener('click', () => {
this.applyTheme(this.currentTheme === 'dark' ? 'system' : 'dark');
});
}
// Update current icon
this.widget.querySelectorAll('.toggle svg').forEach((svg) => {
if (svg.classList.contains(theme)) {
svg.style.display = '';
} else {
svg.style.display = 'none';
}
});
applyTheme(theme) {
const selectedTheme = (theme === 'light' || theme === 'dark') ? theme : 'light dark';
const systemTheme = (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) ? 'dark' : 'light';
const html = document.documentElement;
let currentColorScheme = html.style.getPropertyValue('color-scheme');
if (currentColorScheme === '') {
currentColorScheme = 'light dark';
}
/* do we need to transition */
let doTransition = false;
if (selectedTheme !== currentColorScheme) {
if (selectedTheme === 'light dark') {
doTransition = currentColorScheme !== systemTheme;
} else if (currentColorScheme === 'light dark') {
doTransition = selectedTheme !== systemTheme;
} else {
doTransition = true;
// Set the theme
let selectedTheme;
switch (theme) {
case 'dark':
case 'light':
selectedTheme = theme;
break;
default:
selectedTheme = 'light dark';
}
}
const applyChange = () => {
this.currentTheme = theme;
html.style.setProperty('color-scheme', selectedTheme);
const systemTheme = (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) ? 'dark' : 'light';
const html = document.querySelector('html');
let currentTheme = html.style.getPropertyValue('color-scheme');
if (currentTheme === '') {
currentTheme = 'light dark';
}
this.btnLight.setAttribute('aria-pressed', String(theme === 'light'));
this.btnDark.setAttribute('aria-pressed', String(theme === 'dark'));
this.status.textContent = theme === 'system' ? 'Theme set to system default' : '';
};
/* do we need to transition */
let doTransition = false;
html.style.viewTransitionName = 'changing-theme';
if (doTransition && document.startViewTransition) {
document.startViewTransition(applyChange);
} else {
applyChange();
}
if (selectedTheme !== currentTheme) {
if (selectedTheme === 'light dark') {
doTransition = currentTheme !== systemTheme;
} else if (currentTheme === 'light dark') {
doTransition = selectedTheme !== systemTheme;
} else {
doTransition = true;
}
}
html.style.viewTransitionName = 'changing-theme';
if (doTransition && document.startViewTransition) {
document.startViewTransition(() => {
// Close the popover
document.querySelector('#theme-selector-dropdown').togglePopover();
// Set the colour theme
html.style.setProperty('color-scheme', selectedTheme);
});
} else {
// Close the popover
document.querySelector('#theme-selector-dropdown').togglePopover();
// Set the colour theme
html.style.setProperty('color-scheme', selectedTheme);
}
}));
}
}

View file

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

View file

@ -1,19 +0,0 @@
@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

@ -1,26 +0,0 @@
@extends('master')
@section('title')Site Settings « Admin CP « @stop
@section('content')
<h1>Site settings</h1>
<form action="/admin/settings" method="post" accept-charset="utf-8" class="admin-form form">
{{ csrf_field() }}
{{ method_field('PUT') }}
<div>
<label for="winter_effect_enabled">
<input
type="checkbox"
name="winter_effect_enabled"
id="winter_effect_enabled"
value="1"
@checked(old('winter_effect_enabled', $settings?->winter_effect_enabled))
>
Show winter snow effect
</label>
</div>
<div>
<button type="submit" name="save">Save</button>
</div>
</form>
@stop

View file

@ -1,52 +0,0 @@
@extends('master')
@section('title')New Token « Admin CP « @stop
@section('content')
<h1>Generate a new token</h1>
<p>Use this for clients that can't complete the IndieAuth authorization flow (e.g. they don't support PKCE) and instead let you paste in a token directly.</p>
<form action="/admin/tokens" method="post" accept-charset="utf-8" class="admin-form form">
{{ csrf_field() }}
<div>
<label for="client_id">Client</label>
<input
type="text"
name="client_id"
id="client_id"
value="{{ old('client_id') }}"
placeholder="https://ia.net/writer"
required
>
</div>
<div class="scope-checkboxes">
<span>Scope</span>
<label for="scope_create">
<input
type="checkbox"
name="scope[]"
id="scope_create"
value="create"
@checked(in_array('create', old('scope', []), true))
>
create
</label>
<label for="scope_update">
<input
type="checkbox"
name="scope[]"
id="scope_update"
value="update"
@checked(in_array('update', old('scope', []), true))
>
update
</label>
</div>
<div>
<button type="submit" name="save">Generate token</button>
</div>
</form>
@stop

View file

@ -4,15 +4,6 @@
@section('content')
<h1>Micropub Tokens</h1>
<p><a href="/admin/tokens/create">Generate new token</a></p>
@if(session('new_token'))
<div class="token-reveal">
<p>Here's your new token. <strong>Copy it now</strong> — it won't be shown again.</p>
<input type="text" readonly value="{{ session('new_token') }}" onclick="this.select()">
</div>
@endif
@if($tokens->isEmpty())
<p>No tokens have been issued.</p>
@else

View file

@ -57,18 +57,8 @@
Edit your <a href="/admin/bio">bio</a>.
</p>
<h2>About</h2>
<p>
Edit your <a href="/admin/about">about page</a>.
</p>
<h2>Passkeys</h2>
<p>
Manager <a href="/admin/passkeys">your passkeys</a>.
</p>
<h2>Settings</h2>
<p>
Edit <a href="/admin/settings">site settings</a>.
</p>
@stop

View file

@ -36,17 +36,37 @@
<a href="/likes">Likes</a>
<a href="/contacts">Contacts</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>
</nav>
<div id="theme-selector" role="region" aria-label="Theme switcher">
<button id="btn-light" aria-label="Light mode" aria-pressed="false">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true"><!-- Icon from Fluent UI System Icons by Microsoft Corporation - https://github.com/microsoft/fluentui-system-icons/blob/main/LICENSE --><path fill="currentColor" d="M12 2a.75.75 0 0 1 .75.75v1.5a.75.75 0 0 1-1.5 0v-1.5A.75.75 0 0 1 12 2m5 10a5 5 0 1 1-10 0a5 5 0 0 1 10 0m4.25.75a.75.75 0 0 0 0-1.5h-1.5a.75.75 0 0 0 0 1.5zM12 19a.75.75 0 0 1 .75.75v1.5a.75.75 0 0 1-1.5 0v-1.5A.75.75 0 0 1 12 19m-7.75-6.25a.75.75 0 0 0 0-1.5h-1.5a.75.75 0 0 0 0 1.5zm-.03-8.53a.75.75 0 0 1 1.06 0l1.5 1.5a.75.75 0 0 1-1.06 1.06l-1.5-1.5a.75.75 0 0 1 0-1.06m1.06 15.56a.75.75 0 1 1-1.06-1.06l1.5-1.5a.75.75 0 1 1 1.06 1.06zm14.5-15.56a.75.75 0 0 0-1.06 0l-1.5 1.5a.75.75 0 0 0 1.06 1.06l1.5-1.5a.75.75 0 0 0 0-1.06m-1.06 15.56a.75.75 0 1 0 1.06-1.06l-1.5-1.5a.75.75 0 1 0-1.06 1.06z"/></svg>
<div id="theme-selector">
<button class="toggle" popovertarget="theme-selector-dropdown">
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" class="system" style=""><!-- Icon from Fluent UI System Icons by Microsoft Corporation - https://github.com/microsoft/fluentui-system-icons/blob/main/LICENSE --><path fill="currentColor" d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2S2 6.477 2 12s4.477 10 10 10m0-1.5v-17a8.5 8.5 0 0 1 0 17"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" class="dark" style="display: none"><!-- Icon from Fluent UI System Icons by Microsoft Corporation - https://github.com/microsoft/fluentui-system-icons/blob/main/LICENSE --><path fill="currentColor" d="M20.026 17.001c-2.762 4.784-8.879 6.423-13.663 3.661A10 10 0 0 1 3.13 17.68a.75.75 0 0 1 .365-1.132c3.767-1.348 5.785-2.91 6.956-5.146c1.233-2.353 1.551-4.93.689-8.463a.75.75 0 0 1 .769-.927a9.96 9.96 0 0 1 4.457 1.327c4.784 2.762 6.423 8.879 3.66 13.662"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" class="light" style="display: none"><!-- Icon from Fluent UI System Icons by Microsoft Corporation - https://github.com/microsoft/fluentui-system-icons/blob/main/LICENSE --><path fill="currentColor" d="M12 2a.75.75 0 0 1 .75.75v1.5a.75.75 0 0 1-1.5 0v-1.5A.75.75 0 0 1 12 2m5 10a5 5 0 1 1-10 0a5 5 0 0 1 10 0m4.25.75a.75.75 0 0 0 0-1.5h-1.5a.75.75 0 0 0 0 1.5zM12 19a.75.75 0 0 1 .75.75v1.5a.75.75 0 0 1-1.5 0v-1.5A.75.75 0 0 1 12 19m-7.75-6.25a.75.75 0 0 0 0-1.5h-1.5a.75.75 0 0 0 0 1.5zm-.03-8.53a.75.75 0 0 1 1.06 0l1.5 1.5a.75.75 0 0 1-1.06 1.06l-1.5-1.5a.75.75 0 0 1 0-1.06m1.06 15.56a.75.75 0 1 1-1.06-1.06l1.5-1.5a.75.75 0 1 1 1.06 1.06zm14.5-15.56a.75.75 0 0 0-1.06 0l-1.5 1.5a.75.75 0 0 0 1.06 1.06l1.5-1.5a.75.75 0 0 0 0-1.06m-1.06 15.56a.75.75 0 1 0 1.06-1.06l-1.5-1.5a.75.75 0 1 0-1.06 1.06z"/></svg>
</button>
<button id="btn-dark" aria-label="Dark mode" aria-pressed="false">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true"><!-- Icon from Fluent UI System Icons by Microsoft Corporation - https://github.com/microsoft/fluentui-system-icons/blob/main/LICENSE --><path fill="currentColor" d="M20.026 17.001c-2.762 4.784-8.879 6.423-13.663 3.661A10 10 0 0 1 3.13 17.68a.75.75 0 0 1 .365-1.132c3.767-1.348 5.785-2.91 6.956-5.146c1.233-2.353 1.551-4.93.689-8.463a.75.75 0 0 1 .769-.927a9.96 9.96 0 0 1 4.457 1.327c4.784 2.762 6.423 8.879 3.66 13.662"/></svg>
</button>
<span id="theme-status" class="sr-only" aria-live="polite"></span>
<div id="theme-selector-dropdown" popover>
<fieldset>
<legend>Select theme:</legend>
<div>
<input type="radio" id="theme-system" name="theme" value="system" checked>
<label for="theme-system">
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"><!-- Icon from Fluent UI System Icons by Microsoft Corporation - https://github.com/microsoft/fluentui-system-icons/blob/main/LICENSE --><path fill="currentColor" d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2S2 6.477 2 12s4.477 10 10 10m0-1.5v-17a8.5 8.5 0 0 1 0 17"/></svg>
</label>
</div>
<div>
<input type="radio" id="theme-dark" name="theme" value="dark">
<label for="theme-dark">
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"><!-- Icon from Fluent UI System Icons by Microsoft Corporation - https://github.com/microsoft/fluentui-system-icons/blob/main/LICENSE --><path fill="currentColor" d="M20.026 17.001c-2.762 4.784-8.879 6.423-13.663 3.661A10 10 0 0 1 3.13 17.68a.75.75 0 0 1 .365-1.132c3.767-1.348 5.785-2.91 6.956-5.146c1.233-2.353 1.551-4.93.689-8.463a.75.75 0 0 1 .769-.927a9.96 9.96 0 0 1 4.457 1.327c4.784 2.762 6.423 8.879 3.66 13.662"/></svg>
</label>
</div>
<div>
<input type="radio" id="theme-light" name="theme" value="light">
<label for="theme-light">
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"><!-- Icon from Fluent UI System Icons by Microsoft Corporation - https://github.com/microsoft/fluentui-system-icons/blob/main/LICENSE --><path fill="currentColor" d="M12 2a.75.75 0 0 1 .75.75v1.5a.75.75 0 0 1-1.5 0v-1.5A.75.75 0 0 1 12 2m5 10a5 5 0 1 1-10 0a5 5 0 0 1 10 0m4.25.75a.75.75 0 0 0 0-1.5h-1.5a.75.75 0 0 0 0 1.5zM12 19a.75.75 0 0 1 .75.75v1.5a.75.75 0 0 1-1.5 0v-1.5A.75.75 0 0 1 12 19m-7.75-6.25a.75.75 0 0 0 0-1.5h-1.5a.75.75 0 0 0 0 1.5zm-.03-8.53a.75.75 0 0 1 1.06 0l1.5 1.5a.75.75 0 0 1-1.06 1.06l-1.5-1.5a.75.75 0 0 1 0-1.06m1.06 15.56a.75.75 0 1 1-1.06-1.06l1.5-1.5a.75.75 0 1 1 1.06 1.06zm14.5-15.56a.75.75 0 0 0-1.06 0l-1.5 1.5a.75.75 0 0 0 1.06 1.06l1.5-1.5a.75.75 0 0 0 0-1.06m-1.06 15.56a.75.75 0 1 0 1.06-1.06l-1.5-1.5a.75.75 0 1 0-1.06 1.06z"/></svg>
</label>
</div>
</fieldset>
</div>
</div>
</header>
@ -82,16 +102,14 @@
@section('scripts')
<script type="module" src="/assets/js/app.js"></script>
@if($winterEffectEnabled ?? false)
<script type="module" src="/assets/js/is-land.min.js"></script>
<script type="module" src="/assets/js/winter.js"></script>
<is-land on:media="(prefers-reduced-motion: no-preference)">
<snow-fall
count="150"
style="--snow-fall-color: var(--clr-snow-fall)"
></snow-fall>
</is-land>
@endif
<script type="module" src="/assets/js/is-land.min.js"></script>
<script type="module" src="/assets/js/winter.js"></script>
<is-land on:media="(prefers-reduced-motion: no-preference)">
<snow-fall
count="150"
style="--snow-fall-color: var(--clr-snow-fall)"
></snow-fall>
</is-land>
@show
</body>
</html>

View file

@ -1,7 +1,5 @@
<?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\BioController;
use App\Http\Controllers\Admin\ClientsController;
@ -11,7 +9,6 @@ use App\Http\Controllers\Admin\LikesController as AdminLikesController;
use App\Http\Controllers\Admin\NotesController as AdminNotesController;
use App\Http\Controllers\Admin\PasskeysController;
use App\Http\Controllers\Admin\PlacesController as AdminPlacesController;
use App\Http\Controllers\Admin\SettingsController;
use App\Http\Controllers\Admin\SyndicationTargetsController;
use App\Http\Controllers\Admin\TokensController;
use App\Http\Controllers\ArticlesController;
@ -53,9 +50,6 @@ Route::view('projects', 'projects');
// Static colophon page
Route::view('colophon', 'colophon');
// About page
Route::get('about', [AboutPageController::class, 'show']);
// The login routes to get authd for admin
Route::get('login', [AuthController::class, 'showLogin'])->name('login');
Route::post('login', [AuthController::class, 'login']);
@ -157,8 +151,6 @@ Route::middleware(MyAuthMiddleware::class)->prefix('admin')->group(function () {
// Micropub Tokens
Route::prefix('tokens')->group(function () {
Route::get('/', [TokensController::class, 'index']);
Route::get('/create', [TokensController::class, 'create']);
Route::post('/', [TokensController::class, 'store']);
Route::put('/{token}/revoke', [TokensController::class, 'revoke']);
});
@ -168,18 +160,6 @@ Route::middleware(MyAuthMiddleware::class)->prefix('admin')->group(function () {
Route::put('/', [BioController::class, 'update']);
});
// Settings
Route::prefix('settings')->group(function () {
Route::get('/', [SettingsController::class, 'show'])->name('admin.settings.show');
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
Route::prefix('passkeys')->group(function () {
Route::get('/', [PasskeysController::class, 'index']);

View file

@ -1,26 +0,0 @@
<?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

@ -1,68 +0,0 @@
<?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',
]);
}
}

View file

@ -1,68 +0,0 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Admin;
use App\Models\Setting;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
class SettingsTest extends TestCase
{
use RefreshDatabase;
#[Test]
public function admin_settings_page_loads(): void
{
$user = User::factory()->make();
$response = $this->actingAs($user)
->get('/admin/settings');
$response->assertSeeText('Site settings');
}
#[Test]
public function admin_can_enable_winter_effect(): void
{
$user = User::factory()->make();
$this->actingAs($user)
->post('/admin/settings', [
'_method' => 'PUT',
'winter_effect_enabled' => '1',
]);
$this->assertDatabaseHas('settings', ['winter_effect_enabled' => true]);
}
#[Test]
public function admin_can_disable_winter_effect(): void
{
$user = User::factory()->make();
Setting::factory()->create(['winter_effect_enabled' => true]);
$this->actingAs($user)
->post('/admin/settings', [
'_method' => 'PUT',
]);
$this->assertDatabaseHas('settings', ['winter_effect_enabled' => false]);
}
#[Test]
public function winter_effect_markup_is_hidden_when_disabled(): void
{
$response = $this->get('/');
$response->assertDontSee('snow-fall');
}
#[Test]
public function winter_effect_markup_is_shown_when_enabled(): void
{
Setting::factory()->create(['winter_effect_enabled' => true]);
$response = $this->get('/');
$response->assertSee('snow-fall', false);
}
}

View file

@ -37,73 +37,6 @@ class TokensTest extends TestCase
$response->assertSeeText($token->client_id);
}
#[Test]
public function create_requires_authentication(): void
{
$response = $this->get('/admin/tokens/create');
$response->assertRedirect();
}
#[Test]
public function create_shows_form(): void
{
$user = User::factory()->make();
$response = $this->actingAs($user)->get('/admin/tokens/create');
$response->assertOk();
$response->assertSee('name="client_id"', false);
}
#[Test]
public function store_requires_authentication(): void
{
$response = $this->post('/admin/tokens', [
'client_id' => 'https://ia.net/writer',
'scope' => ['create'],
]);
$response->assertRedirect();
$this->assertDatabaseCount('micropub_tokens', 0);
}
#[Test]
public function store_creates_a_new_token_and_redirects_with_it_flashed(): void
{
$user = User::factory()->make();
$response = $this->actingAs($user)->post('/admin/tokens', [
'client_id' => 'https://ia.net/writer',
'scope' => ['create', 'update'],
]);
$response->assertRedirect('/admin/tokens');
$response->assertSessionHas('new_token');
$this->assertDatabaseHas('micropub_tokens', [
'client_id' => 'https://ia.net/writer',
'scope' => 'create update',
'me' => config('app.url'),
]);
$token = $response->getSession()->get('new_token');
$this->assertNotNull(MicropubToken::findActive($token));
}
#[Test]
public function store_requires_at_least_one_scope(): void
{
$user = User::factory()->make();
$response = $this->actingAs($user)->post('/admin/tokens', [
'client_id' => 'https://ia.net/writer',
'scope' => [],
]);
$response->assertSessionHasErrors('scope');
$this->assertDatabaseCount('micropub_tokens', 0);
}
#[Test]
public function revoke_requires_authentication(): void
{

View file

@ -4,17 +4,14 @@ declare(strict_types=1);
namespace Tests\Feature;
use App\Exceptions\MicropubHandlerException;
use App\Jobs\SendWebMentions;
use App\Jobs\SyndicateNoteToBluesky;
use App\Jobs\SyndicateNoteToMastodon;
use App\Models\Article;
use App\Models\Media;
use App\Models\Note;
use App\Models\Place;
use App\Models\SyndicationTarget;
use Faker\Factory;
use Illuminate\Contracts\Debug\ExceptionHandler;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Queue;
@ -460,11 +457,6 @@ class MicropubControllerTest extends TestCase
#[Test]
public function micropub_client_api_request_for_unsupported_post_type_returns_error(): void
{
$this->mock(ExceptionHandler::class)
->shouldReceive('report')
->once()
->with(\Mockery::type(MicropubHandlerException::class));
$response = $this->postJson(
'/api/post',
[
@ -879,8 +871,6 @@ class MicropubControllerTest extends TestCase
'main' => $content,
'published' => true,
]);
$response->assertHeader('Location');
$this->assertStringStartsWith(config('app.url').'/blog/', $response->headers->get('Location'));
}
#[Test]
@ -912,64 +902,4 @@ class MicropubControllerTest extends TestCase
'published' => false,
]);
}
#[Test]
public function micropub_client_api_request_updates_an_existing_draft_article_with_the_same_name(): void
{
$draft = Article::create([
'title' => 'WireGuard',
'main' => 'Early draft content',
'published' => false,
]);
$response = $this->postJson(
'/api/post',
[
'type' => ['h-entry'],
'properties' => [
'name' => ['WireGuard'],
'content' => ['Finished content'],
],
],
['HTTP_Authorization' => 'Bearer '.$this->getToken()]
);
$response
->assertJson(['response' => 'created'])
->assertStatus(201);
$this->assertSame(1, Article::where('title', 'WireGuard')->count());
$this->assertDatabaseHas('articles', [
'id' => $draft->id,
'title' => 'WireGuard',
'main' => 'Finished content',
'published' => true,
]);
}
#[Test]
public function micropub_client_api_request_errors_when_an_article_with_the_same_name_is_already_published(): void
{
Article::create([
'title' => 'WireGuard',
'main' => 'Published content',
'published' => true,
]);
$response = $this->postJson(
'/api/post',
[
'type' => ['h-entry'],
'properties' => [
'name' => ['WireGuard'],
'content' => ['Some other content'],
],
],
['HTTP_Authorization' => 'Bearer '.$this->getToken()]
);
$response
->assertJson(['error' => 'invalid_request'])
->assertStatus(400);
$this->assertSame(1, Article::where('title', 'WireGuard')->count());
}
}

View file

@ -63,28 +63,6 @@ class ArticlesTest extends TestCase
);
}
#[Test]
public function uri_is_the_absolute_form_of_the_link(): void
{
$article = Article::create([
'title' => 'Test',
'main' => 'Test',
]);
$this->assertEquals(config('app.url').$article->link, $article->uri);
}
#[Test]
public function slug_is_suffixed_when_a_trashed_article_already_used_it(): void
{
$original = Article::create(['title' => 'My Title', 'main' => 'Content']);
$original->delete();
$newArticle = Article::create(['title' => 'My Title', 'main' => 'Other content']);
$this->assertEquals('my-title-2', $newArticle->titleurl);
}
#[Test]
public function date_scope_returns_expected_articles(): void
{