diff --git a/app/Http/Controllers/AboutPageController.php b/app/Http/Controllers/AboutPageController.php deleted file mode 100644 index d174e550..00000000 --- a/app/Http/Controllers/AboutPageController.php +++ /dev/null @@ -1,16 +0,0 @@ - About::first()?->content, - ]); - } -} diff --git a/app/Http/Controllers/Admin/AboutController.php b/app/Http/Controllers/Admin/AboutController.php deleted file mode 100644 index cc5be0ad..00000000 --- a/app/Http/Controllers/Admin/AboutController.php +++ /dev/null @@ -1,32 +0,0 @@ - $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/PasskeysController.php b/app/Http/Controllers/Admin/PasskeysController.php index d9c18f5b..8012f9b8 100644 --- a/app/Http/Controllers/Admin/PasskeysController.php +++ b/app/Http/Controllers/Admin/PasskeysController.php @@ -254,7 +254,7 @@ class PasskeysController extends Controller ], 400); } - $passkey = Passkey::firstWhere('passkey_id', Base64UrlSafe::encodeUnpadded($publicKeyCredential->rawId)); + $passkey = Passkey::firstWhere('passkey_id', $publicKeyCredential->id); if (! $passkey) { return response()->json([ 'success' => false, diff --git a/app/Http/Controllers/Admin/SettingsController.php b/app/Http/Controllers/Admin/SettingsController.php deleted file mode 100644 index 99d283bb..00000000 --- a/app/Http/Controllers/Admin/SettingsController.php +++ /dev/null @@ -1,32 +0,0 @@ - $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'); - } -} diff --git a/app/Http/Controllers/Admin/TokensController.php b/app/Http/Controllers/Admin/TokensController.php index 02b9660c..1b5348f9 100644 --- a/app/Http/Controllers/Admin/TokensController.php +++ b/app/Http/Controllers/Admin/TokensController.php @@ -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. */ diff --git a/app/Http/Controllers/FeedsController.php b/app/Http/Controllers/FeedsController.php index 9aeb4abb..a30c89dc 100644 --- a/app/Http/Controllers/FeedsController.php +++ b/app/Http/Controllers/FeedsController.php @@ -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; } /** diff --git a/app/Http/Controllers/MicropubController.php b/app/Http/Controllers/MicropubController.php index 72242150..2df5d432 100644 --- a/app/Http/Controllers/MicropubController.php +++ b/app/Http/Controllers/MicropubController.php @@ -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', diff --git a/app/Models/About.php b/app/Models/About.php deleted file mode 100644 index 26b6a719..00000000 --- a/app/Models/About.php +++ /dev/null @@ -1,13 +0,0 @@ - [ '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. */ diff --git a/app/Models/Setting.php b/app/Models/Setting.php deleted file mode 100644 index 0c84ea36..00000000 --- a/app/Models/Setting.php +++ /dev/null @@ -1,18 +0,0 @@ - - */ - protected $casts = [ - 'winter_effect_enabled' => 'boolean', - ]; -} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index a40ae43f..68367a97 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -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); - }); } } diff --git a/app/Services/ArticleService.php b/app/Services/ArticleService.php index ab91f9e4..3d5dcc56 100644 --- a/app/Services/ArticleService.php +++ b/app/Services/ArticleService.php @@ -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, + ]); } } diff --git a/app/Services/Micropub/Data/EntryData.php b/app/Services/Micropub/Data/EntryData.php index 192c50f7..52dde72d 100644 --- a/app/Services/Micropub/Data/EntryData.php +++ b/app/Services/Micropub/Data/EntryData.php @@ -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, ]; } } diff --git a/app/Services/Micropub/Handlers/EntryHandler.php b/app/Services/Micropub/Handlers/EntryHandler.php index d79a0418..48bbb550 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)->uri, + isset($dataArray['name']) => resolve(ArticleService::class)->create($dataArray)->link, default => resolve(NoteService::class)->create($dataArray)->uri, }; diff --git a/database/factories/AboutFactory.php b/database/factories/AboutFactory.php deleted file mode 100644 index 5af2532e..00000000 --- a/database/factories/AboutFactory.php +++ /dev/null @@ -1,24 +0,0 @@ - - */ -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/factories/SettingFactory.php b/database/factories/SettingFactory.php deleted file mode 100644 index 8df15756..00000000 --- a/database/factories/SettingFactory.php +++ /dev/null @@ -1,24 +0,0 @@ - - */ -class SettingFactory extends Factory -{ - /** - * Define the model's default state. - * - * @return array - */ - public function definition(): array - { - return [ - 'winter_effect_enabled' => false, - ]; - } -} diff --git a/database/migrations/2026_08_23_101358_create_settings_table.php b/database/migrations/2026_08_23_101358_create_settings_table.php deleted file mode 100644 index a4ea86d2..00000000 --- a/database/migrations/2026_08_23_101358_create_settings_table.php +++ /dev/null @@ -1,28 +0,0 @@ -id(); - $table->boolean('winter_effect_enabled')->default(false); - $table->timestamps(); - }); - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::dropIfExists('settings'); - } -}; diff --git a/database/migrations/2026_08_28_142830_create_about_table.php b/database/migrations/2026_08_28_142830_create_about_table.php deleted file mode 100644 index 15ab6f84..00000000 --- a/database/migrations/2026_08_28_142830_create_about_table.php +++ /dev/null @@ -1,28 +0,0 @@ -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 e255209f..4e2ebd32 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))}.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}} +@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:2/3}&>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{& button{appearance:none;background:0 0;border:none;&:hover{cursor:pointer}}& #theme-selector-dropdown{position-area:block-end span-inline-start;border:2px solid var(--clr-border);background-color:var(--clr-background);color:var(--clr-text);margin:0;position:absolute;& fieldset{border:0;flex-direction:row;gap:1ch;display:flex;& input{display:none}}}@media (prefers-reduced-motion:no-preference){& #theme-selector-dropdown{&:popover-open{opacity:1;transform:translateY(0)scale(1);@starting-style{opacity:0;transform:translateY(30px)scale(0)}}opacity:0;transition:transform, opacity, display allow-discrete, overlay allow-discrete;transform-origin:100% 0;transition-duration:.5s;transform:translateY(0)scale(0)}}}::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%)}}} /*# sourceMappingURL=/assets/css/app.css.map */ diff --git a/public/assets/css/app.css.br b/public/assets/css/app.css.br index 200e0d25..eeec79a3 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 6413bbe9..ca57ea2f 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,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 +{"version":3,"mappings":"AACA,aCGE,uCAOA,oFASA,8DAMA,4CAMA,qBAKA,+CAMA,8BAMA,gEAMA,qDAQA,mEAOA,qCAKA,gCAKA,gCACE,wGDhFJ,YGAE,oaAwBA,kEIxBA,0BAIA,oBAIA,iIAME,0BAIA,uBAIA,iDAIE,yEAME,uBAON,oDPvCF,kBO+CE,SACE,oBLhDF,wHAOE,yBAIA,+BAKA,gDAKE,kCAAqC,2CGrBzC,+HAOE,aACE,qCAIA,mCAMF,6DAKE,gDAQJ,4MD/BA,sFAME,+CENF,gBACE,oDAKE,wBAMF,gMAQE,4DAME,uBAUJ,8CACE,2BACE,yDAQE,gBAAiB,8CAST,0KAahB,iEAIA,kGAKA,4DAIA,qDAIA","sources":["resources/css/app.css","resources/css/reset.css","resources/css/header.css","resources/css/notes.css","resources/css/colours.css","resources/css/layout.css","resources/css/pagination.css","resources/css/theme-selector.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","@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 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 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","@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: 2/3;\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 #theme-selector {\n button {\n appearance: none;\n border: none;\n background: none;\n\n &:hover {\n cursor: pointer;\n }\n }\n\n /* This is the element with the [popover] attribute */\n #theme-selector-dropdown {\n position: absolute;\n position-area: block-end span-inline-start;\n margin: 0;\n border: 2px solid var(--clr-border);\n background-color: var(--clr-background);\n color: var(--clr-text);\n\n fieldset {\n border: 0;\n display: flex;\n flex-direction: row;\n gap: 1ch;\n\n input {\n display: none;\n }\n }\n }\n\n /*\n * This is the element with the [popover] attribute\n * Here we are styling the open and closing animations\n */\n @media (prefers-reduced-motion: no-preference) {\n #theme-selector-dropdown {\n &:popover-open {\n transform: translateY(0) scale(1);\n opacity: 1;\n\n /*\n * Start styles for the opening transition.\n * Added to :popover-open, but after the opened styles.\n */\n @starting-style {\n transform: translateY(30px) scale(0);\n opacity: 0;\n }\n }\n\n /*\n * End styles for the closing transition.\n */\n transform: translateY(0) scale(0);\n opacity: 0;\n\n /*\n * Enumerate transitioning properties, including display and overlay.\n */\n transition: transform, opacity, display allow-discrete, overlay allow-discrete;\n transition-duration: 0.5s;\n transform-origin: top right;\n }\n }\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"],"names":[]} \ No newline at end of file diff --git a/public/assets/css/app.css.zst b/public/assets/css/app.css.zst index 2fda6303..d258af49 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 b/public/assets/js/app.js index 297c3aa1..d19f257c 100644 --- a/public/assets/js/app.js +++ b/public/assets/js/app.js @@ -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{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;sr.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 diff --git a/public/assets/js/app.js.br b/public/assets/js/app.js.br index 7567f865..40b0dd0d 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.map b/public/assets/js/app.js.map index a2fb0730..79c78c67 100644 --- a/public/assets/js/app.js.map +++ b/public/assets/js/app.js.map @@ -1,7 +1,7 @@ { "version": 3, "sources": ["../../../resources/js/auth.js", "../../../resources/js/theme-selector.js", "../../../resources/js/app.js"], - "sourcesContent": ["class Auth {\n constructor() {}\n\n async register() {\n const createOptions = await this.getCreateOptions();\n\n const publicKeyCredentialCreationOptions = {\n challenge: this.base64URLStringToBuffer(createOptions.challenge),\n rp: {\n id: createOptions.rp.id,\n name: createOptions.rp.name,\n },\n user: {\n id: new TextEncoder().encode(window.atob(createOptions.user.id)),\n name: createOptions.user.name,\n displayName: createOptions.user.displayName,\n },\n pubKeyCredParams: createOptions.pubKeyCredParams,\n excludeCredentials: [],\n authenticatorSelection: createOptions.authenticatorSelection,\n timeout: 60000,\n };\n\n const credential = await navigator.credentials.create({\n publicKey: publicKeyCredentialCreationOptions\n });\n if (!credential) {\n throw new Error('Error generating a passkey');\n }\n\n const authenticatorAttestationResponse = {\n id: credential.id ? credential.id : null,\n type: credential.type ? credential.type : null,\n rawId: credential.rawId ? this.bufferToBase64URLString(credential.rawId) : null,\n response: {\n attestationObject: credential.response.attestationObject ? this.bufferToBase64URLString(credential.response.attestationObject) : null,\n clientDataJSON: credential.response.clientDataJSON ? this.bufferToBase64URLString(credential.response.clientDataJSON) : null,\n }\n };\n\n const registerCredential = await window.fetch('/admin/passkeys/register', {\n method: 'POST',\n body: JSON.stringify(authenticatorAttestationResponse),\n cache: 'no-cache',\n headers: {\n 'Content-Type': 'application/json',\n 'X-CSRF-TOKEN': document.querySelector('meta[name=\"csrf-token\"]').getAttribute('content'),\n },\n });\n\n if (!registerCredential.ok) {\n throw new Error('Error saving the passkey');\n }\n\n window.location.reload();\n }\n\n async getCreateOptions() {\n const response = await fetch('/admin/passkeys/register', {\n method: 'GET',\n });\n\n return await response.json();\n }\n\n async login() {\n const loginData = await this.getLoginData();\n\n const publicKeyCredential = await navigator.credentials.get({\n publicKey: {\n challenge: this.base64URLStringToBuffer(loginData.challenge),\n userVerification: loginData.userVerification,\n timeout: 60000,\n }\n });\n\n if (!publicKeyCredential) {\n throw new Error('Authentication failed');\n }\n\n const authenticatorAttestationResponse = {\n id: publicKeyCredential.id ? publicKeyCredential.id : '',\n type: publicKeyCredential.type ? publicKeyCredential.type : '',\n rawId: publicKeyCredential.rawId ? this.bufferToBase64URLString(publicKeyCredential.rawId) : '',\n response: {\n authenticatorData: publicKeyCredential.response.authenticatorData ? this.bufferToBase64URLString(publicKeyCredential.response.authenticatorData) : '',\n clientDataJSON: publicKeyCredential.response.clientDataJSON ? this.bufferToBase64URLString(publicKeyCredential.response.clientDataJSON) : '',\n signature: publicKeyCredential.response.signature ? this.bufferToBase64URLString(publicKeyCredential.response.signature) : '',\n userHandle: publicKeyCredential.response.userHandle ? this.bufferToBase64URLString(publicKeyCredential.response.userHandle) : '',\n },\n };\n\n const loginAttempt = await window.fetch('/login/passkey', {\n method: 'POST',\n body: JSON.stringify(authenticatorAttestationResponse),\n headers: {\n 'Content-Type': 'application/json',\n 'X-CSRF-TOKEN': document.querySelector('meta[name=\"csrf-token\"]').getAttribute('content'),\n },\n });\n\n if (!loginAttempt.ok) {\n throw new Error('Login failed');\n }\n\n window.location.assign('/admin');\n }\n\n async getLoginData() {\n const response = await fetch('/login/passkey', {\n method: 'GET',\n });\n\n return await response.json();\n }\n\n /**\n * Convert a base64 URL string to a buffer.\n *\n * Sourced from https://github.com/MasterKale/SimpleWebAuthn/blob/master/packages/browser/src/helpers/base64URLStringToBuffer.ts#L8\n *\n * @param {string} base64URLString\n * @returns {ArrayBuffer}\n */\n base64URLStringToBuffer(base64URLString) {\n // Convert from Base64URL to Base64\n const base64 = base64URLString.replace(/-/g, '+').replace(/_/g, '/');\n /**\n * Pad with '=' until it's a multiple of four\n * (4 - (85 % 4 = 1) = 3) % 4 = 3 padding\n * (4 - (86 % 4 = 2) = 2) % 4 = 2 padding\n * (4 - (87 % 4 = 3) = 1) % 4 = 1 padding\n * (4 - (88 % 4 = 0) = 4) % 4 = 0 padding\n */\n const padLength = (4 - (base64.length % 4)) % 4;\n const padded = base64.padEnd(base64.length + padLength, '=');\n // Convert to a binary string\n const binary = window.atob(padded);\n // Convert binary string to buffer\n const buffer = new ArrayBuffer(binary.length);\n const bytes = new Uint8Array(buffer);\n for (let i = 0; i < binary.length; i++) {\n bytes[i] = binary.charCodeAt(i);\n }\n return buffer;\n }\n\n /**\n * Convert a buffer to a base64 URL string.\n *\n * Sourced from https://github.com/MasterKale/SimpleWebAuthn/blob/master/packages/browser/src/helpers/bufferToBase64URLString.ts#L7\n *\n * @param {ArrayBuffer} buffer\n * @returns {string}\n */\n bufferToBase64URLString(buffer) {\n const bytes = new Uint8Array(buffer);\n let str = '';\n for (const charCode of bytes) {\n str += String.fromCharCode(charCode);\n }\n const base64String = btoa(str);\n return base64String.replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=/g, '');\n }\n}\n\nexport { Auth };\n", "class ThemeSelector {\n constructor() {\n this.widget = document.querySelector('#theme-selector');\n this.btnLight = this.widget.querySelector('#btn-light');\n this.btnDark = this.widget.querySelector('#btn-dark');\n this.status = this.widget.querySelector('#theme-status');\n this.currentTheme = 'system';\n }\n\n setupEventListeners() {\n this.btnLight.addEventListener('click', () => {\n this.applyTheme(this.currentTheme === 'light' ? 'system' : 'light');\n });\n\n this.btnDark.addEventListener('click', () => {\n this.applyTheme(this.currentTheme === 'dark' ? 'system' : 'dark');\n });\n }\n\n applyTheme(theme) {\n const selectedTheme = (theme === 'light' || theme === 'dark') ? theme : 'light dark';\n\n const systemTheme = (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) ? 'dark' : 'light';\n const html = document.documentElement;\n let currentColorScheme = html.style.getPropertyValue('color-scheme');\n if (currentColorScheme === '') {\n currentColorScheme = 'light dark';\n }\n\n /* do we need to transition */\n let doTransition = false;\n\n if (selectedTheme !== currentColorScheme) {\n if (selectedTheme === 'light dark') {\n doTransition = currentColorScheme !== systemTheme;\n } else if (currentColorScheme === 'light dark') {\n doTransition = selectedTheme !== systemTheme;\n } else {\n doTransition = true;\n }\n }\n\n const applyChange = () => {\n this.currentTheme = theme;\n html.style.setProperty('color-scheme', selectedTheme);\n\n this.btnLight.setAttribute('aria-pressed', String(theme === 'light'));\n this.btnDark.setAttribute('aria-pressed', String(theme === 'dark'));\n this.status.textContent = theme === 'system' ? 'Theme set to system default' : '';\n };\n\n html.style.viewTransitionName = 'changing-theme';\n if (doTransition && document.startViewTransition) {\n document.startViewTransition(applyChange);\n } else {\n applyChange();\n }\n }\n}\n\nexport { ThemeSelector };\n", "import { Auth } from './auth.js';\nimport { ThemeSelector } from './theme-selector.js';\n\nlet auth = new Auth();\ndocument.querySelectorAll('.add-passkey').forEach((el) => {\n el.addEventListener('click', () => {\n auth.register();\n });\n});\n\ndocument.querySelectorAll('.login-passkey').forEach((el) => {\n el.addEventListener('click', () => {\n auth.login();\n });\n});\n\nlet themeSelector = new ThemeSelector();\nthemeSelector.setupEventListeners();\n"], - "mappings": "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", - "names": ["Auth", "createOptions", "publicKeyCredentialCreationOptions", "credential", "authenticatorAttestationResponse", "loginData", "publicKeyCredential", "base64URLString", "base64", "padLength", "padded", "binary", "buffer", "bytes", "i", "str", "charCode", "ThemeSelector", "theme", "selectedTheme", "systemTheme", "html", "currentColorScheme", "doTransition", "applyChange", "auth", "Auth", "el", "themeSelector", "ThemeSelector"] + "sourcesContent": ["class Auth {\n constructor() {}\n\n async register() {\n const createOptions = await this.getCreateOptions();\n\n const publicKeyCredentialCreationOptions = {\n challenge: this.base64URLStringToBuffer(createOptions.challenge),\n rp: {\n id: createOptions.rp.id,\n name: createOptions.rp.name,\n },\n user: {\n id: new TextEncoder().encode(window.atob(createOptions.user.id)),\n name: createOptions.user.name,\n displayName: createOptions.user.displayName,\n },\n pubKeyCredParams: createOptions.pubKeyCredParams,\n excludeCredentials: [],\n authenticatorSelection: createOptions.authenticatorSelection,\n timeout: 60000,\n };\n\n const credential = await navigator.credentials.create({\n publicKey: publicKeyCredentialCreationOptions\n });\n if (!credential) {\n throw new Error('Error generating a passkey');\n }\n\n const authenticatorAttestationResponse = {\n id: credential.id ? credential.id : null,\n type: credential.type ? credential.type : null,\n rawId: credential.rawId ? this.bufferToBase64URLString(credential.rawId) : null,\n response: {\n attestationObject: credential.response.attestationObject ? this.bufferToBase64URLString(credential.response.attestationObject) : null,\n clientDataJSON: credential.response.clientDataJSON ? this.bufferToBase64URLString(credential.response.clientDataJSON) : null,\n }\n };\n\n const registerCredential = await window.fetch('/admin/passkeys/register', {\n method: 'POST',\n body: JSON.stringify(authenticatorAttestationResponse),\n cache: 'no-cache',\n headers: {\n 'Content-Type': 'application/json',\n 'X-CSRF-TOKEN': document.querySelector('meta[name=\"csrf-token\"]').getAttribute('content'),\n },\n });\n\n if (!registerCredential.ok) {\n throw new Error('Error saving the passkey');\n }\n\n window.location.reload();\n }\n\n async getCreateOptions() {\n const response = await fetch('/admin/passkeys/register', {\n method: 'GET',\n });\n\n return await response.json();\n }\n\n async login() {\n const loginData = await this.getLoginData();\n\n const publicKeyCredential = await navigator.credentials.get({\n publicKey: {\n challenge: this.base64URLStringToBuffer(loginData.challenge),\n userVerification: loginData.userVerification,\n timeout: 60000,\n }\n });\n\n if (!publicKeyCredential) {\n throw new Error('Authentication failed');\n }\n\n const authenticatorAttestationResponse = {\n id: publicKeyCredential.id ? publicKeyCredential.id : '',\n type: publicKeyCredential.type ? publicKeyCredential.type : '',\n rawId: publicKeyCredential.rawId ? this.bufferToBase64URLString(publicKeyCredential.rawId) : '',\n response: {\n authenticatorData: publicKeyCredential.response.authenticatorData ? this.bufferToBase64URLString(publicKeyCredential.response.authenticatorData) : '',\n clientDataJSON: publicKeyCredential.response.clientDataJSON ? this.bufferToBase64URLString(publicKeyCredential.response.clientDataJSON) : '',\n signature: publicKeyCredential.response.signature ? this.bufferToBase64URLString(publicKeyCredential.response.signature) : '',\n userHandle: publicKeyCredential.response.userHandle ? this.bufferToBase64URLString(publicKeyCredential.response.userHandle) : '',\n },\n };\n\n const loginAttempt = await window.fetch('/login/passkey', {\n method: 'POST',\n body: JSON.stringify(authenticatorAttestationResponse),\n headers: {\n 'Content-Type': 'application/json',\n 'X-CSRF-TOKEN': document.querySelector('meta[name=\"csrf-token\"]').getAttribute('content'),\n },\n });\n\n if (!loginAttempt.ok) {\n throw new Error('Login failed');\n }\n\n window.location.assign('/admin');\n }\n\n async getLoginData() {\n const response = await fetch('/login/passkey', {\n method: 'GET',\n });\n\n return await response.json();\n }\n\n /**\n * Convert a base64 URL string to a buffer.\n *\n * Sourced from https://github.com/MasterKale/SimpleWebAuthn/blob/master/packages/browser/src/helpers/base64URLStringToBuffer.ts#L8\n *\n * @param {string} base64URLString\n * @returns {ArrayBuffer}\n */\n base64URLStringToBuffer(base64URLString) {\n // Convert from Base64URL to Base64\n const base64 = base64URLString.replace(/-/g, '+').replace(/_/g, '/');\n /**\n * Pad with '=' until it's a multiple of four\n * (4 - (85 % 4 = 1) = 3) % 4 = 3 padding\n * (4 - (86 % 4 = 2) = 2) % 4 = 2 padding\n * (4 - (87 % 4 = 3) = 1) % 4 = 1 padding\n * (4 - (88 % 4 = 0) = 4) % 4 = 0 padding\n */\n const padLength = (4 - (base64.length % 4)) % 4;\n const padded = base64.padEnd(base64.length + padLength, '=');\n // Convert to a binary string\n const binary = window.atob(padded);\n // Convert binary string to buffer\n const buffer = new ArrayBuffer(binary.length);\n const bytes = new Uint8Array(buffer);\n for (let i = 0; i < binary.length; i++) {\n bytes[i] = binary.charCodeAt(i);\n }\n return buffer;\n }\n\n /**\n * Convert a buffer to a base64 URL string.\n *\n * Sourced from https://github.com/MasterKale/SimpleWebAuthn/blob/master/packages/browser/src/helpers/bufferToBase64URLString.ts#L7\n *\n * @param {ArrayBuffer} buffer\n * @returns {string}\n */\n bufferToBase64URLString(buffer) {\n const bytes = new Uint8Array(buffer);\n let str = '';\n for (const charCode of bytes) {\n str += String.fromCharCode(charCode);\n }\n const base64String = btoa(str);\n return base64String.replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=/g, '');\n }\n}\n\nexport { Auth };\n", "class ThemeSelector {\n constructor() {\n this.widget = document.querySelector('#theme-selector');\n }\n\n setupEventListeners() {\n this.widget.querySelectorAll('#theme-selector-dropdown input').forEach((radioInput) => radioInput.addEventListener('input', (e) => {\n let theme = e.target.value;\n\n // Update current icon\n this.widget.querySelectorAll('.toggle svg').forEach((svg) => {\n if (svg.classList.contains(theme)) {\n svg.style.display = '';\n } else {\n svg.style.display = 'none';\n }\n });\n\n // Set the theme\n let selectedTheme;\n switch (theme) {\n case 'dark':\n case 'light':\n selectedTheme = theme;\n break;\n default:\n selectedTheme = 'light dark';\n }\n\n const systemTheme = (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) ? 'dark' : 'light';\n const html = document.querySelector('html');\n let currentTheme = html.style.getPropertyValue('color-scheme');\n if (currentTheme === '') {\n currentTheme = 'light dark';\n }\n\n /* do we need to transition */\n let doTransition = false;\n\n if (selectedTheme !== currentTheme) {\n if (selectedTheme === 'light dark') {\n doTransition = currentTheme !== systemTheme;\n } else if (currentTheme === 'light dark') {\n doTransition = selectedTheme !== systemTheme;\n } else {\n doTransition = true;\n }\n }\n\n html.style.viewTransitionName = 'changing-theme';\n if (doTransition && document.startViewTransition) {\n document.startViewTransition(() => {\n // Close the popover\n document.querySelector('#theme-selector-dropdown').togglePopover();\n\n // Set the colour theme\n html.style.setProperty('color-scheme', selectedTheme);\n });\n } else {\n // Close the popover\n document.querySelector('#theme-selector-dropdown').togglePopover();\n\n // Set the colour theme\n html.style.setProperty('color-scheme', selectedTheme);\n }\n }));\n }\n}\n\nexport { ThemeSelector };\n", "import { Auth } from './auth.js';\nimport { ThemeSelector } from './theme-selector.js';\n\nlet auth = new Auth();\ndocument.querySelectorAll('.add-passkey').forEach((el) => {\n el.addEventListener('click', () => {\n auth.register();\n });\n});\n\ndocument.querySelectorAll('.login-passkey').forEach((el) => {\n el.addEventListener('click', () => {\n auth.login();\n });\n});\n\nlet themeSelector = new ThemeSelector();\nthemeSelector.setupEventListeners();\n"], + "mappings": "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", + "names": ["Auth", "createOptions", "publicKeyCredentialCreationOptions", "credential", "authenticatorAttestationResponse", "loginData", "publicKeyCredential", "base64URLString", "base64", "padLength", "padded", "binary", "buffer", "bytes", "i", "str", "charCode", "ThemeSelector", "radioInput", "theme", "svg", "selectedTheme", "systemTheme", "html", "currentTheme", "doTransition", "auth", "Auth", "el", "themeSelector", "ThemeSelector"] } diff --git a/public/assets/js/app.js.zst b/public/assets/js/app.js.zst index f9a71170..e9a5163a 100644 Binary files a/public/assets/js/app.js.zst and b/public/assets/js/app.js.zst differ diff --git a/public/assets/js/snow-fall.js.br b/public/assets/js/snow-fall.js.br new file mode 100644 index 00000000..b44b6670 Binary files /dev/null and b/public/assets/js/snow-fall.js.br differ diff --git a/public/assets/js/snow-fall.js.zst b/public/assets/js/snow-fall.js.zst new file mode 100644 index 00000000..6c26bd2a Binary files /dev/null and b/public/assets/js/snow-fall.js.zst differ diff --git a/resources/css/admin-tokens.css b/resources/css/admin-tokens.css deleted file mode 100644 index 7bf0ad02..00000000 --- a/resources/css/admin-tokens.css +++ /dev/null @@ -1,163 +0,0 @@ -@layer components { - .token-list-wrapper { - margin-block-start: 1em; - border: 1px solid var(--clr-border); - border-radius: 16px; - overflow: auto hidden; - background: light-dark( - oklch(99% 0.02 var(--primary-hue)), - oklch(22% 0.05 var(--primary-hue)) - ); - } - - .token-list { - width: 100%; - min-width: 640px; - table-layout: fixed; - border-collapse: collapse; - - th, - td { - text-align: left; - padding: 0.9em 1.2em; - border-bottom: 1px solid var(--clr-border); - vertical-align: middle; - } - - th { - font-size: 0.8em; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.08em; - opacity: 0.7; - } - - tr.is-revoked { - opacity: 0.6; - } - - a { - word-break: break-all; - } - - th:nth-child(1), - td:nth-child(1) { - 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) { - width: 14%; - text-align: right; - } - - tr:last-child td { - border-bottom: none; - } - } - - .scope-chips { - display: flex; - flex-wrap: wrap; - gap: 0.4em; - } - - .scope-chip { - display: inline-block; - padding: 0.2em 0.7em; - border-radius: 999px; - background: light-dark( - oklch(92% 0.05 var(--primary-hue)), - oklch(35% 0.08 var(--primary-hue)) - ); - font-size: 0.85em; - white-space: nowrap; - } - - .badge, - .token-list button.revoke { - display: inline-flex; - align-items: center; - gap: 0.4em; - padding: 0.3em 0.8em; - border-radius: 999px; - font-size: 0.85em; - font-weight: 600; - line-height: 1.5; - white-space: nowrap; - } - - .badge { - background: transparent; - border: 1px solid currentcolor; - } - - .badge-active { - color: light-dark(oklch(35% 0.16 145deg), oklch(75% 0.16 145deg)); - - .dot { - width: 6px; - height: 6px; - border-radius: 50%; - background: currentcolor; - } - } - - .badge-revoked { - color: var(--clr-text); - opacity: 0.7; - } - - .token-list button.revoke { - background: light-dark(oklch(92% 0.15 25deg), oklch(30% 0.12 25deg)); - color: light-dark(oklch(30% 0.15 25deg), oklch(92% 0.12 25deg)); - border: 1px solid transparent; - cursor: pointer; - transition: background-color 150ms ease; - } - - .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/css/app.css b/resources/css/app.css index 1f787028..f25e47e3 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -8,4 +8,3 @@ @import url('notes.css'); @import url('pagination.css'); @import url('theme-selector.css'); -@import url('admin-tokens.css'); diff --git a/resources/css/layout.css b/resources/css/layout.css index 44fda96f..dc5f753f 100644 --- a/resources/css/layout.css +++ b/resources/css/layout.css @@ -18,17 +18,7 @@ } > main { - grid-column: 1/-1; - display: grid; - grid-template-columns: subgrid; - - > * { - grid-column: 2/3; - } - - > .full-bleed { - grid-column: 1/-1; - } + grid-column: 2/3; } > footer { diff --git a/resources/css/theme-selector.css b/resources/css/theme-selector.css index f718b4b0..333639a4 100644 --- a/resources/css/theme-selector.css +++ b/resources/css/theme-selector.css @@ -1,66 +1,70 @@ @layer components { #theme-selector { - display: flex; - justify-self: start; - align-items: center; - gap: .25rem; - padding: .25rem; - border-radius: 999px; - background-color: color-mix(in oklch, var(--clr-border) 40%, transparent); - button { - display: grid; - place-items: center; appearance: none; border: none; - border-radius: 999px; - padding: .375rem; - background: transparent; - color: inherit; - transition: background-color .2s, color .2s; + background: none; &:hover { cursor: pointer; } + } - &:focus-visible { - outline: 2px solid var(--clr-text); - outline-offset: 2px; - } + /* This is the element with the [popover] attribute */ + #theme-selector-dropdown { + position: absolute; + position-area: block-end span-inline-start; + margin: 0; + border: 2px solid var(--clr-border); + background-color: var(--clr-background); + color: var(--clr-text); - svg { - width: 1.5rem; - height: 1.5rem; - fill: currentcolor; - } + fieldset { + border: 0; + display: flex; + flex-direction: row; + gap: 1ch; - @media (hover: hover) { - &:hover { - background-color: color-mix(in oklch, var(--clr-border) 70%, transparent); + input { + display: none; } - - &[aria-pressed="true"]:hover { - background-color: var(--clr-text); - } - } - - &[aria-pressed="true"] { - background-color: var(--clr-text); - color: var(--clr-background); } } - } - .sr-only { - position: absolute; - width: 1px; - height: 1px; - padding: 0; - margin: -1px; - overflow: hidden; - clip-path: inset(50%); - white-space: nowrap; - border: 0; + /* + * This is the element with the [popover] attribute + * Here we are styling the open and closing animations + */ + @media (prefers-reduced-motion: no-preference) { + #theme-selector-dropdown { + &:popover-open { + transform: translateY(0) scale(1); + opacity: 1; + + /* + * Start styles for the opening transition. + * Added to :popover-open, but after the opened styles. + */ + @starting-style { + transform: translateY(30px) scale(0); + opacity: 0; + } + } + + /* + * End styles for the closing transition. + */ + transform: translateY(0) scale(0); + opacity: 0; + + /* + * Enumerate transitioning properties, including display and overlay. + */ + transition: transform, opacity, display allow-discrete, overlay allow-discrete; + transition-duration: 0.5s; + transform-origin: top right; + } + } } ::view-transition-group(changing-theme) { diff --git a/resources/js/theme-selector.js b/resources/js/theme-selector.js index 374fdc8c..3248b9bf 100644 --- a/resources/js/theme-selector.js +++ b/resources/js/theme-selector.js @@ -1,60 +1,69 @@ class ThemeSelector { constructor() { this.widget = document.querySelector('#theme-selector'); - this.btnLight = this.widget.querySelector('#btn-light'); - this.btnDark = this.widget.querySelector('#btn-dark'); - this.status = this.widget.querySelector('#theme-status'); - this.currentTheme = 'system'; } setupEventListeners() { - this.btnLight.addEventListener('click', () => { - this.applyTheme(this.currentTheme === 'light' ? 'system' : 'light'); - }); + this.widget.querySelectorAll('#theme-selector-dropdown input').forEach((radioInput) => radioInput.addEventListener('input', (e) => { + let theme = e.target.value; - this.btnDark.addEventListener('click', () => { - this.applyTheme(this.currentTheme === 'dark' ? 'system' : 'dark'); - }); - } + // Update current icon + this.widget.querySelectorAll('.toggle svg').forEach((svg) => { + if (svg.classList.contains(theme)) { + svg.style.display = ''; + } else { + svg.style.display = 'none'; + } + }); - applyTheme(theme) { - const selectedTheme = (theme === 'light' || theme === 'dark') ? theme : 'light dark'; - - const systemTheme = (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) ? 'dark' : 'light'; - const html = document.documentElement; - let currentColorScheme = html.style.getPropertyValue('color-scheme'); - if (currentColorScheme === '') { - currentColorScheme = 'light dark'; - } - - /* do we need to transition */ - let doTransition = false; - - if (selectedTheme !== currentColorScheme) { - if (selectedTheme === 'light dark') { - doTransition = currentColorScheme !== systemTheme; - } else if (currentColorScheme === 'light dark') { - doTransition = selectedTheme !== systemTheme; - } else { - doTransition = true; + // Set the theme + let selectedTheme; + switch (theme) { + case 'dark': + case 'light': + selectedTheme = theme; + break; + default: + selectedTheme = 'light dark'; } - } - const applyChange = () => { - this.currentTheme = theme; - html.style.setProperty('color-scheme', selectedTheme); + const systemTheme = (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) ? 'dark' : 'light'; + const html = document.querySelector('html'); + let currentTheme = html.style.getPropertyValue('color-scheme'); + if (currentTheme === '') { + currentTheme = 'light dark'; + } - this.btnLight.setAttribute('aria-pressed', String(theme === 'light')); - this.btnDark.setAttribute('aria-pressed', String(theme === 'dark')); - this.status.textContent = theme === 'system' ? 'Theme set to system default' : ''; - }; + /* do we need to transition */ + let doTransition = false; - html.style.viewTransitionName = 'changing-theme'; - if (doTransition && document.startViewTransition) { - document.startViewTransition(applyChange); - } else { - applyChange(); - } + if (selectedTheme !== currentTheme) { + if (selectedTheme === 'light dark') { + doTransition = currentTheme !== systemTheme; + } else if (currentTheme === 'light dark') { + doTransition = selectedTheme !== systemTheme; + } else { + doTransition = true; + } + } + + html.style.viewTransitionName = 'changing-theme'; + if (doTransition && document.startViewTransition) { + document.startViewTransition(() => { + // Close the popover + document.querySelector('#theme-selector-dropdown').togglePopover(); + + // Set the colour theme + html.style.setProperty('color-scheme', selectedTheme); + }); + } else { + // Close the popover + document.querySelector('#theme-selector-dropdown').togglePopover(); + + // Set the colour theme + html.style.setProperty('color-scheme', selectedTheme); + } + })); } } diff --git a/resources/views/about.blade.php b/resources/views/about.blade.php deleted file mode 100644 index e73c933a..00000000 --- a/resources/views/about.blade.php +++ /dev/null @@ -1,9 +0,0 @@ -@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 deleted file mode 100644 index 59c6a433..00000000 --- a/resources/views/admin/about/show.blade.php +++ /dev/null @@ -1,19 +0,0 @@ -@extends('master') - -@section('title')Edit About « Admin CP « @stop - -@section('content') -

Edit About

-
- {{ csrf_field() }} - {{ method_field('PUT') }} -
- -
- -
-
- -
-
-@stop diff --git a/resources/views/admin/settings/show.blade.php b/resources/views/admin/settings/show.blade.php deleted file mode 100644 index 801171e1..00000000 --- a/resources/views/admin/settings/show.blade.php +++ /dev/null @@ -1,26 +0,0 @@ -@extends('master') - -@section('title')Site Settings « Admin CP « @stop - -@section('content') -

Site settings

-
- {{ csrf_field() }} - {{ method_field('PUT') }} -
- -
-
- -
-
-@stop diff --git a/resources/views/admin/tokens/create.blade.php b/resources/views/admin/tokens/create.blade.php deleted file mode 100644 index d4847d1e..00000000 --- a/resources/views/admin/tokens/create.blade.php +++ /dev/null @@ -1,52 +0,0 @@ -@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() }} - -
- - -
- -
- Scope - - -
- -
- -
-
-@stop diff --git a/resources/views/admin/tokens/index.blade.php b/resources/views/admin/tokens/index.blade.php index 33806cdd..8836ab07 100644 --- a/resources/views/admin/tokens/index.blade.php +++ b/resources/views/admin/tokens/index.blade.php @@ -4,61 +4,24 @@ @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 -
- - - - - - - - - - - - @foreach($tokens as $token) - - - - - - - - @endforeach - -
ClientScopeIssuedStatusAction
{{ $token->client_id }} - - @foreach(explode(' ', $token->scope) as $scope) - {{ $scope }} - @endforeach - - - @if($token->isRevoked) - Revoked {{ $token->revoked_at->diffForHumans() }} - @else - Active - @endif - - @unless($token->isRevoked) -
- {{ csrf_field() }} - {{ method_field('PUT') }} - -
- @endunless -
-
+
    + @foreach($tokens as $token) +
  • + {{ $token->client_id }} — scope: {{ $token->scope }} — issued {{ $token->created_at->diffForHumans() }} + @if($token->isRevoked) + — revoked {{ $token->revoked_at->diffForHumans() }} + @else +
    + {{ csrf_field() }} + {{ method_field('PUT') }} + +
    + @endif +
  • + @endforeach +
@endif @stop diff --git a/resources/views/admin/welcome.blade.php b/resources/views/admin/welcome.blade.php index 132da7cc..663cfdc4 100644 --- a/resources/views/admin/welcome.blade.php +++ b/resources/views/admin/welcome.blade.php @@ -57,18 +57,8 @@ Edit your bio.

-

About

-

- Edit your about page. -

-

Passkeys

Manager your passkeys.

- -

Settings

-

- Edit site settings. -

@stop diff --git a/resources/views/articles/atom.blade.php b/resources/views/articles/atom.blade.php new file mode 100644 index 00000000..9892bcfb --- /dev/null +++ b/resources/views/articles/atom.blade.php @@ -0,0 +1,20 @@ + + + Atom feed for {{ config('user.display_name') }}’s blog + + {{ config('app.url')}}/blog + {{ $articles[0]->updated_at->toAtomString() }} + +@foreach($articles as $article) + + {{ $article->title }} + + {{ config('app.url') }}{{ $article->link }} + {{ $article->updated_at->toAtomString() }} + {{ $article->main }} + + {{ config('user.display_name') }} + + +@endforeach + diff --git a/resources/views/articles/rss.blade.php b/resources/views/articles/rss.blade.php new file mode 100644 index 00000000..00268681 --- /dev/null +++ b/resources/views/articles/rss.blade.php @@ -0,0 +1,26 @@ + + + + {{ config('user.display_name') }} + + An RSS feed of the blog posts found on {{ config('app.url') }} + {{ config('app.url') }}/blog + {{ $buildDate }} + 1800 + +@foreach($articles as $article) + + {{ strip_tags($article->title) }} + + main }} + @if($article->url)

Permalink

@endif + ]]> +
+ @if($article->url != ''){{ $article->url }}@else{{ config('app.url') }}{{ $article->link }}@endif + {{ config('app.url') }}{{ $article->link }} + {{ $article->pubdate }} +
+@endforeach +
+
diff --git a/resources/views/icons/json-feed.blade.php b/resources/views/icons/json-feed.blade.php deleted file mode 100644 index 7b8df856..00000000 --- a/resources/views/icons/json-feed.blade.php +++ /dev/null @@ -1,11 +0,0 @@ -@php -if (isset($title)) { - $uniqueId = bin2hex(random_bytes(6)); -} -@endphp - - @if($title){{ $title }}@endif - - diff --git a/resources/views/master.blade.php b/resources/views/master.blade.php index b33d1fa9..2e5c6b5a 100644 --- a/resources/views/master.blade.php +++ b/resources/views/master.blade.php @@ -7,9 +7,13 @@ @yield('title'){{ config('app.name') }} - + + + - + + + @@ -36,17 +40,37 @@ Likes Contacts Projects - About - @include('icons.json-feed', ['title' => 'JSON Feed']) + @include('icons.rss', ['title' => 'RSS Feed']) -
- - - +
+
+ Select theme: +
+ + +
+
+ + +
+
+ + +
+
+
@@ -82,16 +106,14 @@ @section('scripts') - @if($winterEffectEnabled ?? false) - - - - - - @endif + + + + + @show diff --git a/resources/views/notes/atom.blade.php b/resources/views/notes/atom.blade.php new file mode 100644 index 00000000..c84a4a93 --- /dev/null +++ b/resources/views/notes/atom.blade.php @@ -0,0 +1,20 @@ + + + Atom feed for {{ config('user.display_name') }}’s notes + + {{ config('app.url')}}/notes + {{ $notes[0]->updated_at->toAtomString() }} + +@foreach($notes as $note) + + {{ strip_tags($note->note) }} + + {{ $note->uri }} + {{ $note->updated_at->toAtomString() }} + {{ $note->note }} + + {{ config('user.display_name') }} + + +@endforeach + diff --git a/resources/views/notes/rss.blade.php b/resources/views/notes/rss.blade.php new file mode 100644 index 00000000..9146ebe0 --- /dev/null +++ b/resources/views/notes/rss.blade.php @@ -0,0 +1,26 @@ + + + + {{ config('user.display_name') }} + + An RSS feed of the notes found on {{ config('app.url') }} + {{ config('app.url') }}/notes + {{ $buildDate }} + 1800 + +@foreach($notes as $note) + + {{ strip_tags($note->note) }} + + note !!} + ]]> + + {{ $note->uri }} + {{ $note->uri}} + {{ $note->pubdate }} + +@endforeach + + + diff --git a/routes/web.php b/routes/web.php index a9b46407..dd594480 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,7 +1,5 @@ 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']); diff --git a/tests/Feature/AboutPageTest.php b/tests/Feature/AboutPageTest.php deleted file mode 100644 index 61455069..00000000 --- a/tests/Feature/AboutPageTest.php +++ /dev/null @@ -1,26 +0,0 @@ -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 deleted file mode 100644 index 141a548b..00000000 --- a/tests/Feature/Admin/AboutTest.php +++ /dev/null @@ -1,68 +0,0 @@ -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/SettingsTest.php b/tests/Feature/Admin/SettingsTest.php deleted file mode 100644 index bf7d068b..00000000 --- a/tests/Feature/Admin/SettingsTest.php +++ /dev/null @@ -1,68 +0,0 @@ -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); - } -} diff --git a/tests/Feature/Admin/TokensTest.php b/tests/Feature/Admin/TokensTest.php index d6695a15..0c415296 100644 --- a/tests/Feature/Admin/TokensTest.php +++ b/tests/Feature/Admin/TokensTest.php @@ -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 { diff --git a/tests/Feature/FeedsTest.php b/tests/Feature/FeedsTest.php index 3f168b58..0323aa73 100644 --- a/tests/Feature/FeedsTest.php +++ b/tests/Feature/FeedsTest.php @@ -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(); } diff --git a/tests/Feature/MicropubControllerTest.php b/tests/Feature/MicropubControllerTest.php index 3fd8b515..1e9f0351 100644 --- a/tests/Feature/MicropubControllerTest.php +++ b/tests/Feature/MicropubControllerTest.php @@ -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()); } } diff --git a/tests/Unit/ArticlesTest.php b/tests/Unit/ArticlesTest.php index afcc0828..fda1abf4 100644 --- a/tests/Unit/ArticlesTest.php +++ b/tests/Unit/ArticlesTest.php @@ -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 {