diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..dd9a2b5
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,15 @@
+root = true
+
+[*]
+charset = utf-8
+end_of_line = lf
+indent_size = 4
+indent_style = space
+insert_final_newline = true
+trim_trailing_whitespace = true
+
+[*.md]
+trim_trailing_whitespace = false
+
+[*.{yml,yaml}]
+indent_size = 2
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..c09f81e
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,20 @@
+# Path-based git attributes
+# https://www.kernel.org/pub/software/scm/git/docs/gitattributes.html
+
+# Ignore all test and documentation with "export-ignore".
+/.github export-ignore
+/.gitattributes export-ignore
+/.gitignore export-ignore
+/phpunit.xml.dist export-ignore
+/art export-ignore
+/docs export-ignore
+/tests export-ignore
+/workbench export-ignore
+/.editorconfig export-ignore
+/.php_cs.dist.php export-ignore
+/psalm.xml export-ignore
+/psalm.xml.dist export-ignore
+/testbench.yaml export-ignore
+/UPGRADING.md export-ignore
+/phpstan.neon.dist export-ignore
+/phpstan-baseline.neon export-ignore
diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml
new file mode 100644
index 0000000..8a70f3a
--- /dev/null
+++ b/.github/FUNDING.yml
@@ -0,0 +1 @@
+github: outer-web
diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml
new file mode 100644
index 0000000..8136330
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug.yml
@@ -0,0 +1,66 @@
+name: Bug Report
+description: Report an Issue or Bug with the Package
+title: "[Bug]: "
+labels: ["bug"]
+body:
+ - type: markdown
+ attributes:
+ value: |
+ We're sorry to hear you have a problem. Can you help us solve it by providing the following details.
+ - type: textarea
+ id: what-happened
+ attributes:
+ label: What happened?
+ description: What did you expect to happen?
+ placeholder: I cannot currently do X thing because when I do, it breaks X thing.
+ validations:
+ required: true
+ - type: textarea
+ id: how-to-reproduce
+ attributes:
+ label: How to reproduce the bug
+ description: How did this occur, please add any config values used and provide a set of reliable steps if possible.
+ placeholder: When I do X I see Y.
+ validations:
+ required: true
+ - type: input
+ id: package-version
+ attributes:
+ label: Package Version
+ description: What version of our Package are you running? Please be as specific as possible
+ placeholder: 2.0.0
+ validations:
+ required: true
+ - type: input
+ id: php-version
+ attributes:
+ label: PHP Version
+ description: What version of PHP are you running? Please be as specific as possible
+ placeholder: 8.2.0
+ validations:
+ required: true
+ - type: input
+ id: laravel-version
+ attributes:
+ label: Laravel Version
+ description: What version of Laravel are you running? Please be as specific as possible
+ placeholder: 9.0.0
+ validations:
+ required: true
+ - type: dropdown
+ id: operating-systems
+ attributes:
+ label: Which operating systems does this happen with?
+ description: You may select more than one.
+ multiple: true
+ options:
+ - macOS
+ - Windows
+ - Linux
+ - type: textarea
+ id: notes
+ attributes:
+ label: Notes
+ description: Use this field to provide any other notes that you feel might be relevant to the issue.
+ validations:
+ required: false
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
new file mode 100644
index 0000000..00fe959
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -0,0 +1,11 @@
+blank_issues_enabled: false
+contact_links:
+ - name: Ask a question
+ url: https://github.com/outerweb/media-library/discussions/new?category=q-a
+ about: Ask the community for help
+ - name: Request a feature
+ url: https://github.com/outerweb/media-library/discussions/new?category=ideas
+ about: Share ideas for new features
+ - name: Report a security issue
+ url: https://github.com/outerweb/media-library/security/policy
+ about: Learn how to notify us for sensitive bugs
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 0000000..39b1580
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,19 @@
+# Please see the documentation for all configuration options:
+# https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
+
+version: 2
+updates:
+
+ - package-ecosystem: "github-actions"
+ directory: "/"
+ schedule:
+ interval: "weekly"
+ labels:
+ - "dependencies"
+
+ - package-ecosystem: "composer"
+ directory: "/"
+ schedule:
+ interval: "weekly"
+ labels:
+ - "dependencies"
diff --git a/.github/workflows/dependabot-auto-merge.yml b/.github/workflows/dependabot-auto-merge.yml
new file mode 100644
index 0000000..cc8c94c
--- /dev/null
+++ b/.github/workflows/dependabot-auto-merge.yml
@@ -0,0 +1,33 @@
+name: dependabot-auto-merge
+on: pull_request_target
+
+permissions:
+ pull-requests: write
+ contents: write
+
+jobs:
+ dependabot:
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+ if: ${{ github.actor == 'dependabot[bot]' }}
+ steps:
+
+ - name: Dependabot metadata
+ id: metadata
+ uses: dependabot/fetch-metadata@v2.4.0
+ with:
+ github-token: "${{ secrets.GITHUB_TOKEN }}"
+
+ - name: Auto-merge Dependabot PRs for semver-minor updates
+ if: ${{steps.metadata.outputs.update-type == 'version-update:semver-minor'}}
+ run: gh pr merge --auto --merge "$PR_URL"
+ env:
+ PR_URL: ${{github.event.pull_request.html_url}}
+ GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}
+
+ - name: Auto-merge Dependabot PRs for semver-patch updates
+ if: ${{steps.metadata.outputs.update-type == 'version-update:semver-patch'}}
+ run: gh pr merge --auto --merge "$PR_URL"
+ env:
+ PR_URL: ${{github.event.pull_request.html_url}}
+ GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}
diff --git a/.github/workflows/fix-php-code-style-issues.yml b/.github/workflows/fix-php-code-style-issues.yml
new file mode 100644
index 0000000..f30644a
--- /dev/null
+++ b/.github/workflows/fix-php-code-style-issues.yml
@@ -0,0 +1,28 @@
+name: Fix PHP code style issues
+
+on:
+ push:
+ paths:
+ - '**.php'
+
+permissions:
+ contents: write
+
+jobs:
+ php-code-styling:
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v5
+ with:
+ ref: ${{ github.head_ref }}
+
+ - name: Fix PHP code style issues
+ uses: aglipanci/laravel-pint-action@2.6
+
+ - name: Commit changes
+ uses: stefanzweifel/git-auto-commit-action@v6
+ with:
+ commit_message: Fix styling
diff --git a/.github/workflows/phpstan.yml b/.github/workflows/phpstan.yml
new file mode 100644
index 0000000..1e5dc43
--- /dev/null
+++ b/.github/workflows/phpstan.yml
@@ -0,0 +1,28 @@
+name: PHPStan
+
+on:
+ push:
+ paths:
+ - '**.php'
+ - 'phpstan.neon.dist'
+ - '.github/workflows/phpstan.yml'
+
+jobs:
+ phpstan:
+ name: phpstan
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+ steps:
+ - uses: actions/checkout@v5
+
+ - name: Setup PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: '8.4'
+ coverage: none
+
+ - name: Install composer dependencies
+ uses: ramsey/composer-install@v3
+
+ - name: Run PHPStan
+ run: ./vendor/bin/phpstan --error-format=github
diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml
new file mode 100644
index 0000000..2fd359e
--- /dev/null
+++ b/.github/workflows/run-tests.yml
@@ -0,0 +1,58 @@
+name: run-tests
+
+on:
+ push:
+ paths:
+ - "**.php"
+ - ".github/workflows/run-tests.yml"
+ - "phpunit.xml.dist"
+ - "composer.json"
+ - "composer.lock"
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ test:
+ runs-on: ${{ matrix.os }}
+ timeout-minutes: 5
+ strategy:
+ fail-fast: true
+ matrix:
+ os: [ubuntu-latest, windows-latest]
+ php: [8.5]
+ laravel: [12.*]
+ stability: [prefer-stable]
+ include:
+ - laravel: 12.*
+ testbench: 10.*
+
+ name: P${{ matrix.php }} - L${{ matrix.laravel }} - ${{ matrix.stability }} - ${{ matrix.os }}
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v5
+
+ - name: Setup PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: ${{ matrix.php }}
+ extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv, imagick, fileinfo
+ coverage: none
+
+ - name: Setup problem matchers
+ run: |
+ echo "::add-matcher::${{ runner.tool_cache }}/php.json"
+ echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json"
+
+ - name: Install dependencies
+ run: |
+ composer require "laravel/framework:${{ matrix.laravel }}" "orchestra/testbench:${{ matrix.testbench }}" --no-interaction --no-update
+ composer update --${{ matrix.stability }} --prefer-dist --no-interaction
+
+ - name: List Installed Dependencies
+ run: composer show -D
+
+ - name: Execute tests
+ run: vendor/bin/pest --ci
diff --git a/.gitignore b/.gitignore
index 9a974a9..e263e1c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,8 +1,31 @@
-/.idea
-/.vscode
+# Composer Related
+composer.lock
/vendor
+
+# Frontend Assets
/node_modules
-package-lock.json
-composer.phar
-composer.lock
-.DS_Store
+
+# Logs
+npm-debug.log
+yarn-error.log
+
+# Caches
+.phpunit.cache
+.phpunit.result.cache
+/build
+
+# IDE Helper
+_ide_helper.php
+_ide_helper_models.php
+.phpstorm.meta.php
+
+# Editors
+/.idea
+/.fleet
+/.vscode
+
+# Misc
+phpunit.xml
+phpstan.neon
+/coverage
+TODO.md
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 167b7ee..53034e1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,95 +2,95 @@
All notable changes to `image library` will be documented in this file.
-## 2.7.1 - 2025-11-17
+## 3.0.0 - 2025-12-22
-### Fixed
+### Changed
-- Fixed an issue where transparent images would get a black background when uploaded.
+- Complete rewrite of the package. Please refer to the (upgrade guide)[./docs/upgrade-to-v3.md] for more information.
## 2.7.0 - 2025-02-27
### Added
-- Added support for Laravel 12.
+- Added support for Laravel 12.
## 2.6.0 - 2025-01-31
### Fixed
-- Use with('conversions') to eager load the conversions in the Image model.
+- Use with('conversions') to eager load the conversions in the Image model.
## 2.5.0 - 2024-03-27
### Fixed
-- Added the $force = true on the ImageConversion observers to force the generation of the conversions. This is intended as the existing conversions should be overridden by the new ones.
+- Added the $force = true on the ImageConversion observers to force the generation of the conversions. This is intended as the existing conversions should be overridden by the new ones.
## 2.4.2 - 2024-03-12
### Added
-- Added support for Laravel 11.
+- Added support for Laravel 11.
## 2.4.1 - 2024-03-09
### Fixed
-- Fixed an issue where `getbasePath` method could return null instead of a string and break the `url` and `path` methods of the Image model.
+- Fixed an issue where `getbasePath` method could return null instead of a string and break the `url` and `path` methods of the Image model.
## 2.4.0 - 2024-03-04
### Added
-- Added a `intersectionObserver` to the scripts blade component to be better at dynamically setting the image sizes attribute of the image tag. This is done to fix issues where the image is hidden and becomes visible after a user action. (e.g. a hidden tab that becomes visible after a user clicks on it.)
+- Added a `intersectionObserver` to the scripts blade component to be better at dynamically setting the image sizes attribute of the image tag. This is done to fix issues where the image is hidden and becomes visible after a user action. (e.g. a hidden tab that becomes visible after a user clicks on it.)
## 2.3.0 - 2024-03-04
### Added
-- Added a `createSync` method to the `ConversionDefinition` entity to inform the image library to dispatch the generateConversion job synchronously. This is done to make the thumbnail generation conversion visible immediately after uploading an image when using a async queue driver.
+- Added a `createSync` method to the `ConversionDefinition` entity to inform the image library to dispatch the generateConversion job synchronously. This is done to make the thumbnail generation conversion visible immediately after uploading an image when using a async queue driver.
## 2.2.1 - 2024-03-04
### Changed
-- Improved the installation process by utilizing more of the Spatie package install command.
+- Improved the installation process by utilizing more of the Spatie package install command.
## 2.1.0 - 2024-03-28
### Fixed
-- Fixed a bug where the javascript code that dynamically sets the image width as the sizes attribute of the image tag caused an infinite loop because the `load` event listener kept firing.
-- Fixed several issues with the rendering of the image and picture tags when the image was set to `NULL`.
+- Fixed a bug where the javascript code that dynamically sets the image width as the sizes attribute of the image tag caused an infinite loop because the `load` event listener kept firing.
+- Fixed several issues with the rendering of the image and picture tags when the image was set to `NULL`.
## 2.0.0 - 2024-02-26
### Added
-- Added `Outerweb\ImageLibrary\Entities\ConversionDefinition::label(string $label)` method to set the label of the conversion. By default, the label will be the name of the conversion.
-- Added `Outerweb\ImageLibrary\Entities\ConversionDefinition::translateLabel(bool $doTranslateLabel = true)` method to set whether the label should be translated. By default, the label will not be translated. This method will take the value of the label and put it through the `__()` function.
+- Added `Outerweb\ImageLibrary\Entities\ConversionDefinition::label(string $label)` method to set the label of the conversion. By default, the label will be the name of the conversion.
+- Added `Outerweb\ImageLibrary\Entities\ConversionDefinition::translateLabel(bool $doTranslateLabel = true)` method to set whether the label should be translated. By default, the label will not be translated. This method will take the value of the label and put it through the `__()` function.
### Changed
-- Changed the javascript code that dynamically sets the image width as the sizes attribute of the image tag. The new code takes into account any rerendering in the browser through a MutationObserver. So when livewire or any javascript library rerenders (a part of) the page, the image width will be recalculated and set as the sizes attribute of the image tag.
+- Changed the javascript code that dynamically sets the image width as the sizes attribute of the image tag. The new code takes into account any rerendering in the browser through a MutationObserver. So when livewire or any javascript library rerenders (a part of) the page, the image width will be recalculated and set as the sizes attribute of the image tag.
### Fixed
-- Fixed a bug where the webp variants of the responsive variants did not get deleted when the conversions get regenerated.
+- Fixed a bug where the webp variants of the responsive variants did not get deleted when the conversions get regenerated.
## 1.2.0 - 2024-02-19
### Added
-- Added `Outerweb\ImageLibrary\Facades\ImageLibrary::isSpatieTranslatable()` method to check the value of the config variable `spatie_translatable`.
+- Added `Outerweb\ImageLibrary\Facades\ImageLibrary::isSpatieTranslatable()` method to check the value of the config variable `spatie_translatable`.
## 1.1.0 - 2024-02-19
### Changed
-- Image blade component will now fallback to the original image if the requested conversion is not (yet) available.
-- Config variable spatie_translatable is now set to false by default.
+- Image blade component will now fallback to the original image if the requested conversion is not (yet) available.
+- Config variable spatie_translatable is now set to false by default.
## 1.0.0 - 2024-02-15
-- Initial release
+- Initial release
diff --git a/README.md b/README.md
index a522fe3..17febce 100644
--- a/README.md
+++ b/README.md
@@ -1,20 +1,40 @@
# Image Library
[](https://packagist.org/packages/outerweb/image-library)
+[](https://github.com/outer-web/image-library/actions?query=workflow%3Arun-tests+branch%3Amain)
+[](https://github.com/outer-web/image-library/actions?query=workflow%3A"Fix+PHP+code+style+issues"+branch%3Amain)
[](https://packagist.org/packages/outerweb/image-library)
-This package adds ways to store and link images to your models.
-
-It provides:
-
-- A way to store images on different disks
-- An Image and ImageConversion model to interact with the images and image_conversions table
-- A way to map cropper.js data to the image_conversions table which than automatically generates and stores the conversion image on disk
-- A way to define conversions for images (e.g. thumbnail, 16:9, ...). Using spatie/image you can crop and add effects to the images.
-- Support for webp images (automatically generated and stored on disk and rendered using the picture HTML element)
-- Support for responsive images (automatically generated and stored on disk and rendered using the srcset attribute)
-- A way to render images using the picture HTML element
-- A way to render images using the image HTML element
+A powerful Laravel package for managing images with responsive breakpoints, automatic optimization, and contextual configurations. Store and link images to your models with advanced features like automatic WebP generation, responsive image versions, and flexible image contexts.
+
+> ⚠️ **Caution:** V3 is a complete rewrite of the package and logic. Please take a look at the [upgrade guide](./docs/upgrade-to-v3.md) before upgrading from v2.x to v3.x.
+
+## Table of Contents
+
+- [Requirements](#requirements)
+- [Installation](#installation)
+- [Core concepts](#core-concepts)
+ - [SourceImages](#sourceimages)
+ - [Images](#images)
+ - [ImageContexts](#imagecontexts)
+ - [Breakpoints](#breakpoints)
+- [Configuration](#configuration)
+ - [The config file](#the-config-file)
+ - [Javascript](#javascript)
+ - [Defining ImageContexts](#defining-imagecontexts)
+ - [Custom Breakpoints](#custom-breakpoints)
+- [Usage](#usage)
+ - [Uploading an image](#uploading-an-image)
+ - [Attaching an image to your model](#attaching-an-image-to-your-model)
+ - [Using your model image(s)](#using-your-model-images)
+ - [Rendering images](#rendering-images)
+- [Upgrading](#upgrading)
+- [Changelog](#changelog)
+- [License](#license)
+
+## Requirements
+
+This package uses [spatie/image](https://github.com/spatie/image) for image manipulations, so it requires the GD or Imagick PHP extension.
## Installation
@@ -24,364 +44,830 @@ You can install the package via composer:
composer require outerweb/image-library
```
-Run the install command:
+Run the install command to publish the migrations, config file, and service provider:
```bash
php artisan image-library:install
```
-Add the ` ` blade component to your layout (at the bottom of the body tag).
+This will:
+
+- Publish the configuration file to `config/image-library.php`
+- Copy and register the `ImageLibraryServiceProvider` in your application
+- Publish the database migrations
+- Optionally run the migrations
+
+## Core concepts
+
+### SourceImages
+
+SourceImages are the original images uploaded to the system. They are not directly linked to any model. They are meant for internal use in this package.
+
+### Images
+
+Images are the link between a SourceImage and your Model(s). The Image has a `BelongsTo` relationship to the SourceImage and a `MorphTo` relationship to your Model.
+
+You can see these as an instance of the uploaded images in a specific use case. You can define the use case using the `context` attribute on the Image model.
+
+### ImageContexts
+
+ImageContexts allow you to define a configuration for images used in a specific way. Examples include "profile_picture", "thumbnail", "gallery_entry", "hero", etc.
+
+They are fully customizable and should be defined in the ImageServiceProvider or in a custom service provider.
+
+Images get generated based on the defined ImageContext when the Image model gets created or updated. This is based on the image_context_hash that is stored per Image. It is a hashed version of the whole configuration so that changes in the context will trigger regeneration of the images.
+
+#### WebP versions
+
+This package also generates WebP versions of images for better performance in modern browsers. You can configure whether to generate WebP versions globally in the config file or per ImageContext.
+
+#### Responsive versions
+
+The package supports generating multiple responsive versions of images based on defined breakpoints. You can configure the sizes and aspect ratios for each breakpoint in the ImageContext, ensuring optimal display across different devices.
+
+Each breakpoint can have a minimum and maximum width defined in the context. This allows the package to generate only necessary image sizes based on your design requirements.
+
+### Breakpoints
+
+Breakpoints define responsive screen sizes for image optimization. The package uses a Breakpoint enum that follows Tailwind CSS conventions, allowing you to specify different image configurations for various screen sizes.
+
+Available breakpoints:
+
+- **`Breakpoint::Small`** (`'sm'`): 640px and up - Mobile devices in landscape, small tablets
+- **`Breakpoint::Medium`** (`'md'`): 768px and up - Tablets in portrait mode
+- **`Breakpoint::Large`** (`'lg'`): 1024px and up - Tablets in landscape, small desktops
+- **`Breakpoint::ExtraLarge`** (`'xl'`): 1280px and up - Desktop screens
+- **`Breakpoint::DoubleExtraLarge`** (`'2xl'`): 1536px and up - Large desktop screens
+
+You can use these breakpoints to define different aspect ratios, sizes, crop positions, and effects for different screen sizes, ensuring optimal image display across all devices.
+
+> **Note:** If the default breakpoints don't match your design system, you can create custom breakpoints. See [Custom Breakpoints](#custom-breakpoints) in the Configuration section.
+
+If you don't need responsive images, you can disable breakpoints globally in the config file or per ImageContext.
+
+## Configuration
+
+### The config file
+
+The config file allows you to customize various aspects of the image library. Some key configuration options include:
+
+- **`defaults.disk`**: The default filesystem disk for storing images if not specified during upload
+- **`use_breakpoints`**: Enable or disable responsive breakpoints globally
+- **`generate.webp`**: Automatically generate WebP versions of images if not specified in the image context
+- **`generate.responsive_versions`**: Generate multiple sizes for responsive images if not specified in the image context
+- **`defaults.crop_position`**: Default crop position for image transformations if not specified in the image context
+- **`models`**: Customize the Eloquent models used by the package to easily extend functionality
+- **`enums`**: Customize the enums used by the package to easily extend functionality
+- **`spatie_image.driver`**: Choose between 'gd' or 'imagick' for image manipulations
+
+### Javascript
+
+The package includes a JavaScript component that automatically sets the `sizes` attribute on `picture` elements rendered by the package. This ensures that the browser selects the most appropriate image size based on the actual display size of the image.
+
+To include the script, add the following Blade component to the `
` section of your layout:
```blade
-
+
```
-This will add a script tag to the bottom of the body tag that will dynamically set the image width as the sizes attribute of the image tag. This is an automatic way of letting the browser know which responsive image variant to download based on the device's screen size, resolution, density and supported image formats.
+### Defining ImageContexts
-## Configuration
+ImageContexts are defined in your application's `ImageLibraryServiceProvider` that gets published during installation. This provider extends the base service provider and allows you to define contexts in the `imageContexts()` method:
+
+```php
+label(fn (): string => __('Profile Picture'))
+ ->aspectRatio(AspectRatio::make(1, 1))
+ ->allowsMultiple(false),
+
+ // Gallery images - 16:9 aspect ratio, multiple images allowed
+ ImageContext::make('gallery')
+ ->label(fn (): string => __('Gallery'))
+ ->aspectRatio(AspectRatio::make(16, 9))
+ ->allowsMultiple(true),
+
+ // Thumbnail - square with responsive sizing
+ ImageContext::make('thumbnail')
+ ->label(fn (): string => __('Thumbnail'))
+ ->aspectRatio(AspectRatio::make(1, 1))
+ ->maxWidth([
+ Breakpoint::Small->value => 150,
+ Breakpoint::Medium->value => 200,
+ Breakpoint::Large->value => 250,
+ ])
+ ->allowsMultiple(false),
+
+ // Image that does not use breakpoints
+ ImageContext::make('no_breakpoints')
+ ->label(fn (): string => __('No Breakpoints'))
+ ->useBreakpoints(false)
+ ];
+ }
+}
+```
-You can configure the package by editing the `config/image-library.php` file.
+### Configuration Methods
-Each setting is documented in the config file itself.
+ImageContexts provide extensive configuration options for different responsive breakpoints and image processing needs:
-## Usage
+#### Label
+
+You can define a human-readable label for each context to use in your UI.
+
+```
+ImageContext::make('thumbnail')
+ ->label('Thumbnail')
+```
+
+If you need localization, you can define the label using a closure:
-### Defining conversions
+```php
+ImageContext::make('thumbnail')
+ ->label(fn() => __('Thumbnail'))
+```
-You can define conversions anywhere in your application. We recommend doing this in a service provider.
-To do this, you can use the `ConversionDefinition` entity.
+If you need information about the ImageContext in the label, you can use the provided `ImageContext` instance:
```php
-use OuterWeb\ImageLibrary\Facades\ImageLibrary;
-use OuterWeb\ImageLibrary\Entities\AspectRatio;
-use OuterWeb\ImageLibrary\Entities\ConversionDefinition;
-use OuterWeb\ImageLibrary\Entities\Effects;
-
-ImageLibrary::addConversionDefinition(
- ConversionDefinition::make()
- ->name('thumbnail')
- ->label('Thumbnail')
- ->translateLabel()
- ->aspectRatio(
- AspectRatio::make()
- ->x(1)
- ->y(1)
- )
- ->defaultWidth(100)
- ->defaultHeight(100)
- ->effects(
- Effects::make()
- ->blur(10)
- ->pixelate(10)
- ->greyscale()
- ->sepia()
- ->sharpen(10)
- )
- ->createSync()
-);
-```
-
-#### Name (required)
-
-The name of the conversion. This is the name you will use to refer to the conversion.
+ImageContext::make('thumbnail')
+ ->label(fn(ImageContext $context) => __('Image Context: :context', ['context' => $context->key]))
+```
+
+#### Allowing multiple images
+
+You can specify whether multiple images are allowed in this context:
```php
-use OuterWeb\ImageLibrary\Entities\ConversionDefinition;
+ImageContext::make('gallery')
+ ->allowsMultiple(true);
+```
+
+#### Generating WebP versions
-ConversionDefinition::make()
- ->name('thumbnail');
+By default, WebP versions are generated based on the global config. You can override this per context:
+
+```php
+ImageContext::make('thumbnail')
+ ->generateWebP(false);
```
-#### Label (optional)
+#### Using breakpoints
-The label of the conversion. This is can be used by other packages that depend on this package to show the label in the user interface. E.g. in our Filament Image Library package, this is used to display the conversion name above the cropper.
+By default, breakpoints are used based on the global config. You can override this per context:
```php
-use OuterWeb\ImageLibrary\Entities\ConversionDefinition;
+ImageContext::make('thumbnail')
+ ->useBreakpoints(false);
+```
+
+> ⚠️ **Caution:** Make sure to call `->useBreakpoints(false)` before any other methods that depend on breakpoints, such as `aspectRatio()`, `minWidth()`, `maxWidth()`, etc. Otherwise, you may encounter errors since those methods check if breakpoints are enabled or not.
+
+#### Generating responsive versions
-ConversionDefinition::make()
- ->label('Thumbnail');
+By default, responsive versions are generated based on the global config. You can override this per context:
+
+```php
+ImageContext::make('thumbnail')
+ ->generateResponsiveVersions(false);
```
-#### Translate label (optional)
+> **Note:** If you disable breakpoints for an ImageContext, responsive versions will also be disabled as these are only generated when breakpoints are used.
+
+#### Aspect Ratio
-Whether the label should be translated. By default, the label will not be translated. This method will take the value of the label and put it through the `__()` function.
+The aspect ratio can be configured per `Breakpoint` in one of the following ways:
```php
-use OuterWeb\ImageLibrary\Entities\ConversionDefinition;
+// Single aspect ratio for all breakpoints
+ImageContext::make('thumbnail')
+ ->aspectRatio(AspectRatio::make(1, 1));
+
+// Different aspect ratios per breakpoint
+ImageContext::make('thumbnail')
+ ->aspectRatio([
+ Breakpoint::Small->value => AspectRatio::make(1, 1),
+ Breakpoint::Medium->value => AspectRatio::make(4, 3),
+ Breakpoint::Large->value => AspectRatio::make(16, 9),
+ Breakpoint::ExtraLarge->value => AspectRatio::make(16, 9),
+ Breakpoint::DoubleExtraLarge->value => AspectRatio::make(2, 1),
+ ]);
+
+// Per breakpoint
+ImageContext::make('thumbnail')
+ ->aspectRatioForBreakpoint(Breakpoint::Small, AspectRatio::make(1, 1))
-ConversionDefinition::make()
- ->label('conversions.labels.thumbnail');
- ->translateLabel();
+// From a Breakpoint and up
+ImageContext::make('thumbnail')
+ ->aspectRatioFromBreakpoint(Breakpoint::Medium, AspectRatio::make(16, 9))
+
+// Up till a Breakpoint
+ImageContext::make('thumbnail')
+ ->aspectRatioUpToBreakpoint(Breakpoint::Large, AspectRatio::make(4, 3))
+
+// Between two Breakpoints
+ImageContext::make('thumbnail')
+ ->aspectRatioBetweenBreakpoints(Breakpoint::Small, Breakpoint::Large, AspectRatio::make(1, 1));
```
-#### Aspect ratio (required)
+#### Minimum width
-The aspect ratio of the conversion. You can define this by using the `AspectRatio` entity.
+You can define the minimum width of the image used in your design per `Breakpoint` in one of the following ways:
```php
-use OuterWeb\ImageLibrary\Entities\AspectRatio;
-use OuterWeb\ImageLibrary\Entities\ConversionDefinition;
+// Single minimum width for all breakpoints
+ImageContext::make('thumbnail')
+ ->minWidth(150)
+
+// Different minimum widths per breakpoint
+ImageContext::make('thumbnail')
+ ->minWidth([
+ Breakpoint::Small->value => 100,
+ Breakpoint::Medium->value => 150,
+ Breakpoint::Large->value => 200,
+ Breakpoint::ExtraLarge->value => 250,
+ Breakpoint::DoubleExtraLarge->value => 300,
+ ]);
+
+// Per breakpoint
+ImageContext::make('thumbnail')
+ ->minWidthForBreakpoint(Breakpoint::Small, 100);
+
+// From a Breakpoint and up
+ImageContext::make('thumbnail')
+ ->minWidthFromBreakpoint(Breakpoint::Medium, 150);
-ConversionDefinition::make()
- ->aspectRatio(
- AspectRatio::make()
- ->x(1)
- ->y(1)
- );
+// Up till a Breakpoint
+ImageContext::make('thumbnail')
+ ->minWidthUpToBreakpoint(Breakpoint::Large, 200);
+
+// Between two Breakpoints
+ImageContext::make('thumbnail')
+ ->minWidthBetweenBreakpoints(Breakpoint::Small, Breakpoint::Large, 150);
```
-Or by providing a string.
+#### Maximum width
+
+You can define the maximum width of the image used in your design per `Breakpoint` in one of the following ways:
```php
-use OuterWeb\ImageLibrary\Entities\ConversionDefinition;
+// Single maximum width for all breakpoints
+ImageContext::make('thumbnail')
+ ->maxWidth(250);
+
+// Different maximum widths per breakpoint
+ImageContext::make('thumbnail')
+ ->maxWidth([
+ Breakpoint::Small->value => 150,
+ Breakpoint::Medium->value => 200,
+ Breakpoint::Large->value => 250,
+ Breakpoint::ExtraLarge->value => 300,
+ Breakpoint::DoubleExtraLarge->value => 350,
+ ]);
+
+// Per breakpoint
+ImageContext::make('thumbnail')
+ ->maxWidthForBreakpoint(Breakpoint::Small, 150);
+
+// From a Breakpoint and up
+ImageContext::make('thumbnail')
+ ->maxWidthFromBreakpoint(Breakpoint::Medium, 200);
-ConversionDefinition::make()
- ->aspectRatio('16:9');
+// Up till a Breakpoint
+ImageContext::make('thumbnail')
+ ->maxWidthUpToBreakpoint(Breakpoint::Large, 250);
+
+// Between two Breakpoints
+ImageContext::make('thumbnail')
+ ->maxWidthBetweenBreakpoints(Breakpoint::Small, Breakpoint::Large, 200);
```
-Or by providing an array.
+#### Crop Position
+
+By default, the crop position from the config file is used. You can override this per context and per `Breakpoint` in one of the following ways:
```php
-use OuterWeb\ImageLibrary\Entities\ConversionDefinition;
+// Single crop position for all breakpoints
+ImageContext::make('thumbnail')
+ ->cropPosition(CropPosition::Center);
+
+// Different crop positions per breakpoint
+ImageContext::make('thumbnail')
+ ->cropPosition([
+ Breakpoint::Small->value => CropPosition::Top,
+ Breakpoint::Medium->value => CropPosition::Center,
+ Breakpoint::Large->value => CropPosition::Bottom,
+ Breakpoint::ExtraLarge->value => CropPosition::Center,
+ Breakpoint::DoubleExtraLarge->value => CropPosition::Center,
+ ]);
+
+// Per breakpoint
+ImageContext::make('thumbnail')
+ ->cropPositionForBreakpoint(Breakpoint::Small, CropPosition::Top);
+
+// From a Breakpoint and up
+ImageContext::make('thumbnail')
+ ->cropPositionFromBreakpoint(Breakpoint::Medium, CropPosition::Center);
-ConversionDefinition::make()
- ->aspectRatio([16, 9]);
+// Up till a Breakpoint
+ImageContext::make('thumbnail')
+ ->cropPositionUpToBreakpoint(Breakpoint::Large, CropPosition::Bottom);
+
+// Between two Breakpoints
+ImageContext::make('thumbnail')
+ ->cropPositionBetweenBreakpoints(Breakpoint::Small, Breakpoint::Large, CropPosition::Center);
```
-#### Default width and height (optional)
+#### Blur
-The default width and height of the conversion. This is the size the image will be cropped to by default.
-These values are overridden by the width and height saved in the database.
+You can apply a blur effect to images in this context per `Breakpoint` in one of the following ways:
```php
-use OuterWeb\ImageLibrary\Entities\ConversionDefinition;
+// Single blur value for all breakpoints
+ImageContext::make('thumbnail')
+ ->blur(10);
+
+// Different blur values per breakpoint
+ImageContext::make('thumbnail')
+ ->blur([
+ Breakpoint::Small->value => 5,
+ Breakpoint::Medium->value => 10,
+ Breakpoint::Large->value => 15,
+ Breakpoint::ExtraLarge->value => 20,
+ Breakpoint::DoubleExtraLarge->value => 25,
+ ]);
+
+// Per breakpoint
+ImageContext::make('thumbnail')
+ ->blurForBreakpoint(Breakpoint::Small, 5);
+
+// From a Breakpoint and up
+ImageContext::make('thumbnail')
+ ->blurFromBreakpoint(Breakpoint::Medium, 10);
+
+// Up till a Breakpoint
+ImageContext::make('thumbnail')
+ ->blurUpToBreakpoint(Breakpoint::Large, 15);
-ConversionDefinition::make()
- ->defaultWidth(100)
- ->defaultHeight(100);
+// Between two Breakpoints
+ImageContext::make('thumbnail')
+ ->blurBetweenBreakpoints(Breakpoint::Small, Breakpoint::Large, 10);
```
-#### Effects (optional)
+#### Greyscale
-You can apply effects to the conversion. You can define this by using the `Effects` entity.
+You can apply a greyscale effect to images in this context per `Breakpoint` in one of the following ways:
```php
-use OuterWeb\ImageLibrary\Entities\ConversionDefinition;
-use OuterWeb\ImageLibrary\Entities\Effects;
+// Single greyscale value for all breakpoints
+ImageContext::make('thumbnail')
+ ->greyscale(true); // or even ->grayscale(true)
+
+// Different greyscale values per breakpoint
+ImageContext::make('thumbnail')
+ ->greyscale([
+ Breakpoint::Small->value => false,
+ Breakpoint::Medium->value => true,
+ Breakpoint::Large->value => false,
+ Breakpoint::ExtraLarge->value => true,
+ Breakpoint::DoubleExtraLarge->value => false,
+ ]);
+
+// Per breakpoint
+ImageContext::make('thumbnail')
+ ->greyscaleForBreakpoint(Breakpoint::Small, false); // or even ->grayscaleForBreakpoint(Breakpoint::Small, false);
+
+// From a Breakpoint and up
+ImageContext::make('thumbnail')
+ ->greyscaleFromBreakpoint(Breakpoint::Medium, true); // or even ->grayscaleFromBreakpoint(Breakpoint::Medium, true);
-ConversionDefinition::make()
- ->effects(
- Effects::make()
- ->blur(10)
- ->pixelate(10)
- ->greyscale()
- ->sepia()
- ->sharpen(10)
- );
+// Up till a Breakpoint
+ImageContext::make('thumbnail')
+ ->greyscaleUpToBreakpoint(Breakpoint::Large, false); // or even ->grayscaleUpToBreakpoint(Breakpoint::Large, false);
+
+// Between two Breakpoints
+ImageContext::make('thumbnail')
+ ->greyscaleBetweenBreakpoints(Breakpoint::Small, Breakpoint::Large, true); // or even ->grayscaleBetweenBreakpoints(Breakpoint::Small, Breakpoint::Large, true);
```
-Or by providing an array.
+#### Sepia
+
+You can apply a sepia effect to images in this context per `Breakpoint` in one of the following ways:
```php
-use OuterWeb\ImageLibrary\Entities\ConversionDefinition;
-
-ConversionDefinition::make()
- ->effects([
- 'blur' => 10,
- 'pixelate' => 10,
- 'greyscale' => true,
- 'sepia' => true,
- 'sharpen' => 10
+// Single sepia value for all breakpoints
+ImageContext::make('thumbnail')
+ ->sepia(true);
+
+// Different sepia values per breakpoint
+ImageContext::make('thumbnail')
+ ->sepia([
+ Breakpoint::Small->value => false,
+ Breakpoint::Medium->value => true,
+ Breakpoint::Large->value => false,
+ Breakpoint::ExtraLarge->value => true,
+ Breakpoint::DoubleExtraLarge->value => false,
]);
+
+// Per breakpoint
+ImageContext::make('thumbnail')
+ ->sepiaForBreakpoint(Breakpoint::Small, false);
+
+// From a Breakpoint and up
+ImageContext::make('thumbnail')
+ ->sepiaFromBreakpoint(Breakpoint::Medium, true);
+
+// Up till a Breakpoint
+ImageContext::make('thumbnail')
+ ->sepiaUpToBreakpoint(Breakpoint::Large, false);
+
+// Between two Breakpoints
+ImageContext::make('thumbnail')
+ ->sepiaBetweenBreakpoints(Breakpoint::Small, Breakpoint::Large, true);
```
-#### Create sync (optional)
+### Preparing your model(s)
+
+### Using the HasImages Trait
-You can inform the image library to dispatch the generateConversion job synchronously. This is done to make the thumbnail generation conversion visible immediately after uploading an image when using a async queue driver.
+Add the `HasImages` trait to any Eloquent model that should support image attachments:
```php
-use OuterWeb\ImageLibrary\Entities\ConversionDefinition;
+createSync();
+namespace App\Models;
+
+use Illuminate\Database\Eloquent\Model;
+use Outerweb\ImageLibrary\Traits\HasImages;
+
+class Product extends Model
+{
+ use HasImages;
+
+ // Your model code...
+}
+```
+
+The trait provides:
+
+- **`images()`**: Default polymorphic relationship returning all images
+- **`attachImage()`**: Method to attach images with context validation
+- Automatic context validation and image replacement for single-image contexts
+
+### Using Custom Relationships
+
+For more control over image relationships, you can define custom morphic relationships alongside the `HasImages` trait. This allows you to create type-specific relationships for different image contexts.
+
+```php
+morphOne(Image::class, 'model')
+ ->where('context', 'featured');
+ }
+
+ /**
+ * Multiple gallery images relationship
+ */
+ public function galleryImages(): MorphMany
+ {
+ return $this->morphMany(Image::class, 'model')
+ ->where('context', 'gallery');
+ }
+
+ /**
+ * Images for a specific layout block (useful for page builders)
+ */
+ public function getLayoutBlockImages(int $blockId): MorphMany
+ {
+ return $this->images()
+ ->whereJsonContains('custom_properties->layout_block_id', $blockId);
+ }
+}
```
-### Uploading images
+### Custom Breakpoints
-You can upload images to the library by using the `ImageLibrary` facade.
+If the default breakpoints don't match your design system, you can create a custom breakpoint enum. This is useful when you need different screen size thresholds or additional breakpoints.
+
+#### Creating a Custom Breakpoint Enum
+
+First, create a custom enum that implements the `ConfiguresBreakpoints` contract:
```php
-use OuterWeb\ImageLibrary\Facades\ImageLibrary;
+sort(fn ($a, $b) => $a->getMinWidth() <=> $b->getMinWidth())
+ ->all();
+ }
+
+ public function getLabel(): string
+ {
+ return match ($this) {
+ self::Mobile => 'Mobile',
+ self::Tablet => 'Tablet',
+ self::Desktop => 'Desktop',
+ self::UltraWide => 'Ultra Wide',
+ };
+ }
+
+ public function getMinWidth(): int
+ {
+ return match ($this) {
+ self::Mobile => 320,
+ self::Tablet => 768,
+ self::Desktop => 1200,
+ self::UltraWide => 1920,
+ };
+ }
+
+ public function getMaxWidth(): ?int
+ {
+ $index = array_search($this, self::sortedCases(), true);
+ $next = self::sortedCases()[$index + 1] ?? null;
+
+ return $next ? $next->getMinWidth() - 1 : null;
+ }
+
+ public function getSlug(): string
+ {
+ return Str::of($this->value)
+ ->lower()
+ ->slug()
+ ->toString();
+ }
+}
+```
+
+#### Configuring the Custom Breakpoint Enum
+
+Update your `config/image-library.php` file to use your custom enum:
-$image = ImageLibrary::upload($request->file('image'));
+```php
+'enums' => [
+ 'breakpoint' => App\Enums\CustomBreakpoint::class,
+],
```
-By default, the image will be stored in the `public` disk. You can change this by setting the `default_disk` option in the config file or by passing it as the second argument to the `upload` method.
+### Disabling breakpoints
+
+If you application or specific context does not require image versions per breakpoint, you can disable breakpoints:
+
+#### Globally
```php
-use OuterWeb\ImageLibrary\Facades\ImageLibrary;
+'use_breakpoints' => false,
+```
+
+#### Per ImageContext
-$image = ImageLibrary::upload($request->file('image'), 's3');
+```php
+ImageContext::make('thumbnail')
+ ->useBreakpoints(false);
```
-You may also pass a title and alt text in the third argument.
+> ⚠️ **Caution:** Make sure to call `->useBreakpoints(false)` before any other methods that depend on breakpoints, such as `aspectRatio()`, `minWidth()`, `maxWidth()`, etc. Otherwise, you may encounter errors since those methods check if breakpoints are enabled or not.
+
+## Usage
+
+### Uploading an image
+
+Upload images from `UploadedFile` instances (typically from form submissions) to create `SourceImage` records:
+
+#### Basic Upload
```php
-use OuterWeb\ImageLibrary\Facades\ImageLibrary;
+use Outerweb\ImageLibrary\Facades\ImageLibrary;
-$image = ImageLibrary::upload($request->file('image'), 's3', [
- 'title' => 'My image',
- 'alt' => 'This is my image'
-]);
+// Basic upload using default settings
+$sourceImage = ImageLibrary::upload($request->file('image'));
+
+// The SourceImage is now stored and optimized, ready to be attached to models
```
-If you want those attributes to be translatable, we have directly integrated Spatie's `laravel-translatable` package. To enable this, you need to set the `spatie_translatable` option to `true` in the config file. After that, you can pass the translations in the third argument.
+#### Upload with Custom Attributes
```php
-use OuterWeb\ImageLibrary\Facades\ImageLibrary;
+// Upload to specific disk
+$sourceImage = ImageLibrary::upload($request->file('image'), [
+ 'disk' => 's3',
+]);
-$image = ImageLibrary::upload($request->file('image'), 's3', [
- 'title' => [
- 'en' => 'My image',
- 'nl' => 'Mijn afbeelding'
+// Upload with custom properties and metadata
+$sourceImage = ImageLibrary::upload($request->file('image'), [
+ 'disk' => 's3',
+ 'custom_properties' => [
+ 'photographer' => 'John Doe',
+ 'license' => 'Creative Commons',
+ 'shoot_date' => '2024-01-15',
+ 'camera_model' => 'Canon EOS R5'
],
- 'alt' => [
- 'en' => 'This is my image',
- 'nl' => 'Dit is mijn afbeelding'
- ]
]);
```
-When an image is uploaded, these things will happen:
+#### What Happens During Upload
-1. The image will be stored on the specified disk.
-2. If webp support is enabled, a webp version of the image will be generated and stored on the specified disk.
-3. If responsive image support is enabled, responsive images will be generated and stored on the specified disk.
-4. If webp support is enabled, a webp version of each responsive image will be generated and stored on the specified disk.
-5. A record will be created in the `images` table. You can use the Image model to interact with this record.
-6. For each defined Conversion, a record will be created in the `image_conversions` table.
-7. The conversion images will be generated and stored on the specified disk.
-8. If webp support is enabled, a webp version of the image conversion will be generated and stored on the specified disk.
-9. If responsive image support is enabled, responsive images for each conversion will be generated and stored on the specified disk.
-10. If webp support is enabled, a webp version of each responsive image for each conversion will be generated and stored on the specified disk.
+1. **Automatic optimization**: Images are processed using Spatie Image with your configured driver (GD/Imagick)
+2. **Metadata extraction**: Width, height, file size, and MIME type are automatically detected and stored
+3. **UUID generation**: A unique identifier is created for organized file storage
+4. **File organization**: Images are stored in a structured directory: `{base_path}/{uuid}/original.{extension}`
+5. **Database record**: A `SourceImage` model is created with all metadata
-### Rendering images
+### Attaching an image to your model
-You can render images by using the ` ` blade component.
+After uploading a `SourceImage`, attach it to your models using the context system:
-```blade
-
-```
+#### Basic Attachment
-This will render a responsive image with the `thumbnail` conversion.
+```php
+// Upload the source image
+$sourceImage = ImageLibrary::upload($request->file('image'));
-You can also render a ` ` blade component.
+// Get your model
+$product = Product::find(1);
-```blade
-
+// Attach with a context
+$image = $product->attachImage($sourceImage, [
+ 'context' => 'thumbnail'
+]);
+
+// The image is now attached and will be processed according to the context configuration
```
-This gives the browser the ability to choose the best image to download based on the device's screen size, resolution, density and supported image formats.
+#### Advanced Attachment Examples
+
+```php
+// Attach with multilingual alt text
+$image = $product->attachImage($sourceImage, [
+ 'context' => 'featured_image',
+ 'alt_text' => [
+ 'en' => 'Taylor Otwell driving his lamborghini',
+ 'nl' => 'Taylor Otwell rijdt in zijn lamborghini',
+ ]
+]);
+
+// Attach with custom properties and metadata
+$image = $product->attachImage($sourceImage, [
+ 'context' => 'gallery',
+ 'custom_properties' => [
+ 'photographer' => 'Jane Smith',
+ 'copyright' => '© 2024 Company Name',
+ 'location' => 'Swiss Alps',
+ 'camera_settings' => [
+ 'aperture' => 'f/2.8',
+ 'shutter_speed' => '1/500s',
+ 'iso' => 200
+ ]
+ ],
+ 'alt_text' => [
+ 'en' => 'Mountain landscape photography'
+ ]
+]);
+```
-#### Fallback image
+#### Attaching to a custom relationship
-You can provide a fallback image by using the `fallback` attribute.
+When using custom relationships, you can still use the `attachImage` method. You can specify the relationship to use:
-```blade
-
+```php
+$image = $product->attachImage($sourceImage, [
+ 'context' => 'featured'
+], 'featuredImage');
```
-This can be a string or another `Image` model.
+#### Context-Specific Behavior
-The fallback image will be rendered using the conversion defined in the `conversion` attribute.
+The `attachImage` method will replace the existing image if the context is not configured to allow multiple images. This ensures that single-image contexts always have only one associated image.
-#### Fallback conversion
+### Using your model image(s)
-You can provide a fallback conversion by using the `fallback-conversion` attribute.
+You can access your model's images through the `images` relationship or any custom relationships you've defined.
-```blade
-
+```php
+// Get all images
+$images = $product->images;
+
+// Query images by context
+$thumbnails = $product->images()
+ ->where('context', 'thumbnail')
+ ->get();
+
+// Query images with specific custom properties
+$landscapeImages = $product->images()
+ ->where('context', 'gallery')
+ ->whereJsonContains('custom_properties->layout_builder_block_id', $blockId)
+ ->get();
```
-Combining this with the `fallback` attribute, you can have different outcomes when the image and/or conversion are not available:
+### Rendering images
-- Defining a `fallback` attribute and a `fallback-conversion` attribute will render the fallback image using the fallback conversion if the fallback image is an `Image` model.
-- Only defining a `fallback` attribute will render the fallback image using the fallback conversion if the fallback image is an `Image` model.
-- Only defining a `fallback-conversion` attribute will render the image using the fallback conversion.
+You can render images in your views using the provided view component:
-### Linking images to models
+```blade
+
+```
-You may link images to your models by any means you like. The package does not provide a way to do this.
+This will render a `picture` element with the following:
-You are free to create:
+- a `source` element per `Breakpoint` with responsive image urls
+- a `source` element per `Breakpoint` for the WebP versions of the responsive image urls
+- an `img` element with the default image url, alt text, and any additional attributes you provide
-- A polymorphic relationship
-- A many-to-many relationship
-- A one-to-many relationship
-- ...
+Make sure you added the script component to the `` of your layout:
-### The image model
+```blade
+
+```
-The package provides an `Image` model that you can use to interact with the images table.
+This script will set all `sizes` attributes of the picture elements automatically when:
-You may change the model by setting the `models.image` option in the config file.
+- The page is loaded
+- The viewport is resized
+- The picture element is added to the viewport
+- The picture element width changes
-It saves the following data in the database:
+## (Re)generating images
-- `id` : The primary key
-- `uuid` : Used as directory name when storing the image on disk
-- `disk` : The disk on which the image is stored
-- `mime_type` : The mime type of the image
-- `file_extension` : The file extension of the image
-- `width` : The width of the image in pixels
-- `height` : The height of the image in pixels
-- `size` : The size of the image in bytes
-- `title` : The title of the image (stored as json to optionally support translations)
-- `alt` : The alt text of the image (stored as json to optionally support translations)
-- `created_at` : The creation date
-- `updated_at` : The last update date
+You can (re)generate images using the following artisan command:
-### The image conversions model
+```bash
+php artisan image-library:generate
+```
-The package provides an `ImageConversion` model that you can use to interact with the image_conversions table.
+This will (re)generate all images files for all `image` records in the database based on their associated `ImageContext` configuration.
-You may change the model by setting the `models.image_conversion` option in the config file.
+You can also (re)generate image files for a specific image:
-It saves the following data in the database:
+```bash
+php artisan image-library:generate {id}
+```
-- `id` : The primary key
-- `image_id` : The id of the image
-- `conversion_name` : The name of the conversion
-- `conversion_md5` : The md5 hash of the conversion to check if a conversion images needs to be re-generated after changing the ConversionDefinition
-- `width` : The width of the conversion image in pixels
-- `height` : The height of the conversion image in pixels
-- `size` : The size of the conversion image in bytes
-- `x` : The x coordinate to crop the image at
-- `y` : The y coordinate to crop the image at
-- `rotate` : The rotation of the image in degrees (0, 90, 180, 270)
-- `scale_x` : The x scale of the image
-- `scale_y` : The y scale of the image
-- `created_at` : The creation date
-- `updated_at` : The last update date
+Or for multiple images:
+
+```bash
+php artisan image-library:generate {id1} {id2} {id3}
+```
## Upgrading
-Please see [UPGRADING](UPGRADING.md) for information on upgrading to a new major version.
+### From v2.x to v3.0
+
+This is a major version with breaking changes. See the [Upgrade guide](./docs/upgrade-to-v3.md) for detailed instructions.
## Changelog
Please see [CHANGELOG](CHANGELOG.md) for more information on what has changed recently.
-## Credits
-
-- [Simon Broekaert](https://github.com/SimonBroekaert)
-- [All Contributors](../../contributors)
-
## License
The MIT License (MIT). Please see [License File](LICENSE.md) for more information.
-
-```
-
-```
diff --git a/UPGRADING.md b/UPGRADING.md
deleted file mode 100644
index 05a0ba9..0000000
--- a/UPGRADING.md
+++ /dev/null
@@ -1,25 +0,0 @@
-# Upgrading
-
-All upgrade guides for upgrading to a new major version can be found here.
-
-## Upgrading to 2.0.0
-
-Upgrading to version 2.0.0 of the image library package brings some breaking changes. Please read this guide carefully before upgrading.
-
-#### Changed the blade component prefix
-
-We have added a blade component prefix to the blade components of the image library package. This is to prevent conflicts with other blade components. You will have to change the prefix of the blade components in your views.
-
-```html
- // instead of
-
- // instead of
-```
-
-#### Changed the javascript code that dynamically sets the image width as the sizes attribute of the image tag
-
-Because the way the image width is set as the sizes attribute of the image tag has changed, you will have to add the new script blade component to your layout. This replaces the old inline onload attribute on the picture and img tags. Including this script more than once will not cause any problems or performance issues.
-
-```html
-
-```
diff --git a/composer.json b/composer.json
index 7d96bd4..b55ce1d 100644
--- a/composer.json
+++ b/composer.json
@@ -1,42 +1,83 @@
{
- "name": "outerweb/image-library",
- "description": "Adds ways to store and link images to your models.",
- "homepage": "https://github.com/outer-web/image-library",
- "license": "MIT",
- "authors": [
- {
- "name": "Outerweb",
- "email": "info@outerweb.be"
- }
- ],
- "require": {
- "php": "^8.0",
- "laravel/framework": "^10.0|^11.0|^12.0",
- "spatie/image": "^3.3",
- "spatie/laravel-package-tools": "^1.16",
- "spatie/laravel-translatable": "^6.5"
- },
- "autoload": {
- "psr-4": {
- "Outerweb\\ImageLibrary\\": "src/"
+ "name": "outerweb/image-library",
+ "description": "Store and link files to your models",
+ "keywords": [
+ "Outerweb",
+ "laravel",
+ "image-library"
+ ],
+ "homepage": "https://github.com/outer-web/image-library",
+ "license": "MIT",
+ "authors": [
+ {
+ "name": "Outerweb",
+ "email": "info@outerweb.be",
+ "role": "Developer"
+ }
+ ],
+ "require": {
+ "php": "^8.4",
+ "illuminate/contracts": "^11.0||^12.0",
+ "illuminate/database": "^11.0||^12.0",
+ "illuminate/support": "^11.0||^12.0",
+ "nesbot/carbon": "^3.10",
+ "outerweb/enum-helpers": "*",
+ "outerweb/filament-translatable-fields": "^4.0",
+ "spatie/eloquent-sortable": "^4.5",
+ "spatie/image": "^3.8",
+ "spatie/laravel-package-tools": "^1.16",
+ "spatie/laravel-translatable": "^6.11",
+ "spatie/temporary-directory": "^2.3"
},
- "files": [
- "src\\Helpers\\helpers.php"
- ]
- },
- "extra": {
- "laravel": {
- "providers": [
- "Outerweb\\ImageLibrary\\ImageLibraryServiceProvider"
- ],
- "aliases": {
- "ImageLibrary": "Outerweb\\ImageLibrary\\Facades\\ImageLibrary"
- }
- }
- },
- "config": {
- "sort-packages": true
- },
- "minimum-stability": "dev",
- "prefer-stable": true
+ "require-dev": {
+ "laravel/pint": "^1.14",
+ "nunomaduro/collision": "^8.8",
+ "larastan/larastan": "^3.0",
+ "orchestra/testbench": "^10.0.0||^9.0.0",
+ "pestphp/pest": "^4.0",
+ "pestphp/pest-plugin-arch": "^4.0",
+ "pestphp/pest-plugin-laravel": "^4.0",
+ "phpstan/extension-installer": "^1.4",
+ "phpstan/phpstan-deprecation-rules": "^2.0",
+ "phpstan/phpstan-phpunit": "^2.0"
+ },
+ "autoload": {
+ "psr-4": {
+ "Outerweb\\ImageLibrary\\": "src/",
+ "Outerweb\\ImageLibrary\\Database\\Factories\\": "database/factories/"
+ }
+ },
+ "autoload-dev": {
+ "psr-4": {
+ "Outerweb\\ImageLibrary\\Tests\\": "tests/",
+ "Workbench\\App\\": "workbench/app/"
+ }
+ },
+ "scripts": {
+ "post-autoload-dump": "@composer run prepare",
+ "prepare": "@php vendor/bin/testbench package:discover --ansi",
+ "analyse": "vendor/bin/phpstan analyse",
+ "test": "vendor/bin/pest",
+ "test-coverage": "vendor/bin/pest --coverage",
+ "format": "vendor/bin/pint"
+ },
+ "config": {
+ "sort-packages": true,
+ "allow-plugins": {
+ "pestphp/pest-plugin": true,
+ "phpstan/extension-installer": true
+ }
+ },
+ "extra": {
+ "laravel": {
+ "providers": [
+ "Outerweb\\ImageLibrary\\ImageLibraryServiceProvider"
+ ],
+ "aliases": {
+ "ImageLibrary": "Outerweb\\ImageLibrary\\Facades\\ImageLibrary"
+ }
+ }
+ },
+ "minimum-stability": "dev",
+ "prefer-stable": true
}
diff --git a/config/image-library.php b/config/image-library.php
index 9a0f3f0..d5ea68a 100644
--- a/config/image-library.php
+++ b/config/image-library.php
@@ -1,74 +1,53 @@
[
- 'image' => \Outerweb\ImageLibrary\Models\Image::class,
- 'image_conversion' => \Outerweb\ImageLibrary\Models\ImageConversion::class,
- ],
-
- /**
- * The image driver to use.
- * Supported: "gd", "imagick"
- */
- 'image_driver' => 'gd',
-
- /**
- * The maximum file size for images.
- */
- 'max_file_size' => '25MB',
+declare(strict_types=1);
- /**
- * The delay to use for the responsive sizes script
- * that runs in the scripts blade component. This
- * is because the responsive sizes initialization
- * sometimes runs before the images is visible
- * (e.g. in a modal that gets opened).
- */
- 'blade_script_init_delay' => 300,
+use Outerweb\ImageLibrary\Enums\Breakpoint;
+use Outerweb\ImageLibrary\Models\Image;
+use Outerweb\ImageLibrary\Models\SourceImage;
+use Spatie\Image\Enums\CropPosition;
+use Spatie\Image\Enums\ImageDriver;
- /**
- * The responsive variants options:
- * - min_width: The minimum width for the responsive variants.
- * - min_height: The minimum height for the responsive variants.
- * - factor: The factor to make each responsive iteration smaller.
- */
- 'responsive_variants' => [
- 'min_width' => 50,
- 'min_height' => 50,
- 'factor' => 0.7,
+// config for Outerweb/ImageLibrary
+return [
+ 'defaults' => [
+ 'crop_position' => CropPosition::Center,
+ 'disk' => 'public',
+ 'temporary_url' => [
+ 'default' => [
+ 'enabled' => false,
+ 'expiration_minutes' => 5,
+ ],
+ 's3' => [
+ 'enabled' => true,
+ ],
+ ],
],
-
- /**
- * The default disk to use for images.
- */
- 'default_disk' => 'public',
-
- /**
- * Whether to use the spatie translatable
- * for title and alt attributes.
- */
- 'spatie_translatable' => false,
-
- /**
- * The support options:
- * - webp: Whether or not to generate webp images.
- * - responsive_variants: Whether or not to generate responsive variants.
- * - mime_types: The supported mime types.
- */
- 'support' => [
+ 'enums' => [
+ 'breakpoint' => Breakpoint::class,
+ ],
+ 'use_breakpoints' => true,
+ 'generate' => [
'webp' => true,
- 'responsive_variants' => true,
- 'mime_types' => [
- 'image/apng',
- 'image/avif',
- 'image/gif',
- 'image/jpeg',
- 'image/png',
- 'image/svg+xml',
- 'image/webp',
- ],
+ 'responsive_versions' => true,
+ ],
+ 'models' => [
+ 'image' => Image::class,
+ 'source_image' => SourceImage::class,
+ ],
+ 'paths' => [
+ 'base' => 'image-library',
+ ],
+ 'queue' => [
+ 'connection' => env('QUEUE_CONNECTION', 'sync'),
+ 'queue' => 'default',
+ ],
+ 'spatie_image' => [
+ 'driver' => ImageDriver::Imagick,
+ ],
+ 'responsive_images' => [
+ 'width_difference_threshold' => 100,
+ 'size_step_multiplier' => 0.7,
+ 'min_width' => 100,
],
];
diff --git a/database/factories/ImageFactory.php b/database/factories/ImageFactory.php
new file mode 100644
index 0000000..555df17
--- /dev/null
+++ b/database/factories/ImageFactory.php
@@ -0,0 +1,73 @@
+
+ */
+class ImageFactory extends Factory
+{
+ public function definition(): array
+ {
+ return [
+ 'source_image_id' => ImageLibrary::getSourceImageModel()::factory(),
+ 'disk' => function (array $attributes) {
+ return ImageLibrary::getSourceImageModel()::find($attributes['source_image_id'])->disk ?? ImageLibrary::getDefaultDisk();
+ },
+ 'context' => fake()->randomElement(ImageLibrary::getImageContexts()),
+ 'crop_data' => function (array $attributes): array {
+ $sourceImage = ImageLibrary::getSourceImageModel()::find($attributes['source_image_id']);
+
+ return collect(ImageLibrary::getBreakpointEnum()::sortedCases())->mapWithKeys(function ($breakpoint) use ($sourceImage): array {
+ $maxWidth = $sourceImage->width ?? 4000;
+ $maxHeight = $sourceImage->height ?? 4000;
+
+ $width = fake()->numberBetween(10, $maxWidth);
+ $height = fake()->numberBetween(10, $maxHeight);
+
+ return [$breakpoint->value => new CropData(
+ x: fake()->numberBetween(0, max(0, $maxWidth - $width)),
+ y: fake()->numberBetween(0, max(0, $maxHeight - $height)),
+ width: $width,
+ height: $height,
+ rotate: fake()->randomElement([0, 90, 180, 270]),
+ scaleX: fake()->randomElement([1, -1]),
+ scaleY: fake()->randomElement([1, -1]),
+ )];
+ })
+ ->all();
+ },
+ 'alt_text' => fake()->boolean() ? collect(ImageLibrary::getSupportedLocales())
+ ->mapWithKeys(fn (string $locale) => [$locale => fake()->sentence()])
+ ->all() : null,
+ ];
+ }
+
+ public function forModel(Model $model): self
+ {
+ return $this->state(function () use ($model) {
+ return [
+ 'model_type' => $model::class,
+ 'model_id' => $model->getKey(),
+ ];
+ });
+ }
+
+ public function forContext(ImageContext $context): self
+ {
+ return $this->state(function () use ($context) {
+ return [
+ 'context' => $context,
+ ];
+ });
+ }
+}
diff --git a/database/factories/SourceImageFactory.php b/database/factories/SourceImageFactory.php
new file mode 100644
index 0000000..31eb31e
--- /dev/null
+++ b/database/factories/SourceImageFactory.php
@@ -0,0 +1,31 @@
+
+ */
+class SourceImageFactory extends Factory
+{
+ public function definition(): array
+ {
+ return [
+ 'disk' => ImageLibrary::getDefaultDisk(),
+ 'name' => fake()->word(),
+ 'extension' => fake()->randomElement(['jpg', 'png', 'webp']),
+ 'mime_type' => fake()->mimeType(),
+ 'width' => fake()->numberBetween(100, 4000),
+ 'height' => fake()->numberBetween(100, 4000),
+ 'size' => fake()->numberBetween(1000, 1000000),
+ 'alt_text' => fake()->boolean() ? collect(ImageLibrary::getSupportedLocales())
+ ->mapWithKeys(fn (string $locale) => [$locale => fake()->sentence()])
+ ->all() : null,
+ ];
+ }
+}
diff --git a/database/migrations/create_image_conversions_table.php.stub b/database/migrations/create_image_conversions_table.php.stub
deleted file mode 100644
index 99ddfa1..0000000
--- a/database/migrations/create_image_conversions_table.php.stub
+++ /dev/null
@@ -1,38 +0,0 @@
-id();
- $table->foreignId('image_id')
- ->constrained()
- ->cascadeOnDelete();
- $table->string('conversion_name');
- $table->string('conversion_md5');
- $table->unsignedInteger('width');
- $table->unsignedInteger('height');
- $table->unsignedInteger('size');
- $table->unsignedInteger('x')
- ->nullable();
- $table->unsignedInteger('y')
- ->nullable();
- $table->unsignedInteger('rotate')
- ->default(0);
- $table->tinyInteger('scale_x')
- ->default(1);
- $table->tinyInteger('scale_y')
- ->default(1);
- $table->timestamps();
- });
- }
-
- public function down(): void
- {
- Schema::dropIfExists('image_conversions');
- }
-};
diff --git a/database/migrations/create_images_table.php.stub b/database/migrations/create_images_table.php.stub
index 84df58f..b100a77 100644
--- a/database/migrations/create_images_table.php.stub
+++ b/database/migrations/create_images_table.php.stub
@@ -1,31 +1,43 @@
id();
$table->uuid()
- ->unique();
+ ->unique()
+ ->index();
+ $table->morphs('model');
+ $table->foreignId('source_image_id')
+ ->constrained()
+ ->cascadeOnDelete();
+ $table->string('context');
+ $table->string('context_configuration_hash');
+ $table->unsignedInteger('sort_order');
$table->string('disk');
- $table->string('mime_type');
- $table->string('file_extension');
- $table->unsignedInteger('width');
- $table->unsignedInteger('height');
- $table->unsignedInteger('size');
- $table->json('title')
+ $table->json('crop_data')
+ ->nullable();
+ $table->json('alt_text')
->nullable();
- $table->json('alt')
+ $table->json('custom_properties')
->nullable();
$table->timestamps();
+
+ $table->index(['context', 'context_configuration_hash']);
+ $table->index(['context', 'sort_order']);
+ $table->index('created_at');
});
}
- public function down(): void
+ public function down()
{
Schema::dropIfExists('images');
}
diff --git a/database/migrations/create_source_images_table.php.stub b/database/migrations/create_source_images_table.php.stub
new file mode 100644
index 0000000..6de76db
--- /dev/null
+++ b/database/migrations/create_source_images_table.php.stub
@@ -0,0 +1,42 @@
+id();
+ $table->uuid()
+ ->unique()
+ ->index();
+ $table->string('disk');
+ $table->string('name')
+ ->index();
+ $table->string('extension');
+ $table->string('mime_type')
+ ->index();
+ $table->unsignedInteger('width');
+ $table->unsignedInteger('height');
+ $table->unsignedBigInteger('size');
+ $table->json('alt_text')
+ ->nullable();
+ $table->json('custom_properties')
+ ->nullable();
+ $table->timestamps();
+
+ $table->index(['created_at', 'disk']);
+ $table->index(['name', 'disk']);
+ });
+ }
+
+ public function down()
+ {
+ Schema::dropIfExists('source_images');
+ }
+};
diff --git a/database/migrations/upgrade/cleanup_image_library_upgrade.php.stub b/database/migrations/upgrade/cleanup_image_library_upgrade.php.stub
new file mode 100644
index 0000000..3b2dac7
--- /dev/null
+++ b/database/migrations/upgrade/cleanup_image_library_upgrade.php.stub
@@ -0,0 +1,31 @@
+cursor() as $oldRecord) {
+ $path = "{$oldRecord->uuid}/original.{$oldRecord->file_extension}";
+
+ Storage::disk($oldRecord->disk)->delete($path);
+ }
+
+ Schema::drop('tmp_images');
+ }
+
+ Schema::dropIfExists('image_conversions');
+ }
+
+ public function down()
+ {
+ // Sorry, this migration is not reversible.
+ }
+};
diff --git a/database/migrations/upgrade/post_image_library_upgrade.php.stub b/database/migrations/upgrade/post_image_library_upgrade.php.stub
new file mode 100644
index 0000000..8845ce9
--- /dev/null
+++ b/database/migrations/upgrade/post_image_library_upgrade.php.stub
@@ -0,0 +1,38 @@
+cursor() as $oldRecord) {
+ $path = "{$oldRecord->uuid}/original.{$oldRecord->file_extension}";
+
+ $tmpFile = sys_get_temp_dir().'/'.basename($path);
+
+ $file = Storage::disk($oldRecord->disk)->get($path);
+
+ file_put_contents($tmpFile, $file);
+
+ $file = new UploadedFile($tmpFile, basename($path));
+
+ ImageLibrary::upload($file, [
+ 'uuid' => $oldRecord->uuid,
+ ]);
+ }
+ }
+
+ public function down()
+ {
+ //
+ }
+};
diff --git a/database/migrations/upgrade/pre_image_library_upgrade.php.stub b/database/migrations/upgrade/pre_image_library_upgrade.php.stub
new file mode 100644
index 0000000..82654ed
--- /dev/null
+++ b/database/migrations/upgrade/pre_image_library_upgrade.php.stub
@@ -0,0 +1,24 @@
+ ⚠️ **Caution:** This is a complete rewrite of the package and logic. Please review the changes carefully and back up your database and filesystem before proceeding.
+
+### Prerequisites
+
+Before starting the upgrade process:
+
+1. **Create a full backup** of your database and file storage
+2. **Test the upgrade process** in a staging environment first
+3. **Review your custom code** that uses the image library to understand required changes
+
+### Step 1: Update the image-library config file
+
+The config file has been completely rewritten. You must re-publish it:
+
+```bash
+php artisan vendor:publish --tag=image-library-config --force
+```
+
+### Step 2: Run the upgrade command
+
+The package includes an automated upgrade command to migrate your existing data:
+
+```bash
+php artisan image-library:upgrade
+```
+
+The upgrade command will walk you through the following 9 steps:
+
+1. **Check if an upgrade is needed** - Looks for existing images table migrations
+2. **Create source_images table migration** - New table for storing source image files
+3. **Create new images table migration** - Updated schema for the images table
+4. **Create pre-upgrade migration** - Renames existing `images` table to `tmp_images` (see [Pre-upgrade Migration Details](#pre-upgrade-migration-details))
+5. **Create post-upgrade migration** - Migrates data from old to new structure (see [Post-upgrade Migration Details](#post-upgrade-migration-details))
+6. **Run migrations** - Prompts to execute the new migrations
+7. **Manual data migration prompt** - Pauses for you to migrate custom model relationships (see [Migrating Custom Model Relationships](#migrating-custom-model-relationships))
+8. **Cleanup old data** - Offers to create cleanup migration (see [Cleanup Migration Details](#cleanup-migration-details))
+9. **Run cleanup migration** - Optionally executes the cleanup to remove old data
+
+## Detailed Migration Information
+
+### Pre-upgrade Migration Details
+
+The pre-upgrade migration (`pre_image_library_upgrade.php`) performs a simple but critical step:
+
+- **Renames the existing `images` table to `tmp_images`**
+- This preserves all your existing image data during the upgrade process
+- The old data remains accessible for mapping to the new structure
+
+### Post-upgrade Migration Details
+
+The post-upgrade migration (`post_image_library_upgrade.php`) handles the complex data migration:
+
+- **Reads each record from the `tmp_images` table**
+- **Creates corresponding `SourceImage` records** with the same UUID
+- **Uploads image files to the new storage structure** while preserving UUIDs
+- **Maintains file integrity** by copying files from old to new locations
+
+This migration ensures that:
+
+- All existing image files are preserved
+- UUIDs remain consistent for data mapping
+- Files are properly structured in the new system
+
+### Migrating Custom Model Relationships
+
+After the automated migration completes, you need to update your models and relationships manually.
+
+Follow the configuration steps in the [README](README.md).
+
+#### Migrate existing image relationships
+
+Use the provided mapping query to connect old images to new structure:
+
+```php
+// Get the ID mapping between old and new images
+$mapping = DB::table('tmp_images')
+ ->join('source_images', 'tmp_images.uuid', '=', 'source_images.uuid')
+ ->pluck('source_images.id', 'tmp_images.id');
+
+// For each of your models, attach the images using the new system
+foreach (YourModel::cursor() as $model) {
+ // Replace 'old_image_id' with your actual column name
+ $oldImageId = $model->old_image_id;
+
+ if (isset($mapping[$oldImageId])) {
+ $sourceImage = SourceImage::find($mapping[$oldImageId]);
+
+ $model->attachImage($sourceImage, [
+ 'context' => 'your-context-key', // Replace with your context
+ // Add any other attributes you need
+ ]);
+ }
+}
+```
+
+### Cleanup Migration Details
+
+The cleanup migration (`cleanup_image_library_upgrade.php`) performs final cleanup:
+
+- **Deletes old image files** from storage (`{uuid}/original.{extension}` format)
+- **Drops the `tmp_images` table** (your backup of the original data)
+- **Drops the `image_conversions` table** if it exists from the old system
+
+> ⚠️ **Warning:** This migration is **not reversible**. Ensure everything works correctly before running cleanup.
+
+#### Manual verification before cleanup
+
+Before running the cleanup, verify your migration was successful:
+
+1. Check that all your models can access their images correctly
+2. Verify image display in your application works as expected
+3. Test image upload and manipulation functionality
diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon
new file mode 100644
index 0000000..e69de29
diff --git a/phpstan.neon.dist b/phpstan.neon.dist
new file mode 100644
index 0000000..7835db3
--- /dev/null
+++ b/phpstan.neon.dist
@@ -0,0 +1,15 @@
+includes:
+ - phpstan-baseline.neon
+
+parameters:
+ level: 5
+ paths:
+ - src
+ - database
+ tmpDir: build/phpstan
+ checkOctaneCompatibility: true
+ checkModelProperties: true
+ ignoreErrors:
+ -
+ identifiers:
+ - trait.unused
diff --git a/phpunit.xml.dist b/phpunit.xml.dist
new file mode 100644
index 0000000..feda7de
--- /dev/null
+++ b/phpunit.xml.dist
@@ -0,0 +1,31 @@
+
+
+
+
+ tests
+
+
+
+
+
+
+
+ ./src
+
+
+
diff --git a/pint.json b/pint.json
new file mode 100644
index 0000000..c5175a6
--- /dev/null
+++ b/pint.json
@@ -0,0 +1,56 @@
+{
+ "preset": "laravel",
+ "notPath": ["tests/TestCase.php"],
+ "rules": {
+ "array_push": true,
+ "backtick_to_shell_exec": true,
+ "date_time_immutable": true,
+ "declare_strict_types": true,
+ "lowercase_keywords": true,
+ "lowercase_static_reference": true,
+ "fully_qualified_strict_types": true,
+ "global_namespace_import": {
+ "import_classes": true,
+ "import_constants": true,
+ "import_functions": true
+ },
+ "mb_str_functions": true,
+ "modernize_types_casting": true,
+ "new_with_parentheses": false,
+ "no_superfluous_elseif": true,
+ "no_useless_else": true,
+ "no_multiple_statements_per_line": true,
+ "ordered_class_elements": {
+ "order": [
+ "use_trait",
+ "case",
+ "constant",
+ "constant_public",
+ "constant_protected",
+ "constant_private",
+ "property_public",
+ "property_protected",
+ "property_private",
+ "construct",
+ "destruct",
+ "magic",
+ "phpunit",
+ "method_abstract",
+ "method_public_static",
+ "method_public",
+ "method_protected_static",
+ "method_protected",
+ "method_private_static",
+ "method_private"
+ ],
+ "sort_algorithm": "none"
+ },
+ "ordered_interfaces": true,
+ "ordered_traits": true,
+ "protected_to_private": true,
+ "self_accessor": true,
+ "self_static_accessor": true,
+ "strict_comparison": true,
+ "visibility_required": true
+ }
+}
diff --git a/resources/fake/fake.csv b/resources/fake/fake.csv
new file mode 100644
index 0000000..599acbf
--- /dev/null
+++ b/resources/fake/fake.csv
@@ -0,0 +1 @@
+Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit
\ No newline at end of file
diff --git a/resources/fake/fake.doc b/resources/fake/fake.doc
new file mode 100644
index 0000000..599acbf
--- /dev/null
+++ b/resources/fake/fake.doc
@@ -0,0 +1 @@
+Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit
\ No newline at end of file
diff --git a/resources/fake/fake.exe b/resources/fake/fake.exe
new file mode 100644
index 0000000..c45ab53
Binary files /dev/null and b/resources/fake/fake.exe differ
diff --git a/resources/fake/fake.jpeg b/resources/fake/fake.jpeg
new file mode 100644
index 0000000..0a7d531
Binary files /dev/null and b/resources/fake/fake.jpeg differ
diff --git a/resources/fake/fake.jpg b/resources/fake/fake.jpg
new file mode 100644
index 0000000..e01c94d
Binary files /dev/null and b/resources/fake/fake.jpg differ
diff --git a/resources/fake/fake.json b/resources/fake/fake.json
new file mode 100644
index 0000000..0198cb5
--- /dev/null
+++ b/resources/fake/fake.json
@@ -0,0 +1 @@
+{"message":"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit"}
\ No newline at end of file
diff --git a/resources/fake/fake.md b/resources/fake/fake.md
new file mode 100644
index 0000000..0fec534
--- /dev/null
+++ b/resources/fake/fake.md
@@ -0,0 +1,3 @@
+# Fake File
+
+Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit
\ No newline at end of file
diff --git a/resources/fake/fake.pdf b/resources/fake/fake.pdf
new file mode 100644
index 0000000..599acbf
Binary files /dev/null and b/resources/fake/fake.pdf differ
diff --git a/resources/fake/fake.png b/resources/fake/fake.png
new file mode 100644
index 0000000..933bc62
Binary files /dev/null and b/resources/fake/fake.png differ
diff --git a/resources/fake/fake.txt b/resources/fake/fake.txt
new file mode 100644
index 0000000..599acbf
--- /dev/null
+++ b/resources/fake/fake.txt
@@ -0,0 +1 @@
+Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit
\ No newline at end of file
diff --git a/resources/fake/fake.webp b/resources/fake/fake.webp
new file mode 100644
index 0000000..b139af1
Binary files /dev/null and b/resources/fake/fake.webp differ
diff --git a/resources/fake/fake.xlsx b/resources/fake/fake.xlsx
new file mode 100644
index 0000000..599acbf
--- /dev/null
+++ b/resources/fake/fake.xlsx
@@ -0,0 +1 @@
+Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit
\ No newline at end of file
diff --git a/resources/fake/fake.xml b/resources/fake/fake.xml
new file mode 100644
index 0000000..77d2e61
--- /dev/null
+++ b/resources/fake/fake.xml
@@ -0,0 +1 @@
+Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit
\ No newline at end of file
diff --git a/resources/fake/fake.yaml b/resources/fake/fake.yaml
new file mode 100644
index 0000000..5dc4650
--- /dev/null
+++ b/resources/fake/fake.yaml
@@ -0,0 +1,2 @@
+message: |
+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit
\ No newline at end of file
diff --git a/resources/fake/fake.zip b/resources/fake/fake.zip
new file mode 100644
index 0000000..5bb740e
Binary files /dev/null and b/resources/fake/fake.zip differ
diff --git a/resources/lang/af/translations.php b/resources/lang/af/translations.php
new file mode 100644
index 0000000..901ff70
--- /dev/null
+++ b/resources/lang/af/translations.php
@@ -0,0 +1,14 @@
+ [
+ 'xs' => 'Ekstra Klein',
+ 'sm' => 'Klein',
+ 'md' => 'Medium',
+ 'lg' => 'Groot',
+ 'xl' => 'Ekstra Groot',
+ '2xl' => 'Dubbel Ekstra Groot',
+ ],
+];
diff --git a/resources/lang/ak/translations.php b/resources/lang/ak/translations.php
new file mode 100644
index 0000000..32329d8
--- /dev/null
+++ b/resources/lang/ak/translations.php
@@ -0,0 +1,14 @@
+ [
+ 'xs' => 'Ketewa',
+ 'sm' => 'Ketewa',
+ 'md' => 'Mfimfini',
+ 'lg' => 'Kɛse',
+ 'xl' => 'Kɛse Papa',
+ '2xl' => 'Kɛse Papa Mmienu',
+ ],
+];
diff --git a/resources/lang/am/translations.php b/resources/lang/am/translations.php
new file mode 100644
index 0000000..ac768f4
--- /dev/null
+++ b/resources/lang/am/translations.php
@@ -0,0 +1,14 @@
+ [
+ 'xs' => 'ተጨማሪ ትንሽ',
+ 'sm' => 'ትንሽ',
+ 'md' => 'መካከለኛ',
+ 'lg' => 'ትልቅ',
+ 'xl' => 'ተጨማሪ ትልቅ',
+ '2xl' => 'ድርብ ተጨማሪ ትልቅ',
+ ],
+];
diff --git a/resources/lang/ar/translations.php b/resources/lang/ar/translations.php
new file mode 100644
index 0000000..ef0a98c
--- /dev/null
+++ b/resources/lang/ar/translations.php
@@ -0,0 +1,14 @@
+ [
+ 'xs' => 'صغير جداً',
+ 'sm' => 'صغير',
+ 'md' => 'متوسط',
+ 'lg' => 'كبير',
+ 'xl' => 'كبير جداً',
+ '2xl' => 'كبير جداً مضاعف',
+ ],
+];
diff --git a/resources/lang/as/translations.php b/resources/lang/as/translations.php
new file mode 100644
index 0000000..db50eb9
--- /dev/null
+++ b/resources/lang/as/translations.php
@@ -0,0 +1,14 @@
+ [
+ 'xs' => 'অতিৰিক্ত সৰু',
+ 'sm' => 'সৰু',
+ 'md' => 'মধ্যম',
+ 'lg' => 'ডাঙৰ',
+ 'xl' => 'অতিৰিক্ত ডাঙৰ',
+ '2xl' => 'দুগুণ অতিৰিক্ত ডাঙৰ',
+ ],
+];
diff --git a/resources/lang/az/translations.php b/resources/lang/az/translations.php
new file mode 100644
index 0000000..ed0a8a6
--- /dev/null
+++ b/resources/lang/az/translations.php
@@ -0,0 +1,14 @@
+ [
+ 'xs' => 'Çox Kiçik',
+ 'sm' => 'Kiçik',
+ 'md' => 'Orta',
+ 'lg' => 'Böyük',
+ 'xl' => 'Çox Böyük',
+ '2xl' => 'İkiqat Böyük',
+ ],
+];
diff --git a/resources/lang/be/translations.php b/resources/lang/be/translations.php
new file mode 100644
index 0000000..0901419
--- /dev/null
+++ b/resources/lang/be/translations.php
@@ -0,0 +1,14 @@
+ [
+ 'xs' => 'Вельмі малы',
+ 'sm' => 'Малы',
+ 'md' => 'Сярэдні',
+ 'lg' => 'Вялікі',
+ 'xl' => 'Вельмі вялікі',
+ '2xl' => 'Падвойны вялікі',
+ ],
+];
diff --git a/resources/lang/bg/translations.php b/resources/lang/bg/translations.php
new file mode 100644
index 0000000..128d079
--- /dev/null
+++ b/resources/lang/bg/translations.php
@@ -0,0 +1,14 @@
+ [
+ 'xs' => 'Много малък',
+ 'sm' => 'Малък',
+ 'md' => 'Среден',
+ 'lg' => 'Голям',
+ 'xl' => 'Много голям',
+ '2xl' => 'Двойно голям',
+ ],
+];
diff --git a/resources/lang/bho/translations.php b/resources/lang/bho/translations.php
new file mode 100644
index 0000000..6dd7c22
--- /dev/null
+++ b/resources/lang/bho/translations.php
@@ -0,0 +1,14 @@
+ [
+ 'xs' => 'बहुत छोट',
+ 'sm' => 'छोट',
+ 'md' => 'मध्यम',
+ 'lg' => 'बड़',
+ 'xl' => 'बहुत बड़',
+ '2xl' => 'दुगुना बड़',
+ ],
+];
diff --git a/resources/lang/bm/translations.php b/resources/lang/bm/translations.php
new file mode 100644
index 0000000..022700f
--- /dev/null
+++ b/resources/lang/bm/translations.php
@@ -0,0 +1,14 @@
+ [
+ 'xs' => 'Fitinin',
+ 'sm' => 'Dògoroba',
+ 'md' => 'Cèmancè',
+ 'lg' => 'Ba',
+ 'xl' => 'Ba kosɛbɛ',
+ '2xl' => 'Ba fla',
+ ],
+];
diff --git a/resources/lang/bn/translations.php b/resources/lang/bn/translations.php
new file mode 100644
index 0000000..41cca6e
--- /dev/null
+++ b/resources/lang/bn/translations.php
@@ -0,0 +1,14 @@
+ [
+ 'xs' => 'অতিরিক্ত ছোট',
+ 'sm' => 'ছোট',
+ 'md' => 'মাঝারি',
+ 'lg' => 'বড়',
+ 'xl' => 'অতিরিক্ত বড়',
+ '2xl' => 'দ্বিগুণ বড়',
+ ],
+];
diff --git a/resources/lang/bs/translations.php b/resources/lang/bs/translations.php
new file mode 100644
index 0000000..680a34f
--- /dev/null
+++ b/resources/lang/bs/translations.php
@@ -0,0 +1,14 @@
+ [
+ 'xs' => 'Vrlo mali',
+ 'sm' => 'Mali',
+ 'md' => 'Srednji',
+ 'lg' => 'Veliki',
+ 'xl' => 'Vrlo veliki',
+ '2xl' => 'Dvostruko veliki',
+ ],
+];
diff --git a/resources/lang/ca/translations.php b/resources/lang/ca/translations.php
new file mode 100644
index 0000000..d9516f4
--- /dev/null
+++ b/resources/lang/ca/translations.php
@@ -0,0 +1,14 @@
+ [
+ 'xs' => 'Extra petit',
+ 'sm' => 'Petit',
+ 'md' => 'Mitjà',
+ 'lg' => 'Gran',
+ 'xl' => 'Extra gran',
+ '2xl' => 'Doble extra gran',
+ ],
+];
diff --git a/resources/lang/ceb/translations.php b/resources/lang/ceb/translations.php
new file mode 100644
index 0000000..4007638
--- /dev/null
+++ b/resources/lang/ceb/translations.php
@@ -0,0 +1,14 @@
+ [
+ 'xs' => 'Gamay kaayo',
+ 'sm' => 'Gamay',
+ 'md' => 'Tunga',
+ 'lg' => 'Dako',
+ 'xl' => 'Dako kaayo',
+ '2xl' => 'Doble nga dako',
+ ],
+];
diff --git a/resources/lang/ckb/translations.php b/resources/lang/ckb/translations.php
new file mode 100644
index 0000000..4a6ec1b
--- /dev/null
+++ b/resources/lang/ckb/translations.php
@@ -0,0 +1,14 @@
+ [
+ 'xs' => 'زۆر بچووک',
+ 'sm' => 'بچووک',
+ 'md' => 'ناوەند',
+ 'lg' => 'گەورە',
+ 'xl' => 'زۆر گەورە',
+ '2xl' => 'دووقات گەورە',
+ ],
+];
diff --git a/resources/lang/cs/translations.php b/resources/lang/cs/translations.php
new file mode 100644
index 0000000..287f80d
--- /dev/null
+++ b/resources/lang/cs/translations.php
@@ -0,0 +1,14 @@
+ [
+ 'xs' => 'Extra malý',
+ 'sm' => 'Malý',
+ 'md' => 'Střední',
+ 'lg' => 'Velký',
+ 'xl' => 'Extra velký',
+ '2xl' => 'Dvojitě velký',
+ ],
+];
diff --git a/resources/lang/cy/translations.php b/resources/lang/cy/translations.php
new file mode 100644
index 0000000..586ddb0
--- /dev/null
+++ b/resources/lang/cy/translations.php
@@ -0,0 +1,14 @@
+ [
+ 'xs' => 'Bach iawn',
+ 'sm' => 'Bach',
+ 'md' => 'Canolig',
+ 'lg' => 'Mawr',
+ 'xl' => 'Mawr iawn',
+ '2xl' => 'Dwbwl mawr',
+ ],
+];
diff --git a/resources/lang/da/translations.php b/resources/lang/da/translations.php
new file mode 100644
index 0000000..95548d3
--- /dev/null
+++ b/resources/lang/da/translations.php
@@ -0,0 +1,14 @@
+ [
+ 'xs' => 'Ekstra lille',
+ 'sm' => 'Lille',
+ 'md' => 'Medium',
+ 'lg' => 'Stor',
+ 'xl' => 'Ekstra stor',
+ '2xl' => 'Dobbelt stor',
+ ],
+];
diff --git a/resources/lang/de/translations.php b/resources/lang/de/translations.php
new file mode 100644
index 0000000..d888306
--- /dev/null
+++ b/resources/lang/de/translations.php
@@ -0,0 +1,14 @@
+ [
+ 'xs' => 'Extra Klein',
+ 'sm' => 'Klein',
+ 'md' => 'Mittel',
+ 'lg' => 'Groß',
+ 'xl' => 'Extra Groß',
+ '2xl' => 'Doppelt Groß',
+ ],
+];
diff --git a/resources/lang/de_CH/translations.php b/resources/lang/de_CH/translations.php
new file mode 100644
index 0000000..8e5d6f5
--- /dev/null
+++ b/resources/lang/de_CH/translations.php
@@ -0,0 +1,14 @@
+ [
+ 'xs' => 'Extra Chli',
+ 'sm' => 'Chli',
+ 'md' => 'Mittel',
+ 'lg' => 'Gross',
+ 'xl' => 'Extra Gross',
+ '2xl' => 'Dopplet Gross',
+ ],
+];
diff --git a/resources/lang/doi/translations.php b/resources/lang/doi/translations.php
new file mode 100644
index 0000000..a0c78dd
--- /dev/null
+++ b/resources/lang/doi/translations.php
@@ -0,0 +1,14 @@
+ [
+ 'xs' => 'बहुत छोटा',
+ 'sm' => 'छोटा',
+ 'md' => 'मध्यम',
+ 'lg' => 'बड़ा',
+ 'xl' => 'बहुत बड़ा',
+ '2xl' => 'दुगुना बड़ा',
+ ],
+];
diff --git a/resources/lang/ee/translations.php b/resources/lang/ee/translations.php
new file mode 100644
index 0000000..7ec0f45
--- /dev/null
+++ b/resources/lang/ee/translations.php
@@ -0,0 +1,14 @@
+ [
+ 'xs' => 'Sue akpa',
+ 'sm' => 'Sue',
+ 'md' => 'Titina',
+ 'lg' => 'Lolo',
+ 'xl' => 'Lolo akpa',
+ '2xl' => 'Lolo eve',
+ ],
+];
diff --git a/resources/lang/el/translations.php b/resources/lang/el/translations.php
new file mode 100644
index 0000000..c523557
--- /dev/null
+++ b/resources/lang/el/translations.php
@@ -0,0 +1,14 @@
+ [
+ 'xs' => 'Πολύ μικρό',
+ 'sm' => 'Μικρό',
+ 'md' => 'Μεσαίο',
+ 'lg' => 'Μεγάλο',
+ 'xl' => 'Πολύ μεγάλο',
+ '2xl' => 'Διπλά μεγάλο',
+ ],
+];
diff --git a/resources/lang/en/translations.php b/resources/lang/en/translations.php
new file mode 100644
index 0000000..d6bb29d
--- /dev/null
+++ b/resources/lang/en/translations.php
@@ -0,0 +1,14 @@
+ [
+ 'xs' => 'Extra Small',
+ 'sm' => 'Small',
+ 'md' => 'Medium',
+ 'lg' => 'Large',
+ 'xl' => 'Extra Large',
+ '2xl' => 'Double Extra Large',
+ ],
+];
diff --git a/resources/lang/en_CA/translations.php b/resources/lang/en_CA/translations.php
new file mode 100644
index 0000000..d6bb29d
--- /dev/null
+++ b/resources/lang/en_CA/translations.php
@@ -0,0 +1,14 @@
+ [
+ 'xs' => 'Extra Small',
+ 'sm' => 'Small',
+ 'md' => 'Medium',
+ 'lg' => 'Large',
+ 'xl' => 'Extra Large',
+ '2xl' => 'Double Extra Large',
+ ],
+];
diff --git a/resources/lang/eo/translations.php b/resources/lang/eo/translations.php
new file mode 100644
index 0000000..dabc7c1
--- /dev/null
+++ b/resources/lang/eo/translations.php
@@ -0,0 +1,14 @@
+ [
+ 'xs' => 'Tre malgranda',
+ 'sm' => 'Malgranda',
+ 'md' => 'Meza',
+ 'lg' => 'Granda',
+ 'xl' => 'Tre granda',
+ '2xl' => 'Duoble granda',
+ ],
+];
diff --git a/resources/lang/es/translations.php b/resources/lang/es/translations.php
new file mode 100644
index 0000000..dd545d6
--- /dev/null
+++ b/resources/lang/es/translations.php
@@ -0,0 +1,14 @@
+ [
+ 'xs' => 'Extra pequeño',
+ 'sm' => 'Pequeño',
+ 'md' => 'Mediano',
+ 'lg' => 'Grande',
+ 'xl' => 'Extra grande',
+ '2xl' => 'Doble extra grande',
+ ],
+];
diff --git a/resources/lang/et/translations.php b/resources/lang/et/translations.php
new file mode 100644
index 0000000..32b2391
--- /dev/null
+++ b/resources/lang/et/translations.php
@@ -0,0 +1,14 @@
+ [
+ 'xs' => 'Eriti väike',
+ 'sm' => 'Väike',
+ 'md' => 'Keskmine',
+ 'lg' => 'Suur',
+ 'xl' => 'Eriti suur',
+ '2xl' => 'Topelt suur',
+ ],
+];
diff --git a/resources/lang/eu/translations.php b/resources/lang/eu/translations.php
new file mode 100644
index 0000000..b7a5910
--- /dev/null
+++ b/resources/lang/eu/translations.php
@@ -0,0 +1,14 @@
+ [
+ 'xs' => 'Oso txikia',
+ 'sm' => 'Txikia',
+ 'md' => 'Ertaina',
+ 'lg' => 'Handia',
+ 'xl' => 'Oso handia',
+ '2xl' => 'Bikoitza handia',
+ ],
+];
diff --git a/resources/lang/fa/translations.php b/resources/lang/fa/translations.php
new file mode 100644
index 0000000..350b707
--- /dev/null
+++ b/resources/lang/fa/translations.php
@@ -0,0 +1,14 @@
+ [
+ 'xs' => 'خیلی کوچک',
+ 'sm' => 'کوچک',
+ 'md' => 'متوسط',
+ 'lg' => 'بزرگ',
+ 'xl' => 'خیلی بزرگ',
+ '2xl' => 'دوبرابر بزرگ',
+ ],
+];
diff --git a/resources/lang/fi/translations.php b/resources/lang/fi/translations.php
new file mode 100644
index 0000000..fc353a3
--- /dev/null
+++ b/resources/lang/fi/translations.php
@@ -0,0 +1,14 @@
+ [
+ 'xs' => 'Erittäin pieni',
+ 'sm' => 'Pieni',
+ 'md' => 'Keskikokoinen',
+ 'lg' => 'Suuri',
+ 'xl' => 'Erittäin suuri',
+ '2xl' => 'Kaksinkertainen suuri',
+ ],
+];
diff --git a/resources/lang/fil/translations.php b/resources/lang/fil/translations.php
new file mode 100644
index 0000000..dab20b3
--- /dev/null
+++ b/resources/lang/fil/translations.php
@@ -0,0 +1,14 @@
+ [
+ 'xs' => 'Napaka-liit',
+ 'sm' => 'Maliit',
+ 'md' => 'Katamtaman',
+ 'lg' => 'Malaki',
+ 'xl' => 'Napaka-laki',
+ '2xl' => 'Doble laki',
+ ],
+];
diff --git a/resources/lang/fr/translations.php b/resources/lang/fr/translations.php
new file mode 100644
index 0000000..846ba6f
--- /dev/null
+++ b/resources/lang/fr/translations.php
@@ -0,0 +1,14 @@
+ [
+ 'xs' => 'Très petit',
+ 'sm' => 'Petit',
+ 'md' => 'Moyen',
+ 'lg' => 'Grand',
+ 'xl' => 'Très grand',
+ '2xl' => 'Double grand',
+ ],
+];
diff --git a/resources/lang/fy/translations.php b/resources/lang/fy/translations.php
new file mode 100644
index 0000000..eccfdc2
--- /dev/null
+++ b/resources/lang/fy/translations.php
@@ -0,0 +1,14 @@
+ [
+ 'xs' => 'Ekstra lyts',
+ 'sm' => 'Lyts',
+ 'md' => 'Middel',
+ 'lg' => 'Grut',
+ 'xl' => 'Ekstra grut',
+ '2xl' => 'Dûbel grut',
+ ],
+];
diff --git a/resources/lang/ga/translations.php b/resources/lang/ga/translations.php
new file mode 100644
index 0000000..4b98827
--- /dev/null
+++ b/resources/lang/ga/translations.php
@@ -0,0 +1,14 @@
+ [
+ 'xs' => 'An-bheag',
+ 'sm' => 'Beag',
+ 'md' => 'Meánach',
+ 'lg' => 'Mór',
+ 'xl' => 'An-mhór',
+ '2xl' => 'Dúbailte mór',
+ ],
+];
diff --git a/resources/lang/gd/translations.php b/resources/lang/gd/translations.php
new file mode 100644
index 0000000..5bd83ae
--- /dev/null
+++ b/resources/lang/gd/translations.php
@@ -0,0 +1,14 @@
+ [
+ 'xs' => 'Glè bheag',
+ 'sm' => 'Beag',
+ 'md' => 'Meadhanach',
+ 'lg' => 'Mòr',
+ 'xl' => 'Glè mhòr',
+ '2xl' => 'Dùbailte mòr',
+ ],
+];
diff --git a/resources/lang/gl/translations.php b/resources/lang/gl/translations.php
new file mode 100644
index 0000000..7a3058e
--- /dev/null
+++ b/resources/lang/gl/translations.php
@@ -0,0 +1,14 @@
+ [
+ 'xs' => 'Extra pequeno',
+ 'sm' => 'Pequeno',
+ 'md' => 'Mediano',
+ 'lg' => 'Grande',
+ 'xl' => 'Extra grande',
+ '2xl' => 'Dobre grande',
+ ],
+];
diff --git a/resources/lang/gu/translations.php b/resources/lang/gu/translations.php
new file mode 100644
index 0000000..79867f5
--- /dev/null
+++ b/resources/lang/gu/translations.php
@@ -0,0 +1,14 @@
+ [
+ 'xs' => 'ખૂબ નાનું',
+ 'sm' => 'નાનું',
+ 'md' => 'મધ્યમ',
+ 'lg' => 'મોટું',
+ 'xl' => 'ખૂબ મોટું',
+ '2xl' => 'બમણું મોટું',
+ ],
+];
diff --git a/resources/lang/ha/translations.php b/resources/lang/ha/translations.php
new file mode 100644
index 0000000..5f98245
--- /dev/null
+++ b/resources/lang/ha/translations.php
@@ -0,0 +1,14 @@
+ [
+ 'xs' => 'Ƙarami sosai',
+ 'sm' => 'Ƙarami',
+ 'md' => 'Matsakaici',
+ 'lg' => 'Babba',
+ 'xl' => 'Babba sosai',
+ '2xl' => 'Babba sau biyu',
+ ],
+];
diff --git a/resources/lang/haw/translations.php b/resources/lang/haw/translations.php
new file mode 100644
index 0000000..e75a80e
--- /dev/null
+++ b/resources/lang/haw/translations.php
@@ -0,0 +1,14 @@
+ [
+ 'xs' => 'Liʻiliʻi loa',
+ 'sm' => 'Liʻiliʻi',
+ 'md' => 'Waena',
+ 'lg' => 'Nui',
+ 'xl' => 'Nui loa',
+ '2xl' => 'Nui lua',
+ ],
+];
diff --git a/resources/lang/he/translations.php b/resources/lang/he/translations.php
new file mode 100644
index 0000000..a4ff440
--- /dev/null
+++ b/resources/lang/he/translations.php
@@ -0,0 +1,14 @@
+ [
+ 'xs' => 'קטן מאוד',
+ 'sm' => 'קטן',
+ 'md' => 'בינוני',
+ 'lg' => 'גדול',
+ 'xl' => 'גדול מאוד',
+ '2xl' => 'גדול כפול',
+ ],
+];
diff --git a/resources/lang/hi/translations.php b/resources/lang/hi/translations.php
new file mode 100644
index 0000000..a0c78dd
--- /dev/null
+++ b/resources/lang/hi/translations.php
@@ -0,0 +1,14 @@
+ [
+ 'xs' => 'बहुत छोटा',
+ 'sm' => 'छोटा',
+ 'md' => 'मध्यम',
+ 'lg' => 'बड़ा',
+ 'xl' => 'बहुत बड़ा',
+ '2xl' => 'दुगुना बड़ा',
+ ],
+];
diff --git a/resources/lang/hr/translations.php b/resources/lang/hr/translations.php
new file mode 100644
index 0000000..680a34f
--- /dev/null
+++ b/resources/lang/hr/translations.php
@@ -0,0 +1,14 @@
+ [
+ 'xs' => 'Vrlo mali',
+ 'sm' => 'Mali',
+ 'md' => 'Srednji',
+ 'lg' => 'Veliki',
+ 'xl' => 'Vrlo veliki',
+ '2xl' => 'Dvostruko veliki',
+ ],
+];
diff --git a/resources/lang/hu/translations.php b/resources/lang/hu/translations.php
new file mode 100644
index 0000000..127f6ca
--- /dev/null
+++ b/resources/lang/hu/translations.php
@@ -0,0 +1,14 @@
+ [
+ 'xs' => 'Extra kicsi',
+ 'sm' => 'Kicsi',
+ 'md' => 'Közepes',
+ 'lg' => 'Nagy',
+ 'xl' => 'Extra nagy',
+ '2xl' => 'Dupla nagy',
+ ],
+];
diff --git a/resources/lang/hy/translations.php b/resources/lang/hy/translations.php
new file mode 100644
index 0000000..39d2513
--- /dev/null
+++ b/resources/lang/hy/translations.php
@@ -0,0 +1,14 @@
+ [
+ 'xs' => 'Շատ փոքր',
+ 'sm' => 'Փոքր',
+ 'md' => 'Միջին',
+ 'lg' => 'Մեծ',
+ 'xl' => 'Շատ մեծ',
+ '2xl' => 'Կրկնակի մեծ',
+ ],
+];
diff --git a/resources/lang/id/translations.php b/resources/lang/id/translations.php
new file mode 100644
index 0000000..d003a87
--- /dev/null
+++ b/resources/lang/id/translations.php
@@ -0,0 +1,14 @@
+ [
+ 'xs' => 'Sangat kecil',
+ 'sm' => 'Kecil',
+ 'md' => 'Sedang',
+ 'lg' => 'Besar',
+ 'xl' => 'Sangat besar',
+ '2xl' => 'Dua kali besar',
+ ],
+];
diff --git a/resources/lang/ig/translations.php b/resources/lang/ig/translations.php
new file mode 100644
index 0000000..5173282
--- /dev/null
+++ b/resources/lang/ig/translations.php
@@ -0,0 +1,14 @@
+ [
+ 'xs' => 'Obere nta',
+ 'sm' => 'Obere',
+ 'md' => 'Nkezi',
+ 'lg' => 'Nnukwu',
+ 'xl' => 'Nnukwu nke ukwu',
+ '2xl' => 'Okpukpu abụọ',
+ ],
+];
diff --git a/resources/lang/is/translations.php b/resources/lang/is/translations.php
new file mode 100644
index 0000000..5202924
--- /dev/null
+++ b/resources/lang/is/translations.php
@@ -0,0 +1,14 @@
+ [
+ 'xs' => 'Ofur lítill',
+ 'sm' => 'Lítill',
+ 'md' => 'Miðlungs',
+ 'lg' => 'Stór',
+ 'xl' => 'Ofur stór',
+ '2xl' => 'Tvöfalt stór',
+ ],
+];
diff --git a/resources/lang/it/translations.php b/resources/lang/it/translations.php
new file mode 100644
index 0000000..e6463fe
--- /dev/null
+++ b/resources/lang/it/translations.php
@@ -0,0 +1,14 @@
+ [
+ 'xs' => 'Extra piccolo',
+ 'sm' => 'Piccolo',
+ 'md' => 'Medio',
+ 'lg' => 'Grande',
+ 'xl' => 'Extra grande',
+ '2xl' => 'Doppio grande',
+ ],
+];
diff --git a/resources/lang/ja/translations.php b/resources/lang/ja/translations.php
new file mode 100644
index 0000000..3ac10ff
--- /dev/null
+++ b/resources/lang/ja/translations.php
@@ -0,0 +1,14 @@
+ [
+ 'xs' => '超小',
+ 'sm' => '小',
+ 'md' => '中',
+ 'lg' => '大',
+ 'xl' => '超大',
+ '2xl' => '特大',
+ ],
+];
diff --git a/resources/lang/ka/translations.php b/resources/lang/ka/translations.php
new file mode 100644
index 0000000..02f9c4f
--- /dev/null
+++ b/resources/lang/ka/translations.php
@@ -0,0 +1,14 @@
+ [
+ 'xs' => 'ძალიან პატარა',
+ 'sm' => 'პატარა',
+ 'md' => 'საშუალო',
+ 'lg' => 'დიდი',
+ 'xl' => 'ძალიან დიდი',
+ '2xl' => 'ორმაგი დიდი',
+ ],
+];
diff --git a/resources/lang/kk/translations.php b/resources/lang/kk/translations.php
new file mode 100644
index 0000000..01dee14
--- /dev/null
+++ b/resources/lang/kk/translations.php
@@ -0,0 +1,14 @@
+ [
+ 'xs' => 'Өте кіші',
+ 'sm' => 'Кіші',
+ 'md' => 'Орташа',
+ 'lg' => 'Үлкен',
+ 'xl' => 'Өте үлкен',
+ '2xl' => 'Қос үлкен',
+ ],
+];
diff --git a/resources/lang/km/translations.php b/resources/lang/km/translations.php
new file mode 100644
index 0000000..6c256a9
--- /dev/null
+++ b/resources/lang/km/translations.php
@@ -0,0 +1,14 @@
+ [
+ 'xs' => 'តូចបំផុត',
+ 'sm' => 'តូច',
+ 'md' => 'កម្រិតមធ្យម',
+ 'lg' => 'ធំ',
+ 'xl' => 'ធំបំផុត',
+ '2xl' => 'ធំទ្វេដង',
+ ],
+];
diff --git a/resources/lang/kn/translations.php b/resources/lang/kn/translations.php
new file mode 100644
index 0000000..55e4687
--- /dev/null
+++ b/resources/lang/kn/translations.php
@@ -0,0 +1,14 @@
+ [
+ 'xs' => 'ಅತಿ ಚಿಕ್ಕ',
+ 'sm' => 'ಚಿಕ್ಕ',
+ 'md' => 'ಮಧ್ಯಮ',
+ 'lg' => 'ದೊಡ್ಡ',
+ 'xl' => 'ಅತಿ ದೊಡ್ಡ',
+ '2xl' => 'ಎರಡು ಪಟ್ಟು ದೊಡ್ಡ',
+ ],
+];
diff --git a/resources/lang/ko/translations.php b/resources/lang/ko/translations.php
new file mode 100644
index 0000000..5ef2d3e
--- /dev/null
+++ b/resources/lang/ko/translations.php
@@ -0,0 +1,14 @@
+ [
+ 'xs' => '매우 작음',
+ 'sm' => '작음',
+ 'md' => '보통',
+ 'lg' => '큼',
+ 'xl' => '매우 큼',
+ '2xl' => '두배 큼',
+ ],
+];
diff --git a/resources/lang/ku/translations.php b/resources/lang/ku/translations.php
new file mode 100644
index 0000000..685d706
--- /dev/null
+++ b/resources/lang/ku/translations.php
@@ -0,0 +1,14 @@
+ [
+ 'xs' => 'Piçûk e zor',
+ 'sm' => 'Piçûk',
+ 'md' => 'Navîn',
+ 'lg' => 'Mezin',
+ 'xl' => 'Mezin e zor',
+ '2xl' => 'Du caran mezin',
+ ],
+];
diff --git a/resources/lang/ky/translations.php b/resources/lang/ky/translations.php
new file mode 100644
index 0000000..bcdeae9
--- /dev/null
+++ b/resources/lang/ky/translations.php
@@ -0,0 +1,14 @@
+ [
+ 'xs' => 'Өтө кичине',
+ 'sm' => 'Кичине',
+ 'md' => 'Орточо',
+ 'lg' => 'Чоң',
+ 'xl' => 'Өтө чоң',
+ '2xl' => 'Эки эсе чоң',
+ ],
+];
diff --git a/resources/lang/lb/translations.php b/resources/lang/lb/translations.php
new file mode 100644
index 0000000..863433a
--- /dev/null
+++ b/resources/lang/lb/translations.php
@@ -0,0 +1,14 @@
+ [
+ 'xs' => 'Extra kleng',
+ 'sm' => 'Kleng',
+ 'md' => 'Mëttel',
+ 'lg' => 'Grouss',
+ 'xl' => 'Extra grouss',
+ '2xl' => 'Duebel grouss',
+ ],
+];
diff --git a/resources/lang/lg/translations.php b/resources/lang/lg/translations.php
new file mode 100644
index 0000000..202cb7c
--- /dev/null
+++ b/resources/lang/lg/translations.php
@@ -0,0 +1,14 @@
+ [
+ 'xs' => 'Katono nnyo',
+ 'sm' => 'Katono',
+ 'md' => 'Wakati',
+ 'lg' => 'Kinene',
+ 'xl' => 'Kinene nnyo',
+ '2xl' => 'Kinene emirundi ebiri',
+ ],
+];
diff --git a/resources/lang/ln/translations.php b/resources/lang/ln/translations.php
new file mode 100644
index 0000000..b9e50d2
--- /dev/null
+++ b/resources/lang/ln/translations.php
@@ -0,0 +1,14 @@
+ [
+ 'xs' => 'Moke mingi',
+ 'sm' => 'Moke',
+ 'md' => 'Kati na kati',
+ 'lg' => 'Monene',
+ 'xl' => 'Monene mingi',
+ '2xl' => 'Monene mbala mibale',
+ ],
+];
diff --git a/resources/lang/lo/translations.php b/resources/lang/lo/translations.php
new file mode 100644
index 0000000..84921c3
--- /dev/null
+++ b/resources/lang/lo/translations.php
@@ -0,0 +1,14 @@
+ [
+ 'xs' => 'ນ້ອຍທີ່ສຸດ',
+ 'sm' => 'ນ້ອຍ',
+ 'md' => 'ກາງ',
+ 'lg' => 'ໃຫຍ່',
+ 'xl' => 'ໃຫຍ່ທີ່ສຸດ',
+ '2xl' => 'ໃຫຍ່ສອງເທົ່າ',
+ ],
+];
diff --git a/resources/lang/lt/translations.php b/resources/lang/lt/translations.php
new file mode 100644
index 0000000..1c0dd3c
--- /dev/null
+++ b/resources/lang/lt/translations.php
@@ -0,0 +1,14 @@
+ [
+ 'xs' => 'Labai mažas',
+ 'sm' => 'Mažas',
+ 'md' => 'Vidutinis',
+ 'lg' => 'Didelis',
+ 'xl' => 'Labai didelis',
+ '2xl' => 'Dvigubai didelis',
+ ],
+];
diff --git a/resources/lang/lv/translations.php b/resources/lang/lv/translations.php
new file mode 100644
index 0000000..0c26d3a
--- /dev/null
+++ b/resources/lang/lv/translations.php
@@ -0,0 +1,14 @@
+ [
+ 'xs' => 'Ļoti mazs',
+ 'sm' => 'Mazs',
+ 'md' => 'Vidējs',
+ 'lg' => 'Liels',
+ 'xl' => 'Ļoti liels',
+ '2xl' => 'Divkārt liels',
+ ],
+];
diff --git a/resources/lang/mai/translations.php b/resources/lang/mai/translations.php
new file mode 100644
index 0000000..9a3b959
--- /dev/null
+++ b/resources/lang/mai/translations.php
@@ -0,0 +1,14 @@
+ [
+ 'xs' => 'बहुत छोट',
+ 'sm' => 'छोट',
+ 'md' => 'मध्यम',
+ 'lg' => 'पैघ',
+ 'xl' => 'बहुत पैघ',
+ '2xl' => 'दूगुन पैघ',
+ ],
+];
diff --git a/resources/lang/mg/translations.php b/resources/lang/mg/translations.php
new file mode 100644
index 0000000..26cdda8
--- /dev/null
+++ b/resources/lang/mg/translations.php
@@ -0,0 +1,14 @@
+ [
+ 'xs' => 'Kely indrindra',
+ 'sm' => 'Kely',
+ 'md' => 'Antonony',
+ 'lg' => 'Lehibe',
+ 'xl' => 'Lehibe indrindra',
+ '2xl' => 'Roa heny lehibe',
+ ],
+];
diff --git a/resources/lang/mi/translations.php b/resources/lang/mi/translations.php
new file mode 100644
index 0000000..e1bd593
--- /dev/null
+++ b/resources/lang/mi/translations.php
@@ -0,0 +1,14 @@
+ [
+ 'xs' => 'Iti rawa',
+ 'sm' => 'Iti',
+ 'md' => 'Tauwaenga',
+ 'lg' => 'Rahi',
+ 'xl' => 'Rahi rawa',
+ '2xl' => 'Rahi takirua',
+ ],
+];
diff --git a/resources/lang/mk/translations.php b/resources/lang/mk/translations.php
new file mode 100644
index 0000000..a73c1a8
--- /dev/null
+++ b/resources/lang/mk/translations.php
@@ -0,0 +1,14 @@
+ [
+ 'xs' => 'Многу мал',
+ 'sm' => 'Мал',
+ 'md' => 'Среден',
+ 'lg' => 'Голем',
+ 'xl' => 'Многу голем',
+ '2xl' => 'Двојно голем',
+ ],
+];
diff --git a/resources/lang/ml/translations.php b/resources/lang/ml/translations.php
new file mode 100644
index 0000000..cfa542d
--- /dev/null
+++ b/resources/lang/ml/translations.php
@@ -0,0 +1,14 @@
+ [
+ 'xs' => 'വളരെ ചെറുത്',
+ 'sm' => 'ചെറുത്',
+ 'md' => 'ഇടത്തരം',
+ 'lg' => 'വലുത്',
+ 'xl' => 'വളരെ വലുത്',
+ '2xl' => 'ഇരട്ടി വലുത്',
+ ],
+];
diff --git a/resources/lang/mn/translations.php b/resources/lang/mn/translations.php
new file mode 100644
index 0000000..2994184
--- /dev/null
+++ b/resources/lang/mn/translations.php
@@ -0,0 +1,14 @@
+ [
+ 'xs' => 'Маш жижиг',
+ 'sm' => 'Жижиг',
+ 'md' => 'Дундаж',
+ 'lg' => 'Том',
+ 'xl' => 'Маш том',
+ '2xl' => 'Давхар том',
+ ],
+];
diff --git a/resources/lang/mni_Mtei/translations.php b/resources/lang/mni_Mtei/translations.php
new file mode 100644
index 0000000..994a67b
--- /dev/null
+++ b/resources/lang/mni_Mtei/translations.php
@@ -0,0 +1,14 @@
+ [
+ 'xs' => 'অসুক খুদিংবা',
+ 'sm' => 'খুদিংবা',
+ 'md' => 'মধোংবা',
+ 'lg' => 'অচৌবা',
+ 'xl' => 'অসুক অচৌবা',
+ '2xl' => 'অনি অচৌবা',
+ ],
+];
diff --git a/resources/lang/mr/translations.php b/resources/lang/mr/translations.php
new file mode 100644
index 0000000..8002d54
--- /dev/null
+++ b/resources/lang/mr/translations.php
@@ -0,0 +1,14 @@
+ [
+ 'xs' => 'खूप लहान',
+ 'sm' => 'लहान',
+ 'md' => 'मध्यम',
+ 'lg' => 'मोठे',
+ 'xl' => 'खूप मोठे',
+ '2xl' => 'दुप्पट मोठे',
+ ],
+];
diff --git a/resources/lang/ms/translations.php b/resources/lang/ms/translations.php
new file mode 100644
index 0000000..763dcf2
--- /dev/null
+++ b/resources/lang/ms/translations.php
@@ -0,0 +1,14 @@
+ [
+ 'xs' => 'Sangat kecil',
+ 'sm' => 'Kecil',
+ 'md' => 'Sederhana',
+ 'lg' => 'Besar',
+ 'xl' => 'Sangat besar',
+ '2xl' => 'Dua kali besar',
+ ],
+];
diff --git a/resources/lang/mt/translations.php b/resources/lang/mt/translations.php
new file mode 100644
index 0000000..65c3428
--- /dev/null
+++ b/resources/lang/mt/translations.php
@@ -0,0 +1,14 @@
+ [
+ 'xs' => 'Żgħir ħafna',
+ 'sm' => 'Żgħir',
+ 'md' => 'Medju',
+ 'lg' => 'Kbir',
+ 'xl' => 'Kbir ħafna',
+ '2xl' => 'Kbir darbtejn',
+ ],
+];
diff --git a/resources/lang/my/translations.php b/resources/lang/my/translations.php
new file mode 100644
index 0000000..eb0716c
--- /dev/null
+++ b/resources/lang/my/translations.php
@@ -0,0 +1,14 @@
+ [
+ 'xs' => 'အလွန်သေး',
+ 'sm' => 'သေး',
+ 'md' => 'အလတ်စား',
+ 'lg' => 'ကြီး',
+ 'xl' => 'အလွန်ကြီး',
+ '2xl' => 'နှစ်ဆကြီး',
+ ],
+];
diff --git a/resources/lang/nb/translations.php b/resources/lang/nb/translations.php
new file mode 100644
index 0000000..9619bb6
--- /dev/null
+++ b/resources/lang/nb/translations.php
@@ -0,0 +1,14 @@
+ [
+ 'xs' => 'Ekstra liten',
+ 'sm' => 'Liten',
+ 'md' => 'Medium',
+ 'lg' => 'Stor',
+ 'xl' => 'Ekstra stor',
+ '2xl' => 'Dobbelt stor',
+ ],
+];
diff --git a/resources/lang/ne/translations.php b/resources/lang/ne/translations.php
new file mode 100644
index 0000000..9cdce39
--- /dev/null
+++ b/resources/lang/ne/translations.php
@@ -0,0 +1,14 @@
+ [
+ 'xs' => 'धेरै सानो',
+ 'sm' => 'सानो',
+ 'md' => 'मध्यम',
+ 'lg' => 'ठूलो',
+ 'xl' => 'धेरै ठूलो',
+ '2xl' => 'दुई गुणा ठूलो',
+ ],
+];
diff --git a/resources/lang/nl/translations.php b/resources/lang/nl/translations.php
new file mode 100644
index 0000000..63a742d
--- /dev/null
+++ b/resources/lang/nl/translations.php
@@ -0,0 +1,14 @@
+ [
+ 'xs' => 'Extra klein',
+ 'sm' => 'Klein',
+ 'md' => 'Medium',
+ 'lg' => 'Groot',
+ 'xl' => 'Extra groot',
+ '2xl' => 'Dubbel groot',
+ ],
+];
diff --git a/resources/lang/nn/translations.php b/resources/lang/nn/translations.php
new file mode 100644
index 0000000..9619bb6
--- /dev/null
+++ b/resources/lang/nn/translations.php
@@ -0,0 +1,14 @@
+ [
+ 'xs' => 'Ekstra liten',
+ 'sm' => 'Liten',
+ 'md' => 'Medium',
+ 'lg' => 'Stor',
+ 'xl' => 'Ekstra stor',
+ '2xl' => 'Dobbelt stor',
+ ],
+];
diff --git a/resources/lang/oc/translations.php b/resources/lang/oc/translations.php
new file mode 100644
index 0000000..3c8b8d7
--- /dev/null
+++ b/resources/lang/oc/translations.php
@@ -0,0 +1,14 @@
+ [
+ 'xs' => 'Pichon de mai',
+ 'sm' => 'Pichon',
+ 'md' => 'Mejan',
+ 'lg' => 'Grand',
+ 'xl' => 'Grand de mai',
+ '2xl' => 'Doble grand',
+ ],
+];
diff --git a/resources/lang/om/translations.php b/resources/lang/om/translations.php
new file mode 100644
index 0000000..5d396e1
--- /dev/null
+++ b/resources/lang/om/translations.php
@@ -0,0 +1,14 @@
+ [
+ 'xs' => 'Baay\'ee xiqqaa',
+ 'sm' => 'Xiqqaa',
+ 'md' => 'Gidduu',
+ 'lg' => 'Guddaa',
+ 'xl' => 'Baay\'ee guddaa',
+ '2xl' => 'Dachaa guddaa',
+ ],
+];
diff --git a/resources/lang/or/translations.php b/resources/lang/or/translations.php
new file mode 100644
index 0000000..67e838f
--- /dev/null
+++ b/resources/lang/or/translations.php
@@ -0,0 +1,14 @@
+ [
+ 'xs' => 'ବହୁତ ଛୋଟ',
+ 'sm' => 'ଛୋଟ',
+ 'md' => 'ମଧ୍ୟମ',
+ 'lg' => 'ବଡ଼',
+ 'xl' => 'ବହୁତ ବଡ଼',
+ '2xl' => 'ଦ୍ୱିଗୁଣ ବଡ଼',
+ ],
+];
diff --git a/resources/lang/pa/translations.php b/resources/lang/pa/translations.php
new file mode 100644
index 0000000..b23f491
--- /dev/null
+++ b/resources/lang/pa/translations.php
@@ -0,0 +1,14 @@
+ [
+ 'xs' => 'ਬਹੁਤ ਛੋਟਾ',
+ 'sm' => 'ਛੋਟਾ',
+ 'md' => 'ਮੱਧਮ',
+ 'lg' => 'ਵੱਡਾ',
+ 'xl' => 'ਬਹੁਤ ਵੱਡਾ',
+ '2xl' => 'ਦੁਗਣਾ ਵੱਡਾ',
+ ],
+];
diff --git a/resources/lang/pl/translations.php b/resources/lang/pl/translations.php
new file mode 100644
index 0000000..9c71b58
--- /dev/null
+++ b/resources/lang/pl/translations.php
@@ -0,0 +1,14 @@
+ [
+ 'xs' => 'Bardzo mały',
+ 'sm' => 'Mały',
+ 'md' => 'Średni',
+ 'lg' => 'Duży',
+ 'xl' => 'Bardzo duży',
+ '2xl' => 'Podwójnie duży',
+ ],
+];
diff --git a/resources/lang/ps/translations.php b/resources/lang/ps/translations.php
new file mode 100644
index 0000000..2a4be15
--- /dev/null
+++ b/resources/lang/ps/translations.php
@@ -0,0 +1,14 @@
+ [
+ 'xs' => 'ډېر کشر',
+ 'sm' => 'کشر',
+ 'md' => 'منځنی',
+ 'lg' => 'لوی',
+ 'xl' => 'ډېر لوی',
+ '2xl' => 'دوه ځله لوی',
+ ],
+];
diff --git a/resources/lang/pt/translations.php b/resources/lang/pt/translations.php
new file mode 100644
index 0000000..cc46909
--- /dev/null
+++ b/resources/lang/pt/translations.php
@@ -0,0 +1,14 @@
+ [
+ 'xs' => 'Extra pequeno',
+ 'sm' => 'Pequeno',
+ 'md' => 'Médio',
+ 'lg' => 'Grande',
+ 'xl' => 'Extra grande',
+ '2xl' => 'Duplo grande',
+ ],
+];
diff --git a/resources/lang/pt_BR/translations.php b/resources/lang/pt_BR/translations.php
new file mode 100644
index 0000000..3851a7b
--- /dev/null
+++ b/resources/lang/pt_BR/translations.php
@@ -0,0 +1,14 @@
+ [
+ 'xs' => 'Extra pequeno',
+ 'sm' => 'Pequeno',
+ 'md' => 'Médio',
+ 'lg' => 'Grande',
+ 'xl' => 'Extra grande',
+ '2xl' => 'Duplo extra grande',
+ ],
+];
diff --git a/resources/lang/qu/translations.php b/resources/lang/qu/translations.php
new file mode 100644
index 0000000..cc1597b
--- /dev/null
+++ b/resources/lang/qu/translations.php
@@ -0,0 +1,14 @@
+ [
+ 'xs' => 'Ancha huch\'uy',
+ 'sm' => 'Huch\'uy',
+ 'md' => 'Chawpi',
+ 'lg' => 'Hatun',
+ 'xl' => 'Ancha hatun',
+ '2xl' => 'Iskay kuti hatun',
+ ],
+];
diff --git a/resources/lang/ro/translations.php b/resources/lang/ro/translations.php
new file mode 100644
index 0000000..516d2e0
--- /dev/null
+++ b/resources/lang/ro/translations.php
@@ -0,0 +1,14 @@
+ [
+ 'xs' => 'Extra mic',
+ 'sm' => 'Mic',
+ 'md' => 'Mediu',
+ 'lg' => 'Mare',
+ 'xl' => 'Extra mare',
+ '2xl' => 'Dublu mare',
+ ],
+];
diff --git a/resources/lang/ru/translations.php b/resources/lang/ru/translations.php
new file mode 100644
index 0000000..2af094d
--- /dev/null
+++ b/resources/lang/ru/translations.php
@@ -0,0 +1,14 @@
+ [
+ 'xs' => 'Очень малый',
+ 'sm' => 'Малый',
+ 'md' => 'Средний',
+ 'lg' => 'Большой',
+ 'xl' => 'Очень большой',
+ '2xl' => 'Двойной большой',
+ ],
+];
diff --git a/resources/lang/rw/translations.php b/resources/lang/rw/translations.php
new file mode 100644
index 0000000..7860267
--- /dev/null
+++ b/resources/lang/rw/translations.php
@@ -0,0 +1,14 @@
+ [
+ 'xs' => 'Gito cyane',
+ 'sm' => 'Gito',
+ 'md' => 'Hagati',
+ 'lg' => 'Ginini',
+ 'xl' => 'Kinini cyane',
+ '2xl' => 'Inshuro ebyiri',
+ ],
+];
diff --git a/resources/lang/sa/translations.php b/resources/lang/sa/translations.php
new file mode 100644
index 0000000..6d3a8a3
--- /dev/null
+++ b/resources/lang/sa/translations.php
@@ -0,0 +1,14 @@
+ [
+ 'xs' => 'अतिलघु',
+ 'sm' => 'लघु',
+ 'md' => 'मध्यम',
+ 'lg' => 'महत्',
+ 'xl' => 'अतिमहत्',
+ '2xl' => 'द्विगुणमहत्',
+ ],
+];
diff --git a/resources/lang/sc/translations.php b/resources/lang/sc/translations.php
new file mode 100644
index 0000000..23c0eaa
--- /dev/null
+++ b/resources/lang/sc/translations.php
@@ -0,0 +1,14 @@
+ [
+ 'xs' => 'Pitzinnu de tottu',
+ 'sm' => 'Pitzinnu',
+ 'md' => 'Mediu',
+ 'lg' => 'Mannu',
+ 'xl' => 'Mannu de tottu',
+ '2xl' => 'Doppiu mannu',
+ ],
+];
diff --git a/resources/lang/sd/translations.php b/resources/lang/sd/translations.php
new file mode 100644
index 0000000..050c33b
--- /dev/null
+++ b/resources/lang/sd/translations.php
@@ -0,0 +1,14 @@
+ [
+ 'xs' => 'تمام ننڍو',
+ 'sm' => 'ننڍو',
+ 'md' => 'وچولو',
+ 'lg' => 'وڏو',
+ 'xl' => 'تمام وڏو',
+ '2xl' => 'ٻيڻو وڏو',
+ ],
+];
diff --git a/resources/lang/si/translations.php b/resources/lang/si/translations.php
new file mode 100644
index 0000000..34cd6ee
--- /dev/null
+++ b/resources/lang/si/translations.php
@@ -0,0 +1,14 @@
+ [
+ 'xs' => 'ඉතා කුඩා',
+ 'sm' => 'කුඩා',
+ 'md' => 'මධ්යම',
+ 'lg' => 'විශාල',
+ 'xl' => 'ඉතා විශාල',
+ '2xl' => 'ද්විත්ව විශාල',
+ ],
+];
diff --git a/resources/lang/sk/translations.php b/resources/lang/sk/translations.php
new file mode 100644
index 0000000..e0ed387
--- /dev/null
+++ b/resources/lang/sk/translations.php
@@ -0,0 +1,14 @@
+ [
+ 'xs' => 'Extra malý',
+ 'sm' => 'Malý',
+ 'md' => 'Stredný',
+ 'lg' => 'Veľký',
+ 'xl' => 'Extra veľký',
+ '2xl' => 'Dvojito veľký',
+ ],
+];
diff --git a/resources/lang/sl/translations.php b/resources/lang/sl/translations.php
new file mode 100644
index 0000000..4ee789b
--- /dev/null
+++ b/resources/lang/sl/translations.php
@@ -0,0 +1,14 @@
+ [
+ 'xs' => 'Zelo majhen',
+ 'sm' => 'Majhen',
+ 'md' => 'Srednji',
+ 'lg' => 'Velik',
+ 'xl' => 'Zelo velik',
+ '2xl' => 'Dvojno velik',
+ ],
+];
diff --git a/resources/lang/sn/translations.php b/resources/lang/sn/translations.php
new file mode 100644
index 0000000..c96dd96
--- /dev/null
+++ b/resources/lang/sn/translations.php
@@ -0,0 +1,14 @@
+ [
+ 'xs' => 'Duku kwazvo',
+ 'sm' => 'Duku',
+ 'md' => 'Pakati',
+ 'lg' => 'Hombe',
+ 'xl' => 'Hombe kwazvo',
+ '2xl' => 'Kaviri hombe',
+ ],
+];
diff --git a/resources/lang/so/translations.php b/resources/lang/so/translations.php
new file mode 100644
index 0000000..86b58aa
--- /dev/null
+++ b/resources/lang/so/translations.php
@@ -0,0 +1,14 @@
+ [
+ 'xs' => 'Aad u yar',
+ 'sm' => 'Yar',
+ 'md' => 'Dhexdhexaad',
+ 'lg' => 'Weyn',
+ 'xl' => 'Aad u weyn',
+ '2xl' => 'Laba jeer weyn',
+ ],
+];
diff --git a/resources/lang/sq/translations.php b/resources/lang/sq/translations.php
new file mode 100644
index 0000000..ddbaed4
--- /dev/null
+++ b/resources/lang/sq/translations.php
@@ -0,0 +1,14 @@
+ [
+ 'xs' => 'Shumë e vogël',
+ 'sm' => 'E vogël',
+ 'md' => 'Mesatare',
+ 'lg' => 'E madhe',
+ 'xl' => 'Shumë e madhe',
+ '2xl' => 'Dyfisht e madhe',
+ ],
+];
diff --git a/resources/lang/sr_Cyrl/translations.php b/resources/lang/sr_Cyrl/translations.php
new file mode 100644
index 0000000..82f3046
--- /dev/null
+++ b/resources/lang/sr_Cyrl/translations.php
@@ -0,0 +1,14 @@
+ [
+ 'xs' => 'Врло мали',
+ 'sm' => 'Мали',
+ 'md' => 'Средњи',
+ 'lg' => 'Велики',
+ 'xl' => 'Врло велики',
+ '2xl' => 'Двоструко велики',
+ ],
+];
diff --git a/resources/lang/sr_Latn/translations.php b/resources/lang/sr_Latn/translations.php
new file mode 100644
index 0000000..680a34f
--- /dev/null
+++ b/resources/lang/sr_Latn/translations.php
@@ -0,0 +1,14 @@
+ [
+ 'xs' => 'Vrlo mali',
+ 'sm' => 'Mali',
+ 'md' => 'Srednji',
+ 'lg' => 'Veliki',
+ 'xl' => 'Vrlo veliki',
+ '2xl' => 'Dvostruko veliki',
+ ],
+];
diff --git a/resources/lang/sr_Latn_ME/translations.php b/resources/lang/sr_Latn_ME/translations.php
new file mode 100644
index 0000000..680a34f
--- /dev/null
+++ b/resources/lang/sr_Latn_ME/translations.php
@@ -0,0 +1,14 @@
+ [
+ 'xs' => 'Vrlo mali',
+ 'sm' => 'Mali',
+ 'md' => 'Srednji',
+ 'lg' => 'Veliki',
+ 'xl' => 'Vrlo veliki',
+ '2xl' => 'Dvostruko veliki',
+ ],
+];
diff --git a/resources/lang/su/translations.php b/resources/lang/su/translations.php
new file mode 100644
index 0000000..2a3aea6
--- /dev/null
+++ b/resources/lang/su/translations.php
@@ -0,0 +1,14 @@
+ [
+ 'xs' => 'Leutik pisan',
+ 'sm' => 'Leutik',
+ 'md' => 'Sedeng',
+ 'lg' => 'Ageung',
+ 'xl' => 'Ageung pisan',
+ '2xl' => 'Dua kali ageung',
+ ],
+];
diff --git a/resources/lang/sv/translations.php b/resources/lang/sv/translations.php
new file mode 100644
index 0000000..05793a4
--- /dev/null
+++ b/resources/lang/sv/translations.php
@@ -0,0 +1,14 @@
+ [
+ 'xs' => 'Extra liten',
+ 'sm' => 'Liten',
+ 'md' => 'Medium',
+ 'lg' => 'Stor',
+ 'xl' => 'Extra stor',
+ '2xl' => 'Dubbelt stor',
+ ],
+];
diff --git a/resources/lang/sw/translations.php b/resources/lang/sw/translations.php
new file mode 100644
index 0000000..8a95b1e
--- /dev/null
+++ b/resources/lang/sw/translations.php
@@ -0,0 +1,14 @@
+ [
+ 'xs' => 'Ndogo sana',
+ 'sm' => 'Ndogo',
+ 'md' => 'Wastani',
+ 'lg' => 'Kubwa',
+ 'xl' => 'Kubwa sana',
+ '2xl' => 'Kubwa mara mbili',
+ ],
+];
diff --git a/resources/lang/ta/translations.php b/resources/lang/ta/translations.php
new file mode 100644
index 0000000..cb7e026
--- /dev/null
+++ b/resources/lang/ta/translations.php
@@ -0,0 +1,14 @@
+ [
+ 'xs' => 'மிக சிறிய',
+ 'sm' => 'சிறிய',
+ 'md' => 'நடுத்தர',
+ 'lg' => 'பெரிய',
+ 'xl' => 'மிக பெரிய',
+ '2xl' => 'இரட்டை பெரிய',
+ ],
+];
diff --git a/resources/lang/te/translations.php b/resources/lang/te/translations.php
new file mode 100644
index 0000000..e1261b1
--- /dev/null
+++ b/resources/lang/te/translations.php
@@ -0,0 +1,14 @@
+ [
+ 'xs' => 'చాలా చిన్న',
+ 'sm' => 'చిన్న',
+ 'md' => 'మధ్యమ',
+ 'lg' => 'పెద్ద',
+ 'xl' => 'చాలా పెద్ద',
+ '2xl' => 'రెండింతల పెద్ద',
+ ],
+];
diff --git a/resources/lang/tg/translations.php b/resources/lang/tg/translations.php
new file mode 100644
index 0000000..fe761ff
--- /dev/null
+++ b/resources/lang/tg/translations.php
@@ -0,0 +1,14 @@
+ [
+ 'xs' => 'Хеле хурд',
+ 'sm' => 'Хурд',
+ 'md' => 'Миёна',
+ 'lg' => 'Калон',
+ 'xl' => 'Хеле калон',
+ '2xl' => 'Ду маротиба калон',
+ ],
+];
diff --git a/resources/lang/th/translations.php b/resources/lang/th/translations.php
new file mode 100644
index 0000000..3ada910
--- /dev/null
+++ b/resources/lang/th/translations.php
@@ -0,0 +1,14 @@
+ [
+ 'xs' => 'เล็กมาก',
+ 'sm' => 'เล็ก',
+ 'md' => 'กลาง',
+ 'lg' => 'ใหญ่',
+ 'xl' => 'ใหญ่มาก',
+ '2xl' => 'ใหญ่สองเท่า',
+ ],
+];
diff --git a/resources/lang/ti/translations.php b/resources/lang/ti/translations.php
new file mode 100644
index 0000000..782f9fe
--- /dev/null
+++ b/resources/lang/ti/translations.php
@@ -0,0 +1,14 @@
+ [
+ 'xs' => 'ኣዝዩ ንእሽቶ',
+ 'sm' => 'ንእሽቶ',
+ 'md' => 'ማእከላይ',
+ 'lg' => 'ዓቢ',
+ 'xl' => 'ኣዝዩ ዓቢ',
+ '2xl' => 'ክልተ ዓቢ',
+ ],
+];
diff --git a/resources/lang/tk/translations.php b/resources/lang/tk/translations.php
new file mode 100644
index 0000000..d61fbc2
--- /dev/null
+++ b/resources/lang/tk/translations.php
@@ -0,0 +1,14 @@
+ [
+ 'xs' => 'Gaty kiçi',
+ 'sm' => 'Kiçi',
+ 'md' => 'Orta',
+ 'lg' => 'Uly',
+ 'xl' => 'Gaty uly',
+ '2xl' => 'Goşa uly',
+ ],
+];
diff --git a/resources/lang/tl/translations.php b/resources/lang/tl/translations.php
new file mode 100644
index 0000000..fd67fde
--- /dev/null
+++ b/resources/lang/tl/translations.php
@@ -0,0 +1,14 @@
+ [
+ 'xs' => 'Sobrang liit',
+ 'sm' => 'Maliit',
+ 'md' => 'Katamtaman',
+ 'lg' => 'Malaki',
+ 'xl' => 'Sobrang laki',
+ '2xl' => 'Doble laki',
+ ],
+];
diff --git a/resources/lang/tr/translations.php b/resources/lang/tr/translations.php
new file mode 100644
index 0000000..aedc867
--- /dev/null
+++ b/resources/lang/tr/translations.php
@@ -0,0 +1,14 @@
+ [
+ 'xs' => 'Çok küçük',
+ 'sm' => 'Küçük',
+ 'md' => 'Orta',
+ 'lg' => 'Büyük',
+ 'xl' => 'Çok büyük',
+ '2xl' => 'Çifte büyük',
+ ],
+];
diff --git a/resources/lang/tt/translations.php b/resources/lang/tt/translations.php
new file mode 100644
index 0000000..1c7f825
--- /dev/null
+++ b/resources/lang/tt/translations.php
@@ -0,0 +1,14 @@
+ [
+ 'xs' => 'Бик кече',
+ 'sm' => 'Кече',
+ 'md' => 'Урта',
+ 'lg' => 'Зур',
+ 'xl' => 'Бик зур',
+ '2xl' => 'Икеләтә зур',
+ ],
+];
diff --git a/resources/lang/ug/translations.php b/resources/lang/ug/translations.php
new file mode 100644
index 0000000..620f97a
--- /dev/null
+++ b/resources/lang/ug/translations.php
@@ -0,0 +1,14 @@
+ [
+ 'xs' => 'بەك كىچىك',
+ 'sm' => 'كىچىك',
+ 'md' => 'ئوتتۇرا',
+ 'lg' => 'چوڭ',
+ 'xl' => 'بەك چوڭ',
+ '2xl' => 'ئىككى ھەسسە چوڭ',
+ ],
+];
diff --git a/resources/lang/uk/translations.php b/resources/lang/uk/translations.php
new file mode 100644
index 0000000..dee62f4
--- /dev/null
+++ b/resources/lang/uk/translations.php
@@ -0,0 +1,14 @@
+ [
+ 'xs' => 'Дуже малий',
+ 'sm' => 'Малий',
+ 'md' => 'Середній',
+ 'lg' => 'Великий',
+ 'xl' => 'Дуже великий',
+ '2xl' => 'Подвійно великий',
+ ],
+];
diff --git a/resources/lang/ur/translations.php b/resources/lang/ur/translations.php
new file mode 100644
index 0000000..2506364
--- /dev/null
+++ b/resources/lang/ur/translations.php
@@ -0,0 +1,14 @@
+ [
+ 'xs' => 'بہت چھوٹا',
+ 'sm' => 'چھوٹا',
+ 'md' => 'درمیانہ',
+ 'lg' => 'بڑا',
+ 'xl' => 'بہت بڑا',
+ '2xl' => 'دوگنا بڑا',
+ ],
+];
diff --git a/resources/lang/uz_Cyrl/translations.php b/resources/lang/uz_Cyrl/translations.php
new file mode 100644
index 0000000..c486ee2
--- /dev/null
+++ b/resources/lang/uz_Cyrl/translations.php
@@ -0,0 +1,14 @@
+ [
+ 'xs' => 'Жуда кичик',
+ 'sm' => 'Кичик',
+ 'md' => 'Ўрта',
+ 'lg' => 'Катта',
+ 'xl' => 'Жуда катта',
+ '2xl' => 'Икки марта катта',
+ ],
+];
diff --git a/resources/lang/uz_Latn/translations.php b/resources/lang/uz_Latn/translations.php
new file mode 100644
index 0000000..8f68c47
--- /dev/null
+++ b/resources/lang/uz_Latn/translations.php
@@ -0,0 +1,14 @@
+ [
+ 'xs' => 'Juda kichik',
+ 'sm' => 'Kichik',
+ 'md' => 'O\'rta',
+ 'lg' => 'Katta',
+ 'xl' => 'Juda katta',
+ '2xl' => 'Ikki marta katta',
+ ],
+];
diff --git a/resources/lang/vi/translations.php b/resources/lang/vi/translations.php
new file mode 100644
index 0000000..3f0e2ac
--- /dev/null
+++ b/resources/lang/vi/translations.php
@@ -0,0 +1,14 @@
+ [
+ 'xs' => 'Rất nhỏ',
+ 'sm' => 'Nhỏ',
+ 'md' => 'Trung bình',
+ 'lg' => 'Lớn',
+ 'xl' => 'Rất lớn',
+ '2xl' => 'Gấp đôi lớn',
+ ],
+];
diff --git a/resources/lang/xh/translations.php b/resources/lang/xh/translations.php
new file mode 100644
index 0000000..73f584d
--- /dev/null
+++ b/resources/lang/xh/translations.php
@@ -0,0 +1,14 @@
+ [
+ 'xs' => 'Encinci kakhulu',
+ 'sm' => 'Encinci',
+ 'md' => 'Phakathi',
+ 'lg' => 'Enkulu',
+ 'xl' => 'Enkulu kakhulu',
+ '2xl' => 'Enkulu kabini',
+ ],
+];
diff --git a/resources/lang/yi/translations.php b/resources/lang/yi/translations.php
new file mode 100644
index 0000000..7a8ce86
--- /dev/null
+++ b/resources/lang/yi/translations.php
@@ -0,0 +1,14 @@
+ [
+ 'xs' => 'זייער קליין',
+ 'sm' => 'קליין',
+ 'md' => 'מיטלער',
+ 'lg' => 'גרויס',
+ 'xl' => 'זייער גרויס',
+ '2xl' => 'טאָפּל גרויס',
+ ],
+];
diff --git a/resources/lang/yo/translations.php b/resources/lang/yo/translations.php
new file mode 100644
index 0000000..f4efb54
--- /dev/null
+++ b/resources/lang/yo/translations.php
@@ -0,0 +1,14 @@
+ [
+ 'xs' => 'Kekere pupọ',
+ 'sm' => 'Kekere',
+ 'md' => 'Arin',
+ 'lg' => 'Nla',
+ 'xl' => 'Nla pupọ',
+ '2xl' => 'Nla meji',
+ ],
+];
diff --git a/resources/lang/zh_CN/translations.php b/resources/lang/zh_CN/translations.php
new file mode 100644
index 0000000..3ac10ff
--- /dev/null
+++ b/resources/lang/zh_CN/translations.php
@@ -0,0 +1,14 @@
+ [
+ 'xs' => '超小',
+ 'sm' => '小',
+ 'md' => '中',
+ 'lg' => '大',
+ 'xl' => '超大',
+ '2xl' => '特大',
+ ],
+];
diff --git a/resources/lang/zh_HK/translations.php b/resources/lang/zh_HK/translations.php
new file mode 100644
index 0000000..e756256
--- /dev/null
+++ b/resources/lang/zh_HK/translations.php
@@ -0,0 +1,14 @@
+ [
+ 'xs' => '超細',
+ 'sm' => '細',
+ 'md' => '中',
+ 'lg' => '大',
+ 'xl' => '超大',
+ '2xl' => '特大',
+ ],
+];
diff --git a/resources/lang/zh_TW/translations.php b/resources/lang/zh_TW/translations.php
new file mode 100644
index 0000000..3ac10ff
--- /dev/null
+++ b/resources/lang/zh_TW/translations.php
@@ -0,0 +1,14 @@
+ [
+ 'xs' => '超小',
+ 'sm' => '小',
+ 'md' => '中',
+ 'lg' => '大',
+ 'xl' => '超大',
+ '2xl' => '特大',
+ ],
+];
diff --git a/resources/lang/zu/translations.php b/resources/lang/zu/translations.php
new file mode 100644
index 0000000..0861f42
--- /dev/null
+++ b/resources/lang/zu/translations.php
@@ -0,0 +1,14 @@
+ [
+ 'xs' => 'Omncane kakhulu',
+ 'sm' => 'Omncane',
+ 'md' => 'Maphakathi',
+ 'lg' => 'Omkhulu',
+ 'xl' => 'Omkhulu kakhulu',
+ '2xl' => 'Omkhulu kabili',
+ ],
+];
diff --git a/resources/stubs/ImageLibraryServiceProvider.php.stub b/resources/stubs/ImageLibraryServiceProvider.php.stub
new file mode 100644
index 0000000..a8f7787
--- /dev/null
+++ b/resources/stubs/ImageLibraryServiceProvider.php.stub
@@ -0,0 +1,21 @@
+label(fn (): string => __('Thumbnail'))
+ ->aspectRatio(AspectRatio::make(1, 1)),
+ ];
+ }
+}
diff --git a/resources/views/.gitkeep b/resources/views/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/resources/views/components/image.blade.php b/resources/views/components/image.blade.php
index 10dd5ea..d05bc0d 100644
--- a/resources/views/components/image.blade.php
+++ b/resources/views/components/image.blade.php
@@ -1,16 +1,24 @@
@php
- $attributes = $attributes->merge([
- 'data-image-library' => 'image',
- 'data-image-library-id' => Str::uuid(),
- ]);
+ $useBreakpoints = $useBreakpoints ?? true;
+ $src = $useBreakpoints ? $image->sourceImage->url() : $image->urlForBreakpoint();
+
+ $attributes = $attributes->merge([
+ 'src' => $src,
+ 'alt' => $image->alt_text ?? $image->sourceImage->alt_text,
+ 'sizes' => $useBreakpoints ? '1px' : null,
+ 'data-image-library' => 'image',
+ 'data-image-library-id' => $image->uuid,
+ ]);
@endphp
-
+
+ @foreach ($sources as $source)
+ media) media="{{ $source->media }}" @endif
+ srcset="{{ $source->srcset }}"
+ type="{{ $source->type }}"
+ @if ($useBreakpoints) sizes="1px" @endif
+ />
+ @endforeach
+
+
diff --git a/resources/views/components/picture.blade.php b/resources/views/components/picture.blade.php
deleted file mode 100644
index 9328f91..0000000
--- a/resources/views/components/picture.blade.php
+++ /dev/null
@@ -1,28 +0,0 @@
-@php
- $attributes = $attributes->merge([
- 'data-image-library' => 'image',
- ]);
-@endphp
-
-
- @if ($srcsetWebp)
-
- @endif
- @if ($srcset && $image)
-
- @endif
-
-
diff --git a/resources/views/components/scripts.blade.php b/resources/views/components/scripts.blade.php
index f444a37..f83da25 100644
--- a/resources/views/components/scripts.blade.php
+++ b/resources/views/components/scripts.blade.php
@@ -1,102 +1,113 @@
-
diff --git a/src/Commands/GenerateCommand.php b/src/Commands/GenerateCommand.php
new file mode 100644
index 0000000..06a0b1c
--- /dev/null
+++ b/src/Commands/GenerateCommand.php
@@ -0,0 +1,50 @@
+argument('imageIds');
+
+ $model = ImageLibrary::getImageModel();
+
+ $query = $model::query()
+ ->when(! empty($imageIds), fn ($query) => $query->whereIn((new $model)->getKeyName(), $imageIds));
+
+ $count = $query->count();
+
+ if ($count === 0) {
+ $this->info('No images found to generate.');
+
+ return Command::SUCCESS;
+ }
+
+ $this->info("Generating {$count} image(s)…");
+ $this->newLine(1);
+
+ $bar = $this->output->createProgressBar($count);
+ $bar->start();
+
+ foreach ($query->cursor() as $image) {
+ $image->generate();
+ $bar->advance();
+ }
+
+ $bar->finish();
+ $this->newLine(2);
+ $this->info('All images generated successfully.');
+
+ return Command::SUCCESS;
+ }
+}
diff --git a/src/Commands/UpgradeCommand.php b/src/Commands/UpgradeCommand.php
new file mode 100644
index 0000000..7b4094c
--- /dev/null
+++ b/src/Commands/UpgradeCommand.php
@@ -0,0 +1,214 @@
+info('The upgrade will go through the following steps:');
+ $this->line('1. Check if an upgrade is needed.');
+ $this->line('2. Create the source_images table migration if it does not exist.');
+ $this->line('3. Create the new images table migration if it does not exist.');
+ $this->line('4. Create pre upgrade migration file if it does not exist. This will rename the existing images table to tmp_images');
+ $this->line('5. Create post upgrade migration file if it does not exist. This will create a source_image for each old image and upload the file to the correct location.');
+ $this->line('6. Ask to run the migrations if any were created.');
+ $this->line('7. Inform you to migrate your custom tables and data as needed.');
+ $this->line('8. Ask to create a cleanup migration to remove old image library data.');
+ $this->line('9. Ask to run the cleanup migration.');
+
+ if (! $this->confirm('Do you wish to proceed with the upgrade?', true)) {
+ $this->warn('Upgrade cancelled.');
+
+ return Command::SUCCESS;
+ }
+
+ $this->info('1. Checking if an upgrade is needed...');
+
+ $imagesTableMigrations = collect(File::files(database_path('migrations')))
+ ->filter(function ($file) {
+ return str_ends_with($file->getFilename(), 'create_images_table.php');
+ })
+ ->sortBy(fn ($file) => $file->getCTime());
+
+ if ($imagesTableMigrations->isEmpty()) {
+ $this->warn('No existing images table migration found. No upgrade needed.');
+
+ return Command::SUCCESS;
+ }
+
+ $this->line('Upgrade needed. Proceeding to the next steps...');
+
+ $hasAddedMigrations = false;
+
+ $this->info('2. Creating source_images table migration if it does not exist...');
+
+ $sourceImagesTableMigrations = collect(File::files(database_path('migrations')))
+ ->filter(function ($file) {
+ return str_ends_with($file->getFilename(), 'create_source_images_table.php');
+ })
+ ->sortBy(fn ($file) => $file->getCTime());
+
+ if ($sourceImagesTableMigrations->isEmpty()) {
+ File::copy(
+ __DIR__.'/../../database/migrations/create_source_images_table.php.stub',
+ database_path('migrations/'.Carbon::now()->format('Y_m_d_His').'_create_source_images_table.php')
+ );
+
+ $this->line('Created source_images table migration.');
+
+ $hasAddedMigrations = true;
+ } else {
+ $this->line('source_images table migration already exists. Skipping...');
+ }
+
+ $this->info('3. Creating new images table migration if it does not exist...');
+
+ if ($imagesTableMigrations->count() === 1) {
+ File::copy(
+ __DIR__.'/../../database/migrations/create_images_table.php.stub',
+ database_path('migrations/'.Carbon::now()->addSecond()->format('Y_m_d_His').'_create_images_table.php')
+ );
+
+ $hasAddedMigrations = true;
+
+ $imagesTableMigrations = collect(File::files(database_path('migrations')))
+ ->filter(function ($file) {
+ return str_ends_with($file->getFilename(), 'create_images_table.php');
+ })
+ ->sortBy(fn ($file) => $file->getCTime());
+
+ $this->line('Created new images table migration.');
+ } else {
+ $this->line('New images table migration already exists. Skipping...');
+ }
+
+ $preUpgradeMigrationExists = collect(File::files(database_path('migrations')))
+ ->contains(function ($file) {
+ return str_ends_with($file->getFilename(), 'pre_image_library_upgrade.php');
+ });
+
+ $postUpgradeMigrationExists = collect(File::files(database_path('migrations')))
+ ->contains(function ($file) {
+ return str_ends_with($file->getFilename(), 'post_image_library_upgrade.php');
+ });
+
+ if (! $preUpgradeMigrationExists || ! $postUpgradeMigrationExists) {
+ $createImagesTableMigrationTimestamp = pathinfo($imagesTableMigrations->last()->getFilename(), PATHINFO_FILENAME);
+ $createImagesTableMigrationTimestamp = mb_substr($createImagesTableMigrationTimestamp, 0, 17);
+ $createImagesTableMigrationTimestamp = Carbon::createFromFormat('Y_m_d_His', $createImagesTableMigrationTimestamp);
+
+ $preUpgradeMigrationPath = __DIR__.'/../../database/migrations/upgrade/pre_image_library_upgrade.php.stub';
+ $postUpgradeMigrationPath = __DIR__.'/../../database/migrations/upgrade/post_image_library_upgrade.php.stub';
+
+ $this->info('4. Creating pre upgrade migration if it does not exist...');
+
+ if (! $preUpgradeMigrationExists) {
+ File::copy(
+ $preUpgradeMigrationPath,
+ database_path('migrations/'.$createImagesTableMigrationTimestamp->subMinute()->format('Y_m_d_His').'_pre_image_library_upgrade.php')
+ );
+
+ $hasAddedMigrations = true;
+
+ $this->line('Created pre upgrade migration.');
+ } else {
+ $this->line('Pre upgrade migration already exists. Skipping...');
+ }
+
+ $this->info('5. Creating post upgrade migration if it does not exist...');
+
+ if (! $postUpgradeMigrationExists) {
+ File::copy(
+ $postUpgradeMigrationPath,
+ database_path('migrations/'.$createImagesTableMigrationTimestamp->addMinute()->format('Y_m_d_His').'_post_image_library_upgrade.php')
+ );
+
+ $hasAddedMigrations = true;
+
+ $this->line('Created post upgrade migration.');
+ } else {
+ $this->line('Post upgrade migration already exists. Skipping...');
+ }
+ } else {
+ $this->info('4. Creating pre upgrade migration if it does not exist...');
+ $this->line('Pre upgrade migration already exists. Skipping...');
+ $this->info('5. Creating post upgrade migration if it does not exist...');
+ $this->line('Post upgrade migration already exists. Skipping...');
+ }
+
+ $this->info('6. Checking if any migrations were created...');
+
+ if ($hasAddedMigrations && $this->confirm('Would you like to run the migrations now?')) {
+ $this->line('Running migrations...');
+
+ $this->call('migrate');
+ } else {
+ $this->line('No new migrations were created or you chose not to run them now.');
+ }
+
+ $this->info('7. Now is the time to migrate your custom tables and data as needed.');
+ $this->line('You should follow the instructions in the README.md to set up your models and Image Contexts.');
+ $this->line('');
+ $this->line('You can use the tmp_images table as a reference for your migrations.');
+ $this->line('The uuid column of the records in the tmp_images table corresponds to the uuid column in the new source_images table.');
+ $this->line('You can use the following id mapping query:');
+ $this->line('');
+ $this->line('$mapping = DB::table(\'tmp_images\')');
+ $this->line(' ->join(\'source_images\', \'tmp_images.uuid\', \'=\', \'source_images.uuid\')');
+ $this->line(' ->pluck("tmp_images.id as old_id", "source_images.id as new_id");');
+ $this->line('');
+ $this->line('Now, for each instance of your models, run the following:');
+ $this->line('');
+ $this->line('foreach (YourModel::cursor() as $model) {');
+ $this->line(' $model->attachImage(');
+ $this->line(' SourceImage::find($mapping[$model->{your_old_id_reference}]), // Replace {your_old_id_reference} with the old image ID reference column');
+ $this->line(' [');
+ $this->line(' \'context\' => \'{your-context-key}\', // Replace {your-context-key} with the appropriate context key');
+ $this->line(' // Add any other attributes you need here');
+ $this->line(' ]');
+ $this->line(' );');
+ $this->line('}');
+ $this->line('');
+ $this->line('Make sure to replace {your_old_id_reference} and {your-context-key} with the appropriate values for your application.');
+
+ $done = false;
+
+ while (! $done) {
+ $done = $this->confirm('Have you completed your custom data migrations?');
+ }
+
+ if ($this->confirm('Would you like to clean up old image library data now?')) {
+ $this->line('Cleaning up old image library data...');
+
+ File::copy(
+ __DIR__.'/../../database/migrations/upgrade/cleanup_image_library_upgrade.php.stub',
+ database_path('migrations/'.Carbon::now()->format('Y_m_d_His').'_cleanup_image_library_upgrade.php')
+ );
+
+ $this->comment('Published the cleanup migration file.');
+
+ if ($this->confirm('Would you like to run the migrations now?')) {
+ $this->comment('Running migrations...');
+
+ $this->call('migrate');
+
+ $this->info('Old image library data cleaned up successfully.');
+ }
+ }
+
+ return Command::SUCCESS;
+ }
+}
+// @codeCoverageIgnoreEnd
diff --git a/src/Components/Image.php b/src/Components/Image.php
index 52fbfd3..4c1c355 100644
--- a/src/Components/Image.php
+++ b/src/Components/Image.php
@@ -1,143 +1,109 @@
title = $this->title ?? $this->image->title ?? null;
- $this->alt = $this->alt ?? $this->image->alt ?? null;
- }
-
- public function render(): View|Closure|string
- {
- return view('image-library::components.image');
- }
+ public ?ImageModel $image = null,
+ ) {}
public function shouldRender(): bool
{
- if (is_null($this->src())) {
- return false;
- }
-
- return true;
+ return ! is_null($this->image);
}
- public function src(): ?string
+ public function render(): View|Closure|string
{
- if ($this->src) {
- return $this->src;
- }
-
- if ($this->conversion) {
- $conversion = $this->getConversion();
-
- if ($conversion) {
- return $this->src = $conversion->getUrl();
- }
-
- if (is_null($this->fallback) && $this->fallbackConversion) {
- $conversion = $this->getFallbackConversion();
-
- if ($conversion) {
- $this->imageConversion = $conversion;
-
- return $this->src = $conversion->getUrl();
- }
- }
+ if ($this->image->context->getUseBreakpoints()) {
+ $useBreakpoints = true;
+
+ $sources = collect(ImageLibrary::getBreakpointEnum()::sortedCases())
+ ->map(function (ConfiguresBreakpoints $case): array {
+ return array_filter([
+ (object) [
+ 'media' => $this->getMediaQueryForBreakpoint($case),
+ 'srcset' => $this->getSrcsetForBreakpoint($case),
+ 'type' => $this->image->sourceImage->mime_type,
+ ],
+ $this->image->context->getGenerateWebP()
+ ? (object) [
+ 'media' => $this->getMediaQueryForBreakpoint($case),
+ 'srcset' => $this->getSrcsetForBreakpoint($case, 'webp'),
+ 'type' => 'image/webp',
+ ]
+ : null,
+ ]);
+ })
+ ->flatten(1);
+ } else {
+ $useBreakpoints = false;
+
+ $sources = array_filter([
+ (object) [
+ 'media' => '',
+ 'srcset' => $this->image->urlForBreakpoint(null),
+ 'type' => $this->image->sourceImage->mime_type,
+ ],
+ $this->image->context->getGenerateWebP()
+ ? (object) [
+ 'media' => '',
+ 'srcset' => $this->image->urlForBreakpoint(null, 'webp'),
+ 'type' => 'image/webp',
+ ]
+ : null,
+ ]);
}
- $src = $this->image?->getUrl();
-
- if ($src) {
- return $this->src = $src;
- }
-
- return $this->src = $this->getSrcByFallback();
+ return view('image-library::components.image', [
+ 'sources' => $sources,
+ 'useBreakpoints' => $useBreakpoints,
+ ]);
}
- public function srcset(): ?string
+ private function getMediaQueryForBreakpoint(ConfiguresBreakpoints $breakpoint): string
{
- $image = $this->imageConversion ?? $this->image;
+ $conditions = [];
- if (is_null($image)) {
- return null;
+ if (array_search($breakpoint, ImageLibrary::getBreakpointEnum()::sortedCases()) !== 0) {
+ $conditions[] = '(min-width: '.$breakpoint->getMinWidth().'px)';
}
- $responsiveVariants = $image->getResponsiveVariants();
-
- if ($responsiveVariants->isEmpty()) {
- return null;
- }
-
- return $responsiveVariants->map(function ($variant) {
- return "{$variant->url} {$variant->width}w";
- })->implode(', ');
- }
-
- public function width(): ?int
- {
- return $this->imageConversion?->width ?? $this->image?->width;
- }
-
- public function height(): ?int
- {
- return $this->imageConversion?->height ?? $this->image?->height;
- }
-
- public function getConversion(): ?ImageConversion
- {
- if ($this->imageConversion) {
- return $this->imageConversion;
+ if (! is_null($breakpoint->getMaxWidth())) {
+ $conditions[] = '(max-width: '.$breakpoint->getMaxWidth().'px)';
}
- return $this->imageConversion = $this->image?->getConversion($this->conversion);
+ return implode(' and ', $conditions);
}
- public function getFallbackConversion(): ?ImageConversion
+ private function getSrcsetForBreakpoint(ConfiguresBreakpoints $breakpoint, ?string $extension = null): string
{
- if ($this->fallbackImageConversion) {
- return $this->fallbackImageConversion;
+ if (! $this->image->context->getGenerateResponsiveVersions()) {
+ return $this->image->urlForBreakpoint($breakpoint, $extension);
}
- return $this->fallbackImageConversion = $this->image?->getConversion($this->fallbackConversion);
- }
-
- public function getSrcByFallback(): ?string
- {
- if ($this->fallback instanceof ModelsImage) {
- $this->image = $this->fallback;
-
- if ($this->getFallbackConversion()) {
- $this->conversion = $this->fallbackConversion;
- }
-
- $this->imageConversion = null;
- $this->fallback = null;
- $this->fallbackImageConversion = null;
+ return $this->image->getResponsiveRelativePathsForBreakpoint($breakpoint, $extension)
+ ->map(function (string $path) use ($breakpoint): string {
+ if (preg_match('/_w(\d+)\./', $path, $m)) {
+ $width = (int) $m[1];
+ } else {
+ $width = $this->image->context->getMaxWidth($breakpoint)
+ ?? $this->image->sourceImage->width;
+ }
- return $this->src();
- }
+ $url = $this->image->urlForRelativePath($path);
- return $this->fallback;
+ return "{$url} {$width}w";
+ })
+ ->implode(', ');
}
}
diff --git a/src/Components/Picture.php b/src/Components/Picture.php
deleted file mode 100644
index 54591ec..0000000
--- a/src/Components/Picture.php
+++ /dev/null
@@ -1,33 +0,0 @@
-imageConversion ?? $this->image;
-
- if (is_null($image)) {
- return null;
- }
-
- $responsiveVariants = $image->getResponsiveVariants(true);
-
- if ($responsiveVariants->isEmpty()) {
- return null;
- }
-
- return $responsiveVariants->map(function ($variant) {
- return "{$variant->url} {$variant->width}w";
- })->implode(', ');
- }
-}
diff --git a/src/Components/Scripts.php b/src/Components/Scripts.php
index fefab0f..d5bf7ae 100644
--- a/src/Components/Scripts.php
+++ b/src/Components/Scripts.php
@@ -1,13 +1,17 @@
option('id');
- $deleteDeprecated = $this->option('delete-deprecated');
-
- if (count($ids) > 0) {
- $images = $this->getImageClass()::whereIn('id', $ids)->get();
- } else {
- $images = $this->getImageClass()::get();
- }
-
- $progressBar = $this->output->createProgressBar(count($images));
- $progressBar->start();
-
- $images->each(function ($image) use ($progressBar, $deleteDeprecated) {
- $image->createOrUpdateConversions($deleteDeprecated);
- $progressBar->advance();
- $this->info(" Created conversions for {$image->id}.");
- });
-
- $progressBar->finish();
-
- $this->info(PHP_EOL . 'Conversions created.');
- }
-
- public function getImageClass(): string
- {
- return config('image-library.models.image');
- }
-}
diff --git a/src/Console/Commands/GenerateConversions.php b/src/Console/Commands/GenerateConversions.php
deleted file mode 100644
index 4718c67..0000000
--- a/src/Console/Commands/GenerateConversions.php
+++ /dev/null
@@ -1,48 +0,0 @@
-option('id');
- $force = $this->option('force');
-
- if (count($ids) > 0) {
- $images = $this->getImageClass()::whereIn('id', $ids)
- ->with('conversions')
- ->get();
- } else {
- $images = $this->getImageClass()::query()
- ->with('conversions')
- ->get();
- }
-
- $progressBar = $this->output->createProgressBar(count($images));
- $progressBar->start();
-
- $images->each(function ($image) use ($progressBar, $force) {
- $image->conversions->each(function ($conversion) use ($force) {
- $conversion->generate($force);
- });
- $progressBar->advance();
- $this->info(" Generated conversions for {$image->id}.");
- });
-
- $progressBar->finish();
-
- $this->info(PHP_EOL . 'Conversions generated.');
- }
-
- public function getImageClass(): string
- {
- return config('image-library.models.image');
- }
-}
diff --git a/src/Console/Commands/GenerateResponsiveVariants.php b/src/Console/Commands/GenerateResponsiveVariants.php
deleted file mode 100644
index 7c47eb4..0000000
--- a/src/Console/Commands/GenerateResponsiveVariants.php
+++ /dev/null
@@ -1,49 +0,0 @@
-option('id');
- $force = $this->option('force');
-
- if (count($ids) > 0) {
- $images = $this->getImageClass()::whereIn('id', $ids)
- ->with('conversions')
- ->get();
- } else {
- $images = $this->getImageClass()::query()
- ->with('conversions')
- ->get();
- }
-
- $progressBar = $this->output->createProgressBar(count($images));
- $progressBar->start();
-
- $images->each(function ($image) use ($progressBar, $force) {
- $image->GenerateResponsiveVariants($force);
- $image->conversions->each(function ($conversion) use ($force) {
- $conversion->GenerateResponsiveVariants($force);
- });
- $progressBar->advance();
- $this->info(" Generated responsive variants for {$image->id}.");
- });
-
- $progressBar->finish();
-
- $this->info(PHP_EOL . 'Responsive variants generated.');
- }
-
- public function getImageClass(): string
- {
- return config('image-library.models.image');
- }
-}
diff --git a/src/Contracts/ConfiguresBreakpoints.php b/src/Contracts/ConfiguresBreakpoints.php
new file mode 100644
index 0000000..58afee3
--- /dev/null
+++ b/src/Contracts/ConfiguresBreakpoints.php
@@ -0,0 +1,20 @@
+horizontal = $horizontal;
+ $this->vertical = $vertical;
}
- public static function fromString(string $string): self
+ public function __toString(): string
{
- $parts = explode(':', $string);
-
- return new self((int) $parts[0] ?? null, (int) $parts[1] ?? null);
+ return "{$this->horizontal}:{$this->vertical}";
}
- public function setX(int $x): self
+ public static function make(int $horizontal, int $vertical): self
{
- $this->x = $x;
-
- return $this;
+ return new self($horizontal, $vertical);
}
- public function setY(int $y): self
+ public function toString(): string
{
- $this->y = $y;
-
- return $this;
+ return (string) $this;
}
- public function __toString(): string
+ public function toFloat(): float
{
- return "{$this->x}:{$this->y}";
+ return round($this->horizontal / $this->vertical, 2);
}
- public function validate(bool $throwExceptions = false): bool
+ public function toArray(): array
{
- try {
- if (is_null($this->x) || is_null($this->y)) {
- throw new \InvalidArgumentException('Aspect ratio must have both X and Y set');
- }
-
- if ($this->x <= 0) {
- throw new \InvalidArgumentException('Aspect ratio X must be greater than 0');
- }
-
- if ($this->y <= 0) {
- throw new \InvalidArgumentException('Aspect ratio Y must be greater than 0');
- }
-
- return true;
- } catch (\InvalidArgumentException $e) {
- if ($throwExceptions) {
- throw $e;
- }
-
- return false;
- }
+ return [
+ 'horizontal' => $this->horizontal,
+ 'vertical' => $this->vertical,
+ ];
}
}
diff --git a/src/Entities/ConversionDefinition.php b/src/Entities/ConversionDefinition.php
deleted file mode 100644
index 40192d0..0000000
--- a/src/Entities/ConversionDefinition.php
+++ /dev/null
@@ -1,186 +0,0 @@
-aspectRatio($aspect_ratio);
- }
-
- $this->effects($effects);
-
- $this->labelValue = $this->labelValue ?: $this->name;
- }
-
- public static function make(
- string $name = '',
- string $label = '',
- AspectRatio|array|string|null $aspect_ratio = null,
- ?int $default_width = null,
- ?int $default_height = null,
- Effects|array $effects = [],
- bool $doTranslateLabel = false,
- bool $createSync = false,
- ): self {
- return new self(
- $name,
- $label,
- $aspect_ratio,
- $default_width,
- $default_height,
- $effects,
- $doTranslateLabel,
- $createSync,
- );
- }
-
- public static function fromArray(array $data): self
- {
- return new self(
- $data['name'] ?? null,
- $data['label'] ?? null,
- $data['aspect_ratio'] ?? null,
- $data['default_width'] ?? null,
- $data['default_height'] ?? null,
- $data['effects'] ?? [],
- $data['do_translate_label'] ?? false,
- $data['create_sync'] ?? false,
- );
- }
-
- public static function get(string $name): self
- {
- return ImageLibrary::getConversionDefinition($name);
- }
-
- public function __get(string $key): mixed
- {
- if ($key === 'label') {
- if (blank($this->labelValue)) {
- return $this->name;
- }
-
- return $this->do_translate_label ? __($this->labelValue) : $this->labelValue;
- }
-
- return null;
- }
-
- public function name(string $name): self
- {
- $this->name = $name;
-
- return $this;
- }
-
- public function label(string $label): self
- {
- $this->labelValue = $label;
-
- return $this;
- }
-
- public function translateLabel(bool $doTranslateLabel = true): self
- {
- $this->do_translate_label = $doTranslateLabel;
-
- return $this;
- }
-
- public function aspectRatio(AspectRatio|array|string $aspectRatio): self
- {
- if (is_array($aspectRatio)) {
- $aspectRatio = AspectRatio::fromArray($aspectRatio);
- }
-
- if (is_string($aspectRatio)) {
- $aspectRatio = AspectRatio::fromString($aspectRatio);
- }
-
- $this->aspect_ratio = $aspectRatio;
-
- return $this;
- }
-
- public function defaultWidth(int $defaultWidth): self
- {
- $this->default_width = $defaultWidth;
-
- return $this;
- }
-
- public function defaultHeight(int $defaultHeight): self
- {
- $this->default_height = $defaultHeight;
-
- return $this;
- }
-
- public function effects(Effects|array $effects): self
- {
- if (is_array($effects)) {
- $effects = Effects::fromArray($effects);
- }
-
- $this->effects = $effects;
-
- return $this;
- }
-
- public function createSync(bool $createSync = true): self
- {
- $this->create_sync = $createSync;
-
- return $this;
- }
-
- public function validate(bool $throwExceptions = false): bool
- {
- try {
- if (is_null($this->name)) {
- throw new \InvalidArgumentException('Conversion definition must have a name');
- }
-
- if (is_null($this->aspect_ratio)) {
- throw new \InvalidArgumentException('Conversion definition must have an aspect ratio');
- }
-
- $this->aspect_ratio->validate();
-
- $this->effects->validate();
-
- return true;
- } catch (\Exception $e) {
- if ($throwExceptions) {
- throw $e;
- }
-
- return false;
- }
- }
-
- public function toArray(): array
- {
- return [
- 'name' => $this->name,
- 'label' => $this->label,
- 'aspect_ratio' => (string) $this->aspect_ratio,
- 'default_width' => $this->default_width,
- 'default_height' => $this->default_height,
- 'effects' => $this->effects->toArray(),
- ];
- }
-}
diff --git a/src/Entities/CropData.php b/src/Entities/CropData.php
new file mode 100644
index 0000000..cf5b939
--- /dev/null
+++ b/src/Entities/CropData.php
@@ -0,0 +1,51 @@
+width = $width;
+ $this->height = $height;
+ $this->x = $x;
+ $this->y = $y;
+ $this->rotate = $rotate;
+ $this->scaleX = $scaleX;
+ $this->scaleY = $scaleY;
+ }
+
+ public static function make(int $width, int $height, ?int $x = null, ?int $y = null, int $rotate = 0, int $scaleX = 1, int $scaleY = 1): self
+ {
+ return new self($width, $height, $x, $y, $rotate, $scaleX, $scaleY);
+ }
+
+ public function toArray(): array
+ {
+ return [
+ 'width' => $this->width,
+ 'height' => $this->height,
+ 'x' => $this->x,
+ 'y' => $this->y,
+ 'rotate' => $this->rotate,
+ 'scaleX' => $this->scaleX,
+ 'scaleY' => $this->scaleY,
+ ];
+ }
+}
diff --git a/src/Entities/Effects.php b/src/Entities/Effects.php
deleted file mode 100644
index d30202e..0000000
--- a/src/Entities/Effects.php
+++ /dev/null
@@ -1,102 +0,0 @@
-blur = $amount;
-
- return $this;
- }
-
- public function pixelate(int $amount): self
- {
- $this->pixelate = $amount;
-
- return $this;
- }
-
- public function greyscale(bool $greyscale = true): self
- {
- $this->greyscale = $greyscale;
-
- return $this;
- }
-
- public function sepia(bool $sepia = true): self
- {
- $this->sepia = $sepia;
-
- return $this;
- }
-
- public function sharpen(int $amount): self
- {
- $this->sharpen = $amount;
-
- return $this;
- }
-
- public function toArray(): array
- {
- return [
- 'blur' => $this->blur,
- 'pixelate' => $this->pixelate,
- 'greyscale' => $this->greyscale,
- 'sepia' => $this->sepia,
- 'sharpen' => $this->sharpen,
- ];
- }
-
- public function validate(bool $throwExceptions = false): bool
- {
- try {
- if ($this->blur < 0 || $this->blur > 100) {
- throw new \InvalidArgumentException('Blur amount must be between 0 and 100');
- }
-
- if ($this->pixelate < 0 || $this->pixelate > 100) {
- throw new \InvalidArgumentException('Pixelate amount must be between 0 and 100');
- }
-
- if ($this->sharpen < 0 || $this->sharpen > 100) {
- throw new \InvalidArgumentException('Sharpen amount must be between 0 and 100');
- }
-
- return true;
- } catch (\Exception $e) {
- if ($throwExceptions) {
- throw $e;
- }
-
- return false;
- }
- }
-}
diff --git a/src/Entities/ImageContext.php b/src/Entities/ImageContext.php
new file mode 100644
index 0000000..94cf20a
--- /dev/null
+++ b/src/Entities/ImageContext.php
@@ -0,0 +1,1225 @@
+ */
+ protected array $aspectRatioByBreakpoint = [];
+
+ /** @var array> */
+ protected array $minWidthByBreakpoint = [];
+
+ /** @var array> */
+ protected array $maxWidthByBreakpoint = [];
+
+ /** @var array */
+ protected array $cropPositionByBreakpoint = [];
+
+ /** @var array */
+ protected array $blurByBreakpoint = [];
+
+ /** @var array */
+ protected array $greyscaleByBreakpoint = [];
+
+ /** @var array */
+ protected array $sepiaByBreakpoint = [];
+
+ protected bool $allowsMultiple = false;
+
+ protected ?bool $useBreakpoints = null;
+
+ protected ?bool $generateWebP = null;
+
+ protected ?bool $generateResponsiveVersions = null;
+
+ public function __construct(string $key)
+ {
+ $this->key = $key;
+ }
+
+ public static function make(string $key): self
+ {
+ return new self($key);
+ }
+
+ public function getKey(): string
+ {
+ return $this->key;
+ }
+
+ public function getConfigurationHash(): string
+ {
+ return md5(json_encode($this->toArray(), JSON_THROW_ON_ERROR));
+ }
+
+ public function label(string|Closure|null $label): self
+ {
+ $this->label = $label;
+
+ return $this;
+ }
+
+ public function getLabel(): ?string
+ {
+ return blank($this->label)
+ ? Str::title(str_replace('_', ' ', $this->key))
+ : $this->evaluate($this->label);
+ }
+
+ /** @param AspectRatio|array $aspectRatio */
+ public function aspectRatio(AspectRatio|array $aspectRatio): self
+ {
+ if (! $this->getUseBreakpoints()) {
+ if (is_array($aspectRatio)) {
+ throw new InvalidArgumentException("Aspect ratio must be an instance of AspectRatio when breakpoints are disabled for ImageContext with key '{$this->key}'.");
+ }
+
+ $this->aspectRatioByBreakpoint = [
+ 'default' => $aspectRatio,
+ ];
+
+ return $this;
+ }
+
+ $this->aspectRatioByBreakpoint = $this->getBreakpoints()
+ ->mapWithKeys(function (ConfiguresBreakpoints $breakpoint) use ($aspectRatio) {
+ if (is_array($aspectRatio)) {
+ if (! array_key_exists($breakpoint->value, $aspectRatio)) {
+ throw new InvalidArgumentException("Aspect ratio for breakpoint '{$breakpoint->value}' is not defined for ImageContext with key '{$this->key}'.");
+ }
+
+ return [$breakpoint->value => $aspectRatio[$breakpoint->value]];
+ }
+
+ return [$breakpoint->value => $aspectRatio];
+ })
+ ->all();
+
+ return $this;
+ }
+
+ public function aspectRatioForBreakpoint(ConfiguresBreakpoints $breakpoint, AspectRatio $aspectRatio): self
+ {
+ if (! $this->getUseBreakpoints()) {
+ throw new InvalidArgumentException("Cannot set aspect ratio for breakpoint when breakpoints are disabled for ImageContext with key '{$this->key}'.");
+ }
+
+ $this->aspectRatioByBreakpoint[$breakpoint->value] = $aspectRatio;
+
+ return $this;
+ }
+
+ public function aspectRatioFromBreakpoint(ConfiguresBreakpoints $breakpoint, AspectRatio $aspectRatio): self
+ {
+ if (! $this->getUseBreakpoints()) {
+ throw new InvalidArgumentException("Cannot set aspect ratio from breakpoint when breakpoints are disabled for ImageContext with key '{$this->key}'.");
+ }
+
+ $index = $this->getBreakpoints()
+ ->search(function (ConfiguresBreakpoints $bp) use ($breakpoint) {
+ return $bp->value === $breakpoint->value;
+ });
+
+ $this->getBreakpoints()
+ ->slice($index)
+ ->each(function (ConfiguresBreakpoints $bp) use ($aspectRatio) {
+ $this->aspectRatioByBreakpoint[$bp->value] = $aspectRatio;
+ });
+
+ return $this;
+ }
+
+ public function aspectRatioToBreakpoint(ConfiguresBreakpoints $breakpoint, AspectRatio $aspectRatio): self
+ {
+ if (! $this->getUseBreakpoints()) {
+ throw new InvalidArgumentException("Cannot set aspect ratio to breakpoint when breakpoints are disabled for ImageContext with key '{$this->key}'.");
+ }
+
+ $index = $this->getBreakpoints()
+ ->search(function (ConfiguresBreakpoints $bp) use ($breakpoint) {
+ return $bp->value === $breakpoint->value;
+ });
+
+ $this->getBreakpoints()
+ ->slice(0, $index + 1)
+ ->each(function (ConfiguresBreakpoints $bp) use ($aspectRatio) {
+ $this->aspectRatioByBreakpoint[$bp->value] = $aspectRatio;
+ });
+
+ return $this;
+ }
+
+ public function aspectRatioBetweenBreakpoints(ConfiguresBreakpoints $startBreakpoint, ConfiguresBreakpoints $endBreakpoint, AspectRatio $aspectRatio): self
+ {
+ if (! $this->getUseBreakpoints()) {
+ throw new InvalidArgumentException("Cannot set aspect ratio between breakpoints when breakpoints are disabled for ImageContext with key '{$this->key}'.");
+ }
+
+ $breakpoints = $this->getBreakpoints();
+
+ $startIndex = $breakpoints->search(function (ConfiguresBreakpoints $bp) use ($startBreakpoint) {
+ return $bp->value === $startBreakpoint->value;
+ });
+
+ $endIndex = $breakpoints->search(function (ConfiguresBreakpoints $bp) use ($endBreakpoint) {
+ return $bp->value === $endBreakpoint->value;
+ });
+
+ $breakpoints
+ ->slice($startIndex, $endIndex - $startIndex + 1)
+ ->each(function (ConfiguresBreakpoints $bp) use ($aspectRatio) {
+ $this->aspectRatioByBreakpoint[$bp->value] = $aspectRatio;
+ });
+
+ return $this;
+ }
+
+ public function getAspectRatio(?ConfiguresBreakpoints $breakpoint = null): ?AspectRatio
+ {
+ if (! $this->getUseBreakpoints()) {
+ return $this->aspectRatioByBreakpoint['default'] ?? null;
+ }
+
+ if (is_null($breakpoint)) {
+ throw new InvalidArgumentException("Breakpoint is required when breakpoints are enabled for ImageContext with key '{$this->key}'.");
+ }
+
+ return $this->aspectRatioByBreakpoint[$breakpoint->value] ?? null;
+ }
+
+ /** @return array */
+ public function getAspectRatioByBreakpoint(): array
+ {
+ if (! $this->getUseBreakpoints()) {
+ throw new InvalidArgumentException("Cannot get aspect ratios by breakpoint when breakpoints are disabled for ImageContext with key '{$this->key}'.");
+ }
+
+ return $this->aspectRatioByBreakpoint;
+ }
+
+ /** @param int|array $minWidth */
+ public function minWidth(int|array $minWidth): self
+ {
+ if (! $this->getUseBreakpoints()) {
+ if (is_array($minWidth)) {
+ throw new InvalidArgumentException("Min width must be an integer when breakpoints are disabled for ImageContext with key '{$this->key}'.");
+ }
+
+ $this->minWidthByBreakpoint = [
+ 'default' => $minWidth,
+ ];
+
+ return $this;
+ }
+
+ $this->minWidthByBreakpoint = $this->getBreakpoints()
+ ->mapWithKeys(function (ConfiguresBreakpoints $breakpoint) use ($minWidth) {
+ if (is_array($minWidth)) {
+ if (! array_key_exists($breakpoint->value, $minWidth)) {
+ throw new InvalidArgumentException("Min width for breakpoint '{$breakpoint->value}' is not defined for ImageContext with key '{$this->key}'.");
+ }
+
+ return [$breakpoint->value => $minWidth[$breakpoint->value]];
+ }
+
+ return [$breakpoint->value => $minWidth];
+ })
+ ->all();
+
+ return $this;
+ }
+
+ public function minWidthForBreakpoint(ConfiguresBreakpoints $breakpoint, int $minWidth): self
+ {
+ if (! $this->getUseBreakpoints()) {
+ throw new InvalidArgumentException("Cannot set min width for breakpoint when breakpoints are disabled for ImageContext with key '{$this->key}'.");
+ }
+
+ $this->minWidthByBreakpoint[$breakpoint->value] = $minWidth;
+
+ return $this;
+ }
+
+ public function minWidthFromBreakpoint(ConfiguresBreakpoints $breakpoint, int $minWidth): self
+ {
+ if (! $this->getUseBreakpoints()) {
+ throw new InvalidArgumentException("Cannot set min width from breakpoint when breakpoints are disabled for ImageContext with key '{$this->key}'.");
+ }
+
+ $index = $this->getBreakpoints()
+ ->search(function (ConfiguresBreakpoints $bp) use ($breakpoint) {
+ return $bp->value === $breakpoint->value;
+ });
+
+ $this->getBreakpoints()
+ ->slice($index)
+ ->each(function (ConfiguresBreakpoints $bp) use ($minWidth) {
+ $this->minWidthByBreakpoint[$bp->value] = $minWidth;
+ });
+
+ return $this;
+ }
+
+ public function minWidthToBreakpoint(ConfiguresBreakpoints $breakpoint, int $minWidth): self
+ {
+ if (! $this->getUseBreakpoints()) {
+ throw new InvalidArgumentException("Cannot set min width to breakpoint when breakpoints are disabled for ImageContext with key '{$this->key}'.");
+ }
+
+ $index = $this->getBreakpoints()
+ ->search(function (ConfiguresBreakpoints $bp) use ($breakpoint) {
+ return $bp->value === $breakpoint->value;
+ });
+
+ $this->getBreakpoints()
+ ->slice(0, $index + 1)
+ ->each(function (ConfiguresBreakpoints $bp) use ($minWidth) {
+ $this->minWidthByBreakpoint[$bp->value] = $minWidth;
+ });
+
+ return $this;
+ }
+
+ public function minWidthBetweenBreakpoints(ConfiguresBreakpoints $startBreakpoint, ConfiguresBreakpoints $endBreakpoint, int $minWidth): self
+ {
+ if (! $this->getUseBreakpoints()) {
+ throw new InvalidArgumentException("Cannot set min width between breakpoints when breakpoints are disabled for ImageContext with key '{$this->key}'.");
+ }
+
+ $breakpoints = $this->getBreakpoints();
+
+ $startIndex = $breakpoints->search(function (ConfiguresBreakpoints $bp) use ($startBreakpoint) {
+ return $bp->value === $startBreakpoint->value;
+ });
+
+ $endIndex = $breakpoints->search(function (ConfiguresBreakpoints $bp) use ($endBreakpoint) {
+ return $bp->value === $endBreakpoint->value;
+ });
+
+ $breakpoints
+ ->slice($startIndex, $endIndex - $startIndex + 1)
+ ->each(function (ConfiguresBreakpoints $bp) use ($minWidth) {
+ $this->minWidthByBreakpoint[$bp->value] = $minWidth;
+ });
+
+ return $this;
+ }
+
+ public function getMinWidth(?ConfiguresBreakpoints $breakpoint = null): ?int
+ {
+ if (! $this->getUseBreakpoints()) {
+ return $this->minWidthByBreakpoint['default'] ?? null;
+ }
+
+ if (is_null($breakpoint)) {
+ throw new InvalidArgumentException("Breakpoint is required when breakpoints are enabled for ImageContext with key '{$this->key}'.");
+ }
+
+ return $this->minWidthByBreakpoint[$breakpoint->value] ?? null;
+ }
+
+ /** @return array */
+ public function getMinWidthByBreakpoint(): array
+ {
+ if (! $this->getUseBreakpoints()) {
+ throw new InvalidArgumentException("Cannot get min widths by breakpoint when breakpoints are disabled for ImageContext with key '{$this->key}'.");
+ }
+
+ return $this->minWidthByBreakpoint;
+ }
+
+ /** @param int|array $maxWidth */
+ public function maxWidth(int|array $maxWidth): self
+ {
+ if (! $this->getUseBreakpoints()) {
+ if (is_array($maxWidth)) {
+ throw new InvalidArgumentException("Max width must be an integer when breakpoints are disabled for ImageContext with key '{$this->key}'.");
+ }
+
+ $this->maxWidthByBreakpoint = [
+ 'default' => $maxWidth,
+ ];
+
+ return $this;
+ }
+
+ $this->maxWidthByBreakpoint = $this->getBreakpoints()
+ ->mapWithKeys(function (ConfiguresBreakpoints $breakpoint) use ($maxWidth) {
+ if (is_array($maxWidth)) {
+ if (! array_key_exists($breakpoint->value, $maxWidth)) {
+ throw new InvalidArgumentException("Max width for breakpoint '{$breakpoint->value}' is not defined for ImageContext with key '{$this->key}'.");
+ }
+ }
+
+ return [$breakpoint->value => is_array($maxWidth) ? $maxWidth[$breakpoint->value] : $maxWidth];
+ })
+ ->all();
+
+ return $this;
+ }
+
+ public function maxWidthForBreakpoint(ConfiguresBreakpoints $breakpoint, int $maxWidth): self
+ {
+ if (! $this->getUseBreakpoints()) {
+ throw new InvalidArgumentException("Cannot set max width for breakpoint when breakpoints are disabled for ImageContext with key '{$this->key}'.");
+ }
+
+ $this->maxWidthByBreakpoint[$breakpoint->value] = $maxWidth;
+
+ return $this;
+ }
+
+ public function maxWidthFromBreakpoint(ConfiguresBreakpoints $breakpoint, int $maxWidth): self
+ {
+ if (! $this->getUseBreakpoints()) {
+ throw new InvalidArgumentException("Cannot set max width from breakpoint when breakpoints are disabled for ImageContext with key '{$this->key}'.");
+ }
+
+ $index = $this->getBreakpoints()
+ ->search(function (ConfiguresBreakpoints $bp) use ($breakpoint) {
+ return $bp->value === $breakpoint->value;
+ });
+
+ $this->getBreakpoints()
+ ->slice($index)
+ ->each(function (ConfiguresBreakpoints $bp) use ($maxWidth) {
+ $this->maxWidthByBreakpoint[$bp->value] = $maxWidth;
+ });
+
+ return $this;
+ }
+
+ public function maxWidthToBreakpoint(ConfiguresBreakpoints $breakpoint, int $maxWidth): self
+ {
+ if (! $this->getUseBreakpoints()) {
+ throw new InvalidArgumentException("Cannot set max width to breakpoint when breakpoints are disabled for ImageContext with key '{$this->key}'.");
+ }
+
+ $index = $this->getBreakpoints()
+ ->search(function (ConfiguresBreakpoints $bp) use ($breakpoint) {
+ return $bp->value === $breakpoint->value;
+ });
+
+ $this->getBreakpoints()
+ ->slice(0, $index + 1)
+ ->each(function (ConfiguresBreakpoints $bp) use ($maxWidth) {
+ $this->maxWidthByBreakpoint[$bp->value] = $maxWidth;
+ });
+
+ return $this;
+ }
+
+ public function maxWidthBetweenBreakpoints(ConfiguresBreakpoints $startBreakpoint, ConfiguresBreakpoints $endBreakpoint, int $maxWidth): self
+ {
+ if (! $this->getUseBreakpoints()) {
+ throw new InvalidArgumentException("Cannot set max width between breakpoints when breakpoints are disabled for ImageContext with key '{$this->key}'.");
+ }
+
+ $breakpoints = $this->getBreakpoints();
+
+ $startIndex = $breakpoints->search(function (ConfiguresBreakpoints $bp) use ($startBreakpoint) {
+ return $bp->value === $startBreakpoint->value;
+ });
+
+ $endIndex = $breakpoints->search(function (ConfiguresBreakpoints $bp) use ($endBreakpoint) {
+ return $bp->value === $endBreakpoint->value;
+ });
+
+ $breakpoints
+ ->slice($startIndex, $endIndex - $startIndex + 1)
+ ->each(function (ConfiguresBreakpoints $bp) use ($maxWidth) {
+ $this->maxWidthByBreakpoint[$bp->value] = $maxWidth;
+ });
+
+ return $this;
+ }
+
+ public function getMaxWidth(?ConfiguresBreakpoints $breakpoint = null): ?int
+ {
+ if (! $this->getUseBreakpoints()) {
+ return $this->maxWidthByBreakpoint['default'] ?? null;
+ }
+
+ if (is_null($breakpoint)) {
+ throw new InvalidArgumentException("Breakpoint is required when breakpoints are enabled for ImageContext with key '{$this->key}'.");
+ }
+
+ return $this->maxWidthByBreakpoint[$breakpoint->value] ?? null;
+ }
+
+ /** @return array */
+ public function getMaxWidthByBreakpoint(): array
+ {
+ if (! $this->getUseBreakpoints()) {
+ throw new InvalidArgumentException("Cannot get max widths by breakpoint when breakpoints are disabled for ImageContext with key '{$this->key}'.");
+ }
+
+ return $this->maxWidthByBreakpoint;
+ }
+
+ /** @param CropPosition|string|array $cropPosition */
+ public function cropPosition(CropPosition|string|array $cropPosition): self
+ {
+ if (! $this->getUseBreakpoints()) {
+ if (is_array($cropPosition)) {
+ throw new InvalidArgumentException("Crop position must be an instance of CropPosition or string when breakpoints are disabled for ImageContext with key '{$this->key}'.");
+ }
+
+ $cropPosition = $cropPosition instanceof CropPosition ? $cropPosition : CropPosition::from($cropPosition);
+
+ $this->cropPositionByBreakpoint = [
+ 'default' => $cropPosition,
+ ];
+
+ return $this;
+ }
+
+ $this->cropPositionByBreakpoint = $this->getBreakpoints()
+ ->mapWithKeys(function (ConfiguresBreakpoints $breakpoint) use ($cropPosition) {
+ if (is_array($cropPosition)) {
+ if (! array_key_exists($breakpoint->value, $cropPosition)) {
+ throw new InvalidArgumentException("Crop position for breakpoint '{$breakpoint->value}' is not defined for ImageContext with key '{$this->key}'.");
+ }
+
+ $cropPosition = $cropPosition[$breakpoint->value];
+ $cropPosition = $cropPosition instanceof CropPosition ? $cropPosition : CropPosition::from($cropPosition);
+
+ return [$breakpoint->value => $cropPosition];
+ }
+
+ $cropPosition = $cropPosition instanceof CropPosition ? $cropPosition : CropPosition::from($cropPosition);
+
+ return [$breakpoint->value => $cropPosition];
+ })
+ ->all();
+
+ return $this;
+ }
+
+ public function cropPositionForBreakpoint(ConfiguresBreakpoints $breakpoint, CropPosition|string $cropPosition): self
+ {
+ if (! $this->getUseBreakpoints()) {
+ throw new InvalidArgumentException("Cannot set crop position for breakpoint when breakpoints are disabled for ImageContext with key '{$this->key}'.");
+ }
+
+ $cropPosition = $cropPosition instanceof CropPosition ? $cropPosition : CropPosition::from($cropPosition);
+
+ $this->cropPositionByBreakpoint[$breakpoint->value] = $cropPosition;
+
+ return $this;
+ }
+
+ public function cropPositionFromBreakpoint(ConfiguresBreakpoints $breakpoint, CropPosition|string $cropPosition): self
+ {
+ if (! $this->getUseBreakpoints()) {
+ throw new InvalidArgumentException("Cannot set crop position from breakpoint when breakpoints are disabled for ImageContext with key '{$this->key}'.");
+ }
+
+ $cropPosition = $cropPosition instanceof CropPosition ? $cropPosition : CropPosition::from($cropPosition);
+
+ $index = $this->getBreakpoints()
+ ->search(function (ConfiguresBreakpoints $bp) use ($breakpoint) {
+ return $bp->value === $breakpoint->value;
+ });
+
+ $this->getBreakpoints()
+ ->slice($index)
+ ->each(function (ConfiguresBreakpoints $bp) use ($cropPosition) {
+ $this->cropPositionByBreakpoint[$bp->value] = $cropPosition;
+ });
+
+ return $this;
+ }
+
+ public function cropPositionToBreakpoint(ConfiguresBreakpoints $breakpoint, CropPosition|string $cropPosition): self
+ {
+ if (! $this->getUseBreakpoints()) {
+ throw new InvalidArgumentException("Cannot set crop position to breakpoint when breakpoints are disabled for ImageContext with key '{$this->key}'.");
+ }
+
+ $cropPosition = $cropPosition instanceof CropPosition ? $cropPosition : CropPosition::from($cropPosition);
+
+ $index = $this->getBreakpoints()
+ ->search(function (ConfiguresBreakpoints $bp) use ($breakpoint) {
+ return $bp->value === $breakpoint->value;
+ });
+
+ $this->getBreakpoints()
+ ->slice(0, $index + 1)
+ ->each(function (ConfiguresBreakpoints $bp) use ($cropPosition) {
+ $this->cropPositionByBreakpoint[$bp->value] = $cropPosition;
+ });
+
+ return $this;
+ }
+
+ public function cropPositionBetweenBreakpoints(ConfiguresBreakpoints $startBreakpoint, ConfiguresBreakpoints $endBreakpoint, CropPosition|string $cropPosition): self
+ {
+ if (! $this->getUseBreakpoints()) {
+ throw new InvalidArgumentException("Cannot set crop position between breakpoints when breakpoints are disabled for ImageContext with key '{$this->key}'.");
+ }
+
+ $cropPosition = $cropPosition instanceof CropPosition ? $cropPosition : CropPosition::from($cropPosition);
+
+ $breakpoints = $this->getBreakpoints();
+
+ $startIndex = $breakpoints->search(function (ConfiguresBreakpoints $bp) use ($startBreakpoint) {
+ return $bp->value === $startBreakpoint->value;
+ });
+
+ $endIndex = $breakpoints->search(function (ConfiguresBreakpoints $bp) use ($endBreakpoint) {
+ return $bp->value === $endBreakpoint->value;
+ });
+
+ $breakpoints
+ ->slice($startIndex, $endIndex - $startIndex + 1)
+ ->each(function (ConfiguresBreakpoints $bp) use ($cropPosition) {
+ $this->cropPositionByBreakpoint[$bp->value] = $cropPosition;
+ });
+
+ return $this;
+ }
+
+ public function getCropPosition(?ConfiguresBreakpoints $breakpoint = null): ?CropPosition
+ {
+ if (! $this->getUseBreakpoints()) {
+ return $this->cropPositionByBreakpoint['default'] ?? ImageLibrary::getDefaultCropPosition();
+ }
+
+ if (is_null($breakpoint)) {
+ throw new InvalidArgumentException("Breakpoint is required when breakpoints are enabled for ImageContext with key '{$this->key}'.");
+ }
+
+ return $this->cropPositionByBreakpoint[$breakpoint->value] ?? ImageLibrary::getDefaultCropPosition();
+ }
+
+ /** @return array */
+ public function getCropPositionByBreakpoint(): array
+ {
+ if (! $this->getUseBreakpoints()) {
+ throw new InvalidArgumentException("Cannot get crop positions by breakpoint when breakpoints are disabled for ImageContext with key '{$this->key}'.");
+ }
+
+ return $this->cropPositionByBreakpoint;
+ }
+
+ /** @param int|array $blur */
+ public function blur(int|array $blur): self
+ {
+ if (! $this->getUseBreakpoints()) {
+ if (is_array($blur)) {
+ throw new InvalidArgumentException("Blur must be an integer when breakpoints are disabled for ImageContext with key '{$this->key}'.");
+ }
+
+ $this->validateBlurValue($blur);
+
+ $this->blurByBreakpoint = [
+ 'default' => $blur,
+ ];
+
+ return $this;
+ }
+
+ $this->blurByBreakpoint = $this->getBreakpoints()
+ ->mapWithKeys(function (ConfiguresBreakpoints $breakpoint) use ($blur) {
+ if (is_array($blur)) {
+ if (! array_key_exists($breakpoint->value, $blur)) {
+ throw new InvalidArgumentException("Blur for breakpoint '{$breakpoint->value}' is not defined for ImageContext with key '{$this->key}'.");
+ }
+
+ $blurValue = $blur[$breakpoint->value];
+
+ $this->validateBlurValue($blurValue, $breakpoint);
+
+ return [$breakpoint->value => $blurValue];
+ }
+
+ $this->validateBlurValue($blur);
+
+ return [$breakpoint->value => $blur];
+ })
+ ->all();
+
+ return $this;
+ }
+
+ public function validateBlurValue(mixed $blur, ?ConfiguresBreakpoints $breakpoint = null): void
+ {
+ if (! is_int($blur)) {
+ throw new InvalidArgumentException(
+ $breakpoint
+ ? "Blur value for breakpoint '{$breakpoint->value}' must be an integer for ImageContext with key '{$this->key}'."
+ : "Blur value must be an integer for ImageContext with key '{$this->key}'."
+ );
+ }
+
+ if ($blur < 0 || $blur > 100) {
+ throw new InvalidArgumentException(
+ $breakpoint
+ ? "Blur value for breakpoint '{$breakpoint->value}' must be between 0 and 100 for ImageContext with key '{$this->key}'."
+ : "Blur value must be between 0 and 100 for ImageContext with key '{$this->key}'."
+ );
+ }
+ }
+
+ public function blurForBreakpoint(ConfiguresBreakpoints $breakpoint, int $blur): self
+ {
+ if (! $this->getUseBreakpoints()) {
+ throw new InvalidArgumentException("Cannot set blur for breakpoint when breakpoints are disabled for ImageContext with key '{$this->key}'.");
+ }
+
+ $this->validateBlurValue($blur, $breakpoint);
+
+ $this->blurByBreakpoint[$breakpoint->value] = $blur;
+
+ return $this;
+ }
+
+ public function blurFromBreakpoint(ConfiguresBreakpoints $breakpoint, int $blur): self
+ {
+ if (! $this->getUseBreakpoints()) {
+ throw new InvalidArgumentException("Cannot set blur from breakpoint when breakpoints are disabled for ImageContext with key '{$this->key}'.");
+ }
+
+ $this->validateBlurValue($blur, $breakpoint);
+
+ $index = $this->getBreakpoints()
+ ->search(function (ConfiguresBreakpoints $bp) use ($breakpoint) {
+ return $bp->value === $breakpoint->value;
+ });
+
+ $this->getBreakpoints()
+ ->slice($index)
+ ->each(function (ConfiguresBreakpoints $bp) use ($blur) {
+ $this->blurByBreakpoint[$bp->value] = $blur;
+ });
+
+ return $this;
+ }
+
+ public function blurToBreakpoint(ConfiguresBreakpoints $breakpoint, int $blur): self
+ {
+ if (! $this->getUseBreakpoints()) {
+ throw new InvalidArgumentException("Cannot set blur to breakpoint when breakpoints are disabled for ImageContext with key '{$this->key}'.");
+ }
+
+ $this->validateBlurValue($blur, $breakpoint);
+
+ $index = $this->getBreakpoints()
+ ->search(function (ConfiguresBreakpoints $bp) use ($breakpoint) {
+ return $bp->value === $breakpoint->value;
+ });
+
+ $this->getBreakpoints()
+ ->slice(0, $index + 1)
+ ->each(function (ConfiguresBreakpoints $bp) use ($blur) {
+ $this->blurByBreakpoint[$bp->value] = $blur;
+ });
+
+ return $this;
+ }
+
+ public function blurBetweenBreakpoints(ConfiguresBreakpoints $startBreakpoint, ConfiguresBreakpoints $endBreakpoint, int $blur): self
+ {
+ if (! $this->getUseBreakpoints()) {
+ throw new InvalidArgumentException("Cannot set blur between breakpoints when breakpoints are disabled for ImageContext with key '{$this->key}'.");
+ }
+
+ $this->validateBlurValue($blur);
+
+ $breakpoints = $this->getBreakpoints();
+
+ $startIndex = $breakpoints->search(function (ConfiguresBreakpoints $bp) use ($startBreakpoint) {
+ return $bp->value === $startBreakpoint->value;
+ });
+
+ $endIndex = $breakpoints->search(function (ConfiguresBreakpoints $bp) use ($endBreakpoint) {
+ return $bp->value === $endBreakpoint->value;
+ });
+
+ $breakpoints
+ ->slice($startIndex, $endIndex - $startIndex + 1)
+ ->each(function (ConfiguresBreakpoints $bp) use ($blur) {
+ $this->blurByBreakpoint[$bp->value] = $blur;
+ });
+
+ return $this;
+ }
+
+ public function getBlur(?ConfiguresBreakpoints $breakpoint = null): ?int
+ {
+ if (! $this->getUseBreakpoints()) {
+ return $this->blurByBreakpoint['default'] ?? null;
+ }
+
+ if (is_null($breakpoint)) {
+ throw new InvalidArgumentException("Breakpoint is required when breakpoints are enabled for ImageContext with key '{$this->key}'.");
+ }
+
+ return $this->blurByBreakpoint[$breakpoint->value] ?? null;
+ }
+
+ /** @return array */
+ public function getBlurByBreakpoint(): array
+ {
+ if (! $this->getUseBreakpoints()) {
+ throw new InvalidArgumentException("Cannot get blur values by breakpoint when breakpoints are disabled for ImageContext with key '{$this->key}'.");
+ }
+
+ return $this->blurByBreakpoint;
+ }
+
+ /** @param bool|array $greyscale */
+ public function greyscale(bool|array $greyscale = true): self
+ {
+ if (! $this->getUseBreakpoints()) {
+ if (is_array($greyscale)) {
+ throw new InvalidArgumentException("Greyscale must be a boolean when breakpoints are disabled for ImageContext with key '{$this->key}'.");
+ }
+
+ $this->validateGreyscaleValue($greyscale);
+
+ $this->greyscaleByBreakpoint = [
+ 'default' => $greyscale,
+ ];
+
+ return $this;
+ }
+
+ $this->greyscaleByBreakpoint = $this->getBreakpoints()
+ ->mapWithKeys(function (ConfiguresBreakpoints $breakpoint) use ($greyscale) {
+ if (is_array($greyscale)) {
+ if (! array_key_exists($breakpoint->value, $greyscale)) {
+ throw new InvalidArgumentException("Greyscale for breakpoint '{$breakpoint->value}' is not defined for ImageContext with key '{$this->key}'.");
+ }
+
+ $grayscaleValue = $greyscale[$breakpoint->value];
+
+ $this->validateGreyscaleValue($grayscaleValue, $breakpoint);
+
+ return [$breakpoint->value => $grayscaleValue];
+ }
+
+ return [$breakpoint->value => $greyscale];
+ })
+ ->all();
+
+ return $this;
+ }
+
+ public function validateGreyscaleValue(mixed $greyscale, ?ConfiguresBreakpoints $breakpoint = null): void
+ {
+ if (! is_bool($greyscale)) {
+ throw new InvalidArgumentException(
+ $breakpoint
+ ? "Greyscale value for breakpoint '{$breakpoint->value}' must be a boolean for ImageContext with key '{$this->key}'."
+ : "Greyscale value must be a boolean for ImageContext with key '{$this->key}'."
+ );
+ }
+ }
+
+ /** @param bool|array $greyscale */
+ public function grayscale(bool|array $greyscale = true): self
+ {
+ return $this->greyscale($greyscale);
+ }
+
+ public function greyscaleForBreakpoint(ConfiguresBreakpoints $breakpoint, bool $greyscale = true): self
+ {
+ if (! $this->getUseBreakpoints()) {
+ throw new InvalidArgumentException("Cannot set greyscale for breakpoint when breakpoints are disabled for ImageContext with key '{$this->key}'.");
+ }
+
+ $this->greyscaleByBreakpoint[$breakpoint->value] = $greyscale;
+
+ return $this;
+ }
+
+ public function grayscaleForBreakpoint(ConfiguresBreakpoints $breakpoint, bool $greyscale = true): self
+ {
+ return $this->greyscaleForBreakpoint($breakpoint, $greyscale);
+ }
+
+ public function greyscaleFromBreakpoint(ConfiguresBreakpoints $breakpoint, bool $greyscale = true): self
+ {
+ if (! $this->getUseBreakpoints()) {
+ throw new InvalidArgumentException("Cannot set greyscale from breakpoint when breakpoints are disabled for ImageContext with key '{$this->key}'.");
+ }
+
+ $index = $this->getBreakpoints()
+ ->search(function (ConfiguresBreakpoints $bp) use ($breakpoint) {
+ return $bp->value === $breakpoint->value;
+ });
+
+ $this->getBreakpoints()
+ ->slice($index)
+ ->each(function (ConfiguresBreakpoints $bp) use ($greyscale) {
+ $this->greyscaleByBreakpoint[$bp->value] = $greyscale;
+ });
+
+ return $this;
+ }
+
+ public function grayscaleFromBreakpoint(ConfiguresBreakpoints $breakpoint, bool $greyscale = true): self
+ {
+ return $this->greyscaleFromBreakpoint($breakpoint, $greyscale);
+ }
+
+ public function greyscaleToBreakpoint(ConfiguresBreakpoints $breakpoint, bool $greyscale = true): self
+ {
+ if (! $this->getUseBreakpoints()) {
+ throw new InvalidArgumentException("Cannot set greyscale to breakpoint when breakpoints are disabled for ImageContext with key '{$this->key}'.");
+ }
+
+ $index = $this->getBreakpoints()
+ ->search(function (ConfiguresBreakpoints $bp) use ($breakpoint) {
+ return $bp->value === $breakpoint->value;
+ });
+
+ $this->getBreakpoints()
+ ->slice(0, $index + 1)
+ ->each(function (ConfiguresBreakpoints $bp) use ($greyscale) {
+ $this->greyscaleByBreakpoint[$bp->value] = $greyscale;
+ });
+
+ return $this;
+ }
+
+ public function grayscaleToBreakpoint(ConfiguresBreakpoints $breakpoint, bool $greyscale = true): self
+ {
+ return $this->greyscaleToBreakpoint($breakpoint, $greyscale);
+ }
+
+ public function greyscaleBetweenBreakpoints(ConfiguresBreakpoints $startBreakpoint, ConfiguresBreakpoints $endBreakpoint, bool $greyscale = true): self
+ {
+ if (! $this->getUseBreakpoints()) {
+ throw new InvalidArgumentException("Cannot set greyscale between breakpoints when breakpoints are disabled for ImageContext with key '{$this->key}'.");
+ }
+
+ $breakpoints = $this->getBreakpoints();
+
+ $startIndex = $breakpoints->search(function (ConfiguresBreakpoints $bp) use ($startBreakpoint) {
+ return $bp->value === $startBreakpoint->value;
+ });
+
+ $endIndex = $breakpoints->search(function (ConfiguresBreakpoints $bp) use ($endBreakpoint) {
+ return $bp->value === $endBreakpoint->value;
+ });
+
+ $breakpoints
+ ->slice($startIndex, $endIndex - $startIndex + 1)
+ ->each(function (ConfiguresBreakpoints $bp) use ($greyscale) {
+ $this->greyscaleByBreakpoint[$bp->value] = $greyscale;
+ });
+
+ return $this;
+ }
+
+ public function grayscaleBetweenBreakpoints(ConfiguresBreakpoints $startBreakpoint, ConfiguresBreakpoints $endBreakpoint, bool $greyscale = true): self
+ {
+ return $this->greyscaleBetweenBreakpoints($startBreakpoint, $endBreakpoint, $greyscale);
+ }
+
+ public function getGreyscale(?ConfiguresBreakpoints $breakpoint = null): ?bool
+ {
+ if (! $this->getUseBreakpoints()) {
+ return $this->greyscaleByBreakpoint['default'] ?? null;
+ }
+
+ if (is_null($breakpoint)) {
+ throw new InvalidArgumentException("Breakpoint is required when breakpoints are enabled for ImageContext with key '{$this->key}'.");
+ }
+
+ return $this->greyscaleByBreakpoint[$breakpoint->value] ?? null;
+ }
+
+ public function getGrayscale(?ConfiguresBreakpoints $breakpoint = null): ?bool
+ {
+ return $this->getGreyscale($breakpoint);
+ }
+
+ /** @return array */
+ public function getGreyscaleByBreakpoint(): array
+ {
+ if (! $this->getUseBreakpoints()) {
+ throw new InvalidArgumentException("Cannot get greyscale values by breakpoint when breakpoints are disabled for ImageContext with key '{$this->key}'.");
+ }
+
+ return $this->greyscaleByBreakpoint;
+ }
+
+ /** @return array */
+ public function getGrayscaleByBreakpoint(): array
+ {
+ return $this->getGreyscaleByBreakpoint();
+ }
+
+ /** @param bool|array $sepia */
+ public function sepia(bool|array $sepia = true): self
+ {
+ if (! $this->getUseBreakpoints()) {
+ if (is_array($sepia)) {
+ throw new InvalidArgumentException("Sepia must be a boolean when breakpoints are disabled for ImageContext with key '{$this->key}'.");
+ }
+
+ $this->validateSepiaValue($sepia);
+
+ $this->sepiaByBreakpoint = [
+ 'default' => $sepia,
+ ];
+
+ return $this;
+ }
+
+ $this->sepiaByBreakpoint = $this->getBreakpoints()
+ ->mapWithKeys(function (ConfiguresBreakpoints $breakpoint) use ($sepia) {
+ if (is_array($sepia)) {
+ if (! array_key_exists($breakpoint->value, $sepia)) {
+ throw new InvalidArgumentException("Sepia for breakpoint '{$breakpoint->value}' is not defined for ImageContext with key '{$this->key}'.");
+ }
+
+ $sepiaValue = $sepia[$breakpoint->value];
+
+ $this->validateSepiaValue($sepiaValue, $breakpoint);
+
+ return [$breakpoint->value => $sepiaValue];
+ }
+
+ return [$breakpoint->value => $sepia];
+ })
+ ->all();
+
+ return $this;
+ }
+
+ public function validateSepiaValue(mixed $sepia, ?ConfiguresBreakpoints $breakpoint = null): void
+ {
+ if (! is_bool($sepia)) {
+ throw new InvalidArgumentException(
+ $breakpoint
+ ? "Sepia value for breakpoint '{$breakpoint->value}' must be a boolean for ImageContext with key '{$this->key}'."
+ : "Sepia value must be a boolean for ImageContext with key '{$this->key}'."
+ );
+ }
+ }
+
+ public function sepiaForBreakpoint(ConfiguresBreakpoints $breakpoint, bool $sepia = true): self
+ {
+ if (! $this->getUseBreakpoints()) {
+ throw new InvalidArgumentException("Cannot set sepia for breakpoint when breakpoints are disabled for ImageContext with key '{$this->key}'.");
+ }
+
+ $this->sepiaByBreakpoint[$breakpoint->value] = $sepia;
+
+ return $this;
+ }
+
+ public function sepiaFromBreakpoint(ConfiguresBreakpoints $breakpoint, bool $sepia = true): self
+ {
+ if (! $this->getUseBreakpoints()) {
+ throw new InvalidArgumentException("Cannot set sepia from breakpoint when breakpoints are disabled for ImageContext with key '{$this->key}'.");
+ }
+
+ $index = $this->getBreakpoints()
+ ->search(function (ConfiguresBreakpoints $bp) use ($breakpoint) {
+ return $bp->value === $breakpoint->value;
+ });
+
+ $this->getBreakpoints()
+ ->slice($index)
+ ->each(function (ConfiguresBreakpoints $bp) use ($sepia) {
+ $this->sepiaByBreakpoint[$bp->value] = $sepia;
+ });
+
+ return $this;
+ }
+
+ public function sepiaToBreakpoint(ConfiguresBreakpoints $breakpoint, bool $sepia = true): self
+ {
+ if (! $this->getUseBreakpoints()) {
+ throw new InvalidArgumentException("Cannot set sepia to breakpoint when breakpoints are disabled for ImageContext with key '{$this->key}'.");
+ }
+
+ $index = $this->getBreakpoints()
+ ->search(function (ConfiguresBreakpoints $bp) use ($breakpoint) {
+ return $bp->value === $breakpoint->value;
+ });
+
+ $this->getBreakpoints()
+ ->slice(0, $index + 1)
+ ->each(function (ConfiguresBreakpoints $bp) use ($sepia) {
+ $this->sepiaByBreakpoint[$bp->value] = $sepia;
+ });
+
+ return $this;
+ }
+
+ public function sepiaBetweenBreakpoints(ConfiguresBreakpoints $startBreakpoint, ConfiguresBreakpoints $endBreakpoint, bool $sepia = true): self
+ {
+ if (! $this->getUseBreakpoints()) {
+ throw new InvalidArgumentException("Cannot set sepia between breakpoints when breakpoints are disabled for ImageContext with key '{$this->key}'.");
+ }
+
+ $breakpoints = $this->getBreakpoints();
+
+ $startIndex = $breakpoints->search(function (ConfiguresBreakpoints $bp) use ($startBreakpoint) {
+ return $bp->value === $startBreakpoint->value;
+ });
+
+ $endIndex = $breakpoints->search(function (ConfiguresBreakpoints $bp) use ($endBreakpoint) {
+ return $bp->value === $endBreakpoint->value;
+ });
+
+ $breakpoints
+ ->slice($startIndex, $endIndex - $startIndex + 1)
+ ->each(function (ConfiguresBreakpoints $bp) use ($sepia) {
+ $this->sepiaByBreakpoint[$bp->value] = $sepia;
+ });
+
+ return $this;
+ }
+
+ public function getSepia(?ConfiguresBreakpoints $breakpoint = null): ?bool
+ {
+ if (! $this->getUseBreakpoints()) {
+ return $this->sepiaByBreakpoint['default'] ?? null;
+ }
+
+ if (is_null($breakpoint)) {
+ throw new InvalidArgumentException("Breakpoint is required when breakpoints are enabled for ImageContext with key '{$this->key}'.");
+ }
+
+ return $this->sepiaByBreakpoint[$breakpoint->value] ?? null;
+ }
+
+ /** @return array */
+ public function getSepiaByBreakpoint(): array
+ {
+ if (! $this->getUseBreakpoints()) {
+ throw new InvalidArgumentException("Cannot get sepia values by breakpoint when breakpoints are disabled for ImageContext with key '{$this->key}'.");
+ }
+
+ return $this->sepiaByBreakpoint;
+ }
+
+ public function allowsMultiple(bool $allowsMultiple = true): self
+ {
+ $this->allowsMultiple = $allowsMultiple;
+
+ return $this;
+ }
+
+ public function getAllowsMultiple(): bool
+ {
+ return $this->allowsMultiple;
+ }
+
+ public function useBreakpoints(bool $useBreakpoints = true): self
+ {
+ $this->useBreakpoints = $useBreakpoints;
+
+ return $this;
+ }
+
+ public function getUseBreakpoints(): bool
+ {
+ return $this->useBreakpoints ?? ImageLibrary::shouldUseBreakpoints();
+ }
+
+ public function generateWebP(bool $generateWebP = true): self
+ {
+ $this->generateWebP = $generateWebP;
+
+ return $this;
+ }
+
+ public function getGenerateWebP(): bool
+ {
+ return $this->generateWebP ?? ImageLibrary::shouldGenerateWebp();
+ }
+
+ public function generateResponsiveVersions(bool $generateResponsiveVersions = true): self
+ {
+ $this->generateResponsiveVersions = $generateResponsiveVersions;
+
+ return $this;
+ }
+
+ public function getGenerateResponsiveVersions(): bool
+ {
+ return $this->generateResponsiveVersions ?? ImageLibrary::shouldGenerateResponsiveVersions();
+ }
+
+ public function toArray(): array
+ {
+ return [
+ 'key' => $this->key,
+ 'label' => $this->label,
+ 'aspectRatio' => $this->getUseBreakpoints()
+ ? array_map(
+ fn (AspectRatio $ar) => $ar->toArray(),
+ $this->aspectRatioByBreakpoint
+ )
+ : ($this->aspectRatioByBreakpoint['default'] ?? null)?->toArray(),
+ 'blur' => $this->getUseBreakpoints()
+ ? $this->blurByBreakpoint
+ : ($this->blurByBreakpoint['default'] ?? null),
+ 'grayscale' => $this->getUseBreakpoints()
+ ? $this->greyscaleByBreakpoint
+ : ($this->greyscaleByBreakpoint['default'] ?? null),
+ 'sepia' => $this->getUseBreakpoints()
+ ? $this->sepiaByBreakpoint
+ : ($this->sepiaByBreakpoint['default'] ?? null),
+ 'allowsMultiple' => $this->allowsMultiple,
+ 'useBreakpoints' => $this->useBreakpoints,
+ 'generateWebP' => $this->generateWebP,
+ 'generateResponsiveVersions' => $this->generateResponsiveVersions,
+ ];
+ }
+
+ /**
+ * @template T
+ *
+ * @param T | callable(): T $value
+ * @return T
+ */
+ private function evaluate(mixed $value): mixed
+ {
+ if (! $value instanceof Closure) {
+ return $value;
+ }
+
+ $dependencies = collect(new ReflectionFunction($value)->getParameters())
+ ->map(function (ReflectionParameter $parameter) {
+ return match ($parameter->getName()) {
+ 'imageContext' => $this,
+ default => null,
+ };
+ })
+ ->all();
+
+ return $value(...$dependencies);
+ }
+
+ private function getBreakpoints(): Collection
+ {
+ return collect(ImageLibrary::getBreakpointEnum()::sortedCases());
+ }
+}
diff --git a/src/Enums/Breakpoint.php b/src/Enums/Breakpoint.php
new file mode 100644
index 0000000..2f88c1b
--- /dev/null
+++ b/src/Enums/Breakpoint.php
@@ -0,0 +1,63 @@
+sort(fn ($a, $b) => $a->getMinWidth() <=> $b->getMinWidth())
+ ->all();
+ }
+
+ public function getLabel(): string
+ {
+ return match ($this) {
+ self::Small => __('image-library::translations.breakpoints.sm'),
+ self::Medium => __('image-library::translations.breakpoints.md'),
+ self::Large => __('image-library::translations.breakpoints.lg'),
+ self::ExtraLarge => __('image-library::translations.breakpoints.xl'),
+ self::DoubleExtraLarge => __('image-library::translations.breakpoints.2xl'),
+ };
+ }
+
+ public function getMinWidth(): int
+ {
+ return match ($this) {
+ self::Small => 640,
+ self::Medium => 768,
+ self::Large => 1024,
+ self::ExtraLarge => 1280,
+ self::DoubleExtraLarge => 1536,
+ };
+ }
+
+ public function getMaxWidth(): ?int
+ {
+ $index = array_search($this, self::sortedCases(), true);
+
+ $next = self::sortedCases()[$index + 1] ?? null;
+
+ return $next ? $next->getMinWidth() - 1 : null;
+ }
+
+ public function getSlug(): string
+ {
+ return Str::of($this->value)
+ ->lower()
+ ->slug()
+ ->toString();
+ }
+}
diff --git a/src/Facades/ImageLibrary.php b/src/Facades/ImageLibrary.php
index d7d7e16..8063440 100644
--- a/src/Facades/ImageLibrary.php
+++ b/src/Facades/ImageLibrary.php
@@ -1,14 +1,54 @@
getBreakpointEnum()
+ * @method static class-string getImageModel()
+ * @method static class-string getSourceImageModel()
+ * @method static string getImageModelKeyName()
+ * @method static string getImageModelSortOrderColumnName()
+ * @method static string getSourceImageModelKeyName()
+ * @method static SpatieImage getSpatieImage()
+ * @method static void registerImageContexts(array $imageContexts)
+ * @method static void registerImageContext(ImageContext $imageContext)
+ * @method static void removeImageContext(ImageContext $imageContext)
+ * @method static array getImageContexts()
+ * @method static ?ImageContext getImageContextByKey(?string $key)
+ * @method static SourceImage upload(UploadedFile $file, array $attributes = [])
+ * @method static bool shouldUseTemporaryUrlsForDisk(string $disk)
+ * @method static int getTemporaryUrlExpirationMinutesForDisk(string $disk)
+ * @method static string getDefaultDisk()
+ * @method static array getSupportedLocales()
+ * @method static CropPosition getDefaultCropPosition()
+ * @method static bool shouldUseBreakpoints()
+ * @method static bool shouldGenerateWebp()
+ * @method static bool shouldGenerateResponsiveVersions()
+ * @method static string getDefaultQueueConnection()
+ * @method static string getDefaultQueue()
+ * @method static string getBasePath()
+ * @method static float getResponsiveImageSizeStepMultiplier()
+ * @method static int getResponsiveImageWidthDifferenceThreshold()
+ * @method static int getResponsiveImageMinWidth()
+ *
+ * @see \Outerweb\ImageLibrary\ImageLibrary
+ */
class ImageLibrary extends Facade
{
- protected static function getFacadeAccessor()
+ protected static function getFacadeAccessor(): string
{
- return ServicesImageLibrary::class;
+ return \Outerweb\ImageLibrary\ImageLibrary::class;
}
}
diff --git a/src/Helpers/helpers.php b/src/Helpers/helpers.php
deleted file mode 100644
index b3d9bbc..0000000
--- a/src/Helpers/helpers.php
+++ /dev/null
@@ -1 +0,0 @@
-
+ */
+ public function getBreakpointEnum(): string
+ {
+ return Config::get('image-library.enums.breakpoint');
+ }
+
+ /**
+ * @return class-string
+ */
+ public function getImageModel(): string
+ {
+ return Config::get('image-library.models.image');
+ }
+
+ public function getImageModelKeyName(): string
+ {
+ return (new (self::getImageModel())())->getKeyName();
+ }
+
+ public function getImageModelSortOrderColumnName(): string
+ {
+ return (new (self::getImageModel())())->determineOrderColumnName();
+ }
+
+ /**
+ * @return class-string
+ */
+ public function getSourceImageModel(): string
+ {
+ return Config::get('image-library.models.source_image');
+ }
+
+ public function getSourceImageModelKeyName(): string
+ {
+ return (new (self::getSourceImageModel())())->getKeyName();
+ }
+
+ public function getSpatieImage(): SpatieImage
+ {
+ return SpatieImage::useImageDriver(Config::get('image-library.spatie_image.driver'));
+ }
+
+ /**
+ * @param array $imageContexts
+ */
+ public function registerImageContexts(array $imageContexts): void
+ {
+ foreach ($imageContexts as $imageContext) {
+ if (! $imageContext instanceof ImageContext) {
+ throw new InvalidArgumentException('Expected instance of ImageContext, but got '.gettype($imageContext).' instead.');
+ }
+
+ $this->imageContexts[$imageContext->getKey()] = $imageContext;
+ }
+ }
+
+ public function registerImageContext(ImageContext $imageContext): void
+ {
+ $this->imageContexts[$imageContext->getKey()] = $imageContext;
+ }
+
+ public function removeImageContext(ImageContext $imageContext): void
+ {
+ unset($this->imageContexts[$imageContext->getKey()]);
+ }
+
+ /**
+ * @return array
+ */
+ public function getImageContexts(): array
+ {
+ return array_values($this->imageContexts);
+ }
+
+ public function getImageContextByKey(?string $key): ?ImageContext
+ {
+ if (blank($key)) {
+ return null;
+ }
+
+ return $this->imageContexts[$key] ?? null;
+ }
+
+ public function upload(UploadedFile $file, array $attributes = []): SourceImage
+ {
+ return $this->getSourceImageModel()::upload($file, $attributes);
+ }
+
+ public function shouldUseBreakpoints(): bool
+ {
+ return Config::get('image-library.use_breakpoints', true);
+ }
+
+ public function shouldGenerateWebp(): bool
+ {
+ return Config::get('image-library.generate.webp', true);
+ }
+
+ public function shouldGenerateResponsiveVersions(): bool
+ {
+ return Config::get('image-library.generate.responsive_versions', true);
+ }
+
+ public function shouldUseTemporaryUrlsForDisk(string $disk): bool
+ {
+ if (! Config::has("image-library.defaults.temporary_url.$disk")) {
+ return Config::get('image-library.defaults.temporary_url.default.enabled', false);
+ }
+
+ return Config::get("image-library.defaults.temporary_url.$disk.enabled", false);
+ }
+
+ public function getTemporaryUrlExpirationMinutesForDisk(string $disk): int
+ {
+ if (! Config::has("image-library.defaults.temporary_url.$disk")) {
+ return Config::get('image-library.defaults.temporary_url.default.expiration_minutes', 5);
+ }
+
+ return Config::get("image-library.defaults.temporary_url.$disk.expiration_minutes", 5);
+ }
+
+ public function getDefaultDisk(): string
+ {
+ return Config::string('image-library.defaults.disk');
+ }
+
+ /** @return array */
+ public function getSupportedLocales(): array
+ {
+ return Config::array('app.supported_locales', [Config::string('app.locale')]);
+ }
+
+ public function getDefaultCropPosition(): CropPosition
+ {
+ $position = Config::get('image-library.defaults.crop_position', CropPosition::Center);
+
+ return $position instanceof CropPosition ? $position : CropPosition::from($position);
+ }
+
+ public function getDefaultQueueConnection(): string
+ {
+ return Config::string('image-library.queue.connection');
+ }
+
+ public function getDefaultQueue(): string
+ {
+ return Config::string('image-library.queue.queue');
+ }
+
+ public function getBasePath(): string
+ {
+ return Config::string('image-library.paths.base');
+ }
+
+ public function getResponsiveImageSizeStepMultiplier(): float
+ {
+ return Config::float('image-library.responsive_images.size_step_multiplier', 0.7);
+ }
+
+ public function getResponsiveImageWidthDifferenceThreshold(): int
+ {
+ return Config::integer('image-library.responsive_images.width_difference_threshold', 50);
+ }
+
+ public function getResponsiveImageMinWidth(): int
+ {
+ return Config::integer('image-library.responsive_images.min_width', 100);
+ }
+}
diff --git a/src/ImageLibraryServiceProvider.php b/src/ImageLibraryServiceProvider.php
index b539511..88b207c 100644
--- a/src/ImageLibraryServiceProvider.php
+++ b/src/ImageLibraryServiceProvider.php
@@ -1,10 +1,13 @@
name('image-library')
->hasConfigFile()
+ ->hasCommands([
+ GenerateCommand::class,
+ UpgradeCommand::class,
+ ])
+ ->hasTranslations()
->hasMigrations([
+ 'create_source_images_table',
'create_images_table',
- 'create_image_conversions_table',
- ])
- ->hasCommands([
- Commands\CreateOrUpdateConversions::class,
- Commands\GenerateConversions::class,
- Commands\GenerateResponsiveVariants::class,
])
+ ->publishesServiceProvider('ImageLibraryServiceProvider')
->hasViews()
->hasViewComponents(
'image-library',
- Components\Image::class,
- Components\Picture::class,
- Components\Scripts::class,
+ Image::class,
+ Scripts::class,
)
->hasInstallCommand(function (InstallCommand $command) {
$command
->publishConfigFile()
+ ->copyAndRegisterServiceProviderInApp()
->publishMigrations()
- ->askToRunMigrations();
-
- $composerFile = file_get_contents(__DIR__ . '/../composer.json');
-
- if ($composerFile) {
- $githubRepo = json_decode($composerFile, true)['homepage'] ?? null;
-
- if ($githubRepo) {
- $command
- ->askToStarRepoOnGitHub($githubRepo);
- }
- }
+ ->askToRunMigrations()
+ ->askToStarRepoOnGitHub('outer-web/image-library');
});
}
-
- public function register()
- {
- parent::register();
-
- $this->app->singleton(ImageLibrary::class, function () {
- return new ImageLibrary();
- });
- }
}
diff --git a/src/Jobs/GenerateConversion.php b/src/Jobs/GenerateConversion.php
deleted file mode 100644
index d6aff90..0000000
--- a/src/Jobs/GenerateConversion.php
+++ /dev/null
@@ -1,27 +0,0 @@
-conversion->generate($this->force);
- }
-}
diff --git a/src/Jobs/GenerateImageVersionJob.php b/src/Jobs/GenerateImageVersionJob.php
new file mode 100644
index 0000000..d1aa3bd
--- /dev/null
+++ b/src/Jobs/GenerateImageVersionJob.php
@@ -0,0 +1,147 @@
+imageId = $imageId instanceof Image ? $imageId->getKey() : $imageId;
+
+ $this->onConnection(ImageLibrary::getDefaultQueueConnection());
+ $this->onQueue(ImageLibrary::getDefaultQueue());
+ }
+
+ public function handle(): void
+ {
+ if ($this->batch()?->cancelled()) {
+ // @codeCoverageIgnoreStart
+ return;
+ // @codeCoverageIgnoreEnd
+ }
+
+ $image = ImageLibrary::getImageModel()::query()
+ ->findOrFail($this->imageId);
+
+ $slug = $this->breakpoint?->getSlug() ?? 'default';
+ $temporaryPath = new TemporaryDirectory()->create()->path($slug.'-'.$image->uuid.'.'.$image->sourceImage->extension);
+
+ File::put($temporaryPath, $image->sourceImage->get());
+
+ $file = ImageLibrary::getSpatieImage()
+ ->loadFile($temporaryPath)
+ ->optimize();
+
+ $cropDataKey = $this->breakpoint->value ?? 'default';
+ $cropData = $image->crop_data[$cropDataKey] ?? null;
+
+ if (! is_null($cropData)) {
+ if (is_null($cropData->x) || is_null($cropData->y)) {
+ $cropPosition = $image->context
+ ? $image->context->getCropPosition($this->breakpoint)
+ : ImageLibrary::getDefaultCropPosition();
+ $file->crop($cropData->width, $cropData->height, $cropPosition);
+ } else {
+ $file->manualCrop(
+ $cropData->width,
+ $cropData->height,
+ $cropData->x,
+ $cropData->y,
+ );
+ }
+
+ if (is_int($cropData->scaleX) && $cropData->scaleX === -1) {
+ $file->flip(FlipDirection::Horizontal);
+ }
+
+ if (is_int($cropData->scaleY) && $cropData->scaleY === -1) {
+ $file->flip(FlipDirection::Vertical);
+ }
+
+ if (is_int($cropData->rotate) && $cropData->rotate !== 0) {
+ $orientation = Orientation::tryFrom((int) ($cropData->rotate));
+
+ if ($orientation) {
+ $file->orientation($orientation);
+ }
+ }
+ } elseif ($image->context) {
+ $fileWidth = $file->getWidth();
+ $maxWidth = min($image->context->getMaxWidth($this->breakpoint) ?? $fileWidth, $fileWidth);
+ $aspectRatio = $image->context->getAspectRatio($this->breakpoint);
+ $cropPosition = $image->context->getCropPosition($this->breakpoint);
+
+ if ($aspectRatio) {
+ $maxHeight = $file->getHeight();
+ $possibleWidth = $maxHeight * $aspectRatio->horizontal / $aspectRatio->vertical;
+ $possibleHeight = $maxWidth * $aspectRatio->vertical / $aspectRatio->horizontal;
+
+ // @codeCoverageIgnoreStart
+ if ($possibleWidth <= $maxWidth) {
+ $width = (int) round($possibleWidth);
+ $height = $maxHeight;
+ } else {
+ $width = $maxWidth;
+ $height = (int) round($possibleHeight);
+ }
+ // @codeCoverageIgnoreEnd
+
+ $file->crop($width, $height, $cropPosition);
+ }
+ }
+
+ if ($image->context) {
+ $breakpointMaxWidth = $image->context->getMaxWidth($this->breakpoint);
+
+ if (! is_null($breakpointMaxWidth)) {
+ $file->fit(Fit::Max, $breakpointMaxWidth);
+ }
+
+ $blur = $image->context->getBlur($this->breakpoint);
+ if (is_int($blur)) {
+ $file->blur($blur);
+ }
+
+ if ($image->context->getGreyscale($this->breakpoint)) {
+ $file->greyscale();
+ }
+
+ if ($image->context->getSepia($this->breakpoint)) {
+ $file->sepia();
+ }
+ }
+
+ // Create directory
+ Storage::disk($image->disk)->makeDirectory($image->getRelativeBasePath());
+
+ $file->save($image->getAbsolutePathForBreakpoint($this->breakpoint));
+
+ $shouldGenerateWebP = $image->context
+ ? $image->context->getGenerateWebP()
+ : ImageLibrary::shouldGenerateWebp();
+
+ if ($shouldGenerateWebP) {
+ $file->save($image->getAbsolutePathForBreakpoint($this->breakpoint, 'webp'));
+ }
+ }
+}
diff --git a/src/Jobs/GenerateResponsiveImageVersionsJob.php b/src/Jobs/GenerateResponsiveImageVersionsJob.php
new file mode 100644
index 0000000..6765bbc
--- /dev/null
+++ b/src/Jobs/GenerateResponsiveImageVersionsJob.php
@@ -0,0 +1,124 @@
+imageId = $imageId instanceof Image ? $imageId->getKey() : $imageId;
+
+ $this->onConnection(ImageLibrary::getDefaultQueueConnection());
+ $this->onQueue(ImageLibrary::getDefaultQueue());
+ }
+
+ public function handle(): void
+ {
+ if ($this->batch()?->cancelled()) {
+ // @codeCoverageIgnoreStart
+ return;
+ // @codeCoverageIgnoreEnd
+ }
+
+ $image = ImageLibrary::getImageModel()::query()
+ ->findOrFail($this->imageId);
+
+ $temporaryPath = new TemporaryDirectory()->create()->path($this->breakpoint->getSlug().'-'.$image->uuid.'.'.$image->sourceImage->extension);
+
+ File::put($temporaryPath, $image->getForBreakpoint($this->breakpoint));
+
+ $widths = $this->calculateWidths(
+ $temporaryPath,
+ $image,
+ );
+
+ foreach ($widths as $width) {
+ $file = ImageLibrary::getSpatieImage()
+ ->loadFile($temporaryPath)
+ ->fit(Fit::Max, $width);
+
+ // Create directory
+ Storage::disk($image->disk)->makeDirectory($image->getRelativeBasePath());
+
+ $file->save(Str::of($image->getAbsolutePathForBreakpoint($this->breakpoint))
+ ->replaceLast('.'.$image->sourceImage->extension, '_w'.$width.'.'.$image->sourceImage->extension)
+ ->toString());
+
+ if ($image->context->getGenerateWebP()) {
+ $file->save(
+ Str::of($image->getAbsolutePathForBreakpoint($this->breakpoint, 'webp'))
+ ->replaceLast('.webp', '_w'.$width.'.webp')
+ ->toString()
+ );
+ }
+ }
+ }
+
+ private function calculateWidths(string $path, Image $image): array
+ {
+ $file = ImageLibrary::getSpatieImage()->loadFile($path);
+
+ $fileWidth = $file->getWidth();
+ $fileHeight = $file->getHeight();
+ $fileSize = File::size($path);
+
+ $contextMaxWidth = $image->context->getMaxWidth($this->breakpoint);
+ $contextMinWidth = $image->context->getMinWidth($this->breakpoint);
+
+ $breakpointMinWidth = $this->breakpoint->getMinWidth();
+
+ $minWidth = min(is_null($contextMaxWidth) ? $breakpointMinWidth : ($contextMinWidth ?? 0), ImageLibrary::getResponsiveImageMinWidth());
+
+ $ratio = $fileHeight / $fileWidth;
+ $area = $fileWidth * $fileHeight;
+ $pixelPrice = $fileSize / $area;
+
+ $sizeStepMultiplier = ImageLibrary::getResponsiveImageSizeStepMultiplier();
+ $widthDiffThreshold = ImageLibrary::getResponsiveImageWidthDifferenceThreshold();
+
+ $widths = [];
+ $predictedFileSize = $fileSize;
+ $prevWidth = $fileWidth;
+
+ while (true) {
+ $predictedFileSize *= $sizeStepMultiplier;
+
+ $newWidth = (int) floor(sqrt(($predictedFileSize / $pixelPrice) / $ratio));
+
+ if ($newWidth < $minWidth || $newWidth >= $prevWidth) {
+ break;
+ }
+
+ if (($prevWidth - $newWidth) < $widthDiffThreshold) {
+ $prevWidth = $newWidth;
+
+ continue;
+ }
+
+ $widths[] = $newWidth;
+
+ $prevWidth = $newWidth;
+ }
+
+ return array_values(array_unique($widths));
+ }
+}
diff --git a/src/Jobs/GenerateResponsiveVariants.php b/src/Jobs/GenerateResponsiveVariants.php
deleted file mode 100644
index 37bcd72..0000000
--- a/src/Jobs/GenerateResponsiveVariants.php
+++ /dev/null
@@ -1,28 +0,0 @@
-image->generateResponsiveVariants($this->force);
- }
-}
diff --git a/src/Jobs/GenerateWebpVariant.php b/src/Jobs/GenerateWebpVariant.php
deleted file mode 100644
index b644780..0000000
--- a/src/Jobs/GenerateWebpVariant.php
+++ /dev/null
@@ -1,28 +0,0 @@
-image->generateWebpVariant($this->force);
- }
-}
diff --git a/src/Models/Image.php b/src/Models/Image.php
index 9924f58..b6fe4e9 100644
--- a/src/Models/Image.php
+++ b/src/Models/Image.php
@@ -1,105 +1,418 @@
$crop_data
+ * @property array|string|null $alt_text
+ * @property array|null $custom_properties
+ * @property CarbonInterface $created_at
+ * @property CarbonInterface $updated_at
+ *
+ * @psalm-property-write ImageContext|string $context
+ * @psalm-property-write array|null $alt_text
+ *
+ * @psalm-property-read ImageContext $context
+ * @psalm-property-read string|null $alt_text
+ *
+ * @method static ImageFactory factory($count = null, $state = [])
+ */
+#[UseFactory(ImageFactory::class)]
+class Image extends Model implements Sortable
{
- use GeneratesUuids;
- use HasConversions;
- use HandlesUploads;
+ use HasFactory;
use HasTranslations;
- use HasResponsiveVariants;
- use HasWebpVariant;
+ use SortableTrait;
+
+ public $sortable = [
+ 'order_column_name' => 'sort_order',
+ 'sort_when_creating' => true,
+ ];
+
+ public $translatable = [
+ 'alt_text',
+ ];
protected $fillable = [
'uuid',
+ 'model_type',
+ 'model_id',
+ 'source_image_id',
+ 'context',
+ 'context_configuration_hash',
+ 'sort_order',
'disk',
- 'mime_type',
- 'file_extension',
- 'width',
- 'height',
- 'size',
- 'title',
- 'alt',
+ 'crop_data',
+ 'alt_text',
+ 'custom_properties',
+ ];
+
+ protected $attributes = [
+ 'custom_properties' => '{}',
];
- protected $casts = [
- 'title' => 'json',
- 'alt' => 'json',
+ protected $with = [
+ 'sourceImage',
];
- public function getTranslatableAttributes() : array
+ public function model(): MorphTo
{
- if (config('image-library.spatie_translatable')) {
- return ['title', 'alt'];
- }
+ return $this->morphTo();
+ }
- return [];
+ public function sourceImage(): BelongsTo
+ {
+ return $this->belongsTo(ImageLibrary::getSourceImageModel());
}
- public function getShortPath(bool $includeFileExtension = true) : ?string
+ public function buildSortQuery(): Builder
{
- $basePath = $this->getBasePath();
+ return static::query()
+ ->where('model_type', $this->model_type)
+ ->where('model_id', $this->model_id)
+ ->where('context', $this->context->getKey());
+ }
- if (is_null($basePath)) {
- return null;
- }
+ public function generate(): void
+ {
+ $this->deleteFiles();
- $path = $this->getBasePath() . '/' . $this->file_name;
+ if (! $this->context->getUseBreakpoints()) {
+ Bus::batch([
+ new GenerateImageVersionJob($this->getKey(), null),
+ ])
+ ->onConnection(ImageLibrary::getDefaultQueueConnection())
+ ->onQueue(ImageLibrary::getDefaultQueue())
+ ->dispatch();
- if ($includeFileExtension) {
- $path .= '.' . $this->file_extension;
+ return;
}
- return $path;
+ Bus::chain([
+ Bus::batch(
+ collect(ImageLibrary::getBreakpointEnum()::sortedCases())
+ ->map(function (ConfiguresBreakpoints $breakpoint) {
+ return new GenerateImageVersionJob($this->getKey(), $breakpoint);
+ })
+ ->all()
+ ),
+ Bus::batch(
+ collect(ImageLibrary::getBreakpointEnum()::sortedCases())
+ ->map(function (ConfiguresBreakpoints $breakpoint) {
+ return new GenerateResponsiveImageVersionsJob($this->getKey(), $breakpoint);
+ })
+ ->all()
+ ),
+ ])
+ ->onConnection(ImageLibrary::getDefaultQueueConnection())
+ ->onQueue(ImageLibrary::getDefaultQueue())
+ ->dispatch();
}
- public function getBasePath() : ?string
+ public function getRelativeBasePath(): string
{
- return $this->uuid;
+ return $this->sourceImage->getRelativeBasePath().'/'.$this->uuid;
}
- public function createBasePath() : void
+ public function getAbsoluteBasePath(): string
{
- Storage::disk($this->disk)->makeDirectory($this->getBasePath());
+ return Storage::disk($this->disk)->path($this->getRelativeBasePath());
}
- public function getPath() : string
+ public function getRelativePathForBreakpoint(?ConfiguresBreakpoints $breakpoint = null, ?string $extension = null): string
{
- $path = $this->getShortPath();
+ $extension ??= $this->sourceImage->extension;
+
+ if (is_null($breakpoint)) {
+ if (! $this->context->getUseBreakpoints()) {
+ return $this->getRelativeBasePath().'/default.'.$extension;
+ }
- if (is_null($path)) {
- return '';
+ throw new InvalidArgumentException('Breakpoint must be provided when context uses breakpoints.');
}
- return Storage::disk($this->disk)->path($this->getShortPath());
+ return $this->getRelativeBasePath().'/'.urlencode($breakpoint->getSlug()).'.'.$extension;
+ }
+
+ public function getAbsolutePathForBreakpoint(?ConfiguresBreakpoints $breakpoint = null, ?string $extension = null): string
+ {
+ return Storage::disk($this->disk)->path($this->getRelativePathForBreakpoint($breakpoint, $extension));
}
- public function getUrl() : string
+ public function getResponsiveRelativePathsForBreakpoint(ConfiguresBreakpoints $breakpoint, ?string $extension = null): Collection
{
- $path = $this->getShortPath();
+ $extension ??= $this->sourceImage->extension;
+
+ $files = Storage::disk($this->disk)->files($this->getRelativeBasePath());
+
+ return collect($files)
+ ->filter(function (string $file) use ($breakpoint, $extension): bool {
+ return (
+ Str::startsWith($file, $this->getRelativeBasePath().'/'.urlencode($breakpoint->getSlug()).'_w')
+ || $file === $this->getRelativePathForBreakpoint($breakpoint, $extension)
+ )
+ && Str::endsWith($file, '.'.$extension);
+ })
+ ->values();
+ }
+
+ public function getResponsiveAbsolutePathsForBreakpoint(ConfiguresBreakpoints $breakpoint, ?string $extension = null): Collection
+ {
+ return collect($this->getResponsiveRelativePathsForBreakpoint($breakpoint, $extension))
+ ->map(function (string $path): string {
+ return Storage::disk($this->disk)->path($path);
+ });
+ }
- if (is_null($path)) {
- return '';
+ public function getForBreakpoint(ConfiguresBreakpoints $breakpoint, ?string $extension = null): ?string
+ {
+ return Storage::disk($this->disk)->get($this->getRelativePathForBreakpoint($breakpoint, $extension));
+ }
+
+ public function existsForBreakpoint(?ConfiguresBreakpoints $breakpoint = null, ?string $extension = null): bool
+ {
+ return Storage::disk($this->disk)->exists($this->getRelativePathForBreakpoint($breakpoint, $extension));
+ }
+
+ public function missingForBreakpoint(?ConfiguresBreakpoints $breakpoint = null, ?string $extension = null): bool
+ {
+ return Storage::disk($this->disk)->missing($this->getRelativePathForBreakpoint($breakpoint, $extension));
+ }
+
+ public function downloadForBreakpoint(?ConfiguresBreakpoints $breakpoint = null, ?string $extension = null): StreamedResponse
+ {
+ return Storage::disk($this->disk)->download($this->getRelativePathForBreakpoint($breakpoint, $extension));
+ }
+
+ public function urlForBreakpoint(?ConfiguresBreakpoints $breakpoint = null, ?string $extension = null): string
+ {
+ $path = $this->getRelativePathForBreakpoint($breakpoint, $extension);
+
+ if (ImageLibrary::shouldUseTemporaryUrlsForDisk($this->disk)) {
+ return $this->temporaryUrlForRelativePath($path);
}
- return Storage::disk($this->disk)->url($this->getShortPath());
+ return $this->urlForRelativePath($path);
+ }
+
+ public function urlForRelativePath(string $relativePath): string
+ {
+ if (ImageLibrary::shouldUseTemporaryUrlsForDisk($this->disk)) {
+ return $this->temporaryUrlForRelativePath($relativePath);
+ }
+
+ return Storage::disk($this->disk)->url($relativePath);
+ }
+
+ public function temporaryUrlForRelativePath(string $relativePath, ?CarbonInterface $expiration = null, array $options = []): string
+ {
+ return Storage::disk($this->disk)->temporaryUrl(
+ $relativePath,
+ $expiration ?? now()->addMinutes(ImageLibrary::getTemporaryUrlExpirationMinutesForDisk($this->disk)),
+ $options,
+ );
+ }
+
+ public function deleteFiles(): void
+ {
+ Storage::disk($this->disk)->deleteDirectory($this->getRelativeBasePath());
+ }
+
+ protected static function booted(): void
+ {
+ static::saving(function (self $model) {
+ $model->uuid ??= (string) Str::uuid();
+
+ $model->updateContextConfigurationHash();
+ });
+
+ static::created(function (self $model) {
+ $model->generate();
+ });
+
+ static::updated(function (self $model) {
+ if ($model->wasChanged(['context_configuration_hash', 'crop_data'])) {
+ $model->generate();
+ }
+ });
+
+ static::deleting(function (self $model) {
+ $model->deleteFiles();
+ });
}
- protected function fileName() : Attribute
+ /** @return Attribute */
+ protected function disk(): Attribute
{
return Attribute::make(
- get: fn () => 'original',
+ get: fn (?string $value): string => $value ?? $this->sourceImage->disk,
+ set: fn (?string $value): string => $value ?? $this->sourceImage->disk,
);
}
+
+ /** @return Attribute */
+ protected function context(): Attribute
+ {
+ return Attribute::make(
+ get: fn (?string $value): ?ImageContext => ImageLibrary::getImageContextByKey($value),
+ set: fn (ImageContext|string|null $value): ?string => is_null($value) ? null : (is_string($value) ? $value : $value->getKey()),
+ );
+ }
+
+ /** @return Attribute, string> */
+ protected function cropData(): Attribute
+ {
+ return Attribute::make(
+ get: function (?string $value): array {
+ if (blank($value)) {
+ return $this->generateCropData(null);
+ }
+
+ $data = json_decode($value, true);
+
+ if (! is_array($data)) {
+ return $this->generateCropData(null);
+ }
+
+ return $this->generateCropData($data);
+ },
+ set: function (array|CropData|null $value): string {
+ return json_encode($this->generateCropData($value));
+ },
+ );
+ }
+
+ /**
+ * @return array{
+ * alt_text: 'array',
+ * custom_properties: 'array',
+ * }
+ */
+ protected function casts(): array
+ {
+ return [
+ 'alt_text' => 'array',
+ 'custom_properties' => 'array',
+ ];
+ }
+
+ private function updateContextConfigurationHash(): void
+ {
+ $this->context_configuration_hash = $this->context->getConfigurationHash();
+ }
+
+ /**
+ * @param array|null|CropData $cropData
+ * @return array
+ */
+ private function generateCropData(array|CropData|null $cropData): array
+ {
+ $contextKey = $this->getRawOriginal('context') ?? $this->attributes['context'] ?? null;
+ $context = $contextKey ? ImageLibrary::getImageContextByKey($contextKey) : null;
+
+ if (is_null($context)) {
+ return [];
+ }
+
+ if (! $context->getUseBreakpoints()) {
+ if ($cropData instanceof CropData || is_null($cropData)) {
+ return ['default' => $cropData];
+ }
+
+ if (array_key_exists('default', $cropData)) {
+ $cropData = $cropData['default'];
+ }
+
+ if (
+ ! is_array($cropData)
+ || ! array_key_exists('width', $cropData)
+ || ! array_key_exists('height', $cropData)
+ ) {
+ return ['default' => null];
+ }
+
+ return [
+ 'default' => CropData::make(
+ $cropData['width'],
+ $cropData['height'],
+ $cropData['x'] ?? null,
+ $cropData['y'] ?? null,
+ $cropData['rotate'] ?? 0,
+ $cropData['scaleX'] ?? 1,
+ $cropData['scaleY'] ?? 1,
+ ),
+ ];
+ }
+
+ if ($cropData instanceof CropData || is_null($cropData)) {
+ return collect(ImageLibrary::getBreakpointEnum()::sortedCases())
+ ->mapWithKeys(function (BackedEnum $case) use ($cropData): array {
+ return [$case->value => $cropData];
+ })
+ ->all();
+ }
+
+ return collect(ImageLibrary::getBreakpointEnum()::sortedCases())
+ ->mapWithKeys(function (BackedEnum $case) use ($cropData): array {
+ $data = $cropData[$case->value] ?? null;
+
+ if (
+ is_null($data)
+ || ! is_array($data)
+ || ! array_key_exists('width', $data)
+ || ! array_key_exists('height', $data)
+ ) {
+ return [$case->value => null];
+ }
+
+ return [$case->value => CropData::make(
+ $data['width'],
+ $data['height'],
+ $data['x'] ?? null,
+ $data['y'] ?? null,
+ $data['rotate'] ?? 0,
+ $data['scaleX'] ?? 1,
+ $data['scaleY'] ?? 1,
+ )];
+ })
+ ->all();
+ }
}
diff --git a/src/Models/ImageConversion.php b/src/Models/ImageConversion.php
deleted file mode 100644
index df6f4be..0000000
--- a/src/Models/ImageConversion.php
+++ /dev/null
@@ -1,99 +0,0 @@
-belongsTo(config('image-library.models.image'));
- }
-
- public function getShortPath(bool $includeFileExtension = true): string
- {
- $path = $this->getBasePath() . '/' . $this->file_name;
-
- if ($includeFileExtension) {
- $path .= '.' . $this->image->file_extension;
- }
-
- return $path;
- }
-
- public function getBasePath(): string
- {
- return $this->image->getBasePath();
- }
-
- public function getPath(): string
- {
- return Storage::disk($this->disk)->path($this->getShortPath());
- }
-
- public function getUrl(): string
- {
- return Storage::disk($this->disk)->url($this->getShortPath());
- }
-
- public function exists(): bool
- {
- return Storage::disk($this->disk)->exists($this->getShortPath());
- }
-
- protected function fileName(): Attribute
- {
- return Attribute::make(
- get: fn() => Str::slug(Str::replace(':', '-', $this->conversion_name), separator: '-'),
- );
- }
-
- protected function disk(): Attribute
- {
- return Attribute::make(
- get: fn() => $this->image->disk,
- );
- }
-
- protected function fileExtension(): Attribute
- {
- return Attribute::make(
- get: fn() => $this->image->file_extension,
- );
- }
-
- protected function definition(): Attribute
- {
- return Attribute::make(
- get: fn() => ImageLibrary::getConversionDefinition($this->conversion_name),
- );
- }
-}
diff --git a/src/Models/SourceImage.php b/src/Models/SourceImage.php
new file mode 100644
index 0000000..f177fb0
--- /dev/null
+++ b/src/Models/SourceImage.php
@@ -0,0 +1,219 @@
+|string|null $alt_text
+ * @property array|null $custom_properties
+ * @property-read \Illuminate\Database\Eloquent\Collection $images
+ * @property CarbonInterface $created_at
+ * @property CarbonInterface $updated_at
+ *
+ * @psalm-property-write array|null $alt_text
+ *
+ * @psalm-property-read string|null $alt_text
+ */
+#[UseFactory(SourceImageFactory::class)]
+class SourceImage extends Model
+{
+ use HasFactory;
+ use HasTranslations;
+
+ public $translatable = [
+ 'alt_text',
+ ];
+
+ protected $fillable = [
+ 'uuid',
+ 'disk',
+ 'name',
+ 'extension',
+ 'mime_type',
+ 'width',
+ 'height',
+ 'size',
+ 'alt_text',
+ 'custom_properties',
+ ];
+
+ protected $attributes = [
+ 'custom_properties' => '{}',
+ ];
+
+ public static function upload(UploadedFile $file, array $attributes = []): self
+ {
+ try {
+ DB::beginTransaction();
+
+ $temporaryPath = new TemporaryDirectory()->create()->path($file->getClientOriginalName());
+
+ $optimizedFile = ImageLibrary::getSpatieImage()
+ ->loadFile($file->getRealPath())
+ ->optimize()
+ ->save($temporaryPath);
+
+ $model = static::query()
+ ->create(array_merge(
+ [
+ 'disk' => ImageLibrary::getDefaultDisk(),
+ 'name' => pathinfo($file->getClientOriginalName(), PATHINFO_FILENAME),
+ ],
+ $attributes,
+ [
+ 'extension' => $file->getClientOriginalExtension(),
+ 'mime_type' => $file->getClientMimeType(),
+ 'width' => $optimizedFile->getWidth(),
+ 'height' => $optimizedFile->getHeight(),
+ 'size' => filesize($temporaryPath),
+ ]
+ ));
+
+ // Create directory
+ Storage::disk($model->disk)->makeDirectory($model->getRelativeBasePath());
+
+ $optimizedFile->save($model->getAbsolutePath());
+
+ DB::commit();
+
+ return $model;
+ } catch (Throwable $exception) {
+ DB::rollBack();
+
+ if (isset($model)) {
+ $model->deleteFiles();
+ }
+
+ throw $exception;
+ }
+ }
+
+ public function images(): HasMany
+ {
+ return $this->hasMany(ImageLibrary::getImageModel());
+ }
+
+ public function getRelativeBasePath(): string
+ {
+ return ImageLibrary::getBasePath().'/'.$this->uuid;
+ }
+
+ public function getAbsoluteBasePath(): string
+ {
+ return Storage::disk($this->disk)->path($this->getRelativeBasePath());
+ }
+
+ public function getRelativePath(): string
+ {
+ return $this->getRelativeBasePath().'/original'.'.'.$this->extension;
+ }
+
+ public function getAbsolutePath(): string
+ {
+ return Storage::disk($this->disk)->path($this->getRelativePath());
+ }
+
+ public function get(): ?string
+ {
+ return Storage::disk($this->disk)->get($this->getRelativePath());
+ }
+
+ public function exists(): bool
+ {
+ return Storage::disk($this->disk)->exists($this->getRelativePath());
+ }
+
+ public function missing(): bool
+ {
+ return Storage::disk($this->disk)->missing($this->getRelativePath());
+ }
+
+ public function download(): StreamedResponse
+ {
+ return Storage::disk($this->disk)->download($this->getRelativePath());
+ }
+
+ public function url(): string
+ {
+ if (ImageLibrary::shouldUseTemporaryUrlsForDisk($this->disk)) {
+ return $this->temporaryUrl();
+ }
+
+ return Storage::disk($this->disk)->url($this->getRelativePath());
+ }
+
+ public function temporaryUrl(?CarbonInterface $expiration = null, array $options = []): string
+ {
+ return Storage::disk($this->disk)->temporaryUrl(
+ $this->getRelativePath(),
+ $expiration ?? now()->addMinutes(ImageLibrary::getTemporaryUrlExpirationMinutesForDisk($this->disk)),
+ $options,
+ );
+ }
+
+ public function deleteFiles(): void
+ {
+ Storage::disk($this->disk)->deleteDirectory($this->getRelativeBasePath());
+ }
+
+ protected static function booted(): void
+ {
+ static::saving(function (self $model) {
+ $model->uuid ??= (string) Str::uuid();
+ });
+
+ static::deleting(function (self $model) {
+ $model->deleteFiles();
+ });
+ }
+
+ /**
+ * @return array{
+ * alt_text: 'array',
+ * custom_properties: 'array',
+ * }
+ */
+ protected function casts(): array
+ {
+ return [
+ 'alt_text' => 'array',
+ 'custom_properties' => 'array',
+ ];
+ }
+
+ /** @return Attribute */
+ protected function nameWithExtension(): Attribute
+ {
+ return Attribute::get(
+ fn (): string => $this->name.'.'.$this->extension,
+ );
+ }
+}
diff --git a/src/Models/Traits/GeneratesUuids.php b/src/Models/Traits/GeneratesUuids.php
deleted file mode 100644
index 0330ddf..0000000
--- a/src/Models/Traits/GeneratesUuids.php
+++ /dev/null
@@ -1,32 +0,0 @@
-exists()) {
- return self::generateUuid($tries - 1);
- }
-
- return $uuid;
- }
-
- public static function bootGeneratesUuids(): void
- {
- self::creating(function (self $image) {
- if (empty($image->uuid)) {
- $image->uuid = self::generateUuid();
- }
- });
- }
-}
diff --git a/src/Models/Traits/HandlesConversions.php b/src/Models/Traits/HandlesConversions.php
deleted file mode 100644
index c61a599..0000000
--- a/src/Models/Traits/HandlesConversions.php
+++ /dev/null
@@ -1,137 +0,0 @@
-deleteFiles();
- });
-
- self::created(function (ImageConversion $conversion) {
- $conversionDefinition = ImageLibrary::getConversionDefinition($conversion->conversion_name);
-
- if ($conversionDefinition->create_sync) {
- GenerateConversion::dispatchSync($conversion, true);
- } else {
- GenerateConversion::dispatch($conversion, true);
- }
- });
-
- self::updated(function (ImageConversion $conversion) {
- if ($conversion->wasChanged(['x', 'y', 'width', 'height', 'rotate', 'scale_x', 'scale_y', 'conversion_name'])) {
- $conversionDefinition = ImageLibrary::getConversionDefinition($conversion->conversion_name);
-
- if ($conversionDefinition->create_sync) {
- GenerateConversion::dispatchSync($conversion, true);
- } else {
- GenerateConversion::dispatch($conversion, true);
- }
- }
- });
- }
- public function generate(bool $force = false) : void
- {
- if ($this->exists() && ! $force) {
- return;
- }
-
- $conversionFile = SpatieImage::useImageDriver(config('image-library.image_driver'))
- ->loadFile($this->image->getPath());
-
- if ($this->x || $this->y) {
- $conversionFile
- ->manualCrop($this->width, $this->height, $this->x, $this->y);
- } else {
- $conversionFile
- ->crop($this->width, $this->height, CropPosition::Center);
- }
-
- $conversionFile
- ->orientation(match ($this->rotate) {
- 90 => Orientation::Rotate90,
- 180 => Orientation::Rotate180,
- 270 => Orientation::Rotate270,
- default => Orientation::Rotate0,
- });
-
- $flipDirection = null;
- if ($this->scale_x === -1 && $this->scale_y === 1) {
- $flipDirection = FlipDirection::Horizontal;
- } elseif ($this->scale_x === 1 && $this->scale_y === -1) {
- $flipDirection = FlipDirection::Vertical;
- } elseif ($this->scale_x === -1 && $this->scale_y === -1) {
- $flipDirection = FlipDirection::Both;
- }
-
- if ($flipDirection) {
- $conversionFile->flip($flipDirection);
- }
-
- $conversionData = ImageLibrary::getConversionDefinition($this->conversion_name);
-
- $conversionData->validate(true);
-
- $effects = $conversionData->effects;
-
- if (! is_null($effects->blur)) {
- $conversionFile->blur($effects->blur);
- }
-
- if ($effects->pixelate) {
- $conversionFile->pixelate($effects->pixelate);
- }
-
- if ($effects->greyscale) {
- $conversionFile->greyscale();
- }
-
- if ($effects->sepia) {
- $conversionFile->sepia();
- }
-
- if ($effects->sharpen) {
- $conversionFile->sharpen($effects->sharpen);
- }
-
- $conversionFile
- ->background('rgba(255, 255, 255, 0)')
- ->optimize()
- ->save($this->getPath());
-
- $this->update([
- 'size' => Storage::disk($this->disk)->size($this->getShortPath()),
- ]);
-
- if (config('image-library.support.webp')) {
- GenerateWebpVariant::dispatch($this, $force);
- }
-
- if (config('image-library.support.responsive_variants')) {
- GenerateResponsiveVariants::dispatch($this, $force);
- }
- }
-
- public function deleteFiles() : void
- {
- foreach (Storage::disk($this->image->disk)->allFiles($this->getBasePath()) as $file) {
- if (preg_match('/\/' . $this->file_name . '(_|\.)?/', $file)) {
- Storage::disk($this->image->disk)->delete($file);
- }
- }
- }
-}
diff --git a/src/Models/Traits/HandlesUploads.php b/src/Models/Traits/HandlesUploads.php
deleted file mode 100644
index 3b89770..0000000
--- a/src/Models/Traits/HandlesUploads.php
+++ /dev/null
@@ -1,125 +0,0 @@
-deleteFiles();
- });
- }
-
- public static function upload(UploadedFile $file, ?string $disk = null, array $attributes = []): self
- {
- self::validateFile($file);
-
- $disk = $disk ?? config('image-library.default_disk');
-
- try {
- DB::beginTransaction();
-
- /** @var Image $image */
- $image = self::create([
- 'disk' => $disk,
- 'mime_type' => $file->getMimeType(),
- 'file_extension' => $file->getClientOriginalExtension(),
- 'width' => getimagesize($file->getPathname())[0],
- 'height' => getimagesize($file->getPathname())[1],
- 'size' => $file->getSize(),
- 'title' => $attributes['title'] ?? null,
- 'alt' => $attributes['alt'] ?? null,
- ]);
-
- $image->createBasePath();
-
- SpatieImage::useImageDriver(config('image-library.image_driver'))
- ->loadFile($file->getPathname())
- ->background('rgba(255, 255, 255, 0)')
- ->optimize()
- ->save($image->getPath());
-
- if (config('image-library.support.webp')) {
- GenerateWebpVariant::dispatch($image);
- }
-
- if (config('image-library.support.responsive_variants')) {
- GenerateResponsiveVariants::dispatch($image);
- }
-
- $image->createOrUpdateConversions();
-
- DB::commit();
- } catch (\Exception $e) {
- if (isset($image)) {
- $image->deleteFiles();
- }
-
- DB::rollBack();
-
- throw $e;
- }
-
- return $image;
- }
-
- public static function validateFile(UploadedFile $file): void
- {
- if (!in_array($file->getMimeType(), self::getSupportedMimeTypes())) {
- throw new \Exception("The file type {$file->getMimeType()} is not supported");
- }
-
- if (!is_null(self::getMaxFilesize()) && $file->getSize() > self::getMaxFileSize()) {
- throw new \Exception("The file size {$file->getSize()} is too large. The maximum file size is " . self::getMaxFileSize() . " bytes");
- }
- }
-
- public static function getSupportedMimeTypes(): array
- {
- return config('image-library.support.mime_types', []);
- }
-
- public static function getMaxFileSize(): int
- {
- $maxFileSize = config('image-library.max_file_size');
-
- if (is_numeric($maxFileSize)) {
- return $maxFileSize;
- }
-
- $maxFileSize = strtoupper(trim($maxFileSize));
- $stringPart = (string) preg_replace('/[^a-zA-Z]/', '', $maxFileSize);
- $valuePart = (int) preg_replace('/[^0-9]/', '', $maxFileSize) ?: 0;
-
- $units = [
- 'B' => 1,
- 'KB' => 1024,
- 'MB' => 1024 * 1024,
- 'GB' => 1024 * 1024 * 1024,
- 'TB' => 1024 * 1024 * 1024 * 1024,
- ];
-
- foreach ($units as $unit => $factor) {
- if ($stringPart === $unit) {
- return $valuePart * $factor;
- }
- }
-
- throw new InvalidArgumentException('Invalid image-library.max_file_size value');
- }
-
- public function deleteFiles(): void
- {
- Storage::disk($this->disk)->deleteDirectory($this->getBasePath());
- }
-}
diff --git a/src/Models/Traits/HasConversions.php b/src/Models/Traits/HasConversions.php
deleted file mode 100644
index 3f99740..0000000
--- a/src/Models/Traits/HasConversions.php
+++ /dev/null
@@ -1,88 +0,0 @@
-hasMany(config('image-library.models.image_conversion'));
- }
-
- public function getConversion(?string $conversionName): ?ImageConversion
- {
- if (is_null($conversionName)) {
- return null;
- }
-
- return $this->conversions->firstWhere('conversion_name', $conversionName);
- }
-
- public function createOrUpdateConversions(bool $deleteDeprecated = true): void
- {
- if ($deleteDeprecated) {
- $this->deleteDeprecatedConversions();
- }
-
- /** @var \Outerweb\ImageLibrary\Entities\ConversionDefinition $definition */
- foreach (ImageLibrary::getConversionDefinitions() as $definition) {
- $existingConversion = $this->getConversion($definition->name);
- $conversionMd5 = md5(json_encode($definition->toArray()));
-
- if ($existingConversion && $existingConversion->conversion_md5 === $conversionMd5) {
- continue;
- }
-
- if ($existingConversion) {
- $existingConversion->delete();
- }
-
- $aspectRatio = $definition->aspect_ratio;
- $defaultWidth = $definition->default_width;
- $defaultHeight = $definition->default_height;
-
- if (is_null($defaultWidth) && is_null($defaultHeight)) {
- $defaultWidth = $this->width;
- $defaultHeight = $this->height;
-
- $possibleWidth = round($defaultHeight * $aspectRatio->x / $aspectRatio->y);
- $possibleHeight = round($defaultWidth * $aspectRatio->y / $aspectRatio->x);
-
- if ($possibleHeight > $defaultHeight) {
- $defaultWidth = $possibleWidth;
- } elseif ($possibleWidth > $defaultWidth) {
- $defaultHeight = $possibleHeight;
- }
- }
-
- if (is_null($defaultWidth)) {
- $defaultWidth = round($defaultHeight * $aspectRatio->x / $aspectRatio->y);
- }
-
- if (is_null($defaultHeight)) {
- $defaultHeight = round($defaultWidth * $aspectRatio->y / $aspectRatio->x);
- }
-
- $this->conversions()->create([
- 'conversion_name' => $definition->name,
- 'conversion_md5' => $conversionMd5,
- 'width' => $defaultWidth,
- 'height' => $defaultHeight,
- 'size' => $this->size,
- ]);
- }
- }
-
- public function deleteDeprecatedConversions(): void
- {
- $this->conversions()
- ->whereNotIn('conversion_name', ImageLibrary::getConversionDefinitions()->pluck('name'))
- ->each(function (ImageConversion $conversion) {
- $conversion->delete();
- });
- }
-}
diff --git a/src/Models/Traits/HasResponsiveVariants.php b/src/Models/Traits/HasResponsiveVariants.php
deleted file mode 100644
index 68ff1fc..0000000
--- a/src/Models/Traits/HasResponsiveVariants.php
+++ /dev/null
@@ -1,127 +0,0 @@
-hasResponsiveVariants() && !$force) {
- return;
- }
-
- if ($force) {
- $this->deleteResponsiveVariants();
- }
-
- $baseImage = SpatieImage::useImageDriver(config('image-library.image_driver'))
- ->loadFile($this->getPath());
-
- $this->generateVariantsRecursive($baseImage);
- }
-
- public function generateVariantsRecursive(SpatieImage $image): void
- {
- $width = $image->getWidth();
- $height = $image->getHeight();
-
- $factor = $this->getResponsiveFactor();
-
- $newWidth = round($width * $factor);
- $newHeight = round($height * $factor);
-
- if ($newWidth < $this->getResponsiveMinWidth() || $newHeight < $this->getResponsiveMinHeight()) {
- // Minimum size reached, stop recursion
- return;
- }
-
- $image->width($newWidth)
- ->height($newHeight)
- ->background('rgba(255, 255, 255, 0)');
-
- $pathInfo = pathinfo($this->getPath());
- $variantFileName = $pathInfo['filename'] . "_{$newWidth}x{$newHeight}.{$pathInfo['extension']}";
- $variantPath = $this->getBasePath() . '/' . $variantFileName;
-
- $image->save(Storage::disk($this->disk)->path($variantPath));
-
- if (config('image-library.support.webp')) {
- $pathInfo = pathinfo($this->getPath());
- $variantFileName = $pathInfo['filename'] . "_{$newWidth}x{$newHeight}.webp";
- $variantPath = $this->getBasePath() . '/' . $variantFileName;
- $image->save(Storage::disk($this->disk)->path($variantPath));
- }
-
- // Recursively generate smaller variants
- $this->generateVariantsRecursive($image);
- }
-
- public function getResponsiveVariants(bool $asWebp = false): Collection
- {
- $variants = collect();
-
- foreach (Storage::disk($this->disk)->allFiles($this->getBasePath()) as $file) {
- if (preg_match("/\/{$this->file_name}_/", $file)) {
- if (!Str::endsWith($file, '.' . ($asWebp ? 'webp' : $this->file_extension))) {
- continue;
- }
-
- $widthAndHeight = Str::beforeLast(Str::afterLast($file, $this->file_name . '_'), '.');
- $width = (int) explode('x', $widthAndHeight)[0];
- $height = (int) explode('x', $widthAndHeight)[1];
- $variants->push((object) [
- 'path' => $file,
- 'url' => Storage::disk($this->disk)->url($file),
- 'width' => $width,
- 'height' => $height,
- ]);
- }
- }
-
- return $variants->sortBy('width');
- }
-
- public function deleteResponsiveVariants(): void
- {
- $this->getResponsiveVariants()->each(function ($variant) {
- Storage::disk($this->disk)->delete($variant->path);
- });
-
- if (config('image-library.support.webp')) {
- $this->getResponsiveVariants(true)->each(function ($variant) {
- Storage::disk($this->disk)->delete($variant->path);
- });
- }
- }
-
- public function hasResponsiveVariants(): bool
- {
- return $this->getResponsiveVariants()->isNotEmpty();
- }
-
- public function getResponsiveFactor(): float
- {
- $factor = config('image-library.responsive_variants.factor');
-
- if ($factor < 0.1 || $factor > 1) {
- throw new \Exception('Responsive variant factor must be between 0.1 and 1');
- }
-
- return $factor;
- }
-
- public function getResponsiveMinWidth(): int
- {
- return config('image-library.responsive_variants.min_width');
- }
-
- public function getResponsiveMinHeight(): int
- {
- return config('image-library.responsive_variants.min_height');
- }
-}
diff --git a/src/Models/Traits/HasWebpVariant.php b/src/Models/Traits/HasWebpVariant.php
deleted file mode 100644
index 1c8dda3..0000000
--- a/src/Models/Traits/HasWebpVariant.php
+++ /dev/null
@@ -1,48 +0,0 @@
-hasWebpVariant() && !$force) {
- return;
- }
-
- SpatieImage::useImageDriver(config('image-library.image_driver'))
- ->loadFile($this->getPath())
- ->background('rgba(255, 255, 255, 0)')
- ->optimize()
- ->save(Storage::disk($this->disk)->path($this->getWebpShortPath()));
- }
-
- public function hasWebpVariant(): bool
- {
- return Storage::disk($this->disk)->exists($this->getWebpShortPath());
- }
-
- public function getWebpShortPath(bool $includeFileExtension = true): string
- {
- $path = $this->getShortPath(false);
-
- if ($includeFileExtension) {
- $path .= '.webp';
- }
-
- return $path;
- }
-
- public function getWebpPath(): string
- {
- return Storage::disk($this->disk)->path($this->getWebpShortPath());
- }
-
- public function getWebpUrl(): string
- {
- return Storage::disk($this->disk)->url($this->getWebpShortPath());
- }
-}
diff --git a/src/Providers/ImageLibraryServiceProvider.php b/src/Providers/ImageLibraryServiceProvider.php
new file mode 100644
index 0000000..875255c
--- /dev/null
+++ b/src/Providers/ImageLibraryServiceProvider.php
@@ -0,0 +1,25 @@
+imageContexts());
+ }
+
+ /** @return array */
+ public function imageContexts(): array
+ {
+ // @codeCoverageIgnoreStart
+ return [];
+ // @codeCoverageIgnoreEnd
+ }
+}
diff --git a/src/Services/ImageLibrary.php b/src/Services/ImageLibrary.php
deleted file mode 100644
index c87636a..0000000
--- a/src/Services/ImageLibrary.php
+++ /dev/null
@@ -1,60 +0,0 @@
-imageModel()::upload($file, $disk, $attributes);
- }
-
- public function imageModel(): string
- {
- return config('image-library.models.image');
- }
-
- public function imageConversionModel(): string
- {
- return config('image-library.models.image_conversion');
- }
-
- public function addConversionDefinition(ConversionDefinition|string $definition, array $data = []): self
- {
- if (is_string($definition)) {
- $definition = ConversionDefinition::fromArray([
- 'name' => $definition,
- ...$data,
- ]);
- }
-
- $definition->validate(true);
-
- $this->conversionDefinitions[] = $definition;
-
- return $this;
- }
-
- public function getConversionDefinitions(): Collection
- {
- return collect($this->conversionDefinitions);
- }
-
- public function getConversionDefinition(string $name): ?ConversionDefinition
- {
- return $this->getConversionDefinitions()->first(fn(ConversionDefinition $definition) => $definition->name === $name);
- }
-
- public function isSpatieTranslatable(): bool
- {
- return config('image-library.spatie_translatable', false);
- }
-}
diff --git a/src/Traits/HasImages.php b/src/Traits/HasImages.php
new file mode 100644
index 0000000..1895aed
--- /dev/null
+++ b/src/Traits/HasImages.php
@@ -0,0 +1,95 @@
+morphMany(ImageLibrary::getImageModel(), 'model');
+ }
+
+ public function attachImage(SourceImage $image, array $attributes = [], string $relation = 'images'): Image
+ {
+ if (! array_key_exists('context', $attributes)) {
+ throw new InvalidArgumentException('You must provide a context when attaching an image.');
+ }
+
+ try {
+ DB::beginTransaction();
+
+ $relationType = $this->validateRelationType($relation);
+
+ $model = new (ImageLibrary::getImageModel())(array_merge(
+ [
+ 'disk' => $image->disk,
+ ],
+ $attributes,
+ [
+ 'model_type' => $this->getMorphClass(),
+ 'model_id' => $this->getKey(),
+ 'source_image_id' => $image->getKey(),
+ ]
+ ));
+
+ if ($relationType === MorphOne::class) {
+ $this->{$relation}
+ ?->delete();
+ } else {
+ $context = $attributes['context'] instanceof ImageContext
+ ? $attributes['context']
+ : ImageLibrary::getImageContextByKey($attributes['context']);
+
+ if ($context->getAllowsMultiple() === false) {
+ $this->{$relation}()
+ ->where('context', $context->getKey())
+ ->get()
+ ->each->delete();
+ }
+ }
+
+ $this->{$relation}()->save($model);
+
+ $this->unsetRelation($relation);
+
+ DB::commit();
+
+ return $model;
+ } catch (Throwable $e) {
+ DB::rollBack();
+
+ throw $e;
+ }
+ }
+
+ private function validateRelationType(string $relation): string
+ {
+ if (! method_exists($this, $relation)) {
+ throw new InvalidArgumentException("Relation {$relation} does not exist on the model.");
+ }
+
+ $instance = $this->{$relation}();
+
+ if ($instance instanceof MorphOne) {
+ return MorphOne::class;
+ }
+
+ if ($instance instanceof MorphMany) {
+ return MorphMany::class;
+ }
+
+ throw new InvalidArgumentException("Relation {$relation} is not a valid MorphOne or MorphMany relation.");
+ }
+}
diff --git a/testbench.yaml b/testbench.yaml
new file mode 100644
index 0000000..89125d4
--- /dev/null
+++ b/testbench.yaml
@@ -0,0 +1,4 @@
+laravel: '@testbench'
+
+providers:
+ - Outerweb\ImageLibrary\ImageLibraryServiceProvider
diff --git a/tests/ArchTest.php b/tests/ArchTest.php
new file mode 100644
index 0000000..0160ae2
--- /dev/null
+++ b/tests/ArchTest.php
@@ -0,0 +1,7 @@
+expect(['dd', 'dump', 'ray'])
+ ->each->not->toBeUsed();
diff --git a/tests/Fixtures/Factories/UserFactory.php b/tests/Fixtures/Factories/UserFactory.php
new file mode 100644
index 0000000..f4b6c26
--- /dev/null
+++ b/tests/Fixtures/Factories/UserFactory.php
@@ -0,0 +1,36 @@
+
+ */
+class UserFactory extends Factory
+{
+ protected static ?string $password;
+
+ public function definition(): array
+ {
+ return [
+ 'name' => fake()->name(),
+ 'email' => fake()->unique()->safeEmail(),
+ 'email_verified_at' => now(),
+ 'password' => static::$password ??= Hash::make('password'),
+ 'remember_token' => Str::random(10),
+ ];
+ }
+
+ public function unverified(): static
+ {
+ return $this->state(fn (array $attributes): array => [
+ 'email_verified_at' => null,
+ ]);
+ }
+}
diff --git a/tests/Fixtures/Models/User.php b/tests/Fixtures/Models/User.php
new file mode 100644
index 0000000..fca4244
--- /dev/null
+++ b/tests/Fixtures/Models/User.php
@@ -0,0 +1,64 @@
+morphOne(Image::class, 'model');
+ }
+
+ public function gallery(): MorphMany
+ {
+ return $this->morphMany(Image::class, 'model');
+ }
+
+ public function friends(): HasMany
+ {
+ return $this->hasMany(self::class, 'user_id');
+ }
+
+ /**
+ * @return array{
+ * email_verified_at: 'datetime',
+ * password: 'hashed',
+ * }
+ */
+ protected function casts(): array
+ {
+ return [
+ 'email_verified_at' => 'datetime',
+ 'password' => 'hashed',
+ ];
+ }
+}
diff --git a/tests/Fixtures/Providers/ImageLibraryServiceProvider.php b/tests/Fixtures/Providers/ImageLibraryServiceProvider.php
new file mode 100644
index 0000000..e4ec69e
--- /dev/null
+++ b/tests/Fixtures/Providers/ImageLibraryServiceProvider.php
@@ -0,0 +1,45 @@
+aspectRatio(
+ AspectRatio::make(1, 1)
+ )
+ ->maxWidth([
+ Breakpoint::Small->value => 300,
+ Breakpoint::Medium->value => 600,
+ Breakpoint::Large->value => 900,
+ Breakpoint::ExtraLarge->value => 1200,
+ Breakpoint::DoubleExtraLarge->value => 1500,
+ ])
+ ->allowsMultiple(false),
+ ImageContext::make('context-multiple')
+ ->aspectRatio(
+ AspectRatio::make(1, 1)
+ )
+ ->maxWidth([
+ Breakpoint::Small->value => 300,
+ Breakpoint::Medium->value => 600,
+ Breakpoint::Large->value => 900,
+ Breakpoint::ExtraLarge->value => 1200,
+ Breakpoint::DoubleExtraLarge->value => 1500,
+ ])
+ ->allowsMultiple(true),
+ ];
+ }
+}
diff --git a/tests/Pest.php b/tests/Pest.php
new file mode 100644
index 0000000..60f253e
--- /dev/null
+++ b/tests/Pest.php
@@ -0,0 +1,14 @@
+in(__DIR__)
+ ->beforeEach(function () {
+ Storage::fake('public');
+ Bus::fake();
+ });
diff --git a/tests/TestCase.php b/tests/TestCase.php
new file mode 100644
index 0000000..cea04f6
--- /dev/null
+++ b/tests/TestCase.php
@@ -0,0 +1,36 @@
+set('database.default', 'testing');
+ config()->set('app.key', 'base64:'.base64_encode(random_bytes(32)));
+ }
+
+ protected function defineDatabaseMigrations(): void
+ {
+ $this->loadLaravelMigrations();
+
+ foreach (File::files(__DIR__.'/../database/migrations') as $migration) {
+ (include $migration->getRealPath())->up();
+ }
+ }
+
+ protected function getPackageProviders($app)
+ {
+ return [
+ ImageLibraryServiceProvider::class,
+ TestFixtureImageLibraryServiceProvider::class,
+ ];
+ }
+}
diff --git a/tests/Unit/Components/ImageTest.php b/tests/Unit/Components/ImageTest.php
new file mode 100644
index 0000000..8b567fa
--- /dev/null
+++ b/tests/Unit/Components/ImageTest.php
@@ -0,0 +1,260 @@
+aspectRatio(AspectRatio::make(1, 1))
+ ->generateWebP(true)
+ ->generateResponsiveVersions(true)
+ );
+
+ ImageLibrary::registerImageContext(
+ ImageContext::make('simple')
+ ->aspectRatio(AspectRatio::make(1, 1))
+ ->generateWebP(false)
+ ->generateResponsiveVersions(false)
+ );
+});
+
+describe('Image Component', function (): void {
+ it('can be constructed with an image model', function (): void {
+ $user = User::factory()->create();
+ $image = ImageModel::factory()
+ ->forModel($user)
+ ->create();
+
+ $component = new Image($image);
+
+ expect($component->image)
+ ->toBe($image);
+ });
+
+ it('renders the correct view', function (): void {
+ $user = User::factory()->create();
+ $image = ImageModel::factory()
+ ->forModel($user)
+ ->create([
+ 'context' => 'thumbnail',
+ ]);
+
+ $component = new Image($image);
+ $view = $component->render();
+
+ expect($view)
+ ->toBeInstanceOf(View::class);
+
+ expect($view->getName())
+ ->toBe('image-library::components.image');
+
+ expect($view->getData())
+ ->toHaveKey('sources')
+ ->and($view->getData()['sources'])
+ ->toBeInstanceOf(Illuminate\Support\Collection::class);
+ });
+
+ it('generates sources with WebP when enabled', function (): void {
+ $user = User::factory()->create();
+
+ $file = UploadedFile::fake()->image('example-image.jpg', 1200, 800);
+ $sourceImage = SourceImage::upload($file);
+
+ $image = ImageModel::factory()
+ ->forModel($user)
+ ->create([
+ 'source_image_id' => $sourceImage->getKey(),
+ 'context' => 'thumbnail',
+ ]);
+
+ // Generate the image versions for testing
+ GenerateImageVersionJob::dispatchSync($image, Breakpoint::Small);
+
+ $component = new Image($image);
+ $view = $component->render();
+ $sources = $view->getData()['sources'];
+
+ // Should include both regular and WebP sources
+ expect($sources->count())->toBeGreaterThan(0);
+
+ // Check that we have WebP sources
+ $hasWebP = $sources->contains(function ($source) {
+ return $source->type === 'image/webp';
+ });
+ expect($hasWebP)->toBeTrue();
+ });
+
+ it('does not generate WebP sources when disabled', function (): void {
+ $user = User::factory()->create();
+
+ $file = UploadedFile::fake()->image('example-image.jpg', 1200, 800);
+ $sourceImage = SourceImage::upload($file);
+
+ $image = ImageModel::factory()
+ ->forModel($user)
+ ->create([
+ 'source_image_id' => $sourceImage->getKey(),
+ 'context' => 'simple',
+ ]);
+
+ $component = new Image($image);
+ $view = $component->render();
+ $sources = $view->getData()['sources'];
+
+ // Should not include WebP sources
+ $hasWebP = $sources->contains(function ($source) {
+ return $source->type === 'image/webp';
+ });
+ expect($hasWebP)->toBeFalse();
+ });
+
+ it('generates media queries for breakpoints', function (): void {
+ $user = User::factory()->create();
+ $image = ImageModel::factory()
+ ->forModel($user)
+ ->create([
+ 'context' => 'thumbnail',
+ ]);
+
+ $component = new Image($image);
+ $view = $component->render();
+ $sources = $view->getData()['sources'];
+
+ // Should have media queries for different breakpoints
+ expect($sources->count())->toBeGreaterThan(0);
+
+ $sources->each(function ($source) {
+ expect($source)->toHaveProperty('media');
+ expect($source)->toHaveProperty('srcset');
+ expect($source)->toHaveProperty('type');
+ });
+ });
+
+ it('handles responsive versions when enabled', function (): void {
+ $user = User::factory()->create();
+
+ $file = UploadedFile::fake()->image('example-image.jpg', 1200, 800);
+ $sourceImage = SourceImage::upload($file);
+
+ $image = ImageModel::factory()
+ ->forModel($user)
+ ->create([
+ 'source_image_id' => $sourceImage->getKey(),
+ 'context' => 'thumbnail', // has generateResponsiveVersions = true
+ ]);
+
+ $component = new Image($image);
+ $view = $component->render();
+ $sources = $view->getData()['sources'];
+
+ expect($sources->count())->toBeGreaterThan(0);
+
+ // Check that the context supports responsive versions
+ expect($image->context->getGenerateResponsiveVersions())->toBeTrue();
+ });
+
+ it('extracts width from responsive image filenames with width patterns', function (): void {
+ $user = User::factory()->create();
+
+ $file = UploadedFile::fake()->image('example-image.jpg', 1200, 800);
+ $sourceImage = SourceImage::upload($file);
+
+ $image = ImageModel::factory()
+ ->forModel($user)
+ ->create([
+ 'source_image_id' => $sourceImage->getKey(),
+ 'context' => 'thumbnail',
+ ]);
+
+ // Create fake responsive image files to simulate the responsive versions
+ $basePath = $image->getRelativeBasePath();
+ Storage::fake('public');
+ Storage::disk('public')->put($basePath.'/sm_w400.jpg', 'fake image content');
+ Storage::disk('public')->put($basePath.'/sm_w800.jpg', 'fake image content');
+ Storage::disk('public')->put($basePath.'/sm.jpg', 'fake image content');
+
+ $component = new Image($image);
+ $view = $component->render();
+ $sources = $view->getData()['sources'];
+
+ expect($sources->count())->toBeGreaterThan(0);
+
+ // Verify that srcsets contain width patterns
+ $sources->each(function ($source) {
+ expect($source->srcset)->toBeString();
+ });
+ });
+
+ it('handles simple srcset when responsive versions are disabled', function (): void {
+ $user = User::factory()->create();
+
+ $file = UploadedFile::fake()->image('example-image.jpg', 1200, 800);
+ $sourceImage = SourceImage::upload($file);
+
+ $image = ImageModel::factory()
+ ->forModel($user)
+ ->create([
+ 'source_image_id' => $sourceImage->getKey(),
+ 'context' => 'simple', // has generateResponsiveVersions = false
+ ]);
+
+ GenerateImageVersionJob::dispatchSync($image, Breakpoint::Small);
+
+ $component = new Image($image);
+ $view = $component->render();
+ $sources = $view->getData()['sources'];
+
+ expect($sources->count())->toBeGreaterThan(0);
+
+ // When responsive versions are disabled, srcset should be simple URL
+ $sources->each(function ($source) {
+ expect($source->srcset)->toBeString()->not->toBeEmpty();
+ });
+ });
+
+ it('component renders sources without media queries when image does not use breakpoints', function () {
+ $sourceImage = SourceImage::factory()->create();
+ $user = User::factory()->create();
+
+ $context = ImageContext::make('email')
+ ->useBreakpoints(false);
+
+ ImageLibrary::registerImageContext($context);
+
+ $image = ImageModel::create([
+ 'source_image_id' => $sourceImage->id,
+ 'model_type' => $user->getMorphClass(),
+ 'model_id' => $user->id,
+ 'context' => $context,
+ 'disk' => 'public',
+ ]);
+
+ $component = new Image($image);
+ $view = $component->render();
+
+ expect($view)->toBeInstanceOf(View::class);
+
+ $viewData = $view->getData();
+ expect($viewData)->toHaveKey('useBreakpoints');
+ expect($viewData['useBreakpoints'])->toBeFalse();
+ expect($viewData['sources'])->toBeArray();
+
+ // Check that sources don't have media queries
+ foreach ($viewData['sources'] as $source) {
+ expect($source->media)->toBe('');
+ }
+ });
+});
diff --git a/tests/Unit/Components/ScriptsTest.php b/tests/Unit/Components/ScriptsTest.php
new file mode 100644
index 0000000..9fbbe4d
--- /dev/null
+++ b/tests/Unit/Components/ScriptsTest.php
@@ -0,0 +1,27 @@
+toBeInstanceOf(Scripts::class);
+ });
+
+ it('renders the correct view', function (): void {
+ $component = new Scripts();
+
+ $view = $component->render();
+
+ expect($view)
+ ->toBeInstanceOf(View::class);
+
+ expect($view->getName())
+ ->toBe('image-library::components.scripts');
+ });
+});
diff --git a/tests/Unit/Entities/AspectRatioTest.php b/tests/Unit/Entities/AspectRatioTest.php
new file mode 100644
index 0000000..54753dd
--- /dev/null
+++ b/tests/Unit/Entities/AspectRatioTest.php
@@ -0,0 +1,34 @@
+toBeInstanceOf(AspectRatio::class)
+ ->horizontal->toBe(4)
+ ->vertical->toBe(3);
+});
+
+it('can be converted to string', function () {
+ $aspectRatio = new AspectRatio(4, 3);
+
+ expect((string) $aspectRatio)
+ ->toBe('4:3');
+
+ expect($aspectRatio->toString())
+ ->toBe('4:3');
+});
+
+it('can be converted to array', function () {
+ $aspectRatio = new AspectRatio(16, 9);
+
+ expect($aspectRatio->toArray())
+ ->toBe([
+ 'horizontal' => 16,
+ 'vertical' => 9,
+ ]);
+});
diff --git a/tests/Unit/Entities/CropDataTest.php b/tests/Unit/Entities/CropDataTest.php
new file mode 100644
index 0000000..c6ea1d3
--- /dev/null
+++ b/tests/Unit/Entities/CropDataTest.php
@@ -0,0 +1,34 @@
+toBeInstanceOf(CropData::class)
+ ->width->toBe(100)
+ ->height->toBe(100)
+ ->x->toBe(10)
+ ->y->toBe(20)
+ ->rotate->toBe(30)
+ ->scaleX->toBe(-1)
+ ->scaleY->toBe(1);
+});
+
+it('can be converted to array', function () {
+ $cropData = CropData::make(100, 100, 10, 20, 30, -1, 1);
+
+ expect($cropData->toArray())
+ ->toBe([
+ 'width' => 100,
+ 'height' => 100,
+ 'x' => 10,
+ 'y' => 20,
+ 'rotate' => 30,
+ 'scaleX' => -1,
+ 'scaleY' => 1,
+ ]);
+});
diff --git a/tests/Unit/Entities/ImageContextTest.php b/tests/Unit/Entities/ImageContextTest.php
new file mode 100644
index 0000000..021e173
--- /dev/null
+++ b/tests/Unit/Entities/ImageContextTest.php
@@ -0,0 +1,910 @@
+toBeInstanceOf(ImageContext::class);
+});
+
+it('can get and get the key', function () {
+ $imageContext = new ImageContext('thumbnail');
+
+ expect($imageContext->getKey())
+ ->toBe('thumbnail');
+});
+
+it('can get a hash string of the configuration', function () {
+ $imageContext1 = ImageContext::make('thumbnail')
+ ->label('Thumbnail Image')
+ ->aspectRatio(AspectRatio::make(16, 9))
+ ->blur(5)
+ ->grayscale(true)
+ ->sepia(false);
+
+ $imageContext2 = ImageContext::make('thumbnail')
+ ->label('Thumbnail Image')
+ ->aspectRatio(AspectRatio::make(16, 9))
+ ->blur(5)
+ ->grayscale(true)
+ ->sepia(false);
+
+ expect($imageContext1->getConfigurationHash())
+ ->toBeString();
+
+ expect($imageContext1->getConfigurationHash())
+ ->toEqual($imageContext2->getConfigurationHash());
+});
+
+describe('label', function () {
+ it('can set and get the label as string', function () {
+ $imageContext = ImageContext::make('thumbnail')
+ ->label('Thumbnail Image');
+
+ expect($imageContext->getLabel())
+ ->toBe('Thumbnail Image');
+ });
+
+ it('can set and get the label as closure', function () {
+ $imageContext = ImageContext::make('thumbnail')
+ ->label(function (ImageContext $imageContext) {
+ return Str::title($imageContext->getKey()).' Image from Closure';
+ });
+
+ expect($imageContext->getLabel())
+ ->toBe('Thumbnail Image from Closure');
+ });
+
+ it('falls back to a generated label from the key when no label is set', function () {
+ $imageContext = ImageContext::make('user_profile_image');
+
+ expect($imageContext->getLabel())
+ ->toBe('User Profile Image');
+ });
+});
+
+describe('aspect ratio', function () {
+ it('can set and get the aspect ratio for all breakpoints', function () {
+ $aspectRatio = AspectRatio::make(16, 9);
+
+ $imageContext = ImageContext::make('thumbnail')
+ ->aspectRatio($aspectRatio);
+
+ expect($imageContext->getAspectRatioByBreakpoint())
+ ->toHaveCount(count(Breakpoint::cases()))
+ ->each->toBe($aspectRatio);
+ });
+
+ it('can set and get the aspect ratio per breakpoint', function () {
+ $mobileAspectRatio = AspectRatio::make(4, 3);
+ $desktopAspectRatio = AspectRatio::make(16, 9);
+
+ $imageContext = ImageContext::make('thumbnail')
+ ->aspectRatio([
+ Breakpoint::Small->value => $mobileAspectRatio,
+ Breakpoint::Medium->value => $mobileAspectRatio,
+ Breakpoint::Large->value => $desktopAspectRatio,
+ Breakpoint::ExtraLarge->value => $desktopAspectRatio,
+ Breakpoint::DoubleExtraLarge->value => $desktopAspectRatio,
+ ]);
+
+ expect($imageContext->getAspectRatioByBreakpoint())
+ ->toHaveCount(count(Breakpoint::cases()))
+ ->and($imageContext->getAspectRatio(Breakpoint::Small))->toBe($mobileAspectRatio)
+ ->and($imageContext->getAspectRatio(Breakpoint::Medium))->toBe($mobileAspectRatio)
+ ->and($imageContext->getAspectRatio(Breakpoint::Large))->toBe($desktopAspectRatio)
+ ->and($imageContext->getAspectRatio(Breakpoint::ExtraLarge))->toBe($desktopAspectRatio)
+ ->and($imageContext->getAspectRatio(Breakpoint::DoubleExtraLarge))->toBe($desktopAspectRatio);
+ });
+
+ it('can set and get the aspect ratio for a specific breakpoint', function () {
+ $aspectRatio1 = AspectRatio::make(4, 3);
+ $aspectRatio2 = AspectRatio::make(16, 9);
+
+ $imageContext = ImageContext::make('thumbnail')
+ ->aspectRatio($aspectRatio2)
+ ->aspectRatioForBreakpoint(Breakpoint::Small, $aspectRatio1);
+
+ expect($imageContext->getAspectRatioByBreakpoint())
+ ->toHaveCount(count(Breakpoint::cases()))
+ ->and($imageContext->getAspectRatio(Breakpoint::Small))->toBe($aspectRatio1)
+ ->and($imageContext->getAspectRatio(Breakpoint::Medium))->toBe($aspectRatio2)
+ ->and($imageContext->getAspectRatio(Breakpoint::Large))->toBe($aspectRatio2)
+ ->and($imageContext->getAspectRatio(Breakpoint::ExtraLarge))->toBe($aspectRatio2)
+ ->and($imageContext->getAspectRatio(Breakpoint::DoubleExtraLarge))->toBe($aspectRatio2);
+ });
+
+ it('can set and get the aspect ratio for all breakpoints after and including a specific breakpoint', function () {
+ $aspectRatio1 = AspectRatio::make(4, 3);
+ $aspectRatio2 = AspectRatio::make(16, 9);
+
+ $imageContext = ImageContext::make('thumbnail')
+ ->aspectRatio($aspectRatio1)
+ ->aspectRatioFromBreakpoint(Breakpoint::Large, $aspectRatio2);
+
+ expect($imageContext->getAspectRatioByBreakpoint())
+ ->toHaveCount(count(Breakpoint::cases()))
+ ->and($imageContext->getAspectRatio(Breakpoint::Small))->toBe($aspectRatio1)
+ ->and($imageContext->getAspectRatio(Breakpoint::Medium))->toBe($aspectRatio1)
+ ->and($imageContext->getAspectRatio(Breakpoint::Large))->toBe($aspectRatio2)
+ ->and($imageContext->getAspectRatio(Breakpoint::ExtraLarge))->toBe($aspectRatio2)
+ ->and($imageContext->getAspectRatio(Breakpoint::DoubleExtraLarge))->toBe($aspectRatio2);
+ });
+
+ it('can set and get the aspect ratio for all breakpoints before and including a specific breakpoint', function () {
+ $aspectRatio1 = AspectRatio::make(4, 3);
+ $aspectRatio2 = AspectRatio::make(16, 9);
+
+ $imageContext = ImageContext::make('thumbnail')
+ ->aspectRatio($aspectRatio1)
+ ->aspectRatioToBreakpoint(Breakpoint::Large, $aspectRatio2);
+
+ expect($imageContext->getAspectRatioByBreakpoint())
+ ->toHaveCount(count(Breakpoint::cases()))
+ ->and($imageContext->getAspectRatio(Breakpoint::Small))->toBe($aspectRatio2)
+ ->and($imageContext->getAspectRatio(Breakpoint::Medium))->toBe($aspectRatio2)
+ ->and($imageContext->getAspectRatio(Breakpoint::Large))->toBe($aspectRatio2)
+ ->and($imageContext->getAspectRatio(Breakpoint::ExtraLarge))->toBe($aspectRatio1)
+ ->and($imageContext->getAspectRatio(Breakpoint::DoubleExtraLarge))->toBe($aspectRatio1);
+ });
+
+ it('can set and get the aspect ratio for all breakpoints between 2 breakpoints', function () {
+ $aspectRatio1 = AspectRatio::make(4, 3);
+ $aspectRatio2 = AspectRatio::make(16, 9);
+
+ $imageContext = ImageContext::make('thumbnail')
+ ->aspectRatio($aspectRatio1)
+ ->aspectRatioBetweenBreakpoints(Breakpoint::Medium, Breakpoint::ExtraLarge, $aspectRatio2);
+
+ expect($imageContext->getAspectRatioByBreakpoint())
+ ->toHaveCount(count(Breakpoint::cases()))
+ ->and($imageContext->getAspectRatio(Breakpoint::Small))->toBe($aspectRatio1)
+ ->and($imageContext->getAspectRatio(Breakpoint::Medium))->toBe($aspectRatio2)
+ ->and($imageContext->getAspectRatio(Breakpoint::Large))->toBe($aspectRatio2)
+ ->and($imageContext->getAspectRatio(Breakpoint::ExtraLarge))->toBe($aspectRatio2)
+ ->and($imageContext->getAspectRatio(Breakpoint::DoubleExtraLarge))->toBe($aspectRatio1);
+ });
+
+ it('throws an exception when aspect ratio for a breakpoint is not defined', function () {
+ ImageContext::make('thumbnail')
+ ->aspectRatio([
+ Breakpoint::Small->value => AspectRatio::make(4, 3),
+ Breakpoint::Large->value => AspectRatio::make(16, 9),
+ ]);
+ })->throws(InvalidArgumentException::class, "Aspect ratio for breakpoint 'md' is not defined for ImageContext with key 'thumbnail'.");
+});
+
+describe('minWidth', function () {
+ it('can set and get the min width for all breakpoints', function () {
+ $imageContext = ImageContext::make('thumbnail')
+ ->minWidth(320);
+
+ expect($imageContext->getMinWidthByBreakpoint())
+ ->toHaveCount(count(Breakpoint::cases()))
+ ->each->toBe(320);
+ });
+
+ it('can set and get the min width per breakpoint', function () {
+ $imageContext = ImageContext::make('thumbnail')
+ ->minWidth([
+ Breakpoint::Small->value => 320,
+ Breakpoint::Medium->value => 480,
+ Breakpoint::Large->value => 768,
+ Breakpoint::ExtraLarge->value => 1024,
+ Breakpoint::DoubleExtraLarge->value => 1280,
+ ]);
+
+ expect($imageContext->getMinWidthByBreakpoint())
+ ->toHaveCount(count(Breakpoint::cases()))
+ ->and($imageContext->getMinWidth(Breakpoint::Small))->toBe(320)
+ ->and($imageContext->getMinWidth(Breakpoint::Medium))->toBe(480)
+ ->and($imageContext->getMinWidth(Breakpoint::Large))->toBe(768)
+ ->and($imageContext->getMinWidth(Breakpoint::ExtraLarge))->toBe(1024)
+ ->and($imageContext->getMinWidth(Breakpoint::DoubleExtraLarge))->toBe(1280);
+ });
+
+ it('can set and get the min width for a specific breakpoint', function () {
+ $imageContext = ImageContext::make('thumbnail')
+ ->minWidth(320)
+ ->minWidthForBreakpoint(Breakpoint::Small, 480);
+
+ expect($imageContext->getMinWidthByBreakpoint())
+ ->toHaveCount(count(Breakpoint::cases()))
+ ->and($imageContext->getMinWidth(Breakpoint::Small))->toBe(480)
+ ->and($imageContext->getMinWidth(Breakpoint::Medium))->toBe(320)
+ ->and($imageContext->getMinWidth(Breakpoint::Large))->toBe(320)
+ ->and($imageContext->getMinWidth(Breakpoint::ExtraLarge))->toBe(320)
+ ->and($imageContext->getMinWidth(Breakpoint::DoubleExtraLarge))->toBe(320);
+ });
+
+ it('can set and get the min width for all breakpoints after and including a specific breakpoint', function () {
+ $imageContext = ImageContext::make('thumbnail')
+ ->minWidth(320)
+ ->minWidthFromBreakpoint(Breakpoint::Large, 768);
+
+ expect($imageContext->getMinWidthByBreakpoint())
+ ->toHaveCount(count(Breakpoint::cases()))
+ ->and($imageContext->getMinWidth(Breakpoint::Small))->toBe(320)
+ ->and($imageContext->getMinWidth(Breakpoint::Medium))->toBe(320)
+ ->and($imageContext->getMinWidth(Breakpoint::Large))->toBe(768)
+ ->and($imageContext->getMinWidth(Breakpoint::ExtraLarge))->toBe(768)
+ ->and($imageContext->getMinWidth(Breakpoint::DoubleExtraLarge))->toBe(768);
+ });
+
+ it('can set and get the min width for all breakpoints before and including a specific breakpoint', function () {
+ $imageContext = ImageContext::make('thumbnail')
+ ->minWidth(768)
+ ->minWidthToBreakpoint(Breakpoint::Large, 320);
+
+ expect($imageContext->getMinWidthByBreakpoint())
+ ->toHaveCount(count(Breakpoint::cases()))
+ ->and($imageContext->getMinWidth(Breakpoint::Small))->toBe(320)
+ ->and($imageContext->getMinWidth(Breakpoint::Medium))->toBe(320)
+ ->and($imageContext->getMinWidth(Breakpoint::Large))->toBe(320)
+ ->and($imageContext->getMinWidth(Breakpoint::ExtraLarge))->toBe(768)
+ ->and($imageContext->getMinWidth(Breakpoint::DoubleExtraLarge))->toBe(768);
+ });
+
+ it('can set and get the min width for all breakpoints between 2 breakpoints', function () {
+ $imageContext = ImageContext::make('thumbnail')
+ ->minWidth(320)
+ ->minWidthBetweenBreakpoints(Breakpoint::Medium, Breakpoint::ExtraLarge, 768);
+
+ expect($imageContext->getMinWidthByBreakpoint())
+ ->toHaveCount(count(Breakpoint::cases()))
+ ->and($imageContext->getMinWidth(Breakpoint::Small))->toBe(320)
+ ->and($imageContext->getMinWidth(Breakpoint::Medium))->toBe(768)
+ ->and($imageContext->getMinWidth(Breakpoint::Large))->toBe(768)
+ ->and($imageContext->getMinWidth(Breakpoint::ExtraLarge))->toBe(768)
+ ->and($imageContext->getMinWidth(Breakpoint::DoubleExtraLarge))->toBe(320);
+ });
+
+ it('throws an exception when min width for a breakpoint is not defined', function () {
+ ImageContext::make('thumbnail')
+ ->minWidth([
+ Breakpoint::Small->value => 320,
+ Breakpoint::Large->value => 768,
+ ]);
+ })->throws(InvalidArgumentException::class, "Min width for breakpoint 'md' is not defined for ImageContext with key 'thumbnail'.");
+});
+
+describe('maxWidth', function () {
+ it('can set and get the min width for all breakpoints', function () {
+ $imageContext = ImageContext::make('thumbnail')
+ ->maxWidth(320);
+
+ expect($imageContext->getMaxWidthByBreakpoint())
+ ->toHaveCount(count(Breakpoint::cases()))
+ ->each->toBe(320);
+ });
+
+ it('can set and get the min width per breakpoint', function () {
+ $imageContext = ImageContext::make('thumbnail')
+ ->maxWidth([
+ Breakpoint::Small->value => 320,
+ Breakpoint::Medium->value => 480,
+ Breakpoint::Large->value => 768,
+ Breakpoint::ExtraLarge->value => 1024,
+ Breakpoint::DoubleExtraLarge->value => 1280,
+ ]);
+
+ expect($imageContext->getMaxWidthByBreakpoint())
+ ->toHaveCount(count(Breakpoint::cases()))
+ ->and($imageContext->getMaxWidth(Breakpoint::Small))->toBe(320)
+ ->and($imageContext->getMaxWidth(Breakpoint::Medium))->toBe(480)
+ ->and($imageContext->getMaxWidth(Breakpoint::Large))->toBe(768)
+ ->and($imageContext->getMaxWidth(Breakpoint::ExtraLarge))->toBe(1024)
+ ->and($imageContext->getMaxWidth(Breakpoint::DoubleExtraLarge))->toBe(1280);
+ });
+
+ it('can set and get the min width for a specific breakpoint', function () {
+ $imageContext = ImageContext::make('thumbnail')
+ ->maxWidth(320)
+ ->maxWidthForBreakpoint(Breakpoint::Small, 480);
+
+ expect($imageContext->getMaxWidthByBreakpoint())
+ ->toHaveCount(count(Breakpoint::cases()))
+ ->and($imageContext->getMaxWidth(Breakpoint::Small))->toBe(480)
+ ->and($imageContext->getMaxWidth(Breakpoint::Medium))->toBe(320)
+ ->and($imageContext->getMaxWidth(Breakpoint::Large))->toBe(320)
+ ->and($imageContext->getMaxWidth(Breakpoint::ExtraLarge))->toBe(320)
+ ->and($imageContext->getMaxWidth(Breakpoint::DoubleExtraLarge))->toBe(320);
+ });
+
+ it('can set and get the min width for all breakpoints after and including a specific breakpoint', function () {
+ $imageContext = ImageContext::make('thumbnail')
+ ->maxWidth(320)
+ ->maxWidthFromBreakpoint(Breakpoint::Large, 768);
+
+ expect($imageContext->getMaxWidthByBreakpoint())
+ ->toHaveCount(count(Breakpoint::cases()))
+ ->and($imageContext->getMaxWidth(Breakpoint::Small))->toBe(320)
+ ->and($imageContext->getMaxWidth(Breakpoint::Medium))->toBe(320)
+ ->and($imageContext->getMaxWidth(Breakpoint::Large))->toBe(768)
+ ->and($imageContext->getMaxWidth(Breakpoint::ExtraLarge))->toBe(768)
+ ->and($imageContext->getMaxWidth(Breakpoint::DoubleExtraLarge))->toBe(768);
+ });
+
+ it('can set and get the min width for all breakpoints before and including a specific breakpoint', function () {
+ $imageContext = ImageContext::make('thumbnail')
+ ->maxWidth(768)
+ ->maxWidthToBreakpoint(Breakpoint::Large, 320);
+
+ expect($imageContext->getMaxWidthByBreakpoint())
+ ->toHaveCount(count(Breakpoint::cases()))
+ ->and($imageContext->getMaxWidth(Breakpoint::Small))->toBe(320)
+ ->and($imageContext->getMaxWidth(Breakpoint::Medium))->toBe(320)
+ ->and($imageContext->getMaxWidth(Breakpoint::Large))->toBe(320)
+ ->and($imageContext->getMaxWidth(Breakpoint::ExtraLarge))->toBe(768)
+ ->and($imageContext->getMaxWidth(Breakpoint::DoubleExtraLarge))->toBe(768);
+ });
+
+ it('can set and get the min width for all breakpoints between 2 breakpoints', function () {
+ $imageContext = ImageContext::make('thumbnail')
+ ->maxWidth(320)
+ ->maxWidthBetweenBreakpoints(Breakpoint::Medium, Breakpoint::ExtraLarge, 768);
+
+ expect($imageContext->getMaxWidthByBreakpoint())
+ ->toHaveCount(count(Breakpoint::cases()))
+ ->and($imageContext->getMaxWidth(Breakpoint::Small))->toBe(320)
+ ->and($imageContext->getMaxWidth(Breakpoint::Medium))->toBe(768)
+ ->and($imageContext->getMaxWidth(Breakpoint::Large))->toBe(768)
+ ->and($imageContext->getMaxWidth(Breakpoint::ExtraLarge))->toBe(768)
+ ->and($imageContext->getMaxWidth(Breakpoint::DoubleExtraLarge))->toBe(320);
+ });
+
+ it('throws an exception when min width for a breakpoint is not defined', function () {
+ ImageContext::make('thumbnail')
+ ->maxWidth([
+ Breakpoint::Small->value => 320,
+ Breakpoint::Large->value => 768,
+ ]);
+ })->throws(InvalidArgumentException::class, "Max width for breakpoint 'md' is not defined for ImageContext with key 'thumbnail'.");
+});
+
+describe('cropPosition', function () {
+ it('can set and get the crop position for all breakpoints', function () {
+ $imageContext = ImageContext::make('thumbnail')
+ ->cropPosition('center');
+
+ expect($imageContext->getCropPositionByBreakpoint())
+ ->toHaveCount(count(Breakpoint::cases()))
+ ->each->toBe(CropPosition::Center);
+ });
+
+ it('can set and get the crop position per breakpoint', function () {
+ $imageContext = ImageContext::make('thumbnail')
+ ->cropPosition([
+ Breakpoint::Small->value => 'top',
+ Breakpoint::Medium->value => 'bottom',
+ Breakpoint::Large->value => 'left',
+ Breakpoint::ExtraLarge->value => 'right',
+ Breakpoint::DoubleExtraLarge->value => 'center',
+ ]);
+
+ expect($imageContext->getCropPositionByBreakpoint())
+ ->toHaveCount(count(Breakpoint::cases()))
+ ->and($imageContext->getCropPosition(Breakpoint::Small))->toBe(CropPosition::Top)
+ ->and($imageContext->getCropPosition(Breakpoint::Medium))->toBe(CropPosition::Bottom)
+ ->and($imageContext->getCropPosition(Breakpoint::Large))->toBe(CropPosition::Left)
+ ->and($imageContext->getCropPosition(Breakpoint::ExtraLarge))->toBe(CropPosition::Right)
+ ->and($imageContext->getCropPosition(Breakpoint::DoubleExtraLarge))->toBe(CropPosition::Center);
+ });
+
+ it('falls back to the default crop position if not set', function () {
+ $imageContext = ImageContext::make('thumbnail');
+
+ expect($imageContext->getCropPosition(Breakpoint::Small))
+ ->toBe(ImageLibrary::getDefaultCropPosition());
+ });
+
+ it('can use the spatie CropPosition enums', function () {
+ $imageContext = ImageContext::make('thumbnail')
+ ->cropPosition(CropPosition::Center);
+
+ expect($imageContext->getCropPosition(Breakpoint::Small))
+ ->toBe(CropPosition::Center);
+ });
+
+ it('throws an exception when crop position for a breakpoint is not defined', function () {
+ ImageContext::make('thumbnail')
+ ->cropPosition([
+ Breakpoint::Small->value => 'top',
+ Breakpoint::Large->value => 'bottom',
+ ]);
+ })->throws(InvalidArgumentException::class, "Crop position for breakpoint 'md' is not defined for ImageContext with key 'thumbnail'.");
+
+ it('can set and get the crop position for a specific breakpoint', function () {
+ $imageContext = ImageContext::make('thumbnail')
+ ->cropPosition('center')
+ ->cropPositionForBreakpoint(Breakpoint::Small, 'topLeft');
+
+ expect($imageContext->getCropPositionByBreakpoint())
+ ->toHaveCount(count(Breakpoint::cases()))
+ ->and($imageContext->getCropPosition(Breakpoint::Small))->toBe(CropPosition::TopLeft)
+ ->and($imageContext->getCropPosition(Breakpoint::Medium))->toBe(CropPosition::Center)
+ ->and($imageContext->getCropPosition(Breakpoint::Large))->toBe(CropPosition::Center)
+ ->and($imageContext->getCropPosition(Breakpoint::ExtraLarge))->toBe(CropPosition::Center)
+ ->and($imageContext->getCropPosition(Breakpoint::DoubleExtraLarge))->toBe(CropPosition::Center);
+ });
+
+ it('can set and get the crop position for all breakpoints after and including a specific breakpoint', function () {
+ $imageContext = ImageContext::make('thumbnail')
+ ->cropPosition('topLeft')
+ ->cropPositionFromBreakpoint(Breakpoint::Large, 'bottomRight');
+
+ expect($imageContext->getCropPositionByBreakpoint())
+ ->toHaveCount(count(Breakpoint::cases()))
+ ->and($imageContext->getCropPosition(Breakpoint::Small))->toBe(CropPosition::TopLeft)
+ ->and($imageContext->getCropPosition(Breakpoint::Medium))->toBe(CropPosition::TopLeft)
+ ->and($imageContext->getCropPosition(Breakpoint::Large))->toBe(CropPosition::BottomRight)
+ ->and($imageContext->getCropPosition(Breakpoint::ExtraLarge))->toBe(CropPosition::BottomRight)
+ ->and($imageContext->getCropPosition(Breakpoint::DoubleExtraLarge))->toBe(CropPosition::BottomRight);
+ });
+
+ it('can set and get the crop position for all breakpoints before and including a specific breakpoint', function () {
+ $imageContext = ImageContext::make('thumbnail')
+ ->cropPosition('topLeft')
+ ->cropPositionToBreakpoint(Breakpoint::Large, 'bottomRight');
+
+ expect($imageContext->getCropPositionByBreakpoint())
+ ->toHaveCount(count(Breakpoint::cases()))
+ ->and($imageContext->getCropPosition(Breakpoint::Small))->toBe(CropPosition::BottomRight)
+ ->and($imageContext->getCropPosition(Breakpoint::Medium))->toBe(CropPosition::BottomRight)
+ ->and($imageContext->getCropPosition(Breakpoint::Large))->toBe(CropPosition::BottomRight)
+ ->and($imageContext->getCropPosition(Breakpoint::ExtraLarge))->toBe(CropPosition::TopLeft)
+ ->and($imageContext->getCropPosition(Breakpoint::DoubleExtraLarge))->toBe(CropPosition::TopLeft);
+ });
+
+ it('can set and get the crop position for all breakpoints between 2 breakpoints', function () {
+ $imageContext = ImageContext::make('thumbnail')
+ ->cropPosition('topLeft')
+ ->cropPositionBetweenBreakpoints(Breakpoint::Medium, Breakpoint::ExtraLarge, 'bottomRight');
+
+ expect($imageContext->getCropPositionByBreakpoint())
+ ->toHaveCount(count(Breakpoint::cases()))
+ ->and($imageContext->getCropPosition(Breakpoint::Small))->toBe(CropPosition::TopLeft)
+ ->and($imageContext->getCropPosition(Breakpoint::Medium))->toBe(CropPosition::BottomRight)
+ ->and($imageContext->getCropPosition(Breakpoint::Large))->toBe(CropPosition::BottomRight)
+ ->and($imageContext->getCropPosition(Breakpoint::ExtraLarge))->toBe(CropPosition::BottomRight)
+ ->and($imageContext->getCropPosition(Breakpoint::DoubleExtraLarge))->toBe(CropPosition::TopLeft);
+ });
+});
+
+describe('blur', function () {
+ it('can set and get the blur for all breakpoints', function () {
+ $imageContext = ImageContext::make('thumbnail')
+ ->blur(5);
+
+ expect($imageContext->getBlurByBreakpoint())
+ ->toHaveCount(count(Breakpoint::cases()))
+ ->each->toBe(5);
+ });
+
+ test('blur must be at least 0', function () {
+ ImageContext::make('thumbnail')
+ ->blur(-1);
+ })->throws(InvalidArgumentException::class, "Blur value must be between 0 and 100 for ImageContext with key 'thumbnail'.");
+
+ test('blur must be at least 0 for each breakpoint', function () {
+ ImageContext::make('thumbnail')
+ ->blur([
+ Breakpoint::Small->value => 5,
+ Breakpoint::Medium->value => -2,
+ ]);
+ })->throws(InvalidArgumentException::class, "Blur value for breakpoint 'md' must be between 0 and 100 for ImageContext with key 'thumbnail'.");
+
+ test('blur must be at most 100', function () {
+ ImageContext::make('thumbnail')
+ ->blur(101);
+ })->throws(InvalidArgumentException::class, "Blur value must be between 0 and 100 for ImageContext with key 'thumbnail'.");
+
+ test('blur must be at most 100 for each breakpoint', function () {
+ ImageContext::make('thumbnail')
+ ->blur([
+ Breakpoint::Small->value => 50,
+ Breakpoint::Medium->value => 150,
+ ]);
+ })->throws(InvalidArgumentException::class, "Blur value for breakpoint 'md' must be between 0 and 100 for ImageContext with key 'thumbnail'.");
+
+ test('blur must be an integer for each breakpoint', function () {
+ ImageContext::make('thumbnail')
+ ->blur([
+ Breakpoint::Small->value => 5,
+ Breakpoint::Medium->value => 'high',
+ ]);
+ })->throws(InvalidArgumentException::class, "Blur value for breakpoint 'md' must be an integer for ImageContext with key 'thumbnail'.");
+
+ it('can set and get the blur per breakpoint', function () {
+ $imageContext = ImageContext::make('thumbnail')
+ ->blur([
+ Breakpoint::Small->value => 2,
+ Breakpoint::Medium->value => 4,
+ Breakpoint::Large->value => 6,
+ Breakpoint::ExtraLarge->value => 8,
+ Breakpoint::DoubleExtraLarge->value => 10,
+ ]);
+
+ expect($imageContext->getBlurByBreakpoint())
+ ->toHaveCount(count(Breakpoint::cases()))
+ ->and($imageContext->getBlur(Breakpoint::Small))->toBe(2)
+ ->and($imageContext->getBlur(Breakpoint::Medium))->toBe(4)
+ ->and($imageContext->getBlur(Breakpoint::Large))->toBe(6)
+ ->and($imageContext->getBlur(Breakpoint::ExtraLarge))->toBe(8)
+ ->and($imageContext->getBlur(Breakpoint::DoubleExtraLarge))->toBe(10);
+ });
+
+ it('can set and get the blur for a specific breakpoint', function () {
+ $imageContext = ImageContext::make('thumbnail')
+ ->blur(0)
+ ->blurForBreakpoint(Breakpoint::Small, 5);
+
+ expect($imageContext->getBlurByBreakpoint())
+ ->toHaveCount(count(Breakpoint::cases()))
+ ->and($imageContext->getBlur(Breakpoint::Small))->toBe(5)
+ ->and($imageContext->getBlur(Breakpoint::Medium))->toBe(0)
+ ->and($imageContext->getBlur(Breakpoint::Large))->toBe(0)
+ ->and($imageContext->getBlur(Breakpoint::ExtraLarge))->toBe(0)
+ ->and($imageContext->getBlur(Breakpoint::DoubleExtraLarge))->toBe(0);
+ });
+
+ it('can set and get the blur for all breakpoints after and including a specific breakpoint', function () {
+ $imageContext = ImageContext::make('thumbnail')
+ ->blur(0)
+ ->blurFromBreakpoint(Breakpoint::Large, 10);
+
+ expect($imageContext->getBlurByBreakpoint())
+ ->toHaveCount(count(Breakpoint::cases()))
+ ->and($imageContext->getBlur(Breakpoint::Small))->toBe(0)
+ ->and($imageContext->getBlur(Breakpoint::Medium))->toBe(0)
+ ->and($imageContext->getBlur(Breakpoint::Large))->toBe(10)
+ ->and($imageContext->getBlur(Breakpoint::ExtraLarge))->toBe(10)
+ ->and($imageContext->getBlur(Breakpoint::DoubleExtraLarge))->toBe(10);
+ });
+
+ it('can set and get the blur for all breakpoints before and including a specific breakpoint', function () {
+ $imageContext = ImageContext::make('thumbnail')
+ ->blur(0)
+ ->blurToBreakpoint(Breakpoint::Large, 10);
+
+ expect($imageContext->getBlurByBreakpoint())
+ ->toHaveCount(count(Breakpoint::cases()))
+ ->and($imageContext->getBlur(Breakpoint::Small))->toBe(10)
+ ->and($imageContext->getBlur(Breakpoint::Medium))->toBe(10)
+ ->and($imageContext->getBlur(Breakpoint::Large))->toBe(10)
+ ->and($imageContext->getBlur(Breakpoint::ExtraLarge))->toBe(0)
+ ->and($imageContext->getBlur(Breakpoint::DoubleExtraLarge))->toBe(0);
+ });
+
+ it('can set and get the blur for all breakpoints between 2 breakpoints', function () {
+ $imageContext = ImageContext::make('thumbnail')
+ ->blur(0)
+ ->blurBetweenBreakpoints(Breakpoint::Medium, Breakpoint::ExtraLarge, 10);
+
+ expect($imageContext->getBlurByBreakpoint())
+ ->toHaveCount(count(Breakpoint::cases()))
+ ->and($imageContext->getBlur(Breakpoint::Small))->toBe(0)
+ ->and($imageContext->getBlur(Breakpoint::Medium))->toBe(10)
+ ->and($imageContext->getBlur(Breakpoint::Large))->toBe(10)
+ ->and($imageContext->getBlur(Breakpoint::ExtraLarge))->toBe(10)
+ ->and($imageContext->getBlur(Breakpoint::DoubleExtraLarge))->toBe(0);
+ });
+
+ it('throws an exception when blur for a breakpoint is not defined', function () {
+ ImageContext::make('thumbnail')
+ ->blur([
+ Breakpoint::Small->value => 5,
+ Breakpoint::Large->value => 10,
+ ]);
+ })->throws(InvalidArgumentException::class, "Blur for breakpoint 'md' is not defined for ImageContext with key 'thumbnail'.");
+
+ it('returns null if not defined', function () {
+ $imageContext = ImageContext::make('thumbnail');
+
+ expect($imageContext->getBlur(Breakpoint::Small))->toBeNull();
+ });
+});
+
+describe('grayscale', function () {
+ it('can set and get the grayscale for all breakpoints', function () {
+ $imageContext = ImageContext::make('thumbnail')
+ ->grayscale(true);
+
+ expect($imageContext->getGrayscaleByBreakpoint())
+ ->toHaveCount(count(Breakpoint::cases()))
+ ->each->toBeTrue();
+ });
+
+ it('can set and get the grayscale per breakpoint', function () {
+ $imageContext = ImageContext::make('thumbnail')
+ ->grayscale([
+ Breakpoint::Small->value => true,
+ Breakpoint::Medium->value => false,
+ Breakpoint::Large->value => true,
+ Breakpoint::ExtraLarge->value => false,
+ Breakpoint::DoubleExtraLarge->value => true,
+ ]);
+
+ expect($imageContext->getGrayscaleByBreakpoint())
+ ->toHaveCount(count(Breakpoint::cases()))
+ ->and($imageContext->getGrayscale(Breakpoint::Small))->toBeTrue()
+ ->and($imageContext->getGrayscale(Breakpoint::Medium))->toBeFalse()
+ ->and($imageContext->getGrayscale(Breakpoint::Large))->toBeTrue()
+ ->and($imageContext->getGrayscale(Breakpoint::ExtraLarge))->toBeFalse()
+ ->and($imageContext->getGrayscale(Breakpoint::DoubleExtraLarge))->toBeTrue();
+ });
+
+ it('can set and get the grayscale for a specific breakpoint', function () {
+ $imageContext = ImageContext::make('thumbnail')
+ ->grayscale(false)
+ ->grayscaleForBreakpoint(Breakpoint::Small, true);
+
+ expect($imageContext->getGrayscaleByBreakpoint())
+ ->toHaveCount(count(Breakpoint::cases()))
+ ->and($imageContext->getGrayscale(Breakpoint::Small))->toBeTrue()
+ ->and($imageContext->getGrayscale(Breakpoint::Medium))->toBeFalse()
+ ->and($imageContext->getGrayscale(Breakpoint::Large))->toBeFalse()
+ ->and($imageContext->getGrayscale(Breakpoint::ExtraLarge))->toBeFalse()
+ ->and($imageContext->getGrayscale(Breakpoint::DoubleExtraLarge))->toBeFalse();
+ });
+
+ it('can set and get the grayscale for all breakpoints after and including a specific breakpoint', function () {
+ $imageContext = ImageContext::make('thumbnail')
+ ->grayscale(false)
+ ->grayscaleFromBreakpoint(Breakpoint::Large, true);
+
+ expect($imageContext->getGrayscaleByBreakpoint())
+ ->toHaveCount(count(Breakpoint::cases()))
+ ->and($imageContext->getGrayscale(Breakpoint::Small))->toBeFalse()
+ ->and($imageContext->getGrayscale(Breakpoint::Medium))->toBeFalse()
+ ->and($imageContext->getGrayscale(Breakpoint::Large))->toBeTrue()
+ ->and($imageContext->getGrayscale(Breakpoint::ExtraLarge))->toBeTrue()
+ ->and($imageContext->getGrayscale(Breakpoint::DoubleExtraLarge))->toBeTrue();
+ });
+
+ it('can set and get the grayscale for all breakpoints before and including a specific breakpoint', function () {
+ $imageContext = ImageContext::make('thumbnail')
+ ->grayscale(false)
+ ->grayscaleToBreakpoint(Breakpoint::Large, true);
+
+ expect($imageContext->getGrayscaleByBreakpoint())
+ ->toHaveCount(count(Breakpoint::cases()))
+ ->and($imageContext->getGrayscale(Breakpoint::Small))->toBeTrue()
+ ->and($imageContext->getGrayscale(Breakpoint::Medium))->toBeTrue()
+ ->and($imageContext->getGrayscale(Breakpoint::Large))->toBeTrue()
+ ->and($imageContext->getGrayscale(Breakpoint::ExtraLarge))->toBeFalse()
+ ->and($imageContext->getGrayscale(Breakpoint::DoubleExtraLarge))->toBeFalse();
+ });
+
+ it('can set and get the grayscale for all breakpoints between 2 breakpoints', function () {
+ $imageContext = ImageContext::make('thumbnail')
+ ->grayscale(false)
+ ->grayscaleBetweenBreakpoints(Breakpoint::Medium, Breakpoint::ExtraLarge, true);
+
+ expect($imageContext->getGrayscaleByBreakpoint())
+ ->toHaveCount(count(Breakpoint::cases()))
+ ->and($imageContext->getGrayscale(Breakpoint::Small))->toBeFalse()
+ ->and($imageContext->getGrayscale(Breakpoint::Medium))->toBeTrue()
+ ->and($imageContext->getGrayscale(Breakpoint::Large))->toBeTrue()
+ ->and($imageContext->getGrayscale(Breakpoint::ExtraLarge))->toBeTrue()
+ ->and($imageContext->getGrayscale(Breakpoint::DoubleExtraLarge))->toBeFalse();
+ });
+
+ it('throws an exception when grayscale for a breakpoint is not defined', function () {
+ ImageContext::make('thumbnail')
+ ->grayscale([
+ Breakpoint::Small->value => true,
+ Breakpoint::Large->value => false,
+ ]);
+ })->throws(InvalidArgumentException::class, "Greyscale for breakpoint 'md' is not defined for ImageContext with key 'thumbnail'.");
+
+ it('returns null if not defined', function () {
+ $imageContext = ImageContext::make('thumbnail');
+
+ expect($imageContext->getGrayscale(Breakpoint::Small))->toBeNull();
+ });
+
+ test('grayscale accepts only boolean values for each breakpoint', function () {
+ ImageContext::make('thumbnail')
+ ->grayscale([
+ Breakpoint::Small->value => true,
+ Breakpoint::Medium->value => 'yes',
+ ]);
+ })->throws(InvalidArgumentException::class, "Greyscale value for breakpoint 'md' must be a boolean for ImageContext with key 'thumbnail'.");
+});
+
+describe('sepia', function () {
+ it('can set and get the sepia for all breakpoints', function () {
+ $imageContext = ImageContext::make('thumbnail')
+ ->sepia(true);
+
+ expect($imageContext->getSepiaByBreakpoint())
+ ->toHaveCount(count(Breakpoint::cases()))
+ ->each->toBeTrue();
+ });
+
+ it('can set and get the sepia per breakpoint', function () {
+ $imageContext = ImageContext::make('thumbnail')
+ ->sepia([
+ Breakpoint::Small->value => true,
+ Breakpoint::Medium->value => false,
+ Breakpoint::Large->value => true,
+ Breakpoint::ExtraLarge->value => false,
+ Breakpoint::DoubleExtraLarge->value => true,
+ ]);
+
+ expect($imageContext->getSepiaByBreakpoint())
+ ->toHaveCount(count(Breakpoint::cases()))
+ ->and($imageContext->getSepia(Breakpoint::Small))->toBeTrue()
+ ->and($imageContext->getSepia(Breakpoint::Medium))->toBeFalse()
+ ->and($imageContext->getSepia(Breakpoint::Large))->toBeTrue()
+ ->and($imageContext->getSepia(Breakpoint::ExtraLarge))->toBeFalse()
+ ->and($imageContext->getSepia(Breakpoint::DoubleExtraLarge))->toBeTrue();
+ });
+
+ it('can set and get the sepia for a specific breakpoint', function () {
+ $imageContext = ImageContext::make('thumbnail')
+ ->sepia(false)
+ ->sepiaForBreakpoint(Breakpoint::Small, true);
+
+ expect($imageContext->getSepiaByBreakpoint())
+ ->toHaveCount(count(Breakpoint::cases()))
+ ->and($imageContext->getSepia(Breakpoint::Small))->toBeTrue()
+ ->and($imageContext->getSepia(Breakpoint::Medium))->toBeFalse()
+ ->and($imageContext->getSepia(Breakpoint::Large))->toBeFalse()
+ ->and($imageContext->getSepia(Breakpoint::ExtraLarge))->toBeFalse()
+ ->and($imageContext->getSepia(Breakpoint::DoubleExtraLarge))->toBeFalse();
+ });
+
+ it('can set and get the sepia for all breakpoints after and including a specific breakpoint', function () {
+ $imageContext = ImageContext::make('thumbnail')
+ ->sepia(false)
+ ->sepiaFromBreakpoint(Breakpoint::Large, true);
+
+ expect($imageContext->getSepiaByBreakpoint())
+ ->toHaveCount(count(Breakpoint::cases()))
+ ->and($imageContext->getSepia(Breakpoint::Small))->toBeFalse()
+ ->and($imageContext->getSepia(Breakpoint::Medium))->toBeFalse()
+ ->and($imageContext->getSepia(Breakpoint::Large))->toBeTrue()
+ ->and($imageContext->getSepia(Breakpoint::ExtraLarge))->toBeTrue()
+ ->and($imageContext->getSepia(Breakpoint::DoubleExtraLarge))->toBeTrue();
+ });
+
+ it('can set and get the sepia for all breakpoints before and including a specific breakpoint', function () {
+ $imageContext = ImageContext::make('thumbnail')
+ ->sepia(false)
+ ->sepiaToBreakpoint(Breakpoint::Large, true);
+
+ expect($imageContext->getSepiaByBreakpoint())
+ ->toHaveCount(count(Breakpoint::cases()))
+ ->and($imageContext->getSepia(Breakpoint::Small))->toBeTrue()
+ ->and($imageContext->getSepia(Breakpoint::Medium))->toBeTrue()
+ ->and($imageContext->getSepia(Breakpoint::Large))->toBeTrue()
+ ->and($imageContext->getSepia(Breakpoint::ExtraLarge))->toBeFalse()
+ ->and($imageContext->getSepia(Breakpoint::DoubleExtraLarge))->toBeFalse();
+ });
+
+ it('can set and get the sepia for all breakpoints between 2 breakpoints', function () {
+ $imageContext = ImageContext::make('thumbnail')
+ ->sepia(false)
+ ->sepiaBetweenBreakpoints(Breakpoint::Medium, Breakpoint::ExtraLarge, true);
+
+ expect($imageContext->getSepiaByBreakpoint())
+ ->toHaveCount(count(Breakpoint::cases()))
+ ->and($imageContext->getSepia(Breakpoint::Small))->toBeFalse()
+ ->and($imageContext->getSepia(Breakpoint::Medium))->toBeTrue()
+ ->and($imageContext->getSepia(Breakpoint::Large))->toBeTrue()
+ ->and($imageContext->getSepia(Breakpoint::ExtraLarge))->toBeTrue()
+ ->and($imageContext->getSepia(Breakpoint::DoubleExtraLarge))->toBeFalse();
+ });
+
+ it('throws an exception when sepia for a breakpoint is not defined', function () {
+ ImageContext::make('thumbnail')
+ ->sepia([
+ Breakpoint::Small->value => true,
+ Breakpoint::Large->value => false,
+ ]);
+ })->throws(InvalidArgumentException::class, "Sepia for breakpoint 'md' is not defined for ImageContext with key 'thumbnail'.");
+
+ it('returns null if not defined', function () {
+ $imageContext = ImageContext::make('thumbnail');
+
+ expect($imageContext->getSepia(Breakpoint::Small))->toBeNull();
+ });
+
+ test('sepia accepts only boolean values for each breakpoint', function () {
+ ImageContext::make('thumbnail')
+ ->sepia([
+ Breakpoint::Small->value => true,
+ Breakpoint::Medium->value => 'yes',
+ ]);
+ })->throws(InvalidArgumentException::class, "Sepia value for breakpoint 'md' must be a boolean for ImageContext with key 'thumbnail'.");
+});
+
+describe('allowsMultiple', function () {
+ it('can set and get allowsMultiple', function () {
+ $imageContext = ImageContext::make('gallery')
+ ->allowsMultiple(true);
+
+ expect($imageContext->getAllowsMultiple())
+ ->toBeTrue();
+ });
+});
+
+describe('generateWebP', function () {
+ it('can set and get generateWebP', function () {
+ $imageContext = ImageContext::make('thumbnail')
+ ->generateWebP(true);
+
+ expect($imageContext->getGenerateWebP())
+ ->toBeTrue();
+ });
+
+ it('falls back to the config value if not set', function () {
+ $imageContext = ImageContext::make('thumbnail');
+
+ Config::set('image-library.generate.webp', true);
+
+ expect($imageContext->getGenerateWebP())
+ ->toBeTrue();
+
+ Config::set('image-library.generate.webp', false);
+
+ expect($imageContext->getGenerateWebP())
+ ->toBeFalse();
+ });
+});
+
+describe('generateResponsiveVersions', function () {
+ it('can set and get generateResponsiveVersions', function () {
+ $imageContext = ImageContext::make('thumbnail')
+ ->generateResponsiveVersions(true);
+
+ expect($imageContext->getGenerateResponsiveVersions())
+ ->toBeTrue();
+ });
+
+ it('falls back to the config value if not set', function () {
+ $imageContext = ImageContext::make('thumbnail');
+
+ Config::set('image-library.generate.responsive_versions', true);
+
+ expect($imageContext->getGenerateResponsiveVersions())
+ ->toBeTrue();
+
+ Config::set('image-library.generate.responsive_versions', false);
+
+ expect($imageContext->getGenerateResponsiveVersions())
+ ->toBeFalse();
+ });
+});
+
+describe('optional breakpoints', function () {
+ it('throws exception when trying to set array values for context that does not use breakpoints', function () {
+ $context = ImageContext::make('email')
+ ->useBreakpoints(false);
+
+ expect(fn () => $context->aspectRatio(['sm' => AspectRatio::make(16, 9)]))
+ ->toThrow(InvalidArgumentException::class, 'Aspect ratio must be an instance of AspectRatio when breakpoints are disabled');
+ });
+
+ it('can set single values for context that does not use breakpoints', function () {
+ $context = ImageContext::make('email')
+ ->useBreakpoints(false)
+ ->aspectRatio(AspectRatio::make(16, 9));
+
+ expect($context->getAspectRatio())->not->toBeNull();
+ expect($context->getAspectRatio()->horizontal)->toBe(16);
+ expect($context->getAspectRatio()->vertical)->toBe(9);
+ });
+});
diff --git a/tests/Unit/Enums/BreakpointTest.php b/tests/Unit/Enums/BreakpointTest.php
new file mode 100644
index 0000000..1833ca1
--- /dev/null
+++ b/tests/Unit/Enums/BreakpointTest.php
@@ -0,0 +1,57 @@
+toBeArray()
+ ->toHaveCount(count(Breakpoint::cases()));
+
+ for ($i = 0; $i < count($sorted) - 1; $i++) {
+ expect($sorted[$i]->getMinWidth())
+ ->toBeLessThan($sorted[$i + 1]->getMinWidth());
+ }
+});
+
+test('each breakpoint has a label', function (Breakpoint $breakpoint) {
+ expect($breakpoint->getLabel())
+ ->toBeString()
+ ->not->toBeEmpty();
+})
+ ->with('breakpoints');
+
+test('each breakpoint has a minimum width', function (Breakpoint $breakpoint) {
+ expect($breakpoint->getMinWidth())
+ ->toBeInt()
+ ->toBeGreaterThan(0);
+})
+ ->with('breakpoints');
+
+test('each breakpoint has a maximum width except the last one', function (Breakpoint $breakpoint) {
+ $breakpoints = Breakpoint::sortedCases();
+
+ if ($breakpoint === $breakpoints[array_key_last($breakpoints)]) {
+ expect($breakpoint->getMaxWidth())
+ ->toBeNull();
+ } else {
+ expect($breakpoint->getMaxWidth())
+ ->toBeInt()
+ ->toBeGreaterThan($breakpoint->getMinWidth());
+ }
+})
+ ->with('breakpoints');
+
+test('each breakpoint has a slug', function (Breakpoint $breakpoint) {
+ expect($breakpoint->getSlug())
+ ->toBeString()
+ ->not->toBeEmpty();
+})
+ ->with('breakpoints');
diff --git a/tests/Unit/ImageLibraryTest.php b/tests/Unit/ImageLibraryTest.php
new file mode 100644
index 0000000..c3ee09d
--- /dev/null
+++ b/tests/Unit/ImageLibraryTest.php
@@ -0,0 +1,195 @@
+toBeString();
+
+ expect(class_exists($enumClass))
+ ->toBeTrue();
+
+ expect($enumClass)
+ ->toEqual(Breakpoint::class);
+});
+
+it('has a method to return the image model class', function () {
+ $modelClass = ImageLibrary::getImageModel();
+
+ expect($modelClass)
+ ->toBeString();
+
+ expect(class_exists($modelClass))
+ ->toBeTrue();
+
+ expect($modelClass)
+ ->toEqual(Image::class);
+});
+
+it('has a method to return the source image model class', function () {
+ $modelClass = ImageLibrary::getSourceImageModel();
+
+ expect($modelClass)
+ ->toBeString();
+
+ expect(class_exists($modelClass))
+ ->toBeTrue();
+
+ expect($modelClass)
+ ->toEqual(SourceImage::class);
+});
+
+it('has a method to return a Spatie Image instance', function () {
+ $spatieImage = ImageLibrary::getSpatieImage();
+
+ expect($spatieImage)
+ ->toBeInstanceOf(SpatieImage::class);
+});
+
+it('can register one or more image contexts', function () {
+ ImageLibrary::registerImageContexts([
+ ImageContext::make('context-single'),
+ ImageContext::make('context-multiple'),
+ ]);
+
+ expect(count(ImageLibrary::getImageContexts()))
+ ->toEqual(2);
+});
+
+it('throws an exception when registering invalid image contexts', function () {
+ ImageLibrary::registerImageContexts([
+ ImageContext::make('context-single'),
+ 'invalid-context',
+ ]);
+})->throws(InvalidArgumentException::class, 'Expected instance of ImageContext, but got string instead.');
+
+it('can register a single image context', function () {
+ ImageLibrary::registerImageContext(
+ ImageContext::make('context-single')
+ );
+ ImageLibrary::registerImageContext(
+ ImageContext::make('context-multiple')
+ );
+
+ expect(count(ImageLibrary::getImageContexts()))
+ ->toEqual(2);
+});
+
+it('can remove an image context', function () {
+ $imageContext = ImageContext::make('context-single');
+ $imageContext = ImageContext::make('context-multiple');
+
+ ImageLibrary::registerImageContext($imageContext);
+
+ expect(count(ImageLibrary::getImageContexts()))
+ ->toEqual(2);
+
+ ImageLibrary::removeImageContext($imageContext);
+
+ expect(count(ImageLibrary::getImageContexts()))
+ ->toEqual(1);
+});
+
+it('can get all registered image contexts', function () {
+ ImageLibrary::registerImageContexts([
+ ImageContext::make('context-single'),
+ ImageContext::make('context-multiple'),
+ ]);
+
+ $imageContexts = ImageLibrary::getImageContexts();
+
+ expect($imageContexts)
+ ->toBeArray()
+ ->toHaveCount(2);
+});
+
+it('can get an image context by its key', function () {
+ $imageContext = ImageContext::make('context-single');
+
+ ImageLibrary::registerImageContext($imageContext);
+
+ $fetchedContext = ImageLibrary::getImageContextByKey('context-single');
+
+ expect($fetchedContext)
+ ->toBeInstanceOf(ImageContext::class)
+ ->and($fetchedContext->getKey())
+ ->toEqual('context-single');
+});
+
+it('returns null when getting an image context by a non-existent key', function () {
+ $fetchedContext = ImageLibrary::getImageContextByKey('non-existent-key');
+
+ expect($fetchedContext)
+ ->toBeNull();
+});
+
+it('returns null when getting an image context by a blank key', function () {
+ expect(ImageLibrary::getImageContextByKey(''))
+ ->toBeNull();
+
+ expect(ImageLibrary::getImageContextByKey(null))
+ ->toBeNull();
+});
+
+it('has a method to upload a file as a source image', function () {
+ $file = UploadedFile::fake()->image('example-image.jpg', 10, 10);
+
+ $sourceImage = ImageLibrary::upload($file);
+
+ expect($sourceImage)
+ ->toBeInstanceOf(SourceImage::class)
+ ->and($sourceImage->disk)
+ ->toEqual('public')
+ ->and($sourceImage->name)
+ ->toEqual('example-image')
+ ->and($sourceImage->extension)
+ ->toEqual('jpg')
+ ->and($sourceImage->mime_type)
+ ->toEqual('image/jpeg')
+ ->and($sourceImage->width)
+ ->toEqual(10)
+ ->and($sourceImage->height)
+ ->toEqual(10);
+
+ Storage::disk($sourceImage->disk)
+ ->assertExists($sourceImage->getRelativePath());
+});
+
+it('has a method to determine if temporary URLs should be used for a given disk', function () {
+ $usesTemporaryUrls = ImageLibrary::shouldUseTemporaryUrlsForDisk('s3');
+
+ expect($usesTemporaryUrls)
+ ->toBeBool();
+});
+
+it('returns the default value when determining if temporary URLs should be used for a disk not explicitly configured', function () {
+ $usesTemporaryUrls = ImageLibrary::shouldUseTemporaryUrlsForDisk('non-existent-disk');
+
+ expect($usesTemporaryUrls)
+ ->toEqual(false);
+});
+
+it('has a method to determine the temporary URL expiration time for a given disk', function () {
+ $expirationTime = ImageLibrary::getTemporaryUrlExpirationMinutesForDisk('s3');
+
+ expect($expirationTime)
+ ->toBeInt();
+});
+
+it('returns the default value when determining the temporary URL expiration time for a disk not explicitly configured', function () {
+ $expirationTime = ImageLibrary::getTemporaryUrlExpirationMinutesForDisk('non-existent-disk');
+
+ expect($expirationTime)
+ ->toBeInt();
+});
diff --git a/tests/Unit/Jobs/GenerateImageVersionJobTest.php b/tests/Unit/Jobs/GenerateImageVersionJobTest.php
new file mode 100644
index 0000000..22e6513
--- /dev/null
+++ b/tests/Unit/Jobs/GenerateImageVersionJobTest.php
@@ -0,0 +1,260 @@
+create();
+
+ $file = UploadedFile::fake()->image('example-image.jpg', 10, 10);
+
+ $sourceImage = SourceImage::upload($file);
+
+ $image = Image::factory()
+ ->forModel($user)
+ ->create([
+ 'source_image_id' => $sourceImage->id,
+ ]);
+
+ $job = new GenerateImageVersionJob($image->id, Breakpoint::Small);
+
+ expect($job->connection)->toBe(Config::string('image-library.queue.connection'));
+});
+
+it('is dispatched on the correction queue', function () {
+ $user = User::factory()
+ ->create();
+
+ $file = UploadedFile::fake()->image('example-image.jpg', 10, 10);
+
+ $sourceImage = SourceImage::upload($file);
+
+ $image = Image::factory()
+ ->forModel($user)
+ ->create([
+ 'source_image_id' => $sourceImage->id,
+ ]);
+
+ $job = new GenerateImageVersionJob($image->id, Breakpoint::Small);
+
+ expect($job->queue)->toBe(Config::string('image-library.queue.queue'));
+});
+
+it('generates an image per breakpoint', function () {
+ $user = User::factory()
+ ->create();
+
+ $file = UploadedFile::fake()->image('example-image.jpg', 1000, 1000);
+
+ $sourceImage = SourceImage::upload($file);
+
+ $image = Image::factory()
+ ->forModel($user)
+ ->create([
+ 'source_image_id' => $sourceImage->id,
+ ]);
+
+ $breakpoint = Breakpoint::Small;
+
+ $job = new GenerateImageVersionJob($image->id, $breakpoint);
+
+ $job->handle();
+
+ Storage::disk($image->disk)
+ ->assertExists($image->getRelativePathForBreakpoint($breakpoint));
+});
+
+it('can generate an image if the x and y crop coordinates are null', function () {
+ $user = User::factory()
+ ->create();
+
+ $file = UploadedFile::fake()->image('example-image.jpg', 1000, 1000);
+
+ $sourceImage = SourceImage::upload($file);
+
+ $image = Image::factory()
+ ->forModel($user)
+ ->create([
+ 'source_image_id' => $sourceImage->id,
+ 'crop_data' => [
+ Breakpoint::Small->value => [
+ 'width' => 500,
+ 'height' => 500,
+ 'x' => null,
+ 'y' => null,
+ ],
+ ],
+ ]);
+
+ $breakpoint = Breakpoint::Small;
+
+ $job = new GenerateImageVersionJob($image->id, $breakpoint);
+
+ $job->handle();
+
+ Storage::disk($image->disk)
+ ->assertExists($image->getRelativePathForBreakpoint($breakpoint));
+});
+
+it('can generate an image if the x and y crop coordinates are set', function () {
+ $user = User::factory()
+ ->create();
+
+ $file = UploadedFile::fake()->image('example-image.jpg', 1000, 1000);
+
+ $sourceImage = SourceImage::upload($file);
+
+ $image = Image::factory()
+ ->forModel($user)
+ ->create([
+ 'source_image_id' => $sourceImage->id,
+ 'crop_data' => [
+ Breakpoint::Small->value => [
+ 'width' => 500,
+ 'height' => 500,
+ 'x' => 100,
+ 'y' => 100,
+ ],
+ ],
+ ]);
+
+ $breakpoint = Breakpoint::Small;
+
+ $job = new GenerateImageVersionJob($image->id, $breakpoint);
+
+ $job->handle();
+
+ Storage::disk($image->disk)
+ ->assertExists($image->getRelativePathForBreakpoint($breakpoint));
+});
+
+it('can apply blur', function () {
+ $user = User::factory()
+ ->create();
+
+ ImageLibrary::registerImageContext(
+ ImageContext::make('blur-test-context')
+ ->aspectRatio(AspectRatio::make(1, 1))
+ ->blur(10)
+ );
+
+ $file = UploadedFile::fake()->image('example-image.jpg', 1000, 1000);
+
+ $sourceImage = SourceImage::upload($file);
+
+ $image = Image::factory()
+ ->forModel($user)
+ ->create([
+ 'source_image_id' => $sourceImage->id,
+ 'context' => 'blur-test-context',
+ ]);
+
+ $breakpoint = Breakpoint::Small;
+
+ $job = new GenerateImageVersionJob($image->id, $breakpoint);
+
+ $job->handle();
+
+ Storage::disk($image->disk)
+ ->assertExists($image->getRelativePathForBreakpoint($breakpoint));
+});
+
+it('can apply greyscale', function () {
+ $user = User::factory()
+ ->create();
+
+ ImageLibrary::registerImageContext(
+ ImageContext::make('greyscale-test-context')
+ ->aspectRatio(AspectRatio::make(1, 1))
+ ->greyscale(true)
+ );
+
+ $file = UploadedFile::fake()->image('example-image.jpg', 1000, 1000);
+
+ $sourceImage = SourceImage::upload($file);
+
+ $image = Image::factory()
+ ->forModel($user)
+ ->create([
+ 'source_image_id' => $sourceImage->id,
+ 'context' => 'greyscale-test-context',
+ ]);
+
+ $breakpoint = Breakpoint::Small;
+
+ $job = new GenerateImageVersionJob($image->id, $breakpoint);
+
+ $job->handle();
+
+ Storage::disk($image->disk)
+ ->assertExists($image->getRelativePathForBreakpoint($breakpoint));
+});
+
+it('can apply sepia', function () {
+ $user = User::factory()
+ ->create();
+
+ ImageLibrary::registerImageContext(
+ ImageContext::make('sepia-test-context')
+ ->aspectRatio(AspectRatio::make(1, 1))
+ ->sepia(true)
+ );
+
+ $file = UploadedFile::fake()->image('example-image.jpg', 1000, 1000);
+
+ $sourceImage = SourceImage::upload($file);
+
+ $image = Image::factory()
+ ->forModel($user)
+ ->create([
+ 'source_image_id' => $sourceImage->id,
+ 'context' => 'sepia-test-context',
+ ]);
+
+ $breakpoint = Breakpoint::Small;
+
+ $job = new GenerateImageVersionJob($image->id, $breakpoint);
+
+ $job->handle();
+
+ Storage::disk($image->disk)
+ ->assertExists($image->getRelativePathForBreakpoint($breakpoint));
+});
+
+it('applies default cropping if no crop_data is set', function () {
+ $user = User::factory()
+ ->create();
+
+ $file = UploadedFile::fake()->image('example-image.jpg', 1000, 1000);
+
+ $sourceImage = SourceImage::upload($file);
+
+ $image = Image::factory()
+ ->forModel($user)
+ ->create([
+ 'source_image_id' => $sourceImage->id,
+ 'context' => 'context-single',
+ 'crop_data' => [],
+ ]);
+
+ $breakpoint = Breakpoint::Small;
+
+ $job = new GenerateImageVersionJob($image->id, $breakpoint);
+
+ $job->handle();
+
+ Storage::disk($image->disk)
+ ->assertExists($image->getRelativePathForBreakpoint($breakpoint));
+});
diff --git a/tests/Unit/Jobs/GenerateResponsiveImageVersionsJobTest.php b/tests/Unit/Jobs/GenerateResponsiveImageVersionsJobTest.php
new file mode 100644
index 0000000..d2119fc
--- /dev/null
+++ b/tests/Unit/Jobs/GenerateResponsiveImageVersionsJobTest.php
@@ -0,0 +1,82 @@
+create();
+
+ $file = UploadedFile::fake()->image('example-image.jpg', 10, 10);
+
+ $sourceImage = SourceImage::upload($file);
+
+ $image = Image::factory()
+ ->forModel($user)
+ ->create([
+ 'source_image_id' => $sourceImage->id,
+ ]);
+
+ new GenerateImageVersionJob($image->id, Breakpoint::Small)->handle();
+
+ $job = new GenerateResponsiveImageVersionsJob($image->id, Breakpoint::Small);
+
+ expect($job->connection)->toBe(Config::string('image-library.queue.connection'));
+});
+
+it('is dispatched on the correction queue', function () {
+ $user = User::factory()
+ ->create();
+
+ $file = UploadedFile::fake()->image('example-image.jpg', 10, 10);
+
+ $sourceImage = SourceImage::upload($file);
+
+ $image = Image::factory()
+ ->forModel($user)
+ ->create([
+ 'source_image_id' => $sourceImage->id,
+ ]);
+
+ new GenerateImageVersionJob($image->id, Breakpoint::Small)->handle();
+
+ $job = new GenerateResponsiveImageVersionsJob($image->id, Breakpoint::Small);
+
+ expect($job->queue)->toBe(Config::string('image-library.queue.queue'));
+});
+
+it('can generate multiple responsive image versions', function () {
+ $user = User::factory()
+ ->create();
+
+ $file = UploadedFile::fake()->image('example-image.jpg', 1920, 1080);
+
+ $sourceImage = SourceImage::upload($file);
+
+ $image = Image::factory()
+ ->forModel($user)
+ ->create([
+ 'source_image_id' => $sourceImage->id,
+ 'context' => 'context-single',
+ ]);
+
+ new GenerateImageVersionJob($image->id, Breakpoint::DoubleExtraLarge)->handle();
+
+ new GenerateResponsiveImageVersionsJob($image->id, Breakpoint::DoubleExtraLarge)
+ ->handle();
+
+ expect($image->getResponsiveRelativePathsForBreakpoint(Breakpoint::DoubleExtraLarge))
+ ->toBeInstanceOf(Collection::class);
+
+ expect($image->getResponsiveRelativePathsForBreakpoint(Breakpoint::DoubleExtraLarge)->count())
+ ->toBeGreaterThan(0);
+});
diff --git a/tests/Unit/Models/ImageTest.php b/tests/Unit/Models/ImageTest.php
new file mode 100644
index 0000000..a3bdfee
--- /dev/null
+++ b/tests/Unit/Models/ImageTest.php
@@ -0,0 +1,849 @@
+aspectRatio(
+ AspectRatio::make(1, 1)
+ ),
+ );
+});
+
+describe('mutators and casts', function (): void {
+ it('has a translatable alt_text attribute', function (): void {
+ $user = User::factory()
+ ->create();
+
+ $image = Image::factory()
+ ->forModel($user)
+ ->create([
+ 'alt_text' => [
+ 'en' => 'An example image',
+ 'nl' => 'Een voorbeeldafbeelding',
+ ],
+ ]);
+
+ expect($image->getTranslations('alt_text'))
+ ->toEqual([
+ 'en' => 'An example image',
+ 'nl' => 'Een voorbeeldafbeelding',
+ ]);
+
+ expect($image->alt_text)
+ ->toEqual('An example image');
+
+ app()->setLocale('nl');
+
+ expect($image->alt_text)
+ ->toEqual('Een voorbeeldafbeelding');
+ });
+
+ it('casts the custom_properties attribute to an array', function (): void {
+ $user = User::factory()
+ ->create();
+
+ $image = Image::factory()
+ ->forModel($user)
+ ->create([
+ 'custom_properties' => [
+ 'photographer' => 'John Doe',
+ 'location' => 'New York',
+ ],
+ ]);
+
+ expect($image->custom_properties)
+ ->toEqual([
+ 'photographer' => 'John Doe',
+ 'location' => 'New York',
+ ]);
+ });
+
+ it('always generates crop data for each breakpoint based on the inputted value', function (): void {
+ $user = User::factory()
+ ->create();
+
+ $image = Image::factory()
+ ->forModel($user)
+ ->create([
+ 'context' => 'thumbnail',
+ 'crop_data' => CropData::make(10, 10, 100, 100, 0, 1, 1),
+ ])
+ ->refresh();
+
+ expect($image->crop_data)
+ ->toHaveCount(count(Breakpoint::cases()));
+
+ foreach (Breakpoint::cases() as $breakpoint) {
+ expect($image->crop_data[$breakpoint->value])
+ ->toBeInstanceOf(CropData::class);
+ }
+ });
+
+ test('crop_data returns null for each breakpoint if null is provided', function (): void {
+ $user = User::factory()
+ ->create();
+
+ $sourceImage = SourceImage::factory()
+ ->create();
+
+ DB::table('images')
+ ->insert([
+ 'id' => 1,
+ 'source_image_id' => $sourceImage->id,
+ 'model_type' => User::class,
+ 'model_id' => $user->id,
+ 'disk' => 'public',
+ 'uuid' => Str::uuid(),
+ 'context' => 'thumbnail',
+ 'context_configuration_hash' => ImageLibrary::getImageContextByKey('thumbnail')
+ ?->getConfigurationHash(),
+ 'crop_data' => null,
+ 'sort_order' => 0,
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+
+ $image = Image::query()
+ ->first();
+
+ expect($image->crop_data)
+ ->toHaveCount(count(Breakpoint::cases()));
+
+ foreach (Breakpoint::cases() as $breakpoint) {
+ expect($image->crop_data[$breakpoint->value])
+ ->toBeNull();
+ }
+ });
+
+ test('crop_data returns null on wrongly structured data (not an array)', function (): void {
+ $user = User::factory()
+ ->create();
+
+ $sourceImage = SourceImage::factory()
+ ->create();
+
+ DB::table('images')
+ ->insert([
+ 'id' => 1,
+ 'source_image_id' => $sourceImage->id,
+ 'model_type' => User::class,
+ 'model_id' => $user->id,
+ 'disk' => 'public',
+ 'uuid' => Str::uuid(),
+ 'context' => 'thumbnail',
+ 'context_configuration_hash' => ImageLibrary::getImageContextByKey('thumbnail')
+ ?->getConfigurationHash(),
+ 'crop_data' => '"invalid structure"',
+ 'sort_order' => 0,
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+
+ $image = Image::query()
+ ->first();
+
+ expect($image->crop_data)
+ ->toHaveCount(count(Breakpoint::cases()));
+
+ foreach (Breakpoint::cases() as $breakpoint) {
+ expect($image->crop_data[$breakpoint->value])
+ ->toBeNull();
+ }
+ });
+
+ test('crop_data returns null on wrongly structured data (missing keys)', function (): void {
+ $user = User::factory()
+ ->create();
+
+ $sourceImage = SourceImage::factory()
+ ->create();
+
+ DB::table('images')
+ ->insert([
+ 'id' => 1,
+ 'source_image_id' => $sourceImage->id,
+ 'model_type' => User::class,
+ 'model_id' => $user->id,
+ 'disk' => 'public',
+ 'uuid' => Str::uuid(),
+ 'context' => 'thumbnail',
+ 'context_configuration_hash' => ImageLibrary::getImageContextByKey('thumbnail')
+ ?->getConfigurationHash(),
+ 'crop_data' => json_encode([
+ 'small' => [
+ 'width' => 100,
+ 'height' => 100,
+ // Missing x and y
+ ],
+ ]),
+ 'sort_order' => 0,
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+
+ $image = Image::query()
+ ->first();
+
+ expect($image->crop_data)
+ ->toHaveCount(count(Breakpoint::cases()));
+
+ foreach (Breakpoint::cases() as $breakpoint) {
+ expect($image->crop_data[$breakpoint->value])
+ ->toBeNull();
+ }
+ });
+});
+
+describe('methods', function (): void {
+ describe('getRelativeBasePath', function (): void {
+ it('returns the correct relative base path', function (): void {
+ $user = User::factory()
+ ->create();
+
+ $image = Image::factory()
+ ->forModel($user)
+ ->create();
+
+ expect($image->getRelativeBasePath())
+ ->toBe("image-library/{$image->sourceImage->uuid}/{$image->uuid}");
+ });
+ });
+
+ describe('getAbsoluteBasePath', function (): void {
+ it('returns the correct absolute base path', function (): void {
+ $user = User::factory()
+ ->create();
+
+ $image = Image::factory()
+ ->forModel($user)
+ ->create();
+
+ expect($image->getAbsoluteBasePath())
+ ->toBe(Storage::disk($image->disk)->path("image-library/{$image->sourceImage->uuid}/{$image->uuid}"));
+ });
+ });
+
+ describe('getRelativePathForBreakpoint', function (): void {
+ it('returns the correct relative path for the given breakpoint', function (): void {
+ $user = User::factory()
+ ->create();
+
+ $image = Image::factory()
+ ->forModel($user)
+ ->create();
+
+ expect($image->getRelativePathForBreakpoint(Breakpoint::Small))
+ ->toBe("image-library/{$image->sourceImage->uuid}/{$image->uuid}/sm.{$image->sourceImage->extension}");
+
+ expect($image->getRelativePathForBreakpoint(Breakpoint::Medium, 'png'))
+ ->toBe("image-library/{$image->sourceImage->uuid}/{$image->uuid}/md.png");
+ });
+ });
+
+ describe('getAbsolutePathForBreakpoint', function (): void {
+ it('returns the correct absolute path for the given breakpoint', function (): void {
+ $user = User::factory()
+ ->create();
+
+ $image = Image::factory()
+ ->forModel($user)
+ ->create();
+
+ expect($image->getAbsolutePathForBreakpoint(Breakpoint::Small))
+ ->toBe(Storage::disk($image->disk)->path("image-library/{$image->sourceImage->uuid}/{$image->uuid}/sm.{$image->sourceImage->extension}"));
+
+ expect($image->getAbsolutePathForBreakpoint(Breakpoint::Medium, 'png'))
+ ->toBe(Storage::disk($image->disk)->path("image-library/{$image->sourceImage->uuid}/{$image->uuid}/md.png"));
+ });
+ });
+
+ describe('getForBreakpoint', function (): void {
+ it('returns the image content for the given breakpoint', function (): void {
+ $user = User::factory()
+ ->create();
+
+ $file = UploadedFile::fake()->image('example-image.jpg', 10, 10);
+
+ $sourceImage = SourceImage::upload($file);
+
+ $image = Image::factory()
+ ->forModel($user)
+ ->create([
+ 'source_image_id' => $sourceImage->id,
+ ]);
+
+ $job = new GenerateImageVersionJob($image, Breakpoint::Small);
+ $job->handle();
+
+ $breakpointImage = $image->getForBreakpoint(Breakpoint::Small);
+
+ expect($breakpointImage)
+ ->toBeString()
+ ->not->toBeEmpty();
+ });
+ });
+
+ describe('existsForBreakpoint', function (): void {
+ it('returns whether an image exists for the given breakpoint', function (): void {
+ $user = User::factory()
+ ->create();
+
+ $file = UploadedFile::fake()->image('example-image.jpg', 10, 10);
+
+ $sourceImage = SourceImage::upload($file);
+
+ $image = Image::factory()
+ ->forModel($user)
+ ->create([
+ 'source_image_id' => $sourceImage->id,
+ ]);
+
+ expect($image->existsForBreakpoint(Breakpoint::Small))
+ ->toBeFalse();
+
+ $job = new GenerateImageVersionJob($image, Breakpoint::Small);
+ $job->handle();
+
+ expect($image->existsForBreakpoint(Breakpoint::Small))
+ ->toBeTrue();
+ });
+ });
+
+ describe('missingForBreakpoint', function (): void {
+ it('returns whether an image is missing for the given breakpoint', function (): void {
+ $user = User::factory()
+ ->create();
+
+ $file = UploadedFile::fake()->image('example-image.jpg', 10, 10);
+
+ $sourceImage = SourceImage::upload($file);
+
+ $image = Image::factory()
+ ->forModel($user)
+ ->create([
+ 'source_image_id' => $sourceImage->id,
+ ]);
+
+ expect($image->missingForBreakpoint(Breakpoint::Small))
+ ->toBeTrue();
+
+ $job = new GenerateImageVersionJob($image, Breakpoint::Small);
+ $job->handle();
+
+ expect($image->missingForBreakpoint(Breakpoint::Small))
+ ->toBeFalse();
+ });
+ });
+
+ describe('downloadForBreakpoint', function (): void {
+ it('returns a download response for the given breakpoint', function (): void {
+ $user = User::factory()
+ ->create();
+
+ $file = UploadedFile::fake()->image('example-image.jpg', 10, 10);
+
+ $sourceImage = SourceImage::upload($file);
+
+ $image = Image::factory()
+ ->forModel($user)
+ ->create([
+ 'source_image_id' => $sourceImage->id,
+ ]);
+
+ $job = new GenerateImageVersionJob($image, Breakpoint::Small);
+ $job->handle();
+
+ $response = $image->downloadForBreakpoint(Breakpoint::Small);
+
+ expect($response)
+ ->toBeInstanceOf(StreamedResponse::class);
+ });
+ });
+
+ describe('urlForBreakpoint', function (): void {
+ it('returns a URL for the given breakpoint', function (): void {
+ $user = User::factory()
+ ->create();
+
+ $file = UploadedFile::fake()->image('example-image.jpg', 10, 10);
+
+ $sourceImage = SourceImage::upload($file);
+
+ $image = Image::factory()
+ ->forModel($user)
+ ->create([
+ 'source_image_id' => $sourceImage->id,
+ ]);
+
+ GenerateImageVersionJob::dispatchSync($image, Breakpoint::Small);
+
+ $url = $image->urlForBreakpoint(Breakpoint::Small);
+
+ expect($url)
+ ->toBeString()
+ ->not->toBeEmpty();
+ });
+
+ it('can return a temporary URL if configured to do so', function (): void {
+ $user = User::factory()
+ ->create();
+
+ $file = UploadedFile::fake()->image('example-image.jpg', 10, 10);
+
+ $sourceImage = SourceImage::upload($file);
+
+ $image = Image::factory()
+ ->forModel($user)
+ ->create([
+ 'source_image_id' => $sourceImage->id,
+ ]);
+
+ GenerateImageVersionJob::dispatchSync($image, Breakpoint::Small);
+
+ ImageLibrary::partialMock();
+
+ ImageLibrary::shouldReceive('shouldUseTemporaryUrlsForDisk')
+ ->with($image->disk)
+ ->andReturn(true);
+
+ $url = $image->urlForBreakpoint(Breakpoint::Small);
+
+ expect($url)
+ ->toBeString()
+ ->not->toBeEmpty();
+ });
+ });
+
+ describe('urlForRelativePath', function (): void {
+ it('returns a URL for the given relative path', function (): void {
+ $user = User::factory()
+ ->create();
+
+ $file = UploadedFile::fake()->image('example-image.jpg', 10, 10);
+
+ $sourceImage = SourceImage::upload($file);
+
+ $image = Image::factory()
+ ->forModel($user)
+ ->create([
+ 'source_image_id' => $sourceImage->id,
+ ]);
+
+ $relativePath = "image-library/{$image->sourceImage->uuid}/{$image->uuid}/test-file.jpg";
+
+ $url = $image->urlForRelativePath($relativePath);
+
+ expect($url)
+ ->toBeString()
+ ->not->toBeEmpty()
+ ->toContain($relativePath);
+ });
+
+ it('returns a temporary URL if configured to use temporary URLs for the disk', function (): void {
+ $user = User::factory()
+ ->create();
+
+ $file = UploadedFile::fake()->image('example-image.jpg', 10, 10);
+
+ $sourceImage = SourceImage::upload($file);
+
+ $image = Image::factory()
+ ->forModel($user)
+ ->create([
+ 'source_image_id' => $sourceImage->id,
+ ]);
+
+ ImageLibrary::partialMock();
+
+ ImageLibrary::shouldReceive('shouldUseTemporaryUrlsForDisk')
+ ->with($image->disk)
+ ->andReturn(true);
+
+ ImageLibrary::shouldReceive('getTemporaryUrlExpirationMinutesForDisk')
+ ->with($image->disk)
+ ->andReturn(60);
+
+ $relativePath = "image-library/{$image->sourceImage->uuid}/{$image->uuid}/test-file.jpg";
+
+ $url = $image->urlForRelativePath($relativePath);
+
+ expect($url)
+ ->toBeString()
+ ->not->toBeEmpty();
+ });
+ });
+
+ describe('temporaryUrlForRelativePath', function (): void {
+ it('returns a temporary URL for the given relative path', function (): void {
+ $user = User::factory()
+ ->create();
+
+ $file = UploadedFile::fake()->image('example-image.jpg', 10, 10);
+
+ $sourceImage = SourceImage::upload($file);
+
+ $image = Image::factory()
+ ->forModel($user)
+ ->create([
+ 'source_image_id' => $sourceImage->id,
+ ]);
+
+ ImageLibrary::partialMock();
+
+ ImageLibrary::shouldReceive('getTemporaryUrlExpirationMinutesForDisk')
+ ->with($image->disk)
+ ->andReturn(60);
+
+ $relativePath = "image-library/{$image->sourceImage->uuid}/{$image->uuid}/test-file.jpg";
+
+ $url = $image->temporaryUrlForRelativePath($relativePath);
+
+ expect($url)
+ ->toBeString()
+ ->not->toBeEmpty();
+ });
+
+ it('accepts custom expiration time', function (): void {
+ $user = User::factory()
+ ->create();
+
+ $file = UploadedFile::fake()->image('example-image.jpg', 10, 10);
+
+ $sourceImage = SourceImage::upload($file);
+
+ $image = Image::factory()
+ ->forModel($user)
+ ->create([
+ 'source_image_id' => $sourceImage->id,
+ ]);
+
+ $relativePath = "image-library/{$image->sourceImage->uuid}/{$image->uuid}/test-file.jpg";
+ $customExpiration = now()->addHours(2);
+
+ $url = $image->temporaryUrlForRelativePath($relativePath, $customExpiration);
+
+ expect($url)
+ ->toBeString()
+ ->not->toBeEmpty();
+ });
+
+ it('accepts custom options for temporary URL generation', function (): void {
+ $user = User::factory()
+ ->create();
+
+ $file = UploadedFile::fake()->image('example-image.jpg', 10, 10);
+
+ $sourceImage = SourceImage::upload($file);
+
+ $image = Image::factory()
+ ->forModel($user)
+ ->create([
+ 'source_image_id' => $sourceImage->id,
+ ]);
+
+ ImageLibrary::partialMock();
+
+ ImageLibrary::shouldReceive('getTemporaryUrlExpirationMinutesForDisk')
+ ->with($image->disk)
+ ->andReturn(60);
+
+ $relativePath = "image-library/{$image->sourceImage->uuid}/{$image->uuid}/test-file.jpg";
+ $options = ['ResponseContentType' => 'image/jpeg'];
+
+ $url = $image->temporaryUrlForRelativePath($relativePath, null, $options);
+
+ expect($url)
+ ->toBeString()
+ ->not->toBeEmpty();
+ });
+ });
+
+ describe('getResponsiveRelativePathsForBreakpoint', function (): void {
+ it('returns multiple responsive relative paths for the given breakpoint', function (): void {
+ $user = User::factory()
+ ->create();
+
+ $file = UploadedFile::fake()->image('example-image.jpg', 1200, 800);
+
+ $sourceImage = SourceImage::upload($file);
+
+ $image = Image::factory()
+ ->forModel($user)
+ ->create([
+ 'source_image_id' => $sourceImage->id,
+ ]);
+
+ new GenerateImageVersionJob($image->id, Breakpoint::Large)->handle();
+
+ new GenerateResponsiveImageVersionsJob($image->id, Breakpoint::Large)
+ ->handle();
+
+ expect($image->getResponsiveRelativePathsForBreakpoint(Breakpoint::Large))
+ ->toBeInstanceOf(Collection::class);
+
+ expect($image->getResponsiveRelativePathsForBreakpoint(Breakpoint::Large)->count())
+ ->toBeGreaterThan(0);
+ });
+ });
+
+ describe('getResponsiveAbsolutePathsForBreakpoint', function (): void {
+ it('returns multiple responsive absolute paths for the given breakpoint', function (): void {
+ $user = User::factory()
+ ->create();
+
+ $file = UploadedFile::fake()->image('example-image.jpg', 1200, 800);
+
+ $sourceImage = SourceImage::upload($file);
+
+ $image = Image::factory()
+ ->forModel($user)
+ ->create([
+ 'source_image_id' => $sourceImage->id,
+ ]);
+
+ new GenerateImageVersionJob($image->id, Breakpoint::Large)->handle();
+
+ new GenerateResponsiveImageVersionsJob($image->id, Breakpoint::Large)
+ ->handle();
+
+ expect($image->getResponsiveAbsolutePathsForBreakpoint(Breakpoint::Large))
+ ->toBeInstanceOf(Collection::class);
+
+ expect($image->getResponsiveAbsolutePathsForBreakpoint(Breakpoint::Large)->count())
+ ->toBeGreaterThan(0);
+ });
+ });
+});
+
+describe('observers & events', function (): void {
+ it('generates a UUID on saving if not set', function (): void {
+ $user = User::factory()
+ ->create();
+
+ $image = Image::factory()
+ ->forModel($user)
+ ->create([
+ 'uuid' => null,
+ ]);
+
+ expect($image->uuid)
+ ->toBeString()
+ ->not->toBeEmpty();
+ });
+
+ it('generates the context_configuration_hash on saving, even if set', function (): void {
+ $user = User::factory()
+ ->create();
+
+ $image = Image::factory()
+ ->forModel($user)
+ ->create([
+ 'context' => 'thumbnail',
+ 'context_configuration_hash' => null,
+ ]);
+
+ $expectedHash = ImageLibrary::getImageContextByKey('thumbnail')
+ ?->getConfigurationHash();
+
+ expect($image->context_configuration_hash)
+ ->toBe($expectedHash);
+
+ ImageLibrary::registerImageContext(
+ ImageContext::make('thumbnail2')
+ ->aspectRatio(
+ AspectRatio::make(4, 3)
+ ),
+ );
+
+ $image->context = 'thumbnail2';
+ $image->save();
+
+ $expectedHash = ImageLibrary::getImageContextByKey('thumbnail2')
+ ?->getConfigurationHash();
+
+ expect($image->context_configuration_hash)
+ ->toBe($expectedHash);
+ });
+
+ it('dispatches multiple jobs via a bus chain after creating an image', function (): void {
+ Bus::fake();
+
+ $user = User::factory()
+ ->create();
+
+ Image::factory()
+ ->forModel($user)
+ ->create();
+
+ Bus::assertChained([
+ Bus::chainedBatch(function (PendingBatch $batch) {
+ return $batch->jobs->count() === count(Breakpoint::cases())
+ && $batch->jobs->every(fn ($job) => $job instanceof GenerateImageVersionJob);
+ }),
+ Bus::chainedBatch(function (PendingBatch $batch) {
+ return $batch->jobs->count() === count(Breakpoint::cases())
+ && $batch->jobs->every(fn ($job) => $job instanceof GenerateResponsiveImageVersionsJob);
+ }),
+ ]);
+ });
+});
+
+describe('relations', function (): void {
+ it('morphs to a model', function (): void {
+ $user = User::factory()
+ ->create();
+
+ $image = Image::factory()
+ ->forModel($user)
+ ->create();
+
+ expect($image->model())
+ ->toBeInstanceOf(MorphTo::class);
+
+ expect($image->model)
+ ->toBeInstanceOf(User::class);
+ });
+
+ it('belongs to a source image', function (): void {
+ $user = User::factory()
+ ->create();
+
+ $image = Image::factory()
+ ->forModel($user)
+ ->create();
+
+ expect($image->sourceImage())
+ ->toBeInstanceOf(BelongsTo::class);
+
+ expect($image->sourceImage)
+ ->toBeInstanceOf(SourceImage::class);
+ });
+});
+
+describe('scopes', function (): void {});
+
+describe('optional breakpoints', function (): void {
+ it('can create an image with a context that does not use breakpoints', function () {
+ $sourceImage = SourceImage::factory()->create();
+ $user = User::factory()->create();
+
+ $context = ImageContext::make('email')
+ ->useBreakpoints(false);
+
+ ImageLibrary::registerImageContext($context);
+
+ $image = Image::create([
+ 'source_image_id' => $sourceImage->id,
+ 'model_type' => $user->getMorphClass(),
+ 'model_id' => $user->id,
+ 'context' => $context,
+ 'disk' => 'public',
+ ]);
+
+ expect($image->context->getUseBreakpoints())->toBeFalse();
+ });
+
+ it('dispatches GenerateImageVersionJob with null breakpoint when context does not use breakpoints', function () {
+ Queue::fake();
+ Bus::fake();
+
+ $sourceImage = SourceImage::factory()->create();
+ $user = User::factory()->create();
+
+ $context = ImageContext::make('email')
+ ->useBreakpoints(false);
+
+ ImageLibrary::registerImageContext($context);
+
+ $image = Image::create([
+ 'source_image_id' => $sourceImage->id,
+ 'model_type' => $user->getMorphClass(),
+ 'model_id' => $user->id,
+ 'context' => $context,
+ 'disk' => 'public',
+ ]);
+
+ Bus::assertBatched(function ($batch) use ($image) {
+ return $batch->jobs->contains(function ($job) use ($image) {
+ return $job instanceof GenerateImageVersionJob
+ && $job->imageId === $image->id
+ && is_null($job->breakpoint);
+ });
+ });
+ });
+
+ it('dispatches GenerateImageVersionJob with null breakpoint when global config disables breakpoints', function () {
+ Queue::fake();
+ Bus::fake();
+ Config::set('image-library.use_breakpoints', false);
+
+ $sourceImage = SourceImage::factory()->create();
+ $user = User::factory()->create();
+
+ // Create a context that inherits global config (no explicit useBreakpoints call)
+ $context = ImageContext::make('global-disabled');
+ ImageLibrary::registerImageContext($context);
+
+ $image = Image::create([
+ 'source_image_id' => $sourceImage->id,
+ 'model_type' => $user->getMorphClass(),
+ 'model_id' => $user->id,
+ 'context' => $context,
+ 'disk' => 'public',
+ ]);
+
+ Bus::assertBatched(function ($batch) use ($image) {
+ return $batch->jobs->contains(function ($job) use ($image) {
+ return $job instanceof GenerateImageVersionJob
+ && $job->imageId === $image->id
+ && is_null($job->breakpoint);
+ });
+ });
+ });
+
+ it('can get image paths without specifying breakpoint when breakpoints are disabled', function () {
+ $sourceImage = SourceImage::factory()->create();
+ $user = User::factory()->create();
+
+ $context = ImageContext::make('email')
+ ->useBreakpoints(false);
+
+ ImageLibrary::registerImageContext($context);
+
+ $image = Image::create([
+ 'source_image_id' => $sourceImage->id,
+ 'model_type' => $user->getMorphClass(),
+ 'model_id' => $user->id,
+ 'context' => $context,
+ 'disk' => 'public',
+ ]);
+
+ expect($image->getRelativePathForBreakpoint())->toBe($image->getRelativeBasePath().'/default.'.$sourceImage->extension);
+ expect($image->getRelativePathForBreakpoint(null, 'webp'))->toBe($image->getRelativeBasePath().'/default.webp');
+ });
+});
diff --git a/tests/Unit/Models/SourceImageTest.php b/tests/Unit/Models/SourceImageTest.php
new file mode 100644
index 0000000..5febb0b
--- /dev/null
+++ b/tests/Unit/Models/SourceImageTest.php
@@ -0,0 +1,361 @@
+aspectRatio(
+ AspectRatio::make(1, 1)
+ ),
+ );
+});
+
+describe('mutators and casts', function (): void {
+ it('has a translatable alt_text attribute', function (): void {
+ $image = SourceImage::factory()
+ ->create([
+ 'alt_text' => [
+ 'en' => 'An example image',
+ 'nl' => 'Een voorbeeldafbeelding',
+ ],
+ ]);
+
+ expect($image->getTranslations('alt_text'))
+ ->toEqual([
+ 'en' => 'An example image',
+ 'nl' => 'Een voorbeeldafbeelding',
+ ]);
+
+ expect($image->alt_text)
+ ->toEqual('An example image');
+
+ app()->setLocale('nl');
+
+ expect($image->alt_text)
+ ->toEqual('Een voorbeeldafbeelding');
+ });
+
+ it('casts the custom_properties attribute to an array', function (): void {
+ $image = SourceImage::factory()
+ ->create([
+ 'custom_properties' => [
+ 'photographer' => 'John Doe',
+ 'location' => 'New York',
+ ],
+ ]);
+
+ expect($image->custom_properties)
+ ->toEqual([
+ 'photographer' => 'John Doe',
+ 'location' => 'New York',
+ ]);
+ });
+
+ it('has a name_with_extension attribute', function (): void {
+ $image = SourceImage::factory()
+ ->create([
+ 'name' => 'example-image',
+ 'extension' => 'jpg',
+ ]);
+
+ expect($image->name_with_extension)
+ ->toEqual('example-image.jpg');
+ });
+});
+
+describe('methods', function (): void {
+ it('can return the relative base path', function (): void {
+ $image = SourceImage::factory()
+ ->create();
+
+ expect($image->getRelativeBasePath())
+ ->toEqual('image-library/'.$image->uuid);
+ });
+
+ it('can return the absolute base path', function (): void {
+ $image = SourceImage::factory()
+ ->create();
+
+ expect($image->getAbsoluteBasePath())
+ ->toEqual(Storage::disk($image->disk)->path('image-library/'.$image->uuid));
+ });
+
+ it('can return the relative path', function (): void {
+ $image = SourceImage::factory()
+ ->create([
+ 'name' => 'example-image',
+ 'extension' => 'png',
+ ]);
+
+ expect($image->getRelativePath())
+ ->toEqual('image-library/'.$image->uuid.'/original.png');
+ });
+
+ it('can return the absolute path', function (): void {
+ $image = SourceImage::factory()
+ ->create([
+ 'name' => 'example-image',
+ 'extension' => 'png',
+ ]);
+
+ expect($image->getAbsolutePath())
+ ->toEqual(Storage::disk($image->disk)->path('image-library/'.$image->uuid.'/original.png'));
+ });
+
+ describe('upload', function (): void {
+ it('can upload a file as a source image', function (): void {
+ $file = UploadedFile::fake()->image('example-image.jpg', 10, 10);
+
+ $sourceImage = SourceImage::upload($file);
+
+ expect($sourceImage)
+ ->toBeInstanceOf(SourceImage::class)
+ ->and($sourceImage->disk)
+ ->toEqual('public')
+ ->and($sourceImage->name)
+ ->toEqual('example-image')
+ ->and($sourceImage->extension)
+ ->toEqual('jpg')
+ ->and($sourceImage->mime_type)
+ ->toEqual('image/jpeg')
+ ->and($sourceImage->width)
+ ->toEqual(10)
+ ->and($sourceImage->height)
+ ->toEqual(10);
+
+ Storage::disk($sourceImage->disk)
+ ->assertExists($sourceImage->getRelativePath());
+ });
+
+ it('cleans up if it fails to upload a file', function (): void {
+ /** @var TestCase $this */
+ $this->expectException(Throwable::class);
+
+ // Create a mock file that will throw an exception when accessed
+ $file = Mockery::mock(UploadedFile::class);
+ $file->shouldReceive('getClientOriginalName')->andReturn('corrupt-image.jpg');
+ $file->shouldReceive('getClientOriginalExtension')->andReturn('jpg');
+ $file->shouldReceive('getClientMimeType')->andReturn('image/jpeg');
+ $file->shouldReceive('getRealPath')->andThrow(new RuntimeException('Failed to read file'));
+
+ try {
+ SourceImage::upload($file);
+ } catch (Throwable $e) {
+ expect(Storage::disk('public')->allFiles('image-library'))
+ ->toBeEmpty();
+
+ throw $e;
+ }
+ });
+
+ it('cleans up the model if it fails to upload a file', function (): void {
+ /** @var TestCase $this */
+ $this->expectException(Throwable::class);
+
+ $file = UploadedFile::fake()->image('corrupt-image.jpg', 10, 10);
+
+ // Create a mock storage disk that will throw an exception when making a directory
+ Storage::shouldReceive('makeDirectory')->andThrow(new RuntimeException('Failed to create directory'));
+
+ try {
+ SourceImage::upload($file);
+ } catch (Throwable $e) {
+ expect(Storage::disk('public')->allFiles('image-library'))
+ ->toBeEmpty();
+
+ throw $e;
+ }
+ });
+ });
+
+ describe('get', function (): void {
+ it('can get the content of the source image file', function (): void {
+ $file = UploadedFile::fake()->image('example-image.jpg', 10, 10);
+
+ $sourceImage = SourceImage::upload($file);
+
+ $content = $sourceImage->get();
+
+ expect($content)
+ ->toBeString()
+ ->not->toBeEmpty();
+ });
+ });
+
+ describe('exists', function (): void {
+ it('can check if the source image file exists', function (): void {
+ $file = UploadedFile::fake()->image('example-image.jpg', 10, 10);
+
+ $sourceImage = SourceImage::upload($file);
+
+ expect($sourceImage->exists())
+ ->toBeTrue();
+
+ // Delete the file
+ Storage::disk($sourceImage->disk)->delete($sourceImage->getRelativePath());
+
+ expect($sourceImage->exists())
+ ->toBeFalse();
+ });
+ });
+
+ describe('missing', function (): void {
+ it('can check if the source image file is missing', function (): void {
+ $file = UploadedFile::fake()->image('example-image.jpg', 10, 10);
+
+ $sourceImage = SourceImage::upload($file);
+
+ expect($sourceImage->missing())
+ ->toBeFalse();
+
+ // Delete the file
+ Storage::disk($sourceImage->disk)->delete($sourceImage->getRelativePath());
+
+ expect($sourceImage->missing())
+ ->toBeTrue();
+ });
+ });
+
+ describe('download', function (): void {
+ it('can download the source image file', function (): void {
+ $file = UploadedFile::fake()->image('example-image.jpg', 10, 10);
+
+ $sourceImage = SourceImage::upload($file);
+
+ $content = $sourceImage->download();
+
+ expect($content)
+ ->toBeInstanceOf(StreamedResponse::class);
+ });
+ });
+
+ describe('url', function (): void {
+ it('can return the URL of the source image file', function (): void {
+ $file = UploadedFile::fake()->image('example-image.jpg', 10, 10);
+
+ $sourceImage = SourceImage::upload($file);
+
+ $url = $sourceImage->url();
+
+ expect($url)
+ ->toBeString()
+ ->not->toBeEmpty();
+ });
+
+ it('can return a temporary URL if configured to do so', function (): void {
+ $file = UploadedFile::fake()->image('example-image.jpg', 10, 10);
+
+ $sourceImage = SourceImage::upload($file);
+
+ ImageLibrary::partialMock();
+
+ ImageLibrary::shouldReceive('shouldUseTemporaryUrlsForDisk')
+ ->with($sourceImage->disk)
+ ->andReturn(true);
+
+ $url = $sourceImage->url();
+
+ expect($url)
+ ->toBeString()
+ ->not->toBeEmpty();
+ });
+ });
+
+ describe('temporaryUrl', function (): void {
+ it('can return a temporary URL of the source image file', function (): void {
+ $file = UploadedFile::fake()->image('example-image.jpg', 10, 10);
+
+ $sourceImage = SourceImage::upload($file);
+
+ $temporaryUrl = $sourceImage->temporaryUrl(now()->addMinutes(30));
+
+ expect($temporaryUrl)
+ ->toBeString()
+ ->not->toBeEmpty();
+ });
+
+ it('uses the default expiration time if none is provided', function (): void {
+ $file = UploadedFile::fake()->image('example-image.jpg', 10, 10);
+
+ $sourceImage = SourceImage::upload($file);
+
+ ImageLibrary::partialMock();
+
+ ImageLibrary::shouldReceive('getTemporaryUrlExpirationMinutesForDisk')
+ ->with($sourceImage->disk)
+ ->andReturn(15);
+
+ $temporaryUrl = $sourceImage->temporaryUrl();
+
+ expect($temporaryUrl)
+ ->toBeString()
+ ->not->toBeEmpty();
+ });
+ });
+});
+
+describe('observers & events', function (): void {
+ it('generates a UUID on saving if not set', function (): void {
+ $image = SourceImage::factory()
+ ->create([
+ 'uuid' => null,
+ ]);
+
+ expect($image->uuid)
+ ->toBeString()
+ ->not->toBeEmpty();
+ });
+
+ it('deletes all files on deletion', function (): void {
+ $file = UploadedFile::fake()->image('example-image.jpg', 10, 10);
+
+ $sourceImage = SourceImage::upload($file);
+
+ expect(Storage::disk($sourceImage->disk)->exists($sourceImage->getRelativeBasePath()))
+ ->toBeTrue();
+
+ $sourceImage->delete();
+
+ expect(Storage::disk($sourceImage->disk)->exists($sourceImage->getRelativeBasePath()))
+ ->toBeFalse();
+ });
+});
+
+describe('relations', function (): void {
+ it('has many images', function (): void {
+ $sourceImage = SourceImage::factory()
+ ->create();
+
+ $user = User::factory()
+ ->create();
+
+ Image::factory()
+ ->count(3)
+ ->forModel($user)
+ ->create([
+ 'source_image_id' => $sourceImage->id,
+ ]);
+
+ expect($sourceImage->images())
+ ->toBeInstanceOf(HasMany::class);
+
+ expect($sourceImage->images)
+ ->toHaveCount(3)
+ ->each->toBeInstanceOf(Image::class);
+ });
+});
+
+describe('scopes', function (): void {});
diff --git a/tests/Unit/Traits/HasImagesTest.php b/tests/Unit/Traits/HasImagesTest.php
new file mode 100644
index 0000000..3cbb9fb
--- /dev/null
+++ b/tests/Unit/Traits/HasImagesTest.php
@@ -0,0 +1,259 @@
+create();
+
+ expect($user->images())
+ ->toBeInstanceOf(MorphMany::class);
+
+ Image::factory()
+ ->forModel($user)
+ ->create();
+
+ expect($user->images)
+ ->toHaveCount(1)
+ ->each->toBeInstanceOf(Image::class);
+});
+
+test('a model using the trait must include a context when attaching an image', function () {
+ $user = User::factory()
+ ->create();
+
+ $sourceImage = SourceImage::factory()
+ ->create();
+
+ $user->attachImage($sourceImage);
+})->throws(InvalidArgumentException::class);
+
+test('a model using the trait has the attachImage method', function () {
+ $user = User::factory()
+ ->create();
+
+ $sourceImage = SourceImage::factory()
+ ->create();
+
+ $context = ImageLibrary::getImageContextByKey('context-single');
+
+ $image = $user->attachImage($sourceImage, ['context' => $context]);
+
+ expect($image)
+ ->toBeInstanceOf(Image::class)
+ ->and($image->model_type)->toBe($user->getMorphClass())
+ ->and($image->model_id)->toBe($user->getKey())
+ ->and($image->source_image_id)->toBe($sourceImage->id)
+ ->and($image->disk)->toBe($sourceImage->disk)
+ ->and($image->context->getKey())->toEqual($context->getKey());
+
+ expect($user->images)
+ ->toHaveCount(1)
+ ->first()->id->toBe($image->id);
+});
+
+test('a model using the trait can use an ImageContext when attaching an image', function () {
+ $user = User::factory()
+ ->create();
+
+ $sourceImage = SourceImage::factory()
+ ->create();
+
+ $context = ImageLibrary::getImageContextByKey('context-single');
+
+ $image = $user->attachImage($sourceImage, ['context' => $context]);
+
+ expect($image)
+ ->toBeInstanceOf(Image::class)
+ ->and($image->context->getKey())->toEqual($context->getKey());
+});
+
+test('a model using the trait can use an ImageContext key when attaching an image', function () {
+ $user = User::factory()
+ ->create();
+
+ $sourceImage = SourceImage::factory()
+ ->create();
+
+ $contextKey = 'context-single';
+
+ $image = $user->attachImage($sourceImage, ['context' => $contextKey]);
+
+ expect($image)
+ ->toBeInstanceOf(Image::class)
+ ->and($image->context->getKey())->toBe($contextKey);
+});
+
+test('a model using the trait can attach an image to a custom MorphOne relation', function () {
+ $user = User::factory()
+ ->create();
+
+ $sourceImage1 = SourceImage::factory()
+ ->create();
+
+ $sourceImage2 = SourceImage::factory()
+ ->create();
+
+ $context = ImageLibrary::getImageContextByKey('context-single');
+
+ $profilePicture1 = $user->attachImage($sourceImage1, ['context' => $context], 'profilePicture');
+
+ expect($profilePicture1)
+ ->toBeInstanceOf(Image::class);
+
+ expect($user->profilePicture)
+ ->id->toBe($profilePicture1->id);
+
+ $profilePicture2 = $user->attachImage($sourceImage2, ['context' => $context], 'profilePicture');
+
+ expect($profilePicture2)
+ ->toBeInstanceOf(Image::class)
+ ->and($profilePicture2->id)->not->toBe($profilePicture1->id);
+
+ expect($user->profilePicture)
+ ->id->toBe($profilePicture2->id);
+
+ expect(Image::where('model_type', $user->getMorphClass())
+ ->where('model_id', $user->getKey())
+ ->count())->toBe(1);
+});
+
+test('a model using the trait can attach an image to a custom MorphMany relation', function () {
+ $user = User::factory()
+ ->create();
+
+ $sourceImage1 = SourceImage::factory()
+ ->create();
+
+ $sourceImage2 = SourceImage::factory()
+ ->create();
+
+ $context = ImageLibrary::getImageContextByKey('context-multiple');
+
+ $galleryImage1 = $user->attachImage($sourceImage1, ['context' => $context], 'gallery');
+
+ expect($galleryImage1)
+ ->toBeInstanceOf(Image::class);
+
+ expect($user->gallery)
+ ->toHaveCount(1)
+ ->first()->id->toBe($galleryImage1->id);
+
+ $galleryImage2 = $user->attachImage($sourceImage2, ['context' => $context], 'gallery');
+
+ expect($galleryImage2)
+ ->toBeInstanceOf(Image::class)
+ ->and($galleryImage2->id)->not->toBe($galleryImage1->id);
+
+ expect($user->gallery)
+ ->toHaveCount(2)
+ ->pluck('id')->toContain($galleryImage1->id)
+ ->and($user->gallery)
+ ->pluck('id')->toContain($galleryImage2->id);
+});
+
+test('attaching an image to an invalid relation throws an exception', function () {
+ $user = User::factory()
+ ->create();
+
+ $sourceImage = SourceImage::factory()
+ ->create();
+
+ $context = ImageLibrary::getImageContextByKey('context-single');
+
+ $user->attachImage($sourceImage, ['context' => $context], 'invalidRelation');
+})->throws(InvalidArgumentException::class, 'Relation invalidRelation does not exist on the model.');
+
+test('attaching an image to a relation that is not MorphOne or MorphMany throws an exception', function () {
+ $user = User::factory()
+ ->create();
+
+ $sourceImage = SourceImage::factory()
+ ->create();
+
+ $context = ImageLibrary::getImageContextByKey('context-single');
+
+ $user->attachImage($sourceImage, ['context' => $context], 'friends');
+})->throws(InvalidArgumentException::class, 'Relation friends is not a valid MorphOne or MorphMany relation.');
+
+test('a model using the trait can attach an image with custom properties', function () {
+ $user = User::factory()
+ ->create();
+
+ $sourceImage = SourceImage::factory()
+ ->create();
+
+ $context = ImageLibrary::getImageContextByKey('context-single');
+
+ $customProperties = [
+ 'caption' => 'A custom caption',
+ ];
+
+ $image = $user->attachImage($sourceImage, [
+ 'context' => $context,
+ 'custom_properties' => $customProperties,
+ ]);
+
+ expect($image)
+ ->toBeInstanceOf(Image::class)
+ ->and($image->custom_properties)->toEqual($customProperties);
+});
+
+test('attaching two images to a single-image context removes the previous image', function () {
+ $user = User::factory()
+ ->create();
+
+ $sourceImage1 = SourceImage::factory()
+ ->create();
+
+ $sourceImage2 = SourceImage::factory()
+ ->create();
+
+ $context = ImageLibrary::getImageContextByKey('context-single');
+
+ $image1 = $user->attachImage($sourceImage1, ['context' => $context]);
+
+ expect($user->images)
+ ->toHaveCount(1)
+ ->first()->id->toBe($image1->id);
+
+ $image2 = $user->attachImage($sourceImage2, ['context' => $context]);
+
+ expect($user->images)
+ ->toHaveCount(1)
+ ->first()->id->toBe($image2->id)
+ ->and($image2->id)->not->toBe($image1->id);
+});
+
+test('attaching two images to a multiple-image context keeps both images', function () {
+ $user = User::factory()
+ ->create();
+
+ $sourceImage1 = SourceImage::factory()
+ ->create();
+
+ $sourceImage2 = SourceImage::factory()
+ ->create();
+
+ $context = ImageLibrary::getImageContextByKey('context-multiple');
+
+ $image1 = $user->attachImage($sourceImage1, ['context' => $context]);
+
+ expect($user->images)
+ ->toHaveCount(1)
+ ->first()->id->toBe($image1->id);
+
+ $image2 = $user->attachImage($sourceImage2, ['context' => $context]);
+
+ expect($user->images)
+ ->toHaveCount(2)
+ ->pluck('id')->toContain($image1->id)
+ ->and($user->images)
+ ->pluck('id')->toContain($image2->id);
+});