Compare commits
53 changed files with 379 additions and 1151 deletions
|
|
@ -1,16 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Http\Controllers;
|
|
||||||
|
|
||||||
use App\Models\About;
|
|
||||||
use Illuminate\View\View;
|
|
||||||
|
|
||||||
class AboutPageController extends Controller
|
|
||||||
{
|
|
||||||
public function show(): View
|
|
||||||
{
|
|
||||||
return view('about', [
|
|
||||||
'about' => About::first()?->content,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Http\Controllers\Admin;
|
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
|
||||||
use App\Models\About;
|
|
||||||
use Illuminate\Http\RedirectResponse;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\View\View;
|
|
||||||
|
|
||||||
class AboutController extends Controller
|
|
||||||
{
|
|
||||||
public function show(): View
|
|
||||||
{
|
|
||||||
$about = About::first();
|
|
||||||
|
|
||||||
return view('admin.about.show', [
|
|
||||||
'aboutEntry' => $about,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function update(Request $request): RedirectResponse
|
|
||||||
{
|
|
||||||
$about = About::firstOrNew();
|
|
||||||
$about->content = $request->input('content');
|
|
||||||
$about->save();
|
|
||||||
|
|
||||||
return redirect()->route('admin.about.show');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -254,7 +254,7 @@ class PasskeysController extends Controller
|
||||||
], 400);
|
], 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
$passkey = Passkey::firstWhere('passkey_id', Base64UrlSafe::encodeUnpadded($publicKeyCredential->rawId));
|
$passkey = Passkey::firstWhere('passkey_id', $publicKeyCredential->id);
|
||||||
if (! $passkey) {
|
if (! $passkey) {
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'success' => false,
|
'success' => false,
|
||||||
|
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Http\Controllers\Admin;
|
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
|
||||||
use App\Models\Setting;
|
|
||||||
use Illuminate\Http\RedirectResponse;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\View\View;
|
|
||||||
|
|
||||||
class SettingsController extends Controller
|
|
||||||
{
|
|
||||||
public function show(): View
|
|
||||||
{
|
|
||||||
$settings = Setting::first();
|
|
||||||
|
|
||||||
return view('admin.settings.show', [
|
|
||||||
'settings' => $settings,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function update(Request $request): RedirectResponse
|
|
||||||
{
|
|
||||||
$settings = Setting::firstOrNew();
|
|
||||||
$settings->winter_effect_enabled = $request->boolean('winter_effect_enabled');
|
|
||||||
$settings->save();
|
|
||||||
|
|
||||||
return redirect()->route('admin.settings.show');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -6,9 +6,7 @@ namespace App\Http\Controllers\Admin;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Models\MicropubToken;
|
use App\Models\MicropubToken;
|
||||||
use App\Services\TokenService;
|
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\View\View;
|
use Illuminate\View\View;
|
||||||
|
|
||||||
class TokensController extends Controller
|
class TokensController extends Controller
|
||||||
|
|
@ -23,36 +21,6 @@ class TokensController extends Controller
|
||||||
return view('admin.tokens.index', compact('tokens'));
|
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.
|
* Revoke a Micropub token.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -7,13 +7,66 @@ namespace App\Http\Controllers;
|
||||||
use App\Models\Article;
|
use App\Models\Article;
|
||||||
use App\Models\Note;
|
use App\Models\Note;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Response;
|
||||||
|
|
||||||
class FeedsController extends Controller
|
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.
|
* Returns the blog JSON feed.
|
||||||
*/
|
*/
|
||||||
public function blogJson(): JsonResponse
|
public function blogJson(): array
|
||||||
{
|
{
|
||||||
$articles = Article::where('published', '1')->latest('updated_at')->take(20)->get();
|
$articles = Article::where('published', '1')->latest('updated_at')->take(20)->get();
|
||||||
$data = [
|
$data = [
|
||||||
|
|
@ -41,15 +94,13 @@ class FeedsController extends Controller
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
return response()->json($data, 200, [
|
return $data;
|
||||||
'Content-Type' => 'application/feed+json',
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the notes JSON feed.
|
* Returns the notes JSON feed.
|
||||||
*/
|
*/
|
||||||
public function notesJson(): JsonResponse
|
public function notesJson(): array
|
||||||
{
|
{
|
||||||
$notes = Note::latest()->with('media', 'place', 'tags')->take(20)->get();
|
$notes = Note::latest()->with('media', 'place', 'tags')->take(20)->get();
|
||||||
$data = [
|
$data = [
|
||||||
|
|
@ -79,9 +130,7 @@ class FeedsController extends Controller
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return response()->json($data, 200, [
|
return $data;
|
||||||
'Content-Type' => 'application/feed+json',
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -70,9 +70,7 @@ class MicropubController extends Controller
|
||||||
'error' => 'invalid_request',
|
'error' => 'invalid_request',
|
||||||
'error_description' => 'No known note with given ID',
|
'error_description' => 'No known note with given ID',
|
||||||
], 404);
|
], 404);
|
||||||
} catch (MicropubUnsupportedModelException $e) {
|
} catch (MicropubUnsupportedModelException) {
|
||||||
report($e);
|
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'error' => 'invalid',
|
'error' => 'invalid',
|
||||||
'error_description' => 'This implementation currently only supports the updating of notes',
|
'error_description' => 'This implementation currently only supports the updating of notes',
|
||||||
|
|
@ -82,16 +80,12 @@ class MicropubController extends Controller
|
||||||
'error' => 'invalid_request',
|
'error' => 'invalid_request',
|
||||||
'error_description' => $e->getMessage(),
|
'error_description' => $e->getMessage(),
|
||||||
], 400);
|
], 400);
|
||||||
} catch (MicropubHandlerException $e) {
|
} catch (MicropubHandlerException) {
|
||||||
report($e);
|
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'error' => 'unsupported_operation',
|
'error' => 'unsupported_operation',
|
||||||
'error_description' => 'The request could not be processed by this server',
|
'error_description' => 'The request could not be processed by this server',
|
||||||
], 500);
|
], 500);
|
||||||
} catch (\Throwable $e) {
|
} catch (\Exception $e) {
|
||||||
report($e);
|
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'error' => 'server_error',
|
'error' => 'server_error',
|
||||||
'error_description' => 'An error occurred processing the request',
|
'error_description' => 'An error occurred processing the request',
|
||||||
|
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Models;
|
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
|
|
||||||
class About extends Model
|
|
||||||
{
|
|
||||||
use HasFactory;
|
|
||||||
|
|
||||||
protected $table = 'about';
|
|
||||||
}
|
|
||||||
|
|
@ -40,7 +40,6 @@ class Article extends Model
|
||||||
return [
|
return [
|
||||||
'titleurl' => [
|
'titleurl' => [
|
||||||
'source' => 'title',
|
'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.
|
* Scope a query to only include articles from a particular year/month.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Models;
|
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
|
|
||||||
class Setting extends Model
|
|
||||||
{
|
|
||||||
use HasFactory;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var array<string, string>
|
|
||||||
*/
|
|
||||||
protected $casts = [
|
|
||||||
'winter_effect_enabled' => 'boolean',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
@ -2,12 +2,10 @@
|
||||||
|
|
||||||
namespace App\Providers;
|
namespace App\Providers;
|
||||||
|
|
||||||
use App\Models\Setting;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Pagination\LengthAwarePaginator;
|
use Illuminate\Pagination\LengthAwarePaginator;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Support\Facades\URL;
|
use Illuminate\Support\Facades\URL;
|
||||||
use Illuminate\Support\Facades\View;
|
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
use Symfony\Component\HtmlSanitizer\HtmlSanitizer;
|
use Symfony\Component\HtmlSanitizer\HtmlSanitizer;
|
||||||
use Symfony\Component\HtmlSanitizer\HtmlSanitizerConfig;
|
use Symfony\Component\HtmlSanitizer\HtmlSanitizerConfig;
|
||||||
|
|
@ -65,10 +63,5 @@ class AppServiceProvider extends ServiceProvider
|
||||||
|
|
||||||
// Force HTTPS URL generation in production
|
// Force HTTPS URL generation in production
|
||||||
URL::forceHttps($this->app->isProduction());
|
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);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,29 +8,12 @@ use App\Models\Article;
|
||||||
|
|
||||||
class ArticleService
|
class ArticleService
|
||||||
{
|
{
|
||||||
/**
|
|
||||||
* @throws \InvalidArgumentException if a published article already has this title
|
|
||||||
*/
|
|
||||||
public function create(array $data): Article
|
public function create(array $data): Article
|
||||||
{
|
{
|
||||||
$attributes = [
|
return Article::create([
|
||||||
'title' => $data['name'],
|
'title' => $data['name'],
|
||||||
'main' => $data['content'],
|
'main' => $data['content'],
|
||||||
'published' => ($data['post-status'] ?? null) !== 'draft',
|
'published' => true,
|
||||||
];
|
]);
|
||||||
|
|
||||||
$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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,6 @@ class EntryData extends MicropubData
|
||||||
public readonly mixed $checkin,
|
public readonly mixed $checkin,
|
||||||
public readonly mixed $syndication,
|
public readonly mixed $syndication,
|
||||||
public readonly ?array $photos,
|
public readonly ?array $photos,
|
||||||
public readonly ?string $postStatus,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public static function fromRequest(Request $request): static
|
public static function fromRequest(Request $request): static
|
||||||
|
|
@ -50,7 +49,6 @@ class EntryData extends MicropubData
|
||||||
checkin: Arr::get($data, 'properties.checkin.0'),
|
checkin: Arr::get($data, 'properties.checkin.0'),
|
||||||
syndication: Arr::get($data, 'properties.syndication.0'),
|
syndication: Arr::get($data, 'properties.syndication.0'),
|
||||||
photos: Arr::get($data, 'properties.photo'),
|
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'),
|
checkin: $request->input('checkin'),
|
||||||
syndication: $request->input('syndication'),
|
syndication: $request->input('syndication'),
|
||||||
photos: $request->input('photos'),
|
photos: $request->input('photos'),
|
||||||
postStatus: $request->input('post-status'),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -103,7 +100,6 @@ class EntryData extends MicropubData
|
||||||
checkin: $data['checkin'] ?? null,
|
checkin: $data['checkin'] ?? null,
|
||||||
syndication: $data['syndication'] ?? null,
|
syndication: $data['syndication'] ?? null,
|
||||||
photos: $data['photos'] ?? null,
|
photos: $data['photos'] ?? null,
|
||||||
postStatus: $data['post-status'] ?? null,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -124,7 +120,6 @@ class EntryData extends MicropubData
|
||||||
'checkin' => $this->checkin,
|
'checkin' => $this->checkin,
|
||||||
'syndication' => $this->syndication,
|
'syndication' => $this->syndication,
|
||||||
'photos' => $this->photos,
|
'photos' => $this->photos,
|
||||||
'post-status' => $this->postStatus,
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,7 @@ class EntryHandler implements MicropubHandlerInterface
|
||||||
$location = match (true) {
|
$location = match (true) {
|
||||||
isset($dataArray['like-of']) => resolve(LikeService::class)->create($dataArray)->url,
|
isset($dataArray['like-of']) => resolve(LikeService::class)->create($dataArray)->url,
|
||||||
isset($dataArray['bookmark-of']) => resolve(BookmarkService::class)->create($dataArray)->uri,
|
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,
|
default => resolve(NoteService::class)->create($dataArray)->uri,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace Database\Factories;
|
|
||||||
|
|
||||||
use App\Models\About;
|
|
||||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @extends Factory<About>
|
|
||||||
*/
|
|
||||||
class AboutFactory extends Factory
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Define the model's default state.
|
|
||||||
*
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
public function definition(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'content' => $this->faker->paragraph,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace Database\Factories;
|
|
||||||
|
|
||||||
use App\Models\Setting;
|
|
||||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @extends Factory<Setting>
|
|
||||||
*/
|
|
||||||
class SettingFactory extends Factory
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Define the model's default state.
|
|
||||||
*
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
public function definition(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'winter_effect_enabled' => false,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
use Illuminate\Database\Migrations\Migration;
|
|
||||||
use Illuminate\Database\Schema\Blueprint;
|
|
||||||
use Illuminate\Support\Facades\Schema;
|
|
||||||
|
|
||||||
return new class extends Migration
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Run the migrations.
|
|
||||||
*/
|
|
||||||
public function up(): void
|
|
||||||
{
|
|
||||||
Schema::create('settings', function (Blueprint $table) {
|
|
||||||
$table->id();
|
|
||||||
$table->boolean('winter_effect_enabled')->default(false);
|
|
||||||
$table->timestamps();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reverse the migrations.
|
|
||||||
*/
|
|
||||||
public function down(): void
|
|
||||||
{
|
|
||||||
Schema::dropIfExists('settings');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
use Illuminate\Database\Migrations\Migration;
|
|
||||||
use Illuminate\Database\Schema\Blueprint;
|
|
||||||
use Illuminate\Support\Facades\Schema;
|
|
||||||
|
|
||||||
return new class extends Migration
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Run the migrations.
|
|
||||||
*/
|
|
||||||
public function up(): void
|
|
||||||
{
|
|
||||||
Schema::create('about', function (Blueprint $table) {
|
|
||||||
$table->id();
|
|
||||||
$table->text('content');
|
|
||||||
$table->timestamps();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reverse the migrations.
|
|
||||||
*/
|
|
||||||
public function down(): void
|
|
||||||
{
|
|
||||||
Schema::dropIfExists('about');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
File diff suppressed because one or more lines are too long
Binary file not shown.
File diff suppressed because one or more lines are too long
Binary file not shown.
|
|
@ -1,2 +1,2 @@
|
||||||
(()=>{var l=class{constructor(){}async register(){let t=await this.getCreateOptions(),e={challenge:this.base64URLStringToBuffer(t.challenge),rp:{id:t.rp.id,name:t.rp.name},user:{id:new TextEncoder().encode(window.atob(t.user.id)),name:t.user.name,displayName:t.user.displayName},pubKeyCredParams:t.pubKeyCredParams,excludeCredentials:[],authenticatorSelection:t.authenticatorSelection,timeout:6e4},r=await navigator.credentials.create({publicKey:e});if(!r)throw new Error("Error generating a passkey");let s={id:r.id?r.id:null,type:r.type?r.type:null,rawId:r.rawId?this.bufferToBase64URLString(r.rawId):null,response:{attestationObject:r.response.attestationObject?this.bufferToBase64URLString(r.response.attestationObject):null,clientDataJSON:r.response.clientDataJSON?this.bufferToBase64URLString(r.response.clientDataJSON):null}};if(!(await window.fetch("/admin/passkeys/register",{method:"POST",body:JSON.stringify(s),cache:"no-cache",headers:{"Content-Type":"application/json","X-CSRF-TOKEN":document.querySelector('meta[name="csrf-token"]').getAttribute("content")}})).ok)throw new Error("Error saving the passkey");window.location.reload()}async getCreateOptions(){return await(await fetch("/admin/passkeys/register",{method:"GET"})).json()}async login(){let t=await this.getLoginData(),e=await navigator.credentials.get({publicKey:{challenge:this.base64URLStringToBuffer(t.challenge),userVerification:t.userVerification,timeout:6e4}});if(!e)throw new Error("Authentication failed");let r={id:e.id?e.id:"",type:e.type?e.type:"",rawId:e.rawId?this.bufferToBase64URLString(e.rawId):"",response:{authenticatorData:e.response.authenticatorData?this.bufferToBase64URLString(e.response.authenticatorData):"",clientDataJSON:e.response.clientDataJSON?this.bufferToBase64URLString(e.response.clientDataJSON):"",signature:e.response.signature?this.bufferToBase64URLString(e.response.signature):"",userHandle:e.response.userHandle?this.bufferToBase64URLString(e.response.userHandle):""}};if(!(await window.fetch("/login/passkey",{method:"POST",body:JSON.stringify(r),headers:{"Content-Type":"application/json","X-CSRF-TOKEN":document.querySelector('meta[name="csrf-token"]').getAttribute("content")}})).ok)throw new Error("Login failed");window.location.assign("/admin")}async getLoginData(){return await(await fetch("/login/passkey",{method:"GET"})).json()}base64URLStringToBuffer(t){let e=t.replace(/-/g,"+").replace(/_/g,"/"),r=(4-e.length%4)%4,s=e.padEnd(e.length+r,"="),n=window.atob(s),a=new ArrayBuffer(n.length),o=new Uint8Array(a);for(let c=0;c<n.length;c++)o[c]=n.charCodeAt(c);return a}bufferToBase64URLString(t){let e=new Uint8Array(t),r="";for(let n of e)r+=String.fromCharCode(n);return btoa(r).replace(/\+/g,"-").replace(/\//g,"_").replace(/=/g,"")}};var d=class{constructor(){this.widget=document.querySelector("#theme-selector"),this.btnLight=this.widget.querySelector("#btn-light"),this.btnDark=this.widget.querySelector("#btn-dark"),this.status=this.widget.querySelector("#theme-status"),this.currentTheme="system"}setupEventListeners(){this.btnLight.addEventListener("click",()=>{this.applyTheme(this.currentTheme==="light"?"system":"light")}),this.btnDark.addEventListener("click",()=>{this.applyTheme(this.currentTheme==="dark"?"system":"dark")})}applyTheme(t){let e=t==="light"||t==="dark"?t:"light dark",r=window.matchMedia&&window.matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light",s=document.documentElement,n=s.style.getPropertyValue("color-scheme");n===""&&(n="light dark");let a=!1;e!==n&&(e==="light dark"?a=n!==r:n==="light dark"?a=e!==r:a=!0);let o=()=>{this.currentTheme=t,s.style.setProperty("color-scheme",e),this.btnLight.setAttribute("aria-pressed",String(t==="light")),this.btnDark.setAttribute("aria-pressed",String(t==="dark")),this.status.textContent=t==="system"?"Theme set to system default":""};s.style.viewTransitionName="changing-theme",a&&document.startViewTransition?document.startViewTransition(o):o()}};var h=new l;document.querySelectorAll(".add-passkey").forEach(i=>{i.addEventListener("click",()=>{h.register()})});document.querySelectorAll(".login-passkey").forEach(i=>{i.addEventListener("click",()=>{h.login()})});var u=new d;u.setupEventListeners();})();
|
(()=>{var l=class{constructor(){}async register(){let r=await this.getCreateOptions(),e={challenge:this.base64URLStringToBuffer(r.challenge),rp:{id:r.rp.id,name:r.rp.name},user:{id:new TextEncoder().encode(window.atob(r.user.id)),name:r.user.name,displayName:r.user.displayName},pubKeyCredParams:r.pubKeyCredParams,excludeCredentials:[],authenticatorSelection:r.authenticatorSelection,timeout:6e4},t=await navigator.credentials.create({publicKey:e});if(!t)throw new Error("Error generating a passkey");let n={id:t.id?t.id:null,type:t.type?t.type:null,rawId:t.rawId?this.bufferToBase64URLString(t.rawId):null,response:{attestationObject:t.response.attestationObject?this.bufferToBase64URLString(t.response.attestationObject):null,clientDataJSON:t.response.clientDataJSON?this.bufferToBase64URLString(t.response.clientDataJSON):null}};if(!(await window.fetch("/admin/passkeys/register",{method:"POST",body:JSON.stringify(n),cache:"no-cache",headers:{"Content-Type":"application/json","X-CSRF-TOKEN":document.querySelector('meta[name="csrf-token"]').getAttribute("content")}})).ok)throw new Error("Error saving the passkey");window.location.reload()}async getCreateOptions(){return await(await fetch("/admin/passkeys/register",{method:"GET"})).json()}async login(){let r=await this.getLoginData(),e=await navigator.credentials.get({publicKey:{challenge:this.base64URLStringToBuffer(r.challenge),userVerification:r.userVerification,timeout:6e4}});if(!e)throw new Error("Authentication failed");let t={id:e.id?e.id:"",type:e.type?e.type:"",rawId:e.rawId?this.bufferToBase64URLString(e.rawId):"",response:{authenticatorData:e.response.authenticatorData?this.bufferToBase64URLString(e.response.authenticatorData):"",clientDataJSON:e.response.clientDataJSON?this.bufferToBase64URLString(e.response.clientDataJSON):"",signature:e.response.signature?this.bufferToBase64URLString(e.response.signature):"",userHandle:e.response.userHandle?this.bufferToBase64URLString(e.response.userHandle):""}};if(!(await window.fetch("/login/passkey",{method:"POST",body:JSON.stringify(t),headers:{"Content-Type":"application/json","X-CSRF-TOKEN":document.querySelector('meta[name="csrf-token"]').getAttribute("content")}})).ok)throw new Error("Login failed");window.location.assign("/admin")}async getLoginData(){return await(await fetch("/login/passkey",{method:"GET"})).json()}base64URLStringToBuffer(r){let e=r.replace(/-/g,"+").replace(/_/g,"/"),t=(4-e.length%4)%4,n=e.padEnd(e.length+t,"="),a=window.atob(n),o=new ArrayBuffer(a.length),i=new Uint8Array(o);for(let s=0;s<a.length;s++)i[s]=a.charCodeAt(s);return o}bufferToBase64URLString(r){let e=new Uint8Array(r),t="";for(let a of e)t+=String.fromCharCode(a);return btoa(t).replace(/\+/g,"-").replace(/\//g,"_").replace(/=/g,"")}};var d=class{constructor(){this.widget=document.querySelector("#theme-selector")}setupEventListeners(){this.widget.querySelectorAll("#theme-selector-dropdown input").forEach(r=>r.addEventListener("input",e=>{let t=e.target.value;this.widget.querySelectorAll(".toggle svg").forEach(u=>{u.classList.contains(t)?u.style.display="":u.style.display="none"});let n;switch(t){case"dark":case"light":n=t;break;default:n="light dark"}let a=window.matchMedia&&window.matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light",o=document.querySelector("html"),i=o.style.getPropertyValue("color-scheme");i===""&&(i="light dark");let s=!1;n!==i&&(n==="light dark"?s=i!==a:i==="light dark"?s=n!==a:s=!0),o.style.viewTransitionName="changing-theme",s&&document.startViewTransition?document.startViewTransition(()=>{document.querySelector("#theme-selector-dropdown").togglePopover(),o.style.setProperty("color-scheme",n)}):(document.querySelector("#theme-selector-dropdown").togglePopover(),o.style.setProperty("color-scheme",n))}))}};var h=new l;document.querySelectorAll(".add-passkey").forEach(c=>{c.addEventListener("click",()=>{h.register()})});document.querySelectorAll(".login-passkey").forEach(c=>{c.addEventListener("click",()=>{h.login()})});var p=new d;p.setupEventListeners();})();
|
||||||
//# sourceMappingURL=app.js.map
|
//# sourceMappingURL=app.js.map
|
||||||
|
|
|
||||||
Binary file not shown.
File diff suppressed because one or more lines are too long
Binary file not shown.
BIN
public/assets/js/snow-fall.js.br
Normal file
BIN
public/assets/js/snow-fall.js.br
Normal file
Binary file not shown.
BIN
public/assets/js/snow-fall.js.zst
Normal file
BIN
public/assets/js/snow-fall.js.zst
Normal file
Binary file not shown.
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -8,4 +8,3 @@
|
||||||
@import url('notes.css');
|
@import url('notes.css');
|
||||||
@import url('pagination.css');
|
@import url('pagination.css');
|
||||||
@import url('theme-selector.css');
|
@import url('theme-selector.css');
|
||||||
@import url('admin-tokens.css');
|
|
||||||
|
|
|
||||||
|
|
@ -18,19 +18,9 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
> main {
|
> main {
|
||||||
grid-column: 1/-1;
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: subgrid;
|
|
||||||
|
|
||||||
> * {
|
|
||||||
grid-column: 2/3;
|
grid-column: 2/3;
|
||||||
}
|
}
|
||||||
|
|
||||||
> .full-bleed {
|
|
||||||
grid-column: 1/-1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
> footer {
|
> footer {
|
||||||
grid-column: 1/-1;
|
grid-column: 1/-1;
|
||||||
margin-block-start: 2ex;
|
margin-block-start: 2ex;
|
||||||
|
|
|
||||||
|
|
@ -1,66 +1,70 @@
|
||||||
@layer components {
|
@layer components {
|
||||||
#theme-selector {
|
#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 {
|
button {
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
appearance: none;
|
appearance: none;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 999px;
|
background: none;
|
||||||
padding: .375rem;
|
|
||||||
background: transparent;
|
|
||||||
color: inherit;
|
|
||||||
transition: background-color .2s, color .2s;
|
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:focus-visible {
|
|
||||||
outline: 2px solid var(--clr-text);
|
|
||||||
outline-offset: 2px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
svg {
|
/* This is the element with the [popover] attribute */
|
||||||
width: 1.5rem;
|
#theme-selector-dropdown {
|
||||||
height: 1.5rem;
|
|
||||||
fill: currentcolor;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (hover: hover) {
|
|
||||||
&:hover {
|
|
||||||
background-color: color-mix(in oklch, var(--clr-border) 70%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
&[aria-pressed="true"]:hover {
|
|
||||||
background-color: var(--clr-text);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&[aria-pressed="true"] {
|
|
||||||
background-color: var(--clr-text);
|
|
||||||
color: var(--clr-background);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.sr-only {
|
|
||||||
position: absolute;
|
position: absolute;
|
||||||
width: 1px;
|
position-area: block-end span-inline-start;
|
||||||
height: 1px;
|
margin: 0;
|
||||||
padding: 0;
|
border: 2px solid var(--clr-border);
|
||||||
margin: -1px;
|
background-color: var(--clr-background);
|
||||||
overflow: hidden;
|
color: var(--clr-text);
|
||||||
clip-path: inset(50%);
|
|
||||||
white-space: nowrap;
|
fieldset {
|
||||||
border: 0;
|
border: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 1ch;
|
||||||
|
|
||||||
|
input {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This is the element with the [popover] attribute
|
||||||
|
* Here we are styling the open and closing animations
|
||||||
|
*/
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
#theme-selector-dropdown {
|
||||||
|
&:popover-open {
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
opacity: 1;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Start styles for the opening transition.
|
||||||
|
* Added to :popover-open, but after the opened styles.
|
||||||
|
*/
|
||||||
|
@starting-style {
|
||||||
|
transform: translateY(30px) scale(0);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* End styles for the closing transition.
|
||||||
|
*/
|
||||||
|
transform: translateY(0) scale(0);
|
||||||
|
opacity: 0;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Enumerate transitioning properties, including display and overlay.
|
||||||
|
*/
|
||||||
|
transition: transform, opacity, display allow-discrete, overlay allow-discrete;
|
||||||
|
transition-duration: 0.5s;
|
||||||
|
transform-origin: top right;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
::view-transition-group(changing-theme) {
|
::view-transition-group(changing-theme) {
|
||||||
|
|
|
||||||
|
|
@ -1,60 +1,69 @@
|
||||||
class ThemeSelector {
|
class ThemeSelector {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.widget = document.querySelector('#theme-selector');
|
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() {
|
setupEventListeners() {
|
||||||
this.btnLight.addEventListener('click', () => {
|
this.widget.querySelectorAll('#theme-selector-dropdown input').forEach((radioInput) => radioInput.addEventListener('input', (e) => {
|
||||||
this.applyTheme(this.currentTheme === 'light' ? 'system' : 'light');
|
let theme = e.target.value;
|
||||||
|
|
||||||
|
// Update current icon
|
||||||
|
this.widget.querySelectorAll('.toggle svg').forEach((svg) => {
|
||||||
|
if (svg.classList.contains(theme)) {
|
||||||
|
svg.style.display = '';
|
||||||
|
} else {
|
||||||
|
svg.style.display = 'none';
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
this.btnDark.addEventListener('click', () => {
|
// Set the theme
|
||||||
this.applyTheme(this.currentTheme === 'dark' ? 'system' : 'dark');
|
let selectedTheme;
|
||||||
});
|
switch (theme) {
|
||||||
|
case 'dark':
|
||||||
|
case 'light':
|
||||||
|
selectedTheme = theme;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
selectedTheme = 'light dark';
|
||||||
}
|
}
|
||||||
|
|
||||||
applyTheme(theme) {
|
|
||||||
const selectedTheme = (theme === 'light' || theme === 'dark') ? theme : 'light dark';
|
|
||||||
|
|
||||||
const systemTheme = (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) ? 'dark' : 'light';
|
const systemTheme = (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) ? 'dark' : 'light';
|
||||||
const html = document.documentElement;
|
const html = document.querySelector('html');
|
||||||
let currentColorScheme = html.style.getPropertyValue('color-scheme');
|
let currentTheme = html.style.getPropertyValue('color-scheme');
|
||||||
if (currentColorScheme === '') {
|
if (currentTheme === '') {
|
||||||
currentColorScheme = 'light dark';
|
currentTheme = 'light dark';
|
||||||
}
|
}
|
||||||
|
|
||||||
/* do we need to transition */
|
/* do we need to transition */
|
||||||
let doTransition = false;
|
let doTransition = false;
|
||||||
|
|
||||||
if (selectedTheme !== currentColorScheme) {
|
if (selectedTheme !== currentTheme) {
|
||||||
if (selectedTheme === 'light dark') {
|
if (selectedTheme === 'light dark') {
|
||||||
doTransition = currentColorScheme !== systemTheme;
|
doTransition = currentTheme !== systemTheme;
|
||||||
} else if (currentColorScheme === 'light dark') {
|
} else if (currentTheme === 'light dark') {
|
||||||
doTransition = selectedTheme !== systemTheme;
|
doTransition = selectedTheme !== systemTheme;
|
||||||
} else {
|
} else {
|
||||||
doTransition = true;
|
doTransition = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const applyChange = () => {
|
|
||||||
this.currentTheme = theme;
|
|
||||||
html.style.setProperty('color-scheme', selectedTheme);
|
|
||||||
|
|
||||||
this.btnLight.setAttribute('aria-pressed', String(theme === 'light'));
|
|
||||||
this.btnDark.setAttribute('aria-pressed', String(theme === 'dark'));
|
|
||||||
this.status.textContent = theme === 'system' ? 'Theme set to system default' : '';
|
|
||||||
};
|
|
||||||
|
|
||||||
html.style.viewTransitionName = 'changing-theme';
|
html.style.viewTransitionName = 'changing-theme';
|
||||||
if (doTransition && document.startViewTransition) {
|
if (doTransition && document.startViewTransition) {
|
||||||
document.startViewTransition(applyChange);
|
document.startViewTransition(() => {
|
||||||
|
// Close the popover
|
||||||
|
document.querySelector('#theme-selector-dropdown').togglePopover();
|
||||||
|
|
||||||
|
// Set the colour theme
|
||||||
|
html.style.setProperty('color-scheme', selectedTheme);
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
applyChange();
|
// Close the popover
|
||||||
|
document.querySelector('#theme-selector-dropdown').togglePopover();
|
||||||
|
|
||||||
|
// Set the colour theme
|
||||||
|
html.style.setProperty('color-scheme', selectedTheme);
|
||||||
}
|
}
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
@extends('master')
|
|
||||||
@section('title')About « @stop
|
|
||||||
|
|
||||||
@section('content')
|
|
||||||
<h2>About</h2>
|
|
||||||
<div class="e-content">
|
|
||||||
{!! $about !!}
|
|
||||||
</div>
|
|
||||||
@stop
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
@extends('master')
|
|
||||||
|
|
||||||
@section('title')Edit About « Admin CP « @stop
|
|
||||||
|
|
||||||
@section('content')
|
|
||||||
<h1>Edit About</h1>
|
|
||||||
<form action="/admin/about" method="post" accept-charset="utf-8" class="admin-form form">
|
|
||||||
{{ csrf_field() }}
|
|
||||||
{{ method_field('PUT') }}
|
|
||||||
<div>
|
|
||||||
<label for="content">Content:</label>
|
|
||||||
<br>
|
|
||||||
<textarea name="content" id="content" rows="10" cols="50">{{ old('content', $aboutEntry?->content) }}</textarea>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<button type="submit" name="save">Save</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
@stop
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
@extends('master')
|
|
||||||
|
|
||||||
@section('title')Site Settings « Admin CP « @stop
|
|
||||||
|
|
||||||
@section('content')
|
|
||||||
<h1>Site settings</h1>
|
|
||||||
<form action="/admin/settings" method="post" accept-charset="utf-8" class="admin-form form">
|
|
||||||
{{ csrf_field() }}
|
|
||||||
{{ method_field('PUT') }}
|
|
||||||
<div>
|
|
||||||
<label for="winter_effect_enabled">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
name="winter_effect_enabled"
|
|
||||||
id="winter_effect_enabled"
|
|
||||||
value="1"
|
|
||||||
@checked(old('winter_effect_enabled', $settings?->winter_effect_enabled))
|
|
||||||
>
|
|
||||||
Show winter snow effect
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<button type="submit" name="save">Save</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
@stop
|
|
||||||
|
|
@ -1,52 +0,0 @@
|
||||||
@extends('master')
|
|
||||||
|
|
||||||
@section('title')New Token « Admin CP « @stop
|
|
||||||
|
|
||||||
@section('content')
|
|
||||||
<h1>Generate a new token</h1>
|
|
||||||
<p>Use this for clients that can't complete the IndieAuth authorization flow (e.g. they don't support PKCE) and instead let you paste in a token directly.</p>
|
|
||||||
|
|
||||||
<form action="/admin/tokens" method="post" accept-charset="utf-8" class="admin-form form">
|
|
||||||
{{ csrf_field() }}
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label for="client_id">Client</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="client_id"
|
|
||||||
id="client_id"
|
|
||||||
value="{{ old('client_id') }}"
|
|
||||||
placeholder="https://ia.net/writer"
|
|
||||||
required
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="scope-checkboxes">
|
|
||||||
<span>Scope</span>
|
|
||||||
<label for="scope_create">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
name="scope[]"
|
|
||||||
id="scope_create"
|
|
||||||
value="create"
|
|
||||||
@checked(in_array('create', old('scope', []), true))
|
|
||||||
>
|
|
||||||
create
|
|
||||||
</label>
|
|
||||||
<label for="scope_update">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
name="scope[]"
|
|
||||||
id="scope_update"
|
|
||||||
value="update"
|
|
||||||
@checked(in_array('update', old('scope', []), true))
|
|
||||||
>
|
|
||||||
update
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<button type="submit" name="save">Generate token</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
@stop
|
|
||||||
|
|
@ -4,61 +4,24 @@
|
||||||
|
|
||||||
@section('content')
|
@section('content')
|
||||||
<h1>Micropub Tokens</h1>
|
<h1>Micropub Tokens</h1>
|
||||||
<p><a href="/admin/tokens/create">Generate new token</a></p>
|
|
||||||
|
|
||||||
@if(session('new_token'))
|
|
||||||
<div class="token-reveal">
|
|
||||||
<p>Here's your new token. <strong>Copy it now</strong> — it won't be shown again.</p>
|
|
||||||
<input type="text" readonly value="{{ session('new_token') }}" onclick="this.select()">
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
@if($tokens->isEmpty())
|
@if($tokens->isEmpty())
|
||||||
<p>No tokens have been issued.</p>
|
<p>No tokens have been issued.</p>
|
||||||
@else
|
@else
|
||||||
<div class="full-bleed token-list-wrapper">
|
<ul>
|
||||||
<table class="token-list">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th scope="col">Client</th>
|
|
||||||
<th scope="col">Scope</th>
|
|
||||||
<th scope="col">Issued</th>
|
|
||||||
<th scope="col">Status</th>
|
|
||||||
<th scope="col">Action</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
@foreach($tokens as $token)
|
@foreach($tokens as $token)
|
||||||
<tr class="{{ $token->isRevoked ? 'is-revoked' : '' }}">
|
<li>
|
||||||
<td><a href="{{ $token->client_id }}" rel="noopener">{{ $token->client_id }}</a></td>
|
{{ $token->client_id }} — scope: {{ $token->scope }} — issued {{ $token->created_at->diffForHumans() }}
|
||||||
<td>
|
|
||||||
<span class="scope-chips">
|
|
||||||
@foreach(explode(' ', $token->scope) as $scope)
|
|
||||||
<span class="scope-chip">{{ $scope }}</span>
|
|
||||||
@endforeach
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td><time datetime="{{ $token->created_at->toIso8601String() }}" title="{{ $token->created_at }}">{{ $token->created_at->diffForHumans() }}</time></td>
|
|
||||||
<td>
|
|
||||||
@if($token->isRevoked)
|
@if($token->isRevoked)
|
||||||
<span class="badge badge-revoked">Revoked {{ $token->revoked_at->diffForHumans() }}</span>
|
— revoked {{ $token->revoked_at->diffForHumans() }}
|
||||||
@else
|
@else
|
||||||
<span class="badge badge-active"><span class="dot"></span>Active</span>
|
<form action="/admin/tokens/{{ $token->id }}/revoke" method="post">
|
||||||
@endif
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
@unless($token->isRevoked)
|
|
||||||
<form action="/admin/tokens/{{ $token->id }}/revoke" method="post" onsubmit="return confirm('Revoke this token? This cannot be undone.')">
|
|
||||||
{{ csrf_field() }}
|
{{ csrf_field() }}
|
||||||
{{ method_field('PUT') }}
|
{{ method_field('PUT') }}
|
||||||
<button type="submit" class="revoke" name="revoke">Revoke</button>
|
<button type="submit" name="revoke">Revoke</button>
|
||||||
</form>
|
</form>
|
||||||
@endunless
|
@endif
|
||||||
</td>
|
</li>
|
||||||
</tr>
|
|
||||||
@endforeach
|
@endforeach
|
||||||
</tbody>
|
</ul>
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
@endif
|
@endif
|
||||||
@stop
|
@stop
|
||||||
|
|
|
||||||
|
|
@ -57,18 +57,8 @@
|
||||||
Edit your <a href="/admin/bio">bio</a>.
|
Edit your <a href="/admin/bio">bio</a>.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h2>About</h2>
|
|
||||||
<p>
|
|
||||||
Edit your <a href="/admin/about">about page</a>.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<h2>Passkeys</h2>
|
<h2>Passkeys</h2>
|
||||||
<p>
|
<p>
|
||||||
Manager <a href="/admin/passkeys">your passkeys</a>.
|
Manager <a href="/admin/passkeys">your passkeys</a>.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h2>Settings</h2>
|
|
||||||
<p>
|
|
||||||
Edit <a href="/admin/settings">site settings</a>.
|
|
||||||
</p>
|
|
||||||
@stop
|
@stop
|
||||||
|
|
|
||||||
20
resources/views/articles/atom.blade.php
Normal file
20
resources/views/articles/atom.blade.php
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<feed xmlns="http://www.w3.org/2005/Atom">
|
||||||
|
<title>Atom feed for {{ config('user.display_name') }}’s blog</title>
|
||||||
|
<link rel="self" href="{{ config('app.url') }}/blog/feed.atom" />
|
||||||
|
<id>{{ config('app.url')}}/blog</id>
|
||||||
|
<updated>{{ $articles[0]->updated_at->toAtomString() }}</updated>
|
||||||
|
|
||||||
|
@foreach($articles as $article)
|
||||||
|
<entry>
|
||||||
|
<title>{{ $article->title }}</title>
|
||||||
|
<link href="{{ config('app.url') }}{{ $article->link }}" />
|
||||||
|
<id>{{ config('app.url') }}{{ $article->link }}</id>
|
||||||
|
<updated>{{ $article->updated_at->toAtomString() }}</updated>
|
||||||
|
<content>{{ $article->main }}</content>
|
||||||
|
<author>
|
||||||
|
<name>{{ config('user.display_name') }}</name>
|
||||||
|
</author>
|
||||||
|
</entry>
|
||||||
|
@endforeach
|
||||||
|
</feed>
|
||||||
26
resources/views/articles/rss.blade.php
Normal file
26
resources/views/articles/rss.blade.php
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8" ?>
|
||||||
|
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
|
||||||
|
<channel>
|
||||||
|
<title>{{ config('user.display_name') }}</title>
|
||||||
|
<atom:link href="{{ config('app.url') }}/blog/feed.rss" rel="self" type="application/rss+xml" />
|
||||||
|
<description>An RSS feed of the blog posts found on {{ config('app.url') }}</description>
|
||||||
|
<link>{{ config('app.url') }}/blog</link>
|
||||||
|
<lastBuildDate>{{ $buildDate }}</lastBuildDate>
|
||||||
|
<ttl>1800</ttl>
|
||||||
|
|
||||||
|
@foreach($articles as $article)
|
||||||
|
<item>
|
||||||
|
<title>{{ strip_tags($article->title) }}</title>
|
||||||
|
<description>
|
||||||
|
<![CDATA[
|
||||||
|
{{ $article->main }}
|
||||||
|
@if($article->url)<p><a href="{{ config('app.url') }}{{ $article->link }}">Permalink</a></p>@endif
|
||||||
|
]]>
|
||||||
|
</description>
|
||||||
|
<link>@if($article->url != ''){{ $article->url }}@else{{ config('app.url') }}{{ $article->link }}@endif</link>
|
||||||
|
<guid>{{ config('app.url') }}{{ $article->link }}</guid>
|
||||||
|
<pubDate>{{ $article->pubdate }}</pubDate>
|
||||||
|
</item>
|
||||||
|
@endforeach
|
||||||
|
</channel>
|
||||||
|
</rss>
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
@php
|
|
||||||
if (isset($title)) {
|
|
||||||
$uniqueId = bin2hex(random_bytes(6));
|
|
||||||
}
|
|
||||||
@endphp
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 200 200" class="feather feather-json-feed"
|
|
||||||
@if($title)aria-labelledby="{{ $uniqueId }}"@endif
|
|
||||||
>
|
|
||||||
@if($title)<title id="{{ $uniqueId }}">{{ $title }}</title>@endif
|
|
||||||
<path fill="#497714" transform="translate(0,200) scale(0.1,-0.1)" d="M373 1547 c-88 -95 -127 -171 -127 -252 -1 -90 28 -140 164 -280 150 -155 160 -168 160 -219 0 -31 -9 -55 -41 -101 -34 -51 -39 -65 -34 -95 3 -20 18 -46 36 -62 44 -41 89 -38 163 12 32 22 67 40 78 40 46 0 99 -38 227 -164 169 -165 209 -190 305 -190 92 -1 158 32 258 125 l73 67 -59 61 -60 60 -39 -36 c-22 -19 -60 -44 -84 -55 -73 -32 -109 -12 -278 152 -77 76 -156 145 -175 155 -47 24 -110 22 -159 -6 -23 -12 -44 -19 -48 -15 -4 4 2 25 15 46 24 42 29 110 12 154 -5 15 -66 84 -135 156 -138 143 -165 180 -165 227 0 47 15 78 61 128 l42 45 -54 59 c-30 32 -57 59 -61 60 -3 0 -37 -32 -75 -72z M1474 1631 c-36 -22 -58 -75 -48 -115 10 -41 59 -76 106 -76 107 0 137 147 41 199 -31 16 -64 14 -99 -8z M1229 1385 c-55 -30 -73 -89 -44 -145 19 -37 43 -50 95 -50 110 0 139 144 40 195 -36 18 -57 18 -91 0z M949 1111 c-86 -87 29 -228 130 -159 40 27 52 62 41 115 -12 51 -41 73 -97 73 -36 0 -50 -6 -74 -29z"/>
|
|
||||||
</svg>
|
|
||||||
|
|
@ -7,9 +7,13 @@
|
||||||
<title>@yield('title'){{ config('app.name') }}</title>
|
<title>@yield('title'){{ config('app.name') }}</title>
|
||||||
<link rel="stylesheet" href="/assets/highlight/nord.css">
|
<link rel="stylesheet" href="/assets/highlight/nord.css">
|
||||||
<link rel="stylesheet" href="/assets/css/app.css">
|
<link rel="stylesheet" href="/assets/css/app.css">
|
||||||
<link rel="alternate" type="application/feed+json" title="Blog JSON Feed" href="{{ route('feed.blog.json') }}">
|
<link rel="alternate" type="application/rss+xml" title="Blog RSS Feed" href="{{ route('feed.blog.rss') }}">
|
||||||
|
<link rel="alternate" type="application/atom+xml" title="Blog Atom Feed" href="{{ route('feed.blog.atom') }}">
|
||||||
|
<link rel="alternate" type="application/json" title="Blog JSON Feed" href="{{ route('feed.blog.json') }}">
|
||||||
<link rel="alternate" type="application/jf2feed+json" title="Blog JF2 Feed" href="{{ route('feed.blog.jf2') }}">
|
<link rel="alternate" type="application/jf2feed+json" title="Blog JF2 Feed" href="{{ route('feed.blog.jf2') }}">
|
||||||
<link rel="alternate" type="application/feed+json" title="Notes JSON Feed" href="{{ route('feed.notes.json') }}">
|
<link rel="alternate" type="application/rss+xml" title="Notes RSS Feed" href="{{ route('feed.notes.rss') }}">
|
||||||
|
<link rel="alternate" type="application/atom+xml" title="Notes Atom Feed" href="{{ route('feed.notes.atom') }}">
|
||||||
|
<link rel="alternate" type="application/json" title="Notes JSON Feed" href="{{ route('feed.notes.json') }}">
|
||||||
<link rel="alternate" type="application/jf2feed+json" title="Notes JF2 Feed" href="{{ route('feed.notes.jf2') }}">
|
<link rel="alternate" type="application/jf2feed+json" title="Notes JF2 Feed" href="{{ route('feed.notes.jf2') }}">
|
||||||
<link rel="indieauth-metadata" href="{{ route('indieauth.metadata') }}">
|
<link rel="indieauth-metadata" href="{{ route('indieauth.metadata') }}">
|
||||||
<link rel="authorization_endpoint" href="{{ route('indieauth.start') }}">
|
<link rel="authorization_endpoint" href="{{ route('indieauth.start') }}">
|
||||||
|
|
@ -36,17 +40,37 @@
|
||||||
<a href="/likes">Likes</a>
|
<a href="/likes">Likes</a>
|
||||||
<a href="/contacts">Contacts</a>
|
<a href="/contacts">Contacts</a>
|
||||||
<a href="/projects">Projects</a>
|
<a href="/projects">Projects</a>
|
||||||
<a href="/about">About</a>
|
<a href="{{ route('feed.notes.json') }}" class="rss-icon">@include('icons.rss', ['title' => 'RSS Feed'])</a>
|
||||||
<a href="{{ route('feed.notes.json') }}" class="rss-icon">@include('icons.json-feed', ['title' => 'JSON Feed'])</a>
|
|
||||||
</nav>
|
</nav>
|
||||||
<div id="theme-selector" role="region" aria-label="Theme switcher">
|
<div id="theme-selector">
|
||||||
<button id="btn-light" aria-label="Light mode" aria-pressed="false">
|
<button class="toggle" popovertarget="theme-selector-dropdown">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true"><!-- Icon from Fluent UI System Icons by Microsoft Corporation - https://github.com/microsoft/fluentui-system-icons/blob/main/LICENSE --><path fill="currentColor" d="M12 2a.75.75 0 0 1 .75.75v1.5a.75.75 0 0 1-1.5 0v-1.5A.75.75 0 0 1 12 2m5 10a5 5 0 1 1-10 0a5 5 0 0 1 10 0m4.25.75a.75.75 0 0 0 0-1.5h-1.5a.75.75 0 0 0 0 1.5zM12 19a.75.75 0 0 1 .75.75v1.5a.75.75 0 0 1-1.5 0v-1.5A.75.75 0 0 1 12 19m-7.75-6.25a.75.75 0 0 0 0-1.5h-1.5a.75.75 0 0 0 0 1.5zm-.03-8.53a.75.75 0 0 1 1.06 0l1.5 1.5a.75.75 0 0 1-1.06 1.06l-1.5-1.5a.75.75 0 0 1 0-1.06m1.06 15.56a.75.75 0 1 1-1.06-1.06l1.5-1.5a.75.75 0 1 1 1.06 1.06zm14.5-15.56a.75.75 0 0 0-1.06 0l-1.5 1.5a.75.75 0 0 0 1.06 1.06l1.5-1.5a.75.75 0 0 0 0-1.06m-1.06 15.56a.75.75 0 1 0 1.06-1.06l-1.5-1.5a.75.75 0 1 0-1.06 1.06z"/></svg>
|
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" class="system" style=""><!-- Icon from Fluent UI System Icons by Microsoft Corporation - https://github.com/microsoft/fluentui-system-icons/blob/main/LICENSE --><path fill="currentColor" d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2S2 6.477 2 12s4.477 10 10 10m0-1.5v-17a8.5 8.5 0 0 1 0 17"/></svg>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" class="dark" style="display: none"><!-- Icon from Fluent UI System Icons by Microsoft Corporation - https://github.com/microsoft/fluentui-system-icons/blob/main/LICENSE --><path fill="currentColor" d="M20.026 17.001c-2.762 4.784-8.879 6.423-13.663 3.661A10 10 0 0 1 3.13 17.68a.75.75 0 0 1 .365-1.132c3.767-1.348 5.785-2.91 6.956-5.146c1.233-2.353 1.551-4.93.689-8.463a.75.75 0 0 1 .769-.927a9.96 9.96 0 0 1 4.457 1.327c4.784 2.762 6.423 8.879 3.66 13.662"/></svg>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" class="light" style="display: none"><!-- Icon from Fluent UI System Icons by Microsoft Corporation - https://github.com/microsoft/fluentui-system-icons/blob/main/LICENSE --><path fill="currentColor" d="M12 2a.75.75 0 0 1 .75.75v1.5a.75.75 0 0 1-1.5 0v-1.5A.75.75 0 0 1 12 2m5 10a5 5 0 1 1-10 0a5 5 0 0 1 10 0m4.25.75a.75.75 0 0 0 0-1.5h-1.5a.75.75 0 0 0 0 1.5zM12 19a.75.75 0 0 1 .75.75v1.5a.75.75 0 0 1-1.5 0v-1.5A.75.75 0 0 1 12 19m-7.75-6.25a.75.75 0 0 0 0-1.5h-1.5a.75.75 0 0 0 0 1.5zm-.03-8.53a.75.75 0 0 1 1.06 0l1.5 1.5a.75.75 0 0 1-1.06 1.06l-1.5-1.5a.75.75 0 0 1 0-1.06m1.06 15.56a.75.75 0 1 1-1.06-1.06l1.5-1.5a.75.75 0 1 1 1.06 1.06zm14.5-15.56a.75.75 0 0 0-1.06 0l-1.5 1.5a.75.75 0 0 0 1.06 1.06l1.5-1.5a.75.75 0 0 0 0-1.06m-1.06 15.56a.75.75 0 1 0 1.06-1.06l-1.5-1.5a.75.75 0 1 0-1.06 1.06z"/></svg>
|
||||||
</button>
|
</button>
|
||||||
<button id="btn-dark" aria-label="Dark mode" aria-pressed="false">
|
<div id="theme-selector-dropdown" popover>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true"><!-- Icon from Fluent UI System Icons by Microsoft Corporation - https://github.com/microsoft/fluentui-system-icons/blob/main/LICENSE --><path fill="currentColor" d="M20.026 17.001c-2.762 4.784-8.879 6.423-13.663 3.661A10 10 0 0 1 3.13 17.68a.75.75 0 0 1 .365-1.132c3.767-1.348 5.785-2.91 6.956-5.146c1.233-2.353 1.551-4.93.689-8.463a.75.75 0 0 1 .769-.927a9.96 9.96 0 0 1 4.457 1.327c4.784 2.762 6.423 8.879 3.66 13.662"/></svg>
|
<fieldset>
|
||||||
</button>
|
<legend>Select theme:</legend>
|
||||||
<span id="theme-status" class="sr-only" aria-live="polite"></span>
|
<div>
|
||||||
|
<input type="radio" id="theme-system" name="theme" value="system" checked>
|
||||||
|
<label for="theme-system">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"><!-- Icon from Fluent UI System Icons by Microsoft Corporation - https://github.com/microsoft/fluentui-system-icons/blob/main/LICENSE --><path fill="currentColor" d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2S2 6.477 2 12s4.477 10 10 10m0-1.5v-17a8.5 8.5 0 0 1 0 17"/></svg>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<input type="radio" id="theme-dark" name="theme" value="dark">
|
||||||
|
<label for="theme-dark">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"><!-- Icon from Fluent UI System Icons by Microsoft Corporation - https://github.com/microsoft/fluentui-system-icons/blob/main/LICENSE --><path fill="currentColor" d="M20.026 17.001c-2.762 4.784-8.879 6.423-13.663 3.661A10 10 0 0 1 3.13 17.68a.75.75 0 0 1 .365-1.132c3.767-1.348 5.785-2.91 6.956-5.146c1.233-2.353 1.551-4.93.689-8.463a.75.75 0 0 1 .769-.927a9.96 9.96 0 0 1 4.457 1.327c4.784 2.762 6.423 8.879 3.66 13.662"/></svg>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<input type="radio" id="theme-light" name="theme" value="light">
|
||||||
|
<label for="theme-light">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"><!-- Icon from Fluent UI System Icons by Microsoft Corporation - https://github.com/microsoft/fluentui-system-icons/blob/main/LICENSE --><path fill="currentColor" d="M12 2a.75.75 0 0 1 .75.75v1.5a.75.75 0 0 1-1.5 0v-1.5A.75.75 0 0 1 12 2m5 10a5 5 0 1 1-10 0a5 5 0 0 1 10 0m4.25.75a.75.75 0 0 0 0-1.5h-1.5a.75.75 0 0 0 0 1.5zM12 19a.75.75 0 0 1 .75.75v1.5a.75.75 0 0 1-1.5 0v-1.5A.75.75 0 0 1 12 19m-7.75-6.25a.75.75 0 0 0 0-1.5h-1.5a.75.75 0 0 0 0 1.5zm-.03-8.53a.75.75 0 0 1 1.06 0l1.5 1.5a.75.75 0 0 1-1.06 1.06l-1.5-1.5a.75.75 0 0 1 0-1.06m1.06 15.56a.75.75 0 1 1-1.06-1.06l1.5-1.5a.75.75 0 1 1 1.06 1.06zm14.5-15.56a.75.75 0 0 0-1.06 0l-1.5 1.5a.75.75 0 0 0 1.06 1.06l1.5-1.5a.75.75 0 0 0 0-1.06m-1.06 15.56a.75.75 0 1 0 1.06-1.06l-1.5-1.5a.75.75 0 1 0-1.06 1.06z"/></svg>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|
@ -82,7 +106,6 @@
|
||||||
@section('scripts')
|
@section('scripts')
|
||||||
<script type="module" src="/assets/js/app.js"></script>
|
<script type="module" src="/assets/js/app.js"></script>
|
||||||
|
|
||||||
@if($winterEffectEnabled ?? false)
|
|
||||||
<script type="module" src="/assets/js/is-land.min.js"></script>
|
<script type="module" src="/assets/js/is-land.min.js"></script>
|
||||||
<script type="module" src="/assets/js/winter.js"></script>
|
<script type="module" src="/assets/js/winter.js"></script>
|
||||||
<is-land on:media="(prefers-reduced-motion: no-preference)">
|
<is-land on:media="(prefers-reduced-motion: no-preference)">
|
||||||
|
|
@ -91,7 +114,6 @@
|
||||||
style="--snow-fall-color: var(--clr-snow-fall)"
|
style="--snow-fall-color: var(--clr-snow-fall)"
|
||||||
></snow-fall>
|
></snow-fall>
|
||||||
</is-land>
|
</is-land>
|
||||||
@endif
|
|
||||||
@show
|
@show
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
20
resources/views/notes/atom.blade.php
Normal file
20
resources/views/notes/atom.blade.php
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<feed xmlns="http://www.w3.org/2005/Atom">
|
||||||
|
<title>Atom feed for {{ config('user.display_name') }}’s notes</title>
|
||||||
|
<link rel="self" href="{{ config('app.url') }}/notes/feed.atom" />
|
||||||
|
<id>{{ config('app.url')}}/notes</id>
|
||||||
|
<updated>{{ $notes[0]->updated_at->toAtomString() }}</updated>
|
||||||
|
|
||||||
|
@foreach($notes as $note)
|
||||||
|
<entry>
|
||||||
|
<title>{{ strip_tags($note->note) }}</title>
|
||||||
|
<link href="{{ $note->uri }}" />
|
||||||
|
<id>{{ $note->uri }}</id>
|
||||||
|
<updated>{{ $note->updated_at->toAtomString() }}</updated>
|
||||||
|
<content type="html">{{ $note->note }}</content>
|
||||||
|
<author>
|
||||||
|
<name>{{ config('user.display_name') }}</name>
|
||||||
|
</author>
|
||||||
|
</entry>
|
||||||
|
@endforeach
|
||||||
|
</feed>
|
||||||
26
resources/views/notes/rss.blade.php
Normal file
26
resources/views/notes/rss.blade.php
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8" ?>
|
||||||
|
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
|
||||||
|
<channel>
|
||||||
|
<title>{{ config('user.display_name') }}</title>
|
||||||
|
<atom:link href="{{ config('app.url') }}/notes/feed.rss" rel="self" type="application/rss+xml" />
|
||||||
|
<description>An RSS feed of the notes found on {{ config('app.url') }}</description>
|
||||||
|
<link>{{ config('app.url') }}/notes</link>
|
||||||
|
<lastBuildDate>{{ $buildDate }}</lastBuildDate>
|
||||||
|
<ttl>1800</ttl>
|
||||||
|
|
||||||
|
@foreach($notes as $note)
|
||||||
|
<item>
|
||||||
|
<title>{{ strip_tags($note->note) }}</title>
|
||||||
|
<description>
|
||||||
|
<![CDATA[
|
||||||
|
{!! $note->note !!}
|
||||||
|
]]>
|
||||||
|
</description>
|
||||||
|
<link>{{ $note->uri }}</link>
|
||||||
|
<guid>{{ $note->uri}}</guid>
|
||||||
|
<pubDate>{{ $note->pubdate }}</pubDate>
|
||||||
|
</item>
|
||||||
|
@endforeach
|
||||||
|
|
||||||
|
</channel>
|
||||||
|
</rss>
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Http\Controllers\AboutPageController;
|
|
||||||
use App\Http\Controllers\Admin\AboutController;
|
|
||||||
use App\Http\Controllers\Admin\ArticlesController as AdminArticlesController;
|
use App\Http\Controllers\Admin\ArticlesController as AdminArticlesController;
|
||||||
use App\Http\Controllers\Admin\BioController;
|
use App\Http\Controllers\Admin\BioController;
|
||||||
use App\Http\Controllers\Admin\ClientsController;
|
use App\Http\Controllers\Admin\ClientsController;
|
||||||
|
|
@ -11,7 +9,6 @@ use App\Http\Controllers\Admin\LikesController as AdminLikesController;
|
||||||
use App\Http\Controllers\Admin\NotesController as AdminNotesController;
|
use App\Http\Controllers\Admin\NotesController as AdminNotesController;
|
||||||
use App\Http\Controllers\Admin\PasskeysController;
|
use App\Http\Controllers\Admin\PasskeysController;
|
||||||
use App\Http\Controllers\Admin\PlacesController as AdminPlacesController;
|
use App\Http\Controllers\Admin\PlacesController as AdminPlacesController;
|
||||||
use App\Http\Controllers\Admin\SettingsController;
|
|
||||||
use App\Http\Controllers\Admin\SyndicationTargetsController;
|
use App\Http\Controllers\Admin\SyndicationTargetsController;
|
||||||
use App\Http\Controllers\Admin\TokensController;
|
use App\Http\Controllers\Admin\TokensController;
|
||||||
use App\Http\Controllers\ArticlesController;
|
use App\Http\Controllers\ArticlesController;
|
||||||
|
|
@ -53,9 +50,6 @@ Route::view('projects', 'projects');
|
||||||
// Static colophon page
|
// Static colophon page
|
||||||
Route::view('colophon', 'colophon');
|
Route::view('colophon', 'colophon');
|
||||||
|
|
||||||
// About page
|
|
||||||
Route::get('about', [AboutPageController::class, 'show']);
|
|
||||||
|
|
||||||
// The login routes to get auth’d for admin
|
// The login routes to get auth’d for admin
|
||||||
Route::get('login', [AuthController::class, 'showLogin'])->name('login');
|
Route::get('login', [AuthController::class, 'showLogin'])->name('login');
|
||||||
Route::post('login', [AuthController::class, 'login']);
|
Route::post('login', [AuthController::class, 'login']);
|
||||||
|
|
@ -157,8 +151,6 @@ Route::middleware(MyAuthMiddleware::class)->prefix('admin')->group(function () {
|
||||||
// Micropub Tokens
|
// Micropub Tokens
|
||||||
Route::prefix('tokens')->group(function () {
|
Route::prefix('tokens')->group(function () {
|
||||||
Route::get('/', [TokensController::class, 'index']);
|
Route::get('/', [TokensController::class, 'index']);
|
||||||
Route::get('/create', [TokensController::class, 'create']);
|
|
||||||
Route::post('/', [TokensController::class, 'store']);
|
|
||||||
Route::put('/{token}/revoke', [TokensController::class, 'revoke']);
|
Route::put('/{token}/revoke', [TokensController::class, 'revoke']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -168,18 +160,6 @@ Route::middleware(MyAuthMiddleware::class)->prefix('admin')->group(function () {
|
||||||
Route::put('/', [BioController::class, 'update']);
|
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
|
// Passkeys
|
||||||
Route::prefix('passkeys')->group(function () {
|
Route::prefix('passkeys')->group(function () {
|
||||||
Route::get('/', [PasskeysController::class, 'index']);
|
Route::get('/', [PasskeysController::class, 'index']);
|
||||||
|
|
@ -190,6 +170,8 @@ Route::middleware(MyAuthMiddleware::class)->prefix('admin')->group(function () {
|
||||||
|
|
||||||
// Blog pages using ArticlesController
|
// Blog pages using ArticlesController
|
||||||
Route::prefix('blog')->group(function () {
|
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.json', [FeedsController::class, 'blogJson'])->name('feed.blog.json');
|
||||||
Route::get('/feed.jf2', [FeedsController::class, 'blogJf2'])->name('feed.blog.jf2');
|
Route::get('/feed.jf2', [FeedsController::class, 'blogJf2'])->name('feed.blog.jf2');
|
||||||
Route::get('/s/{id}', [ArticlesController::class, 'onlyIdInURL']);
|
Route::get('/s/{id}', [ArticlesController::class, 'onlyIdInURL']);
|
||||||
|
|
@ -200,6 +182,8 @@ Route::prefix('blog')->group(function () {
|
||||||
// Notes pages using NotesController
|
// Notes pages using NotesController
|
||||||
Route::prefix('notes')->group(function () {
|
Route::prefix('notes')->group(function () {
|
||||||
Route::get('/', [NotesController::class, 'index']);
|
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.json', [FeedsController::class, 'notesJson'])->name('feed.notes.json');
|
||||||
Route::get('/feed.jf2', [FeedsController::class, 'notesJf2'])->name('feed.notes.jf2');
|
Route::get('/feed.jf2', [FeedsController::class, 'notesJf2'])->name('feed.notes.jf2');
|
||||||
Route::get('/new', [NotesController::class, 'create']);
|
Route::get('/new', [NotesController::class, 'create']);
|
||||||
|
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Tests\Feature;
|
|
||||||
|
|
||||||
use App\Models\About;
|
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
||||||
use PHPUnit\Framework\Attributes\Test;
|
|
||||||
use Tests\TestCase;
|
|
||||||
|
|
||||||
class AboutPageTest extends TestCase
|
|
||||||
{
|
|
||||||
use RefreshDatabase;
|
|
||||||
|
|
||||||
#[Test]
|
|
||||||
public function about_page_shows_content(): void
|
|
||||||
{
|
|
||||||
About::factory()->create([
|
|
||||||
'content' => 'This is the about page content.',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->get('/about')
|
|
||||||
->assertSee('This is the about page content.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,68 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Tests\Feature\Admin;
|
|
||||||
|
|
||||||
use App\Models\About;
|
|
||||||
use App\Models\User;
|
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
||||||
use PHPUnit\Framework\Attributes\Test;
|
|
||||||
use Tests\TestCase;
|
|
||||||
|
|
||||||
class AboutTest extends TestCase
|
|
||||||
{
|
|
||||||
use RefreshDatabase;
|
|
||||||
|
|
||||||
#[Test]
|
|
||||||
public function admin_about_page_loads(): void
|
|
||||||
{
|
|
||||||
$user = User::factory()->make();
|
|
||||||
|
|
||||||
$response = $this->actingAs($user)
|
|
||||||
->get('/admin/about');
|
|
||||||
$response->assertSeeText('Edit About');
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Test]
|
|
||||||
public function admin_can_create_about(): void
|
|
||||||
{
|
|
||||||
$user = User::factory()->make();
|
|
||||||
|
|
||||||
$this->actingAs($user)
|
|
||||||
->post('/admin/about', [
|
|
||||||
'_method' => 'PUT',
|
|
||||||
'content' => 'About content',
|
|
||||||
]);
|
|
||||||
$this->assertDatabaseHas('about', ['content' => 'About content']);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Test]
|
|
||||||
public function admin_can_load_existing_about(): void
|
|
||||||
{
|
|
||||||
$user = User::factory()->make();
|
|
||||||
$about = About::factory()->create([
|
|
||||||
'content' => 'This is <em>my</em> about page. It uses <strong>HTML</strong>.',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response = $this->actingAs($user)
|
|
||||||
->get('/admin/about');
|
|
||||||
$response->assertSeeText('This is <em>my</em> about page. It uses <strong>HTML</strong>.');
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Test]
|
|
||||||
public function admin_can_edit_about(): void
|
|
||||||
{
|
|
||||||
$user = User::factory()->make();
|
|
||||||
$about = About::factory()->create();
|
|
||||||
|
|
||||||
$this->actingAs($user)
|
|
||||||
->post('/admin/about', [
|
|
||||||
'_method' => 'PUT',
|
|
||||||
'content' => 'This about page has been edited',
|
|
||||||
]);
|
|
||||||
$this->assertDatabaseHas('about', [
|
|
||||||
'content' => 'This about page has been edited',
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,68 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Tests\Feature\Admin;
|
|
||||||
|
|
||||||
use App\Models\Setting;
|
|
||||||
use App\Models\User;
|
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
||||||
use PHPUnit\Framework\Attributes\Test;
|
|
||||||
use Tests\TestCase;
|
|
||||||
|
|
||||||
class SettingsTest extends TestCase
|
|
||||||
{
|
|
||||||
use RefreshDatabase;
|
|
||||||
|
|
||||||
#[Test]
|
|
||||||
public function admin_settings_page_loads(): void
|
|
||||||
{
|
|
||||||
$user = User::factory()->make();
|
|
||||||
|
|
||||||
$response = $this->actingAs($user)
|
|
||||||
->get('/admin/settings');
|
|
||||||
$response->assertSeeText('Site settings');
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Test]
|
|
||||||
public function admin_can_enable_winter_effect(): void
|
|
||||||
{
|
|
||||||
$user = User::factory()->make();
|
|
||||||
|
|
||||||
$this->actingAs($user)
|
|
||||||
->post('/admin/settings', [
|
|
||||||
'_method' => 'PUT',
|
|
||||||
'winter_effect_enabled' => '1',
|
|
||||||
]);
|
|
||||||
$this->assertDatabaseHas('settings', ['winter_effect_enabled' => true]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Test]
|
|
||||||
public function admin_can_disable_winter_effect(): void
|
|
||||||
{
|
|
||||||
$user = User::factory()->make();
|
|
||||||
Setting::factory()->create(['winter_effect_enabled' => true]);
|
|
||||||
|
|
||||||
$this->actingAs($user)
|
|
||||||
->post('/admin/settings', [
|
|
||||||
'_method' => 'PUT',
|
|
||||||
]);
|
|
||||||
$this->assertDatabaseHas('settings', ['winter_effect_enabled' => false]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Test]
|
|
||||||
public function winter_effect_markup_is_hidden_when_disabled(): void
|
|
||||||
{
|
|
||||||
$response = $this->get('/');
|
|
||||||
$response->assertDontSee('snow-fall');
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Test]
|
|
||||||
public function winter_effect_markup_is_shown_when_enabled(): void
|
|
||||||
{
|
|
||||||
Setting::factory()->create(['winter_effect_enabled' => true]);
|
|
||||||
|
|
||||||
$response = $this->get('/');
|
|
||||||
$response->assertSee('snow-fall', false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -37,73 +37,6 @@ class TokensTest extends TestCase
|
||||||
$response->assertSeeText($token->client_id);
|
$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]
|
#[Test]
|
||||||
public function revoke_requires_authentication(): void
|
public function revoke_requires_authentication(): void
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,42 @@ class FeedsTest extends TestCase
|
||||||
{
|
{
|
||||||
use RefreshDatabase;
|
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]
|
#[Test]
|
||||||
public function blog_jf2_feed_is_present(): void
|
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.
|
* Test the blog JSON feed.
|
||||||
*/
|
*/
|
||||||
|
|
@ -45,7 +93,7 @@ class FeedsTest extends TestCase
|
||||||
{
|
{
|
||||||
Article::factory()->count(3)->create();
|
Article::factory()->count(3)->create();
|
||||||
$response = $this->get('/blog/feed.json');
|
$response = $this->get('/blog/feed.json');
|
||||||
$response->assertHeader('Content-Type', 'application/feed+json');
|
$response->assertHeader('Content-Type', 'application/json');
|
||||||
$response->assertOk();
|
$response->assertOk();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -57,7 +105,7 @@ class FeedsTest extends TestCase
|
||||||
{
|
{
|
||||||
Note::factory()->count(3)->create();
|
Note::factory()->count(3)->create();
|
||||||
$response = $this->get('/notes/feed.json');
|
$response = $this->get('/notes/feed.json');
|
||||||
$response->assertHeader('Content-Type', 'application/feed+json');
|
$response->assertHeader('Content-Type', 'application/json');
|
||||||
$response->assertOk();
|
$response->assertOk();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,17 +4,14 @@ declare(strict_types=1);
|
||||||
|
|
||||||
namespace Tests\Feature;
|
namespace Tests\Feature;
|
||||||
|
|
||||||
use App\Exceptions\MicropubHandlerException;
|
|
||||||
use App\Jobs\SendWebMentions;
|
use App\Jobs\SendWebMentions;
|
||||||
use App\Jobs\SyndicateNoteToBluesky;
|
use App\Jobs\SyndicateNoteToBluesky;
|
||||||
use App\Jobs\SyndicateNoteToMastodon;
|
use App\Jobs\SyndicateNoteToMastodon;
|
||||||
use App\Models\Article;
|
|
||||||
use App\Models\Media;
|
use App\Models\Media;
|
||||||
use App\Models\Note;
|
use App\Models\Note;
|
||||||
use App\Models\Place;
|
use App\Models\Place;
|
||||||
use App\Models\SyndicationTarget;
|
use App\Models\SyndicationTarget;
|
||||||
use Faker\Factory;
|
use Faker\Factory;
|
||||||
use Illuminate\Contracts\Debug\ExceptionHandler;
|
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
use Illuminate\Support\Facades\Queue;
|
use Illuminate\Support\Facades\Queue;
|
||||||
|
|
@ -460,11 +457,6 @@ class MicropubControllerTest extends TestCase
|
||||||
#[Test]
|
#[Test]
|
||||||
public function micropub_client_api_request_for_unsupported_post_type_returns_error(): void
|
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(
|
$response = $this->postJson(
|
||||||
'/api/post',
|
'/api/post',
|
||||||
[
|
[
|
||||||
|
|
@ -877,99 +869,6 @@ class MicropubControllerTest extends TestCase
|
||||||
$this->assertDatabaseHas('articles', [
|
$this->assertDatabaseHas('articles', [
|
||||||
'title' => $name,
|
'title' => $name,
|
||||||
'main' => $content,
|
'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());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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]
|
#[Test]
|
||||||
public function date_scope_returns_expected_articles(): void
|
public function date_scope_returns_expected_articles(): void
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue