Compare commits

..
50 changed files with 361 additions and 957 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

@ -7,13 +7,66 @@ namespace App\Http\Controllers;
use App\Models\Article;
use App\Models\Note;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Response;
class FeedsController extends Controller
{
/**
* Returns the blog RSS feed.
*/
public function blogRss(): Response
{
$articles = Article::where('published', '1')->latest('updated_at')->take(20)->get();
$buildDate = $articles->first()->updated_at->toRssString();
return response()
->view('articles.rss', compact('articles', 'buildDate'))
->header('Content-Type', 'application/rss+xml; charset=utf-8');
}
/**
* Returns the blog Atom feed.
*/
public function blogAtom(): Response
{
$articles = Article::where('published', '1')->latest('updated_at')->take(20)->get();
return response()
->view('articles.atom', compact('articles'))
->header('Content-Type', 'application/atom+xml; charset=utf-8');
}
/**
* Returns the notes RSS feed.
*/
public function notesRss(): Response
{
$notes = Note::latest()->take(20)->get();
$buildDate = $notes->first()->updated_at->toRssString();
return response()
->view('notes.rss', compact('notes', 'buildDate'))
->header('Content-Type', 'application/rss+xml; charset=utf-8');
}
/**
* Returns the notes Atom feed.
*/
public function notesAtom(): Response
{
$notes = Note::latest()->take(20)->get();
return response()
->view('notes.atom', compact('notes'))
->header('Content-Type', 'application/atom+xml; charset=utf-8');
}
/** @todo sort out return type for json responses */
/**
* Returns the blog JSON feed.
*/
public function blogJson(): JsonResponse
public function blogJson(): array
{
$articles = Article::where('published', '1')->latest('updated_at')->take(20)->get();
$data = [
@ -41,15 +94,13 @@ class FeedsController extends Controller
];
}
return response()->json($data, 200, [
'Content-Type' => 'application/feed+json',
]);
return $data;
}
/**
* Returns the notes JSON feed.
*/
public function notesJson(): JsonResponse
public function notesJson(): array
{
$notes = Note::latest()->with('media', 'place', 'tags')->take(20)->get();
$data = [
@ -79,9 +130,7 @@ class FeedsController extends Controller
}
}
return response()->json($data, 200, [
'Content-Type' => 'application/feed+json',
]);
return $data;
}
/**

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);
'published' => true,
]);
}
}

View file

@ -24,7 +24,6 @@ class EntryData extends MicropubData
public readonly mixed $checkin,
public readonly mixed $syndication,
public readonly ?array $photos,
public readonly ?string $postStatus,
) {}
public static function fromRequest(Request $request): static
@ -50,7 +49,6 @@ class EntryData extends MicropubData
checkin: Arr::get($data, 'properties.checkin.0'),
syndication: Arr::get($data, 'properties.syndication.0'),
photos: Arr::get($data, 'properties.photo'),
postStatus: Arr::get($data, 'properties.post-status.0'),
);
}
@ -69,7 +67,6 @@ class EntryData extends MicropubData
checkin: $request->input('checkin'),
syndication: $request->input('syndication'),
photos: $request->input('photos'),
postStatus: $request->input('post-status'),
);
}
@ -103,7 +100,6 @@ class EntryData extends MicropubData
checkin: $data['checkin'] ?? null,
syndication: $data['syndication'] ?? null,
photos: $data['photos'] ?? null,
postStatus: $data['post-status'] ?? null,
);
}
@ -124,7 +120,6 @@ class EntryData extends MicropubData
'checkin' => $this->checkin,
'syndication' => $this->syndication,
'photos' => $this->photos,
'post-status' => $this->postStatus,
];
}
}

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;
}
svg {
width: 1.5rem;
height: 1.5rem;
fill: currentcolor;
}
@media (hover: hover) {
&:hover {
background-color: color-mix(in oklch, var(--clr-border) 70%, transparent);
}
&[aria-pressed="true"]:hover {
background-color: var(--clr-text);
}
}
&[aria-pressed="true"] {
background-color: var(--clr-text);
color: var(--clr-background);
}
}
}
.sr-only {
/* This is the element with the [popover] attribute */
#theme-selector-dropdown {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
position-area: block-end span-inline-start;
margin: 0;
border: 2px solid var(--clr-border);
background-color: var(--clr-background);
color: var(--clr-text);
fieldset {
border: 0;
display: flex;
flex-direction: row;
gap: 1ch;
input {
display: none;
}
}
}
/*
* 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;
// Update current icon
this.widget.querySelectorAll('.toggle svg').forEach((svg) => {
if (svg.classList.contains(theme)) {
svg.style.display = '';
} else {
svg.style.display = 'none';
}
});
this.btnDark.addEventListener('click', () => {
this.applyTheme(this.currentTheme === 'dark' ? 'system' : 'dark');
});
// Set the theme
let selectedTheme;
switch (theme) {
case 'dark':
case 'light':
selectedTheme = theme;
break;
default:
selectedTheme = 'light dark';
}
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';
const html = document.querySelector('html');
let currentTheme = html.style.getPropertyValue('color-scheme');
if (currentTheme === '') {
currentTheme = 'light dark';
}
/* do we need to transition */
let doTransition = false;
if (selectedTheme !== currentColorScheme) {
if (selectedTheme !== currentTheme) {
if (selectedTheme === 'light dark') {
doTransition = currentColorScheme !== systemTheme;
} else if (currentColorScheme === 'light dark') {
doTransition = currentTheme !== systemTheme;
} else if (currentTheme === 'light dark') {
doTransition = selectedTheme !== systemTheme;
} else {
doTransition = true;
}
}
const applyChange = () => {
this.currentTheme = theme;
html.style.setProperty('color-scheme', selectedTheme);
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' : '';
};
html.style.viewTransitionName = 'changing-theme';
if (doTransition && document.startViewTransition) {
document.startViewTransition(applyChange);
document.startViewTransition(() => {
// Close the popover
document.querySelector('#theme-selector-dropdown').togglePopover();
// Set the colour theme
html.style.setProperty('color-scheme', selectedTheme);
});
} else {
applyChange();
// 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

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>Atom feed for {{ config('user.display_name') }}s blog</title>
<link rel="self" href="{{ config('app.url') }}/blog/feed.atom" />
<id>{{ config('app.url')}}/blog</id>
<updated>{{ $articles[0]->updated_at->toAtomString() }}</updated>
@foreach($articles as $article)
<entry>
<title>{{ $article->title }}</title>
<link href="{{ config('app.url') }}{{ $article->link }}" />
<id>{{ config('app.url') }}{{ $article->link }}</id>
<updated>{{ $article->updated_at->toAtomString() }}</updated>
<content>{{ $article->main }}</content>
<author>
<name>{{ config('user.display_name') }}</name>
</author>
</entry>
@endforeach
</feed>

View file

@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8" ?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>{{ config('user.display_name') }}</title>
<atom:link href="{{ config('app.url') }}/blog/feed.rss" rel="self" type="application/rss+xml" />
<description>An RSS feed of the blog posts found on {{ config('app.url') }}</description>
<link>{{ config('app.url') }}/blog</link>
<lastBuildDate>{{ $buildDate }}</lastBuildDate>
<ttl>1800</ttl>
@foreach($articles as $article)
<item>
<title>{{ strip_tags($article->title) }}</title>
<description>
<![CDATA[
{{ $article->main }}
@if($article->url)<p><a href="{{ config('app.url') }}{{ $article->link }}">Permalink</a></p>@endif
]]>
</description>
<link>@if($article->url != ''){{ $article->url }}@else{{ config('app.url') }}{{ $article->link }}@endif</link>
<guid>{{ config('app.url') }}{{ $article->link }}</guid>
<pubDate>{{ $article->pubdate }}</pubDate>
</item>
@endforeach
</channel>
</rss>

View file

@ -1,11 +0,0 @@
@php
if (isset($title)) {
$uniqueId = bin2hex(random_bytes(6));
}
@endphp
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 200 200" class="feather feather-json-feed"
@if($title)aria-labelledby="{{ $uniqueId }}"@endif
>
@if($title)<title id="{{ $uniqueId }}">{{ $title }}</title>@endif
<path fill="#497714" transform="translate(0,200) scale(0.1,-0.1)" d="M373 1547 c-88 -95 -127 -171 -127 -252 -1 -90 28 -140 164 -280 150 -155 160 -168 160 -219 0 -31 -9 -55 -41 -101 -34 -51 -39 -65 -34 -95 3 -20 18 -46 36 -62 44 -41 89 -38 163 12 32 22 67 40 78 40 46 0 99 -38 227 -164 169 -165 209 -190 305 -190 92 -1 158 32 258 125 l73 67 -59 61 -60 60 -39 -36 c-22 -19 -60 -44 -84 -55 -73 -32 -109 -12 -278 152 -77 76 -156 145 -175 155 -47 24 -110 22 -159 -6 -23 -12 -44 -19 -48 -15 -4 4 2 25 15 46 24 42 29 110 12 154 -5 15 -66 84 -135 156 -138 143 -165 180 -165 227 0 47 15 78 61 128 l42 45 -54 59 c-30 32 -57 59 -61 60 -3 0 -37 -32 -75 -72z M1474 1631 c-36 -22 -58 -75 -48 -115 10 -41 59 -76 106 -76 107 0 137 147 41 199 -31 16 -64 14 -99 -8z M1229 1385 c-55 -30 -73 -89 -44 -145 19 -37 43 -50 95 -50 110 0 139 144 40 195 -36 18 -57 18 -91 0z M949 1111 c-86 -87 29 -228 130 -159 40 27 52 62 41 115 -12 51 -41 73 -97 73 -36 0 -50 -6 -74 -29z"/>
</svg>

View file

@ -7,9 +7,13 @@
<title>@yield('title'){{ config('app.name') }}</title>
<link rel="stylesheet" href="/assets/highlight/nord.css">
<link rel="stylesheet" href="/assets/css/app.css">
<link rel="alternate" type="application/feed+json" title="Blog JSON Feed" href="{{ route('feed.blog.json') }}">
<link rel="alternate" type="application/rss+xml" title="Blog RSS Feed" href="{{ route('feed.blog.rss') }}">
<link rel="alternate" type="application/atom+xml" title="Blog Atom Feed" href="{{ route('feed.blog.atom') }}">
<link rel="alternate" type="application/json" title="Blog JSON Feed" href="{{ route('feed.blog.json') }}">
<link rel="alternate" type="application/jf2feed+json" title="Blog JF2 Feed" href="{{ route('feed.blog.jf2') }}">
<link rel="alternate" type="application/feed+json" title="Notes JSON Feed" href="{{ route('feed.notes.json') }}">
<link rel="alternate" type="application/rss+xml" title="Notes RSS Feed" href="{{ route('feed.notes.rss') }}">
<link rel="alternate" type="application/atom+xml" title="Notes Atom Feed" href="{{ route('feed.notes.atom') }}">
<link rel="alternate" type="application/json" title="Notes JSON Feed" href="{{ route('feed.notes.json') }}">
<link rel="alternate" type="application/jf2feed+json" title="Notes JF2 Feed" href="{{ route('feed.notes.jf2') }}">
<link rel="indieauth-metadata" href="{{ route('indieauth.metadata') }}">
<link rel="authorization_endpoint" href="{{ route('indieauth.start') }}">
@ -36,17 +40,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>
<a href="{{ route('feed.notes.json') }}" class="rss-icon">@include('icons.rss', ['title' => 'RSS 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,7 +106,6 @@
@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)">
@ -91,7 +114,6 @@
style="--snow-fall-color: var(--clr-snow-fall)"
></snow-fall>
</is-land>
@endif
@show
</body>
</html>

View file

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>Atom feed for {{ config('user.display_name') }}s notes</title>
<link rel="self" href="{{ config('app.url') }}/notes/feed.atom" />
<id>{{ config('app.url')}}/notes</id>
<updated>{{ $notes[0]->updated_at->toAtomString() }}</updated>
@foreach($notes as $note)
<entry>
<title>{{ strip_tags($note->note) }}</title>
<link href="{{ $note->uri }}" />
<id>{{ $note->uri }}</id>
<updated>{{ $note->updated_at->toAtomString() }}</updated>
<content type="html">{{ $note->note }}</content>
<author>
<name>{{ config('user.display_name') }}</name>
</author>
</entry>
@endforeach
</feed>

View file

@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8" ?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>{{ config('user.display_name') }}</title>
<atom:link href="{{ config('app.url') }}/notes/feed.rss" rel="self" type="application/rss+xml" />
<description>An RSS feed of the notes found on {{ config('app.url') }}</description>
<link>{{ config('app.url') }}/notes</link>
<lastBuildDate>{{ $buildDate }}</lastBuildDate>
<ttl>1800</ttl>
@foreach($notes as $note)
<item>
<title>{{ strip_tags($note->note) }}</title>
<description>
<![CDATA[
{!! $note->note !!}
]]>
</description>
<link>{{ $note->uri }}</link>
<guid>{{ $note->uri}}</guid>
<pubDate>{{ $note->pubdate }}</pubDate>
</item>
@endforeach
</channel>
</rss>

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']);
@ -190,6 +170,8 @@ Route::middleware(MyAuthMiddleware::class)->prefix('admin')->group(function () {
// Blog pages using ArticlesController
Route::prefix('blog')->group(function () {
Route::get('/feed.rss', [FeedsController::class, 'blogRss'])->name('feed.blog.rss');
Route::get('/feed.atom', [FeedsController::class, 'blogAtom'])->name('feed.blog.atom');
Route::get('/feed.json', [FeedsController::class, 'blogJson'])->name('feed.blog.json');
Route::get('/feed.jf2', [FeedsController::class, 'blogJf2'])->name('feed.blog.jf2');
Route::get('/s/{id}', [ArticlesController::class, 'onlyIdInURL']);
@ -200,6 +182,8 @@ Route::prefix('blog')->group(function () {
// Notes pages using NotesController
Route::prefix('notes')->group(function () {
Route::get('/', [NotesController::class, 'index']);
Route::get('/feed.rss', [FeedsController::class, 'notesRss'])->name('feed.notes.rss');
Route::get('/feed.atom', [FeedsController::class, 'notesAtom'])->name('feed.notes.atom');
Route::get('/feed.json', [FeedsController::class, 'notesJson'])->name('feed.notes.json');
Route::get('/feed.jf2', [FeedsController::class, 'notesJf2'])->name('feed.notes.jf2');
Route::get('/new', [NotesController::class, 'create']);

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

@ -15,6 +15,42 @@ class FeedsTest extends TestCase
{
use RefreshDatabase;
/**
* Test the blog RSS feed.
*/
#[Test]
public function blog_rss_feed_is_present(): void
{
Article::factory()->count(3)->create();
$response = $this->get('/blog/feed.rss');
$response->assertHeader('Content-Type', 'application/rss+xml; charset=utf-8');
$response->assertOk();
}
/**
* Test the notes RSS feed.
*/
#[Test]
public function notes_rss_feed_is_present(): void
{
Note::factory()->count(3)->create();
$response = $this->get('/notes/feed.rss');
$response->assertHeader('Content-Type', 'application/rss+xml; charset=utf-8');
$response->assertOk();
}
/**
* Test the blog RSS feed.
*/
#[Test]
public function blog_atom_feed_is_present(): void
{
Article::factory()->count(3)->create();
$response = $this->get('/blog/feed.atom');
$response->assertHeader('Content-Type', 'application/atom+xml; charset=utf-8');
$response->assertOk();
}
#[Test]
public function blog_jf2_feed_is_present(): void
{
@ -37,6 +73,18 @@ class FeedsTest extends TestCase
]);
}
/**
* Test the notes RSS feed.
*/
#[Test]
public function notes_atom_feed_is_present(): void
{
Note::factory()->count(3)->create();
$response = $this->get('/notes/feed.atom');
$response->assertHeader('Content-Type', 'application/atom+xml; charset=utf-8');
$response->assertOk();
}
/**
* Test the blog JSON feed.
*/
@ -45,7 +93,7 @@ class FeedsTest extends TestCase
{
Article::factory()->count(3)->create();
$response = $this->get('/blog/feed.json');
$response->assertHeader('Content-Type', 'application/feed+json');
$response->assertHeader('Content-Type', 'application/json');
$response->assertOk();
}
@ -57,7 +105,7 @@ class FeedsTest extends TestCase
{
Note::factory()->count(3)->create();
$response = $this->get('/notes/feed.json');
$response->assertHeader('Content-Type', 'application/feed+json');
$response->assertHeader('Content-Type', 'application/json');
$response->assertOk();
}

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',
[
@ -877,99 +869,6 @@ class MicropubControllerTest extends TestCase
$this->assertDatabaseHas('articles', [
'title' => $name,
'main' => $content,
'published' => true,
]);
$response->assertHeader('Location');
$this->assertStringStartsWith(config('app.url').'/blog/', $response->headers->get('Location'));
}
#[Test]
public function micropub_client_api_request_creates_an_unpublished_article_when_post_status_is_draft(): void
{
$faker = Factory::create();
$name = $faker->text(50);
$content = $faker->paragraphs(5, true);
$response = $this->postJson(
'/api/post',
[
'type' => ['h-entry'],
'properties' => [
'name' => [$name],
'content' => [$content],
'post-status' => ['draft'],
],
],
['HTTP_Authorization' => 'Bearer '.$this->getToken()]
);
$response
->assertJson(['response' => 'created'])
->assertStatus(201);
$this->assertDatabaseHas('articles', [
'title' => $name,
'main' => $content,
'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
{