From d3d6913474a459a9ec19302a11b30ee0ce06ffbc Mon Sep 17 00:00:00 2001 From: Jonny Barnes Date: Sat, 13 Dec 2025 15:53:00 +0000 Subject: [PATCH 1/4] Switch local setup to PHP8.5 Also pull in config changes from upstream laravel/laravel --- app/Http/Controllers/NotesController.php | 3 +-- config/app.php | 2 +- config/auth.php | 2 +- config/cache.php | 15 ++++++++++++--- config/database.php | 15 ++++++++++++--- config/logging.php | 7 +++---- config/mail.php | 19 +++++++++++++++++-- config/queue.php | 19 ++++++++++++++++++- config/services.php | 6 +++++- config/session.php | 11 +++++------ docker-compose.yml | 4 ++-- 11 files changed, 77 insertions(+), 26 deletions(-) diff --git a/app/Http/Controllers/NotesController.php b/app/Http/Controllers/NotesController.php index d5c9bc90..e20b67a1 100644 --- a/app/Http/Controllers/NotesController.php +++ b/app/Http/Controllers/NotesController.php @@ -5,7 +5,6 @@ declare(strict_types=1); namespace App\Http\Controllers; use App\Models\Note; -use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Response; @@ -53,7 +52,7 @@ class NotesController extends Controller ->withCount(['webmentions AS reposts' => function ($query) { $query->where('type', 'repost-of'); }])->firstOrFail(); - } catch (ModelNotFoundException $exception) { + } catch (\Exception) { abort(404); } diff --git a/config/app.php b/config/app.php index 324b513a..423eed59 100644 --- a/config/app.php +++ b/config/app.php @@ -101,7 +101,7 @@ return [ 'previous_keys' => [ ...array_filter( - explode(',', env('APP_PREVIOUS_KEYS', '')) + explode(',', (string) env('APP_PREVIOUS_KEYS', '')) ), ], diff --git a/config/auth.php b/config/auth.php index 0ba5d5d8..7d1eb0de 100644 --- a/config/auth.php +++ b/config/auth.php @@ -104,7 +104,7 @@ return [ | Password Confirmation Timeout |-------------------------------------------------------------------------- | - | Here you may define the amount of seconds before a password confirmation + | Here you may define the number of seconds before a password confirmation | window expires and users are asked to re-enter their password via the | confirmation screen. By default, the timeout lasts for three hours. | diff --git a/config/cache.php b/config/cache.php index 38680919..461aacbe 100644 --- a/config/cache.php +++ b/config/cache.php @@ -26,8 +26,9 @@ return [ | well as their drivers. You may even define multiple stores for the | same cache driver to group types of items stored in your caches. | - | Supported drivers: "apc", "array", "database", "file", "memcached", - | "redis", "dynamodb", "octane", "null" + | Supported drivers: "array", "database", "file", "memcached", + | "redis", "dynamodb", "octane", + | "failover", "null" | */ @@ -89,6 +90,14 @@ return [ 'driver' => 'octane', ], + 'failover' => [ + 'driver' => 'failover', + 'stores' => [ + 'database', + 'array', + ], + ], + ], /* @@ -102,6 +111,6 @@ return [ | */ - 'prefix' => env('CACHE_PREFIX', Str::slug(env('APP_NAME', 'laravel'), '_').'_cache_'), + 'prefix' => env('CACHE_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'_cache_'), ]; diff --git a/config/database.php b/config/database.php index 8910562d..630407ef 100644 --- a/config/database.php +++ b/config/database.php @@ -40,6 +40,7 @@ return [ 'busy_timeout' => null, 'journal_mode' => null, 'synchronous' => null, + 'transaction_mode' => 'DEFERRED', ], 'mysql' => [ @@ -58,7 +59,7 @@ return [ 'strict' => true, 'engine' => null, 'options' => extension_loaded('pdo_mysql') ? array_filter([ - PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'), + (PHP_VERSION_ID >= 80500 ? \Pdo\Mysql::ATTR_SSL_CA : \PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'), ]) : [], ], @@ -78,7 +79,7 @@ return [ 'strict' => true, 'engine' => null, 'options' => extension_loaded('pdo_mysql') ? array_filter([ - PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'), + (PHP_VERSION_ID >= 80500 ? \Pdo\Mysql::ATTR_SSL_CA : \PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'), ]) : [], ], @@ -147,7 +148,7 @@ return [ 'options' => [ 'cluster' => env('REDIS_CLUSTER', 'redis'), - 'prefix' => env('REDIS_PREFIX', Str::slug(env('APP_NAME', 'laravel'), '_').'_database_'), + 'prefix' => env('REDIS_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'_database_'), 'persistent' => env('REDIS_PERSISTENT', false), ], @@ -158,6 +159,10 @@ return [ 'password' => env('REDIS_PASSWORD'), 'port' => env('REDIS_PORT', '6379'), 'database' => env('REDIS_DB', '0'), + 'max_retries' => env('REDIS_MAX_RETRIES', 3), + 'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'), + 'backoff_base' => env('REDIS_BACKOFF_BASE', 100), + 'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000), ], 'cache' => [ @@ -167,6 +172,10 @@ return [ 'password' => env('REDIS_PASSWORD'), 'port' => env('REDIS_PORT', '6379'), 'database' => env('REDIS_CACHE_DB', '1'), + 'max_retries' => env('REDIS_MAX_RETRIES', 3), + 'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'), + 'backoff_base' => env('REDIS_BACKOFF_BASE', 100), + 'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000), ], ], diff --git a/config/logging.php b/config/logging.php index 06297cd6..96d344bb 100644 --- a/config/logging.php +++ b/config/logging.php @@ -54,7 +54,7 @@ return [ 'stack' => [ 'driver' => 'stack', - 'channels' => explode(',', env('LOG_STACK', 'single')), + 'channels' => explode(',', (string) env('LOG_STACK', 'single')), 'ignore_exceptions' => false, ], @@ -97,11 +97,10 @@ return [ 'stderr' => [ 'driver' => 'monolog', 'level' => env('LOG_LEVEL', 'debug'), - 'handler' => StreamHandler::class, - 'formatter' => env('LOG_STDERR_FORMATTER'), - 'with' => [ + 'handler_with' => [ 'stream' => 'php://stderr', ], + 'formatter' => env('LOG_STDERR_FORMATTER'), 'processors' => [PsrLogMessageProcessor::class], ], diff --git a/config/mail.php b/config/mail.php index 7132144d..522b284b 100644 --- a/config/mail.php +++ b/config/mail.php @@ -30,7 +30,8 @@ return [ | your mailers below. You may also add additional mailers if needed. | | Supported: "smtp", "sendmail", "mailgun", "ses", "ses-v2", - | "postmark", "log", "array", "failover", "roundrobin" + | "postmark", "resend", "log", "array", + | "failover", "roundrobin" | */ @@ -45,7 +46,7 @@ return [ 'username' => env('MAIL_USERNAME'), 'password' => env('MAIL_PASSWORD'), 'timeout' => null, - 'local_domain' => env('MAIL_EHLO_DOMAIN', parse_url(env('APP_URL', 'http://localhost'), PHP_URL_HOST)), + 'local_domain' => env('MAIL_EHLO_DOMAIN', parse_url((string) env('APP_URL', 'http://localhost'), PHP_URL_HOST)), ], 'ses' => [ @@ -60,6 +61,10 @@ return [ // ], ], + 'resend' => [ + 'transport' => 'resend', + ], + 'sendmail' => [ 'transport' => 'sendmail', 'path' => env('MAIL_SENDMAIL_PATH', '/usr/sbin/sendmail -bs -i'), @@ -80,6 +85,16 @@ return [ 'smtp', 'log', ], + 'retry_after' => 60, + ], + + 'roundrobin' => [ + 'transport' => 'roundrobin', + 'mailers' => [ + 'ses', + 'postmark', + ], + 'retry_after' => 60, ], ], diff --git a/config/queue.php b/config/queue.php index 116bd8d0..79c2c0a2 100644 --- a/config/queue.php +++ b/config/queue.php @@ -24,7 +24,8 @@ return [ | used by your application. An example configuration is provided for | each backend supported by Laravel. You're also free to add more. | - | Drivers: "sync", "database", "beanstalkd", "sqs", "redis", "null" + | Drivers: "sync", "database", "beanstalkd", "sqs", "redis", + | "deferred", "background", "failover", "null" | */ @@ -72,6 +73,22 @@ return [ 'after_commit' => false, ], + 'deferred' => [ + 'driver' => 'deferred', + ], + + 'background' => [ + 'driver' => 'background', + ], + + 'failover' => [ + 'driver' => 'failover', + 'connections' => [ + 'database', + 'deferred', + ], + ], + ], /* diff --git a/config/services.php b/config/services.php index b3d02f78..b784e544 100644 --- a/config/services.php +++ b/config/services.php @@ -15,7 +15,11 @@ return [ */ 'postmark' => [ - 'token' => env('POSTMARK_TOKEN'), + 'key' => env('POSTMARK_API_KEY'), + ], + + 'resend' => [ + 'key' => env('RESEND_API_KEY'), ], 'ses' => [ diff --git a/config/session.php b/config/session.php index 6ff263bd..5b541b75 100644 --- a/config/session.php +++ b/config/session.php @@ -13,8 +13,8 @@ return [ | incoming requests. Laravel supports a variety of storage options to | persist session data. Database storage is a great default choice. | - | Supported: "file", "cookie", "database", "apc", - | "memcached", "redis", "dynamodb", "array" + | Supported: "file", "cookie", "database", "memcached", + | "redis", "dynamodb", "array" | */ @@ -97,7 +97,7 @@ return [ | define the cache store which should be used to store the session data | between requests. This must match one of your defined cache stores. | - | Affects: "apc", "dynamodb", "memcached", "redis" + | Affects: "dynamodb", "memcached", "redis" | */ @@ -125,12 +125,11 @@ return [ | the framework. Typically, you should not need to change this value | since doing so does not grant a meaningful security improvement. | - | */ 'cookie' => env( 'SESSION_COOKIE', - Str::slug(env('APP_NAME', 'laravel'), '_').'_session' + Str::slug((string) env('APP_NAME', 'laravel')).'-session' ), /* @@ -153,7 +152,7 @@ return [ | | This value determines the domain and subdomains the session cookie is | available to. By default, the cookie will be available to the root - | domain and all subdomains. Typically, this shouldn't be changed. + | domain without subdomains. Typically, this shouldn't be changed. | */ diff --git a/docker-compose.yml b/docker-compose.yml index 74f97af5..aa3dec15 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,11 +2,11 @@ services: laravel.test: build: - context: ./vendor/laravel/sail/runtimes/8.4 + context: ./vendor/laravel/sail/runtimes/8.5 dockerfile: Dockerfile args: WWWGROUP: '${WWWGROUP}' - image: sail-8.4/app + image: sail-8.5/app extra_hosts: - 'host.docker.internal:host-gateway' ports: -- 2.55.0 From 76db869af5fd4bf24674a910876de3bc4f58e371 Mon Sep 17 00:00:00 2001 From: Jonny Barnes Date: Sat, 27 Dec 2025 17:55:29 +0000 Subject: [PATCH 2/4] Allow files to be added to notes This is specifically when a Micropub client is using the JSON syntax and initially uploads the photo via the media endpoint --- app/Http/Requests/MicropubRequest.php | 3 +++ app/Jobs/ProcessMedia.php | 12 ++++++------ app/Services/NoteService.php | 20 ++++++++++---------- config/filesystems.php | 2 +- config/logging.php | 1 - public/assets/css/app.css | 2 +- public/assets/css/app.css.br | Bin 1202 -> 1217 bytes public/assets/css/app.css.map | 2 +- public/assets/css/app.css.zst | Bin 1450 -> 1463 bytes resources/css/notes.css | 11 +++++++++-- tests/Unit/Jobs/ProcessMediaJobTest.php | 3 ++- 11 files changed, 33 insertions(+), 23 deletions(-) diff --git a/app/Http/Requests/MicropubRequest.php b/app/Http/Requests/MicropubRequest.php index 41c70280..cc22dd3e 100644 --- a/app/Http/Requests/MicropubRequest.php +++ b/app/Http/Requests/MicropubRequest.php @@ -80,6 +80,9 @@ class MicropubRequest extends FormRequest // Add checkin value $this->micropubData['checkin'] = Arr::get($data, 'checkin'); $this->micropubData['syndication'] = Arr::get($data, 'properties.syndication.0'); + + // Add photos + $this->micropubData['photos'] = Arr::get($data, 'properties.photo'); } private function normalizeMicropubForm(): void diff --git a/app/Jobs/ProcessMedia.php b/app/Jobs/ProcessMedia.php index b7f36648..a693a821 100644 --- a/app/Jobs/ProcessMedia.php +++ b/app/Jobs/ProcessMedia.php @@ -33,20 +33,20 @@ class ProcessMedia implements ShouldQueue public function handle(ImageManager $manager): void { // Load file - $file = Storage::disk('local')->get('media/' . $this->filename); + $file = Storage::disk('local')->get($this->filename); // Open file try { $image = $manager->read($file); } catch (DecoderException) { // not an image; delete file and end job - Storage::disk('local')->delete('media/' . $this->filename); + Storage::disk('local')->delete($this->filename); return; } // Save the file publicly - Storage::disk('public')->put('media/' . $this->filename, $file); + Storage::disk('public')->put($this->filename, $file); // Create smaller versions if necessary if ($image->width() > 1000) { @@ -57,13 +57,13 @@ class ProcessMedia implements ShouldQueue $basename = trim(implode('.', $filenameParts), '.'); $medium = $image->resize(width: 1000); - Storage::disk('public')->put('media/' . $basename . '-medium.' . $extension, (string) $medium->encode()); + Storage::disk('public')->put($basename . '-medium.' . $extension, (string) $medium->encode()); $small = $image->resize(width: 500); - Storage::disk('public')->put('media/' . $basename . '-small.' . $extension, (string) $small->encode()); + Storage::disk('public')->put($basename . '-small.' . $extension, (string) $small->encode()); } // Now we can delete the locally saved image - Storage::disk('local')->delete('media/' . $this->filename); + Storage::disk('local')->delete($this->filename); } } diff --git a/app/Services/NoteService.php b/app/Services/NoteService.php index d8c55507..c80a450c 100644 --- a/app/Services/NoteService.php +++ b/app/Services/NoteService.php @@ -48,12 +48,12 @@ class NoteService $note->place()->associate($this->getCheckin($data)); $note->swarm_url = $this->getSwarmUrl($data); } - // - // $note->instagram_url = $this->getInstagramUrl($request); - // - // foreach ($this->getMedia($request) as $media) { - // $note->media()->save($media); - // } + + // $note->instagram_url = $this->getInstagramUrl($request); + + foreach ($this->getMedia($data) as $media) { + $note->media()->save($media); + } $note->save(); @@ -188,16 +188,16 @@ class NoteService /** * Get the media URLs from the request to create a new note. */ - private function getMedia(array $request): array + private function getMedia(array $data): array { $media = []; - $photos = Arr::get($request, 'photo') ?? Arr::get($request, 'properties.photo'); + $photos = Arr::get($data, 'photos'); if (isset($photos)) { foreach ((array) $photos as $photo) { // check the media was uploaded to my endpoint, and use path - if (Str::startsWith($photo, config('filesystems.disks.s3.url'))) { - $path = substr($photo, strlen(config('filesystems.disks.s3.url'))); + if (Str::startsWith($photo, config('filesystems.disks.public.url'))) { + $path = substr($photo, strlen(config('filesystems.disks.public.url'))); $media[] = Media::where('path', ltrim($path, '/'))->firstOrFail(); } else { $newMedia = Media::firstOrNew(['path' => $photo]); diff --git a/config/filesystems.php b/config/filesystems.php index a17a9a47..cb80a5bf 100644 --- a/config/filesystems.php +++ b/config/filesystems.php @@ -41,7 +41,7 @@ return [ 'public' => [ 'driver' => 'local', 'root' => storage_path('app/public'), - 'url' => env('APP_URL').'/storage', + 'url' => rtrim(env('APP_URL'), '/').'/storage', 'visibility' => 'public', 'throw' => false, 'report' => false, diff --git a/config/logging.php b/config/logging.php index 96d344bb..db7145bb 100644 --- a/config/logging.php +++ b/config/logging.php @@ -1,7 +1,6 @@ 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{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}& .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%)}}} +@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%;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{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 74dedb608c0fbcc8d358975837e331b87128e37d..b15a799d91f73ebf2b740d7c4be274c8a4a98436 100644 GIT binary patch literal 1217 zcmX>&qm1E!$A;tyZxv=)mpRqF-12_?${(GMa<&sviW5s$X4b9aV45SqSD3zZ_yXdX_9({bN6$dh3NiN)Qy4he`%9AZW&x`&1mcz+j@~8hv@e!ZZTX}81v76~H zEnT$t5=+ReXZw^y*E}-xjsJGlWlz?WtgE$W{@LF=ys+!;x)vtiB+GT4OcUGNW^|ap zm^JO`w(CtF=WV|#+V%F;7psK2bfxcFVnp?}#+R9>5{@G|GSeQ_7Pueefh(w zlU5h*R8`w_WuLv@`VJ103^(D)+q9Q$X!{%R==MYpk;tXgw;w(fxM%{S}KF*`AyZi4A|W zNc}^EP?+9^MO@pW^Sb7*E?d&qcv?we_w)kxss8DAvH}lfGIMF%;m!J0vf%m2fSY{F zk59F~#qvLdJy=8W>!fEoD!cM`*M8dXTO;`FDDTrV(`{#`T{!=&Ca|@)-AC$P?i8`- z-PfFAZS88mPre}-_Rwe(>zXC+)$aPfE;(AmlrN^WWkdPSfn{xQwwJAqVT{yJBRzZ2onU0Pp%rVopm%Wm`7BZGox{@(EP;8f3}>(-}P&F8r`Yx4E=pI4}# zEP5I-SCh+k>+`2Q!V@H2rFmEO^xb?td+UwhmA_Wl&UqYhQ7nDkhNBl!1P`9Dyve92 zajo!oUiG$B37-^CX)`@7{9n)WH1c@jWpOi~IR;yvIx4GK*3RcJ*HDF!U? zf=t`p`bAnN>@{!R;x~6$+>qyF(kFD@o$vWP_n7?+9TzzAH=NF6Q&L*DPgh1+ad&@V z)2WXlTR$tWi2B&Wpzb9%*)Y$r;g_8<)8XxJW>n03v6z!li8b?Bll(d3Wlt54eh>N@ zcI)X(_Pa{=1vawXIC0c=d3kv3@r222nbrH))eP+WSeeT>?g z-d&8Tn-W8mEuZw}UjJBiHpV|oX4?mesO9CKa=G0*t*3oFV(VsnBUW8QD}wXLF85@Y zBLy)>UFLXXb_qXKJ=7r9_b}Q%^1t1oHPw9lf6w+*-9D@w_Vc_#+kuj(T~cgH(c7xd zXiOAx*|KHB#mF8x%X?AYN)P7UXivUS%cl~_-h0AZa8l8%m#)=DpUqF4K5VdX`E8*k zr;^=SFBJ*AGW#_3Y;5Ig7vRZ3qwy#<{H!vfP>2`%b-*Sz2@yn&ocrMh{ zDaziu!f}(7gwcARt+TI9lQ_8HG_&276+gH9JTLb3TMlP?$e;d2#YcQrZ}m0#=B}o_ zvviTIHq)j>JJ)-#9yVDv^Yp%`3E{pmYpeQR{#UQI2%2;|bb&*#L7SwiKxM{+&Wh%B zl5*Sk3ib4FS-Wn*jdga0<-y+~x#or)+R^!Aww>hM;^#si;}5I4Zg&iwl$l`NCg`8t zAJ}Gc@|;@2AswCCe+!T1CxU+ev#33zb76tx*_K=9ToQS|HD7$%IPaS!1M6>g9`*j#x*~?NY5N!MIr`y$ z<{b006Qy!KJ2lHPY6w#Vc)mkhlWNUIa?oU2hZt-ESEO=9a@&(qrb*rOM0Ag)je?+UY;H4(vzH*Tz4qj(J0FH zKu9Tm$`ji{i|-~g+Dv~rPxF3$Wsl^G<_SO7J&?$}QryGM)u%pfuKdSKtI}Ik16Q>C z@JME8`Few0W?$-}SM$_=U2$yiaGce-!)=qZMuErjy&-!im)U$?(Ed~U#?=PpqC)?Z zwt?mXeM=l~`MsSecI#oyTo(Q4i>zn-4b>O5RyAz-+T!ZTA5^jYh+9B!i;vm14qq-^0V5Q%l;0;n=zA!kqBs^F$sl#IC10nHK+73U~ ziYRYpGF#xBN%(x9uI-?h#P-Y<%vs;;Xw_*iU8vma$bV diff --git a/public/assets/css/app.css.map b/public/assets/css/app.css.map index f6a39007..6002b431 100644 --- a/public/assets/css/app.css.map +++ b/public/assets/css/app.css.map @@ -1 +1 @@ -{"version":3,"mappings":"AACA,aIGE,uCAOA,oFASA,8DAMA,4CAMA,qBAKA,+CAMA,8BAMA,gEAMA,yCAOA,mEAOA,qCAKA,gCAKA,gCACE,wGJ/EJ,YMAE,oaAwBA,kECxBA,0BAIA,oBAIA,iIAME,0BAIA,uBAIA,iDAIE,yEAME,uBAON,oDPvCF,kBCAE,wHAOE,yBAIA,+BAKA,gDAKE,kCAAqC,2CCrBzC,+HAOE,gDAIA,6DAKE,gDAQJ,4MCxBA,sFAME,+CENF,gBACE,oDAKE,wBAMF,gMAQE,4DAME,uBAUJ,8CACE,2BACE,yDAQE,gBAAiB,8CAST,uKAahB,iEAIA,kGAKA,4DAIA,qDAIA","sources":["resources/css/app.css","resources/css/notes.css","resources/css/header.css","resources/css/pagination.css","resources/css/reset.css","resources/css/colours.css","resources/css/theme-selector.css","resources/css/layout.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 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 > p:first-child {\n margin-block-start: 0;\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 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 .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 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 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 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 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","@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"],"names":[]} \ No newline at end of file +{"version":3,"mappings":"AACA,aIGE,uCAOA,oFASA,8DAMA,4CAMA,qBAKA,+CAMA,8BAMA,gEAMA,yCAOA,mEAOA,qCAKA,gCAKA,gCACE,wGJ/EJ,YGAE,oaAwBA,kEGxBA,0BAIA,oBAIA,iIAME,0BAIA,uBAIA,iDAIE,yEAME,uBAON,oDNvCF,kBEAE,wHAOE,yBAIA,+BAKA,gDAKE,kCAAqC,2CDrBzC,+HAOE,aACE,qCAIA,mCAMF,6DAKE,gDAQJ,4MM/BA,sFAME,+CFNF,gBACE,oDAKE,wBAMF,gMAQE,4DAME,uBAUJ,8CACE,2BACE,yDAQE,gBAAiB,8CAST,uKAahB,iEAIA,kGAKA,4DAIA,qDAIA","sources":["resources/css/app.css","resources/css/colours.css","resources/css/notes.css","resources/css/header.css","resources/css/layout.css","resources/css/pagination.css","resources/css/theme-selector.css","resources/css/reset.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 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 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 components {\n body > header {\n display: grid;\n grid-template-columns: auto 1fr auto;\n grid-template-rows: auto auto;\n align-items: baseline;\n gap: 1rem;\n\n a {\n text-decoration: none;\n }\n\n h1 {\n margin: 0;\n text-wrap: nowrap;\n }\n\n nav {\n display: flex;\n flex-direction: row;\n gap: .5rem;\n\n @media screen and (width <= 1100px) {\n grid-row: 2;\n grid-column: 1 / span 3;\n flex-wrap: wrap;\n }\n }\n }\n}\n","@layer base {\n :root {\n --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","@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","@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 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"],"names":[]} \ No newline at end of file diff --git a/public/assets/css/app.css.zst b/public/assets/css/app.css.zst index 5adb7c02d39790cbf69121da35d830d40bede8b1..962112506c2be8756dff8c812575f4d3d70d7b08 100644 GIT binary patch literal 1463 zcmdPcs{c3T3a_s&1D6W}j|M|-hnxZPh4~%#Kd|0d#J~N>pX(NFg))b&OgZurTi@L) zI4$?(|6IG4O$?hDwlmn(Ru>g|te5+7;$3j^y$!Q3+v`>z+5V*ZjOg#bRpw<~&6eWZ z*gEf@($%}ue)20vu7W-H^!X_@-f#ZDT*>Qt*sjQ?Pt3h-^8D^^j~+a4=Ht07ePE^0 z8P_;%yY}@ibE6kT|EO;Ikik2@wYJD6Ve*jzKK#AL>&y{#*ITPUpSSGl_kA!~ zEJ|K_(S3n$AU+vc1D3Y|I{I`QW1 z91R6&$!jiL8@-akOX{B72zqs_;LL?Ck*O044nMJydES5LMveBm%Qxmn3$+PM-J>XK z8b6Qgp~sAa_r5P@*42^txHg3$aQ4=W8JnZ~cogPvv@{;wF7wfH`=vl7fn|xEQkN$r z7<)}#ER%J`Jx_;0jLU1?k^r4&Io&^Pp6kg7XhbMHReSI(*k9Elk#WJx^<0~E*0|&x zXW5o=aed6EgKokStJJ(-Uuco%=SfW0$!*cz+08RwwK8ZfZ`-26BfU{`d5=E1_BoFG zf3fx@fpt;SqQqygyJrgVZ1-*TG)V9}toHxbsbv#W^*a}Et(V&R{H20@M}5^aOP;T3 z-crhMQ+7wMSN%NC&h!<}%ML|}qXpfsPY3CRa=h9b=dgCdbF~Z6D^~>luWnRdQMfkA z!zCp}#gl8&F-ML=33H~%-D4Db=)mL5CD_WL%-G4%%Q8_UMC-7Wut21bLBi)M1r^bv z*VAW~U7Pr2+ElNM6MrU#tYj~7+wi{cpOJ@H%*o!WQmb#2XmSyrpw-q7X z7ytPuuJT)1m%Z$oO_I-&B|A7Io9E9uHutCWl)W{7T|M=jgJmZ6&S2(yreNi;TkEo2 zK~uvunHb^qLCQ?(x~Y`{rY=fe_a+x53-<)*^v)|eaHnv(`_5CB9gh5b+A=Mco%^Qc zgXAwchpR5WIlHjttkdbW{4038Cp!c!ysLP`r26jM#-yj-#-D`hIi{vKUtBqRR`Xr0 zsR#FOw{b~l4^ZVYuHo~`of+(5mG&gw(dFKBA0C!t5fjq)@=55-%&le8(<Qd+BRBO&U6d#uO z7O29UKjG1?KRiE=hsD~lXEz?^(qYW+^YS@nrE#WWcUr}I(@xoE8~oZ1=_$TuUVHd~ z#2g#8g2>>hH92j<;TK)jv`C5QDzh)*JL1Z}kvUN(X5F`wR}GZwT|1YH?7Fkerg(12 zy@jvWTFsa-b=wJZe(424RtqkdZ2o$pPSS7U<7bJL$Hk6E3v1|$T|6KwC&is?wrI7! zKtPSjl6e)10wF_54;Ism6y^D7PB*R+3K_#{^}U|aPa0DGme8~^|S literal 1450 zcmdPcs{c1-J?~Q;1}^72-0BLxbJ#a9{g^Gb?IG9p-dT6gX>K{jAiVQbN?i%B@q;q1 zYGD=*K_}OZ3>z4>F!<^5y?E8s|3+@()4#JU|6W|@UgEXuc*w@P>&+i8^O#XA-`((I z?U_gWa&>mfEj;`9*uJOAl6QrE+h>KiEsEr~lyUA--ha~HwR+-*%eE|kZYl3sVYJ#c zOv|cWf5}?i^}2s#11mPv^|sm;?MR=<&6ttcFpYJK$pxd#yDLPcwAz!8e>I+UA?%~~ zkE^@go0YrleguS`JG*tfR!&}d|uq(g4TKSyEb-UX6gSA(V*S#>V zvzeZHf7g?evyul4&Nl7a*)eH@`+27SAA&dSkN2II(mqdlBFhS9qvy>##ng=*Zx;&$ z+uybIR(o(n$+By)$@eO?&K!+;_coDmzt%;8Dw`Y&7$!#D5t%vH*Nth@#7N#Le6tm* z7kySfK5fIzMTYiVULkIW&Q5w=Fvs2~`tr&he97g$oMbCH;AiXqWT5)unkjxy8mIGaWmW#4;mo9cu@Hxz6xQuaQTFb=Ae=;w*r+I&T zKY0Tyg9x{$o{x%1vC+|*dBvg}5*8H-Pt6jZ1)f()aAaKYay{2(0}Yp)<4?B6Tv#7+ z$<~eCz{I!h_4yWgE{Q|?B=Tyw_qtBAh)_?Sm?2ZB7^bc)X!>-|)kE)Rb$zLxJ#A?i zn_J^z6OOvm%MK|h=q!(%T%MD9`NXEFHUX`ntv5dB^xr)!Z(3&AePz-y5A&P%LP~X~ zfB(`EnC{{H`hZGXWK8_DqD-rR{I5(|tvk#r@0@aJ{rjG=QDsHp+9VH`loShh*! zy-4|26C%iTD|*WA_4`F8#fJwiKIRoZ)3>E_5lfU5ry=u1Egz+~-chC z{h3>EY{q1dOG-;Lf_xtbWwxO3ccmM=2HRE21W&OJgRaJSL+>~)x0k>)!3Hj&YC`# zIH6X_V!u%9sgowVJp8F%JE7mTxlu&kKENauUYFHEiutn;)B44mrCBT&U5Z7*a|W|h!+bD&Df*%c1G6npF6rI zuD|YZ>h(iIK0Bd)jo=a@O*lc51?WfjzU;W&i zs9R{xbhBZOvVq!3Sw3U6D`(n2O5OHTizxR<^gQvY@m^v9kCPnpjy)@@?Ai`@hnjrX zQZ!rapRz$Csqnh^DqpbLQ^zZ!3N*RmriT`S`{{w&~klO+K7*KAe3f&HC$7#w_>uot@&78fv)wG^7~h zPus4qIR9DJ^~~B^tB!8eWmwIr=e$NrVp-pU$V-<_Kbds$spgxbbquDRVyV{)mLZ-u+lyq~o?JjGl8C`WduS#_V@ T$^9(%{=*uL;AmFwf;H*@sn46T diff --git a/resources/css/notes.css b/resources/css/notes.css index ea85f2d2..8b4726c5 100644 --- a/resources/css/notes.css +++ b/resources/css/notes.css @@ -6,8 +6,15 @@ border-radius: var(--border-radius); padding: 1ex 2ex; - .e-content > p:first-child { - margin-block-start: 0; + .e-content { + > p:first-child { + margin-block-start: 0; + } + + .u-photo { + width: 100%; + height: auto; + } } .syndication-links { diff --git a/tests/Unit/Jobs/ProcessMediaJobTest.php b/tests/Unit/Jobs/ProcessMediaJobTest.php index e3fa2059..a405fde8 100644 --- a/tests/Unit/Jobs/ProcessMediaJobTest.php +++ b/tests/Unit/Jobs/ProcessMediaJobTest.php @@ -43,7 +43,8 @@ class ProcessMediaJobTest extends TestCase { $manager = app()->make(ImageManager::class); Storage::disk('local')->put('media/test-image.jpg', file_get_contents(__DIR__.'/../../test-image.jpg')); - $job = new ProcessMedia('test-image.jpg'); + ray(Storage::disk('local')->get('media/test-image.jpg')); + $job = new ProcessMedia('media/test-image.jpg'); $job->handle($manager); // These need to look in public disk -- 2.55.0 From 9e788e0fe8db62d63e8ccf4feb962b4a2efbe79f Mon Sep 17 00:00:00 2001 From: Jonny Barnes Date: Sun, 4 Jan 2026 10:07:25 +0000 Subject: [PATCH 3/4] Images can be uploaded with alt text This means associating images to notes via a pivot table, we also finish off the migration of medai from S3. --- app/Console/Commands/MigrateMedia.php | 92 +++++++++++++++++++ app/Models/Media.php | 8 +- app/Models/Note.php | 8 +- app/Services/NoteService.php | 38 ++++++-- ...1_110617_create_media_note_pivot_table.php | 33 +++++++ database/seeders/NotesTableSeeder.php | 8 +- resources/views/templates/note.blade.php | 2 +- tests/Unit/MediaTest.php | 13 ++- tests/Unit/NotesTest.php | 17 ++++ 9 files changed, 200 insertions(+), 19 deletions(-) create mode 100644 app/Console/Commands/MigrateMedia.php create mode 100644 database/migrations/2025_12_31_110617_create_media_note_pivot_table.php diff --git a/app/Console/Commands/MigrateMedia.php b/app/Console/Commands/MigrateMedia.php new file mode 100644 index 00000000..987da9af --- /dev/null +++ b/app/Console/Commands/MigrateMedia.php @@ -0,0 +1,92 @@ +hasTable('media_note')) { + $this->error('The table "media_note" does not exist.'); + + exit(1); + } + + // Load all media already saved in `media_endpoint` table + $this->line('Updating existing local media'); + $mediaEndpointMedia = Media::all(); + // Save relationship in new `media_note` table based on `media_endpoint.note_id` + $this->withProgressBar($mediaEndpointMedia, function (Media $mediaEndpointMediaItem) { + $note = Note::find($mediaEndpointMediaItem->note_id); + if ($note) { + $note->media()->attach($mediaEndpointMediaItem->id); + } + }); + + // Load all media records from `media` table + $this->line(''); + $this->line('Migrating old media from S3'); + $oldMedia = DB::table('media')->get(); + foreach ($oldMedia as $oldMediaItem) { + // We only want to process the S3 media + if ($oldMediaItem->disk !== 's3') { + $this->warn('Original media item never stored in S3'); + + continue; + } + + // Check media exists in S3 + if (! Storage::disk('s3')->exists($oldMediaItem->id.DIRECTORY_SEPARATOR.$oldMediaItem->file_name)) { + $this->warn('Original media item not found in S3'); + + continue; + } + // We want to just copy the file, check it does not already exist locally + if (Storage::disk('public')->exists('media'.DIRECTORY_SEPARATOR.$oldMediaItem->file_name)) { + $this->warn('File already exists locally with filename of original media item'); + + continue; + } + // Copy the file + Storage::disk('public')->writeStream('media'.DIRECTORY_SEPARATOR.$oldMediaItem->file_name, Storage::disk('s3')->readStream($oldMediaItem->id.DIRECTORY_SEPARATOR.$oldMediaItem->file_name)); + + // Save relationship based on `media.model_id` + // I have already checked they are all notes + $note = $oldMediaItem->model_id; + $newMediaItem = Media::create([ + 'path' => 'media'.DIRECTORY_SEPARATOR.$oldMediaItem->file_name, + 'type' => 'image', + ]); + $note->media()->attach($newMediaItem->id); + + $this->info('Media item migrated from S3'); + } + + $this->line(''); + $this->line('Migration finished'); + } +} diff --git a/app/Models/Media.php b/app/Models/Media.php index 3d923bed..b25e889a 100644 --- a/app/Models/Media.php +++ b/app/Models/Media.php @@ -7,7 +7,7 @@ namespace App\Models; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; -use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Support\Str; class Media extends Model @@ -20,9 +20,11 @@ class Media extends Model /** @var array */ protected $fillable = ['token', 'path', 'type', 'image_widths']; - public function note(): BelongsTo + public function notes(): BelongsToMany { - return $this->belongsTo(Note::class); + return $this->belongsToMany(Note::class) + ->withPivot('alt_text', 'order') + ->withTimestamps(); } protected function url(): Attribute diff --git a/app/Models/Note.php b/app/Models/Note.php index 74533443..91f4b8a1 100644 --- a/app/Models/Note.php +++ b/app/Models/Note.php @@ -14,7 +14,6 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; -use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\MorphMany; use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Support\Facades\Cache; @@ -92,9 +91,12 @@ class Note extends Model return $this->belongsTo(Place::class); } - public function media(): HasMany + public function media(): BelongsToMany { - return $this->hasMany(Media::class); + return $this->BelongsToMany(Media::class) + ->withPivot('alt_text', 'order') + ->withTimestamps() + ->orderBy('order'); } /** diff --git a/app/Services/NoteService.php b/app/Services/NoteService.php index c80a450c..096f7f25 100644 --- a/app/Services/NoteService.php +++ b/app/Services/NoteService.php @@ -51,8 +51,11 @@ class NoteService // $note->instagram_url = $this->getInstagramUrl($request); - foreach ($this->getMedia($data) as $media) { - $note->media()->save($media); + foreach ($this->getMedia($data) as $index => $media) { + $note->media()->attach($media['value']->id, [ + 'alt_text' => $media['alt_text'], + 'order' => $index, + ]); } $note->save(); @@ -195,16 +198,37 @@ class NoteService if (isset($photos)) { foreach ((array) $photos as $photo) { + // $photo can be a string of the URL opf the photo + // or it can be an object with a `value` and `alt` + $photoUrl = null; + $photoAlt = null; + if (is_string($photo)) { + $photoUrl = $photo; + } elseif (is_array($photo)) { + $photoUrl = $photo['value']; + $photoAlt = $photo['alt']; + } + + if (empty($photoUrl)) { + continue; + } + // check the media was uploaded to my endpoint, and use path - if (Str::startsWith($photo, config('filesystems.disks.public.url'))) { - $path = substr($photo, strlen(config('filesystems.disks.public.url'))); - $media[] = Media::where('path', ltrim($path, '/'))->firstOrFail(); + if (Str::startsWith($photoUrl, config('filesystems.disks.public.url'))) { + $path = substr($photoUrl, strlen(config('filesystems.disks.public.url'))); + $media[] = [ + 'value' => Media::where('path', ltrim($path, '/'))->firstOrFail(), + 'alt' => $photoAlt, + ]; } else { - $newMedia = Media::firstOrNew(['path' => $photo]); + $newMedia = Media::firstOrNew(['path' => $photoUrl]); // currently assuming this is a photo from Swarm or OwnYourGram $newMedia->type = 'image'; $newMedia->save(); - $media[] = $newMedia; + $media[] = [ + 'value' => $newMedia, + 'alt' => $photoAlt, + ]; } } } diff --git a/database/migrations/2025_12_31_110617_create_media_note_pivot_table.php b/database/migrations/2025_12_31_110617_create_media_note_pivot_table.php new file mode 100644 index 00000000..4017aefb --- /dev/null +++ b/database/migrations/2025_12_31_110617_create_media_note_pivot_table.php @@ -0,0 +1,33 @@ +id(); + $table->foreignId('note_id')->constrained()->onDelete('cascade'); + $table->foreignId('media_id')->constrained('media_endpoint')->onDelete('cascade'); + $table->text('alt_text')->nullable(); + $table->integer('order')->default(0); + $table->timestamps(); + + $table->unique(['note_id', 'media_id']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('media_note'); + } +}; diff --git a/database/seeders/NotesTableSeeder.php b/database/seeders/NotesTableSeeder.php index 630cfda6..ea00c7d5 100644 --- a/database/seeders/NotesTableSeeder.php +++ b/database/seeders/NotesTableSeeder.php @@ -84,7 +84,9 @@ class NotesTableSeeder extends Seeder // copy aaron’s profile pic in place $spl = new SplFileInfo(public_path() . '/assets/profile-images/aaronparecki.com'); if ($spl->isDir() === false) { - mkdir(public_path() . '/assets/profile-images/aaronparecki.com', 0755); + if (! mkdir($concurrentDirectory = public_path() . '/assets/profile-images/aaronparecki.com', 0755) && ! is_dir($concurrentDirectory)) { + throw new \RuntimeException(sprintf('Directory "%s" was not created', $concurrentDirectory)); + } copy(base_path() . '/tests/aaron.png', public_path() . '/assets/profile-images/aaronparecki.com/image'); } @@ -171,7 +173,9 @@ class NotesTableSeeder extends Seeder $noteWithOnlyImage->setCreatedAt($now); $noteWithOnlyImage->setUpdatedAt($now); $noteWithOnlyImage->save(); - $noteWithOnlyImage->media()->save($media); + $noteWithOnlyImage->media()->attach($media->id, [ + 'alt_text' => 'Test alt text', + ]); DB::table('notes') ->where('id', $noteWithOnlyImage->id) ->update(['updated_at' => $now->toDateTimeString()]); diff --git a/resources/views/templates/note.blade.php b/resources/views/templates/note.blade.php index 68c9d2f6..e8418184 100644 --- a/resources/views/templates/note.blade.php +++ b/resources/views/templates/note.blade.php @@ -12,7 +12,7 @@ @foreach($note->media as $media) @if($media->type === 'image') - image_widths !== null) srcset="{{ $media->url }} {{ $media->image_widths }}w, {{ $media->mediumurl }} 1000w, {{ $media->smallurl }} 500w" sizes="80vh"@endif> + {{ $media->pivot->alt_text }}image_widths !== null) srcset="{{ $media->url }} {{ $media->image_widths }}w, {{ $media->mediumurl }} 1000w, {{ $media->smallurl }} 500w" sizes="min(80ch,80vw)" @else src="{{ $media->url }}" @endif> @endif @if($media->type === 'audio') diff --git a/tests/Unit/MediaTest.php b/tests/Unit/MediaTest.php index 642d797f..675f761d 100644 --- a/tests/Unit/MediaTest.php +++ b/tests/Unit/MediaTest.php @@ -15,11 +15,18 @@ class MediaTest extends TestCase use RefreshDatabase; #[Test] - public function get_the_note_that_media_instance_belongs_to(): void + public function media_can_belong_to_multiple_notes(): void { - $media = Media::factory()->for(Note::factory())->create(); + $media = Media::factory()->create(); + $note1 = Note::factory()->create(); + $note2 = Note::factory()->create(); - $this->assertInstanceOf(Note::class, $media->note); + $note1->media()->attach($media->id, ['alt_text' => 'Alt text for note 1']); + $note2->media()->attach($media->id, ['alt_text' => 'Alt text for note 2']); + + $this->assertCount(2, $media->notes); + $this->assertTrue($media->notes->contains($note1)); + $this->assertTrue($media->notes->contains($note2)); } #[Test] diff --git a/tests/Unit/NotesTest.php b/tests/Unit/NotesTest.php index e71f513a..330d93fe 100644 --- a/tests/Unit/NotesTest.php +++ b/tests/Unit/NotesTest.php @@ -434,4 +434,21 @@ class NotesTest extends TestCase $this->assertSame($expected, $note->note); } + + #[Test] + public function note_can_have_media_with_alt_text(): void + { + $note = Note::factory()->create(); + $media = Media::factory()->create(); + + $note->media()->attach($media->id, [ + 'alt_text' => 'Test alt text', + 'order' => 0, + ]); + + $note->refresh(); + + $this->assertCount(1, $note->media); + $this->assertEquals('Test alt text', $note->media->first()->pivot->alt_text); + } } -- 2.55.0 From 51780523b4954d4cecbe1deb50ffabbe2c8b4a30 Mon Sep 17 00:00:00 2001 From: Jonny Barnes Date: Sun, 4 Jan 2026 10:22:32 +0000 Subject: [PATCH 4/4] Fix unit tests --- app/Services/NoteService.php | 2 +- tests/Feature/MicropubControllerTest.php | 7 ++++--- tests/Unit/Jobs/ProcessMediaJobTest.php | 1 - 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/app/Services/NoteService.php b/app/Services/NoteService.php index 096f7f25..ff77ef7b 100644 --- a/app/Services/NoteService.php +++ b/app/Services/NoteService.php @@ -53,7 +53,7 @@ class NoteService foreach ($this->getMedia($data) as $index => $media) { $note->media()->attach($media['value']->id, [ - 'alt_text' => $media['alt_text'], + 'alt_text' => $media['alt'], 'order' => $index, ]); } diff --git a/tests/Feature/MicropubControllerTest.php b/tests/Feature/MicropubControllerTest.php index 3d28f399..8d10fc63 100644 --- a/tests/Feature/MicropubControllerTest.php +++ b/tests/Feature/MicropubControllerTest.php @@ -236,11 +236,11 @@ class MicropubControllerTest extends TestCase * Test a valid micropub requests using JSON syntax creates a new note. */ #[Test] - public function micropub_client_api_request_creates_new_note(): void + public function micropub_client_api_request_creates_new_note_with_photo(): void { Queue::fake(); Media::create([ - 'path' => 'test-photo.jpg', + 'path' => 'media/test-photo.jpg', 'type' => 'image', ]); SyndicationTarget::factory()->create([ @@ -265,12 +265,13 @@ class MicropubControllerTest extends TestCase 'https://mastodon.social/@jonnybarnes', 'https://bsky.app/profile/jonnybarnes.uk', ], - 'photo' => [config('filesystems.disks.s3.url') . '/test-photo.jpg'], + 'photo' => [config('filesystems.disks.public.url') . '/media/test-photo.jpg'], ], ], ['HTTP_Authorization' => 'Bearer ' . $this->getToken()] ); $response + ->dump() ->assertStatus(201) ->assertJson(['response' => 'created']); Queue::assertPushed(SendWebMentions::class); diff --git a/tests/Unit/Jobs/ProcessMediaJobTest.php b/tests/Unit/Jobs/ProcessMediaJobTest.php index a405fde8..29eb780c 100644 --- a/tests/Unit/Jobs/ProcessMediaJobTest.php +++ b/tests/Unit/Jobs/ProcessMediaJobTest.php @@ -43,7 +43,6 @@ class ProcessMediaJobTest extends TestCase { $manager = app()->make(ImageManager::class); Storage::disk('local')->put('media/test-image.jpg', file_get_contents(__DIR__.'/../../test-image.jpg')); - ray(Storage::disk('local')->get('media/test-image.jpg')); $job = new ProcessMedia('media/test-image.jpg'); $job->handle($manager); -- 2.55.0