From c6369a6ddb157c3b098edda0466948778a825f3f Mon Sep 17 00:00:00 2001
From: Paulo Castellano
Date: Fri, 3 Jul 2026 21:46:19 -0300
Subject: [PATCH 1/8] Crop the avatar/logo before upload with a dependency-free
cropper
Selecting an avatar or workspace logo now opens a crop dialog (drag + zoom)
before uploading, so the image is framed the way it renders. The crop is
performed client-side and the resized 512x512 result is what gets uploaded.
This reworks the idea from #131 without its cropper dependency: vue-advanced-cropper
was last released ~2 years ago and we did not want an unmaintained package for
something this load-bearing. What we need is narrow (fixed 1:1, a circle/square
mask, fixed-size output), so a small canvas-based cropper covers it:
- imageCrop.ts: pure transform math (cover-fit, clamp, zoom, viewport->source).
- ImageCropperDialog.vue: CSS-transform preview, pointer drag, wheel/button zoom,
a ResizeObserver to measure the modal (no requestAnimationFrame timing hacks),
and a canvas toBlob only on save.
- PhotoUpload.vue: opens the cropper on file select; the mask shape follows the
display shape (round avatar / square logo) instead of always being round.
- crop_* strings added to all 15 locales.
Also installs Pest browser testing (pest-plugin-browser + Playwright) and adds a
browser test for the crop flow. TestCase only calls withoutVite() for non-browser
tests, since browser tests need the real Vite assets to boot the SPA. The Pest
browser server does not parse multipart uploads, so the test asserts the crop
dispatches the correct upload request; endpoint persistence stays covered by
ProfileUpdateTest.
---
.gitignore | 1 +
composer.json | 1 +
composer.lock | 1573 ++++++++++++++++-
lang/ar/common.php | 5 +
lang/de/common.php | 5 +
lang/el/common.php | 5 +
lang/en/common.php | 5 +
lang/es/common.php | 5 +
lang/fr/common.php | 5 +
lang/it/common.php | 5 +
lang/ja/common.php | 5 +
lang/ko/common.php | 5 +
lang/nl/common.php | 5 +
lang/pl/common.php | 5 +
lang/pt-BR/common.php | 5 +
lang/ru/common.php | 5 +
lang/tr/common.php | 5 +
lang/zh/common.php | 5 +
package-lock.json | 90 +-
package.json | 1 +
.../js/components/ImageCropperDialog.vue | 283 +++
resources/js/components/PhotoUpload.vue | 39 +-
resources/js/lib/imageCrop.ts | 81 +
tests/Browser/ImageCropperTest.php | 89 +
tests/Browser/ProbeTest.php | 41 +
tests/Pest.php | 2 +-
tests/TestCase.php | 13 +-
27 files changed, 2240 insertions(+), 49 deletions(-)
create mode 100644 resources/js/components/ImageCropperDialog.vue
create mode 100644 resources/js/lib/imageCrop.ts
create mode 100644 tests/Browser/ImageCropperTest.php
create mode 100644 tests/Browser/ProbeTest.php
diff --git a/.gitignore b/.gitignore
index 508a4046..e61ad860 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,7 @@
/.phpunit.cache
/bootstrap/ssr
/node_modules
+/tests/Browser/Screenshots
/public/build
/lang/php_*.json
/public/hot
diff --git a/composer.json b/composer.json
index 5768bda8..1edbfebc 100644
--- a/composer.json
+++ b/composer.json
@@ -74,6 +74,7 @@
"mockery/mockery": "^1.6",
"nunomaduro/collision": "^8.6",
"pestphp/pest": "^4.4",
+ "pestphp/pest-plugin-browser": "^4.3",
"pestphp/pest-plugin-laravel": "^4.1"
},
"autoload": {
diff --git a/composer.lock b/composer.lock
index e556996b..5c17b705 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "f2f0d0ee195cb8181f595e304a6cfce0",
+ "content-hash": "25153dfd602f4770eee4612fef9e2857",
"packages": [
{
"name": "aws/aws-crt-php",
@@ -10905,6 +10905,1236 @@
}
],
"packages-dev": [
+ {
+ "name": "amphp/amp",
+ "version": "v3.1.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/amphp/amp.git",
+ "reference": "2f3ebed5a4f663968a0590dbb7654a8b32cb63cb"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/amphp/amp/zipball/2f3ebed5a4f663968a0590dbb7654a8b32cb63cb",
+ "reference": "2f3ebed5a4f663968a0590dbb7654a8b32cb63cb",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.1",
+ "revolt/event-loop": "^1 || ^0.2"
+ },
+ "require-dev": {
+ "amphp/php-cs-fixer-config": "^2",
+ "phpunit/phpunit": "^9",
+ "psalm/phar": "6.16.1"
+ },
+ "type": "library",
+ "autoload": {
+ "files": [
+ "src/functions.php",
+ "src/Future/functions.php",
+ "src/Internal/functions.php"
+ ],
+ "psr-4": {
+ "Amp\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Aaron Piotrowski",
+ "email": "aaron@trowski.com"
+ },
+ {
+ "name": "Bob Weinand",
+ "email": "bobwei9@hotmail.com"
+ },
+ {
+ "name": "Niklas Keller",
+ "email": "me@kelunik.com"
+ },
+ {
+ "name": "Daniel Lowrey",
+ "email": "rdlowrey@php.net"
+ }
+ ],
+ "description": "A non-blocking concurrency framework for PHP applications.",
+ "homepage": "https://amphp.org/amp",
+ "keywords": [
+ "async",
+ "asynchronous",
+ "awaitable",
+ "concurrency",
+ "event",
+ "event-loop",
+ "future",
+ "non-blocking",
+ "promise"
+ ],
+ "support": {
+ "issues": "https://github.com/amphp/amp/issues",
+ "source": "https://github.com/amphp/amp/tree/v3.1.2"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/amphp",
+ "type": "github"
+ }
+ ],
+ "time": "2026-06-21T13:59:44+00:00"
+ },
+ {
+ "name": "amphp/byte-stream",
+ "version": "v2.1.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/amphp/byte-stream.git",
+ "reference": "55a6bd071aec26fa2a3e002618c20c35e3df1b46"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/amphp/byte-stream/zipball/55a6bd071aec26fa2a3e002618c20c35e3df1b46",
+ "reference": "55a6bd071aec26fa2a3e002618c20c35e3df1b46",
+ "shasum": ""
+ },
+ "require": {
+ "amphp/amp": "^3",
+ "amphp/parser": "^1.1",
+ "amphp/pipeline": "^1",
+ "amphp/serialization": "^1",
+ "amphp/sync": "^2",
+ "php": ">=8.1",
+ "revolt/event-loop": "^1 || ^0.2.3"
+ },
+ "require-dev": {
+ "amphp/php-cs-fixer-config": "^2",
+ "amphp/phpunit-util": "^3",
+ "phpunit/phpunit": "^9",
+ "psalm/phar": "5.22.1"
+ },
+ "type": "library",
+ "autoload": {
+ "files": [
+ "src/functions.php",
+ "src/Internal/functions.php"
+ ],
+ "psr-4": {
+ "Amp\\ByteStream\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Aaron Piotrowski",
+ "email": "aaron@trowski.com"
+ },
+ {
+ "name": "Niklas Keller",
+ "email": "me@kelunik.com"
+ }
+ ],
+ "description": "A stream abstraction to make working with non-blocking I/O simple.",
+ "homepage": "https://amphp.org/byte-stream",
+ "keywords": [
+ "amp",
+ "amphp",
+ "async",
+ "io",
+ "non-blocking",
+ "stream"
+ ],
+ "support": {
+ "issues": "https://github.com/amphp/byte-stream/issues",
+ "source": "https://github.com/amphp/byte-stream/tree/v2.1.2"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/amphp",
+ "type": "github"
+ }
+ ],
+ "time": "2025-03-16T17:10:27+00:00"
+ },
+ {
+ "name": "amphp/cache",
+ "version": "v2.0.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/amphp/cache.git",
+ "reference": "46912e387e6aa94933b61ea1ead9cf7540b7797c"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/amphp/cache/zipball/46912e387e6aa94933b61ea1ead9cf7540b7797c",
+ "reference": "46912e387e6aa94933b61ea1ead9cf7540b7797c",
+ "shasum": ""
+ },
+ "require": {
+ "amphp/amp": "^3",
+ "amphp/serialization": "^1",
+ "amphp/sync": "^2",
+ "php": ">=8.1",
+ "revolt/event-loop": "^1 || ^0.2"
+ },
+ "require-dev": {
+ "amphp/php-cs-fixer-config": "^2",
+ "amphp/phpunit-util": "^3",
+ "phpunit/phpunit": "^9",
+ "psalm/phar": "^5.4"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Amp\\Cache\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Niklas Keller",
+ "email": "me@kelunik.com"
+ },
+ {
+ "name": "Aaron Piotrowski",
+ "email": "aaron@trowski.com"
+ },
+ {
+ "name": "Daniel Lowrey",
+ "email": "rdlowrey@php.net"
+ }
+ ],
+ "description": "A fiber-aware cache API based on Amp and Revolt.",
+ "homepage": "https://amphp.org/cache",
+ "support": {
+ "issues": "https://github.com/amphp/cache/issues",
+ "source": "https://github.com/amphp/cache/tree/v2.0.1"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/amphp",
+ "type": "github"
+ }
+ ],
+ "time": "2024-04-19T03:38:06+00:00"
+ },
+ {
+ "name": "amphp/dns",
+ "version": "v2.4.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/amphp/dns.git",
+ "reference": "78eb3db5fc69bf2fc0cb503c4fcba667bc223c71"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/amphp/dns/zipball/78eb3db5fc69bf2fc0cb503c4fcba667bc223c71",
+ "reference": "78eb3db5fc69bf2fc0cb503c4fcba667bc223c71",
+ "shasum": ""
+ },
+ "require": {
+ "amphp/amp": "^3",
+ "amphp/byte-stream": "^2",
+ "amphp/cache": "^2",
+ "amphp/parser": "^1",
+ "amphp/process": "^2",
+ "daverandom/libdns": "^2.0.2",
+ "ext-filter": "*",
+ "ext-json": "*",
+ "php": ">=8.1",
+ "revolt/event-loop": "^1 || ^0.2"
+ },
+ "require-dev": {
+ "amphp/php-cs-fixer-config": "^2",
+ "amphp/phpunit-util": "^3",
+ "phpunit/phpunit": "^9",
+ "psalm/phar": "5.20"
+ },
+ "type": "library",
+ "autoload": {
+ "files": [
+ "src/functions.php"
+ ],
+ "psr-4": {
+ "Amp\\Dns\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Chris Wright",
+ "email": "addr@daverandom.com"
+ },
+ {
+ "name": "Daniel Lowrey",
+ "email": "rdlowrey@php.net"
+ },
+ {
+ "name": "Bob Weinand",
+ "email": "bobwei9@hotmail.com"
+ },
+ {
+ "name": "Niklas Keller",
+ "email": "me@kelunik.com"
+ },
+ {
+ "name": "Aaron Piotrowski",
+ "email": "aaron@trowski.com"
+ }
+ ],
+ "description": "Async DNS resolution for Amp.",
+ "homepage": "https://github.com/amphp/dns",
+ "keywords": [
+ "amp",
+ "amphp",
+ "async",
+ "client",
+ "dns",
+ "resolve"
+ ],
+ "support": {
+ "issues": "https://github.com/amphp/dns/issues",
+ "source": "https://github.com/amphp/dns/tree/v2.4.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/amphp",
+ "type": "github"
+ }
+ ],
+ "time": "2025-01-19T15:43:40+00:00"
+ },
+ {
+ "name": "amphp/hpack",
+ "version": "v3.2.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/amphp/hpack.git",
+ "reference": "291da27078e7e149a9bad4d08ff05bf7d81c89f4"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/amphp/hpack/zipball/291da27078e7e149a9bad4d08ff05bf7d81c89f4",
+ "reference": "291da27078e7e149a9bad4d08ff05bf7d81c89f4",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.1"
+ },
+ "require-dev": {
+ "amphp/php-cs-fixer-config": "^2",
+ "http2jp/hpack-test-case": "^1",
+ "nikic/php-fuzzer": "^0.0.11",
+ "phpunit/phpunit": "^7 | ^8 | ^9"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "3.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Amp\\Http\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Daniel Lowrey",
+ "email": "rdlowrey@php.net"
+ },
+ {
+ "name": "Bob Weinand"
+ },
+ {
+ "name": "Niklas Keller",
+ "email": "me@kelunik.com"
+ },
+ {
+ "name": "Aaron Piotrowski",
+ "email": "aaron@trowski.com"
+ }
+ ],
+ "description": "HTTP/2 HPack implementation.",
+ "homepage": "https://github.com/amphp/hpack",
+ "keywords": [
+ "headers",
+ "hpack",
+ "http-2"
+ ],
+ "support": {
+ "issues": "https://github.com/amphp/hpack/issues",
+ "source": "https://github.com/amphp/hpack/tree/v3.2.2"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/amphp",
+ "type": "github"
+ }
+ ],
+ "time": "2026-05-03T19:28:59+00:00"
+ },
+ {
+ "name": "amphp/http",
+ "version": "v2.1.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/amphp/http.git",
+ "reference": "3680d80bd38b5d6f3c2cef2214ca6dd6cef26588"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/amphp/http/zipball/3680d80bd38b5d6f3c2cef2214ca6dd6cef26588",
+ "reference": "3680d80bd38b5d6f3c2cef2214ca6dd6cef26588",
+ "shasum": ""
+ },
+ "require": {
+ "amphp/hpack": "^3",
+ "amphp/parser": "^1.1",
+ "league/uri-components": "^2.4.2 | ^7.1",
+ "php": ">=8.1",
+ "psr/http-message": "^1 | ^2"
+ },
+ "require-dev": {
+ "amphp/php-cs-fixer-config": "^2",
+ "league/uri": "^6.8 | ^7.1",
+ "phpunit/phpunit": "^9",
+ "psalm/phar": "^5.26.1"
+ },
+ "type": "library",
+ "autoload": {
+ "files": [
+ "src/functions.php",
+ "src/Internal/constants.php"
+ ],
+ "psr-4": {
+ "Amp\\Http\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Niklas Keller",
+ "email": "me@kelunik.com"
+ },
+ {
+ "name": "Aaron Piotrowski",
+ "email": "aaron@trowski.com"
+ }
+ ],
+ "description": "Basic HTTP primitives which can be shared by servers and clients.",
+ "support": {
+ "issues": "https://github.com/amphp/http/issues",
+ "source": "https://github.com/amphp/http/tree/v2.1.2"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/amphp",
+ "type": "github"
+ }
+ ],
+ "time": "2024-11-23T14:57:26+00:00"
+ },
+ {
+ "name": "amphp/http-client",
+ "version": "v5.3.6",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/amphp/http-client.git",
+ "reference": "ca155026acafa74a612d776a97202d53077fee86"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/amphp/http-client/zipball/ca155026acafa74a612d776a97202d53077fee86",
+ "reference": "ca155026acafa74a612d776a97202d53077fee86",
+ "shasum": ""
+ },
+ "require": {
+ "amphp/amp": "^3",
+ "amphp/byte-stream": "^2",
+ "amphp/hpack": "^3",
+ "amphp/http": "^2",
+ "amphp/pipeline": "^1",
+ "amphp/socket": "^2",
+ "amphp/sync": "^2",
+ "league/uri": "^7",
+ "league/uri-components": "^7",
+ "league/uri-interfaces": "^7.1",
+ "php": ">=8.1",
+ "psr/http-message": "^1 | ^2",
+ "revolt/event-loop": "^1"
+ },
+ "conflict": {
+ "amphp/file": "<3 | >=5"
+ },
+ "require-dev": {
+ "amphp/file": "^3 | ^4",
+ "amphp/http-server": "^3",
+ "amphp/php-cs-fixer-config": "^2",
+ "amphp/phpunit-util": "^3",
+ "ext-json": "*",
+ "kelunik/link-header-rfc5988": "^1",
+ "phpunit/phpunit": "^9",
+ "psalm/phar": "6.16.1"
+ },
+ "suggest": {
+ "amphp/file": "Required for file request bodies and HTTP archive logging",
+ "ext-json": "Required for logging HTTP archives",
+ "ext-zlib": "Allows using compression for response bodies."
+ },
+ "type": "library",
+ "autoload": {
+ "files": [
+ "src/functions.php",
+ "src/Internal/functions.php"
+ ],
+ "psr-4": {
+ "Amp\\Http\\Client\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Daniel Lowrey",
+ "email": "rdlowrey@gmail.com"
+ },
+ {
+ "name": "Niklas Keller",
+ "email": "me@kelunik.com"
+ },
+ {
+ "name": "Aaron Piotrowski",
+ "email": "aaron@trowski.com"
+ }
+ ],
+ "description": "An advanced async HTTP client library for PHP, enabling efficient, non-blocking, and concurrent requests and responses.",
+ "homepage": "https://amphp.org/http-client",
+ "keywords": [
+ "async",
+ "client",
+ "concurrent",
+ "http",
+ "non-blocking",
+ "rest"
+ ],
+ "support": {
+ "issues": "https://github.com/amphp/http-client/issues",
+ "source": "https://github.com/amphp/http-client/tree/v5.3.6"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/amphp",
+ "type": "github"
+ }
+ ],
+ "time": "2026-05-15T23:29:38+00:00"
+ },
+ {
+ "name": "amphp/http-server",
+ "version": "v3.4.6",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/amphp/http-server.git",
+ "reference": "8a971bf92cf8cf2bc511f37a75b39126d5305315"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/amphp/http-server/zipball/8a971bf92cf8cf2bc511f37a75b39126d5305315",
+ "reference": "8a971bf92cf8cf2bc511f37a75b39126d5305315",
+ "shasum": ""
+ },
+ "require": {
+ "amphp/amp": "^3",
+ "amphp/byte-stream": "^2",
+ "amphp/cache": "^2",
+ "amphp/hpack": "^3",
+ "amphp/http": "^2",
+ "amphp/pipeline": "^1",
+ "amphp/socket": "^2.1",
+ "amphp/sync": "^2.2",
+ "league/uri": "^7.1",
+ "league/uri-interfaces": "^7.1",
+ "php": ">=8.1",
+ "psr/http-message": "^1 | ^2",
+ "psr/log": "^1 | ^2 | ^3",
+ "revolt/event-loop": "^1"
+ },
+ "require-dev": {
+ "amphp/http-client": "^5",
+ "amphp/log": "^2",
+ "amphp/php-cs-fixer-config": "^2",
+ "amphp/phpunit-util": "^3",
+ "league/uri-components": "^7.1",
+ "monolog/monolog": "^3",
+ "phpunit/phpunit": "^9",
+ "psalm/phar": "6.16.1"
+ },
+ "suggest": {
+ "ext-zlib": "Allows GZip compression of response bodies"
+ },
+ "type": "library",
+ "autoload": {
+ "files": [
+ "src/Driver/functions.php",
+ "src/Middleware/functions.php",
+ "src/functions.php"
+ ],
+ "psr-4": {
+ "Amp\\Http\\Server\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Daniel Lowrey",
+ "email": "rdlowrey@php.net"
+ },
+ {
+ "name": "Bob Weinand"
+ },
+ {
+ "name": "Niklas Keller",
+ "email": "me@kelunik.com"
+ },
+ {
+ "name": "Aaron Piotrowski",
+ "email": "aaron@trowski.com"
+ }
+ ],
+ "description": "A non-blocking HTTP application server for PHP based on Amp.",
+ "homepage": "https://github.com/amphp/http-server",
+ "keywords": [
+ "amp",
+ "amphp",
+ "async",
+ "http",
+ "non-blocking",
+ "server"
+ ],
+ "support": {
+ "issues": "https://github.com/amphp/http-server/issues",
+ "source": "https://github.com/amphp/http-server/tree/v3.4.6"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/amphp",
+ "type": "github"
+ }
+ ],
+ "time": "2026-06-27T10:31:48+00:00"
+ },
+ {
+ "name": "amphp/parser",
+ "version": "v1.1.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/amphp/parser.git",
+ "reference": "3cf1f8b32a0171d4b1bed93d25617637a77cded7"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/amphp/parser/zipball/3cf1f8b32a0171d4b1bed93d25617637a77cded7",
+ "reference": "3cf1f8b32a0171d4b1bed93d25617637a77cded7",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.4"
+ },
+ "require-dev": {
+ "amphp/php-cs-fixer-config": "^2",
+ "phpunit/phpunit": "^9",
+ "psalm/phar": "^5.4"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Amp\\Parser\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Aaron Piotrowski",
+ "email": "aaron@trowski.com"
+ },
+ {
+ "name": "Niklas Keller",
+ "email": "me@kelunik.com"
+ }
+ ],
+ "description": "A generator parser to make streaming parsers simple.",
+ "homepage": "https://github.com/amphp/parser",
+ "keywords": [
+ "async",
+ "non-blocking",
+ "parser",
+ "stream"
+ ],
+ "support": {
+ "issues": "https://github.com/amphp/parser/issues",
+ "source": "https://github.com/amphp/parser/tree/v1.1.1"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/amphp",
+ "type": "github"
+ }
+ ],
+ "time": "2024-03-21T19:16:53+00:00"
+ },
+ {
+ "name": "amphp/pipeline",
+ "version": "v1.2.5",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/amphp/pipeline.git",
+ "reference": "92f121dde31cd1d89d5d0f9eba64ac40271b236e"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/amphp/pipeline/zipball/92f121dde31cd1d89d5d0f9eba64ac40271b236e",
+ "reference": "92f121dde31cd1d89d5d0f9eba64ac40271b236e",
+ "shasum": ""
+ },
+ "require": {
+ "amphp/amp": "^3",
+ "php": ">=8.1",
+ "revolt/event-loop": "^1"
+ },
+ "require-dev": {
+ "amphp/php-cs-fixer-config": "^2",
+ "amphp/phpunit-util": "^3",
+ "phpunit/phpunit": "^9",
+ "psalm/phar": "6.16.1"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Amp\\Pipeline\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Aaron Piotrowski",
+ "email": "aaron@trowski.com"
+ },
+ {
+ "name": "Niklas Keller",
+ "email": "me@kelunik.com"
+ }
+ ],
+ "description": "Asynchronous iterators and operators.",
+ "homepage": "https://amphp.org/pipeline",
+ "keywords": [
+ "amp",
+ "amphp",
+ "async",
+ "io",
+ "iterator",
+ "non-blocking"
+ ],
+ "support": {
+ "issues": "https://github.com/amphp/pipeline/issues",
+ "source": "https://github.com/amphp/pipeline/tree/v1.2.5"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/amphp",
+ "type": "github"
+ }
+ ],
+ "time": "2026-06-27T14:17:20+00:00"
+ },
+ {
+ "name": "amphp/process",
+ "version": "v2.1.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/amphp/process.git",
+ "reference": "583959df17d00304ad7b0b32285373f985935643"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/amphp/process/zipball/583959df17d00304ad7b0b32285373f985935643",
+ "reference": "583959df17d00304ad7b0b32285373f985935643",
+ "shasum": ""
+ },
+ "require": {
+ "amphp/amp": "^3",
+ "amphp/byte-stream": "^2",
+ "amphp/sync": "^2",
+ "php": ">=8.1",
+ "revolt/event-loop": "^1 || ^0.2"
+ },
+ "require-dev": {
+ "amphp/php-cs-fixer-config": "^2",
+ "amphp/phpunit-util": "^3",
+ "phpunit/phpunit": "^9",
+ "psalm/phar": "6.16.1"
+ },
+ "type": "library",
+ "autoload": {
+ "files": [
+ "src/functions.php"
+ ],
+ "psr-4": {
+ "Amp\\Process\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Bob Weinand",
+ "email": "bobwei9@hotmail.com"
+ },
+ {
+ "name": "Aaron Piotrowski",
+ "email": "aaron@trowski.com"
+ },
+ {
+ "name": "Niklas Keller",
+ "email": "me@kelunik.com"
+ }
+ ],
+ "description": "A fiber-aware process manager based on Amp and Revolt.",
+ "homepage": "https://amphp.org/process",
+ "support": {
+ "issues": "https://github.com/amphp/process/issues",
+ "source": "https://github.com/amphp/process/tree/v2.1.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/amphp",
+ "type": "github"
+ }
+ ],
+ "time": "2026-05-31T15:11:55+00:00"
+ },
+ {
+ "name": "amphp/serialization",
+ "version": "v1.1.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/amphp/serialization.git",
+ "reference": "fdf2834d78cebb0205fb2672676c1b1eb84371f0"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/amphp/serialization/zipball/fdf2834d78cebb0205fb2672676c1b1eb84371f0",
+ "reference": "fdf2834d78cebb0205fb2672676c1b1eb84371f0",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.4"
+ },
+ "require-dev": {
+ "amphp/php-cs-fixer-config": "^2",
+ "ext-json": "*",
+ "ext-zlib": "*",
+ "phpunit/phpunit": "^9",
+ "psalm/phar": "6.16.1"
+ },
+ "type": "library",
+ "autoload": {
+ "files": [
+ "src/functions.php"
+ ],
+ "psr-4": {
+ "Amp\\Serialization\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Aaron Piotrowski",
+ "email": "aaron@trowski.com"
+ },
+ {
+ "name": "Niklas Keller",
+ "email": "me@kelunik.com"
+ }
+ ],
+ "description": "Serialization tools for IPC and data storage in PHP.",
+ "homepage": "https://github.com/amphp/serialization",
+ "keywords": [
+ "async",
+ "asynchronous",
+ "serialization",
+ "serialize"
+ ],
+ "support": {
+ "issues": "https://github.com/amphp/serialization/issues",
+ "source": "https://github.com/amphp/serialization/tree/v1.1.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/amphp",
+ "type": "github"
+ }
+ ],
+ "time": "2026-04-05T15:59:53+00:00"
+ },
+ {
+ "name": "amphp/socket",
+ "version": "v2.4.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/amphp/socket.git",
+ "reference": "dadb63c5d3179fd83803e29dfeac27350e619314"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/amphp/socket/zipball/dadb63c5d3179fd83803e29dfeac27350e619314",
+ "reference": "dadb63c5d3179fd83803e29dfeac27350e619314",
+ "shasum": ""
+ },
+ "require": {
+ "amphp/amp": "^3",
+ "amphp/byte-stream": "^2",
+ "amphp/dns": "^2",
+ "ext-openssl": "*",
+ "kelunik/certificate": "^1.1",
+ "league/uri": "^7",
+ "league/uri-interfaces": "^7",
+ "php": ">=8.1",
+ "revolt/event-loop": "^1"
+ },
+ "require-dev": {
+ "amphp/php-cs-fixer-config": "^2",
+ "amphp/phpunit-util": "^3",
+ "amphp/process": "^2",
+ "phpunit/phpunit": "^9",
+ "psalm/phar": "6.16.1"
+ },
+ "type": "library",
+ "autoload": {
+ "files": [
+ "src/functions.php",
+ "src/Internal/functions.php",
+ "src/SocketAddress/functions.php"
+ ],
+ "psr-4": {
+ "Amp\\Socket\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Daniel Lowrey",
+ "email": "rdlowrey@gmail.com"
+ },
+ {
+ "name": "Aaron Piotrowski",
+ "email": "aaron@trowski.com"
+ },
+ {
+ "name": "Niklas Keller",
+ "email": "me@kelunik.com"
+ }
+ ],
+ "description": "Non-blocking socket connection / server implementations based on Amp and Revolt.",
+ "homepage": "https://github.com/amphp/socket",
+ "keywords": [
+ "amp",
+ "async",
+ "encryption",
+ "non-blocking",
+ "sockets",
+ "tcp",
+ "tls"
+ ],
+ "support": {
+ "issues": "https://github.com/amphp/socket/issues",
+ "source": "https://github.com/amphp/socket/tree/v2.4.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/amphp",
+ "type": "github"
+ }
+ ],
+ "time": "2026-04-19T15:09:56+00:00"
+ },
+ {
+ "name": "amphp/sync",
+ "version": "v2.3.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/amphp/sync.git",
+ "reference": "217097b785130d77cfcc58ff583cf26cd1770bf1"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/amphp/sync/zipball/217097b785130d77cfcc58ff583cf26cd1770bf1",
+ "reference": "217097b785130d77cfcc58ff583cf26cd1770bf1",
+ "shasum": ""
+ },
+ "require": {
+ "amphp/amp": "^3",
+ "amphp/pipeline": "^1",
+ "amphp/serialization": "^1",
+ "php": ">=8.1",
+ "revolt/event-loop": "^1 || ^0.2"
+ },
+ "require-dev": {
+ "amphp/php-cs-fixer-config": "^2",
+ "amphp/phpunit-util": "^3",
+ "phpunit/phpunit": "^9",
+ "psalm/phar": "5.23"
+ },
+ "type": "library",
+ "autoload": {
+ "files": [
+ "src/functions.php"
+ ],
+ "psr-4": {
+ "Amp\\Sync\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Aaron Piotrowski",
+ "email": "aaron@trowski.com"
+ },
+ {
+ "name": "Niklas Keller",
+ "email": "me@kelunik.com"
+ },
+ {
+ "name": "Stephen Coakley",
+ "email": "me@stephencoakley.com"
+ }
+ ],
+ "description": "Non-blocking synchronization primitives for PHP based on Amp and Revolt.",
+ "homepage": "https://github.com/amphp/sync",
+ "keywords": [
+ "async",
+ "asynchronous",
+ "mutex",
+ "semaphore",
+ "synchronization"
+ ],
+ "support": {
+ "issues": "https://github.com/amphp/sync/issues",
+ "source": "https://github.com/amphp/sync/tree/v2.3.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/amphp",
+ "type": "github"
+ }
+ ],
+ "time": "2024-08-03T19:31:26+00:00"
+ },
+ {
+ "name": "amphp/websocket",
+ "version": "v2.0.4",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/amphp/websocket.git",
+ "reference": "963904b6a883c4b62d9222d1d9749814fac96a3b"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/amphp/websocket/zipball/963904b6a883c4b62d9222d1d9749814fac96a3b",
+ "reference": "963904b6a883c4b62d9222d1d9749814fac96a3b",
+ "shasum": ""
+ },
+ "require": {
+ "amphp/amp": "^3",
+ "amphp/byte-stream": "^2",
+ "amphp/parser": "^1",
+ "amphp/pipeline": "^1",
+ "amphp/socket": "^2",
+ "php": ">=8.1",
+ "revolt/event-loop": "^1"
+ },
+ "require-dev": {
+ "amphp/php-cs-fixer-config": "^2",
+ "amphp/phpunit-util": "^3",
+ "phpunit/phpunit": "^9",
+ "psalm/phar": "^5.18"
+ },
+ "suggest": {
+ "ext-zlib": "Required for compression"
+ },
+ "type": "library",
+ "autoload": {
+ "files": [
+ "src/functions.php"
+ ],
+ "psr-4": {
+ "Amp\\Websocket\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Aaron Piotrowski",
+ "email": "aaron@trowski.com"
+ },
+ {
+ "name": "Niklas Keller",
+ "email": "me@kelunik.com"
+ },
+ {
+ "name": "Bob Weinand",
+ "email": "bobwei9@hotmail.com"
+ }
+ ],
+ "description": "Shared code for websocket servers and clients.",
+ "homepage": "https://github.com/amphp/websocket",
+ "keywords": [
+ "amp",
+ "amphp",
+ "async",
+ "http",
+ "non-blocking",
+ "websocket"
+ ],
+ "support": {
+ "issues": "https://github.com/amphp/websocket/issues",
+ "source": "https://github.com/amphp/websocket/tree/v2.0.4"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/amphp",
+ "type": "github"
+ }
+ ],
+ "time": "2024-10-28T21:28:45+00:00"
+ },
+ {
+ "name": "amphp/websocket-client",
+ "version": "v2.0.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/amphp/websocket-client.git",
+ "reference": "dc033fdce0af56295a23f63ac4f579b34d470d6c"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/amphp/websocket-client/zipball/dc033fdce0af56295a23f63ac4f579b34d470d6c",
+ "reference": "dc033fdce0af56295a23f63ac4f579b34d470d6c",
+ "shasum": ""
+ },
+ "require": {
+ "amphp/amp": "^3",
+ "amphp/byte-stream": "^2.1",
+ "amphp/http": "^2.1",
+ "amphp/http-client": "^5",
+ "amphp/socket": "^2.2",
+ "amphp/websocket": "^2",
+ "league/uri": "^7.1",
+ "php": ">=8.1",
+ "psr/http-message": "^1|^2",
+ "revolt/event-loop": "^1"
+ },
+ "require-dev": {
+ "amphp/http-server": "^3",
+ "amphp/php-cs-fixer-config": "^2",
+ "amphp/phpunit-util": "^3",
+ "amphp/websocket-server": "^3|^4",
+ "phpunit/phpunit": "^9",
+ "psalm/phar": "~5.26.1",
+ "psr/log": "^1"
+ },
+ "type": "library",
+ "autoload": {
+ "files": [
+ "src/functions.php"
+ ],
+ "psr-4": {
+ "Amp\\Websocket\\Client\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Bob Weinand",
+ "email": "bobwei9@hotmail.com"
+ },
+ {
+ "name": "Aaron Piotrowski",
+ "email": "aaron@trowski.com"
+ },
+ {
+ "name": "Niklas Keller",
+ "email": "me@kelunik.com"
+ }
+ ],
+ "description": "Async WebSocket client for PHP based on Amp.",
+ "keywords": [
+ "amp",
+ "amphp",
+ "async",
+ "client",
+ "http",
+ "non-blocking",
+ "websocket"
+ ],
+ "support": {
+ "issues": "https://github.com/amphp/websocket-client/issues",
+ "source": "https://github.com/amphp/websocket-client/tree/v2.0.2"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/amphp",
+ "type": "github"
+ }
+ ],
+ "time": "2025-08-24T17:25:34+00:00"
+ },
{
"name": "brianium/paratest",
"version": "v7.20.0",
@@ -11140,6 +12370,50 @@
],
"time": "2024-05-06T16:37:16+00:00"
},
+ {
+ "name": "daverandom/libdns",
+ "version": "v2.1.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/DaveRandom/LibDNS.git",
+ "reference": "b84c94e8fe6b7ee4aecfe121bfe3b6177d303c8a"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/DaveRandom/LibDNS/zipball/b84c94e8fe6b7ee4aecfe121bfe3b6177d303c8a",
+ "reference": "b84c94e8fe6b7ee4aecfe121bfe3b6177d303c8a",
+ "shasum": ""
+ },
+ "require": {
+ "ext-ctype": "*",
+ "php": ">=7.1"
+ },
+ "suggest": {
+ "ext-intl": "Required for IDN support"
+ },
+ "type": "library",
+ "autoload": {
+ "files": [
+ "src/functions.php"
+ ],
+ "psr-4": {
+ "LibDNS\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "description": "DNS protocol implementation written in pure PHP",
+ "keywords": [
+ "dns"
+ ],
+ "support": {
+ "issues": "https://github.com/DaveRandom/LibDNS/issues",
+ "source": "https://github.com/DaveRandom/LibDNS/tree/v2.1.0"
+ },
+ "time": "2024-04-12T12:12:48+00:00"
+ },
{
"name": "doctrine/deprecations",
"version": "1.1.6",
@@ -11494,6 +12768,64 @@
},
"time": "2025-03-19T14:43:43+00:00"
},
+ {
+ "name": "kelunik/certificate",
+ "version": "v1.1.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/kelunik/certificate.git",
+ "reference": "7e00d498c264d5eb4f78c69f41c8bd6719c0199e"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/kelunik/certificate/zipball/7e00d498c264d5eb4f78c69f41c8bd6719c0199e",
+ "reference": "7e00d498c264d5eb4f78c69f41c8bd6719c0199e",
+ "shasum": ""
+ },
+ "require": {
+ "ext-openssl": "*",
+ "php": ">=7.0"
+ },
+ "require-dev": {
+ "amphp/php-cs-fixer-config": "^2",
+ "phpunit/phpunit": "^6 | 7 | ^8 | ^9"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "1.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Kelunik\\Certificate\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Niklas Keller",
+ "email": "me@kelunik.com"
+ }
+ ],
+ "description": "Access certificate details and transform between different formats.",
+ "keywords": [
+ "DER",
+ "certificate",
+ "certificates",
+ "openssl",
+ "pem",
+ "x509"
+ ],
+ "support": {
+ "issues": "https://github.com/kelunik/certificate/issues",
+ "source": "https://github.com/kelunik/certificate/tree/v1.1.3"
+ },
+ "time": "2023-02-03T21:26:53+00:00"
+ },
{
"name": "laravel/pail",
"version": "v1.2.7",
@@ -11774,6 +13106,90 @@
},
"time": "2026-04-06T12:52:26+00:00"
},
+ {
+ "name": "league/uri-components",
+ "version": "7.8.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/thephpleague/uri-components.git",
+ "reference": "848ff9db2f0be06229d6034b7c2e33d41b4fd675"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/thephpleague/uri-components/zipball/848ff9db2f0be06229d6034b7c2e33d41b4fd675",
+ "reference": "848ff9db2f0be06229d6034b7c2e33d41b4fd675",
+ "shasum": ""
+ },
+ "require": {
+ "league/uri": "^7.8.1",
+ "php": "^8.1"
+ },
+ "suggest": {
+ "ext-bcmath": "to improve IPV4 host parsing",
+ "ext-fileinfo": "to create Data URI from file contennts",
+ "ext-gmp": "to improve IPV4 host parsing",
+ "ext-intl": "to handle IDN host with the best performance",
+ "ext-mbstring": "to use the sorting algorithm of URLSearchParams",
+ "jeremykendall/php-domain-parser": "to further parse the URI host and resolve its Public Suffix and Top Level Domain",
+ "league/uri-polyfill": "to backport the PHP URI extension for older versions of PHP",
+ "php-64bit": "to improve IPV4 host parsing",
+ "rowbot/url": "to handle URLs using the WHATWG URL Living Standard specification",
+ "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "7.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "League\\Uri\\": ""
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Ignace Nyamagana Butera",
+ "email": "nyamsprod@gmail.com",
+ "homepage": "https://nyamsprod.com"
+ }
+ ],
+ "description": "URI components manipulation library",
+ "homepage": "http://uri.thephpleague.com",
+ "keywords": [
+ "authority",
+ "components",
+ "fragment",
+ "host",
+ "middleware",
+ "modifier",
+ "path",
+ "port",
+ "query",
+ "rfc3986",
+ "scheme",
+ "uri",
+ "url",
+ "userinfo"
+ ],
+ "support": {
+ "docs": "https://uri.thephpleague.com",
+ "forum": "https://thephpleague.slack.com",
+ "issues": "https://github.com/thephpleague/uri-src/issues",
+ "source": "https://github.com/thephpleague/uri-components/tree/7.8.1"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/nyamsprod",
+ "type": "github"
+ }
+ ],
+ "time": "2026-03-15T20:22:25+00:00"
+ },
{
"name": "mockery/mockery",
"version": "1.6.12",
@@ -12272,6 +13688,89 @@
],
"time": "2026-04-10T17:20:19+00:00"
},
+ {
+ "name": "pestphp/pest-plugin-browser",
+ "version": "v4.3.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/pestphp/pest-plugin-browser.git",
+ "reference": "b6e76d3e4a2f81da9f050ec54be2a29b402287c4"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/pestphp/pest-plugin-browser/zipball/b6e76d3e4a2f81da9f050ec54be2a29b402287c4",
+ "reference": "b6e76d3e4a2f81da9f050ec54be2a29b402287c4",
+ "shasum": ""
+ },
+ "require": {
+ "amphp/amp": "^3.1.1",
+ "amphp/http-server": "^3.4.4",
+ "amphp/websocket-client": "^2.0.2",
+ "ext-sockets": "*",
+ "pestphp/pest": "^4.4.5",
+ "pestphp/pest-plugin": "^4.0.0",
+ "php": "^8.3",
+ "symfony/process": "^7.4.8|^8.0.5"
+ },
+ "require-dev": {
+ "ext-pcntl": "*",
+ "ext-posix": "*",
+ "livewire/livewire": "^3.7.15",
+ "nunomaduro/collision": "^8.9.3",
+ "orchestra/testbench": "^10.11.0",
+ "pestphp/pest-dev-tools": "^4.1.0",
+ "pestphp/pest-plugin-laravel": "^4.1",
+ "pestphp/pest-plugin-type-coverage": "^4.0.4"
+ },
+ "type": "library",
+ "extra": {
+ "pest": {
+ "plugins": [
+ "Pest\\Browser\\Plugin"
+ ]
+ }
+ },
+ "autoload": {
+ "files": [
+ "src/Autoload.php"
+ ],
+ "psr-4": {
+ "Pest\\Browser\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "description": "Pest plugin to test browser interactions",
+ "keywords": [
+ "browser",
+ "framework",
+ "pest",
+ "php",
+ "test",
+ "testing",
+ "unit"
+ ],
+ "support": {
+ "source": "https://github.com/pestphp/pest-plugin-browser/tree/v4.3.1"
+ },
+ "funding": [
+ {
+ "url": "https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=66BYDWAT92N6L",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/nunomaduro",
+ "type": "github"
+ },
+ {
+ "url": "https://www.patreon.com/nunomaduro",
+ "type": "patreon"
+ }
+ ],
+ "time": "2026-04-08T21:04:12+00:00"
+ },
{
"name": "pestphp/pest-plugin-laravel",
"version": "v4.1.0",
@@ -13207,6 +14706,78 @@
],
"time": "2026-06-04T06:14:42+00:00"
},
+ {
+ "name": "revolt/event-loop",
+ "version": "v1.0.9",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/revoltphp/event-loop.git",
+ "reference": "44061cf513e53c6200372fc935ac42271566295d"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/revoltphp/event-loop/zipball/44061cf513e53c6200372fc935ac42271566295d",
+ "reference": "44061cf513e53c6200372fc935ac42271566295d",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.1"
+ },
+ "require-dev": {
+ "ext-json": "*",
+ "jetbrains/phpstorm-stubs": "^2019.3",
+ "phpunit/phpunit": "^9",
+ "psalm/phar": "6.16.*"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "1.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Revolt\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Aaron Piotrowski",
+ "email": "aaron@trowski.com"
+ },
+ {
+ "name": "Cees-Jan Kiewiet",
+ "email": "ceesjank@gmail.com"
+ },
+ {
+ "name": "Christian Lück",
+ "email": "christian@clue.engineering"
+ },
+ {
+ "name": "Niklas Keller",
+ "email": "me@kelunik.com"
+ }
+ ],
+ "description": "Rock-solid event loop for concurrent PHP applications.",
+ "keywords": [
+ "async",
+ "asynchronous",
+ "concurrency",
+ "event",
+ "event-loop",
+ "non-blocking",
+ "scheduler"
+ ],
+ "support": {
+ "issues": "https://github.com/revoltphp/event-loop/issues",
+ "source": "https://github.com/revoltphp/event-loop/tree/v1.0.9"
+ },
+ "time": "2026-05-16T17:55:38+00:00"
+ },
{
"name": "sebastian/cli-parser",
"version": "4.2.1",
diff --git a/lang/ar/common.php b/lang/ar/common.php
index 59fd7803..ee82031c 100644
--- a/lang/ar/common.php
+++ b/lang/ar/common.php
@@ -21,6 +21,11 @@
'uploading' => 'جارٍ الرفع...',
'remove' => 'إزالة الصورة',
'hint' => 'موصى به: صورة مربعة، بحد أقصى 2 ميغابايت.',
+ 'crop_title' => 'قص الصورة',
+ 'crop_description' => 'اسحب وكبّر لتأطيرها.',
+ 'crop_hint' => 'اسحب لإعادة التموضع',
+ 'crop_save' => 'حفظ',
+ 'crop_cancel' => 'إلغاء',
],
'timezone' => [
diff --git a/lang/de/common.php b/lang/de/common.php
index ce786c16..d14036e6 100644
--- a/lang/de/common.php
+++ b/lang/de/common.php
@@ -21,6 +21,11 @@
'uploading' => 'Wird hochgeladen...',
'remove' => 'Foto entfernen',
'hint' => 'Empfohlen: quadratisches Bild, max. 2 MB.',
+ 'crop_title' => 'Bild zuschneiden',
+ 'crop_description' => 'Ziehen und zoomen, um es auszurichten.',
+ 'crop_hint' => 'Zum Verschieben ziehen',
+ 'crop_save' => 'Speichern',
+ 'crop_cancel' => 'Abbrechen',
],
'timezone' => [
diff --git a/lang/el/common.php b/lang/el/common.php
index 0bb7db23..66633824 100644
--- a/lang/el/common.php
+++ b/lang/el/common.php
@@ -21,6 +21,11 @@
'uploading' => 'Μεταφόρτωση...',
'remove' => 'Αφαίρεση φωτογραφίας',
'hint' => 'Συνιστάται: τετράγωνη εικόνα, έως 2 MB.',
+ 'crop_title' => 'Περικοπή εικόνας',
+ 'crop_description' => 'Σύρετε και κάντε ζουμ για πλαισίωση.',
+ 'crop_hint' => 'Σύρετε για επανατοποθέτηση',
+ 'crop_save' => 'Αποθήκευση',
+ 'crop_cancel' => 'Άκυρο',
],
'timezone' => [
diff --git a/lang/en/common.php b/lang/en/common.php
index a41a2d55..0fbe653f 100644
--- a/lang/en/common.php
+++ b/lang/en/common.php
@@ -21,6 +21,11 @@
'uploading' => 'Uploading...',
'remove' => 'Remove photo',
'hint' => 'Recommended: square image, max 2 MB.',
+ 'crop_title' => 'Crop image',
+ 'crop_description' => 'Drag and zoom to frame it.',
+ 'crop_hint' => 'Drag to reposition',
+ 'crop_save' => 'Save',
+ 'crop_cancel' => 'Cancel',
],
'timezone' => [
diff --git a/lang/es/common.php b/lang/es/common.php
index fe84cf9b..d513ff5b 100644
--- a/lang/es/common.php
+++ b/lang/es/common.php
@@ -21,6 +21,11 @@
'uploading' => 'Subiendo...',
'remove' => 'Eliminar foto',
'hint' => 'Recomendado: imagen cuadrada, máximo 2 MB.',
+ 'crop_title' => 'Recortar imagen',
+ 'crop_description' => 'Arrastra y haz zoom para encuadrarla.',
+ 'crop_hint' => 'Arrastra para reposicionar',
+ 'crop_save' => 'Guardar',
+ 'crop_cancel' => 'Cancelar',
],
'timezone' => [
diff --git a/lang/fr/common.php b/lang/fr/common.php
index 206a805d..6c167327 100644
--- a/lang/fr/common.php
+++ b/lang/fr/common.php
@@ -21,6 +21,11 @@
'uploading' => 'Import en cours...',
'remove' => 'Supprimer la photo',
'hint' => 'Recommandé : image carrée, 2 Mo maximum.',
+ 'crop_title' => 'Recadrer l\'image',
+ 'crop_description' => 'Faites glisser et zoomez pour cadrer.',
+ 'crop_hint' => 'Faites glisser pour repositionner',
+ 'crop_save' => 'Enregistrer',
+ 'crop_cancel' => 'Annuler',
],
'timezone' => [
diff --git a/lang/it/common.php b/lang/it/common.php
index 44785808..367ce4de 100644
--- a/lang/it/common.php
+++ b/lang/it/common.php
@@ -21,6 +21,11 @@
'uploading' => 'Caricamento in corso...',
'remove' => 'Rimuovi foto',
'hint' => 'Consigliato: immagine quadrata, max 2 MB.',
+ 'crop_title' => 'Ritaglia immagine',
+ 'crop_description' => 'Trascina e ingrandisci per inquadrarla.',
+ 'crop_hint' => 'Trascina per riposizionare',
+ 'crop_save' => 'Salva',
+ 'crop_cancel' => 'Annulla',
],
'timezone' => [
diff --git a/lang/ja/common.php b/lang/ja/common.php
index 96e89257..40551cef 100644
--- a/lang/ja/common.php
+++ b/lang/ja/common.php
@@ -21,6 +21,11 @@
'uploading' => 'アップロード中...',
'remove' => '写真を削除',
'hint' => '推奨: 正方形の画像、最大 2 MB。',
+ 'crop_title' => '画像を切り抜く',
+ 'crop_description' => 'ドラッグとズームで位置を調整します。',
+ 'crop_hint' => 'ドラッグして移動',
+ 'crop_save' => '保存',
+ 'crop_cancel' => 'キャンセル',
],
'timezone' => [
diff --git a/lang/ko/common.php b/lang/ko/common.php
index 5bb2d175..9c434b1c 100644
--- a/lang/ko/common.php
+++ b/lang/ko/common.php
@@ -21,6 +21,11 @@
'uploading' => '업로드 중...',
'remove' => '사진 제거',
'hint' => '권장: 정사각형 이미지, 최대 2MB.',
+ 'crop_title' => '이미지 자르기',
+ 'crop_description' => '드래그하고 확대/축소하여 맞추세요.',
+ 'crop_hint' => '드래그하여 위치 조정',
+ 'crop_save' => '저장',
+ 'crop_cancel' => '취소',
],
'timezone' => [
diff --git a/lang/nl/common.php b/lang/nl/common.php
index 7fb1ead5..74491bba 100644
--- a/lang/nl/common.php
+++ b/lang/nl/common.php
@@ -21,6 +21,11 @@
'uploading' => 'Uploaden...',
'remove' => 'Foto verwijderen',
'hint' => 'Aanbevolen: vierkante afbeelding, max. 2 MB.',
+ 'crop_title' => 'Afbeelding bijsnijden',
+ 'crop_description' => 'Sleep en zoom om uit te lijnen.',
+ 'crop_hint' => 'Sleep om te verplaatsen',
+ 'crop_save' => 'Opslaan',
+ 'crop_cancel' => 'Annuleren',
],
'timezone' => [
diff --git a/lang/pl/common.php b/lang/pl/common.php
index ed7a3fe1..5d298c0d 100644
--- a/lang/pl/common.php
+++ b/lang/pl/common.php
@@ -21,6 +21,11 @@
'uploading' => 'Przesyłanie...',
'remove' => 'Usuń zdjęcie',
'hint' => 'Zalecane: kwadratowe zdjęcie, maks. 2 MB.',
+ 'crop_title' => 'Przytnij obraz',
+ 'crop_description' => 'Przeciągnij i powiększ, aby wykadrować.',
+ 'crop_hint' => 'Przeciągnij, aby zmienić położenie',
+ 'crop_save' => 'Zapisz',
+ 'crop_cancel' => 'Anuluj',
],
'timezone' => [
diff --git a/lang/pt-BR/common.php b/lang/pt-BR/common.php
index a64d8a34..dc059548 100644
--- a/lang/pt-BR/common.php
+++ b/lang/pt-BR/common.php
@@ -21,6 +21,11 @@
'uploading' => 'Enviando...',
'remove' => 'Remover foto',
'hint' => 'Recomendado: imagem quadrada, máximo 2 MB.',
+ 'crop_title' => 'Cortar imagem',
+ 'crop_description' => 'Arraste e dê zoom para enquadrar.',
+ 'crop_hint' => 'Arraste para reposicionar',
+ 'crop_save' => 'Salvar',
+ 'crop_cancel' => 'Cancelar',
],
'timezone' => [
diff --git a/lang/ru/common.php b/lang/ru/common.php
index 6b7e39fd..0895c276 100644
--- a/lang/ru/common.php
+++ b/lang/ru/common.php
@@ -21,6 +21,11 @@
'uploading' => 'Загрузка...',
'remove' => 'Удалить фото',
'hint' => 'Рекомендуется: квадратное изображение, до 2 МБ.',
+ 'crop_title' => 'Обрезать изображение',
+ 'crop_description' => 'Перетащите и масштабируйте для кадрирования.',
+ 'crop_hint' => 'Перетащите, чтобы переместить',
+ 'crop_save' => 'Сохранить',
+ 'crop_cancel' => 'Отмена',
],
'timezone' => [
diff --git a/lang/tr/common.php b/lang/tr/common.php
index 294ef152..6ea72d0d 100644
--- a/lang/tr/common.php
+++ b/lang/tr/common.php
@@ -21,6 +21,11 @@
'uploading' => 'Yükleniyor...',
'remove' => 'Fotoğrafı kaldır',
'hint' => 'Önerilen: kare görsel, en fazla 2 MB.',
+ 'crop_title' => 'Görseli kırp',
+ 'crop_description' => 'Çerçevelemek için sürükleyip yakınlaştırın.',
+ 'crop_hint' => 'Yeniden konumlandırmak için sürükleyin',
+ 'crop_save' => 'Kaydet',
+ 'crop_cancel' => 'İptal',
],
'timezone' => [
diff --git a/lang/zh/common.php b/lang/zh/common.php
index b16eb072..29a7e066 100644
--- a/lang/zh/common.php
+++ b/lang/zh/common.php
@@ -21,6 +21,11 @@
'uploading' => '上传中…',
'remove' => '移除照片',
'hint' => '推荐:正方形图片,最大 2 MB。',
+ 'crop_title' => '裁剪图片',
+ 'crop_description' => '拖动并缩放以取景。',
+ 'crop_hint' => '拖动以重新定位',
+ 'crop_save' => '保存',
+ 'crop_cancel' => '取消',
],
'timezone' => [
diff --git a/package-lock.json b/package-lock.json
index 3941c5b3..51f067e3 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -55,6 +55,7 @@
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-vue": "^9.32.0",
"laravel-echo": "^2.3.0",
+ "playwright": "^1.61.1",
"prettier": "^3.4.2",
"prettier-plugin-organize-imports": "^4.1.0",
"prettier-plugin-tailwindcss": "^0.6.11",
@@ -1448,9 +1449,6 @@
"cpu": [
"arm64"
],
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1467,9 +1465,6 @@
"cpu": [
"arm64"
],
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1486,9 +1481,6 @@
"cpu": [
"ppc64"
],
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1505,9 +1497,6 @@
"cpu": [
"s390x"
],
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1524,9 +1513,6 @@
"cpu": [
"x64"
],
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1543,9 +1529,6 @@
"cpu": [
"x64"
],
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1855,9 +1838,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1875,9 +1855,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1894,9 +1871,6 @@
"cpu": [
"x64"
],
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1914,9 +1888,6 @@
"x64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -7204,9 +7175,6 @@
"cpu": [
"arm64"
],
- "libc": [
- "glibc"
- ],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -7227,9 +7195,6 @@
"cpu": [
"arm64"
],
- "libc": [
- "musl"
- ],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -7250,9 +7215,6 @@
"cpu": [
"x64"
],
- "libc": [
- "glibc"
- ],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -7273,9 +7235,6 @@
"cpu": [
"x64"
],
- "libc": [
- "musl"
- ],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -7866,6 +7825,53 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
+ "node_modules/playwright": {
+ "version": "1.61.1",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz",
+ "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright-core": "1.61.1"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "fsevents": "2.3.2"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.61.1",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz",
+ "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/playwright/node_modules/fsevents": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
"node_modules/possible-typed-array-names": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
diff --git a/package.json b/package.json
index 9f650655..5920e3a0 100644
--- a/package.json
+++ b/package.json
@@ -28,6 +28,7 @@
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-vue": "^9.32.0",
"laravel-echo": "^2.3.0",
+ "playwright": "^1.61.1",
"prettier": "^3.4.2",
"prettier-plugin-organize-imports": "^4.1.0",
"prettier-plugin-tailwindcss": "^0.6.11",
diff --git a/resources/js/components/ImageCropperDialog.vue b/resources/js/components/ImageCropperDialog.vue
new file mode 100644
index 00000000..a19e7d6e
--- /dev/null
+++ b/resources/js/components/ImageCropperDialog.vue
@@ -0,0 +1,283 @@
+
+
+
+
+
diff --git a/resources/js/components/PhotoUpload.vue b/resources/js/components/PhotoUpload.vue
index 3be75a6f..3e46ebfc 100644
--- a/resources/js/components/PhotoUpload.vue
+++ b/resources/js/components/PhotoUpload.vue
@@ -2,8 +2,9 @@
import { router } from '@inertiajs/vue3';
import { IconTrash } from '@tabler/icons-vue';
import { trans } from 'laravel-vue-i18n';
-import { ref } from 'vue';
+import { computed, ref } from 'vue';
+import ImageCropperDialog from '@/components/ImageCropperDialog.vue';
import { Avatar } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import {
@@ -34,6 +35,13 @@ const props = withDefaults(defineProps(), {
const fileInput = ref(null);
const uploading = ref(false);
+const cropOpen = ref(false);
+const cropSrc = ref(null);
+const cropFileName = ref('image.png');
+const cropMime = ref('image/png');
+
+const cropShape = computed<'circle' | 'square'>(() => (props.rounded === 'full' ? 'circle' : 'square'));
+
const sizeClasses = {
sm: 'size-16',
md: 'size-20',
@@ -63,6 +71,22 @@ const handleFileChange = (event: Event) => {
return;
}
+ cropFileName.value = file.name || 'image.png';
+ cropMime.value = file.type || 'image/png';
+
+ const reader = new FileReader();
+ reader.onload = () => {
+ cropSrc.value = reader.result as string;
+ cropOpen.value = true;
+ };
+ reader.readAsDataURL(file);
+
+ if (fileInput.value) {
+ fileInput.value.value = '';
+ }
+};
+
+const uploadCropped = (file: File) => {
uploading.value = true;
router.post(
@@ -72,9 +96,7 @@ const handleFileChange = (event: Event) => {
forceFormData: true,
onFinish: () => {
uploading.value = false;
- if (fileInput.value) {
- fileInput.value.value = '';
- }
+ cropSrc.value = null;
},
},
);
@@ -140,5 +162,14 @@ const handleDelete = () => {
{{ $t('common.photo_upload.hint') }}
+
+
diff --git a/resources/js/lib/imageCrop.ts b/resources/js/lib/imageCrop.ts
new file mode 100644
index 00000000..77bbc4c2
--- /dev/null
+++ b/resources/js/lib/imageCrop.ts
@@ -0,0 +1,81 @@
+export type CropTransform = {
+ scale: number;
+ x: number;
+ y: number;
+};
+
+export type SourceRect = {
+ sx: number;
+ sy: number;
+ sw: number;
+ sh: number;
+};
+
+export const coverScale = (naturalWidth: number, naturalHeight: number, viewport: number): number => {
+ if (naturalWidth <= 0 || naturalHeight <= 0) {
+ return 1;
+ }
+
+ return Math.max(viewport / naturalWidth, viewport / naturalHeight);
+};
+
+export const clampTransform = (
+ transform: CropTransform,
+ naturalWidth: number,
+ naturalHeight: number,
+ viewport: number,
+): CropTransform => {
+ const scale = Math.max(transform.scale, coverScale(naturalWidth, naturalHeight, viewport));
+ const displayWidth = naturalWidth * scale;
+ const displayHeight = naturalHeight * scale;
+
+ const x = Math.min(0, Math.max(viewport - displayWidth, transform.x));
+ const y = Math.min(0, Math.max(viewport - displayHeight, transform.y));
+
+ return { scale, x, y };
+};
+
+export const centerTransform = (naturalWidth: number, naturalHeight: number, viewport: number): CropTransform => {
+ const scale = coverScale(naturalWidth, naturalHeight, viewport);
+
+ return {
+ scale,
+ x: (viewport - naturalWidth * scale) / 2,
+ y: (viewport - naturalHeight * scale) / 2,
+ };
+};
+
+export const zoomTransform = (
+ transform: CropTransform,
+ factor: number,
+ naturalWidth: number,
+ naturalHeight: number,
+ viewport: number,
+): CropTransform => {
+ const nextScale = Math.max(transform.scale * factor, coverScale(naturalWidth, naturalHeight, viewport));
+ const center = viewport / 2;
+ const sourceX = (center - transform.x) / transform.scale;
+ const sourceY = (center - transform.y) / transform.scale;
+
+ return clampTransform(
+ {
+ scale: nextScale,
+ x: center - sourceX * nextScale,
+ y: center - sourceY * nextScale,
+ },
+ naturalWidth,
+ naturalHeight,
+ viewport,
+ );
+};
+
+export const viewportToSource = (transform: CropTransform, viewport: number): SourceRect => {
+ const size = viewport / transform.scale;
+
+ return {
+ sx: -transform.x / transform.scale,
+ sy: -transform.y / transform.scale,
+ sw: size,
+ sh: size,
+ };
+};
diff --git a/tests/Browser/ImageCropperTest.php b/tests/Browser/ImageCropperTest.php
new file mode 100644
index 00000000..846c8711
--- /dev/null
+++ b/tests/Browser/ImageCropperTest.php
@@ -0,0 +1,89 @@
+script(<< {
+ const findInput = () => document.querySelector('input[type="file"]');
+ for (let attempt = 0; attempt < 50 && !findInput(); attempt++) {
+ await new Promise((resolve) => setTimeout(resolve, 100));
+ }
+ const input = findInput();
+ const bytes = Uint8Array.from(atob('{$base64}'), (character) => character.charCodeAt(0));
+ const file = new File([bytes], 'logo.png', { type: 'image/png' });
+ const data = new DataTransfer();
+ data.items.add(file);
+ input.files = data.files;
+ input.dispatchEvent(new Event('change', { bubbles: true }));
+ })();
+ JS);
+}
+
+/**
+ * Capture the next multipart request the page sends. The Pest browser server
+ * does not parse multipart bodies (its file handling is an open TODO), so we
+ * assert the crop dispatches the right upload rather than that it persists —
+ * server-side persistence is covered by ProfileUpdateTest.
+ */
+function recordUpload(mixed $page): void
+{
+ $page->script(<<<'JS'
+ (() => {
+ window.__uploadRequest = null;
+ const open = XMLHttpRequest.prototype.open;
+ const send = XMLHttpRequest.prototype.send;
+ XMLHttpRequest.prototype.open = function (method, url) {
+ this.__method = method;
+ this.__url = url;
+ return open.apply(this, arguments);
+ };
+ XMLHttpRequest.prototype.send = function (body) {
+ if (body instanceof FormData) {
+ window.__uploadRequest = { method: this.__method, url: this.__url, keys: [...body.keys()] };
+ }
+ return send.apply(this, arguments);
+ };
+ })();
+ JS);
+}
+
+test('cropping a selected photo dispatches a cropped avatar upload', function () {
+ $this->actingAs(User::factory()->create());
+
+ $page = visit(route('app.profile.edit'));
+
+ selectPhoto($page);
+ recordUpload($page);
+
+ // Auto-waits for the cropper dialog to open and its Save button to become
+ // enabled — the button only enables once the image has loaded and been
+ // measured inside the modal, which is exactly what a broken cropper fails.
+ $page->click('@crop-save')
+ ->assertNoJavaScriptErrors();
+
+ $request = json_decode((string) $page->script(<<<'JS'
+ (async () => {
+ for (let attempt = 0; attempt < 80 && !window.__uploadRequest; attempt++) {
+ await new Promise((resolve) => setTimeout(resolve, 100));
+ }
+ return JSON.stringify(window.__uploadRequest);
+ })();
+ JS), true);
+
+ expect($request)->not->toBeNull()
+ ->and($request['method'])->toBe('POST')
+ ->and($request['url'])->toContain('/settings/profile/photo')
+ ->and($request['keys'])->toContain('photo');
+});
diff --git a/tests/Browser/ProbeTest.php b/tests/Browser/ProbeTest.php
new file mode 100644
index 00000000..a7d32640
--- /dev/null
+++ b/tests/Browser/ProbeTest.php
@@ -0,0 +1,41 @@
+create();
+ $this->actingAs($user);
+ $base64 = base64_encode((string) file_get_contents(base_path('tests/fixtures/blue-logo.png')));
+
+ $page = visit(route('app.profile.edit'));
+ $page->script(<< {
+ const findInput = () => document.querySelector('input[type="file"]');
+ for (let i = 0; i < 50 && !findInput(); i++) { await new Promise(r => setTimeout(r, 100)); }
+ const input = findInput();
+ const bytes = Uint8Array.from(atob('{$base64}'), (c) => c.charCodeAt(0));
+ const file = new File([bytes], 'logo.png', { type: 'image/png' });
+ const dt = new DataTransfer(); dt.items.add(file);
+ input.files = dt.files;
+ input.dispatchEvent(new Event('change', { bubbles: true }));
+ })();
+ JS);
+ $page->script(<<<'JS'
+ (() => {
+ window.__resp = null;
+ const oOpen = XMLHttpRequest.prototype.open;
+ XMLHttpRequest.prototype.open = function (m, u) { this.__u = u; this.addEventListener('loadend', () => {
+ if (String(this.__u).includes('/photo')) window.__resp = { status: this.status, body: (this.responseText || '').slice(0, 300) };
+ }); return oOpen.apply(this, arguments); };
+ })();
+ JS);
+ $page->click('@crop-save');
+ $info = $page->script(<<<'JS'
+ (async () => { for (let i = 0; i < 80 && !window.__resp; i++) { await new Promise(r => setTimeout(r, 100)); } return JSON.stringify(window.__resp || 'NO RESPONSE'); })();
+ JS);
+ fwrite(STDERR, "\nUPLOAD_RESP => {$info}\n");
+ fwrite(STDERR, 'DB_HAS_PHOTO => '.json_encode($user->fresh()->has_photo)."\n");
+ expect(true)->toBeTrue();
+});
diff --git a/tests/Pest.php b/tests/Pest.php
index 251d4171..9e58793a 100644
--- a/tests/Pest.php
+++ b/tests/Pest.php
@@ -24,7 +24,7 @@
pest()->extend(TestCase::class)
->use(RefreshDatabase::class)
- ->in('Feature', 'Unit');
+ ->in('Feature', 'Unit', 'Browser');
/*
|--------------------------------------------------------------------------
diff --git a/tests/TestCase.php b/tests/TestCase.php
index 3312b582..09b229fc 100644
--- a/tests/TestCase.php
+++ b/tests/TestCase.php
@@ -21,6 +21,17 @@ protected function setUp(): void
{
parent::setUp();
- $this->withoutVite();
+ if (! $this->isBrowserTest()) {
+ $this->withoutVite();
+ }
+ }
+
+ /**
+ * Browser tests drive a real browser and need the built Vite assets, so the
+ * Vite manifest must not be faked away for them.
+ */
+ private function isBrowserTest(): bool
+ {
+ return str_contains(static::class, '\\Browser\\');
}
}
From 2549a82e9b7156ce41b1eacb5e330c221a20ea00 Mon Sep 17 00:00:00 2001
From: Paulo Castellano
Date: Fri, 3 Jul 2026 22:07:23 -0300
Subject: [PATCH 2/8] Address review: cropper mime/zoom/error fixes, harden
browser test
- Output a canvas-encodable mime (jpeg/png/webp, else png) and keep the File's
name/extension in sync, so non-encodable input (gif/svg/heic) no longer ships
PNG bytes mislabeled as the original type.
- Clamp zoom to a maximum (8x cover) so scrolling in can't collapse the crop to
a sub-pixel region.
- Handle undecodable/zero-dimension images (@error + naturalWidth guard) with a
crop_error message instead of a permanently-disabled Save.
- Replace the str_contains(static::class) Vite heuristic with a dedicated
BrowserTestCase ($fakesVite = false).
- The browser test now decodes the dispatched blob and asserts a 512x512 image,
and uses route(..., absolute: false) instead of a hardcoded path.
- Remove tests/Browser/ProbeTest.php (committed debug scratch).
---
lang/ar/common.php | 1 +
lang/de/common.php | 1 +
lang/el/common.php | 1 +
lang/en/common.php | 1 +
lang/es/common.php | 1 +
lang/fr/common.php | 1 +
lang/it/common.php | 1 +
lang/ja/common.php | 1 +
lang/ko/common.php | 1 +
lang/nl/common.php | 1 +
lang/pl/common.php | 1 +
lang/pt-BR/common.php | 1 +
lang/ru/common.php | 1 +
lang/tr/common.php | 1 +
lang/zh/common.php | 1 +
.../js/components/ImageCropperDialog.vue | 36 ++++++++++++++--
resources/js/lib/imageCrop.ts | 5 ++-
tests/Browser/ImageCropperTest.php | 36 ++++++++++------
tests/Browser/ProbeTest.php | 41 -------------------
tests/BrowserTestCase.php | 14 +++++++
tests/Pest.php | 7 +++-
tests/TestCase.php | 17 ++++----
22 files changed, 103 insertions(+), 68 deletions(-)
delete mode 100644 tests/Browser/ProbeTest.php
create mode 100644 tests/BrowserTestCase.php
diff --git a/lang/ar/common.php b/lang/ar/common.php
index ee82031c..85e9f7f0 100644
--- a/lang/ar/common.php
+++ b/lang/ar/common.php
@@ -26,6 +26,7 @@
'crop_hint' => 'اسحب لإعادة التموضع',
'crop_save' => 'حفظ',
'crop_cancel' => 'إلغاء',
+ 'crop_error' => 'تعذّر تحميل هذه الصورة. جرّب ملفًا آخر.',
],
'timezone' => [
diff --git a/lang/de/common.php b/lang/de/common.php
index d14036e6..15b2e67e 100644
--- a/lang/de/common.php
+++ b/lang/de/common.php
@@ -26,6 +26,7 @@
'crop_hint' => 'Zum Verschieben ziehen',
'crop_save' => 'Speichern',
'crop_cancel' => 'Abbrechen',
+ 'crop_error' => 'Dieses Bild konnte nicht geladen werden. Versuchen Sie eine andere Datei.',
],
'timezone' => [
diff --git a/lang/el/common.php b/lang/el/common.php
index 66633824..00f7733a 100644
--- a/lang/el/common.php
+++ b/lang/el/common.php
@@ -26,6 +26,7 @@
'crop_hint' => 'Σύρετε για επανατοποθέτηση',
'crop_save' => 'Αποθήκευση',
'crop_cancel' => 'Άκυρο',
+ 'crop_error' => 'Δεν ήταν δυνατή η φόρτωση αυτής της εικόνας. Δοκιμάστε άλλο αρχείο.',
],
'timezone' => [
diff --git a/lang/en/common.php b/lang/en/common.php
index 0fbe653f..294d55a6 100644
--- a/lang/en/common.php
+++ b/lang/en/common.php
@@ -26,6 +26,7 @@
'crop_hint' => 'Drag to reposition',
'crop_save' => 'Save',
'crop_cancel' => 'Cancel',
+ 'crop_error' => 'Couldn\'t load this image. Try another file.',
],
'timezone' => [
diff --git a/lang/es/common.php b/lang/es/common.php
index d513ff5b..4947c817 100644
--- a/lang/es/common.php
+++ b/lang/es/common.php
@@ -26,6 +26,7 @@
'crop_hint' => 'Arrastra para reposicionar',
'crop_save' => 'Guardar',
'crop_cancel' => 'Cancelar',
+ 'crop_error' => 'No se pudo cargar esta imagen. Prueba con otro archivo.',
],
'timezone' => [
diff --git a/lang/fr/common.php b/lang/fr/common.php
index 6c167327..d893ced6 100644
--- a/lang/fr/common.php
+++ b/lang/fr/common.php
@@ -26,6 +26,7 @@
'crop_hint' => 'Faites glisser pour repositionner',
'crop_save' => 'Enregistrer',
'crop_cancel' => 'Annuler',
+ 'crop_error' => 'Impossible de charger cette image. Essayez un autre fichier.',
],
'timezone' => [
diff --git a/lang/it/common.php b/lang/it/common.php
index 367ce4de..438ee268 100644
--- a/lang/it/common.php
+++ b/lang/it/common.php
@@ -26,6 +26,7 @@
'crop_hint' => 'Trascina per riposizionare',
'crop_save' => 'Salva',
'crop_cancel' => 'Annulla',
+ 'crop_error' => 'Impossibile caricare questa immagine. Prova un altro file.',
],
'timezone' => [
diff --git a/lang/ja/common.php b/lang/ja/common.php
index 40551cef..43de5380 100644
--- a/lang/ja/common.php
+++ b/lang/ja/common.php
@@ -26,6 +26,7 @@
'crop_hint' => 'ドラッグして移動',
'crop_save' => '保存',
'crop_cancel' => 'キャンセル',
+ 'crop_error' => 'この画像を読み込めませんでした。別のファイルをお試しください。',
],
'timezone' => [
diff --git a/lang/ko/common.php b/lang/ko/common.php
index 9c434b1c..eaaf3249 100644
--- a/lang/ko/common.php
+++ b/lang/ko/common.php
@@ -26,6 +26,7 @@
'crop_hint' => '드래그하여 위치 조정',
'crop_save' => '저장',
'crop_cancel' => '취소',
+ 'crop_error' => '이 이미지를 불러올 수 없습니다. 다른 파일을 사용해 보세요.',
],
'timezone' => [
diff --git a/lang/nl/common.php b/lang/nl/common.php
index 74491bba..5ae09ecb 100644
--- a/lang/nl/common.php
+++ b/lang/nl/common.php
@@ -26,6 +26,7 @@
'crop_hint' => 'Sleep om te verplaatsen',
'crop_save' => 'Opslaan',
'crop_cancel' => 'Annuleren',
+ 'crop_error' => 'Kan deze afbeelding niet laden. Probeer een ander bestand.',
],
'timezone' => [
diff --git a/lang/pl/common.php b/lang/pl/common.php
index 5d298c0d..30b2470e 100644
--- a/lang/pl/common.php
+++ b/lang/pl/common.php
@@ -26,6 +26,7 @@
'crop_hint' => 'Przeciągnij, aby zmienić położenie',
'crop_save' => 'Zapisz',
'crop_cancel' => 'Anuluj',
+ 'crop_error' => 'Nie można załadować tego obrazu. Spróbuj innego pliku.',
],
'timezone' => [
diff --git a/lang/pt-BR/common.php b/lang/pt-BR/common.php
index dc059548..0bc91663 100644
--- a/lang/pt-BR/common.php
+++ b/lang/pt-BR/common.php
@@ -26,6 +26,7 @@
'crop_hint' => 'Arraste para reposicionar',
'crop_save' => 'Salvar',
'crop_cancel' => 'Cancelar',
+ 'crop_error' => 'Não foi possível carregar esta imagem. Tente outro arquivo.',
],
'timezone' => [
diff --git a/lang/ru/common.php b/lang/ru/common.php
index 0895c276..c4720bb6 100644
--- a/lang/ru/common.php
+++ b/lang/ru/common.php
@@ -26,6 +26,7 @@
'crop_hint' => 'Перетащите, чтобы переместить',
'crop_save' => 'Сохранить',
'crop_cancel' => 'Отмена',
+ 'crop_error' => 'Не удалось загрузить это изображение. Попробуйте другой файл.',
],
'timezone' => [
diff --git a/lang/tr/common.php b/lang/tr/common.php
index 6ea72d0d..eddef71c 100644
--- a/lang/tr/common.php
+++ b/lang/tr/common.php
@@ -26,6 +26,7 @@
'crop_hint' => 'Yeniden konumlandırmak için sürükleyin',
'crop_save' => 'Kaydet',
'crop_cancel' => 'İptal',
+ 'crop_error' => 'Bu görsel yüklenemedi. Başka bir dosya deneyin.',
],
'timezone' => [
diff --git a/lang/zh/common.php b/lang/zh/common.php
index 29a7e066..6f6fa51b 100644
--- a/lang/zh/common.php
+++ b/lang/zh/common.php
@@ -26,6 +26,7 @@
'crop_hint' => '拖动以重新定位',
'crop_save' => '保存',
'crop_cancel' => '取消',
+ 'crop_error' => '无法加载此图片。请尝试其他文件。',
],
'timezone' => [
diff --git a/resources/js/components/ImageCropperDialog.vue b/resources/js/components/ImageCropperDialog.vue
index a19e7d6e..c16d326f 100644
--- a/resources/js/components/ImageCropperDialog.vue
+++ b/resources/js/components/ImageCropperDialog.vue
@@ -47,15 +47,25 @@ const natural = ref({ width: 0, height: 0 });
const transform = ref({ scale: 1, x: 0, y: 0 });
const processing = ref(false);
const initialized = ref(false);
+const imageError = ref(false);
let dragPointerId: number | null = null;
let dragStart = { pointerX: 0, pointerY: 0, x: 0, y: 0 };
let resizeObserver: ResizeObserver | null = null;
+const encodableMimes = ['image/jpeg', 'image/png', 'image/webp'];
+const extensions: Record = { 'image/jpeg': 'jpg', 'image/png': 'png', 'image/webp': 'webp' };
+
const ready = computed(() => viewportSize.value > 0 && natural.value.width > 0);
const maskClass = computed(() => (props.shape === 'square' ? 'rounded-lg' : 'rounded-full'));
+const outputMime = computed(() => (encodableMimes.includes(props.mimeType) ? props.mimeType : 'image/png'));
+
+const outputFileName = computed(
+ () => `${props.fileName.replace(/\.[^./]+$/, '') || 'image'}.${extensions[outputMime.value]}`,
+);
+
const imageStyle = computed(() => ({
width: `${natural.value.width * transform.value.scale}px`,
height: `${natural.value.height * transform.value.scale}px`,
@@ -94,10 +104,20 @@ const onImageLoad = () => {
return;
}
+ if (img.naturalWidth === 0 || img.naturalHeight === 0) {
+ imageError.value = true;
+
+ return;
+ }
+
natural.value = { width: img.naturalWidth, height: img.naturalHeight };
maybeInitialize();
};
+const onImageError = () => {
+ imageError.value = true;
+};
+
const onPointerDown = (event: PointerEvent) => {
if (!ready.value) {
return;
@@ -187,10 +207,10 @@ const save = () => {
return;
}
- emit('cropped', new File([blob], props.fileName, { type: props.mimeType }));
+ emit('cropped', new File([blob], outputFileName.value, { type: outputMime.value }));
close();
},
- props.mimeType,
+ outputMime.value,
0.92,
);
};
@@ -201,6 +221,7 @@ watch(
if (isOpen) {
initialized.value = false;
processing.value = false;
+ imageError.value = false;
await nextTick();
measure();
@@ -219,6 +240,7 @@ watch(
() => props.src,
() => {
initialized.value = false;
+ imageError.value = false;
natural.value = { width: 0, height: 0 };
},
);
@@ -244,7 +266,7 @@ onBeforeUnmount(() => resizeObserver?.disconnect());
@wheel="onWheel"
>
resizeObserver?.disconnect());
class="absolute left-0 top-0 max-w-none"
:style="imageStyle"
@load="onImageLoad"
+ @error="onImageError"
/>
+ {{ $t('common.photo_upload.crop_error') }}
+
+ {
if (naturalWidth <= 0 || naturalHeight <= 0) {
return 1;
@@ -52,7 +54,8 @@ export const zoomTransform = (
naturalHeight: number,
viewport: number,
): CropTransform => {
- const nextScale = Math.max(transform.scale * factor, coverScale(naturalWidth, naturalHeight, viewport));
+ const minScale = coverScale(naturalWidth, naturalHeight, viewport);
+ const nextScale = Math.min(minScale * MAX_ZOOM, Math.max(transform.scale * factor, minScale));
const center = viewport / 2;
const sourceX = (center - transform.x) / transform.scale;
const sourceY = (center - transform.y) / transform.scale;
diff --git a/tests/Browser/ImageCropperTest.php b/tests/Browser/ImageCropperTest.php
index 846c8711..6377707a 100644
--- a/tests/Browser/ImageCropperTest.php
+++ b/tests/Browser/ImageCropperTest.php
@@ -32,10 +32,11 @@ function selectPhoto(mixed $page): void
}
/**
- * Capture the next multipart request the page sends. The Pest browser server
- * does not parse multipart bodies (its file handling is an open TODO), so we
- * assert the crop dispatches the right upload rather than that it persists —
- * server-side persistence is covered by ProfileUpdateTest.
+ * Capture the next multipart upload the page sends, keeping the uploaded File so
+ * the test can decode it. The Pest browser server does not parse multipart
+ * bodies (its file handling is an open TODO), so we assert the crop dispatches
+ * a valid image rather than that it persists — persistence is covered by
+ * ProfileUpdateTest.
*/
function recordUpload(mixed $page): void
{
@@ -51,7 +52,14 @@ function recordUpload(mixed $page): void
};
XMLHttpRequest.prototype.send = function (body) {
if (body instanceof FormData) {
- window.__uploadRequest = { method: this.__method, url: this.__url, keys: [...body.keys()] };
+ const photo = body.get('photo');
+ window.__uploadFile = photo;
+ window.__uploadRequest = {
+ method: this.__method,
+ url: this.__url,
+ keys: [...body.keys()],
+ size: photo instanceof File ? photo.size : 0,
+ };
}
return send.apply(this, arguments);
};
@@ -59,7 +67,7 @@ function recordUpload(mixed $page): void
JS);
}
-test('cropping a selected photo dispatches a cropped avatar upload', function () {
+test('cropping a selected photo dispatches a valid 512x512 avatar upload', function () {
$this->actingAs(User::factory()->create());
$page = visit(route('app.profile.edit'));
@@ -67,9 +75,6 @@ function recordUpload(mixed $page): void
selectPhoto($page);
recordUpload($page);
- // Auto-waits for the cropper dialog to open and its Save button to become
- // enabled — the button only enables once the image has loaded and been
- // measured inside the modal, which is exactly what a broken cropper fails.
$page->click('@crop-save')
->assertNoJavaScriptErrors();
@@ -78,12 +83,19 @@ function recordUpload(mixed $page): void
for (let attempt = 0; attempt < 80 && !window.__uploadRequest; attempt++) {
await new Promise((resolve) => setTimeout(resolve, 100));
}
- return JSON.stringify(window.__uploadRequest);
+ if (!window.__uploadRequest) {
+ return 'null';
+ }
+ const bitmap = await createImageBitmap(window.__uploadFile);
+ return JSON.stringify({ ...window.__uploadRequest, width: bitmap.width, height: bitmap.height });
})();
JS), true);
expect($request)->not->toBeNull()
->and($request['method'])->toBe('POST')
- ->and($request['url'])->toContain('/settings/profile/photo')
- ->and($request['keys'])->toContain('photo');
+ ->and($request['url'])->toContain(route('app.profile.upload-photo', absolute: false))
+ ->and($request['keys'])->toContain('photo')
+ ->and($request['size'])->toBeGreaterThan(0)
+ ->and($request['width'])->toBe(512)
+ ->and($request['height'])->toBe(512);
});
diff --git a/tests/Browser/ProbeTest.php b/tests/Browser/ProbeTest.php
deleted file mode 100644
index a7d32640..00000000
--- a/tests/Browser/ProbeTest.php
+++ /dev/null
@@ -1,41 +0,0 @@
-create();
- $this->actingAs($user);
- $base64 = base64_encode((string) file_get_contents(base_path('tests/fixtures/blue-logo.png')));
-
- $page = visit(route('app.profile.edit'));
- $page->script(<<
{
- const findInput = () => document.querySelector('input[type="file"]');
- for (let i = 0; i < 50 && !findInput(); i++) { await new Promise(r => setTimeout(r, 100)); }
- const input = findInput();
- const bytes = Uint8Array.from(atob('{$base64}'), (c) => c.charCodeAt(0));
- const file = new File([bytes], 'logo.png', { type: 'image/png' });
- const dt = new DataTransfer(); dt.items.add(file);
- input.files = dt.files;
- input.dispatchEvent(new Event('change', { bubbles: true }));
- })();
- JS);
- $page->script(<<<'JS'
- (() => {
- window.__resp = null;
- const oOpen = XMLHttpRequest.prototype.open;
- XMLHttpRequest.prototype.open = function (m, u) { this.__u = u; this.addEventListener('loadend', () => {
- if (String(this.__u).includes('/photo')) window.__resp = { status: this.status, body: (this.responseText || '').slice(0, 300) };
- }); return oOpen.apply(this, arguments); };
- })();
- JS);
- $page->click('@crop-save');
- $info = $page->script(<<<'JS'
- (async () => { for (let i = 0; i < 80 && !window.__resp; i++) { await new Promise(r => setTimeout(r, 100)); } return JSON.stringify(window.__resp || 'NO RESPONSE'); })();
- JS);
- fwrite(STDERR, "\nUPLOAD_RESP => {$info}\n");
- fwrite(STDERR, 'DB_HAS_PHOTO => '.json_encode($user->fresh()->has_photo)."\n");
- expect(true)->toBeTrue();
-});
diff --git a/tests/BrowserTestCase.php b/tests/BrowserTestCase.php
new file mode 100644
index 00000000..479adf35
--- /dev/null
+++ b/tests/BrowserTestCase.php
@@ -0,0 +1,14 @@
+extend(TestCase::class)
->use(RefreshDatabase::class)
- ->in('Feature', 'Unit', 'Browser');
+ ->in('Feature', 'Unit');
+
+pest()->extend(BrowserTestCase::class)
+ ->use(RefreshDatabase::class)
+ ->in('Browser');
/*
|--------------------------------------------------------------------------
diff --git a/tests/TestCase.php b/tests/TestCase.php
index 09b229fc..388d375c 100644
--- a/tests/TestCase.php
+++ b/tests/TestCase.php
@@ -17,21 +17,18 @@ abstract class TestCase extends BaseTestCase
*/
protected $seed = true;
+ /**
+ * Whether to fake the Vite manifest. Browser tests drive a real browser and
+ * need the built assets, so they opt out via BrowserTestCase.
+ */
+ protected bool $fakesVite = true;
+
protected function setUp(): void
{
parent::setUp();
- if (! $this->isBrowserTest()) {
+ if ($this->fakesVite) {
$this->withoutVite();
}
}
-
- /**
- * Browser tests drive a real browser and need the built Vite assets, so the
- * Vite manifest must not be faked away for them.
- */
- private function isBrowserTest(): bool
- {
- return str_contains(static::class, '\\Browser\\');
- }
}
From 36ccc570e02042356f7588931788a0b941a99868 Mon Sep 17 00:00:00 2001
From: Paulo Castellano
Date: Sat, 4 Jul 2026 09:56:31 -0300
Subject: [PATCH 3/8] Rewrite the image cropper as a movable/resizable
selection box
Replace the move-image-behind-a-fixed-frame model with a selection box over the fully visible (contain-fit) image: drag to move, corner handles to resize, locked to 1:1 for the square avatar/logo output. The default selection is inset so the crop outline is always visible, handles sit inside the frame so they are never clipped, and wheel/pinch zoom is swallowed. Reset drag state on open/close, guard pointer capture and multi-touch, and fail-safe the canvas encode. Update the crop copy in all 15 locales to match the selection model.
---
lang/ar/common.php | 4 +-
lang/de/common.php | 4 +-
lang/el/common.php | 4 +-
lang/en/common.php | 4 +-
lang/es/common.php | 4 +-
lang/fr/common.php | 4 +-
lang/it/common.php | 4 +-
lang/ja/common.php | 4 +-
lang/ko/common.php | 4 +-
lang/nl/common.php | 4 +-
lang/pl/common.php | 4 +-
lang/pt-BR/common.php | 4 +-
lang/ru/common.php | 4 +-
lang/tr/common.php | 4 +-
lang/zh/common.php | 4 +-
.../js/components/ImageCropperDialog.vue | 245 +++++++++++-------
resources/js/components/PhotoUpload.vue | 10 +-
resources/js/lib/imageCrop.ts | 108 ++++----
18 files changed, 236 insertions(+), 187 deletions(-)
diff --git a/lang/ar/common.php b/lang/ar/common.php
index 85e9f7f0..e99f2356 100644
--- a/lang/ar/common.php
+++ b/lang/ar/common.php
@@ -22,8 +22,8 @@
'remove' => 'إزالة الصورة',
'hint' => 'موصى به: صورة مربعة، بحد أقصى 2 ميغابايت.',
'crop_title' => 'قص الصورة',
- 'crop_description' => 'اسحب وكبّر لتأطيرها.',
- 'crop_hint' => 'اسحب لإعادة التموضع',
+ 'crop_description' => 'حدّد المنطقة التي تريد الاحتفاظ بها.',
+ 'crop_hint' => 'اسحب للتحريك، أو زاوية لتغيير الحجم.',
'crop_save' => 'حفظ',
'crop_cancel' => 'إلغاء',
'crop_error' => 'تعذّر تحميل هذه الصورة. جرّب ملفًا آخر.',
diff --git a/lang/de/common.php b/lang/de/common.php
index 15b2e67e..4e6d4b99 100644
--- a/lang/de/common.php
+++ b/lang/de/common.php
@@ -22,8 +22,8 @@
'remove' => 'Foto entfernen',
'hint' => 'Empfohlen: quadratisches Bild, max. 2 MB.',
'crop_title' => 'Bild zuschneiden',
- 'crop_description' => 'Ziehen und zoomen, um es auszurichten.',
- 'crop_hint' => 'Zum Verschieben ziehen',
+ 'crop_description' => 'Wählen Sie den Bereich aus, den Sie behalten möchten.',
+ 'crop_hint' => 'Zum Verschieben ziehen oder an einer Ecke die Größe ändern.',
'crop_save' => 'Speichern',
'crop_cancel' => 'Abbrechen',
'crop_error' => 'Dieses Bild konnte nicht geladen werden. Versuchen Sie eine andere Datei.',
diff --git a/lang/el/common.php b/lang/el/common.php
index 00f7733a..c18cb1a1 100644
--- a/lang/el/common.php
+++ b/lang/el/common.php
@@ -22,8 +22,8 @@
'remove' => 'Αφαίρεση φωτογραφίας',
'hint' => 'Συνιστάται: τετράγωνη εικόνα, έως 2 MB.',
'crop_title' => 'Περικοπή εικόνας',
- 'crop_description' => 'Σύρετε και κάντε ζουμ για πλαισίωση.',
- 'crop_hint' => 'Σύρετε για επανατοποθέτηση',
+ 'crop_description' => 'Επιλέξτε την περιοχή που θέλετε να κρατήσετε.',
+ 'crop_hint' => 'Σύρετε για μετακίνηση ή μια γωνία για αλλαγή μεγέθους.',
'crop_save' => 'Αποθήκευση',
'crop_cancel' => 'Άκυρο',
'crop_error' => 'Δεν ήταν δυνατή η φόρτωση αυτής της εικόνας. Δοκιμάστε άλλο αρχείο.',
diff --git a/lang/en/common.php b/lang/en/common.php
index 294d55a6..6bda1664 100644
--- a/lang/en/common.php
+++ b/lang/en/common.php
@@ -22,8 +22,8 @@
'remove' => 'Remove photo',
'hint' => 'Recommended: square image, max 2 MB.',
'crop_title' => 'Crop image',
- 'crop_description' => 'Drag and zoom to frame it.',
- 'crop_hint' => 'Drag to reposition',
+ 'crop_description' => 'Select the area you want to keep.',
+ 'crop_hint' => 'Drag to move, or a corner to resize.',
'crop_save' => 'Save',
'crop_cancel' => 'Cancel',
'crop_error' => 'Couldn\'t load this image. Try another file.',
diff --git a/lang/es/common.php b/lang/es/common.php
index 4947c817..2a532649 100644
--- a/lang/es/common.php
+++ b/lang/es/common.php
@@ -22,8 +22,8 @@
'remove' => 'Eliminar foto',
'hint' => 'Recomendado: imagen cuadrada, máximo 2 MB.',
'crop_title' => 'Recortar imagen',
- 'crop_description' => 'Arrastra y haz zoom para encuadrarla.',
- 'crop_hint' => 'Arrastra para reposicionar',
+ 'crop_description' => 'Selecciona el área que quieres conservar.',
+ 'crop_hint' => 'Arrastra para mover o una esquina para redimensionar.',
'crop_save' => 'Guardar',
'crop_cancel' => 'Cancelar',
'crop_error' => 'No se pudo cargar esta imagen. Prueba con otro archivo.',
diff --git a/lang/fr/common.php b/lang/fr/common.php
index d893ced6..2531f3ea 100644
--- a/lang/fr/common.php
+++ b/lang/fr/common.php
@@ -22,8 +22,8 @@
'remove' => 'Supprimer la photo',
'hint' => 'Recommandé : image carrée, 2 Mo maximum.',
'crop_title' => 'Recadrer l\'image',
- 'crop_description' => 'Faites glisser et zoomez pour cadrer.',
- 'crop_hint' => 'Faites glisser pour repositionner',
+ 'crop_description' => 'Sélectionnez la zone à conserver.',
+ 'crop_hint' => 'Faites glisser pour déplacer, ou un coin pour redimensionner.',
'crop_save' => 'Enregistrer',
'crop_cancel' => 'Annuler',
'crop_error' => 'Impossible de charger cette image. Essayez un autre fichier.',
diff --git a/lang/it/common.php b/lang/it/common.php
index 438ee268..1d7cf87a 100644
--- a/lang/it/common.php
+++ b/lang/it/common.php
@@ -22,8 +22,8 @@
'remove' => 'Rimuovi foto',
'hint' => 'Consigliato: immagine quadrata, max 2 MB.',
'crop_title' => 'Ritaglia immagine',
- 'crop_description' => 'Trascina e ingrandisci per inquadrarla.',
- 'crop_hint' => 'Trascina per riposizionare',
+ 'crop_description' => 'Seleziona l\'area da mantenere.',
+ 'crop_hint' => 'Trascina per spostare o un angolo per ridimensionare.',
'crop_save' => 'Salva',
'crop_cancel' => 'Annulla',
'crop_error' => 'Impossibile caricare questa immagine. Prova un altro file.',
diff --git a/lang/ja/common.php b/lang/ja/common.php
index 43de5380..539f41fe 100644
--- a/lang/ja/common.php
+++ b/lang/ja/common.php
@@ -22,8 +22,8 @@
'remove' => '写真を削除',
'hint' => '推奨: 正方形の画像、最大 2 MB。',
'crop_title' => '画像を切り抜く',
- 'crop_description' => 'ドラッグとズームで位置を調整します。',
- 'crop_hint' => 'ドラッグして移動',
+ 'crop_description' => '残したい範囲を選択してください。',
+ 'crop_hint' => 'ドラッグで移動、角でサイズ変更できます。',
'crop_save' => '保存',
'crop_cancel' => 'キャンセル',
'crop_error' => 'この画像を読み込めませんでした。別のファイルをお試しください。',
diff --git a/lang/ko/common.php b/lang/ko/common.php
index eaaf3249..ef309ea0 100644
--- a/lang/ko/common.php
+++ b/lang/ko/common.php
@@ -22,8 +22,8 @@
'remove' => '사진 제거',
'hint' => '권장: 정사각형 이미지, 최대 2MB.',
'crop_title' => '이미지 자르기',
- 'crop_description' => '드래그하고 확대/축소하여 맞추세요.',
- 'crop_hint' => '드래그하여 위치 조정',
+ 'crop_description' => '남길 영역을 선택하세요.',
+ 'crop_hint' => '드래그하여 이동하거나 모서리로 크기를 조절하세요.',
'crop_save' => '저장',
'crop_cancel' => '취소',
'crop_error' => '이 이미지를 불러올 수 없습니다. 다른 파일을 사용해 보세요.',
diff --git a/lang/nl/common.php b/lang/nl/common.php
index 5ae09ecb..6046682e 100644
--- a/lang/nl/common.php
+++ b/lang/nl/common.php
@@ -22,8 +22,8 @@
'remove' => 'Foto verwijderen',
'hint' => 'Aanbevolen: vierkante afbeelding, max. 2 MB.',
'crop_title' => 'Afbeelding bijsnijden',
- 'crop_description' => 'Sleep en zoom om uit te lijnen.',
- 'crop_hint' => 'Sleep om te verplaatsen',
+ 'crop_description' => 'Selecteer het gebied dat je wilt behouden.',
+ 'crop_hint' => 'Sleep om te verplaatsen of een hoek om te vergroten of verkleinen.',
'crop_save' => 'Opslaan',
'crop_cancel' => 'Annuleren',
'crop_error' => 'Kan deze afbeelding niet laden. Probeer een ander bestand.',
diff --git a/lang/pl/common.php b/lang/pl/common.php
index 30b2470e..d4b0e2db 100644
--- a/lang/pl/common.php
+++ b/lang/pl/common.php
@@ -22,8 +22,8 @@
'remove' => 'Usuń zdjęcie',
'hint' => 'Zalecane: kwadratowe zdjęcie, maks. 2 MB.',
'crop_title' => 'Przytnij obraz',
- 'crop_description' => 'Przeciągnij i powiększ, aby wykadrować.',
- 'crop_hint' => 'Przeciągnij, aby zmienić położenie',
+ 'crop_description' => 'Zaznacz obszar, który chcesz zachować.',
+ 'crop_hint' => 'Przeciągnij, aby przesunąć, lub róg, aby zmienić rozmiar.',
'crop_save' => 'Zapisz',
'crop_cancel' => 'Anuluj',
'crop_error' => 'Nie można załadować tego obrazu. Spróbuj innego pliku.',
diff --git a/lang/pt-BR/common.php b/lang/pt-BR/common.php
index 0bc91663..5c41d709 100644
--- a/lang/pt-BR/common.php
+++ b/lang/pt-BR/common.php
@@ -22,8 +22,8 @@
'remove' => 'Remover foto',
'hint' => 'Recomendado: imagem quadrada, máximo 2 MB.',
'crop_title' => 'Cortar imagem',
- 'crop_description' => 'Arraste e dê zoom para enquadrar.',
- 'crop_hint' => 'Arraste para reposicionar',
+ 'crop_description' => 'Selecione a área que deseja manter.',
+ 'crop_hint' => 'Arraste para mover ou um canto para redimensionar.',
'crop_save' => 'Salvar',
'crop_cancel' => 'Cancelar',
'crop_error' => 'Não foi possível carregar esta imagem. Tente outro arquivo.',
diff --git a/lang/ru/common.php b/lang/ru/common.php
index c4720bb6..aecc5a2c 100644
--- a/lang/ru/common.php
+++ b/lang/ru/common.php
@@ -22,8 +22,8 @@
'remove' => 'Удалить фото',
'hint' => 'Рекомендуется: квадратное изображение, до 2 МБ.',
'crop_title' => 'Обрезать изображение',
- 'crop_description' => 'Перетащите и масштабируйте для кадрирования.',
- 'crop_hint' => 'Перетащите, чтобы переместить',
+ 'crop_description' => 'Выберите область, которую нужно оставить.',
+ 'crop_hint' => 'Перетащите, чтобы переместить, или угол — чтобы изменить размер.',
'crop_save' => 'Сохранить',
'crop_cancel' => 'Отмена',
'crop_error' => 'Не удалось загрузить это изображение. Попробуйте другой файл.',
diff --git a/lang/tr/common.php b/lang/tr/common.php
index eddef71c..f73011a5 100644
--- a/lang/tr/common.php
+++ b/lang/tr/common.php
@@ -22,8 +22,8 @@
'remove' => 'Fotoğrafı kaldır',
'hint' => 'Önerilen: kare görsel, en fazla 2 MB.',
'crop_title' => 'Görseli kırp',
- 'crop_description' => 'Çerçevelemek için sürükleyip yakınlaştırın.',
- 'crop_hint' => 'Yeniden konumlandırmak için sürükleyin',
+ 'crop_description' => 'Saklamak istediğiniz alanı seçin.',
+ 'crop_hint' => 'Taşımak için sürükleyin veya boyutlandırmak için bir köşeyi sürükleyin.',
'crop_save' => 'Kaydet',
'crop_cancel' => 'İptal',
'crop_error' => 'Bu görsel yüklenemedi. Başka bir dosya deneyin.',
diff --git a/lang/zh/common.php b/lang/zh/common.php
index 6f6fa51b..1eeb850b 100644
--- a/lang/zh/common.php
+++ b/lang/zh/common.php
@@ -22,8 +22,8 @@
'remove' => '移除照片',
'hint' => '推荐:正方形图片,最大 2 MB。',
'crop_title' => '裁剪图片',
- 'crop_description' => '拖动并缩放以取景。',
- 'crop_hint' => '拖动以重新定位',
+ 'crop_description' => '选择要保留的区域。',
+ 'crop_hint' => '拖动以移动,拖动边角可调整大小。',
'crop_save' => '保存',
'crop_cancel' => '取消',
'crop_error' => '无法加载此图片。请尝试其他文件。',
diff --git a/resources/js/components/ImageCropperDialog.vue b/resources/js/components/ImageCropperDialog.vue
index c16d326f..90b2fb2f 100644
--- a/resources/js/components/ImageCropperDialog.vue
+++ b/resources/js/components/ImageCropperDialog.vue
@@ -1,5 +1,4 @@