diff --git a/composer.json b/composer.json index 8221ca0..aecb085 100644 --- a/composer.json +++ b/composer.json @@ -70,6 +70,9 @@ "allow-plugins": { "pestphp/pest-plugin": true, "phpstan/extension-installer": true + }, + "platform": { + "php": "8.4" } }, "extra": { diff --git a/database/migrations/2025_11_24_082802_add_slug_to_images_table.php b/database/migrations/2025_11_24_082802_add_slug_to_images_table.php new file mode 100644 index 0000000..f14e5f7 --- /dev/null +++ b/database/migrations/2025_11_24_082802_add_slug_to_images_table.php @@ -0,0 +1,22 @@ +string('slug')->unique()->index()->nullable()->after('filename'); + }); + } + + public function down() + { + Schema::table('images', function (Blueprint $table) { + $table->dropColumn('slug'); + }); + } +} diff --git a/database/migrations/2025_11_24_082803_add_slug_to_videos_table.php b/database/migrations/2025_11_24_082803_add_slug_to_videos_table.php new file mode 100644 index 0000000..b5f5ccf --- /dev/null +++ b/database/migrations/2025_11_24_082803_add_slug_to_videos_table.php @@ -0,0 +1,22 @@ +string('slug')->unique()->index()->nullable()->after('identifier'); + }); + } + + public function down() + { + Schema::table('videos', function (Blueprint $table) { + $table->dropColumn('slug'); + }); + } +} diff --git a/src/Filament/Resources/Images/Schemas/ImageForm.php b/src/Filament/Resources/Images/Schemas/ImageForm.php index 6a6a4b8..66e60b3 100644 --- a/src/Filament/Resources/Images/Schemas/ImageForm.php +++ b/src/Filament/Resources/Images/Schemas/ImageForm.php @@ -45,6 +45,8 @@ public static function configure(Schema $schema): Schema }), TextInput::make('description') ->columnSpanFull(), + TextInput::make('slug') + ->helperText('Unique identifier for embedding in content (e.g., "my-image")'), TextInput::make('alt'), TextInput::make('credit'), // @phpstan-ignore-next-line Placeholder is deprecated in newer Filament; compatible here diff --git a/src/Filament/Resources/Videos/Schemas/VideoForm.php b/src/Filament/Resources/Videos/Schemas/VideoForm.php index 9f28c8e..3616199 100644 --- a/src/Filament/Resources/Videos/Schemas/VideoForm.php +++ b/src/Filament/Resources/Videos/Schemas/VideoForm.php @@ -23,6 +23,9 @@ public static function configure(Schema $schema): Schema ->visibleOn('create'), TextInput::make('name')->label('Internal Name'), + TextInput::make('slug') + ->helperText('Unique identifier for embedding in content (e.g., "my-video")') + ->visibleOn('edit'), TextInput::make('credit')->visibleOn('edit'), Select::make('image_id') ->label('Thumbnail Image') diff --git a/src/Models/Image.php b/src/Models/Image.php index 0713435..23f0c8d 100644 --- a/src/Models/Image.php +++ b/src/Models/Image.php @@ -11,6 +11,7 @@ /** * @property int $id * @property string|null $filename + * @property string|null $slug * @property int|null $width * @property int|null $height * @property string|null $description @@ -63,6 +64,16 @@ public function getExtensionAttribute(): ?string return isset($parts['extension']) ? strtolower($parts['extension']) : null; } + public function embed(): string + { + $slug = $this->getAttribute('slug'); + if (! $slug) { + return ''; + } + + return '{image:'.$slug.'}'; + } + public function videos() { return $this->hasMany(Video::class); diff --git a/src/Models/Video.php b/src/Models/Video.php index 30eeb71..ae2de76 100644 --- a/src/Models/Video.php +++ b/src/Models/Video.php @@ -11,6 +11,7 @@ /** * @property int $id * @property string $identifier + * @property string|null $slug * @property string $source * @property string|null $name * @property string|null $title @@ -77,4 +78,14 @@ public function youtubeUrl(): ?string // Use the youtu.be short link format return sprintf('https://youtu.be/%s', $this->identifier); } + + public function embed(): string + { + $slug = $this->getAttribute('slug'); + if (! $slug) { + return ''; + } + + return '{video:'.$slug.'}'; + } } diff --git a/tests/Unit/ImageSlugEmbedTest.php b/tests/Unit/ImageSlugEmbedTest.php new file mode 100644 index 0000000..823b9e5 --- /dev/null +++ b/tests/Unit/ImageSlugEmbedTest.php @@ -0,0 +1,121 @@ +user = createUser(); +}); + +describe('Image Slug', function () { + describe('Basic Slug Functionality', function () { + test('creates image with slug', function () { + $image = createImage(['slug' => 'test-image-slug']); + + expect($image) + ->toBeInstanceOf(Image::class) + ->slug->toBe('test-image-slug'); + }); + + test('creates image without slug', function () { + $image = createImage(['slug' => null]); + + expect($image) + ->slug->toBeNull(); + }); + + test('enforces unique slugs', function () { + createImage(['filename' => '/unique1.jpg', 'slug' => 'unique-slug']); + + expect(fn () => createImage(['filename' => '/unique2.jpg', 'slug' => 'unique-slug'])) + ->toThrow(\Illuminate\Database\QueryException::class); + }); + + test('allows null slugs for multiple images', function () { + $image1 = createImage(['filename' => '/image1.jpg', 'slug' => null]); + $image2 = createImage(['filename' => '/image2.jpg', 'slug' => null]); + + expect($image1->slug)->toBeNull() + ->and($image2->slug)->toBeNull(); + }); + }); + + describe('Slug Queries', function () { + test('can find image by slug', function () { + $image = createImage(['slug' => 'findable-slug']); + + $found = Image::where('slug', 'findable-slug')->first(); + + expect($found) + ->toBeInstanceOf(Image::class) + ->id->toBe($image->id) + ->slug->toBe('findable-slug'); + }); + + test('returns null when slug does not exist', function () { + $found = Image::where('slug', 'non-existent-slug')->first(); + + expect($found)->toBeNull(); + }); + }); +}); + +describe('Image Embed', function () { + describe('Embed Method', function () { + test('returns formatted embed string with slug', function () { + $image = createImage(['slug' => 'my-image-slug']); + + expect($image->embed()) + ->toBe('{image:my-image-slug}'); + }); + + test('returns empty string when slug is null', function () { + $image = createImage(['slug' => null]); + + expect($image->embed()) + ->toBe(''); + }); + + test('returns empty string when slug is empty', function () { + $image = createImage(['slug' => '']); + + expect($image->embed()) + ->toBe(''); + }); + + test('handles slug with special characters', function () { + $image = createImage(['slug' => 'image-with-123']); + + expect($image->embed()) + ->toBe('{image:image-with-123}'); + }); + + test('handles slug with hyphens', function () { + $image = createImage(['slug' => 'my-awesome-image']); + + expect($image->embed()) + ->toBe('{image:my-awesome-image}'); + }); + }); + + describe('Embed Integration', function () { + test('embed works after updating slug', function () { + $image = createImage(['slug' => 'original-slug']); + $image->update(['slug' => 'updated-slug']); + + expect($image->embed()) + ->toBe('{image:updated-slug}'); + }); + + test('multiple images have different embed strings', function () { + $image1 = createImage(['filename' => '/image1.jpg', 'slug' => 'first-image']); + $image2 = createImage(['filename' => '/image2.jpg', 'slug' => 'second-image']); + + expect($image1->embed()) + ->toBe('{image:first-image}') + ->and($image2->embed()) + ->toBe('{image:second-image}') + ->and($image1->embed()) + ->not->toBe($image2->embed()); + }); + }); +}); diff --git a/tests/Unit/VideoSlugEmbedTest.php b/tests/Unit/VideoSlugEmbedTest.php new file mode 100644 index 0000000..3af6e43 --- /dev/null +++ b/tests/Unit/VideoSlugEmbedTest.php @@ -0,0 +1,130 @@ +user = createUser(); +}); + +describe('Video Slug', function () { + describe('Basic Slug Functionality', function () { + test('creates video with slug', function () { + $video = createVideo(['slug' => 'test-video-slug']); + + expect($video) + ->toBeInstanceOf(Video::class) + ->slug->toBe('test-video-slug'); + }); + + test('creates video without slug', function () { + $video = createVideo(['slug' => null]); + + expect($video) + ->slug->toBeNull(); + }); + + test('enforces unique slugs', function () { + createVideo(['identifier' => 'unique-vid-1', 'slug' => 'unique-slug']); + + expect(fn () => createVideo(['identifier' => 'unique-vid-2', 'slug' => 'unique-slug'])) + ->toThrow(\Illuminate\Database\QueryException::class); + }); + + test('allows null slugs for multiple videos', function () { + $video1 = createVideo(['identifier' => 'vid1', 'slug' => null]); + $video2 = createVideo(['identifier' => 'vid2', 'slug' => null]); + + expect($video1->slug)->toBeNull() + ->and($video2->slug)->toBeNull(); + }); + }); + + describe('Slug Queries', function () { + test('can find video by slug', function () { + $video = createVideo(['slug' => 'findable-video-slug']); + + $found = Video::where('slug', 'findable-video-slug')->first(); + + expect($found) + ->toBeInstanceOf(Video::class) + ->id->toBe($video->id) + ->slug->toBe('findable-video-slug'); + }); + + test('returns null when slug does not exist', function () { + $found = Video::where('slug', 'non-existent-slug')->first(); + + expect($found)->toBeNull(); + }); + }); +}); + +describe('Video Embed', function () { + describe('Embed Method', function () { + test('returns formatted embed string with slug', function () { + $video = createVideo(['slug' => 'my-video-slug']); + + expect($video->embed()) + ->toBe('{video:my-video-slug}'); + }); + + test('returns empty string when slug is null', function () { + $video = createVideo(['slug' => null]); + + expect($video->embed()) + ->toBe(''); + }); + + test('returns empty string when slug is empty', function () { + $video = createVideo(['slug' => '']); + + expect($video->embed()) + ->toBe(''); + }); + + test('handles slug with special characters', function () { + $video = createVideo(['slug' => 'video-with-123']); + + expect($video->embed()) + ->toBe('{video:video-with-123}'); + }); + + test('handles slug with hyphens', function () { + $video = createVideo(['slug' => 'my-awesome-video']); + + expect($video->embed()) + ->toBe('{video:my-awesome-video}'); + }); + }); + + describe('Embed Integration', function () { + test('embed works after updating slug', function () { + $video = createVideo(['slug' => 'original-slug']); + $video->update(['slug' => 'updated-slug']); + + expect($video->embed()) + ->toBe('{video:updated-slug}'); + }); + + test('multiple videos have different embed strings', function () { + $video1 = createVideo(['identifier' => 'vid1', 'slug' => 'first-video']); + $video2 = createVideo(['identifier' => 'vid2', 'slug' => 'second-video']); + + expect($video1->embed()) + ->toBe('{video:first-video}') + ->and($video2->embed()) + ->toBe('{video:second-video}') + ->and($video1->embed()) + ->not->toBe($video2->embed()); + }); + + test('embed is independent of youtube url', function () { + $video = createVideo(['identifier' => 'dQw4w9WgXcQ', 'slug' => 'rick-roll']); + + expect($video->embed()) + ->toBe('{video:rick-roll}') + ->and($video->youtubeUrl()) + ->toBe('https://youtu.be/dQw4w9WgXcQ'); + }); + }); +});