diff --git a/app/Http/Controllers/AboutPageController.php b/app/Http/Controllers/AboutPageController.php
new file mode 100644
index 00000000..d174e550
--- /dev/null
+++ b/app/Http/Controllers/AboutPageController.php
@@ -0,0 +1,16 @@
+ About::first()?->content,
+ ]);
+ }
+}
diff --git a/app/Http/Controllers/Admin/AboutController.php b/app/Http/Controllers/Admin/AboutController.php
new file mode 100644
index 00000000..cc5be0ad
--- /dev/null
+++ b/app/Http/Controllers/Admin/AboutController.php
@@ -0,0 +1,32 @@
+ $about,
+ ]);
+ }
+
+ public function update(Request $request): RedirectResponse
+ {
+ $about = About::firstOrNew();
+ $about->content = $request->input('content');
+ $about->save();
+
+ return redirect()->route('admin.about.show');
+ }
+}
diff --git a/app/Http/Controllers/Admin/TokensController.php b/app/Http/Controllers/Admin/TokensController.php
index 1b5348f9..02b9660c 100644
--- a/app/Http/Controllers/Admin/TokensController.php
+++ b/app/Http/Controllers/Admin/TokensController.php
@@ -6,7 +6,9 @@ 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
@@ -21,6 +23,36 @@ 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.
*/
diff --git a/app/Http/Controllers/MicropubController.php b/app/Http/Controllers/MicropubController.php
index 2df5d432..72242150 100644
--- a/app/Http/Controllers/MicropubController.php
+++ b/app/Http/Controllers/MicropubController.php
@@ -70,7 +70,9 @@ class MicropubController extends Controller
'error' => 'invalid_request',
'error_description' => 'No known note with given ID',
], 404);
- } catch (MicropubUnsupportedModelException) {
+ } catch (MicropubUnsupportedModelException $e) {
+ report($e);
+
return response()->json([
'error' => 'invalid',
'error_description' => 'This implementation currently only supports the updating of notes',
@@ -80,12 +82,16 @@ class MicropubController extends Controller
'error' => 'invalid_request',
'error_description' => $e->getMessage(),
], 400);
- } catch (MicropubHandlerException) {
+ } catch (MicropubHandlerException $e) {
+ report($e);
+
return response()->json([
'error' => 'unsupported_operation',
'error_description' => 'The request could not be processed by this server',
], 500);
- } catch (\Exception $e) {
+ } catch (\Throwable $e) {
+ report($e);
+
return response()->json([
'error' => 'server_error',
'error_description' => 'An error occurred processing the request',
diff --git a/app/Models/About.php b/app/Models/About.php
new file mode 100644
index 00000000..26b6a719
--- /dev/null
+++ b/app/Models/About.php
@@ -0,0 +1,13 @@
+ [
'source' => 'title',
+ 'includeTrashed' => true,
],
];
}
@@ -93,6 +94,13 @@ 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.
*/
diff --git a/app/Services/ArticleService.php b/app/Services/ArticleService.php
index 2372ffb7..ab91f9e4 100644
--- a/app/Services/ArticleService.php
+++ b/app/Services/ArticleService.php
@@ -8,12 +8,29 @@ use App\Models\Article;
class ArticleService
{
+ /**
+ * @throws \InvalidArgumentException if a published article already has this title
+ */
public function create(array $data): Article
{
- return Article::create([
+ $attributes = [
'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);
}
}
diff --git a/app/Services/Micropub/Handlers/EntryHandler.php b/app/Services/Micropub/Handlers/EntryHandler.php
index 48bbb550..d79a0418 100644
--- a/app/Services/Micropub/Handlers/EntryHandler.php
+++ b/app/Services/Micropub/Handlers/EntryHandler.php
@@ -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)->link,
+ isset($dataArray['name']) => resolve(ArticleService::class)->create($dataArray)->uri,
default => resolve(NoteService::class)->create($dataArray)->uri,
};
diff --git a/database/factories/AboutFactory.php b/database/factories/AboutFactory.php
new file mode 100644
index 00000000..5af2532e
--- /dev/null
+++ b/database/factories/AboutFactory.php
@@ -0,0 +1,24 @@
+
+ */
+class AboutFactory extends Factory
+{
+ /**
+ * Define the model's default state.
+ *
+ * @return array
+ */
+ public function definition(): array
+ {
+ return [
+ 'content' => $this->faker->paragraph,
+ ];
+ }
+}
diff --git a/database/migrations/2026_08_28_142830_create_about_table.php b/database/migrations/2026_08_28_142830_create_about_table.php
new file mode 100644
index 00000000..15ab6f84
--- /dev/null
+++ b/database/migrations/2026_08_28_142830_create_about_table.php
@@ -0,0 +1,28 @@
+id();
+ $table->text('content');
+ $table->timestamps();
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('about');
+ }
+};
diff --git a/public/assets/css/app.css b/public/assets/css/app.css
index 5c50c0b4..e255209f 100644
--- a/public/assets/css/app.css
+++ b/public/assets/css/app.css
@@ -1,2 +1,2 @@
-@layer reset{*,:before,:after{box-sizing:border-box}html{-webkit-text-size-adjust:none;-moz-text-size-adjust:none;text-size-adjust:none}body,h1,h2,h3,h4,p,figure,blockquote,dl,dd{margin-block-end:0}ul[role=list],ol[role=list]{list-style:none}body{line-height:1.5}h1,h2,h3,h4,button,input,label{line-height:1.1}h1,h2,h3,h4{text-wrap:balance}a:not([class]){text-decoration-skip-ink:auto;color:currentColor}img,picture{max-width:100%;height:auto;display:block}input,button,textarea,select{font-family:inherit;font-size:inherit}textarea:not([rows]){min-height:10em}:target{scroll-margin-block:5ex}@media (prefers-reduced-motion){::view-transition-group(*),::view-transition-old(*),::view-transition-new(*){animation:none!important}}}@layer base{:root{color-scheme:light dark;--primary-hue:307;--clr-text:light-dark(oklch(5% .1 var(--primary-hue)),oklch(100% .1 var(--primary-hue)));--clr-background:light-dark(oklch(100% .1 var(--primary-hue)),oklch(15% .15 var(--primary-hue)));--clr-border:light-dark(oklch(90% .2 var(--primary-hue)),oklch(25% .1 var(--primary-hue)));--clr-snow-fall:light-dark(oklch(25% .15 var(--primary-hue)),oklch(85% .2 var(--primary-hue)))}body{background-color:var(--clr-background);color:var(--clr-text)}:root{--border-radius:8px}html{font-size:125%}body{grid-template-rows:min-content 1fr min-content;grid-template-columns:1fr min(80ch,80vw) 1fr;padding-inline:4vw;display:grid;&>header{grid-column:1/-1}&>main{grid-column:1/-1;grid-template-columns:subgrid;display:grid;&>*{grid-column:2/3}&>.full-bleed{grid-column:1/-1}}&>footer{grid-column:1/-1;margin-block-start:2ex;& search form{flex-direction:row;align-items:center;gap:1ex;display:flex;& input{min-width:0}}}}.h-feed{flex-direction:column;gap:2ex;display:flex}}@layer components{.h-entry{& pre{padding:1rem}}body>header{grid-template-rows:auto auto;grid-template-columns:auto 1fr auto;align-items:baseline;gap:1rem;display:grid;& a{text-decoration:none}& h1{text-wrap:nowrap;margin:0}& nav{flex-direction:row;gap:.5rem;display:flex;@media screen and (width<=1100px){flex-wrap:wrap;grid-area:2/1/auto/span 3}}}.note{border:1px solid var(--clr-border);border-radius:var(--border-radius);flex-direction:column;padding:1ex 2ex;display:flex;& .e-content{&>p:first-child{margin-block-start:0}& .u-photo{width:100%;height:auto}}& .syndication-links{flex-direction:row;gap:1ex;display:flex;& svg{border-radius:4px;width:auto;height:1lh}}}.reply-to{border:1px solid var(--clr-border);border-top-left-radius:var(--border-radius);border-top-right-radius:var(--border-radius);border-bottom:none;margin-inline:2ex;padding-inline:1ex;font-size:85%}.pagination{flex-direction:row;justify-content:center;align-items:center;display:flex;& div{flex-direction:row;gap:1ex;display:flex}}#theme-selector{background-color:color-mix(in oklch, var(--clr-border) 40%, transparent);border-radius:999px;justify-self:start;align-items:center;gap:.25rem;padding:.25rem;display:flex;& button{appearance:none;color:inherit;background:0 0;border:none;border-radius:999px;place-items:center;padding:.375rem;transition:background-color .2s,color .2s;display:grid;&:hover{cursor:pointer}&:focus-visible{outline:2px solid var(--clr-text);outline-offset:2px}& svg{fill:currentColor;width:1.5rem;height:1.5rem}@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{clip-path:inset(50%);white-space:nowrap;border:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}::view-transition-group(changing-theme){animation-duration:1.25s}::view-transition-new(changing-theme),::view-transition-old(changing-theme){mix-blend-mode:normal}::view-transition-new(changing-theme){animation-name:reveal}::view-transition-old(changing-theme){animation:none}@keyframes reveal{0%{clip-path:polygon(-30% 0,-30% 0,-15% 100%,-10% 115%)}to{clip-path:polygon(-30% 0,130% 0,115% 100%,-10% 115%)}}.token-list-wrapper{border:1px solid var(--clr-border);background:light-dark(oklch(99% .02 var(--primary-hue)),oklch(22% .05 var(--primary-hue)));border-radius:16px;margin-block-start:1em;overflow:auto hidden}.token-list{table-layout:fixed;border-collapse:collapse;width:100%;min-width:640px;& th,& td{text-align:left;border-bottom:1px solid var(--clr-border);vertical-align:middle;padding:.9em 1.2em}& th{text-transform:uppercase;letter-spacing:.08em;opacity:.7;font-size:.8em;font-weight:700}& tr.is-revoked{opacity:.6}& a{word-break:break-all}& th:first-child,& td:first-child{width:31%}& th:nth-child(2),& td:nth-child(2){width:24%}& th:nth-child(3),& td:nth-child(3){width:15%}& th:nth-child(4),& td:nth-child(4){width:16%}& th:nth-child(5),& td:nth-child(5){text-align:right;width:14%}& tr:last-child td{border-bottom:none}}.scope-chips{flex-wrap:wrap;gap:.4em;display:flex}.scope-chip{background:light-dark(oklch(92% .05 var(--primary-hue)),oklch(35% .08 var(--primary-hue)));white-space:nowrap;border-radius:999px;padding:.2em .7em;font-size:.85em;display:inline-block}.badge,.token-list button.revoke{white-space:nowrap;border-radius:999px;align-items:center;gap:.4em;padding:.3em .8em;font-size:.85em;font-weight:600;line-height:1.5;display:inline-flex}.badge{background:0 0;border:1px solid}.badge-active{color:light-dark(oklch(35% .16 145),oklch(75% .16 145));& .dot{background:currentColor;border-radius:50%;width:6px;height:6px}}.badge-revoked{color:var(--clr-text);opacity:.7}.token-list button.revoke{color:light-dark(oklch(30% .15 25),oklch(92% .12 25));cursor:pointer;background:light-dark(oklch(92% .15 25),oklch(30% .12 25));border:1px solid #0000;transition:background-color .15s}.token-list button.revoke:hover{background:light-dark(oklch(80% .2 25),oklch(45% .18 25))}}
+@layer reset{*,:before,:after{box-sizing:border-box}html{-webkit-text-size-adjust:none;-moz-text-size-adjust:none;text-size-adjust:none}body,h1,h2,h3,h4,p,figure,blockquote,dl,dd{margin-block-end:0}ul[role=list],ol[role=list]{list-style:none}body{line-height:1.5}h1,h2,h3,h4,button,input,label{line-height:1.1}h1,h2,h3,h4{text-wrap:balance}a:not([class]){text-decoration-skip-ink:auto;color:currentColor}img,picture{max-width:100%;height:auto;display:block}input,button,textarea,select{font-family:inherit;font-size:inherit}textarea:not([rows]){min-height:10em}:target{scroll-margin-block:5ex}@media (prefers-reduced-motion){::view-transition-group(*),::view-transition-old(*),::view-transition-new(*){animation:none!important}}}@layer base{:root{color-scheme:light dark;--primary-hue:307;--clr-text:light-dark(oklch(5% .1 var(--primary-hue)),oklch(100% .1 var(--primary-hue)));--clr-background:light-dark(oklch(100% .1 var(--primary-hue)),oklch(15% .15 var(--primary-hue)));--clr-border:light-dark(oklch(90% .2 var(--primary-hue)),oklch(25% .1 var(--primary-hue)));--clr-snow-fall:light-dark(oklch(25% .15 var(--primary-hue)),oklch(85% .2 var(--primary-hue)))}body{background-color:var(--clr-background);color:var(--clr-text)}:root{--border-radius:8px}html{font-size:125%}body{grid-template-rows:min-content 1fr min-content;grid-template-columns:1fr min(80ch,80vw) 1fr;padding-inline:4vw;display:grid;&>header{grid-column:1/-1}&>main{grid-column:1/-1;grid-template-columns:subgrid;display:grid;&>*{grid-column:2/3}&>.full-bleed{grid-column:1/-1}}&>footer{grid-column:1/-1;margin-block-start:2ex;& search form{flex-direction:row;align-items:center;gap:1ex;display:flex;& input{min-width:0}}}}.h-feed{flex-direction:column;gap:2ex;display:flex}}@layer components{.h-entry{& pre{padding:1rem}}body>header{grid-template-rows:auto auto;grid-template-columns:auto 1fr auto;align-items:baseline;gap:1rem;display:grid;& a{text-decoration:none}& h1{text-wrap:nowrap;margin:0}& nav{flex-direction:row;gap:.5rem;display:flex;@media screen and (width<=1100px){flex-wrap:wrap;grid-area:2/1/auto/span 3}}}.note{border:1px solid var(--clr-border);border-radius:var(--border-radius);flex-direction:column;padding:1ex 2ex;display:flex;& .e-content{&>p:first-child{margin-block-start:0}& .u-photo{width:100%;height:auto}}& .syndication-links{flex-direction:row;gap:1ex;display:flex;& svg{border-radius:4px;width:auto;height:1lh}}}.reply-to{border:1px solid var(--clr-border);border-top-left-radius:var(--border-radius);border-top-right-radius:var(--border-radius);border-bottom:none;margin-inline:2ex;padding-inline:1ex;font-size:85%}.pagination{flex-direction:row;justify-content:center;align-items:center;display:flex;& div{flex-direction:row;gap:1ex;display:flex}}#theme-selector{background-color:color-mix(in oklch, var(--clr-border) 40%, transparent);border-radius:999px;justify-self:start;align-items:center;gap:.25rem;padding:.25rem;display:flex;& button{appearance:none;color:inherit;background:0 0;border:none;border-radius:999px;place-items:center;padding:.375rem;transition:background-color .2s,color .2s;display:grid;&:hover{cursor:pointer}&:focus-visible{outline:2px solid var(--clr-text);outline-offset:2px}& svg{fill:currentColor;width:1.5rem;height:1.5rem}@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{clip-path:inset(50%);white-space:nowrap;border:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}::view-transition-group(changing-theme){animation-duration:1.25s}::view-transition-new(changing-theme),::view-transition-old(changing-theme){mix-blend-mode:normal}::view-transition-new(changing-theme){animation-name:reveal}::view-transition-old(changing-theme){animation:none}@keyframes reveal{0%{clip-path:polygon(-30% 0,-30% 0,-15% 100%,-10% 115%)}to{clip-path:polygon(-30% 0,130% 0,115% 100%,-10% 115%)}}.token-list-wrapper{border:1px solid var(--clr-border);background:light-dark(oklch(99% .02 var(--primary-hue)),oklch(22% .05 var(--primary-hue)));border-radius:16px;margin-block-start:1em;overflow:auto hidden}.token-list{table-layout:fixed;border-collapse:collapse;width:100%;min-width:640px;& th,& td{text-align:left;border-bottom:1px solid var(--clr-border);vertical-align:middle;padding:.9em 1.2em}& th{text-transform:uppercase;letter-spacing:.08em;opacity:.7;font-size:.8em;font-weight:700}& tr.is-revoked{opacity:.6}& a{word-break:break-all}& th:first-child,& td:first-child{width:31%}& th:nth-child(2),& td:nth-child(2){width:24%}& th:nth-child(3),& td:nth-child(3){width:15%}& th:nth-child(4),& td:nth-child(4){width:16%}& th:nth-child(5),& td:nth-child(5){text-align:right;width:14%}& tr:last-child td{border-bottom:none}}.scope-chips{flex-wrap:wrap;gap:.4em;display:flex}.scope-chip{background:light-dark(oklch(92% .05 var(--primary-hue)),oklch(35% .08 var(--primary-hue)));white-space:nowrap;border-radius:999px;padding:.2em .7em;font-size:.85em;display:inline-block}.badge,.token-list button.revoke{white-space:nowrap;border-radius:999px;align-items:center;gap:.4em;padding:.3em .8em;font-size:.85em;font-weight:600;line-height:1.5;display:inline-flex}.badge{background:0 0;border:1px solid}.badge-active{color:light-dark(oklch(35% .16 145),oklch(75% .16 145));& .dot{background:currentColor;border-radius:50%;width:6px;height:6px}}.badge-revoked{color:var(--clr-text);opacity:.7}.token-list button.revoke{color:light-dark(oklch(30% .15 25),oklch(92% .12 25));cursor:pointer;background:light-dark(oklch(92% .15 25),oklch(30% .12 25));border:1px solid #0000;transition:background-color .15s}.token-list button.revoke:hover{background:light-dark(oklch(80% .2 25),oklch(45% .18 25))}.token-reveal{border:1px solid var(--clr-border);background:light-dark(oklch(96% .08 145),oklch(28% .08 145));border-radius:16px;margin-block-end:1em;padding:1em 1.2em;& input{border:1px solid var(--clr-border);border-radius:8px;width:100%;padding:.5em .7em;font-family:monospace}}.scope-checkboxes{flex-wrap:wrap;align-items:center;gap:1em;display:flex}}
/*# sourceMappingURL=/assets/css/app.css.map */
diff --git a/public/assets/css/app.css.br b/public/assets/css/app.css.br
index 9ca789b9..200e0d25 100644
Binary files a/public/assets/css/app.css.br and b/public/assets/css/app.css.br differ
diff --git a/public/assets/css/app.css.map b/public/assets/css/app.css.map
index fef4e32c..6413bbe9 100644
--- a/public/assets/css/app.css.map
+++ b/public/assets/css/app.css.map
@@ -1 +1 @@
-{"version":3,"mappings":"AACA,aCGE,uCAOA,oFASA,8DAMA,4CAMA,qBAKA,+CAMA,8BAMA,gEAMA,qDAQA,mEAOA,qCAKA,gCAKA,gCACE,wGDhFJ,YOAE,oaAwBA,kELxBA,0BAIA,oBAIA,iIAME,0BAIA,mEAKE,oBAIA,gCAKF,iDAIE,yEAME,uBAON,oDFjDF,kBEyDE,SACE,oBG1DF,wHAOE,yBAIA,+BAKA,gDAKE,kCAAqC,2CGrBzC,+HAOE,aACE,qCAIA,mCAMF,6DAKE,gDAQJ,4ML/BA,sFAME,+CGNF,0LASE,gLAWE,uBAIA,qEAKA,mDAMA,qBACE,iFAIA,6DAKF,oFAOJ,uIAYA,iEAIA,kGAKA,4DAIA,qDAIA,mIFjFA,iNAWA,mFAME,6GAQA,6FAQA,2BAIA,yBAIA,4CAKA,8CAKA,8CAKA,8CAKA,+DAMA,uCAKF,kDAMA,qMAYA,0LAaA,uCAKA,sEAGE,uEAQF,gDAKA,kNAQA","sources":["resources/css/app.css","resources/css/theme-selector.css","resources/css/layout.css","resources/css/pagination.css","resources/css/admin-tokens.css","resources/css/header.css","resources/css/colours.css","resources/css/reset.css","resources/css/notes.css"],"sourcesContent":["/* First thing we need to do is declare our cascade layers */\n@layer reset, base, components;\n\n@import url('reset.css');\n@import url('colours.css');\n@import url('layout.css');\n@import url('header.css');\n@import url('notes.css');\n@import url('pagination.css');\n@import url('theme-selector.css');\n@import url('admin-tokens.css');\n","@layer components {\n #theme-selector {\n display: flex;\n justify-self: start;\n align-items: center;\n gap: .25rem;\n padding: .25rem;\n border-radius: 999px;\n background-color: color-mix(in oklch, var(--clr-border) 40%, transparent);\n\n button {\n display: grid;\n place-items: center;\n appearance: none;\n border: none;\n border-radius: 999px;\n padding: .375rem;\n background: transparent;\n color: inherit;\n transition: background-color .2s, color .2s;\n\n &:hover {\n cursor: pointer;\n }\n\n &:focus-visible {\n outline: 2px solid var(--clr-text);\n outline-offset: 2px;\n }\n\n svg {\n width: 1.5rem;\n height: 1.5rem;\n fill: currentcolor;\n }\n\n @media (hover: hover) {\n &:hover {\n background-color: color-mix(in oklch, var(--clr-border) 70%, transparent);\n }\n\n &[aria-pressed=\"true\"]:hover {\n background-color: var(--clr-text);\n }\n }\n\n &[aria-pressed=\"true\"] {\n background-color: var(--clr-text);\n color: var(--clr-background);\n }\n }\n }\n\n .sr-only {\n position: absolute;\n width: 1px;\n height: 1px;\n padding: 0;\n margin: -1px;\n overflow: hidden;\n clip-path: inset(50%);\n white-space: nowrap;\n border: 0;\n }\n\n ::view-transition-group(changing-theme) {\n animation-duration: 1.25s;\n }\n\n ::view-transition-new(changing-theme),\n ::view-transition-old(changing-theme) {\n mix-blend-mode: normal;\n }\n\n ::view-transition-new(changing-theme) {\n animation-name: reveal;\n }\n\n ::view-transition-old(changing-theme) {\n animation: none;\n }\n\n @keyframes reveal {\n from {\n clip-path: polygon(-30% 0, -30% 0, -15% 100%, -10% 115%);\n }\n\n to {\n clip-path: polygon(-30% 0, 130% 0, 115% 100%, -10% 115%);\n }\n }\n}\n","@layer base {\n :root {\n --border-radius: 8px;\n }\n\n html {\n font-size: 125%;\n }\n\n body {\n display: grid;\n grid-template-columns: 1fr min(80ch, 80vw) 1fr;\n grid-template-rows: min-content 1fr min-content;\n padding-inline: 4vw;\n\n > header {\n grid-column: 1/-1;\n }\n\n > main {\n grid-column: 1/-1;\n display: grid;\n grid-template-columns: subgrid;\n\n > * {\n grid-column: 2/3;\n }\n\n > .full-bleed {\n grid-column: 1/-1;\n }\n }\n\n > footer {\n grid-column: 1/-1;\n margin-block-start: 2ex;\n\n search form {\n display: flex;\n flex-direction: row;\n align-items: center;\n gap: 1ex;\n\n input {\n min-width: 0;\n }\n }\n }\n }\n\n .h-feed {\n display: flex;\n flex-direction: column;\n gap: 2ex;\n }\n}\n\n@layer components {\n .h-entry {\n pre {\n padding: 1rem;\n }\n }\n}\n\n","@layer components {\n .pagination {\n display: flex;\n flex-direction: row;\n justify-content: center;\n align-items: center;\n\n div {\n display: flex;\n flex-direction: row;\n gap: 1ex;\n }\n }\n}\n","@layer components {\n .token-list-wrapper {\n margin-block-start: 1em;\n border: 1px solid var(--clr-border);\n border-radius: 16px;\n overflow: auto hidden;\n background: light-dark(\n oklch(99% 0.02 var(--primary-hue)),\n oklch(22% 0.05 var(--primary-hue))\n );\n }\n\n .token-list {\n width: 100%;\n min-width: 640px;\n table-layout: fixed;\n border-collapse: collapse;\n\n th,\n td {\n text-align: left;\n padding: 0.9em 1.2em;\n border-bottom: 1px solid var(--clr-border);\n vertical-align: middle;\n }\n\n th {\n font-size: 0.8em;\n font-weight: 700;\n text-transform: uppercase;\n letter-spacing: 0.08em;\n opacity: 0.7;\n }\n\n tr.is-revoked {\n opacity: 0.6;\n }\n\n a {\n word-break: break-all;\n }\n\n th:nth-child(1),\n td:nth-child(1) {\n width: 31%;\n }\n\n th:nth-child(2),\n td:nth-child(2) {\n width: 24%;\n }\n\n th:nth-child(3),\n td:nth-child(3) {\n width: 15%;\n }\n\n th:nth-child(4),\n td:nth-child(4) {\n width: 16%;\n }\n\n th:nth-child(5),\n td:nth-child(5) {\n width: 14%;\n text-align: right;\n }\n\n tr:last-child td {\n border-bottom: none;\n }\n }\n\n .scope-chips {\n display: flex;\n flex-wrap: wrap;\n gap: 0.4em;\n }\n\n .scope-chip {\n display: inline-block;\n padding: 0.2em 0.7em;\n border-radius: 999px;\n background: light-dark(\n oklch(92% 0.05 var(--primary-hue)),\n oklch(35% 0.08 var(--primary-hue))\n );\n font-size: 0.85em;\n white-space: nowrap;\n }\n\n .badge,\n .token-list button.revoke {\n display: inline-flex;\n align-items: center;\n gap: 0.4em;\n padding: 0.3em 0.8em;\n border-radius: 999px;\n font-size: 0.85em;\n font-weight: 600;\n line-height: 1.5;\n white-space: nowrap;\n }\n\n .badge {\n background: transparent;\n border: 1px solid currentcolor;\n }\n\n .badge-active {\n color: light-dark(oklch(35% 0.16 145deg), oklch(75% 0.16 145deg));\n\n .dot {\n width: 6px;\n height: 6px;\n border-radius: 50%;\n background: currentcolor;\n }\n }\n\n .badge-revoked {\n color: var(--clr-text);\n opacity: 0.7;\n }\n\n .token-list button.revoke {\n background: light-dark(oklch(92% 0.15 25deg), oklch(30% 0.12 25deg));\n color: light-dark(oklch(30% 0.15 25deg), oklch(92% 0.12 25deg));\n border: 1px solid transparent;\n cursor: pointer;\n transition: background-color 150ms ease;\n }\n\n .token-list button.revoke:hover {\n background: light-dark(oklch(80% 0.2 25deg), oklch(45% 0.18 25deg));\n }\n}\n","@layer components {\n body > header {\n display: grid;\n grid-template-columns: auto 1fr auto;\n grid-template-rows: auto auto;\n align-items: baseline;\n gap: 1rem;\n\n a {\n text-decoration: none;\n }\n\n h1 {\n margin: 0;\n text-wrap: nowrap;\n }\n\n nav {\n display: flex;\n flex-direction: row;\n gap: .5rem;\n\n @media screen and (width <= 1100px) {\n grid-row: 2;\n grid-column: 1 / span 3;\n flex-wrap: wrap;\n }\n }\n }\n}\n","@layer base {\n :root {\n color-scheme: light dark;\n\n --primary-hue: 307;\n --clr-text: light-dark(\n oklch(5% 0.1 var(--primary-hue)),\n oklch(100% 0.1 var(--primary-hue))\n );\n --clr-background: light-dark(\n oklch(100% 0.1 var(--primary-hue)),\n oklch(15% 0.15 var(--primary-hue))\n );\n --clr-border: light-dark(\n oklch(90% 0.2 var(--primary-hue)),\n oklch(25% 0.1 var(--primary-hue))\n );\n\n /* Adding snow fall colour whilst we are using it */\n --clr-snow-fall: light-dark(\n oklch(25% 0.15 var(--primary-hue)),\n oklch(85% 0.2 var(--primary-hue))\n );\n }\n\n body {\n background-color: var(--clr-background);\n color: var(--clr-text);\n }\n}\n","@layer reset {\n /* Sourced from https://piccalil.li/blog/a-more-modern-css-reset/ */\n\n /* Box sizing rules */\n *,\n *::before,\n *::after {\n box-sizing: border-box;\n }\n\n /* Prevent font size inflation */\n html {\n /* stylelint-disable property-no-vendor-prefix */\n -moz-text-size-adjust: none;\n -webkit-text-size-adjust: none;\n /* stylelint-enable property-no-vendor-prefix */\n text-size-adjust: none;\n }\n\n /* Remove default margin in favour of better control in authored CSS */\n body, h1, h2, h3, h4, p,\n figure, blockquote, dl, dd {\n margin-block-end: 0;\n }\n\n /* Remove list styles on ul, ol elements with a list role, which suggests default styling will be removed */\n ul[role='list'],\n ol[role='list'] {\n list-style: none;\n }\n\n /* Set core body defaults */\n body {\n line-height: 1.5;\n }\n\n /* Set shorter line heights on headings and interactive elements */\n h1, h2, h3, h4,\n button, input, label {\n line-height: 1.1;\n }\n\n /* Balance text wrapping on headings */\n h1, h2,\n h3, h4 {\n text-wrap: balance;\n }\n\n /* A elements that don't have a class get default styles */\n a:not([class]) {\n text-decoration-skip-ink: auto;\n color: currentcolor;\n }\n\n /* Make images easier to work with */\n img,\n picture {\n max-width: 100%;\n height: auto;\n display: block;\n }\n\n /* Inherit fonts for inputs and buttons */\n input, button,\n textarea, select {\n font-family: inherit;\n font-size: inherit;\n }\n\n /* Make sure textareas without a rows attribute are not tiny */\n textarea:not([rows]) {\n min-height: 10em;\n }\n\n /* Anything that has been anchored to should have extra scroll margin */\n :target {\n scroll-margin-block: 5ex;\n }\n\n /* Disable view transition animations for users who don’t want them */\n @media (prefers-reduced-motion) {\n ::view-transition-group(*),\n ::view-transition-old(*),\n ::view-transition-new(*) {\n animation: none !important;\n }\n }\n}\n","@layer components {\n .note {\n display: flex;\n flex-direction: column;\n border: 1px solid var(--clr-border);\n border-radius: var(--border-radius);\n padding: 1ex 2ex;\n\n .e-content {\n > p:first-child {\n margin-block-start: 0;\n }\n\n .u-photo {\n width: 100%;\n height: auto;\n }\n }\n\n .syndication-links {\n display: flex;\n flex-direction: row;\n gap: 1ex;\n\n svg {\n border-radius: 4px;\n width: auto;\n height: 1lh;\n }\n }\n }\n\n .reply-to {\n font-size: 85%;\n margin-inline: 2ex;\n padding-inline: 1ex;\n border: 1px solid var(--clr-border);\n border-bottom: none;\n border-top-left-radius: var(--border-radius);\n border-top-right-radius: var(--border-radius);\n }\n}\n"],"names":[]}
\ No newline at end of file
+{"version":3,"mappings":"AACA,aCGE,uCAOA,oFASA,8DAMA,4CAMA,qBAKA,+CAMA,8BAMA,gEAMA,qDAQA,mEAOA,qCAKA,gCAKA,gCACE,wGDhFJ,YEAE,oaAwBA,kECxBA,0BAIA,oBAIA,iIAME,0BAIA,mEAKE,oBAIA,gCAKF,iDAIE,yEAME,uBAON,oDHjDF,kBGyDE,SACE,oBC1DF,wHAOE,yBAIA,+BAKA,gDAKE,kCAAqC,2CIrBzC,+HAOE,aACE,qCAIA,mCAMF,6DAKE,gDAQJ,4MH/BA,sFAME,+CENF,0LASE,gLAWE,uBAIA,qEAKA,mDAMA,qBACE,iFAIA,6DAKF,oFAOJ,uIAYA,iEAIA,kGAKA,4DAIA,qDAIA,mIDjFA,iNAWA,mFAME,6GAQA,6FAQA,2BAIA,yBAIA,4CAKA,8CAKA,8CAKA,8CAKA,+DAMA,uCAKF,kDAMA,qMAYA,0LAaA,uCAKA,sEAGE,uEAQF,gDAKA,kNAQA,0FAIA,wKAUE,iHASF","sources":["resources/css/app.css","resources/css/colours.css","resources/css/layout.css","resources/css/header.css","resources/css/reset.css","resources/css/pagination.css","resources/css/admin-tokens.css","resources/css/theme-selector.css","resources/css/notes.css"],"sourcesContent":["/* First thing we need to do is declare our cascade layers */\n@layer reset, base, components;\n\n@import url('reset.css');\n@import url('colours.css');\n@import url('layout.css');\n@import url('header.css');\n@import url('notes.css');\n@import url('pagination.css');\n@import url('theme-selector.css');\n@import url('admin-tokens.css');\n","@layer base {\n :root {\n color-scheme: light dark;\n\n --primary-hue: 307;\n --clr-text: light-dark(\n oklch(5% 0.1 var(--primary-hue)),\n oklch(100% 0.1 var(--primary-hue))\n );\n --clr-background: light-dark(\n oklch(100% 0.1 var(--primary-hue)),\n oklch(15% 0.15 var(--primary-hue))\n );\n --clr-border: light-dark(\n oklch(90% 0.2 var(--primary-hue)),\n oklch(25% 0.1 var(--primary-hue))\n );\n\n /* Adding snow fall colour whilst we are using it */\n --clr-snow-fall: light-dark(\n oklch(25% 0.15 var(--primary-hue)),\n oklch(85% 0.2 var(--primary-hue))\n );\n }\n\n body {\n background-color: var(--clr-background);\n color: var(--clr-text);\n }\n}\n","@layer base {\n :root {\n --border-radius: 8px;\n }\n\n html {\n font-size: 125%;\n }\n\n body {\n display: grid;\n grid-template-columns: 1fr min(80ch, 80vw) 1fr;\n grid-template-rows: min-content 1fr min-content;\n padding-inline: 4vw;\n\n > header {\n grid-column: 1/-1;\n }\n\n > main {\n grid-column: 1/-1;\n display: grid;\n grid-template-columns: subgrid;\n\n > * {\n grid-column: 2/3;\n }\n\n > .full-bleed {\n grid-column: 1/-1;\n }\n }\n\n > footer {\n grid-column: 1/-1;\n margin-block-start: 2ex;\n\n search form {\n display: flex;\n flex-direction: row;\n align-items: center;\n gap: 1ex;\n\n input {\n min-width: 0;\n }\n }\n }\n }\n\n .h-feed {\n display: flex;\n flex-direction: column;\n gap: 2ex;\n }\n}\n\n@layer components {\n .h-entry {\n pre {\n padding: 1rem;\n }\n }\n}\n\n","@layer components {\n body > header {\n display: grid;\n grid-template-columns: auto 1fr auto;\n grid-template-rows: auto auto;\n align-items: baseline;\n gap: 1rem;\n\n a {\n text-decoration: none;\n }\n\n h1 {\n margin: 0;\n text-wrap: nowrap;\n }\n\n nav {\n display: flex;\n flex-direction: row;\n gap: .5rem;\n\n @media screen and (width <= 1100px) {\n grid-row: 2;\n grid-column: 1 / span 3;\n flex-wrap: wrap;\n }\n }\n }\n}\n","@layer reset {\n /* Sourced from https://piccalil.li/blog/a-more-modern-css-reset/ */\n\n /* Box sizing rules */\n *,\n *::before,\n *::after {\n box-sizing: border-box;\n }\n\n /* Prevent font size inflation */\n html {\n /* stylelint-disable property-no-vendor-prefix */\n -moz-text-size-adjust: none;\n -webkit-text-size-adjust: none;\n /* stylelint-enable property-no-vendor-prefix */\n text-size-adjust: none;\n }\n\n /* Remove default margin in favour of better control in authored CSS */\n body, h1, h2, h3, h4, p,\n figure, blockquote, dl, dd {\n margin-block-end: 0;\n }\n\n /* Remove list styles on ul, ol elements with a list role, which suggests default styling will be removed */\n ul[role='list'],\n ol[role='list'] {\n list-style: none;\n }\n\n /* Set core body defaults */\n body {\n line-height: 1.5;\n }\n\n /* Set shorter line heights on headings and interactive elements */\n h1, h2, h3, h4,\n button, input, label {\n line-height: 1.1;\n }\n\n /* Balance text wrapping on headings */\n h1, h2,\n h3, h4 {\n text-wrap: balance;\n }\n\n /* A elements that don't have a class get default styles */\n a:not([class]) {\n text-decoration-skip-ink: auto;\n color: currentcolor;\n }\n\n /* Make images easier to work with */\n img,\n picture {\n max-width: 100%;\n height: auto;\n display: block;\n }\n\n /* Inherit fonts for inputs and buttons */\n input, button,\n textarea, select {\n font-family: inherit;\n font-size: inherit;\n }\n\n /* Make sure textareas without a rows attribute are not tiny */\n textarea:not([rows]) {\n min-height: 10em;\n }\n\n /* Anything that has been anchored to should have extra scroll margin */\n :target {\n scroll-margin-block: 5ex;\n }\n\n /* Disable view transition animations for users who don’t want them */\n @media (prefers-reduced-motion) {\n ::view-transition-group(*),\n ::view-transition-old(*),\n ::view-transition-new(*) {\n animation: none !important;\n }\n }\n}\n","@layer components {\n .pagination {\n display: flex;\n flex-direction: row;\n justify-content: center;\n align-items: center;\n\n div {\n display: flex;\n flex-direction: row;\n gap: 1ex;\n }\n }\n}\n","@layer components {\n .token-list-wrapper {\n margin-block-start: 1em;\n border: 1px solid var(--clr-border);\n border-radius: 16px;\n overflow: auto hidden;\n background: light-dark(\n oklch(99% 0.02 var(--primary-hue)),\n oklch(22% 0.05 var(--primary-hue))\n );\n }\n\n .token-list {\n width: 100%;\n min-width: 640px;\n table-layout: fixed;\n border-collapse: collapse;\n\n th,\n td {\n text-align: left;\n padding: 0.9em 1.2em;\n border-bottom: 1px solid var(--clr-border);\n vertical-align: middle;\n }\n\n th {\n font-size: 0.8em;\n font-weight: 700;\n text-transform: uppercase;\n letter-spacing: 0.08em;\n opacity: 0.7;\n }\n\n tr.is-revoked {\n opacity: 0.6;\n }\n\n a {\n word-break: break-all;\n }\n\n th:nth-child(1),\n td:nth-child(1) {\n width: 31%;\n }\n\n th:nth-child(2),\n td:nth-child(2) {\n width: 24%;\n }\n\n th:nth-child(3),\n td:nth-child(3) {\n width: 15%;\n }\n\n th:nth-child(4),\n td:nth-child(4) {\n width: 16%;\n }\n\n th:nth-child(5),\n td:nth-child(5) {\n width: 14%;\n text-align: right;\n }\n\n tr:last-child td {\n border-bottom: none;\n }\n }\n\n .scope-chips {\n display: flex;\n flex-wrap: wrap;\n gap: 0.4em;\n }\n\n .scope-chip {\n display: inline-block;\n padding: 0.2em 0.7em;\n border-radius: 999px;\n background: light-dark(\n oklch(92% 0.05 var(--primary-hue)),\n oklch(35% 0.08 var(--primary-hue))\n );\n font-size: 0.85em;\n white-space: nowrap;\n }\n\n .badge,\n .token-list button.revoke {\n display: inline-flex;\n align-items: center;\n gap: 0.4em;\n padding: 0.3em 0.8em;\n border-radius: 999px;\n font-size: 0.85em;\n font-weight: 600;\n line-height: 1.5;\n white-space: nowrap;\n }\n\n .badge {\n background: transparent;\n border: 1px solid currentcolor;\n }\n\n .badge-active {\n color: light-dark(oklch(35% 0.16 145deg), oklch(75% 0.16 145deg));\n\n .dot {\n width: 6px;\n height: 6px;\n border-radius: 50%;\n background: currentcolor;\n }\n }\n\n .badge-revoked {\n color: var(--clr-text);\n opacity: 0.7;\n }\n\n .token-list button.revoke {\n background: light-dark(oklch(92% 0.15 25deg), oklch(30% 0.12 25deg));\n color: light-dark(oklch(30% 0.15 25deg), oklch(92% 0.12 25deg));\n border: 1px solid transparent;\n cursor: pointer;\n transition: background-color 150ms ease;\n }\n\n .token-list button.revoke:hover {\n background: light-dark(oklch(80% 0.2 25deg), oklch(45% 0.18 25deg));\n }\n\n .token-reveal {\n margin-block-end: 1em;\n padding: 1em 1.2em;\n border: 1px solid var(--clr-border);\n border-radius: 16px;\n background: light-dark(\n oklch(96% 0.08 145deg),\n oklch(28% 0.08 145deg)\n );\n\n input {\n width: 100%;\n font-family: monospace;\n padding: 0.5em 0.7em;\n border-radius: 8px;\n border: 1px solid var(--clr-border);\n }\n }\n\n .scope-checkboxes {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n gap: 1em;\n }\n}\n","@layer components {\n #theme-selector {\n display: flex;\n justify-self: start;\n align-items: center;\n gap: .25rem;\n padding: .25rem;\n border-radius: 999px;\n background-color: color-mix(in oklch, var(--clr-border) 40%, transparent);\n\n button {\n display: grid;\n place-items: center;\n appearance: none;\n border: none;\n border-radius: 999px;\n padding: .375rem;\n background: transparent;\n color: inherit;\n transition: background-color .2s, color .2s;\n\n &:hover {\n cursor: pointer;\n }\n\n &:focus-visible {\n outline: 2px solid var(--clr-text);\n outline-offset: 2px;\n }\n\n svg {\n width: 1.5rem;\n height: 1.5rem;\n fill: currentcolor;\n }\n\n @media (hover: hover) {\n &:hover {\n background-color: color-mix(in oklch, var(--clr-border) 70%, transparent);\n }\n\n &[aria-pressed=\"true\"]:hover {\n background-color: var(--clr-text);\n }\n }\n\n &[aria-pressed=\"true\"] {\n background-color: var(--clr-text);\n color: var(--clr-background);\n }\n }\n }\n\n .sr-only {\n position: absolute;\n width: 1px;\n height: 1px;\n padding: 0;\n margin: -1px;\n overflow: hidden;\n clip-path: inset(50%);\n white-space: nowrap;\n border: 0;\n }\n\n ::view-transition-group(changing-theme) {\n animation-duration: 1.25s;\n }\n\n ::view-transition-new(changing-theme),\n ::view-transition-old(changing-theme) {\n mix-blend-mode: normal;\n }\n\n ::view-transition-new(changing-theme) {\n animation-name: reveal;\n }\n\n ::view-transition-old(changing-theme) {\n animation: none;\n }\n\n @keyframes reveal {\n from {\n clip-path: polygon(-30% 0, -30% 0, -15% 100%, -10% 115%);\n }\n\n to {\n clip-path: polygon(-30% 0, 130% 0, 115% 100%, -10% 115%);\n }\n }\n}\n","@layer components {\n .note {\n display: flex;\n flex-direction: column;\n border: 1px solid var(--clr-border);\n border-radius: var(--border-radius);\n padding: 1ex 2ex;\n\n .e-content {\n > p:first-child {\n margin-block-start: 0;\n }\n\n .u-photo {\n width: 100%;\n height: auto;\n }\n }\n\n .syndication-links {\n display: flex;\n flex-direction: row;\n gap: 1ex;\n\n svg {\n border-radius: 4px;\n width: auto;\n height: 1lh;\n }\n }\n }\n\n .reply-to {\n font-size: 85%;\n margin-inline: 2ex;\n padding-inline: 1ex;\n border: 1px solid var(--clr-border);\n border-bottom: none;\n border-top-left-radius: var(--border-radius);\n border-top-right-radius: var(--border-radius);\n }\n}\n"],"names":[]}
\ No newline at end of file
diff --git a/public/assets/css/app.css.zst b/public/assets/css/app.css.zst
index d7479672..2fda6303 100644
Binary files a/public/assets/css/app.css.zst and b/public/assets/css/app.css.zst differ
diff --git a/public/assets/js/app.js.br b/public/assets/js/app.js.br
index 40b0dd0d..7567f865 100644
Binary files a/public/assets/js/app.js.br and b/public/assets/js/app.js.br differ
diff --git a/public/assets/js/app.js.zst b/public/assets/js/app.js.zst
index e9a5163a..f9a71170 100644
Binary files a/public/assets/js/app.js.zst and b/public/assets/js/app.js.zst differ
diff --git a/resources/css/admin-tokens.css b/resources/css/admin-tokens.css
index a0db29e5..7bf0ad02 100644
--- a/resources/css/admin-tokens.css
+++ b/resources/css/admin-tokens.css
@@ -134,4 +134,30 @@
.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;
+ }
}
diff --git a/resources/views/about.blade.php b/resources/views/about.blade.php
new file mode 100644
index 00000000..e73c933a
--- /dev/null
+++ b/resources/views/about.blade.php
@@ -0,0 +1,9 @@
+@extends('master')
+@section('title')About « @stop
+
+@section('content')
+ About
+
+ {!! $about !!}
+
+@stop
diff --git a/resources/views/admin/about/show.blade.php b/resources/views/admin/about/show.blade.php
new file mode 100644
index 00000000..59c6a433
--- /dev/null
+++ b/resources/views/admin/about/show.blade.php
@@ -0,0 +1,19 @@
+@extends('master')
+
+@section('title')Edit About « Admin CP « @stop
+
+@section('content')
+ Edit About
+
+@stop
diff --git a/resources/views/admin/tokens/create.blade.php b/resources/views/admin/tokens/create.blade.php
new file mode 100644
index 00000000..d4847d1e
--- /dev/null
+++ b/resources/views/admin/tokens/create.blade.php
@@ -0,0 +1,52 @@
+@extends('master')
+
+@section('title')New Token « Admin CP « @stop
+
+@section('content')
+ Generate a new token
+ 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.
+
+
+ {{ csrf_field() }}
+
+
+ Client
+
+
+
+
+ Scope
+
+
+ create
+
+
+
+ update
+
+
+
+
+ Generate token
+
+
+@stop
diff --git a/resources/views/admin/tokens/index.blade.php b/resources/views/admin/tokens/index.blade.php
index c9443e29..33806cdd 100644
--- a/resources/views/admin/tokens/index.blade.php
+++ b/resources/views/admin/tokens/index.blade.php
@@ -4,6 +4,15 @@
@section('content')
Micropub Tokens
+ Generate new token
+
+ @if(session('new_token'))
+
+
Here's your new token. Copy it now — it won't be shown again.
+
+
+ @endif
+
@if($tokens->isEmpty())
No tokens have been issued.
@else
diff --git a/resources/views/admin/welcome.blade.php b/resources/views/admin/welcome.blade.php
index 03112665..132da7cc 100644
--- a/resources/views/admin/welcome.blade.php
+++ b/resources/views/admin/welcome.blade.php
@@ -57,6 +57,11 @@
Edit your bio .
+ About
+
+ Edit your about page .
+
+
Passkeys
Manager your passkeys .
diff --git a/resources/views/master.blade.php b/resources/views/master.blade.php
index 543f4255..b33d1fa9 100644
--- a/resources/views/master.blade.php
+++ b/resources/views/master.blade.php
@@ -36,6 +36,7 @@
Likes
Contacts
Projects
+ About
diff --git a/routes/web.php b/routes/web.php
index 3b6fa7d2..a9b46407 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -1,5 +1,7 @@
name('login');
Route::post('login', [AuthController::class, 'login']);
@@ -152,6 +157,8 @@ 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']);
});
@@ -167,6 +174,12 @@ Route::middleware(MyAuthMiddleware::class)->prefix('admin')->group(function () {
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']);
diff --git a/tests/Feature/AboutPageTest.php b/tests/Feature/AboutPageTest.php
new file mode 100644
index 00000000..61455069
--- /dev/null
+++ b/tests/Feature/AboutPageTest.php
@@ -0,0 +1,26 @@
+create([
+ 'content' => 'This is the about page content.',
+ ]);
+
+ $this->get('/about')
+ ->assertSee('This is the about page content.');
+ }
+}
diff --git a/tests/Feature/Admin/AboutTest.php b/tests/Feature/Admin/AboutTest.php
new file mode 100644
index 00000000..141a548b
--- /dev/null
+++ b/tests/Feature/Admin/AboutTest.php
@@ -0,0 +1,68 @@
+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 my about page. It uses HTML .',
+ ]);
+
+ $response = $this->actingAs($user)
+ ->get('/admin/about');
+ $response->assertSeeText('This is my about page. It uses HTML .');
+ }
+
+ #[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',
+ ]);
+ }
+}
diff --git a/tests/Feature/Admin/TokensTest.php b/tests/Feature/Admin/TokensTest.php
index 0c415296..d6695a15 100644
--- a/tests/Feature/Admin/TokensTest.php
+++ b/tests/Feature/Admin/TokensTest.php
@@ -37,6 +37,73 @@ 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
{
diff --git a/tests/Feature/MicropubControllerTest.php b/tests/Feature/MicropubControllerTest.php
index efcfb6ce..3fd8b515 100644
--- a/tests/Feature/MicropubControllerTest.php
+++ b/tests/Feature/MicropubControllerTest.php
@@ -4,14 +4,17 @@ 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;
@@ -457,6 +460,11 @@ 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',
[
@@ -871,6 +879,8 @@ class MicropubControllerTest extends TestCase
'main' => $content,
'published' => true,
]);
+ $response->assertHeader('Location');
+ $this->assertStringStartsWith(config('app.url').'/blog/', $response->headers->get('Location'));
}
#[Test]
@@ -902,4 +912,64 @@ 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());
+ }
}
diff --git a/tests/Unit/ArticlesTest.php b/tests/Unit/ArticlesTest.php
index fda1abf4..afcc0828 100644
--- a/tests/Unit/ArticlesTest.php
+++ b/tests/Unit/ArticlesTest.php
@@ -63,6 +63,28 @@ 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
{