Merge pull request 'MTM Micropub Media support' (#68) from develop into main

Reviewed-on: #68
This commit is contained in:
Jonny Barnes 2026-01-04 11:37:18 +01:00
commit bc8d535b24
30 changed files with 309 additions and 67 deletions

View file

@ -0,0 +1,92 @@
<?php
namespace App\Console\Commands;
use App\Models\Media;
use App\Models\Note;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
class MigrateMedia extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'app:migrate-media';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Migrate media';
/**
* Execute the console command.
*/
public function handle(): void
{
// First check new `media_note` table exists
if (! DB::getSchemaBuilder()->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');
}
}

View file

@ -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);
}

View file

@ -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

View file

@ -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);
}
}

View file

@ -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<int, string> */
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

View file

@ -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');
}
/**

View file

@ -48,12 +48,15 @@ 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);
// }
foreach ($this->getMedia($data) as $index => $media) {
$note->media()->attach($media['value']->id, [
'alt_text' => $media['alt'],
'order' => $index,
]);
}
$note->save();
@ -188,23 +191,44 @@ 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) {
// $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.s3.url'))) {
$path = substr($photo, strlen(config('filesystems.disks.s3.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,
];
}
}
}

View file

@ -101,7 +101,7 @@ return [
'previous_keys' => [
...array_filter(
explode(',', env('APP_PREVIOUS_KEYS', ''))
explode(',', (string) env('APP_PREVIOUS_KEYS', ''))
),
],

View file

@ -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.
|

View file

@ -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_'),
];

View file

@ -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),
],
],

View file

@ -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,

View file

@ -1,7 +1,6 @@
<?php
use Monolog\Handler\NullHandler;
use Monolog\Handler\StreamHandler;
use Monolog\Handler\SyslogUdpHandler;
use Monolog\Processor\PsrLogMessageProcessor;
@ -54,7 +53,7 @@ return [
'stack' => [
'driver' => 'stack',
'channels' => explode(',', env('LOG_STACK', 'single')),
'channels' => explode(',', (string) env('LOG_STACK', 'single')),
'ignore_exceptions' => false,
],
@ -97,11 +96,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],
],

View file

@ -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,
],
],

View file

@ -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',
],
],
],
/*

View file

@ -15,7 +15,11 @@ return [
*/
'postmark' => [
'token' => env('POSTMARK_TOKEN'),
'key' => env('POSTMARK_API_KEY'),
],
'resend' => [
'key' => env('RESEND_API_KEY'),
],
'ses' => [

View file

@ -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.
|
*/

View file

@ -0,0 +1,33 @@
<?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('media_note', function (Blueprint $table) {
$table->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');
}
};

View file

@ -84,7 +84,9 @@ class NotesTableSeeder extends Seeder
// copy aarons 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()]);

View file

@ -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:

View file

@ -1,2 +1,2 @@
@layer reset{*,:before,:after{box-sizing:border-box}html{-webkit-text-size-adjust:none;-moz-text-size-adjust:none;text-size-adjust:none}body,h1,h2,h3,h4,p,figure,blockquote,dl,dd{margin-block-end:0}ul[role=list],ol[role=list]{list-style:none}body{line-height:1.5}h1,h2,h3,h4,button,input,label{line-height:1.1}h1,h2,h3,h4{text-wrap:balance}a:not([class]){text-decoration-skip-ink:auto;color:currentColor}img,picture{max-width:100%;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}& .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 */

Binary file not shown.

File diff suppressed because one or more lines are too long

Binary file not shown.

View file

@ -6,10 +6,17 @@
border-radius: var(--border-radius);
padding: 1ex 2ex;
.e-content > p:first-child {
.e-content {
> p:first-child {
margin-block-start: 0;
}
.u-photo {
width: 100%;
height: auto;
}
}
.syndication-links {
display: flex;
flex-direction: row;

View file

@ -12,7 +12,7 @@
@foreach($note->media as $media)
@if($media->type === 'image')
<a class="naked-link" href="{{ $media->url }}">
<img class="u-photo" src="{{ $media->url }}" alt="" @if($media->image_widths !== null) srcset="{{ $media->url }} {{ $media->image_widths }}w, {{ $media->mediumurl }} 1000w, {{ $media->smallurl }} 500w" sizes="80vh"@endif>
<img class="u-photo" alt="{{ $media->pivot->alt_text }}" @if($media->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>
</a>
@endif
@if($media->type === 'audio')

View file

@ -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);

View file

@ -43,7 +43,7 @@ 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');
$job = new ProcessMedia('media/test-image.jpg');
$job->handle($manager);
// These need to look in public disk

View file

@ -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]

View file

@ -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);
}
}