From 4566d3ab3e29b77b32810e71f6d93f906faf5c7e Mon Sep 17 00:00:00 2001 From: Bas van Dinther Date: Fri, 12 Dec 2025 12:49:39 +0100 Subject: [PATCH 01/29] feat: media optimizations (#7) * Squashed 'packages/filament-uploadcare-field/' content from commit 34c1516 git-subtree-dir: packages/filament-uploadcare-field git-subtree-split: 34c15165cfe7d938bdd1a77db2b1a2b01551b7b6 * Squashed 'packages/uploadcare-field/' content from commit a994f38 git-subtree-dir: packages/uploadcare-field git-subtree-split: a994f3896d2f93608a518261885576f3c0c31c57 * import media package changes * import uploadcare-field changes * import core changes * build uploadcare assets * fix icon path * packages * add missing files * columnSpanFull for first alt * rename alt tag to alt text * remove original filename and set filename as modal heading * add translations to the media model * fix: styling * wip it * fix: styling * remove dupe notification * fix: styling * improve media picker * media grid picker improvements * fix: styling * feat: check on file types and allow multiple * fix: keyvalue nested arrays * fix uploading files * backstage specific listmedia * custom uploadcare src attribute * fix: styling * remove code * fix: nested array values * fix: styling * fix imports * feat: add database view * fix: styling * feat: add image preview in edit mode and show array values correct * fix: translations in UC * remove hardcoded lang * fix: styling * alignCenter * decouple Uploadcare from core packages * fix: styling * this needs testing: check if saving works correctly * feat: allow hydrating values from custom fields * give custom fields priority over normal fields * fix handling nested fields in repeater/builder * fix: styling * reduce redundant code * wip * fix: styling * return Media model * getMimeTypeAttribute * fix: styling * cast metadata to array * fix: styling * wip * convert data to media_relationships * relationships and hydrating * observer and define relation * fix: styling * rename usages to edits * load edits for media * `getEditAttribute` * `getEditAttribute` * fix: styling * disable future mediaPicker * fix: styling * return relation * fix manoj * wip * remove media resource * fix: styling * disable broken translations in edit form * fix file upload by uploadcare * extends functionality from the media baseclass * fix translations * hide translate button when no openai key set * file icons for previews * fix: styling * dont encode metadata * use correct md5 hashes for normal media and uploadcare * fix: styling * docs: hydrating fields * docs: custom mediaupload using events * fix: media grid picker and cleanup Uploadcare code * styles: darkmode issues * fix migration * wip * fix: styling * fix: defining extension * fix: styling * fix: saving crop doesnt always saves cdnUrlModifiers * fix: load values when getting field * wip * wip * fix: styling * wip * fix: styling * fix: order of executing * fix: load site relation * fix: styling * wip * wip it * fix composer.json * test: trigger workflow * test: trigger workflow * self.version * fix incorrect config key * get nested configs * wip * fix phpstan issues --------- Co-authored-by: Baspa <10845460+Baspa@users.noreply.github.com> Co-authored-by: Mathieu --- README.md | 51 + ...1_normalize_uploadcare_values_to_ulids.php | 160 ++ package-lock.json | 2013 +++++++++++++++++ package.json | 19 + resources/css/index.css | 5 + resources/dist/uploadcare-field.css | 2 + .../components/media-grid-picker.blade.php | 76 + .../livewire/media-grid-picker.blade.php | 121 + src/Forms/Components/MediaGridPicker.php | 66 + src/Listeners/CreateMediaFromUploadcare.php | 172 ++ src/Livewire/MediaGridPicker.php | 175 ++ src/Observers/ContentFieldValueObserver.php | 244 ++ src/Uploadcare.php | 295 ++- src/UploadcareFieldServiceProvider.php | 43 +- tailwind.config.js | 11 + tests/MediaGridPickerTest.php | 80 + 16 files changed, 3511 insertions(+), 22 deletions(-) create mode 100644 database/migrations/2025_12_08_163311_normalize_uploadcare_values_to_ulids.php create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 resources/css/index.css create mode 100644 resources/dist/uploadcare-field.css create mode 100644 resources/views/forms/components/media-grid-picker.blade.php create mode 100644 resources/views/livewire/media-grid-picker.blade.php create mode 100644 src/Forms/Components/MediaGridPicker.php create mode 100644 src/Listeners/CreateMediaFromUploadcare.php create mode 100644 src/Livewire/MediaGridPicker.php create mode 100644 src/Observers/ContentFieldValueObserver.php create mode 100644 tailwind.config.js create mode 100644 tests/MediaGridPickerTest.php diff --git a/README.md b/README.md index f036445..3c64303 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,57 @@ return [ ]; ``` +### CSS and Styling + +This package includes a MediaGridPicker component that requires Tailwind CSS classes to be properly compiled. The package automatically registers its CSS assets with Filament, but you may need to ensure your main application's Tailwind build includes the package's source files. + +If you're using Tailwind CSS v4 in your main application, add the package's source directories to your `resources/css/sources.css` file: + +```css +@source "/path/to/backstage-uploadcare-field/resources/"; +@source "/path/to/backstage-uploadcare-field/src/"; +``` + +For Tailwind CSS v3, add the package paths to your `tailwind.config.js`: + +```javascript +module.exports = { + content: [ + // ... your existing paths + './vendor/backstage/uploadcare-field/resources/**/*.blade.php', + './vendor/backstage/uploadcare-field/src/**/*.php', + ], + // ... rest of your config +} +``` + +The package's CSS is automatically loaded in Filament admin panels and includes all necessary styles for the MediaGridPicker component. + +### MediaGridPicker Integration + +The package includes a MediaGridPicker component that allows users to select existing media files from the media library and add them directly to Uploadcare fields. This feature is automatically available when using uploadcare fields in Filament forms. + +**How it works:** +1. When editing content with uploadcare fields, a "Select from Media" button appears next to the field +2. Clicking this button opens a modal with a grid of existing media files +3. Selecting a media file automatically adds it to the Uploadcare field +4. The integration uses Alpine.js events and JavaScript to communicate between the MediaGridPicker and Uploadcare components + +**Technical details:** +- The MediaGridPicker dispatches an `add-uploadcare-file` event when a file is selected +- The package's JavaScript listens for this event and attempts to add the file to the Uploadcare field +- Multiple fallback methods are used to ensure compatibility with different Uploadcare configurations: + 1. **Direct Uploadcare API**: Tries to use the Uploadcare widget's API to add files + 2. **Livewire State Management**: Updates the Livewire component state directly + 3. **Hidden Input Fields**: Sets values on hidden input fields and triggers events + 4. **File Object Creation**: Attempts to create File objects from CDN URLs + 5. **Generic Input Fields**: Falls back to setting values on any matching input fields + +**Debugging:** +- The JavaScript includes comprehensive console logging to help debug integration issues +- Check the browser console for detailed information about which methods are being attempted +- The system will log which Uploadcare elements are found and which methods succeed or fail + ## Automatic Migration This package includes an automatic migration that fixes double-encoded JSON data in Uploadcare fields. This migration runs automatically when the package is installed or updated. diff --git a/database/migrations/2025_12_08_163311_normalize_uploadcare_values_to_ulids.php b/database/migrations/2025_12_08_163311_normalize_uploadcare_values_to_ulids.php new file mode 100644 index 0000000..8a7a3aa --- /dev/null +++ b/database/migrations/2025_12_08_163311_normalize_uploadcare_values_to_ulids.php @@ -0,0 +1,160 @@ +whereIn('field_type', ['uploadcare', 'builder', 'repeater']) + ->pluck('ulid'); + + if ($targetFieldIds->isEmpty()) { + return; + } + + $firstSiteUlid = DB::table('sites')->orderBy('ulid')->value('ulid'); + + $processValue = function (&$data, $siteUlid, $rowUlid) use (&$processValue) { + $anyModified = false; + + if (! is_array($data)) { + return false; + } + + $isRawUploadcareList = false; + if (! empty($data) && isset($data[0]) && is_array($data[0]) && isset($data[0]['uuid'])) { + $isRawUploadcareList = true; + } + + $isAlreadyUlidList = false; + if (! empty($data) && isset($data[0]) && is_string($data[0]) && strlen($data[0]) === 26) { + $isAlreadyUlidList = true; + foreach ($data as $item) { + if (! is_string($item) || strlen($item) !== 26) { + $isAlreadyUlidList = false; + + break; + } + } + } + + if ($isRawUploadcareList) { + $newUlids = []; + foreach ($data as $fileData) { + $uuid = $fileData['uuid']; + + $media = Media::where('filename', $uuid)->first(); + + if (! $media) { + $media = new Media; + $media->ulid = (string) Str::ulid(); + $media->site_ulid = $siteUlid; + $media->disk = 'uploadcare'; + $media->filename = $uuid; + $info = $fileData['fileInfo'] ?? $fileData; + $detailedInfo = $info['imageInfo'] ?? $info['videoInfo'] ?? $info['contentInfo'] ?? []; + + $media->extension = $detailedInfo['format'] + ?? pathinfo($info['originalFilename'] ?? $info['name'] ?? '', PATHINFO_EXTENSION); + + $media->original_filename = $info['originalFilename'] ?? $info['original_filename'] ?? $info['name'] ?? 'unknown'; + $media->mime_type = $info['mimeType'] ?? $info['mime_type'] ?? 'application/octet-stream'; + $media->size = $info['size'] ?? 0; + $media->public = true; + $media->metadata = $info; + + $media->checksum = md5($uuid); + $media->save(); + } + $newUlids[] = $media->ulid; + + DB::table('media_relationships')->insertOrIgnore([ + 'media_ulid' => $media->ulid, + 'model_type' => 'content_field_value', + 'model_id' => $rowUlid, + 'meta' => json_encode($fileData), + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + $data = $newUlids; + + return true; + } + + if ($isAlreadyUlidList) { + foreach ($data as $mediaUlid) { + $media = Media::where('ulid', $mediaUlid)->first(); + if ($media) { + DB::table('media_relationships')->insertOrIgnore([ + 'media_ulid' => $media->ulid, + 'model_type' => 'content_field_value', + 'model_id' => $rowUlid, + 'meta' => json_encode($media->metadata), + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + } + + return false; + } + + foreach ($data as $key => &$value) { + if (is_array($value)) { + if ($processValue($value, $siteUlid, $rowUlid)) { + $anyModified = true; + } + } elseif (is_string($value)) { + if (str_starts_with($value, '[') || str_starts_with($value, '{')) { + $decoded = json_decode($value, true); + if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) { + if ($processValue($decoded, $siteUlid, $rowUlid)) { + $value = $decoded; + $anyModified = true; + } + } + } + } + } + + return $anyModified; + }; + + DB::table('content_field_values') + ->whereIn('field_ulid', $targetFieldIds) + ->chunkById(50, function ($rows) use ($processValue, $firstSiteUlid) { + foreach ($rows as $row) { + $value = $row->value; + $decoded = json_decode($value, true); + + if (is_string($decoded)) { + $decoded = json_decode($decoded, true); + } + + if (! is_array($decoded)) { + continue; + } + + // Use row's site_ulid if available, otherwise fallback to first site + $siteUlid = $row->site_ulid ?? $firstSiteUlid; + + if ($processValue($decoded, $siteUlid, $row->ulid)) { + DB::table('content_field_values') + ->where('ulid', $row->ulid) + ->update(['value' => json_encode($decoded)]); + } + } + }, 'ulid'); + } + + public function down(): void + { + // + } +}; diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..8c4e35d --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2013 @@ +{ + "name": "uploadcare-field", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "devDependencies": { + "@awcodes/filament-plugin-purge": "^1.1.1", + "@tailwindcss/cli": "^4.1.11", + "@tailwindcss/forms": "^0.5.4", + "@tailwindcss/typography": "^0.5.9", + "prettier": "^3.0.0", + "prettier-plugin-tailwindcss": "^0.6.13", + "tailwindcss": "^4.1.11" + } + }, + "node_modules/@awcodes/filament-plugin-purge": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@awcodes/filament-plugin-purge/-/filament-plugin-purge-1.1.2.tgz", + "integrity": "sha512-eFFGA3IPSya8ldUQWUMHk5HxidU/XnL3fEGIdX6Lza/bz4U7hgOdGT64CxLKbhEF1eFJbM7hFsxAfrfZm85x5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "axios": "^1.4.0", + "chalk": "^5.0.1", + "css-tree": "^2.2.1", + "ora": "^6.1.2" + }, + "bin": { + "filament-purge": "filament-purge.js" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@parcel/watcher": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz", + "integrity": "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "detect-libc": "^1.0.3", + "is-glob": "^4.0.3", + "micromatch": "^4.0.5", + "node-addon-api": "^7.0.0" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.5.1", + "@parcel/watcher-darwin-arm64": "2.5.1", + "@parcel/watcher-darwin-x64": "2.5.1", + "@parcel/watcher-freebsd-x64": "2.5.1", + "@parcel/watcher-linux-arm-glibc": "2.5.1", + "@parcel/watcher-linux-arm-musl": "2.5.1", + "@parcel/watcher-linux-arm64-glibc": "2.5.1", + "@parcel/watcher-linux-arm64-musl": "2.5.1", + "@parcel/watcher-linux-x64-glibc": "2.5.1", + "@parcel/watcher-linux-x64-musl": "2.5.1", + "@parcel/watcher-win32-arm64": "2.5.1", + "@parcel/watcher-win32-ia32": "2.5.1", + "@parcel/watcher-win32-x64": "2.5.1" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.1.tgz", + "integrity": "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.1.tgz", + "integrity": "sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.1.tgz", + "integrity": "sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.1.tgz", + "integrity": "sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.1.tgz", + "integrity": "sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.1.tgz", + "integrity": "sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.1.tgz", + "integrity": "sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.1.tgz", + "integrity": "sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.1.tgz", + "integrity": "sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.1.tgz", + "integrity": "sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.1.tgz", + "integrity": "sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-ia32": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.1.tgz", + "integrity": "sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.1.tgz", + "integrity": "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/cli": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/cli/-/cli-4.1.17.tgz", + "integrity": "sha512-jUIxcyUNlCC2aNPnyPEWU/L2/ik3pB4fF3auKGXr8AvN3T3OFESVctFKOBoPZQaZJIeUpPn1uCLp0MRxuek8gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@parcel/watcher": "^2.5.1", + "@tailwindcss/node": "4.1.17", + "@tailwindcss/oxide": "4.1.17", + "enhanced-resolve": "^5.18.3", + "mri": "^1.2.0", + "picocolors": "^1.1.1", + "tailwindcss": "4.1.17" + }, + "bin": { + "tailwindcss": "dist/index.mjs" + } + }, + "node_modules/@tailwindcss/forms": { + "version": "0.5.10", + "resolved": "https://registry.npmjs.org/@tailwindcss/forms/-/forms-0.5.10.tgz", + "integrity": "sha512-utI1ONF6uf/pPNO68kmN1b8rEwNXv3czukalo8VtJH8ksIkZXr3Q3VYudZLkCsDd4Wku120uF02hYK25XGPorw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mini-svg-data-uri": "^1.2.3" + }, + "peerDependencies": { + "tailwindcss": ">=3.0.0 || >= 3.0.0-alpha.1 || >= 4.0.0-alpha.20 || >= 4.0.0-beta.1" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.17.tgz", + "integrity": "sha512-csIkHIgLb3JisEFQ0vxr2Y57GUNYh447C8xzwj89U/8fdW8LhProdxvnVH6U8M2Y73QKiTIH+LWbK3V2BBZsAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "enhanced-resolve": "^5.18.3", + "jiti": "^2.6.1", + "lightningcss": "1.30.2", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.1.17" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.17.tgz", + "integrity": "sha512-F0F7d01fmkQhsTjXezGBLdrl1KresJTcI3DB8EkScCldyKp3Msz4hub4uyYaVnk88BAS1g5DQjjF6F5qczheLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.1.17", + "@tailwindcss/oxide-darwin-arm64": "4.1.17", + "@tailwindcss/oxide-darwin-x64": "4.1.17", + "@tailwindcss/oxide-freebsd-x64": "4.1.17", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.17", + "@tailwindcss/oxide-linux-arm64-gnu": "4.1.17", + "@tailwindcss/oxide-linux-arm64-musl": "4.1.17", + "@tailwindcss/oxide-linux-x64-gnu": "4.1.17", + "@tailwindcss/oxide-linux-x64-musl": "4.1.17", + "@tailwindcss/oxide-wasm32-wasi": "4.1.17", + "@tailwindcss/oxide-win32-arm64-msvc": "4.1.17", + "@tailwindcss/oxide-win32-x64-msvc": "4.1.17" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.17.tgz", + "integrity": "sha512-BMqpkJHgOZ5z78qqiGE6ZIRExyaHyuxjgrJ6eBO5+hfrfGkuya0lYfw8fRHG77gdTjWkNWEEm+qeG2cDMxArLQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.17.tgz", + "integrity": "sha512-EquyumkQweUBNk1zGEU/wfZo2qkp/nQKRZM8bUYO0J+Lums5+wl2CcG1f9BgAjn/u9pJzdYddHWBiFXJTcxmOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.17.tgz", + "integrity": "sha512-gdhEPLzke2Pog8s12oADwYu0IAw04Y2tlmgVzIN0+046ytcgx8uZmCzEg4VcQh+AHKiS7xaL8kGo/QTiNEGRog==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.17.tgz", + "integrity": "sha512-hxGS81KskMxML9DXsaXT1H0DyA+ZBIbyG/sSAjWNe2EDl7TkPOBI42GBV3u38itzGUOmFfCzk1iAjDXds8Oh0g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.17.tgz", + "integrity": "sha512-k7jWk5E3ldAdw0cNglhjSgv501u7yrMf8oeZ0cElhxU6Y2o7f8yqelOp3fhf7evjIS6ujTI3U8pKUXV2I4iXHQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.17.tgz", + "integrity": "sha512-HVDOm/mxK6+TbARwdW17WrgDYEGzmoYayrCgmLEw7FxTPLcp/glBisuyWkFz/jb7ZfiAXAXUACfyItn+nTgsdQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.17.tgz", + "integrity": "sha512-HvZLfGr42i5anKtIeQzxdkw/wPqIbpeZqe7vd3V9vI3RQxe3xU1fLjss0TjyhxWcBaipk7NYwSrwTwK1hJARMg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.17.tgz", + "integrity": "sha512-M3XZuORCGB7VPOEDH+nzpJ21XPvK5PyjlkSFkFziNHGLc5d6g3di2McAAblmaSUNl8IOmzYwLx9NsE7bplNkwQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.17.tgz", + "integrity": "sha512-k7f+pf9eXLEey4pBlw+8dgfJHY4PZ5qOUFDyNf7SI6lHjQ9Zt7+NcscjpwdCEbYi6FI5c2KDTDWyf2iHcCSyyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.17.tgz", + "integrity": "sha512-cEytGqSSoy7zK4JRWiTCx43FsKP/zGr0CsuMawhH67ONlH+T79VteQeJQRO/X7L0juEUA8ZyuYikcRBf0vsxhg==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.6.0", + "@emnapi/runtime": "^1.6.0", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.0.7", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.17.tgz", + "integrity": "sha512-JU5AHr7gKbZlOGvMdb4722/0aYbU+tN6lv1kONx0JK2cGsh7g148zVWLM0IKR3NeKLv+L90chBVYcJ8uJWbC9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.17.tgz", + "integrity": "sha512-SKWM4waLuqx0IH+FMDUw6R66Hu4OuTALFgnleKbqhgGU30DY20NORZMZUKgLRjQXNN2TLzKvh48QXTig4h4bGw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/typography": { + "version": "0.5.19", + "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.19.tgz", + "integrity": "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "6.0.10" + }, + "peerDependencies": { + "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", + "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bl": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-5.1.0.tgz", + "integrity": "sha512-tv1ZJHLfTDnXE6tMHv73YgSJaWR2AFuPwMntBe7XL/GBFHnT0CLnsHMogfk5+GzCDC5ZWarSCYaIGATZt9dNsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer": "^6.0.3", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/cli-cursor": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-4.0.0.tgz", + "integrity": "sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==", + "dev": true, + "license": "MIT", + "dependencies": { + "restore-cursor": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-spinners": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", + "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/clone": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", + "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/css-tree": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.3.1.tgz", + "integrity": "sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.0.30", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/defaults": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", + "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "clone": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/detect-libc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", + "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "detect-libc": "bin/detect-libc.js" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.18.3", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz", + "integrity": "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.2.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-interactive": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz", + "integrity": "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-unicode-supported": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-1.3.0.tgz", + "integrity": "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/lightningcss": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", + "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.30.2", + "lightningcss-darwin-arm64": "1.30.2", + "lightningcss-darwin-x64": "1.30.2", + "lightningcss-freebsd-x64": "1.30.2", + "lightningcss-linux-arm-gnueabihf": "1.30.2", + "lightningcss-linux-arm64-gnu": "1.30.2", + "lightningcss-linux-arm64-musl": "1.30.2", + "lightningcss-linux-x64-gnu": "1.30.2", + "lightningcss-linux-x64-musl": "1.30.2", + "lightningcss-win32-arm64-msvc": "1.30.2", + "lightningcss-win32-x64-msvc": "1.30.2" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz", + "integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz", + "integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz", + "integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz", + "integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz", + "integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz", + "integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz", + "integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz", + "integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz", + "integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz", + "integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz", + "integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss/node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/log-symbols": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-5.1.0.tgz", + "integrity": "sha512-l0x2DvrW294C9uDCoQe1VSU4gf529FkSZ6leBl4TiqZH/e+0R7hSfHQBNut2mNygDgHwvYHfFLn6Oxb3VWj2rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^5.0.0", + "is-unicode-supported": "^1.1.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mdn-data": { + "version": "2.0.30", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.30.tgz", + "integrity": "sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/mini-svg-data-uri": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/mini-svg-data-uri/-/mini-svg-data-uri-1.4.4.tgz", + "integrity": "sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==", + "dev": true, + "license": "MIT", + "bin": { + "mini-svg-data-uri": "cli.js" + } + }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/ora/-/ora-6.3.1.tgz", + "integrity": "sha512-ERAyNnZOfqM+Ao3RAvIXkYh5joP220yf59gVe2X/cI6SiCxIdi4c9HZKZD8R6q/RDXEje1THBju6iExiSsgJaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^5.0.0", + "cli-cursor": "^4.0.0", + "cli-spinners": "^2.6.1", + "is-interactive": "^2.0.0", + "is-unicode-supported": "^1.1.0", + "log-symbols": "^5.1.0", + "stdin-discarder": "^0.1.0", + "strip-ansi": "^7.0.1", + "wcwidth": "^1.0.1" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.0.10", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz", + "integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/prettier": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", + "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prettier-plugin-tailwindcss": { + "version": "0.6.14", + "resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.6.14.tgz", + "integrity": "sha512-pi2e/+ZygeIqntN+vC573BcW5Cve8zUB0SSAGxqpB4f96boZF4M3phPVoOFCeypwkpRYdi7+jQ5YJJUwrkGUAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.21.3" + }, + "peerDependencies": { + "@ianvs/prettier-plugin-sort-imports": "*", + "@prettier/plugin-hermes": "*", + "@prettier/plugin-oxc": "*", + "@prettier/plugin-pug": "*", + "@shopify/prettier-plugin-liquid": "*", + "@trivago/prettier-plugin-sort-imports": "*", + "@zackad/prettier-plugin-twig": "*", + "prettier": "^3.0", + "prettier-plugin-astro": "*", + "prettier-plugin-css-order": "*", + "prettier-plugin-import-sort": "*", + "prettier-plugin-jsdoc": "*", + "prettier-plugin-marko": "*", + "prettier-plugin-multiline-arrays": "*", + "prettier-plugin-organize-attributes": "*", + "prettier-plugin-organize-imports": "*", + "prettier-plugin-sort-imports": "*", + "prettier-plugin-style-order": "*", + "prettier-plugin-svelte": "*" + }, + "peerDependenciesMeta": { + "@ianvs/prettier-plugin-sort-imports": { + "optional": true + }, + "@prettier/plugin-hermes": { + "optional": true + }, + "@prettier/plugin-oxc": { + "optional": true + }, + "@prettier/plugin-pug": { + "optional": true + }, + "@shopify/prettier-plugin-liquid": { + "optional": true + }, + "@trivago/prettier-plugin-sort-imports": { + "optional": true + }, + "@zackad/prettier-plugin-twig": { + "optional": true + }, + "prettier-plugin-astro": { + "optional": true + }, + "prettier-plugin-css-order": { + "optional": true + }, + "prettier-plugin-import-sort": { + "optional": true + }, + "prettier-plugin-jsdoc": { + "optional": true + }, + "prettier-plugin-marko": { + "optional": true + }, + "prettier-plugin-multiline-arrays": { + "optional": true + }, + "prettier-plugin-organize-attributes": { + "optional": true + }, + "prettier-plugin-organize-imports": { + "optional": true + }, + "prettier-plugin-sort-imports": { + "optional": true + }, + "prettier-plugin-style-order": { + "optional": true + }, + "prettier-plugin-svelte": { + "optional": true + } + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "dev": true, + "license": "MIT" + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/restore-cursor": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-4.0.0.tgz", + "integrity": "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==", + "dev": true, + "license": "MIT", + "dependencies": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stdin-discarder": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.1.0.tgz", + "integrity": "sha512-xhV7w8S+bUwlPTb4bAOUQhv8/cSS5offJuX8GQGq32ONF0ZtDWKfkdomM3HMRA+LhX6um/FZ0COqlwsjD53LeQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bl": "^5.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/tailwindcss": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.17.tgz", + "integrity": "sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/wcwidth": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", + "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", + "dev": true, + "license": "MIT", + "dependencies": { + "defaults": "^1.0.3" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..3c90566 --- /dev/null +++ b/package.json @@ -0,0 +1,19 @@ +{ + "private": true, + "type": "module", + "scripts": { + "dev:styles": "npx @tailwindcss/cli --input resources/css/index.css --output resources/dist/uploadcare-field.css --watch", + "build:styles": "npx @tailwindcss/cli --input resources/css/index.css --output resources/dist/uploadcare-field.css --minify", + "dev": "npm run dev:styles", + "build": "npm run build:styles" + }, + "devDependencies": { + "@awcodes/filament-plugin-purge": "^1.1.1", + "@tailwindcss/cli": "^4.1.11", + "@tailwindcss/forms": "^0.5.4", + "@tailwindcss/typography": "^0.5.9", + "prettier": "^3.0.0", + "prettier-plugin-tailwindcss": "^0.6.13", + "tailwindcss": "^4.1.11" + } +} \ No newline at end of file diff --git a/resources/css/index.css b/resources/css/index.css new file mode 100644 index 0000000..27dc141 --- /dev/null +++ b/resources/css/index.css @@ -0,0 +1,5 @@ +@import "tailwindcss"; +@config "../../tailwind.config.js"; + +@source '../../src/**/*.php'; +@source '../../resources/views/**/*.blade.php'; \ No newline at end of file diff --git a/resources/dist/uploadcare-field.css b/resources/dist/uploadcare-field.css new file mode 100644 index 0000000..e48fbd2 --- /dev/null +++ b/resources/dist/uploadcare-field.css @@ -0,0 +1,2 @@ +/*! tailwindcss v4.1.17 | MIT License | https://tailwindcss.com */ +@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-space-y-reverse:0;--tw-space-x-reverse:0;--tw-border-style:solid;--tw-font-weight:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-duration:initial}}}@layer theme{:root,:host{--font-sans:ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--font-mono:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;--color-blue-200:oklch(88.2% .059 254.128);--color-blue-500:oklch(62.3% .214 259.815);--color-gray-50:oklch(98.5% .002 247.839);--color-gray-100:oklch(96.7% .003 264.542);--color-gray-200:oklch(92.8% .006 264.531);--color-gray-300:oklch(87.2% .01 258.338);--color-gray-400:oklch(70.7% .022 261.325);--color-gray-500:oklch(55.1% .027 264.364);--color-gray-600:oklch(44.6% .03 256.802);--color-gray-700:oklch(37.3% .034 259.733);--color-gray-800:oklch(27.8% .033 256.848);--color-gray-900:oklch(21% .034 264.665);--color-white:#fff;--spacing:.25rem;--text-xs:.75rem;--text-xs--line-height:calc(1/.75);--text-sm:.875rem;--text-sm--line-height:calc(1.25/.875);--font-weight-medium:500;--radius-md:.375rem;--radius-lg:.5rem;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4,0,.2,1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.visible{visibility:visible}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.static{position:static}.top-1{top:calc(var(--spacing)*1)}.right-1{right:calc(var(--spacing)*1)}.col-span-full{grid-column:1/-1}.mx-auto{margin-inline:auto}.mb-4{margin-bottom:calc(var(--spacing)*4)}.contents{display:contents}.flex{display:flex}.grid{display:grid}.hidden{display:none}.table{display:table}.aspect-square{aspect-ratio:1}.h-3{height:calc(var(--spacing)*3)}.h-5{height:calc(var(--spacing)*5)}.h-8{height:calc(var(--spacing)*8)}.h-12{height:calc(var(--spacing)*12)}.h-full{height:100%}.max-h-96{max-height:calc(var(--spacing)*96)}.w-3{width:calc(var(--spacing)*3)}.w-5{width:calc(var(--spacing)*5)}.w-8{width:calc(var(--spacing)*8)}.w-12{width:calc(var(--spacing)*12)}.w-full{width:100%}.flex-1{flex:1}.cursor-pointer{cursor:pointer}.resize{resize:both}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.items-center{align-items:center}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.gap-3{gap:calc(var(--spacing)*3)}.gap-4{gap:calc(var(--spacing)*4)}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*4)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*4)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-x-2>:not(:last-child)){--tw-space-x-reverse:0;margin-inline-start:calc(calc(var(--spacing)*2)*var(--tw-space-x-reverse));margin-inline-end:calc(calc(var(--spacing)*2)*calc(1 - var(--tw-space-x-reverse)))}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-hidden{overflow:hidden}.overflow-y-auto{overflow-y:auto}.rounded{border-radius:.25rem}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-md{border-radius:var(--radius-md)}.border{border-style:var(--tw-border-style);border-width:1px}.border-2{border-style:var(--tw-border-style);border-width:2px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-blue-500{border-color:var(--color-blue-500)}.border-gray-200{border-color:var(--color-gray-200)}.border-gray-300{border-color:var(--color-gray-300)}.bg-blue-500{background-color:var(--color-blue-500)}.bg-gray-100{background-color:var(--color-gray-100)}.bg-white{background-color:var(--color-white)}.object-cover{object-fit:cover}.p-2{padding:calc(var(--spacing)*2)}.px-2{padding-inline:calc(var(--spacing)*2)}.px-3{padding-inline:calc(var(--spacing)*3)}.py-1{padding-block:calc(var(--spacing)*1)}.py-2{padding-block:calc(var(--spacing)*2)}.py-8{padding-block:calc(var(--spacing)*8)}.pt-4{padding-top:calc(var(--spacing)*4)}.text-center{text-align:center}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.whitespace-nowrap{white-space:nowrap}.text-gray-300{color:var(--color-gray-300)}.text-gray-400{color:var(--color-gray-400)}.text-gray-500{color:var(--color-gray-500)}.text-gray-700{color:var(--color-gray-700)}.text-gray-900{color:var(--color-gray-900)}.text-white{color:var(--color-white)}.ring-2{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(2px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.ring-blue-200{--tw-ring-color:var(--color-blue-200)}.blur{--tw-blur:blur(8px);filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,)}.filter{filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,)}.transition-all{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.duration-200{--tw-duration:.2s;transition-duration:.2s}@media (hover:hover){.hover\:border-gray-300:hover{border-color:var(--color-gray-300)}.hover\:bg-gray-50:hover{background-color:var(--color-gray-50)}.hover\:shadow-md:hover{--tw-shadow:0 4px 6px -1px var(--tw-shadow-color,#0000001a),0 2px 4px -2px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}}.focus\:ring-2:focus{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(2px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-50:disabled{opacity:.5}@media (min-width:40rem){.sm\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}}@media (min-width:48rem){.md\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}}@media (min-width:64rem){.lg\:grid-cols-6{grid-template-columns:repeat(6,minmax(0,1fr))}}.dark\:border-gray-600:where(.dark,.dark *){border-color:var(--color-gray-600)}.dark\:border-gray-700:where(.dark,.dark *){border-color:var(--color-gray-700)}.dark\:bg-gray-800:where(.dark,.dark *){background-color:var(--color-gray-800)}.dark\:text-gray-100:where(.dark,.dark *){color:var(--color-gray-100)}.dark\:text-gray-300:where(.dark,.dark *){color:var(--color-gray-300)}.dark\:text-gray-400:where(.dark,.dark *){color:var(--color-gray-400)}@media (hover:hover){.dark\:hover\:border-gray-600:where(.dark,.dark *):hover{border-color:var(--color-gray-600)}.dark\:hover\:bg-gray-700:where(.dark,.dark *):hover{background-color:var(--color-gray-700)}}}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-space-x-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-duration{syntax:"*";inherits:false} \ No newline at end of file diff --git a/resources/views/forms/components/media-grid-picker.blade.php b/resources/views/forms/components/media-grid-picker.blade.php new file mode 100644 index 0000000..8292c9d --- /dev/null +++ b/resources/views/forms/components/media-grid-picker.blade.php @@ -0,0 +1,76 @@ + +
+ @livewire('backstage-uploadcare-field::media-grid-picker', [ + 'fieldName' => $getFieldName(), + 'perPage' => $getPerPage(), + 'multiple' => $getMultiple(), + 'acceptedFileTypes' => $getAcceptedFileTypes() + ], key('media-grid-picker-' . $getFieldName() . '-' . uniqid())) +
+
\ No newline at end of file diff --git a/resources/views/livewire/media-grid-picker.blade.php b/resources/views/livewire/media-grid-picker.blade.php new file mode 100644 index 0000000..99837c9 --- /dev/null +++ b/resources/views/livewire/media-grid-picker.blade.php @@ -0,0 +1,121 @@ +
+
+
+ +
+
+ {{ __('Showing') }} {{ $this->mediaItems->firstItem() ?? 0 }} {{ __('to') }} {{ $this->mediaItems->lastItem() ?? 0 }} {{ __('of') }} {{ $this->mediaItems->total() }} {{ __('results') }} +
+
+ +
+ @forelse($this->mediaItems as $media) + @php + $isSelected = $multiple + ? in_array($media['id'], $selectedMediaIds) + : ($selectedMediaId === $media['id']); + @endphp +
+ @if($media['is_image'] && $media['cdn_url']) +
+ {{ $media['filename'] }} +
+ @else +
+ + + +
+ @endif + +
+
{{ $media['filename'] }}
+ @if($media['is_image'] && $media['width'] && $media['height']) +
{{ $media['width'] }}×{{ $media['height'] }}
+ @endif +
+ + @if($isSelected) +
+ @if($multiple) +
+ + + +
+ @else +
+ + + +
+ @endif +
+ @endif +
+ @empty +
+ + + +

{{ __('No media files found') }}

+
+ @endforelse +
+ + @if($this->mediaItems->hasPages() || $this->mediaItems->total() > $perPage) +
+
+ + + + {{ __('Page') }} {{ $this->mediaItems->currentPage() }} {{ __('of') }} {{ $this->mediaItems->lastPage() }} + + + +
+ +
+ {{ __('Per page') }}: + +
+
+ @endif +
\ No newline at end of file diff --git a/src/Forms/Components/MediaGridPicker.php b/src/Forms/Components/MediaGridPicker.php new file mode 100644 index 0000000..453c945 --- /dev/null +++ b/src/Forms/Components/MediaGridPicker.php @@ -0,0 +1,66 @@ +fieldName = $fieldName; + + return $this; + } + + public function getFieldName(): string + { + return $this->fieldName; + } + + public function perPage(int $perPage): static + { + $this->perPage = $perPage; + + return $this; + } + + public function getPerPage(): int + { + return $this->perPage; + } + + public function multiple(bool $multiple = true): static + { + $this->multiple = $multiple; + + return $this; + } + + public function getMultiple(): bool + { + return $this->multiple; + } + + public function acceptedFileTypes(?array $acceptedFileTypes): static + { + $this->acceptedFileTypes = $acceptedFileTypes; + + return $this; + } + + public function getAcceptedFileTypes(): ?array + { + return $this->acceptedFileTypes; + } +} diff --git a/src/Listeners/CreateMediaFromUploadcare.php b/src/Listeners/CreateMediaFromUploadcare.php new file mode 100644 index 0000000..fca66dd --- /dev/null +++ b/src/Listeners/CreateMediaFromUploadcare.php @@ -0,0 +1,172 @@ +createMediaFromUploadcare($event->file); + } + + private function createMediaFromUploadcare(mixed $file): ?Media + { + try { + $normalizedFile = $this->normalizeUploadcareFile($file); + if (! $normalizedFile) { + return null; + } + + $fileInfo = $this->extractUploadcareFileInfo($normalizedFile); + if (! $fileInfo) { + return null; + } + + $disk = 'uploadcare'; + $searchCriteria = $this->buildUploadcareSearchCriteria($fileInfo, $disk); + $values = $this->buildUploadcareValues($fileInfo, $disk); + + $searchCriteria = $this->addTenantToSearchCriteria($searchCriteria); + $values = $this->addTenantToMediaData($values); + + return Media::updateOrCreate($searchCriteria, $values); + } catch (Exception $e) { + return null; + } + } + + private function normalizeUploadcareFile(mixed $file): ?array + { + if (is_string($file)) { + if (filter_var($file, FILTER_VALIDATE_URL) && str_contains($file, 'ucarecdn.com')) { + return ['cdnUrl' => $file, 'name' => basename(parse_url($file, PHP_URL_PATH))]; + } + + $decoded = json_decode($file, true); + if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) { + return $decoded; + } + + return null; + } + + return is_array($file) ? $file : null; + } + + private function extractUploadcareFileInfo(array $file): ?array + { + $info = $file['fileInfo'] ?? $file; + $cdnUrl = $info['cdnUrl'] ?? null; + + if (! $cdnUrl || (! str_contains($cdnUrl, 'ucarecdn.com') && ! str_contains($cdnUrl, 'ucarecd.net'))) { + return null; + } + + $detailedInfo = $info['imageInfo'] ?? $info['videoInfo'] ?? $info['contentInfo'] ?? []; + + // Extract UUID from info or URL + $uuid = $info['uuid'] ?? $this->extractUuidFromUrl($cdnUrl); + + // Use UUID as filename, fallback to original name if UUID not found (unlikely) + $filename = $uuid ?? $info['name'] ?? basename(parse_url($cdnUrl, PHP_URL_PATH)); + $originalFilename = $info['originalFilename'] ?? $info['name'] ?? basename(parse_url($cdnUrl, PHP_URL_PATH)); + + return [ + 'info' => $info, + 'detailedInfo' => $detailedInfo, + 'cdnUrl' => $cdnUrl, + 'filename' => $filename, + 'originalFilename' => $originalFilename, + 'checksum' => md5($uuid), + ]; + } + + private function extractUuidFromUrl(string $url): ?string + { + if (preg_match('/([a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})/', $url, $matches)) { + return $matches[1]; + } + + return null; + } + + private function buildUploadcareSearchCriteria(array $fileInfo, string $disk): array + { + return [ + 'disk' => $disk, + 'filename' => $fileInfo['filename'], + ]; + } + + private function buildUploadcareValues(array $fileInfo, string $disk): array + { + $info = $fileInfo['info']; + $detailedInfo = $fileInfo['detailedInfo']; + + return [ + 'disk' => $disk, + 'uploaded_by' => Auth::id(), + 'original_filename' => $fileInfo['originalFilename'], + 'filename' => $fileInfo['filename'], + 'extension' => $detailedInfo['format'] ?? pathinfo($fileInfo['originalFilename'], PATHINFO_EXTENSION), + 'mime_type' => $info['mimeType'] ?? null, + 'size' => $info['size'] ?? null, + 'width' => $detailedInfo['width'] ?? null, + 'height' => $detailedInfo['height'] ?? null, + 'alt' => null, + 'public' => config('backstage.media.visibility') === 'public', + 'metadata' => $info, + 'checksum' => md5($fileInfo['cdnUrl']), + ]; + } + + private function addTenantToMediaData(array $mediaData): array + { + if (! config('backstage.media.is_tenant_aware', false) || ! Filament::hasTenancy()) { + return $mediaData; + } + + $tenant = Filament::getTenant(); + if (! $tenant) { + return $mediaData; + } + + $tenantRelationship = config('backstage.media.tenant_relationship', 'site'); + $tenantField = $tenantRelationship . '_ulid'; + $tenantUlid = $tenant->ulid ?? (method_exists($tenant, 'getKey') ? $tenant->getKey() : ($tenant->id ?? null)); + + if ($tenantUlid) { + $mediaData[$tenantField] = $tenantUlid; + } + + return $mediaData; + } + + private function addTenantToSearchCriteria(array $searchCriteria): array + { + if (! config('backstage.media.is_tenant_aware', false) || ! Filament::hasTenancy()) { + return $searchCriteria; + } + + $tenant = Filament::getTenant(); + if (! $tenant) { + return $searchCriteria; + } + + $tenantRelationship = config('backstage.media.tenant_relationship', 'site'); + $tenantField = $tenantRelationship . '_ulid'; + $tenantUlid = $tenant->ulid ?? (method_exists($tenant, 'getKey') ? $tenant->getKey() : ($tenant->id ?? null)); + + if ($tenantUlid) { + $searchCriteria[$tenantField] = $tenantUlid; + } + + return $searchCriteria; + } +} diff --git a/src/Livewire/MediaGridPicker.php b/src/Livewire/MediaGridPicker.php new file mode 100644 index 0000000..fc2a891 --- /dev/null +++ b/src/Livewire/MediaGridPicker.php @@ -0,0 +1,175 @@ +fieldName = $fieldName; + $this->perPage = $perPage; + $this->multiple = $multiple; + $this->acceptedFileTypes = $acceptedFileTypes; + } + + #[Computed] + public function mediaItems(): LengthAwarePaginator + { + $mediaModel = config('backstage.media.model', 'Backstage\\Models\\Media'); + + $query = $mediaModel::query(); + + // Apply search filter + if (! empty($this->search)) { + $query->where('original_filename', 'like', '%' . $this->search . '%'); + } + + // Apply accepted file types filter at query level + if (! empty($this->acceptedFileTypes)) { + $query->where(function ($q) { + foreach ($this->acceptedFileTypes as $acceptedType) { + // Handle wildcard patterns like "image/*" + if (str_ends_with($acceptedType, '/*')) { + $baseType = substr($acceptedType, 0, -2); + $q->orWhere('mime_type', 'like', $baseType . '/%'); + } + // Handle exact matches + else { + $q->orWhere('mime_type', $acceptedType); + } + } + }); + } + + return $query->paginate($this->perPage) + ->through(function ($media) { + // Decode metadata if it's a JSON string + $metadata = is_string($media->metadata) ? json_decode($media->metadata, true) : $media->metadata; + + $mimeType = $media->mime_type; + + return [ + 'id' => $media->ulid, + 'filename' => $media->original_filename, + 'mime_type' => $mimeType, + 'is_image' => $mimeType && str_starts_with($mimeType, 'image/'), + 'cdn_url' => $metadata['cdnUrl'] ?? null, + 'width' => $media->width, + 'height' => $media->height, + ]; + }); + } + + public function updatePerPage(int $newPerPage): void + { + $this->perPage = $newPerPage; + $this->resetPage(); + } + + public function updatingSearch(): void + { + $this->resetPage(); + } + + public function selectMedia(array $media): void + { + $mediaId = $media['id']; + + // Extract UUID from CDN URL + $cdnUrl = $media['cdn_url'] ?? null; + $uuid = $cdnUrl; + + if ($cdnUrl && str_contains($cdnUrl, 'ucarecdn.com/')) { + if (preg_match('/ucarecdn\.com\/([^\/\?]+)/', $cdnUrl, $matches)) { + $uuid = $matches[1]; + } + } + + if ($this->multiple) { + // Toggle selection in arrays + $index = array_search($mediaId, $this->selectedMediaIds); + if ($index !== false) { + // Remove from selection + unset($this->selectedMediaIds[$index]); + unset($this->selectedMediaUuids[$index]); + $this->selectedMediaIds = array_values($this->selectedMediaIds); + $this->selectedMediaUuids = array_values($this->selectedMediaUuids); + } else { + // Add to selection + $this->selectedMediaIds[] = $mediaId; + $this->selectedMediaUuids[] = $uuid; + } + + // Dispatch event to update hidden field in modal with array + $this->dispatch( + 'set-hidden-field', + fieldName: 'selected_media_uuid', + value: $this->selectedMediaUuids + ); + } else { + // Single selection mode + $this->selectedMediaId = $mediaId; + $this->selectedMediaUuid = $uuid; + + // Dispatch event to update hidden field in modal + $this->dispatch( + 'set-hidden-field', + fieldName: 'selected_media_uuid', + value: $uuid + ); + } + } + + private function matchesAcceptedFileTypes(?string $mimeType): bool + { + if (empty($this->acceptedFileTypes) || empty($mimeType)) { + return true; + } + + foreach ($this->acceptedFileTypes as $acceptedType) { + // Handle wildcard patterns like "image/*" + if (str_ends_with($acceptedType, '/*')) { + $baseType = substr($acceptedType, 0, -2); + if (str_starts_with($mimeType, $baseType . '/')) { + return true; + } + } + // Handle exact matches + elseif ($mimeType === $acceptedType) { + return true; + } + } + + return false; + } + + public function render() + { + return view('backstage-uploadcare-field::livewire.media-grid-picker'); + } +} diff --git a/src/Observers/ContentFieldValueObserver.php b/src/Observers/ContentFieldValueObserver.php new file mode 100644 index 0000000..999f905 --- /dev/null +++ b/src/Observers/ContentFieldValueObserver.php @@ -0,0 +1,244 @@ +isValidField($contentFieldValue)) { + return; + } + + $value = $contentFieldValue->getAttribute('value'); + + // Normalize initial value: it could be a raw JSON string or already an array/object + if (is_string($value)) { + $decoded = json_decode($value, true); + if (json_last_error() === JSON_ERROR_NONE) { + $value = $decoded; + } else { + return; // Invalid JSON + } + } + + if (empty($value) || ! is_array($value)) { + return; + } + + $mediaData = []; + $modifiedValue = $this->processValueRecursively($value, $mediaData); + + // Even if empty($mediaData), we might have cleared a field, so we should sync (detach all) + // But if nothing was modified (strict check?), maybe we skip? + // Actually, if it's a repeater saving, we always want to ensure we catch the latest state. + // Let's rely on mediaData being collected. + + if (empty($mediaData) && $value === $modifiedValue) { + // If no media found and value didn't change (structure-wise substitutions), might be nothing to do. + // However, detached images need to be handled. + // If we found no media, we sync an empty array, which detaches everything. + } + + Log::info('Syncing Media Data', ['count' => count($mediaData)]); + + $this->syncRelationships($contentFieldValue, $mediaData, $modifiedValue); + } + + private function isValidField(ContentFieldValue $contentFieldValue): bool + { + if (! $contentFieldValue->relationLoaded('field')) { + $contentFieldValue->load('field'); + } + + return $contentFieldValue->field && in_array(($contentFieldValue->field->field_type ?? ''), [ + 'uploadcare', + 'repeater', + 'builder', + ]); + } + + /** + * Recursively traverses the value to find Uploadcare data. + * Returns the modified structure (with ULIDs replacing Uploadcare objects). + * Populates $mediaData by reference. + */ + private function processValueRecursively(mixed $data, array &$mediaData): mixed + { + // Handle JSON strings that might contain Uploadcare data + if (is_string($data) && (str_starts_with($data, '[') || str_starts_with($data, '{'))) { + $decoded = json_decode($data, true); + if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) { + // Determine if this decoded data is an Uploadcare value + if ($this->isUploadcareValue($decoded)) { + // It is! Process it as such. + // We recurse on the DECODED value, which falls into the array handling below. + // But wait, the array handling below expects to traverse keys if it's not a value? + // No, let's just pass $decoded to a recursive call? + // Or just handle it right here to avoid logic duplication. + + // Actually, if it is an Uploadcare value, we want to run the extraction logic. + // The extraction logic is inside current function's "isUploadcareValue" block. + // So let's normalize $data to $decoded and proceed. + $data = $decoded; + } else { + // It's a JSON string but NOT an Uploadcare value (maybe a nested repeater encoded as string?). + // We should probably traverse it too? + // Only if we want to fix ALL nested JSON strings. + // The user asked about "repeaters and builders". + // If a repeater inside a repeater is stored as a string, we should decode it to find the deeper files. + $data = $decoded; + } + } + } + + if (! is_array($data)) { + return $data; + } + + // Check if this specific array node is an Uploadcare File object + if ($this->isUploadcareValue($data)) { + $newUlids = []; + foreach ($data as $index => $item) { + [$uuid, $meta] = $this->parseItem($item); + if ($uuid) { + $mediaUlid = $this->resolveMediaUlid($uuid); + if ($mediaUlid) { + $mediaData[] = [ + 'media_ulid' => $mediaUlid, + 'position' => count($mediaData), + 'meta' => ! empty($meta) ? json_encode($meta) : null, + ]; + $newUlids[] = $mediaUlid; + } + } + } + + return $newUlids; + } + + foreach ($data as $key => $value) { + $data[$key] = $this->processValueRecursively($value, $mediaData); + } + + return $data; + } + + private function isUploadcareValue(array $data): bool + { + // Must be a list (integer keys) + if (! array_is_list($data)) { + return false; + } + + // Check first item to see if it looks like an Uploadcare file structure + if (empty($data)) { + // Empty array could be an empty file list or empty repeater. + // Ambiguous. But safe to return false and just return empty array. + return false; + } + + $first = $data[0]; + + // Existing logic used: isset($value['uuid']) + if (is_array($first) && isset($first['uuid'])) { + return true; + } + + // It might be a list of mixed things? Unlikely for a strictly typed field. + return false; + } + + private function parseItem(mixed $item): array + { + $uuid = null; + $meta = []; + + if (is_string($item)) { + if (filter_var($item, FILTER_VALIDATE_URL) && str_contains($item, 'ucarecdn.com')) { + preg_match('/ucarecdn\.com\/([a-f0-9-]{36})(\/.*)?/i', $item, $matches); + $uuid = $matches[1] ?? null; + if ($uuid) { + $meta = [ + 'cdnUrl' => $item, + 'cdnUrlModifiers' => $matches[2] ?? '', + 'uuid' => $uuid, + ]; + } else { + $uuid = $item; + } + } else { + $uuid = $item; + } + } elseif (is_array($item)) { + $uuid = $item['uuid'] ?? ($item['fileInfo']['uuid'] ?? null); + $meta = $item; + + // Try to extract modifiers from cdnUrl if not explicitly present or if we want to be sure + if (isset($item['cdnUrl']) && is_string($item['cdnUrl']) && str_contains($item['cdnUrl'], 'ucarecdn.com')) { + preg_match('/ucarecdn\.com\/([a-f0-9-]{36})(\/.*)?/i', $item['cdnUrl'], $matches); + if (isset($matches[2]) && ! empty($matches[2])) { + $meta['cdnUrlModifiers'] = $matches[2]; + $meta['cdnUrl'] = $item['cdnUrl']; // Ensure url matches + } + } + } + + return [$uuid, $meta]; + } + + private function resolveMediaUlid(string $uuid): ?string + { + if (strlen($uuid) === 26) { + return $uuid; // Already a ULID? + } + + // Check if it looks like a version 4 UUID (Uploadcare usually uses these) + if (! Str::isUuid($uuid)) { + // If strictly not a UUID, and not a ULID (checked via length/format), what is it? + // Maybe it's a filename that is just a string? + // Let's just try to find it. + } + + $mediaModel = config('backstage.media.model', Media::class); + $media = $mediaModel::where('filename', $uuid)->first(); + + return $media?->ulid; + } + + private function syncRelationships(ContentFieldValue $contentFieldValue, array $mediaData, mixed $modifiedValue): void + { + DB::transaction(function () use ($contentFieldValue, $mediaData, $modifiedValue) { + $contentFieldValue->media()->detach(); + + foreach ($mediaData as $data) { + $contentFieldValue->media()->attach($data['media_ulid'], [ + 'position' => $data['position'], + 'meta' => $data['meta'], + ]); + } + + // Important: We must save the modified value (with ULIDs) back to the field + // But we must NOT double encode. + // ContentFieldValue uses implicit casting or just stores string? + // The model is "DecodesJsonStrings", but for saving we generally pass array if we want it cast, + // or we manually json_encode if the model doesn't cast it automatically on set. + // ContentFieldValue definition: + // protected $guarded = []; + // no specific casts defined for 'value' in the snippet viewed earlier (returns empty array). + // But it has `use DecodesJsonStrings`. + + // In the original code: + // $contentFieldValue->updateQuietly(['value' => json_encode($ulids)]); + + // So we should json_encode the result. + $contentFieldValue->updateQuietly(['value' => json_encode($modifiedValue)]); + }); + } +} diff --git a/src/Uploadcare.php b/src/Uploadcare.php index 24bdf11..37844d7 100755 --- a/src/Uploadcare.php +++ b/src/Uploadcare.php @@ -3,10 +3,13 @@ namespace Backstage\UploadcareField; use Backstage\Fields\Contracts\FieldContract; +use Backstage\Fields\Contracts\HydratesValues; use Backstage\Fields\Fields\Base; use Backstage\Fields\Models\Field; use Backstage\Uploadcare\Enums\Style; use Backstage\Uploadcare\Forms\Components\Uploadcare as Input; +use Backstage\UploadcareField\Forms\Components\MediaGridPicker; +use Filament\Actions\Action; use Filament\Facades\Filament; use Filament\Forms\Components\Select; use Filament\Forms\Components\TextInput; @@ -14,10 +17,11 @@ use Filament\Schemas\Components\Grid; use Filament\Schemas\Components\Tabs; use Filament\Schemas\Components\Tabs\Tab; +use Filament\Support\Icons\Heroicon; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Facades\Auth; -class Uploadcare extends Base implements FieldContract +class Uploadcare extends Base implements FieldContract, HydratesValues { public static function getDefaultConfig(): array { @@ -39,16 +43,54 @@ public static function make(string $name, Field $field): Input field: $field ); + $isMultiple = $field->config['multiple'] ?? self::getDefaultConfig()['multiple']; + $acceptedFileTypes = self::parseAcceptedFileTypes($field); + + // TODO: Implement media picker when we got it working fully. Remember to check content_field_values and media_relations as well. + $input = $input->hintActions([ + Action::make('mediaPicker') + ->hiddenLabel() + ->tooltip(__('Select from Media')) + ->icon(Heroicon::Photo) + ->color('gray') + ->size('sm') + ->modalHeading(__('Select Media')) + ->modalWidth('Screen') + ->modalCancelActionLabel(__('Cancel')) + ->modalSubmitActionLabel(__('Select')) + ->action(function (Action $action, array $data, $livewire) use ($input) { + $selectedMediaUuid = $data['selected_media_uuid'] ?? null; + + if ($selectedMediaUuid) { + $cdnUrls = self::convertUuidsToCdnUrls($selectedMediaUuid); + + if ($cdnUrls) { + self::updateStateWithSelectedMedia($input, $cdnUrls); + } + } + }) + ->schema([ + MediaGridPicker::make('media_picker') + ->label('') + ->hiddenLabel() + ->fieldName($name) + ->perPage(12) + ->multiple($isMultiple) + ->acceptedFileTypes($acceptedFileTypes), + \Filament\Forms\Components\Hidden::make('selected_media_uuid') + ->default(null) + ->dehydrated() + ->live(), + ]), + ]); + $input = $input->label($field->name ?? self::getDefaultConfig()['label'] ?? null) ->uploaderStyle(Style::tryFrom($field->config['uploaderStyle'] ?? null) ?? Style::tryFrom(self::getDefaultConfig()['uploaderStyle'])) ->multiple($field->config['multiple'] ?? self::getDefaultConfig()['multiple']) ->withMetadata($field->config['withMetadata'] ?? self::getDefaultConfig()['withMetadata']) ->cropPreset($field->config['cropPreset'] ?? self::getDefaultConfig()['cropPreset']); - if ($acceptedFileTypes = $field->config['acceptedFileTypes'] ?? self::getDefaultConfig()['acceptedFileTypes']) { - if (is_string($acceptedFileTypes)) { - $acceptedFileTypes = explode(',', $acceptedFileTypes); - } + if ($acceptedFileTypes) { $input->acceptedFileTypes($acceptedFileTypes); } @@ -59,6 +101,23 @@ public static function make(string $name, Field $field): Input return $input; } + private static function parseAcceptedFileTypes(Field $field): ?array + { + if (! isset($field->config['acceptedFileTypes']) || ! $field->config['acceptedFileTypes']) { + return null; + } + + $types = $field->config['acceptedFileTypes']; + + if (is_array($types)) { + return $types; + } + + $types = explode(',', $types); + + return array_map('trim', $types); + } + public function getForm(): array { return [ @@ -80,6 +139,10 @@ public function getForm(): array ->label(__('With metadata')) ->formatStateUsing(function ($state, $record) { // Check if withMetadata exists in the config + if ($record === null) { + return self::getDefaultConfig()['withMetadata']; + } + $config = is_string($record->config) ? json_decode($record->config, true) : $record->config; return isset($config['withMetadata']) ? $config['withMetadata'] : self::getDefaultConfig()['withMetadata']; @@ -108,7 +171,9 @@ public function getForm(): array 'image/*' => __('Image'), 'video/*' => __('Video'), 'audio/*' => __('Audio'), - 'application/*' => __('Application'), + 'application/*' => __('Application (Word, Excel, PowerPoint, etc.)'), + 'application/pdf' => __('PDF'), + 'application/zip' => __('ZIP'), ]; if ($state) { @@ -179,7 +244,7 @@ public static function mutateBeforeSaveCallback(Model $record, Field $field, arr return $data; } - $values = self::findFieldValues($data[$record->valueColumn], (string) $field->ulid); + $values = self::findFieldValues($data[$record->valueColumn] ?? [], (string) $field->ulid); if ($values === '' || $values === [] || $values === null) { $data[$record->valueColumn][$field->ulid] = null; @@ -194,7 +259,10 @@ public static function mutateBeforeSaveCallback(Model $record, Field $field, arr } $media = self::processUploadedFiles($values); - $data[$record->valueColumn][$field->ulid] = collect($media)->pluck('ulid')->toArray(); + + // We save the full values including metadata so they can be processed by the Observer + // into relationships. The Observer will then clear the value column. + $data[$record->valueColumn][$field->ulid] = $values; return $data; } @@ -311,7 +379,7 @@ private static function processUploadedFiles(array $files): array { $media = []; - foreach ($files as $file) { + foreach ($files as $index => $file) { $normalizedFiles = self::normalizeFileData($file); if ($normalizedFiles === null || $normalizedFiles === false) { @@ -359,7 +427,7 @@ private static function shouldSkipFile(mixed $file): bool } if (self::isArrayOfArrays($file)) { - foreach ($file as $singleFile) { + foreach ($file as $index => $singleFile) { if (self::shouldSkipFile($singleFile)) { return true; } @@ -383,13 +451,6 @@ private static function shouldSkipFile(mixed $file): bool return false; } - private static function mediaExists(string $file): bool - { - $mediaModel = self::getMediaModel(); - - return $mediaModel::where('checksum', md5_file($file))->exists(); - } - private static function mediaExistsByUuid(string $uuid): bool { $mediaModel = self::getMediaModel(); @@ -434,7 +495,7 @@ private static function createOrUpdateMediaRecord(array $file): Model $tenantUlid = Filament::getTenant()->ulid ?? null; - return $mediaModel::updateOrCreate([ + $media = $mediaModel::updateOrCreate([ 'site_ulid' => $tenantUlid, 'disk' => 'uploadcare', 'filename' => $info['uuid'], @@ -446,10 +507,13 @@ private static function createOrUpdateMediaRecord(array $file): Model 'size' => $info['size'], 'width' => $detailedInfo['width'] ?? null, 'height' => $detailedInfo['height'] ?? null, - 'public' => config('media-picker.visibility') === 'public', - 'metadata' => json_encode($info), - 'checksum' => md5($info['cdnUrl']), + 'alt' => null, + 'public' => config('backstage.media.visibility') === 'public', + 'metadata' => $info, + 'checksum' => md5($info['uuid']), ]); + + return $media; } private static function extractDetailedInfo(array $info): array @@ -478,4 +542,193 @@ private static function extractCdnUrlsFromFileData(mixed $files): array return $cdnUrls; } + + private static function convertUuidsToCdnUrls(mixed $uuids): mixed + { + if (empty($uuids)) { + return null; + } + + if (is_string($uuids)) { + $decoded = json_decode($uuids, true); + + if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) { + $uuids = $decoded; + } elseif (self::isValidCdnUrl($uuids)) { + return $uuids; + } + } + + if (is_array($uuids)) { + $urls = array_map(fn ($uuid) => self::resolveCdnUrl($uuid), $uuids); + + return array_filter($urls); + } + + return self::resolveCdnUrl($uuids); + } + + private static function resolveCdnUrl(mixed $uuid): ?string + { + if (! is_string($uuid) || empty($uuid)) { + return null; + } + + if (filter_var($uuid, FILTER_VALIDATE_URL)) { + return $uuid; + } + + if (str_contains($uuid, 'ucarecdn.com')) { + return $uuid; + } + + $mediaModel = self::getMediaModel(); + + $media = $mediaModel::where('filename', $uuid) + ->orWhere('metadata->cdnUrl', 'like', '%' . $uuid . '%') + ->first(); + + if ($media && isset($media->metadata['cdnUrl'])) { + return $media->metadata['cdnUrl']; + } + + return 'https://ucarecdn.com/' . $uuid . '/'; + } + + private static function isValidCdnUrl(string $url): bool + { + return filter_var($url, FILTER_VALIDATE_URL) && str_contains($url, 'ucarecdn.com'); + } + + private static function updateStateWithSelectedMedia(Input $input, mixed $urls): void + { + if (! $urls) { + return; + } + + if (! $input->isMultiple()) { + $input->state($urls); + $input->callAfterStateUpdated(); + + return; + } + + $currentState = self::normalizeCurrentState($input->getState()); + + if (is_string($urls)) { + $urls = [$urls]; + } + + $newState = array_unique(array_merge($currentState, $urls), SORT_REGULAR); + + $input->state($newState); + $input->callAfterStateUpdated(); + } + + private static function normalizeCurrentState(mixed $state): array + { + if (is_string($state)) { + $state = json_decode($state, true) ?? []; + } + + if (! is_array($state)) { + return []; + } + + // Handle double-encoded JSON or nested structures + if (count($state) > 0 && is_string($state[0])) { + $firstItem = json_decode($state[0], true); + + if (json_last_error() === JSON_ERROR_NONE && is_array($firstItem)) { + if (count($state) === 1 && array_is_list($firstItem)) { + return $firstItem; + } + + return array_map(function ($item) { + if (is_string($item)) { + $decoded = json_decode($item, true); + + return $decoded ?: $item; + } + + return $item; + }, $state); + } + } + + return $state; + } + + public function hydrate(mixed $value, ?Model $model = null): mixed + { + // Try to load from model relationship if available + $hydratedFromModel = self::hydrateFromModel($model); + + if ($hydratedFromModel !== null) { + return $hydratedFromModel; + } + + if (empty($value)) { + return $value; + } + + $mediaModel = self::getMediaModel(); + + if (is_string($value) && ! json_validate($value)) { + return $mediaModel::where('ulid', $value)->first() ?? $value; + } + + $hydratedUlids = self::hydrateBackstageUlids($value); + if ($hydratedUlids !== null) { + return $hydratedUlids; + } + + return $value; + } + + private static function hydrateFromModel(?Model $model): ?array + { + if (! $model || ! method_exists($model, 'media')) { + return null; + } + + if (! $model->relationLoaded('media')) { + $model->load('media'); + } + + if ($model->media->isEmpty()) { + return null; + } + + return $model->media->map(function ($media) { + $meta = $media->pivot->meta ? json_decode($media->pivot->meta, true) : []; + + return array_merge($media->toArray(), $meta, [ + 'uuid' => $media->filename, + 'cdnUrl' => $meta['cdnUrl'] ?? $media->metadata['cdnUrl'] ?? null, + ]); + })->toArray(); + } + + private static function hydrateBackstageUlids(mixed $value): ?array + { + $isListOfUlids = is_array($value) && ! empty($value) && is_string($value[0]) && ! json_validate($value[0]); + + if (! $isListOfUlids) { + return null; + } + + $mediaModel = self::getMediaModel(); + $mediaItems = $mediaModel::whereIn('ulid', $value)->get(); + $hydrated = []; + + foreach ($value as $ulid) { + $media = $mediaItems->firstWhere('ulid', $ulid); + if ($media) { + $hydrated[] = $media->load('edits'); + } + } + + return ! empty($hydrated) ? $hydrated : null; + } } diff --git a/src/UploadcareFieldServiceProvider.php b/src/UploadcareFieldServiceProvider.php index 6117f70..e8dce28 100644 --- a/src/UploadcareFieldServiceProvider.php +++ b/src/UploadcareFieldServiceProvider.php @@ -2,6 +2,8 @@ namespace Backstage\UploadcareField; +use Filament\Support\Assets\Css; +use Filament\Support\Facades\FilamentAsset; use Spatie\LaravelPackageTools\Package; use Spatie\LaravelPackageTools\PackageServiceProvider; @@ -13,6 +15,45 @@ public function configurePackage(Package $package): void ->name('backstage/uploadcare-field') ->hasMigrations([ '2025_08_08_000000_fix_uploadcare_double_encoded_json', - ]); + '2025_12_08_163311_normalize_uploadcare_values_to_ulids', + ]) + ->hasAssets() + ->hasViews(); + } + + public function packageBooted(): void + { + FilamentAsset::register([ + Css::make('uploadcare-field', __DIR__ . '/../resources/dist/uploadcare-field.css'), + ], 'backstage/uploadcare-field'); + + \Illuminate\Support\Facades\Event::listen( + \Backstage\Media\Events\MediaUploading::class, + \Backstage\UploadcareField\Listeners\CreateMediaFromUploadcare::class, + ); + + \Backstage\Models\ContentFieldValue::observe(\Backstage\UploadcareField\Observers\ContentFieldValueObserver::class); + + \Backstage\Fields\Fields::registerField(\Backstage\UploadcareField\Uploadcare::class); + } + + public function bootingPackage(): void + { + $this->loadViewsFrom(__DIR__ . '/../resources/views', 'backstage-uploadcare-field'); + + // Register Media src resolver + \Backstage\Media\Models\Media::resolveSrcUsing(function ($media) { + if ($media->metadata && isset($media->metadata['cdnUrl'])) { + $cdnUrl = $media->metadata['cdnUrl']; + if (filter_var($cdnUrl, FILTER_VALIDATE_URL)) { + return $cdnUrl; + } + } + + return null; + }); + + // Register Livewire components + $this->app->make('livewire')->component('backstage-uploadcare-field::media-grid-picker', \Backstage\UploadcareField\Livewire\MediaGridPicker::class); } } diff --git a/tailwind.config.js b/tailwind.config.js new file mode 100644 index 0000000..8db9ef8 --- /dev/null +++ b/tailwind.config.js @@ -0,0 +1,11 @@ +export default { + darkMode: 'selector', + content: [ + './src/**/*.php', + './resources/views/**/*.blade.php', + ], + theme: { + extend: {}, + }, + plugins: [], +} diff --git a/tests/MediaGridPickerTest.php b/tests/MediaGridPickerTest.php new file mode 100644 index 0000000..3f17050 --- /dev/null +++ b/tests/MediaGridPickerTest.php @@ -0,0 +1,80 @@ + 'Backstage\\Models\\Media']); + } + + public function test_form_component_can_initialize_with_default_values(): void + { + $picker = new MediaGridPicker('test_field'); + + $this->assertEquals(12, $picker->getPerPage()); + } + + public function test_form_component_can_change_per_page(): void + { + $picker = new MediaGridPicker('test_field'); + + $picker->perPage(24); + + $this->assertEquals(24, $picker->getPerPage()); + } + + public function test_livewire_component_can_initialize(): void + { + $component = Livewire::test(LivewireMediaGridPicker::class, [ + 'fieldName' => 'test_field', + 'perPage' => 12, + ]); + + $component->assertSet('fieldName', 'test_field') + ->assertSet('perPage', 12); + } + + public function test_livewire_component_can_update_per_page(): void + { + $component = Livewire::test(LivewireMediaGridPicker::class, [ + 'fieldName' => 'test_field', + 'perPage' => 12, + ]); + + $component->call('updatePerPage', 24) + ->assertSet('perPage', 24); + } + + public function test_livewire_component_dispatches_media_selected_event(): void + { + $component = Livewire::test(LivewireMediaGridPicker::class, [ + 'fieldName' => 'test_field', + 'perPage' => 12, + ]); + + $media = [ + 'id' => 'test-id', + 'filename' => 'test.jpg', + 'cdn_url' => 'https://ucarecdn.com/test-uuid/', + ]; + + $component->call('selectMedia', $media) + ->assertDispatched('media-selected', [ + 'fieldName' => 'test_field', + 'media' => $media, + ]); + } +} From 14b147b0d5359c5b848c5e5855f8381327abbbec Mon Sep 17 00:00:00 2001 From: Baspa Date: Fri, 12 Dec 2025 14:08:57 +0100 Subject: [PATCH 02/29] fix: nested arrays may not be passed to `whereIn` --- src/Uploadcare.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Uploadcare.php b/src/Uploadcare.php index 37844d7..864afa9 100755 --- a/src/Uploadcare.php +++ b/src/Uploadcare.php @@ -20,6 +20,7 @@ use Filament\Support\Icons\Heroicon; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Arr; class Uploadcare extends Base implements FieldContract, HydratesValues { @@ -46,7 +47,6 @@ public static function make(string $name, Field $field): Input $isMultiple = $field->config['multiple'] ?? self::getDefaultConfig()['multiple']; $acceptedFileTypes = self::parseAcceptedFileTypes($field); - // TODO: Implement media picker when we got it working fully. Remember to check content_field_values and media_relations as well. $input = $input->hintActions([ Action::make('mediaPicker') ->hiddenLabel() @@ -317,7 +317,7 @@ private static function extractMediaUrls(array $mediaUlids, bool $withMetadata = { $mediaModel = self::getMediaModel(); - return $mediaModel::whereIn('ulid', $mediaUlids) + return $mediaModel::whereIn('ulid', array_filter(Arr::flatten($mediaUlids), 'is_string')) ->get() ->map(function ($media) use ($withMetadata) { $metadata = is_string($media->metadata) @@ -719,7 +719,7 @@ private static function hydrateBackstageUlids(mixed $value): ?array } $mediaModel = self::getMediaModel(); - $mediaItems = $mediaModel::whereIn('ulid', $value)->get(); + $mediaItems = $mediaModel::whereIn('ulid', array_filter(Arr::flatten($value), 'is_string'))->get(); $hydrated = []; foreach ($value as $ulid) { From b792a21a0a29cb0ea8598d895c115564a8253cc4 Mon Sep 17 00:00:00 2001 From: Baspa <10845460+Baspa@users.noreply.github.com> Date: Fri, 12 Dec 2025 13:09:29 +0000 Subject: [PATCH 03/29] fix: styling --- src/Uploadcare.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Uploadcare.php b/src/Uploadcare.php index 864afa9..bcc9c5a 100755 --- a/src/Uploadcare.php +++ b/src/Uploadcare.php @@ -19,8 +19,8 @@ use Filament\Schemas\Components\Tabs\Tab; use Filament\Support\Icons\Heroicon; use Illuminate\Database\Eloquent\Model; -use Illuminate\Support\Facades\Auth; use Illuminate\Support\Arr; +use Illuminate\Support\Facades\Auth; class Uploadcare extends Base implements FieldContract, HydratesValues { From bec28e6076f8c4a25c191d49914dfa20114e3df6 Mon Sep 17 00:00:00 2001 From: Baspa Date: Wed, 17 Dec 2025 16:32:44 +0100 Subject: [PATCH 04/29] fix: repair `uc` relationships, improve retreiving uuids --- ..._repair_uploadcare_media_relationships.php | 349 ++++++++++++++++++ src/Listeners/CreateMediaFromUploadcare.php | 4 +- src/Livewire/MediaGridPicker.php | 18 +- src/Observers/ContentFieldValueObserver.php | 99 ++--- src/Uploadcare.php | 247 +++++++++++-- src/UploadcareFieldServiceProvider.php | 1 + 6 files changed, 608 insertions(+), 110 deletions(-) create mode 100644 database/migrations/2025_12_17_000001_repair_uploadcare_media_relationships.php diff --git a/database/migrations/2025_12_17_000001_repair_uploadcare_media_relationships.php b/database/migrations/2025_12_17_000001_repair_uploadcare_media_relationships.php new file mode 100644 index 0000000..15df33e --- /dev/null +++ b/database/migrations/2025_12_17_000001_repair_uploadcare_media_relationships.php @@ -0,0 +1,349 @@ +whereIn('field_type', ['uploadcare', 'builder', 'repeater']) + ->pluck('ulid'); + + if ($targetFieldIds->isEmpty()) { + return; + } + + $firstSiteUlid = DB::table('sites')->orderBy('ulid')->value('ulid'); + + $mediaModelClass = config('backstage.media.model', \Backstage\Media\Models\Media::class); + if (! is_string($mediaModelClass) || ! class_exists($mediaModelClass)) { + $mediaModelClass = \Backstage\Media\Models\Media::class; + } + + $mediaTable = app($mediaModelClass)->getTable(); + + $mediaHasSiteUlid = Schema::hasColumn($mediaTable, 'site_ulid'); + $mediaHasDisk = Schema::hasColumn($mediaTable, 'disk'); + $mediaHasPublic = Schema::hasColumn($mediaTable, 'public'); + $mediaHasMetadata = Schema::hasColumn($mediaTable, 'metadata'); + $mediaHasOriginalFilename = Schema::hasColumn($mediaTable, 'original_filename'); + $mediaHasMimeType = Schema::hasColumn($mediaTable, 'mime_type'); + $mediaHasExtension = Schema::hasColumn($mediaTable, 'extension'); + $mediaHasSize = Schema::hasColumn($mediaTable, 'size'); + $mediaHasWidth = Schema::hasColumn($mediaTable, 'width'); + $mediaHasHeight = Schema::hasColumn($mediaTable, 'height'); + $mediaHasChecksum = Schema::hasColumn($mediaTable, 'checksum'); + + $isUlid = function (mixed $value): bool { + return is_string($value) && (bool) preg_match('/^[0-9A-HJKMNP-TV-Z]{26}$/i', $value); + }; + + $extractUuidFromString = function (string $value): ?string { + if (preg_match('/([a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})/i', $value, $matches)) { + return $matches[1]; + } + + return null; + }; + + $buildUrlMeta = function (string $url, string $uuid) { + $pos = stripos($url, $uuid); + $modifiers = $pos === false ? '' : substr($url, $pos + strlen($uuid)); + + return [ + 'cdnUrl' => $url, + 'cdnUrlModifiers' => $modifiers, + 'uuid' => $uuid, + ]; + }; + + $shouldUpdateMeta = function (?string $existingMeta): bool { + if (! $existingMeta) { + return true; + } + + $decoded = json_decode($existingMeta, true); + if (! is_array($decoded) || empty($decoded)) { + return true; + } + + // If we already have identifying info, keep it. + if (! empty($decoded['uuid']) || ! empty($decoded['cdnUrl']) || ! empty($decoded['fileInfo']['uuid'] ?? null) || ! empty($decoded['fileInfo']['cdnUrl'] ?? null)) { + return false; + } + + return true; + }; + + $ensureRelationship = function (string $contentFieldValueUlid, string $mediaUlid, int $position, ?array $meta) use ($shouldUpdateMeta) { + $existing = DB::table('media_relationships') + ->where('model_type', 'content_field_value') + ->where('model_id', $contentFieldValueUlid) + ->where('media_ulid', $mediaUlid) + ->first(); + + $payload = [ + 'position' => $position, + 'updated_at' => now(), + ]; + + if ($meta !== null) { + $metaJson = json_encode($meta); + if (! $existing || $shouldUpdateMeta($existing->meta ?? null)) { + $payload['meta'] = $metaJson; + } + } + + if (! $existing) { + DB::table('media_relationships')->insert([ + 'media_ulid' => $mediaUlid, + 'model_type' => 'content_field_value', + 'model_id' => $contentFieldValueUlid, + 'position' => $position, + 'meta' => $meta !== null ? json_encode($meta) : null, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + return; + } + + DB::table('media_relationships') + ->where('id', $existing->id) + ->update($payload); + }; + + $findOrCreateMediaByUuid = function (string $uuid, ?array $fileData, ?string $siteUlid) use ( + $mediaModelClass, + $mediaHasSiteUlid, + $mediaHasDisk, + $mediaHasPublic, + $mediaHasMetadata, + $mediaHasOriginalFilename, + $mediaHasMimeType, + $mediaHasExtension, + $mediaHasSize, + $mediaHasWidth, + $mediaHasHeight, + $mediaHasChecksum + ) { + $media = $mediaModelClass::where('filename', $uuid)->first(); + if ($media) { + return $media; + } + + if (! is_array($fileData) || empty($fileData)) { + return null; + } + + $info = $fileData['fileInfo'] ?? $fileData; + $detailedInfo = $info['imageInfo'] ?? $info['videoInfo'] ?? $info['contentInfo'] ?? []; + + $media = new $mediaModelClass; + + // Some Media models auto-generate ULIDs, but setting explicitly is safe if field exists. + if (Schema::hasColumn($media->getTable(), 'ulid')) { + $media->ulid = (string) Str::ulid(); + } + + if ($mediaHasSiteUlid && $siteUlid) { + $media->site_ulid = $siteUlid; + } + if ($mediaHasDisk) { + $media->disk = 'uploadcare'; + } + + $media->filename = $uuid; + + if ($mediaHasOriginalFilename) { + $media->original_filename = $info['originalFilename'] ?? $info['original_filename'] ?? $info['name'] ?? 'unknown'; + } + if ($mediaHasMimeType) { + $media->mime_type = $info['mimeType'] ?? $info['mime_type'] ?? 'application/octet-stream'; + } + if ($mediaHasExtension) { + $media->extension = $detailedInfo['format'] + ?? pathinfo(($info['originalFilename'] ?? $info['name'] ?? ''), PATHINFO_EXTENSION); + } + if ($mediaHasSize) { + $media->size = (int) ($info['size'] ?? 0); + } + if ($mediaHasWidth) { + $media->width = $detailedInfo['width'] ?? null; + } + if ($mediaHasHeight) { + $media->height = $detailedInfo['height'] ?? null; + } + if ($mediaHasPublic) { + $media->public = true; + } + if ($mediaHasMetadata) { + $media->metadata = $info; + } + if ($mediaHasChecksum) { + $media->checksum = md5($uuid); + } + + $media->save(); + + return $media; + }; + + $processValue = function (&$data, string $siteUlid, string $rowUlid, int &$position) use ( + &$processValue, + $isUlid, + $extractUuidFromString, + $buildUrlMeta, + $ensureRelationship, + $findOrCreateMediaByUuid, + $mediaModelClass + ): bool { + $anyModified = false; + + if (is_string($data) && (str_starts_with($data, '[') || str_starts_with($data, '{'))) { + $decoded = json_decode($data, true); + if (json_last_error() === JSON_ERROR_NONE) { + $data = $decoded; + $anyModified = true; + } + } + + if (! is_array($data)) { + return $anyModified; + } + + // Raw Uploadcare list: array of arrays with uuid + $isRawUploadcareList = ! empty($data) && isset($data[0]) && is_array($data[0]) && isset($data[0]['uuid']); + + // List of strings (ULIDs, UUIDs, URLs) + $isStringList = ! empty($data) && isset($data[0]) && is_string($data[0]) && array_is_list($data); + + if ($isRawUploadcareList) { + $newUlids = []; + foreach ($data as $fileData) { + if (! is_array($fileData)) { + continue; + } + + $uuid = $fileData['uuid'] ?? ($fileData['fileInfo']['uuid'] ?? null); + if (! is_string($uuid) || ! Str::isUuid($uuid)) { + continue; + } + + $media = $findOrCreateMediaByUuid($uuid, $fileData, $siteUlid); + if (! $media) { + // If media can't be created, skip relationship. + continue; + } + + $position++; + $ensureRelationship($rowUlid, $media->ulid, $position, $fileData); + $newUlids[] = $media->ulid; + } + + if (! empty($newUlids)) { + $data = $newUlids; + $anyModified = true; + } + + return $anyModified; + } + + if ($isStringList) { + $newUlids = []; + foreach ($data as $item) { + if (! is_string($item) || $item === '') { + continue; + } + + // ULID list: only attach when a Media record exists. + if ($isUlid($item)) { + $media = $mediaModelClass::where('ulid', $item)->first(); + if (! $media) { + continue; + } + + $meta = is_array($media->metadata ?? null) ? $media->metadata : null; + $position++; + $ensureRelationship($rowUlid, $media->ulid, $position, $meta); + $newUlids[] = $media->ulid; + continue; + } + + // UUID string or URL containing UUID: only attach when a Media record exists. + $uuid = $extractUuidFromString($item); + if (! $uuid || ! Str::isUuid($uuid)) { + continue; + } + + $media = $mediaModelClass::where('filename', $uuid)->first(); + if (! $media) { + // Don't create media here (too risky without fileData). + continue; + } + + $meta = filter_var($item, FILTER_VALIDATE_URL) ? $buildUrlMeta($item, $uuid) : ['uuid' => $uuid]; + $position++; + $ensureRelationship($rowUlid, $media->ulid, $position, $meta); + $newUlids[] = $media->ulid; + } + + if (! empty($newUlids)) { + // Normalize to ULIDs + $data = $newUlids; + $anyModified = true; + } + + return $anyModified; + } + + foreach ($data as $key => &$value) { + if (is_array($value) || is_string($value)) { + if ($processValue($value, $siteUlid, $rowUlid, $position)) { + $anyModified = true; + } + } + } + unset($value); + + return $anyModified; + }; + + DB::table('content_field_values') + ->whereIn('field_ulid', $targetFieldIds) + ->chunkById(50, function ($rows) use ($processValue, $firstSiteUlid) { + foreach ($rows as $row) { + $value = $row->value; + + $decoded = json_decode($value, true); + if (is_string($decoded)) { + $decoded = json_decode($decoded, true); + } + + if (! is_array($decoded)) { + continue; + } + + $siteUlid = $row->site_ulid ?? $firstSiteUlid; + $position = 0; + + if ($processValue($decoded, $siteUlid, $row->ulid, $position)) { + DB::table('content_field_values') + ->where('ulid', $row->ulid) + ->update(['value' => json_encode($decoded)]); + } + } + }, 'ulid'); + } + + public function down(): void + { + // + } +}; + + diff --git a/src/Listeners/CreateMediaFromUploadcare.php b/src/Listeners/CreateMediaFromUploadcare.php index fca66dd..c609335 100644 --- a/src/Listeners/CreateMediaFromUploadcare.php +++ b/src/Listeners/CreateMediaFromUploadcare.php @@ -44,7 +44,7 @@ private function createMediaFromUploadcare(mixed $file): ?Media private function normalizeUploadcareFile(mixed $file): ?array { if (is_string($file)) { - if (filter_var($file, FILTER_VALIDATE_URL) && str_contains($file, 'ucarecdn.com')) { + if (filter_var($file, FILTER_VALIDATE_URL) && $this->extractUuidFromUrl($file)) { return ['cdnUrl' => $file, 'name' => basename(parse_url($file, PHP_URL_PATH))]; } @@ -64,7 +64,7 @@ private function extractUploadcareFileInfo(array $file): ?array $info = $file['fileInfo'] ?? $file; $cdnUrl = $info['cdnUrl'] ?? null; - if (! $cdnUrl || (! str_contains($cdnUrl, 'ucarecdn.com') && ! str_contains($cdnUrl, 'ucarecd.net'))) { + if (! $cdnUrl || ! filter_var($cdnUrl, FILTER_VALIDATE_URL) || ! $this->extractUuidFromUrl($cdnUrl)) { return null; } diff --git a/src/Livewire/MediaGridPicker.php b/src/Livewire/MediaGridPicker.php index fc2a891..ea70a7a 100644 --- a/src/Livewire/MediaGridPicker.php +++ b/src/Livewire/MediaGridPicker.php @@ -99,16 +99,8 @@ public function updatingSearch(): void public function selectMedia(array $media): void { $mediaId = $media['id']; - - // Extract UUID from CDN URL - $cdnUrl = $media['cdn_url'] ?? null; - $uuid = $cdnUrl; - - if ($cdnUrl && str_contains($cdnUrl, 'ucarecdn.com/')) { - if (preg_match('/ucarecdn\.com\/([^\/\?]+)/', $cdnUrl, $matches)) { - $uuid = $matches[1]; - } - } + // Send Media ULIDs; Uploadcare::convertUuidsToCdnUrls() will resolve them to the correct URL/UUID. + $selected = $mediaId; if ($this->multiple) { // Toggle selection in arrays @@ -122,7 +114,7 @@ public function selectMedia(array $media): void } else { // Add to selection $this->selectedMediaIds[] = $mediaId; - $this->selectedMediaUuids[] = $uuid; + $this->selectedMediaUuids[] = $selected; } // Dispatch event to update hidden field in modal with array @@ -134,13 +126,13 @@ public function selectMedia(array $media): void } else { // Single selection mode $this->selectedMediaId = $mediaId; - $this->selectedMediaUuid = $uuid; + $this->selectedMediaUuid = $selected; // Dispatch event to update hidden field in modal $this->dispatch( 'set-hidden-field', fieldName: 'selected_media_uuid', - value: $uuid + value: $selected ); } } diff --git a/src/Observers/ContentFieldValueObserver.php b/src/Observers/ContentFieldValueObserver.php index 999f905..4ebb0e9 100644 --- a/src/Observers/ContentFieldValueObserver.php +++ b/src/Observers/ContentFieldValueObserver.php @@ -5,8 +5,6 @@ use Backstage\Media\Models\Media; use Backstage\Models\ContentFieldValue; use Illuminate\Support\Facades\DB; -use Illuminate\Support\Facades\Log; -use Illuminate\Support\Str; class ContentFieldValueObserver { @@ -35,19 +33,14 @@ public function saved(ContentFieldValue $contentFieldValue): void $mediaData = []; $modifiedValue = $this->processValueRecursively($value, $mediaData); - // Even if empty($mediaData), we might have cleared a field, so we should sync (detach all) - // But if nothing was modified (strict check?), maybe we skip? - // Actually, if it's a repeater saving, we always want to ensure we catch the latest state. - // Let's rely on mediaData being collected. - if (empty($mediaData) && $value === $modifiedValue) { - // If no media found and value didn't change (structure-wise substitutions), might be nothing to do. - // However, detached images need to be handled. - // If we found no media, we sync an empty array, which detaches everything. + // If there were previously media relations, but no media is found now (e.g. field cleared), + // ensure we detach stale relationships. + if (! $contentFieldValue->media()->exists()) { + return; + } } - - Log::info('Syncing Media Data', ['count' => count($mediaData)]); - + $this->syncRelationships($contentFieldValue, $mediaData, $modifiedValue); } @@ -71,28 +64,12 @@ private function isValidField(ContentFieldValue $contentFieldValue): bool */ private function processValueRecursively(mixed $data, array &$mediaData): mixed { - // Handle JSON strings that might contain Uploadcare data if (is_string($data) && (str_starts_with($data, '[') || str_starts_with($data, '{'))) { $decoded = json_decode($data, true); if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) { - // Determine if this decoded data is an Uploadcare value if ($this->isUploadcareValue($decoded)) { - // It is! Process it as such. - // We recurse on the DECODED value, which falls into the array handling below. - // But wait, the array handling below expects to traverse keys if it's not a value? - // No, let's just pass $decoded to a recursive call? - // Or just handle it right here to avoid logic duplication. - - // Actually, if it is an Uploadcare value, we want to run the extraction logic. - // The extraction logic is inside current function's "isUploadcareValue" block. - // So let's normalize $data to $decoded and proceed. $data = $decoded; } else { - // It's a JSON string but NOT an Uploadcare value (maybe a nested repeater encoded as string?). - // We should probably traverse it too? - // Only if we want to fix ALL nested JSON strings. - // The user asked about "repeaters and builders". - // If a repeater inside a repeater is stored as a string, we should decode it to find the deeper files. $data = $decoded; } } @@ -139,8 +116,7 @@ private function isUploadcareValue(array $data): bool // Check first item to see if it looks like an Uploadcare file structure if (empty($data)) { - // Empty array could be an empty file list or empty repeater. - // Ambiguous. But safe to return false and just return empty array. + return false; } @@ -151,7 +127,13 @@ private function isUploadcareValue(array $data): bool return true; } - // It might be a list of mixed things? Unlikely for a strictly typed field. + if (is_string($first)) { + // UUID strings or URLs containing UUIDs + if (preg_match('/[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}/i', $first)) { + return true; + } + } + return false; } @@ -161,13 +143,16 @@ private function parseItem(mixed $item): array $meta = []; if (is_string($item)) { - if (filter_var($item, FILTER_VALIDATE_URL) && str_contains($item, 'ucarecdn.com')) { - preg_match('/ucarecdn\.com\/([a-f0-9-]{36})(\/.*)?/i', $item, $matches); - $uuid = $matches[1] ?? null; + if (filter_var($item, FILTER_VALIDATE_URL)) { + preg_match('/([a-f0-9-]{36})/i', $item, $matches, PREG_OFFSET_CAPTURE); + $uuid = $matches[1][0] ?? null; if ($uuid) { + $uuidOffset = $matches[1][1] ?? null; + $uuidLen = strlen($uuid); + $modifiers = ($uuidOffset !== null) ? substr($item, $uuidOffset + $uuidLen) : ''; $meta = [ 'cdnUrl' => $item, - 'cdnUrlModifiers' => $matches[2] ?? '', + 'cdnUrlModifiers' => $modifiers, 'uuid' => $uuid, ]; } else { @@ -181,11 +166,17 @@ private function parseItem(mixed $item): array $meta = $item; // Try to extract modifiers from cdnUrl if not explicitly present or if we want to be sure - if (isset($item['cdnUrl']) && is_string($item['cdnUrl']) && str_contains($item['cdnUrl'], 'ucarecdn.com')) { - preg_match('/ucarecdn\.com\/([a-f0-9-]{36})(\/.*)?/i', $item['cdnUrl'], $matches); - if (isset($matches[2]) && ! empty($matches[2])) { - $meta['cdnUrlModifiers'] = $matches[2]; - $meta['cdnUrl'] = $item['cdnUrl']; // Ensure url matches + if (isset($item['cdnUrl']) && is_string($item['cdnUrl']) && filter_var($item['cdnUrl'], FILTER_VALIDATE_URL)) { + preg_match('/([a-f0-9-]{36})/i', $item['cdnUrl'], $matches, PREG_OFFSET_CAPTURE); + $foundUuid = $matches[1][0] ?? null; + if ($foundUuid) { + $uuidOffset = $matches[1][1] ?? null; + $uuidLen = strlen($foundUuid); + $modifiers = ($uuidOffset !== null) ? substr($item['cdnUrl'], $uuidOffset + $uuidLen) : ''; + if (! empty($modifiers)) { + $meta['cdnUrlModifiers'] = $modifiers; + $meta['cdnUrl'] = $item['cdnUrl']; // Ensure url matches + } } } } @@ -196,14 +187,12 @@ private function parseItem(mixed $item): array private function resolveMediaUlid(string $uuid): ?string { if (strlen($uuid) === 26) { - return $uuid; // Already a ULID? - } + // Only treat 26-char strings as Media ULIDs if they exist. + // Builder/repeater data can contain Content ULIDs as well; those must NOT be attached as media. + $mediaModel = config('backstage.media.model', Media::class); + $media = $mediaModel::where('ulid', $uuid)->first(); - // Check if it looks like a version 4 UUID (Uploadcare usually uses these) - if (! Str::isUuid($uuid)) { - // If strictly not a UUID, and not a ULID (checked via length/format), what is it? - // Maybe it's a filename that is just a string? - // Let's just try to find it. + return $media?->ulid; } $mediaModel = config('backstage.media.model', Media::class); @@ -224,20 +213,6 @@ private function syncRelationships(ContentFieldValue $contentFieldValue, array $ ]); } - // Important: We must save the modified value (with ULIDs) back to the field - // But we must NOT double encode. - // ContentFieldValue uses implicit casting or just stores string? - // The model is "DecodesJsonStrings", but for saving we generally pass array if we want it cast, - // or we manually json_encode if the model doesn't cast it automatically on set. - // ContentFieldValue definition: - // protected $guarded = []; - // no specific casts defined for 'value' in the snippet viewed earlier (returns empty array). - // But it has `use DecodesJsonStrings`. - - // In the original code: - // $contentFieldValue->updateQuietly(['value' => json_encode($ulids)]); - - // So we should json_encode the result. $contentFieldValue->updateQuietly(['value' => json_encode($modifiedValue)]); }); } diff --git a/src/Uploadcare.php b/src/Uploadcare.php index bcc9c5a..a0dfc83 100755 --- a/src/Uploadcare.php +++ b/src/Uploadcare.php @@ -40,15 +40,37 @@ public static function getDefaultConfig(): array public static function make(string $name, Field $field): Input { $input = self::applyDefaultSettings( - input: Input::make($name)->withMetadata()->removeCopyright(), + input: Input::make($name) + ->withMetadata() + ->removeCopyright() + ->dehydrateStateUsing(function ($state) use ($name, $field) { + if (is_string($state) && json_validate($state)) { + return json_decode($state, true); + } + + return $state; + }) + ->afterStateHydrated(function ($component, $state) use ($name, $field) { + $newState = $state; + + if (is_string($state) && json_validate($state)) { + $newState = json_decode($state, true); + } + + if ($newState !== $state) { + $component->state($newState); + } + }), field: $field ); + $isMultiple = $field->config['multiple'] ?? self::getDefaultConfig()['multiple']; $acceptedFileTypes = self::parseAcceptedFileTypes($field); $input = $input->hintActions([ - Action::make('mediaPicker') + fn (Input $component) => Action::make('mediaPicker') + ->schemaComponent($component) ->hiddenLabel() ->tooltip(__('Select from Media')) ->icon(Heroicon::Photo) @@ -58,16 +80,18 @@ public static function make(string $name, Field $field): Input ->modalWidth('Screen') ->modalCancelActionLabel(__('Cancel')) ->modalSubmitActionLabel(__('Select')) - ->action(function (Action $action, array $data, $livewire) use ($input) { - $selectedMediaUuid = $data['selected_media_uuid'] ?? null; - - if ($selectedMediaUuid) { - $cdnUrls = self::convertUuidsToCdnUrls($selectedMediaUuid); + ->action(function (Action $action, array $data, Input $component) { + $selected = $data['selected_media_uuid'] ?? null; + if (! $selected) { + return; + } - if ($cdnUrls) { - self::updateStateWithSelectedMedia($input, $cdnUrls); - } + $cdnUrls = self::convertUuidsToCdnUrls($selected); + if (! $cdnUrls) { + return; } + + self::updateStateWithSelectedMedia($component, $cdnUrls); }) ->schema([ MediaGridPicker::make('media_picker') @@ -224,7 +248,9 @@ public static function mutateFormDataCallback(Model $record, Field $field, array $values = self::parseValues($values); if (self::isMediaUlidArray($values)) { - $mediaData = self::extractMediaUrls($values, $withMetadata); + // Always return metadata for ULID-based values (default behavior), otherwise + // the Uploadcare field may not be able to render a preview. + $mediaData = self::extractMediaUrls($values, true); $data[$record->valueColumn][$field->ulid] = $mediaData; } else { $mediaUrls = self::extractCdnUrlsFromFileData($values); @@ -324,17 +350,42 @@ private static function extractMediaUrls(array $mediaUlids, bool $withMetadata = ? json_decode($media->metadata, true) : $media->metadata; - if (! isset($metadata['cdnUrl'])) { + $metadata = is_array($metadata) ? $metadata : []; + + // Prefer per-edit pivot meta when available (e.g. cropped/modified cdnUrl). + // In Backstage this is exposed as $media->edit (see Backstage\Models\Media). + $editMeta = $media->edit ?? null; + if (is_string($editMeta)) { + $editMeta = json_decode($editMeta, true); + } + if (is_array($editMeta)) { + $metadata = array_merge($metadata, $editMeta); + } + + $cdnUrl = $metadata['cdnUrl'] + ?? ($metadata['fileInfo']['cdnUrl'] ?? null); + + $uuid = $metadata['uuid'] + ?? ($metadata['fileInfo']['uuid'] ?? null) + ?? (is_string($media->filename) ? self::extractUuidFromString($media->filename) : null); + + // Fallback for older records: construct a default Uploadcare URL if we only have a UUID. + if (! $cdnUrl && $uuid) { + $cdnUrl = 'https://ucarecdn.com/' . $uuid . '/'; + } + + if (! $cdnUrl || ! filter_var($cdnUrl, FILTER_VALIDATE_URL)) { return null; } if ($withMetadata) { - return $metadata; + return array_merge($metadata, array_filter([ + 'uuid' => $uuid, + 'cdnUrl' => $cdnUrl, + ])); } - $cdnUrl = $metadata['cdnUrl']; - - return filter_var($cdnUrl, FILTER_VALIDATE_URL) ? $cdnUrl : null; + return $cdnUrl; }) ->filter() ->values() @@ -460,15 +511,11 @@ private static function mediaExistsByUuid(string $uuid): bool private static function extractUuidFromString(string $string): ?string { - if (preg_match('/~\d+\//', $string)) { - return null; - } - if (preg_match('/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i', $string)) { return $string; } - if (preg_match('/\/([a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})(?:\/|$)/i', $string, $matches)) { + if (preg_match('/([a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})/i', $string, $matches)) { return $matches[1]; } @@ -578,7 +625,37 @@ private static function resolveCdnUrl(mixed $uuid): ?string return $uuid; } - if (str_contains($uuid, 'ucarecdn.com')) { + // If this is a Media ULID, resolve to stored CDN URL (or derive from filename UUID). + if (preg_match('/^[0-9A-HJKMNP-TV-Z]{26}$/i', $uuid)) { + $mediaModel = self::getMediaModel(); + $media = $mediaModel::where('ulid', $uuid)->first(); + if (! $media) { + return null; + } + + $metadata = is_string($media->metadata) ? json_decode($media->metadata, true) : $media->metadata; + $metadata = is_array($metadata) ? $metadata : []; + + // Prefer edit/pivot meta if exposed + $editMeta = $media->edit ?? null; + if (is_string($editMeta)) { + $editMeta = json_decode($editMeta, true); + } + if (is_array($editMeta)) { + $metadata = array_merge($metadata, $editMeta); + } + + $cdnUrl = $metadata['cdnUrl'] ?? ($metadata['fileInfo']['cdnUrl'] ?? null); + $fileUuid = $metadata['uuid'] ?? ($metadata['fileInfo']['uuid'] ?? null) ?? self::extractUuidFromString((string) ($media->filename ?? '')); + + if (! $cdnUrl && $fileUuid) { + $cdnUrl = 'https://ucarecdn.com/' . $fileUuid . '/'; + } + + return is_string($cdnUrl) && filter_var($cdnUrl, FILTER_VALIDATE_URL) ? $cdnUrl : null; + } + + if (str_contains($uuid, 'ucarecdn.com') || str_contains($uuid, 'ucarecd.net')) { return $uuid; } @@ -597,7 +674,7 @@ private static function resolveCdnUrl(mixed $uuid): ?string private static function isValidCdnUrl(string $url): bool { - return filter_var($url, FILTER_VALIDATE_URL) && str_contains($url, 'ucarecdn.com'); + return filter_var($url, FILTER_VALIDATE_URL) && self::extractUuidFromString($url) !== null; } private static function updateStateWithSelectedMedia(Input $input, mixed $urls): void @@ -674,6 +751,14 @@ public function hydrate(mixed $value, ?Model $model = null): mixed $mediaModel = self::getMediaModel(); + if (is_string($value) && json_validate($value)) { + $decoded = json_decode($value, true); + + if (is_array($decoded)) { + $value = $decoded; + } + } + if (is_string($value) && ! json_validate($value)) { return $mediaModel::where('ulid', $value)->first() ?? $value; } @@ -710,25 +795,121 @@ private static function hydrateFromModel(?Model $model): ?array })->toArray(); } - private static function hydrateBackstageUlids(mixed $value): ?array + private static function resolveMediaFromMixedValue(mixed $item): ?Model { - $isListOfUlids = is_array($value) && ! empty($value) && is_string($value[0]) && ! json_validate($value[0]); + $mediaModel = self::getMediaModel(); + + if ($item instanceof Model) { + return $item; + } - if (! $isListOfUlids) { + if (is_string($item) && $item !== '') { + if (filter_var($item, FILTER_VALIDATE_URL)) { + $uuid = self::extractUuidFromString($item); + + return $uuid ? $mediaModel::where('filename', $uuid)->first() : null; + } + + if (preg_match('/^[0-9A-HJKMNP-TV-Z]{26}$/i', $item)) { + return $mediaModel::where('ulid', $item)->first(); + } + + $uuid = self::extractUuidFromString($item); + + return $uuid ? $mediaModel::where('filename', $uuid)->first() : null; + } + + if (is_array($item)) { + $ulid = $item['ulid'] ?? $item['id'] ?? $item['media_ulid'] ?? null; + if (is_string($ulid) && $ulid !== '') { + $media = $mediaModel::where('ulid', $ulid)->first(); + if ($media) { + return $media; + } + } + + $uuid = $item['uuid'] ?? ($item['fileInfo']['uuid'] ?? null); + if (is_string($uuid) && $uuid !== '') { + $media = $mediaModel::where('filename', $uuid)->first(); + if ($media) { + return $media; + } + + if (preg_match('/^[0-9A-HJKMNP-TV-Z]{26}$/i', $uuid)) { + return $mediaModel::where('ulid', $uuid)->first(); + } + } + + $cdnUrl = $item['cdnUrl'] ?? ($item['fileInfo']['cdnUrl'] ?? null) ?? null; + if (is_string($cdnUrl) && filter_var($cdnUrl, FILTER_VALIDATE_URL)) { + $uuid = self::extractUuidFromString($cdnUrl); + + return $uuid ? $mediaModel::where('filename', $uuid)->first() : null; + } + } + + return null; + } + + private static function hydrateBackstageUlids(mixed $value): ?array + { + if (! is_array($value)) { return null; } + // Common cases: + // - list of Media ULIDs (strings) + // - list of Uploadcare UUIDs / CDN URLs (strings) + // - list of Uploadcare file arrays (arrays with uuid / fileInfo.uuid) + if (array_is_list($value)) { + $hydrated = []; + foreach ($value as $item) { + $media = self::resolveMediaFromMixedValue($item); + if ($media) { + $hydrated[] = $media->load('edits'); + } + } + + return ! empty($hydrated) ? $hydrated : null; + } + $mediaModel = self::getMediaModel(); - $mediaItems = $mediaModel::whereIn('ulid', array_filter(Arr::flatten($value), 'is_string'))->get(); - $hydrated = []; + + // Find all strings that look like ULIDs + $potentialUlids = array_filter(Arr::flatten($value), function ($item) { + return is_string($item) && ! json_validate($item); + }); - foreach ($value as $ulid) { - $media = $mediaItems->firstWhere('ulid', $ulid); - if ($media) { - $hydrated[] = $media->load('edits'); - } + if (empty($potentialUlids)) { + return null; } + $mediaItems = $mediaModel::whereIn('ulid', $potentialUlids)->get(); + + $resolve = function ($item) use ($mediaItems, &$resolve) { + if (is_array($item)) { + return array_map($resolve, $item); + } + + if (is_string($item) && ! json_validate($item)) { + // Try to find media + $media = $mediaItems->firstWhere('ulid', $item); + if ($media) { + return $media->load('edits'); + } + + return null; + } + + return $item; + }; + + $hydrated = array_map($resolve, $value); + + // Filter out nulls from the top level (invalid ULIDs) + $hydrated = array_values(array_filter($hydrated)); + return ! empty($hydrated) ? $hydrated : null; } + } diff --git a/src/UploadcareFieldServiceProvider.php b/src/UploadcareFieldServiceProvider.php index e8dce28..8a31289 100644 --- a/src/UploadcareFieldServiceProvider.php +++ b/src/UploadcareFieldServiceProvider.php @@ -16,6 +16,7 @@ public function configurePackage(Package $package): void ->hasMigrations([ '2025_08_08_000000_fix_uploadcare_double_encoded_json', '2025_12_08_163311_normalize_uploadcare_values_to_ulids', + '2025_12_17_000001_repair_uploadcare_media_relationships', ]) ->hasAssets() ->hasViews(); From d27fa5fad609275193f16d44003d045b33a4cab8 Mon Sep 17 00:00:00 2001 From: Baspa <10845460+Baspa@users.noreply.github.com> Date: Fri, 19 Dec 2025 08:14:57 +0000 Subject: [PATCH 05/29] fix: styling --- ...1_repair_uploadcare_media_relationships.php | 3 +-- src/Observers/ContentFieldValueObserver.php | 2 +- src/Uploadcare.php | 18 ++++++++---------- 3 files changed, 10 insertions(+), 13 deletions(-) diff --git a/database/migrations/2025_12_17_000001_repair_uploadcare_media_relationships.php b/database/migrations/2025_12_17_000001_repair_uploadcare_media_relationships.php index 15df33e..70ccf9d 100644 --- a/database/migrations/2025_12_17_000001_repair_uploadcare_media_relationships.php +++ b/database/migrations/2025_12_17_000001_repair_uploadcare_media_relationships.php @@ -271,6 +271,7 @@ public function up(): void $position++; $ensureRelationship($rowUlid, $media->ulid, $position, $meta); $newUlids[] = $media->ulid; + continue; } @@ -345,5 +346,3 @@ public function down(): void // } }; - - diff --git a/src/Observers/ContentFieldValueObserver.php b/src/Observers/ContentFieldValueObserver.php index 4ebb0e9..2e51482 100644 --- a/src/Observers/ContentFieldValueObserver.php +++ b/src/Observers/ContentFieldValueObserver.php @@ -40,7 +40,7 @@ public function saved(ContentFieldValue $contentFieldValue): void return; } } - + $this->syncRelationships($contentFieldValue, $mediaData, $modifiedValue); } diff --git a/src/Uploadcare.php b/src/Uploadcare.php index a0dfc83..f95384a 100755 --- a/src/Uploadcare.php +++ b/src/Uploadcare.php @@ -43,14 +43,14 @@ public static function make(string $name, Field $field): Input input: Input::make($name) ->withMetadata() ->removeCopyright() - ->dehydrateStateUsing(function ($state) use ($name, $field) { + ->dehydrateStateUsing(function ($state) { if (is_string($state) && json_validate($state)) { return json_decode($state, true); } return $state; }) - ->afterStateHydrated(function ($component, $state) use ($name, $field) { + ->afterStateHydrated(function ($component, $state) { $newState = $state; if (is_string($state) && json_validate($state)) { @@ -58,13 +58,12 @@ public static function make(string $name, Field $field): Input } if ($newState !== $state) { - $component->state($newState); + $component->state($newState); } }), field: $field ); - $isMultiple = $field->config['multiple'] ?? self::getDefaultConfig()['multiple']; $acceptedFileTypes = self::parseAcceptedFileTypes($field); @@ -874,7 +873,7 @@ private static function hydrateBackstageUlids(mixed $value): ?array } $mediaModel = self::getMediaModel(); - + // Find all strings that look like ULIDs $potentialUlids = array_filter(Arr::flatten($value), function ($item) { return is_string($item) && ! json_validate($item); @@ -885,7 +884,7 @@ private static function hydrateBackstageUlids(mixed $value): ?array } $mediaItems = $mediaModel::whereIn('ulid', $potentialUlids)->get(); - + $resolve = function ($item) use ($mediaItems, &$resolve) { if (is_array($item)) { return array_map($resolve, $item); @@ -895,21 +894,20 @@ private static function hydrateBackstageUlids(mixed $value): ?array // Try to find media $media = $mediaItems->firstWhere('ulid', $item); if ($media) { - return $media->load('edits'); + return $media->load('edits'); } return null; } - + return $item; }; $hydrated = array_map($resolve, $value); - + // Filter out nulls from the top level (invalid ULIDs) $hydrated = array_values(array_filter($hydrated)); return ! empty($hydrated) ? $hydrated : null; } - } From c8d7fcd334d65211d19acb3c74625f140f3156f7 Mon Sep 17 00:00:00 2001 From: Baspa Date: Mon, 22 Dec 2025 17:46:43 +0100 Subject: [PATCH 06/29] fix: saving and reading Uploadccare metadata --- src/Observers/ContentFieldValueObserver.php | 9 +- src/Uploadcare.php | 149 +++++++++++++++++--- 2 files changed, 135 insertions(+), 23 deletions(-) diff --git a/src/Observers/ContentFieldValueObserver.php b/src/Observers/ContentFieldValueObserver.php index 2e51482..4f148cf 100644 --- a/src/Observers/ContentFieldValueObserver.php +++ b/src/Observers/ContentFieldValueObserver.php @@ -150,6 +150,9 @@ private function parseItem(mixed $item): array $uuidOffset = $matches[1][1] ?? null; $uuidLen = strlen($uuid); $modifiers = ($uuidOffset !== null) ? substr($item, $uuidOffset + $uuidLen) : ''; + if (! empty($modifiers) && $modifiers[0] === '/') { + $modifiers = substr($modifiers, 1); + } $meta = [ 'cdnUrl' => $item, 'cdnUrlModifiers' => $modifiers, @@ -173,8 +176,12 @@ private function parseItem(mixed $item): array $uuidOffset = $matches[1][1] ?? null; $uuidLen = strlen($foundUuid); $modifiers = ($uuidOffset !== null) ? substr($item['cdnUrl'], $uuidOffset + $uuidLen) : ''; + + if (! empty($modifiers) && $modifiers[0] === '/') { + $modifiers = substr($modifiers, 1); + } if (! empty($modifiers)) { - $meta['cdnUrlModifiers'] = $modifiers; + $meta['cdnUrlModifiers'] = $meta['cdnUrlModifiers'] ?? $modifiers; $meta['cdnUrl'] = $item['cdnUrl']; // Ensure url matches } } diff --git a/src/Uploadcare.php b/src/Uploadcare.php index f95384a..458288a 100755 --- a/src/Uploadcare.php +++ b/src/Uploadcare.php @@ -737,29 +737,68 @@ private static function normalizeCurrentState(mixed $state): array public function hydrate(mixed $value, ?Model $model = null): mixed { - // Try to load from model relationship if available - $hydratedFromModel = self::hydrateFromModel($model); - - if ($hydratedFromModel !== null) { - return $hydratedFromModel; - } - + // If value is null or empty, return early (don't load all media from relationship) if (empty($value)) { return $value; } - $mediaModel = self::getMediaModel(); - + // Normalize value first if (is_string($value) && json_validate($value)) { $decoded = json_decode($value, true); - if (is_array($decoded)) { $value = $decoded; } } + // Try to load from model relationship if available + // Pass the value so hydrateFromModel can filter by ULIDs if needed + $hydratedFromModel = self::hydrateFromModel($model, $value); + + if ($hydratedFromModel !== null && $hydratedFromModel->isNotEmpty()) { + // Always return an array of Media instances for consistency + return $hydratedFromModel->all(); + } + + $mediaModel = self::getMediaModel(); + if (is_string($value) && ! json_validate($value)) { - return $mediaModel::where('ulid', $value)->first() ?? $value; + // Check if it's a ULID + if (preg_match('/^[0-9A-HJKMNP-TV-Z]{26}$/i', $value)) { + $media = $mediaModel::where('ulid', $value)->first(); + return $media ? [$media] : $value; + } + + // Check if it's a CDN URL - try to extract UUID and load Media + if (filter_var($value, FILTER_VALIDATE_URL) && (str_contains($value, 'ucarecdn.com') || str_contains($value, 'ucarecd.net'))) { + $uuid = self::extractUuidFromString($value); + if ($uuid) { + $media = $mediaModel::where('filename', $uuid)->first(); + if ($media) { + // Extract modifiers from URL if present + $cdnUrlModifiers = null; + $uuidPos = strpos($value, $uuid); + if ($uuidPos !== false) { + $modifiers = substr($value, $uuidPos + strlen($uuid)); + if (! empty($modifiers) && $modifiers[0] === '/') { + $cdnUrlModifiers = substr($modifiers, 1); + } elseif (! empty($modifiers)) { + $cdnUrlModifiers = $modifiers; + } + } + + // Attach the CDN URL as edit metadata + $media->setAttribute('edit', [ + 'uuid' => $uuid, + 'cdnUrl' => $value, + 'cdnUrlModifiers' => $cdnUrlModifiers, + ]); + + return [$media]; + } + } + } + + return $value; } $hydratedUlids = self::hydrateBackstageUlids($value); @@ -770,28 +809,94 @@ public function hydrate(mixed $value, ?Model $model = null): mixed return $value; } - private static function hydrateFromModel(?Model $model): ?array + private static function hydrateFromModel(?Model $model, mixed $value = null): ?\Illuminate\Support\Collection { if (! $model || ! method_exists($model, 'media')) { return null; } - if (! $model->relationLoaded('media')) { - $model->load('media'); + // Extract ULIDs from value if it's an array + $ulids = null; + if (is_array($value) && ! empty($value)) { + $ulids = array_filter(Arr::flatten($value), function ($item) { + return is_string($item) && preg_match('/^[0-9A-HJKMNP-TV-Z]{26}$/i', $item); + }); + $ulids = array_values($ulids); // Re-index } - if ($model->media->isEmpty()) { + $mediaQuery = $model->media()->withPivot(['meta', 'position'])->distinct(); + + if (! empty($ulids)) { + $mediaQuery->whereIn('media_ulid', $ulids) + ->orderByRaw('FIELD(media_ulid, ' . implode(',', array_fill(0, count($ulids), '?')) . ')', $ulids); + } + + $media = $mediaQuery->get()->unique('ulid'); + + if ($media->isEmpty()) { return null; } - return $model->media->map(function ($media) { - $meta = $media->pivot->meta ? json_decode($media->pivot->meta, true) : []; + try { + return $media->map(function ($mediaItem) { + $meta = null; + if (isset($mediaItem->pivot) && isset($mediaItem->pivot->meta)) { + $meta = is_string($mediaItem->pivot->meta) + ? json_decode($mediaItem->pivot->meta, true) + : $mediaItem->pivot->meta; + } + $meta = is_array($meta) ? $meta : []; + + // Get base metadata + $metadata = is_string($mediaItem->metadata) + ? json_decode($mediaItem->metadata, true) + : $mediaItem->metadata; + $metadata = is_array($metadata) ? $metadata : []; - return array_merge($media->toArray(), $meta, [ - 'uuid' => $media->filename, - 'cdnUrl' => $meta['cdnUrl'] ?? $media->metadata['cdnUrl'] ?? null, - ]); - })->toArray(); + // Merge pivot meta (cropped data) with base metadata, pivot takes precedence + $mergedMeta = array_merge($metadata, $meta); + + // Ensure cdnUrlModifiers is included from pivot meta + $cdnUrl = $mergedMeta['cdnUrl'] ?? $metadata['cdnUrl'] ?? null; + $cdnUrlModifiers = $mergedMeta['cdnUrlModifiers'] ?? null; + + // If we have a cdnUrl with modifiers but no explicit cdnUrlModifiers, extract from URL + if (! $cdnUrlModifiers && $cdnUrl && is_string($cdnUrl)) { + $uuid = self::extractUuidFromString($cdnUrl); + if ($uuid) { + $uuidPos = strpos($cdnUrl, $uuid); + if ($uuidPos !== false) { + $modifiers = substr($cdnUrl, $uuidPos + strlen($uuid)); + if (! empty($modifiers) && $modifiers[0] === '/') { + $cdnUrlModifiers = substr($modifiers, 1); + } elseif (! empty($modifiers)) { + $cdnUrlModifiers = $modifiers; + } + } + } + } + + // Add cdnUrlModifiers to merged meta if extracted + if ($cdnUrlModifiers) { + $mergedMeta['cdnUrlModifiers'] = $cdnUrlModifiers; + } + if ($cdnUrl) { + $mergedMeta['cdnUrl'] = $cdnUrl; + } + if (! isset($mergedMeta['uuid'])) { + $mergedMeta['uuid'] = $mediaItem->filename; + } + + // Attach merged metadata to Media object's edit property (used by UploadcareService) + $mediaItem->setAttribute('edit', $mergedMeta); + + return $mediaItem; + }) + ->filter() // Remove any null items + ->values(); + } catch (\Throwable $e) { + return null; + } } private static function resolveMediaFromMixedValue(mixed $item): ?Model From a6ce53fdea5bdbec00bbedd80e6129cda76cfe3c Mon Sep 17 00:00:00 2001 From: Baspa <10845460+Baspa@users.noreply.github.com> Date: Mon, 22 Dec 2025 16:47:38 +0000 Subject: [PATCH 07/29] fix: styling --- src/Uploadcare.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Uploadcare.php b/src/Uploadcare.php index 458288a..a83d13c 100755 --- a/src/Uploadcare.php +++ b/src/Uploadcare.php @@ -765,6 +765,7 @@ public function hydrate(mixed $value, ?Model $model = null): mixed // Check if it's a ULID if (preg_match('/^[0-9A-HJKMNP-TV-Z]{26}$/i', $value)) { $media = $mediaModel::where('ulid', $value)->first(); + return $media ? [$media] : $value; } @@ -892,8 +893,8 @@ private static function hydrateFromModel(?Model $model, mixed $value = null): ?\ return $mediaItem; }) - ->filter() // Remove any null items - ->values(); + ->filter() // Remove any null items + ->values(); } catch (\Throwable $e) { return null; } From ae1ce772e84c7214a06aaab5531b6fd781054bd9 Mon Sep 17 00:00:00 2001 From: Baspa Date: Mon, 29 Dec 2025 14:25:01 +0100 Subject: [PATCH 08/29] fix: force casting to `string` to prevent `array` error --- src/Uploadcare.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Uploadcare.php b/src/Uploadcare.php index a83d13c..6d3561c 100755 --- a/src/Uploadcare.php +++ b/src/Uploadcare.php @@ -71,14 +71,14 @@ public static function make(string $name, Field $field): Input fn (Input $component) => Action::make('mediaPicker') ->schemaComponent($component) ->hiddenLabel() - ->tooltip(__('Select from Media')) + ->tooltip((string) __('Select from Media')) ->icon(Heroicon::Photo) ->color('gray') ->size('sm') - ->modalHeading(__('Select Media')) + ->modalHeading((string) __('Select Media')) ->modalWidth('Screen') - ->modalCancelActionLabel(__('Cancel')) - ->modalSubmitActionLabel(__('Select')) + ->modalCancelActionLabel((string) __('Cancel')) + ->modalSubmitActionLabel((string) __('Select')) ->action(function (Action $action, array $data, Input $component) { $selected = $data['selected_media_uuid'] ?? null; if (! $selected) { From f6a718481169357734d3e4cf22b09011352e4e9c Mon Sep 17 00:00:00 2001 From: Baspa Date: Mon, 29 Dec 2025 15:48:49 +0100 Subject: [PATCH 09/29] feat: temporarily disable translation strings --- src/Uploadcare.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Uploadcare.php b/src/Uploadcare.php index 6d3561c..dc8dddb 100755 --- a/src/Uploadcare.php +++ b/src/Uploadcare.php @@ -71,14 +71,14 @@ public static function make(string $name, Field $field): Input fn (Input $component) => Action::make('mediaPicker') ->schemaComponent($component) ->hiddenLabel() - ->tooltip((string) __('Select from Media')) + ->tooltip('Select from Media') ->icon(Heroicon::Photo) ->color('gray') ->size('sm') - ->modalHeading((string) __('Select Media')) + ->modalHeading('Select Media') ->modalWidth('Screen') - ->modalCancelActionLabel((string) __('Cancel')) - ->modalSubmitActionLabel((string) __('Select')) + ->modalCancelActionLabel('Cancel') + ->modalSubmitActionLabel('Select') ->action(function (Action $action, array $data, Input $component) { $selected = $data['selected_media_uuid'] ?? null; if (! $selected) { From 3b8e2726768f7890e42803e8748c3defd8f4fc15 Mon Sep 17 00:00:00 2001 From: Baspa Date: Wed, 31 Dec 2025 08:32:55 +0100 Subject: [PATCH 10/29] fix: hydrate differently for front- and backend --- src/Uploadcare.php | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/src/Uploadcare.php b/src/Uploadcare.php index dc8dddb..28333f9 100755 --- a/src/Uploadcare.php +++ b/src/Uploadcare.php @@ -53,7 +53,13 @@ public static function make(string $name, Field $field): Input ->afterStateHydrated(function ($component, $state) { $newState = $state; - if (is_string($state) && json_validate($state)) { + if ($state instanceof \Illuminate\Support\Collection) { + $newState = $state->map(fn ($item) => $item instanceof Model ? self::mapMediaToValue($item) : $item)->all(); + } elseif (is_array($state) && isset($state[0]) && $state[0] instanceof Model) { + $newState = array_map(fn ($item) => self::mapMediaToValue($item), $state); + } elseif ($state instanceof Model) { + $newState = self::mapMediaToValue($state); + } elseif (is_string($state) && json_validate($state)) { $newState = json_decode($state, true); } @@ -755,7 +761,6 @@ public function hydrate(mixed $value, ?Model $model = null): mixed $hydratedFromModel = self::hydrateFromModel($model, $value); if ($hydratedFromModel !== null && $hydratedFromModel->isNotEmpty()) { - // Always return an array of Media instances for consistency return $hydratedFromModel->all(); } @@ -787,7 +792,6 @@ public function hydrate(mixed $value, ?Model $model = null): mixed } } - // Attach the CDN URL as edit metadata $media->setAttribute('edit', [ 'uuid' => $uuid, 'cdnUrl' => $value, @@ -810,6 +814,17 @@ public function hydrate(mixed $value, ?Model $model = null): mixed return $value; } + private static function mapMediaToValue(Model $media): array + { + $data = $media->edit ?? $media->metadata; + + if (is_string($data)) { + $data = json_decode($data, true); + } + + return is_array($data) ? $data : []; + } + private static function hydrateFromModel(?Model $model, mixed $value = null): ?\Illuminate\Support\Collection { if (! $model || ! method_exists($model, 'media')) { From 52526392142e0a7f8e0eec48485ab2784f208219 Mon Sep 17 00:00:00 2001 From: Baspa Date: Wed, 31 Dec 2025 13:59:49 +0100 Subject: [PATCH 11/29] wip --- src/Uploadcare.php | 35 ++--------------------------------- 1 file changed, 2 insertions(+), 33 deletions(-) diff --git a/src/Uploadcare.php b/src/Uploadcare.php index 28333f9..41c2bd2 100755 --- a/src/Uploadcare.php +++ b/src/Uploadcare.php @@ -444,12 +444,12 @@ private static function processUploadedFiles(array $files): array if (self::isArrayOfArrays($normalizedFiles)) { foreach ($normalizedFiles as $singleFile) { - if ($singleFile !== null && ! self::shouldSkipFile($singleFile)) { + if ($singleFile !== null) { $media[] = self::createOrUpdateMediaRecord($singleFile); } } } else { - if (is_array($normalizedFiles) && ! self::shouldSkipFile($normalizedFiles)) { + if (is_array($normalizedFiles)) { $media[] = self::createOrUpdateMediaRecord($normalizedFiles); } } @@ -476,37 +476,6 @@ private static function normalizeFileData(mixed $file): mixed return $file; } - private static function shouldSkipFile(mixed $file): bool - { - if ($file === null || (! is_array($file) && ! is_string($file))) { - return true; - } - - if (self::isArrayOfArrays($file)) { - foreach ($file as $index => $singleFile) { - if (self::shouldSkipFile($singleFile)) { - return true; - } - } - - return false; - } - - if (is_string($file)) { - $uuid = self::extractUuidFromString($file); - - return $uuid ? self::mediaExistsByUuid($uuid) : false; - } - - if (is_array($file)) { - $uuid = $file['uuid'] ?? $file['fileInfo']['uuid'] ?? null; - - return $uuid ? self::mediaExistsByUuid($uuid) : false; - } - - return false; - } - private static function mediaExistsByUuid(string $uuid): bool { $mediaModel = self::getMediaModel(); From 4b2e17d9e0c33010bf0eaa7e644a4624d1a9fc33 Mon Sep 17 00:00:00 2001 From: Baspa Date: Wed, 31 Dec 2025 14:31:02 +0100 Subject: [PATCH 12/29] wip --- src/Observers/ContentFieldValueObserver.php | 24 ++++++++++----------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/src/Observers/ContentFieldValueObserver.php b/src/Observers/ContentFieldValueObserver.php index 4f148cf..a14e800 100644 --- a/src/Observers/ContentFieldValueObserver.php +++ b/src/Observers/ContentFieldValueObserver.php @@ -14,7 +14,9 @@ public function saved(ContentFieldValue $contentFieldValue): void return; } + \Log::info('[Uploadcare Debug] Observer saved called for field ULID: ' . $contentFieldValue->field_ulid); $value = $contentFieldValue->getAttribute('value'); + \Log::info('[Uploadcare Debug] Observer value:', ['value' => $value]); // Normalize initial value: it could be a raw JSON string or already an array/object if (is_string($value)) { @@ -33,14 +35,6 @@ public function saved(ContentFieldValue $contentFieldValue): void $mediaData = []; $modifiedValue = $this->processValueRecursively($value, $mediaData); - if (empty($mediaData) && $value === $modifiedValue) { - // If there were previously media relations, but no media is found now (e.g. field cleared), - // ensure we detach stale relationships. - if (! $contentFieldValue->media()->exists()) { - return; - } - } - $this->syncRelationships($contentFieldValue, $mediaData, $modifiedValue); } @@ -211,13 +205,17 @@ private function resolveMediaUlid(string $uuid): ?string private function syncRelationships(ContentFieldValue $contentFieldValue, array $mediaData, mixed $modifiedValue): void { DB::transaction(function () use ($contentFieldValue, $mediaData, $modifiedValue) { + \Log::info('[Uploadcare Debug] Detaching relationships for ContentFieldValue: ' . $contentFieldValue->ulid); $contentFieldValue->media()->detach(); - foreach ($mediaData as $data) { - $contentFieldValue->media()->attach($data['media_ulid'], [ - 'position' => $data['position'], - 'meta' => $data['meta'], - ]); + if (! empty($mediaData)) { + \Log::info('[Uploadcare Debug] Attaching media items:', ['count' => count($mediaData)]); + foreach ($mediaData as $data) { + $contentFieldValue->media()->attach($data['media_ulid'], [ + 'position' => $data['position'], + 'meta' => $data['meta'], + ]); + } } $contentFieldValue->updateQuietly(['value' => json_encode($modifiedValue)]); From a162f01126eab4443b2f5b3ceb601d7c98b64731 Mon Sep 17 00:00:00 2001 From: Baspa Date: Fri, 2 Jan 2026 11:13:47 +0100 Subject: [PATCH 13/29] fix: refreshing and persisting form states (create & create another) --- src/Uploadcare.php | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/src/Uploadcare.php b/src/Uploadcare.php index 41c2bd2..7a7e233 100755 --- a/src/Uploadcare.php +++ b/src/Uploadcare.php @@ -275,7 +275,7 @@ public static function mutateBeforeSaveCallback(Model $record, Field $field, arr return $data; } - $values = self::findFieldValues($data[$record->valueColumn] ?? [], (string) $field->ulid); + $values = self::findFieldValues($data[$record->valueColumn] ?? [], $field); if ($values === '' || $values === [] || $values === null) { $data[$record->valueColumn][$field->ulid] = null; @@ -397,15 +397,19 @@ private static function extractMediaUrls(array $mediaUlids, bool $withMetadata = ->toArray(); } - private static function findFieldValues(array $data, string $fieldUlid): mixed + private static function findFieldValues(array $data, Field $field): mixed { - $findInNested = function ($array, $key) use (&$findInNested) { + $fieldUlid = (string) $field->ulid; + $fieldSlug = (string) $field->slug; + + + $findInNested = function ($array, $ulid, $slug) use (&$findInNested) { foreach ($array as $k => $value) { - if ($k === $key) { + if ($k === $ulid || $k === $slug) { return $value; } if (is_array($value)) { - $result = $findInNested($value, $key); + $result = $findInNested($value, $ulid, $slug); if ($result !== null) { return $result; } @@ -415,7 +419,10 @@ private static function findFieldValues(array $data, string $fieldUlid): mixed return null; }; - return $findInNested($data, $fieldUlid); + $result = $findInNested($data, $fieldUlid, $fieldSlug); + + + return $result; } private static function normalizeValues(mixed $values): mixed From fcfdabca7f1258fe750f348658b877f2961ff89c Mon Sep 17 00:00:00 2001 From: Baspa Date: Fri, 2 Jan 2026 12:49:59 +0100 Subject: [PATCH 14/29] fix: applying modifiers --- src/Observers/ContentFieldValueObserver.php | 4 - src/Uploadcare.php | 93 ++++++--------------- 2 files changed, 24 insertions(+), 73 deletions(-) diff --git a/src/Observers/ContentFieldValueObserver.php b/src/Observers/ContentFieldValueObserver.php index a14e800..fc885d8 100644 --- a/src/Observers/ContentFieldValueObserver.php +++ b/src/Observers/ContentFieldValueObserver.php @@ -14,9 +14,7 @@ public function saved(ContentFieldValue $contentFieldValue): void return; } - \Log::info('[Uploadcare Debug] Observer saved called for field ULID: ' . $contentFieldValue->field_ulid); $value = $contentFieldValue->getAttribute('value'); - \Log::info('[Uploadcare Debug] Observer value:', ['value' => $value]); // Normalize initial value: it could be a raw JSON string or already an array/object if (is_string($value)) { @@ -205,11 +203,9 @@ private function resolveMediaUlid(string $uuid): ?string private function syncRelationships(ContentFieldValue $contentFieldValue, array $mediaData, mixed $modifiedValue): void { DB::transaction(function () use ($contentFieldValue, $mediaData, $modifiedValue) { - \Log::info('[Uploadcare Debug] Detaching relationships for ContentFieldValue: ' . $contentFieldValue->ulid); $contentFieldValue->media()->detach(); if (! empty($mediaData)) { - \Log::info('[Uploadcare Debug] Attaching media items:', ['count' => count($mediaData)]); foreach ($mediaData as $data) { $contentFieldValue->media()->attach($data['media_ulid'], [ 'position' => $data['position'], diff --git a/src/Uploadcare.php b/src/Uploadcare.php index 7a7e233..38ffeaa 100755 --- a/src/Uploadcare.php +++ b/src/Uploadcare.php @@ -55,7 +55,7 @@ public static function make(string $name, Field $field): Input if ($state instanceof \Illuminate\Support\Collection) { $newState = $state->map(fn ($item) => $item instanceof Model ? self::mapMediaToValue($item) : $item)->all(); - } elseif (is_array($state) && isset($state[0]) && $state[0] instanceof Model) { + } elseif (is_array($state) && isset($state[0]) && ($state[0] instanceof Model || is_array($state[0]))) { $newState = array_map(fn ($item) => self::mapMediaToValue($item), $state); } elseif ($state instanceof Model) { $newState = self::mapMediaToValue($state); @@ -384,10 +384,11 @@ private static function extractMediaUrls(array $mediaUlids, bool $withMetadata = } if ($withMetadata) { - return array_merge($metadata, array_filter([ + $result = array_merge($metadata, array_filter([ 'uuid' => $uuid, 'cdnUrl' => $cdnUrl, ])); + return $result; } return $cdnUrl; @@ -719,8 +720,11 @@ private static function normalizeCurrentState(mixed $state): array public function hydrate(mixed $value, ?Model $model = null): mixed { + file_put_contents('/tmp/uploadcare_hydrate.log', "[" . date('H:i:s') . "] hydrate called. Value type: " . gettype($value) . ", Value: " . print_r($value, true) . "\n", FILE_APPEND); + // If value is null or empty, return early (don't load all media from relationship) if (empty($value)) { + file_put_contents('/tmp/uploadcare_hydrate.log', " > Value empty, returning.\n", FILE_APPEND); return $value; } @@ -736,8 +740,8 @@ public function hydrate(mixed $value, ?Model $model = null): mixed // Pass the value so hydrateFromModel can filter by ULIDs if needed $hydratedFromModel = self::hydrateFromModel($model, $value); - if ($hydratedFromModel !== null && $hydratedFromModel->isNotEmpty()) { - return $hydratedFromModel->all(); + if ($hydratedFromModel !== null && ! empty($hydratedFromModel)) { + return $hydratedFromModel; } $mediaModel = self::getMediaModel(); @@ -790,8 +794,12 @@ public function hydrate(mixed $value, ?Model $model = null): mixed return $value; } - private static function mapMediaToValue(Model $media): array + private static function mapMediaToValue(Model|array $media): array { + if (is_array($media)) { + return $media; + } + $data = $media->edit ?? $media->metadata; if (is_string($data)) { @@ -801,8 +809,9 @@ private static function mapMediaToValue(Model $media): array return is_array($data) ? $data : []; } - private static function hydrateFromModel(?Model $model, mixed $value = null): ?\Illuminate\Support\Collection + private static function hydrateFromModel(?Model $model, mixed $value = null): ?array { + if (! $model || ! method_exists($model, 'media')) { return null; } @@ -825,70 +834,16 @@ private static function hydrateFromModel(?Model $model, mixed $value = null): ?\ $media = $mediaQuery->get()->unique('ulid'); - if ($media->isEmpty()) { - return null; - } - - try { - return $media->map(function ($mediaItem) { - $meta = null; - if (isset($mediaItem->pivot) && isset($mediaItem->pivot->meta)) { - $meta = is_string($mediaItem->pivot->meta) - ? json_decode($mediaItem->pivot->meta, true) - : $mediaItem->pivot->meta; - } - $meta = is_array($meta) ? $meta : []; - - // Get base metadata - $metadata = is_string($mediaItem->metadata) - ? json_decode($mediaItem->metadata, true) - : $mediaItem->metadata; - $metadata = is_array($metadata) ? $metadata : []; - - // Merge pivot meta (cropped data) with base metadata, pivot takes precedence - $mergedMeta = array_merge($metadata, $meta); - - // Ensure cdnUrlModifiers is included from pivot meta - $cdnUrl = $mergedMeta['cdnUrl'] ?? $metadata['cdnUrl'] ?? null; - $cdnUrlModifiers = $mergedMeta['cdnUrlModifiers'] ?? null; - - // If we have a cdnUrl with modifiers but no explicit cdnUrlModifiers, extract from URL - if (! $cdnUrlModifiers && $cdnUrl && is_string($cdnUrl)) { - $uuid = self::extractUuidFromString($cdnUrl); - if ($uuid) { - $uuidPos = strpos($cdnUrl, $uuid); - if ($uuidPos !== false) { - $modifiers = substr($cdnUrl, $uuidPos + strlen($uuid)); - if (! empty($modifiers) && $modifiers[0] === '/') { - $cdnUrlModifiers = substr($modifiers, 1); - } elseif (! empty($modifiers)) { - $cdnUrlModifiers = $modifiers; - } - } - } - } - - // Add cdnUrlModifiers to merged meta if extracted - if ($cdnUrlModifiers) { - $mergedMeta['cdnUrlModifiers'] = $cdnUrlModifiers; - } - if ($cdnUrl) { - $mergedMeta['cdnUrl'] = $cdnUrl; - } - if (! isset($mergedMeta['uuid'])) { - $mergedMeta['uuid'] = $mediaItem->filename; - } - - // Attach merged metadata to Media object's edit property (used by UploadcareService) - $mediaItem->setAttribute('edit', $mergedMeta); + $media->each(function ($m) { + if ($m->pivot && $m->pivot->meta) { + $pivotMeta = is_string($m->pivot->meta) ? json_decode($m->pivot->meta, true) : $m->pivot->meta; + if (is_array($pivotMeta)) { + $m->setAttribute('edit', $pivotMeta); + } + } + }); - return $mediaItem; - }) - ->filter() // Remove any null items - ->values(); - } catch (\Throwable $e) { - return null; - } + return self::extractMediaUrls($media, true); } private static function resolveMediaFromMixedValue(mixed $item): ?Model From a8c9e9c02edfa7d2ecb9ad0fc74591e495134bdb Mon Sep 17 00:00:00 2001 From: Baspa <10845460+Baspa@users.noreply.github.com> Date: Fri, 2 Jan 2026 11:50:30 +0000 Subject: [PATCH 15/29] fix: styling --- src/Uploadcare.php | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/src/Uploadcare.php b/src/Uploadcare.php index 38ffeaa..03d3b2d 100755 --- a/src/Uploadcare.php +++ b/src/Uploadcare.php @@ -388,6 +388,7 @@ private static function extractMediaUrls(array $mediaUlids, bool $withMetadata = 'uuid' => $uuid, 'cdnUrl' => $cdnUrl, ])); + return $result; } @@ -402,7 +403,6 @@ private static function findFieldValues(array $data, Field $field): mixed { $fieldUlid = (string) $field->ulid; $fieldSlug = (string) $field->slug; - $findInNested = function ($array, $ulid, $slug) use (&$findInNested) { foreach ($array as $k => $value) { @@ -421,8 +421,7 @@ private static function findFieldValues(array $data, Field $field): mixed }; $result = $findInNested($data, $fieldUlid, $fieldSlug); - - + return $result; } @@ -720,11 +719,12 @@ private static function normalizeCurrentState(mixed $state): array public function hydrate(mixed $value, ?Model $model = null): mixed { - file_put_contents('/tmp/uploadcare_hydrate.log', "[" . date('H:i:s') . "] hydrate called. Value type: " . gettype($value) . ", Value: " . print_r($value, true) . "\n", FILE_APPEND); + file_put_contents('/tmp/uploadcare_hydrate.log', '[' . date('H:i:s') . '] hydrate called. Value type: ' . gettype($value) . ', Value: ' . print_r($value, true) . "\n", FILE_APPEND); // If value is null or empty, return early (don't load all media from relationship) if (empty($value)) { file_put_contents('/tmp/uploadcare_hydrate.log', " > Value empty, returning.\n", FILE_APPEND); + return $value; } @@ -794,10 +794,10 @@ public function hydrate(mixed $value, ?Model $model = null): mixed return $value; } - private static function mapMediaToValue(Model|array $media): array + private static function mapMediaToValue(Model | array $media): array { if (is_array($media)) { - return $media; + return $media; } $data = $media->edit ?? $media->metadata; @@ -835,12 +835,12 @@ private static function hydrateFromModel(?Model $model, mixed $value = null): ?a $media = $mediaQuery->get()->unique('ulid'); $media->each(function ($m) { - if ($m->pivot && $m->pivot->meta) { - $pivotMeta = is_string($m->pivot->meta) ? json_decode($m->pivot->meta, true) : $m->pivot->meta; - if (is_array($pivotMeta)) { - $m->setAttribute('edit', $pivotMeta); - } - } + if ($m->pivot && $m->pivot->meta) { + $pivotMeta = is_string($m->pivot->meta) ? json_decode($m->pivot->meta, true) : $m->pivot->meta; + if (is_array($pivotMeta)) { + $m->setAttribute('edit', $pivotMeta); + } + } }); return self::extractMediaUrls($media, true); From d76c7344663e56ff3cbdd7e79188e31775381787 Mon Sep 17 00:00:00 2001 From: Baspa Date: Fri, 2 Jan 2026 15:23:32 +0100 Subject: [PATCH 16/29] fix: hydrated values and relation fields --- src/Uploadcare.php | 28 ++++++++++++++++++---------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/src/Uploadcare.php b/src/Uploadcare.php index 38ffeaa..4058a9b 100755 --- a/src/Uploadcare.php +++ b/src/Uploadcare.php @@ -24,6 +24,11 @@ class Uploadcare extends Base implements FieldContract, HydratesValues { + public function getFieldType(): ?string + { + return 'uploadcare'; + } + public static function getDefaultConfig(): array { return [ @@ -256,10 +261,12 @@ public static function mutateFormDataCallback(Model $record, Field $field, array // Always return metadata for ULID-based values (default behavior), otherwise // the Uploadcare field may not be able to render a preview. $mediaData = self::extractMediaUrls($values, true); - $data[$record->valueColumn][$field->ulid] = $mediaData; + // Return as JSON string to avoid Array to String conversion errors in Filament + $data[$record->valueColumn][$field->ulid] = json_encode($mediaData); } else { $mediaUrls = self::extractCdnUrlsFromFileData($values); - $data[$record->valueColumn][$field->ulid] = $withMetadata ? $values : self::filterValidUrls($mediaUrls); + $result = $withMetadata ? $values : self::filterValidUrls($mediaUrls); + $data[$record->valueColumn][$field->ulid] = is_array($result) ? json_encode($result) : $result; } return $data; @@ -402,7 +409,6 @@ private static function findFieldValues(array $data, Field $field): mixed { $fieldUlid = (string) $field->ulid; $fieldSlug = (string) $field->slug; - $findInNested = function ($array, $ulid, $slug) use (&$findInNested) { foreach ($array as $k => $value) { @@ -741,7 +747,8 @@ public function hydrate(mixed $value, ?Model $model = null): mixed $hydratedFromModel = self::hydrateFromModel($model, $value); if ($hydratedFromModel !== null && ! empty($hydratedFromModel)) { - return $hydratedFromModel; + // Ensure result is string + return is_array($hydratedFromModel) ? json_encode($hydratedFromModel) : $hydratedFromModel; } $mediaModel = self::getMediaModel(); @@ -751,7 +758,8 @@ public function hydrate(mixed $value, ?Model $model = null): mixed if (preg_match('/^[0-9A-HJKMNP-TV-Z]{26}$/i', $value)) { $media = $mediaModel::where('ulid', $value)->first(); - return $media ? [$media] : $value; + $result = $media ? [$media] : $value; + return is_array($result) ? json_encode($result) : $result; } // Check if it's a CDN URL - try to extract UUID and load Media @@ -778,7 +786,7 @@ public function hydrate(mixed $value, ?Model $model = null): mixed 'cdnUrlModifiers' => $cdnUrlModifiers, ]); - return [$media]; + return json_encode([$media]); } } } @@ -788,10 +796,10 @@ public function hydrate(mixed $value, ?Model $model = null): mixed $hydratedUlids = self::hydrateBackstageUlids($value); if ($hydratedUlids !== null) { - return $hydratedUlids; + return json_encode($hydratedUlids); } - return $value; + return is_array($value) ? json_encode($value) : $value; } private static function mapMediaToValue(Model|array $media): array @@ -809,7 +817,7 @@ private static function mapMediaToValue(Model|array $media): array return is_array($data) ? $data : []; } - private static function hydrateFromModel(?Model $model, mixed $value = null): ?array + private static function hydrateFromModel(?Model $model, mixed $value = null): mixed { if (! $model || ! method_exists($model, 'media')) { @@ -843,7 +851,7 @@ private static function hydrateFromModel(?Model $model, mixed $value = null): ?a } }); - return self::extractMediaUrls($media, true); + return json_encode(self::extractMediaUrls($media, true)); } private static function resolveMediaFromMixedValue(mixed $item): ?Model From 2527e68682236eb8c7c9d7648421d740eb79c90d Mon Sep 17 00:00:00 2001 From: Baspa <10845460+Baspa@users.noreply.github.com> Date: Fri, 2 Jan 2026 14:24:23 +0000 Subject: [PATCH 17/29] fix: styling --- src/Uploadcare.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Uploadcare.php b/src/Uploadcare.php index 8ad28d4..049f209 100755 --- a/src/Uploadcare.php +++ b/src/Uploadcare.php @@ -760,6 +760,7 @@ public function hydrate(mixed $value, ?Model $model = null): mixed $media = $mediaModel::where('ulid', $value)->first(); $result = $media ? [$media] : $value; + return is_array($result) ? json_encode($result) : $result; } From dfd41ba37d96832da8f8f891a007f4c37f7f1469 Mon Sep 17 00:00:00 2001 From: Baspa Date: Mon, 5 Jan 2026 09:33:43 +0100 Subject: [PATCH 18/29] wip --- src/Uploadcare.php | 118 +++++++++++++++++++++++++++++++++++++-------- 1 file changed, 99 insertions(+), 19 deletions(-) diff --git a/src/Uploadcare.php b/src/Uploadcare.php index 8ad28d4..52ba6cb 100755 --- a/src/Uploadcare.php +++ b/src/Uploadcare.php @@ -63,7 +63,38 @@ public static function make(string $name, Field $field): Input } elseif (is_array($state) && isset($state[0]) && ($state[0] instanceof Model || is_array($state[0]))) { $newState = array_map(fn ($item) => self::mapMediaToValue($item), $state); } elseif ($state instanceof Model) { - $newState = self::mapMediaToValue($state); + $newState = [self::mapMediaToValue($state)]; + } elseif (is_array($state) && ! Arr::isList($state)) { + $newState = [self::mapMediaToValue($state)]; + } elseif (is_array($state) && Arr::isList($state) && count($state) > 1 && is_string($state[0]) && preg_match('/^[0-9A-Z]{26}$/i', $state[0])) { + // Handle "flattened" list case where keys are lost (e.g. Model to Array conversion quirks) + // Heuristic: Input is a list of property values [ULID, ..., UUID, ..., URL, ...] + // We reconstruct a valid single file object from this. + $uuid = null; + $cdnUrl = null; + $filename = null; + + foreach ($state as $item) { + if (!is_string($item)) continue; + if (!$uuid && preg_match('/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i', $item)) { + $uuid = $item; + } + if (!$cdnUrl && str_contains($item, 'ucarecd.net/') && filter_var($item, FILTER_VALIDATE_URL)) { + $cdnUrl = $item; + } + if (!$filename && preg_match('/\.[a-z0-9]{3,4}$/i', $item) && !str_starts_with($item, 'http')) { + $filename = $item; + } + } + + if ($cdnUrl) { + $newState = [[ + 'uuid' => $uuid, + 'cdnUrl' => $cdnUrl, + 'original_filename' => $filename ?? null, + 'name' => $filename ?? null, + ]]; + } } elseif (is_string($state) && json_validate($state)) { $newState = json_decode($state, true); } @@ -724,14 +755,11 @@ private static function normalizeCurrentState(mixed $state): array return $state; } + public ?Field $field_model = null; + public function hydrate(mixed $value, ?Model $model = null): mixed { - file_put_contents('/tmp/uploadcare_hydrate.log', '[' . date('H:i:s') . '] hydrate called. Value type: ' . gettype($value) . ', Value: ' . print_r($value, true) . "\n", FILE_APPEND); - - // If value is null or empty, return early (don't load all media from relationship) if (empty($value)) { - file_put_contents('/tmp/uploadcare_hydrate.log', " > Value empty, returning.\n", FILE_APPEND); - return $value; } @@ -743,24 +771,36 @@ public function hydrate(mixed $value, ?Model $model = null): mixed } } - // Try to load from model relationship if available - // Pass the value so hydrateFromModel can filter by ULIDs if needed - $hydratedFromModel = self::hydrateFromModel($model, $value); + // Try to hydrate from relation + $hydratedFromModel = self::hydrateFromModel($model, $value, true); if ($hydratedFromModel !== null && ! empty($hydratedFromModel)) { - // Ensure result is string - return is_array($hydratedFromModel) ? json_encode($hydratedFromModel) : $hydratedFromModel; + // Check config to decide if we should return single or multiple + $config = $this->field_model->config ?? $model->field->config ?? []; + $isMultiple = $config['multiple'] ?? false; + + if ($isMultiple) { + return $hydratedFromModel; + } + return $hydratedFromModel->first(); } $mediaModel = self::getMediaModel(); - + if (is_string($value) && ! json_validate($value)) { // Check if it's a ULID if (preg_match('/^[0-9A-HJKMNP-TV-Z]{26}$/i', $value)) { $media = $mediaModel::where('ulid', $value)->first(); - $result = $media ? [$media] : $value; - return is_array($result) ? json_encode($result) : $result; + // Check config to decide if we should return single or multiple + $config = $this->field_model->config ?? $model->field->config ?? []; + $isMultiple = $config['multiple'] ?? false; + + if ($isMultiple && $media) { + return new \Illuminate\Database\Eloquent\Collection([$media]); + } + + return $media ? [$media] : $value; } // Check if it's a CDN URL - try to extract UUID and load Media @@ -787,7 +827,15 @@ public function hydrate(mixed $value, ?Model $model = null): mixed 'cdnUrlModifiers' => $cdnUrlModifiers, ]); - return json_encode([$media]); + // Check config to decide if we should return single or multiple + $config = $this->field_model->config ?? $model->field->config ?? []; + $isMultiple = $config['multiple'] ?? false; + + if ($isMultiple) { + return new \Illuminate\Database\Eloquent\Collection([$media]); + } + + return [$media]; } } } @@ -795,12 +843,41 @@ public function hydrate(mixed $value, ?Model $model = null): mixed return $value; } + // Try manual hydration if relation hydration failed (e.g. pivot missing but media exists) $hydratedUlids = self::hydrateBackstageUlids($value); + if ($hydratedUlids !== null) { - return json_encode($hydratedUlids); + // Check if we need to return a single item based on config, even for manual hydration + // Priority: Local field model config -> Parent model field config + $config = $this->field_model->config ?? $model->field->config ?? []; + + // hydrateBackstageUlids returns an array, so we check if single + if (! ($config['multiple'] ?? false) && is_array($hydratedUlids) && ! empty($hydratedUlids)) { + // Wrap in collection first to match expected behavior if we were to return collection, + // but here we want single model + return $hydratedUlids[0]; + } + // If expected multiple, return collection + return new \Illuminate\Database\Eloquent\Collection($hydratedUlids); + } + + // If it looks like a list of ULIDs but failed to hydrate (e.g. media deleted), + // return an empty Collection (or null if single) instead of the raw string array. + if (is_array($value) && ! empty($value)) { + $first = reset($value); + $isString = is_string($first); + $matches = $isString ? preg_match('/^[0-9A-HJKMNP-TV-Z]{26}$/i', $first) : false; + + if ($isString && $matches) { + $config = $this->field_model->config ?? $model->field->config ?? []; + if (! ($config['multiple'] ?? false)) { + return null; + } + return new \Illuminate\Database\Eloquent\Collection(); + } } - return is_array($value) ? json_encode($value) : $value; + return $value; } private static function mapMediaToValue(Model | array $media): array @@ -818,9 +895,8 @@ private static function mapMediaToValue(Model | array $media): array return is_array($data) ? $data : []; } - private static function hydrateFromModel(?Model $model, mixed $value = null): mixed + private static function hydrateFromModel(?Model $model, mixed $value = null, bool $returnModels = false): mixed { - if (! $model || ! method_exists($model, 'media')) { return null; } @@ -852,6 +928,10 @@ private static function hydrateFromModel(?Model $model, mixed $value = null): mi } }); + if ($returnModels) { + return $media; + } + return json_encode(self::extractMediaUrls($media, true)); } From c378c0548ed9648b615035a885fc3694b5823ec9 Mon Sep 17 00:00:00 2001 From: Baspa Date: Mon, 5 Jan 2026 11:42:56 +0100 Subject: [PATCH 19/29] fix: loading crops --- src/Uploadcare.php | 227 +++++++++++++++++++++++++++++++++++---------- 1 file changed, 176 insertions(+), 51 deletions(-) diff --git a/src/Uploadcare.php b/src/Uploadcare.php index 52ba6cb..f40f44e 100755 --- a/src/Uploadcare.php +++ b/src/Uploadcare.php @@ -58,45 +58,112 @@ public static function make(string $name, Field $field): Input ->afterStateHydrated(function ($component, $state) { $newState = $state; - if ($state instanceof \Illuminate\Support\Collection) { + if ($state instanceof \Illuminate\Database\Eloquent\Collection) { $newState = $state->map(fn ($item) => $item instanceof Model ? self::mapMediaToValue($item) : $item)->all(); } elseif (is_array($state) && isset($state[0]) && ($state[0] instanceof Model || is_array($state[0]))) { $newState = array_map(fn ($item) => self::mapMediaToValue($item), $state); } elseif ($state instanceof Model) { $newState = [self::mapMediaToValue($state)]; - } elseif (is_array($state) && ! Arr::isList($state)) { + } elseif (is_array($state) && !empty($state) && is_array($state[0])) { $newState = [self::mapMediaToValue($state)]; - } elseif (is_array($state) && Arr::isList($state) && count($state) > 1 && is_string($state[0]) && preg_match('/^[0-9A-Z]{26}$/i', $state[0])) { - // Handle "flattened" list case where keys are lost (e.g. Model to Array conversion quirks) - // Heuristic: Input is a list of property values [ULID, ..., UUID, ..., URL, ...] - // We reconstruct a valid single file object from this. - $uuid = null; - $cdnUrl = null; - $filename = null; - - foreach ($state as $item) { - if (!is_string($item)) continue; - if (!$uuid && preg_match('/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i', $item)) { - $uuid = $item; - } - if (!$cdnUrl && str_contains($item, 'ucarecd.net/') && filter_var($item, FILTER_VALIDATE_URL)) { - $cdnUrl = $item; - } - if (!$filename && preg_match('/\.[a-z0-9]{3,4}$/i', $item) && !str_starts_with($item, 'http')) { - $filename = $item; - } - } + } elseif (is_array($state) && Arr::isList($state) && count($state) > 0 && is_string($state[0])) { + $potentialUlids = collect($state)->filter(fn($s) => preg_match('/^[0-9A-Z]{26}$/i', $s)); + $mediaModel = self::getMediaModel(); + $foundModels = new \Illuminate\Database\Eloquent\Collection(); + + $record = $component->getRecord(); + $fieldName = $component->getName(); + if ($record && $fieldName && $potentialUlids->isNotEmpty()) { + try { + $fieldUlid = $fieldName; + if (str_contains($fieldName, '.')) { + $fieldUlid = explode('.', $fieldName)[1] ?? $fieldName; + } + $fieldValue = \Backstage\Models\ContentFieldValue::where('content_ulid', $record->getKey()) + ->where('field_ulid', $fieldUlid) + ->first(); + + if ($fieldValue) { + $foundModels = $fieldValue->media() + ->whereIn('ulid', $potentialUlids) + ->get(); + } + } catch (\Exception $e) { + $foundModels = new \Illuminate\Database\Eloquent\Collection(); + } + } + + if ($foundModels->isEmpty() && $potentialUlids->isNotEmpty()) { + $foundModels = $mediaModel::whereIn('ulid', $potentialUlids)->get(); + } + + if ($foundModels->isNotEmpty()) { + if ($record) { + $foundModels->each(function($m) use ($record) { + if ($m->relationLoaded('pivot') && $m->pivot && $m->pivot->meta) { + $meta = is_string($m->pivot->meta) ? json_decode($m->pivot->meta, true) : $m->pivot->meta; + if (is_array($meta)) { + $m->setAttribute('hydrated_edit', $meta); + } + } + $contextModel = clone $record; + if ($m->relationLoaded('pivot') && $m->pivot) { + $contextModel->setRelation('pivot', $m->pivot); + } else { + $dummyPivot = new \Backstage\Models\ContentFieldValue(); + $dummyPivot->setAttribute('meta', null); + $contextModel->setRelation('pivot', $dummyPivot); + } + $m->setRelation('edits', new \Illuminate\Database\Eloquent\Collection([$contextModel])); + }); + } + + if ($foundModels->count() === 1 && count($state) > 1) { + $newState = [self::mapMediaToValue($foundModels->first())]; + } else { + $newState = $foundModels->map(fn($m) => self::mapMediaToValue($m))->all(); + } + } else { + $uuid = null; + $cdnUrl = null; + $filename = null; + $hasStructure = false; + + foreach ($state as $item) { + if (!is_string($item)) continue; + if (!$uuid && preg_match('/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i', $item)) { + $uuid = $item; + $hasStructure = true; + } + if (!$cdnUrl && str_contains($item, 'ucarecd.net/') && filter_var($item, FILTER_VALIDATE_URL)) { + $cdnUrl = $item; + $hasStructure = true; + } + if (!$filename && preg_match('/\.[a-z0-9]{3,4}$/i', $item) && !str_starts_with($item, 'http')) { + $filename = $item; + } + } + + if ($hasStructure && ($uuid || $cdnUrl)) { + $newState = [[ + 'uuid' => $uuid ?? self::extractUuidFromString($cdnUrl ?? ''), + 'cdnUrl' => $cdnUrl, + 'original_filename' => $filename, + 'name' => $filename, + ]]; + } else { + $newState = array_map(function($item) { + if (is_string($item) && json_validate($item)) { + return json_decode($item, true); + } + return $item; + }, $state); + } + } - if ($cdnUrl) { - $newState = [[ - 'uuid' => $uuid, - 'cdnUrl' => $cdnUrl, - 'original_filename' => $filename ?? null, - 'name' => $filename ?? null, - ]]; - } } elseif (is_string($state) && json_validate($state)) { $newState = json_decode($state, true); + } else { } if ($newState !== $state) { @@ -277,6 +344,7 @@ public static function mutateFormDataCallback(Model $record, Field $field, array return $data; } + $values = $record->values[$field->ulid]; if ($values == '' || $values == [] || $values == null || empty($values)) { @@ -289,9 +357,32 @@ public static function mutateFormDataCallback(Model $record, Field $field, array $values = self::parseValues($values); if (self::isMediaUlidArray($values)) { - // Always return metadata for ULID-based values (default behavior), otherwise - // the Uploadcare field may not be able to render a preview. - $mediaData = self::extractMediaUrls($values, true); + // Try to load via ContentFieldValue relation to ensure pivot data (crops) are included + $mediaData = null; + + if ($record->exists && class_exists(\Backstage\Models\ContentFieldValue::class)) { + try { + $cfv = \Backstage\Models\ContentFieldValue::where('content_ulid', $record->ulid) + ->where('field_ulid', $field->ulid) + ->first(); + + if ($cfv) { + $models = self::hydrateFromModel($cfv, $values, true); + if ($models && $models instanceof \Illuminate\Support\Collection) { + $mediaData = $models->map(fn ($m) => self::mapMediaToValue($m))->values()->all(); + } + } + } catch (\Exception $e) { + // Fallback to simple extraction + } + } + + if (empty($mediaData)) { + // Always return metadata for ULID-based values (default behavior), otherwise + // the Uploadcare field may not be able to render a preview. + $mediaData = self::extractMediaUrls($values, true); + } + // Return as JSON string to avoid Array to String conversion errors in Filament $data[$record->valueColumn][$field->ulid] = json_encode($mediaData); } else { @@ -322,6 +413,33 @@ public static function mutateBeforeSaveCallback(Model $record, Field $field, arr } $values = self::normalizeValues($values); + + + if (is_array($values) && !empty($values) && isset($values[0]) && is_string($values[0]) && preg_match('/^[0-9A-HJKMNP-TV-Z]{26}$/i', $values[0])) { + if ($record->exists && class_exists(\Backstage\Models\ContentFieldValue::class)) { + try { + $cfv = \Backstage\Models\ContentFieldValue::where('content_ulid', $record->ulid) + ->where('field_ulid', $field->ulid) + ->first(); + + if ($cfv) { + $fullyHydrated = []; + $existingMedia = $cfv->media->keyBy('ulid'); + + foreach ($values as $ulid) { + if ($existingMedia->has($ulid)) { + $mediaItem = $existingMedia->get($ulid); + $fullyHydrated[] = self::mapMediaToValue($mediaItem); + } else { + $fullyHydrated[] = $ulid; + } + } + $values = $fullyHydrated; + } + } catch (\Exception $e) { + } + } + } if (! is_array($values)) { return $data; @@ -329,8 +447,7 @@ public static function mutateBeforeSaveCallback(Model $record, Field $field, arr $media = self::processUploadedFiles($values); - // We save the full values including metadata so they can be processed by the Observer - // into relationships. The Observer will then clear the value column. + $data[$record->valueColumn][$field->ulid] = $values; return $data; @@ -760,7 +877,7 @@ private static function normalizeCurrentState(mixed $state): array public function hydrate(mixed $value, ?Model $model = null): mixed { if (empty($value)) { - return $value; + return null; } // Normalize value first @@ -886,7 +1003,13 @@ private static function mapMediaToValue(Model | array $media): array return $media; } - $data = $media->edit ?? $media->metadata; + $data = $media->hydrated_edit ?? $media->edit; + + if (empty($data) && $media->relationLoaded('pivot') && $media->pivot && $media->pivot->meta) { + $data = $media->pivot->meta; + } + + $data = $data ?? $media->metadata; if (is_string($data)) { $data = json_decode($data, true); @@ -901,13 +1024,13 @@ private static function hydrateFromModel(?Model $model, mixed $value = null, boo return null; } - // Extract ULIDs from value if it's an array + $ulids = null; if (is_array($value) && ! empty($value)) { $ulids = array_filter(Arr::flatten($value), function ($item) { return is_string($item) && preg_match('/^[0-9A-HJKMNP-TV-Z]{26}$/i', $item); }); - $ulids = array_values($ulids); // Re-index + $ulids = array_values($ulids); } $mediaQuery = $model->media()->withPivot(['meta', 'position'])->distinct(); @@ -919,11 +1042,16 @@ private static function hydrateFromModel(?Model $model, mixed $value = null, boo $media = $mediaQuery->get()->unique('ulid'); - $media->each(function ($m) { + $media->each(function ($m) use ($model) { if ($m->pivot && $m->pivot->meta) { $pivotMeta = is_string($m->pivot->meta) ? json_decode($m->pivot->meta, true) : $m->pivot->meta; if (is_array($pivotMeta)) { - $m->setAttribute('edit', $pivotMeta); + $m->setAttribute('hydrated_edit', $pivotMeta); + if ($model) { + $contextModel = clone $model; + $contextModel->setRelation('pivot', $m->pivot); + $m->setRelation('edits', new \Illuminate\Database\Eloquent\Collection([$contextModel])); + } } } }); @@ -932,7 +1060,7 @@ private static function hydrateFromModel(?Model $model, mixed $value = null, boo return $media; } - return json_encode(self::extractMediaUrls($media, true)); + return json_encode($media->map(fn ($m) => self::mapMediaToValue($m))->values()->all()); } private static function resolveMediaFromMixedValue(mixed $item): ?Model @@ -997,10 +1125,7 @@ private static function hydrateBackstageUlids(mixed $value): ?array return null; } - // Common cases: - // - list of Media ULIDs (strings) - // - list of Uploadcare UUIDs / CDN URLs (strings) - // - list of Uploadcare file arrays (arrays with uuid / fileInfo.uuid) + if (array_is_list($value)) { $hydrated = []; foreach ($value as $item) { @@ -1011,11 +1136,14 @@ private static function hydrateBackstageUlids(mixed $value): ?array } return ! empty($hydrated) ? $hydrated : null; + } elseif (is_array($value)) { + $media = self::resolveMediaFromMixedValue($value); + if ($media) { + return [$media->load('edits')]; + } } $mediaModel = self::getMediaModel(); - - // Find all strings that look like ULIDs $potentialUlids = array_filter(Arr::flatten($value), function ($item) { return is_string($item) && ! json_validate($item); }); @@ -1032,7 +1160,6 @@ private static function hydrateBackstageUlids(mixed $value): ?array } if (is_string($item) && ! json_validate($item)) { - // Try to find media $media = $mediaItems->firstWhere('ulid', $item); if ($media) { return $media->load('edits'); @@ -1045,8 +1172,6 @@ private static function hydrateBackstageUlids(mixed $value): ?array }; $hydrated = array_map($resolve, $value); - - // Filter out nulls from the top level (invalid ULIDs) $hydrated = array_values(array_filter($hydrated)); return ! empty($hydrated) ? $hydrated : null; From 6311f71e496c26f21f5238d9f33900c17e931473 Mon Sep 17 00:00:00 2001 From: Baspa Date: Mon, 5 Jan 2026 16:28:27 +0100 Subject: [PATCH 20/29] wip --- src/Observers/ContentFieldValueObserver.php | 68 ++- src/Uploadcare.php | 469 +++++++++++++++----- 2 files changed, 405 insertions(+), 132 deletions(-) diff --git a/src/Observers/ContentFieldValueObserver.php b/src/Observers/ContentFieldValueObserver.php index fc885d8..cf3265f 100644 --- a/src/Observers/ContentFieldValueObserver.php +++ b/src/Observers/ContentFieldValueObserver.php @@ -10,6 +10,13 @@ class ContentFieldValueObserver { public function saved(ContentFieldValue $contentFieldValue): void { + \Log::info("[OBSERVER DEBUG] ContentFieldValueObserver::saved triggered", [ + 'ulid' => $contentFieldValue->ulid, + 'field_ulid' => $contentFieldValue->field_ulid, + 'value_type' => gettype($contentFieldValue->value), + 'value_preview' => is_string($contentFieldValue->value) ? substr($contentFieldValue->value, 0, 100) : 'ARRAY/OBJ', + ]); + if (! $this->isValidField($contentFieldValue)) { return; } @@ -73,8 +80,11 @@ private function processValueRecursively(mixed $data, array &$mediaData): mixed // Check if this specific array node is an Uploadcare File object if ($this->isUploadcareValue($data)) { + $isList = array_is_list($data); + $items = $isList ? $data : [$data]; $newUlids = []; - foreach ($data as $index => $item) { + + foreach ($items as $item) { [$uuid, $meta] = $this->parseItem($item); if ($uuid) { $mediaUlid = $this->resolveMediaUlid($uuid); @@ -89,7 +99,7 @@ private function processValueRecursively(mixed $data, array &$mediaData): mixed } } - return $newUlids; + return $isList ? $newUlids : ($newUlids[0] ?? null); } foreach ($data as $key => $value) { @@ -101,32 +111,30 @@ private function processValueRecursively(mixed $data, array &$mediaData): mixed private function isUploadcareValue(array $data): bool { - // Must be a list (integer keys) - if (! array_is_list($data)) { - return false; - } - - // Check first item to see if it looks like an Uploadcare file structure if (empty($data)) { - return false; } - $first = $data[0]; - - // Existing logic used: isset($value['uuid']) - if (is_array($first) && isset($first['uuid'])) { - return true; - } + // If it's a list, check the first item + if (array_is_list($data)) { + $first = $data[0]; - if (is_string($first)) { - // UUID strings or URLs containing UUIDs - if (preg_match('/[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}/i', $first)) { + if (is_array($first) && isset($first['uuid'])) { return true; } + + if (is_string($first)) { + // UUID strings or URLs containing UUIDs + if (preg_match('/[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}/i', $first)) { + return true; + } + } + + return false; } - return false; + // It's an associative array (single object). Check for uuid/cdnUrl. + return isset($data['uuid']) || (isset($data['cdnUrl']) && is_string($data['cdnUrl'])); } private function parseItem(mixed $item): array @@ -145,6 +153,9 @@ private function parseItem(mixed $item): array if (! empty($modifiers) && $modifiers[0] === '/') { $modifiers = substr($modifiers, 1); } + if ($item && str_contains($item, '/-/crop/')) { + \Log::info("[OBSERVER DEBUG] Found crop in URL string", ['item' => $item, 'modifiers' => $modifiers]); + } $meta = [ 'cdnUrl' => $item, 'cdnUrlModifiers' => $modifiers, @@ -159,6 +170,18 @@ private function parseItem(mixed $item): array } elseif (is_array($item)) { $uuid = $item['uuid'] ?? ($item['fileInfo']['uuid'] ?? null); $meta = $item; + + \Log::info("[CROP DEBUG] ContentFieldValueObserver::parseItem processing array item", [ + 'uuid' => $uuid, + 'has_cdnUrlModifiers' => isset($item['cdnUrlModifiers']), + 'cdnUrlModifiers_value' => $item['cdnUrlModifiers'] ?? null, + 'has_crop' => isset($item['crop']), + 'item_keys' => array_keys($item), + ]); + + if (!empty($item['cdnUrlModifiers'])) { + \Log::info("[OBSERVER DEBUG] Found explicit cdnUrlModifiers in array item", ['modifiers' => $item['cdnUrlModifiers']]); + } // Try to extract modifiers from cdnUrl if not explicitly present or if we want to be sure if (isset($item['cdnUrl']) && is_string($item['cdnUrl']) && filter_var($item['cdnUrl'], FILTER_VALIDATE_URL)) { @@ -178,6 +201,13 @@ private function parseItem(mixed $item): array } } } + + \Log::info("[CROP DEBUG] ContentFieldValueObserver::parseItem final meta", [ + 'uuid' => $uuid, + 'has_cdnUrlModifiers_in_meta' => isset($meta['cdnUrlModifiers']), + 'cdnUrlModifiers_in_meta' => $meta['cdnUrlModifiers'] ?? null, + 'meta_keys' => array_keys($meta), + ]); } return [$uuid, $meta]; diff --git a/src/Uploadcare.php b/src/Uploadcare.php index f40f44e..5d91d01 100755 --- a/src/Uploadcare.php +++ b/src/Uploadcare.php @@ -48,58 +48,137 @@ public static function make(string $name, Field $field): Input input: Input::make($name) ->withMetadata() ->removeCopyright() - ->dehydrateStateUsing(function ($state) { + ->dehydrateStateUsing(function ($state, $component, $record) { + \Log::info("[CROP DEBUG] dehydrateStateUsing called", [ + 'field' => $component->getName(), + 'state_path' => $component->getStatePath(), + 'state_type' => gettype($state), + 'is_string' => is_string($state), + 'is_array' => is_array($state), + 'is_collection' => $state instanceof \Illuminate\Database\Eloquent\Collection, + ]); + if (is_string($state) && json_validate($state)) { - return json_decode($state, true); + $state = json_decode($state, true); + } + + // Ensure Media models are properly mapped to include crop data + if ($state instanceof \Illuminate\Database\Eloquent\Collection) { + return $state->map(fn ($item) => $item instanceof Model ? self::mapMediaToValue($item) : $item)->all(); + } + + if (is_array($state) && array_is_list($state)) { + $result = array_map(function ($item) { + if ($item instanceof Model || is_array($item)) { + return self::mapMediaToValue($item); + } + + return $item; + }, $state); + + \Log::info("[CROP DEBUG] dehydrateStateUsing returning array", [ + 'count' => count($result), + 'first_has_cdnUrlModifiers' => isset($result[0]['cdnUrlModifiers']), + 'first_item_keys' => isset($result[0]) && is_array($result[0]) ? array_keys($result[0]) : 'NOT_ARRAY', + ]); + + /* + // Ensure we return a single object (or string) for non-multiple fields during dehydration + // to prevent Filament from clearing the state. + if (! $component->isMultiple() && ! empty($result)) { + return $result[0]; + } + */ + + return $result; + } + + if (is_array($state)) { + return self::mapMediaToValue($state); } return $state; }) ->afterStateHydrated(function ($component, $state) { + $fieldName = $component->getName(); + $record = $component->getRecord(); + + $newState = $state; if ($state instanceof \Illuminate\Database\Eloquent\Collection) { $newState = $state->map(fn ($item) => $item instanceof Model ? self::mapMediaToValue($item) : $item)->all(); - } elseif (is_array($state) && isset($state[0]) && ($state[0] instanceof Model || is_array($state[0]))) { - $newState = array_map(fn ($item) => self::mapMediaToValue($item), $state); + } elseif (is_array($state) && ! empty($state)) { + $isList = array_is_list($state); + $firstKey = array_key_first($state); + $firstItem = $state[$firstKey]; + + if ($isList && ($firstItem instanceof Model || is_array($firstItem))) { + $newState = array_map(fn ($item) => self::mapMediaToValue($item), $state); + } elseif (! $isList && (isset($state['uuid']) || isset($state['cdnUrl']))) { + // Single rich object + $newState = [self::mapMediaToValue($state)]; + } elseif ($isList && is_string($firstItem) && preg_match('/^[0-9A-HJKMNP-TV-Z]{26}$/i', $firstItem)) { + // Resolution of ULIDs handled below + $newState = $state; + } elseif (is_array($firstItem)) { + // Possibly a list of something else or nested + $newState = array_map(fn ($item) => self::mapMediaToValue($item), $state); + } } elseif ($state instanceof Model) { $newState = [self::mapMediaToValue($state)]; - } elseif (is_array($state) && !empty($state) && is_array($state[0])) { - $newState = [self::mapMediaToValue($state)]; - } elseif (is_array($state) && Arr::isList($state) && count($state) > 0 && is_string($state[0])) { - $potentialUlids = collect($state)->filter(fn($s) => preg_match('/^[0-9A-Z]{26}$/i', $s)); + } + + // Resolve ULIDs if we have a list of strings + if (is_array($newState) && array_is_list($newState) && count($newState) > 0 && is_string($newState[0]) && preg_match('/^[0-9A-Z]{26}$/i', $newState[0])) { + // Resolve ULIDs + $potentialUlids = collect($newState)->filter(fn($s) => is_string($s) && preg_match('/^[0-9A-Z]{26}$/i', $s)); $mediaModel = self::getMediaModel(); $foundModels = new \Illuminate\Database\Eloquent\Collection(); - $record = $component->getRecord(); - $fieldName = $component->getName(); if ($record && $fieldName && $potentialUlids->isNotEmpty()) { try { + // Robust field ULID resolution (matching component logic) $fieldUlid = $fieldName; if (str_contains($fieldName, '.')) { - $fieldUlid = explode('.', $fieldName)[1] ?? $fieldName; + $parts = explode('.', $fieldName); + foreach ($parts as $part) { + if (preg_match('/^[0-9A-HJKMNP-TV-Z]{26}$/i', $part)) { + $fieldUlid = $part; + break; + } + } } + $fieldValue = \Backstage\Models\ContentFieldValue::where('content_ulid', $record->getKey()) - ->where('field_ulid', $fieldUlid) + ->where(function($query) use ($fieldUlid) { + $query->where('field_ulid', $fieldUlid) + ->orWhere('ulid', $fieldUlid); + }) ->first(); if ($fieldValue) { $foundModels = $fieldValue->media() - ->whereIn('ulid', $potentialUlids) + ->whereIn('media_ulid', $potentialUlids->toArray()) ->get(); } - } catch (\Exception $e) { - $foundModels = new \Illuminate\Database\Eloquent\Collection(); - } + } catch (\Exception $e) {} } if ($foundModels->isEmpty() && $potentialUlids->isNotEmpty()) { - $foundModels = $mediaModel::whereIn('ulid', $potentialUlids)->get(); + $foundModels = $mediaModel::whereIn('ulid', $potentialUlids->toArray())->get(); } if ($foundModels->isNotEmpty()) { if ($record) { - $foundModels->each(function($m) use ($record) { + $foundModels->each(function($m) use ($record, $fieldName) { + $mediaUlid = $m->ulid ?? 'UNKNOWN'; + + \Log::info("[CROP DEBUG] Hydrating media {$mediaUlid} in field {$fieldName}", [ + 'has_pivot' => $m->relationLoaded('pivot') && $m->pivot !== null, + 'has_pivot_meta' => $m->relationLoaded('pivot') && $m->pivot && $m->pivot->meta !== null, + ]); + if ($m->relationLoaded('pivot') && $m->pivot && $m->pivot->meta) { $meta = is_string($m->pivot->meta) ? json_decode($m->pivot->meta, true) : $m->pivot->meta; if (is_array($meta)) { @@ -123,49 +202,81 @@ public static function make(string $name, Field $field): Input } else { $newState = $foundModels->map(fn($m) => self::mapMediaToValue($m))->all(); } + } else { - $uuid = null; - $cdnUrl = null; - $filename = null; - $hasStructure = false; + // Process each item in the state array + $extractedFiles = []; foreach ($state as $item) { + if (is_array($item)) { + $extractedFiles[] = self::mapMediaToValue($item); + continue; + } + if (!is_string($item)) continue; - if (!$uuid && preg_match('/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i', $item)) { + + $uuid = null; + $cdnUrl = null; + $filename = null; + + // Check if it's a UUID + if (preg_match('/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i', $item)) { $uuid = $item; - $hasStructure = true; } - if (!$cdnUrl && str_contains($item, 'ucarecd.net/') && filter_var($item, FILTER_VALIDATE_URL)) { + // Check if it's a CDN URL + elseif (str_contains($item, 'ucarecd.net/') && filter_var($item, FILTER_VALIDATE_URL)) { $cdnUrl = $item; - $hasStructure = true; + $uuid = self::extractUuidFromString($cdnUrl); } - if (!$filename && preg_match('/\.[a-z0-9]{3,4}$/i', $item) && !str_starts_with($item, 'http')) { + // Check if it's a filename + elseif (preg_match('/\.[a-z0-9]{3,4}$/i', $item) && !str_starts_with($item, 'http')) { $filename = $item; } + + // If we found a UUID or CDN URL, add it to the extracted files + if ($uuid || $cdnUrl) { + $fileData = [ + 'uuid' => $uuid ?? self::extractUuidFromString($cdnUrl ?? ''), + 'cdnUrl' => $cdnUrl ?? ($uuid ? 'https://ucarecdn.com/' . $uuid . '/' : null), + 'original_filename' => $filename, + 'name' => $filename, + ]; + $extractedFiles[] = self::mapMediaToValue($fileData); + } } + + if (!empty($extractedFiles)) { + $newState = $extractedFiles; - if ($hasStructure && ($uuid || $cdnUrl)) { - $newState = [[ - 'uuid' => $uuid ?? self::extractUuidFromString($cdnUrl ?? ''), - 'cdnUrl' => $cdnUrl, - 'original_filename' => $filename, - 'name' => $filename, - ]]; } else { - $newState = array_map(function($item) { - if (is_string($item) && json_validate($item)) { - return json_decode($item, true); - } - return $item; - }, $state); + if (array_is_list($state)) { + $newState = array_map(function($item) { + if (is_string($item) && json_validate($item)) { + return self::mapMediaToValue(json_decode($item, true)); + } + return self::mapMediaToValue($item); + }, $state); + } else { + $newState = self::mapMediaToValue($state); + } } } } elseif (is_string($state) && json_validate($state)) { + $newState = json_decode($state, true); } else { + } + \Log::info("[CROP DEBUG] afterStateHydrated final result", [ + 'field' => $fieldName, + 'type' => gettype($newState), + 'is_array' => is_array($newState), + 'is_list' => is_array($newState) ? array_is_list($newState) : 'N/A', + 'count' => is_array($newState) ? count($newState) : 'N/A', + ]); + if ($newState !== $state) { $component->state($newState); } @@ -340,16 +451,30 @@ public static function mutateFormDataCallback(Model $record, Field $field, array return $data; } - if (! property_exists($record, 'valueColumn') || ! isset($record->values[$field->ulid])) { - return $data; - } + \Log::info("[CROP DEBUG] mutateFormDataCallback start", [ + 'field' => $field->ulid, + 'record_exists' => $record->exists, + 'has_values_prop' => isset($record->values), + 'record_values_type' => isset($record->values) ? gettype($record->values) : 'null', + ]); + $values = null; - $values = $record->values[$field->ulid]; + // 1. Try to get from property first (set by EditContent) + if (isset($record->values) && is_array($record->values)) { + $values = $record->values[$field->ulid] ?? null; + \Log::info("[CROP DEBUG] Got value from property", ['value_type' => gettype($values)]); + } - if ($values == '' || $values == [] || $values == null || empty($values)) { - $data[$record->valueColumn][$field->ulid] = []; + // 2. Fallback to getFieldValueFromRecord which checks relationships + if ($values === null) { + $values = self::getFieldValueFromRecord($record, $field); + \Log::info("[CROP DEBUG] Got value from getFieldValueFromRecord", ['value_type' => gettype($values)]); + } + if ($values === '' || $values === [] || $values === null || empty($values)) { + $data[$record->valueColumn ?? 'values'][$field->ulid] = []; + \Log::info("[CROP DEBUG] Value empty, setting empty array"); return $data; } @@ -357,7 +482,6 @@ public static function mutateFormDataCallback(Model $record, Field $field, array $values = self::parseValues($values); if (self::isMediaUlidArray($values)) { - // Try to load via ContentFieldValue relation to ensure pivot data (crops) are included $mediaData = null; if ($record->exists && class_exists(\Backstage\Models\ContentFieldValue::class)) { @@ -378,17 +502,14 @@ public static function mutateFormDataCallback(Model $record, Field $field, array } if (empty($mediaData)) { - // Always return metadata for ULID-based values (default behavior), otherwise - // the Uploadcare field may not be able to render a preview. $mediaData = self::extractMediaUrls($values, true); } - // Return as JSON string to avoid Array to String conversion errors in Filament - $data[$record->valueColumn][$field->ulid] = json_encode($mediaData); + $data[$record->valueColumn ?? 'values'][$field->ulid] = $mediaData; } else { $mediaUrls = self::extractCdnUrlsFromFileData($values); $result = $withMetadata ? $values : self::filterValidUrls($mediaUrls); - $data[$record->valueColumn][$field->ulid] = is_array($result) ? json_encode($result) : $result; + $data[$record->valueColumn ?? 'values'][$field->ulid] = $result; } return $data; @@ -400,55 +521,39 @@ public static function mutateBeforeSaveCallback(Model $record, Field $field, arr return $data; } - if (! property_exists($record, 'valueColumn')) { - return $data; - } + // Handle valueColumn default or missing property + $valueColumn = $record->valueColumn ?? 'values'; - $values = self::findFieldValues($data[$record->valueColumn] ?? [], $field); + $values = self::findFieldValues($data, $field); + + \Log::info("[CROP DEBUG] mutateBeforeSaveCallback", [ + 'field' => $field->ulid, + 'values_type' => gettype($values), + 'values_preview' => is_array($values) ? (array_is_list($values) ? 'list count ' . count($values) : 'assoc keys ' . implode(',', array_keys($values))) : $values, + 'data_keys' => array_keys($data), + ]); - if ($values === '' || $values === [] || $values === null) { - $data[$record->valueColumn][$field->ulid] = null; - return $data; - } + if ($values === '' || $values === [] || $values === null || empty($values)) { + // Check if key exists using strict check to avoid wiping out data that wasn't submitted + $fieldFound = array_key_exists($field->ulid, $data) || + array_key_exists($field->slug, $data) || + (isset($data['values']) && is_array($data['values']) && (array_key_exists($field->ulid, $data['values']) || array_key_exists($field->slug, $data['values']))); - $values = self::normalizeValues($values); - - - if (is_array($values) && !empty($values) && isset($values[0]) && is_string($values[0]) && preg_match('/^[0-9A-HJKMNP-TV-Z]{26}$/i', $values[0])) { - if ($record->exists && class_exists(\Backstage\Models\ContentFieldValue::class)) { - try { - $cfv = \Backstage\Models\ContentFieldValue::where('content_ulid', $record->ulid) - ->where('field_ulid', $field->ulid) - ->first(); - - if ($cfv) { - $fullyHydrated = []; - $existingMedia = $cfv->media->keyBy('ulid'); - - foreach ($values as $ulid) { - if ($existingMedia->has($ulid)) { - $mediaItem = $existingMedia->get($ulid); - $fullyHydrated[] = self::mapMediaToValue($mediaItem); - } else { - $fullyHydrated[] = $ulid; - } - } - $values = $fullyHydrated; - } - } catch (\Exception $e) { - } - } - } + if ($fieldFound) { + $data[$valueColumn][$field->ulid] = []; + } - if (! is_array($values)) { return $data; } - $media = self::processUploadedFiles($values); + $values = self::normalizeValues($values); + // Side effect: create media records for new uploads + self::processUploadedFiles($values); - $data[$record->valueColumn][$field->ulid] = $values; + // Save the values (Array) - Filament/PersistsContentData will handle encoding if needed + $data[$valueColumn][$field->ulid] = $values; return $data; } @@ -559,23 +664,62 @@ private static function findFieldValues(array $data, Field $field): mixed $fieldUlid = (string) $field->ulid; $fieldSlug = (string) $field->slug; - $findInNested = function ($array, $ulid, $slug) use (&$findInNested) { + \Log::info("[CROP DEBUG] findFieldValues searching", [ + 'ulid' => $fieldUlid, + 'slug' => $fieldSlug, + 'data_keys' => array_keys($data), + 'has_values_key' => isset($data['values']), + 'values_is_array' => isset($data['values']) && is_array($data['values']), + 'values_is_string' => isset($data['values']) && is_string($data['values']), + ]); + + // Try direct key first (most common) + if (array_key_exists($fieldUlid, $data)) return $data[$fieldUlid]; + if (array_key_exists($fieldSlug, $data)) return $data[$fieldSlug]; + + // Recursive search that correctly traverses lists (repeaters/builders) + $notFound = new \stdClass(); + $findInNested = function ($array, $ulid, $slug, $depth = 0) use (&$findInNested, $notFound) { + $keys = array_keys($array); + \Log::info("[CROP DEBUG] findInNested level {$depth}", [ + 'keys' => $keys, + 'searching_for' => [$ulid, $slug] + ]); + + // First pass: look for direct keys at this level + if (array_key_exists($ulid, $array)) { + \Log::info("[CROP DEBUG] findInNested FOUND direct key", ['key' => $ulid, 'value_type' => gettype($array[$ulid]), 'value_preview' => $array[$ulid]]); + return $array[$ulid]; + } + if (array_key_exists($slug, $array)) { + return $array[$slug]; + } + + // Second pass: recurse foreach ($array as $k => $value) { - if ($k === $ulid || $k === $slug) { - return $value; - } if (is_array($value)) { - $result = $findInNested($value, $ulid, $slug); - if ($result !== null) { + $result = $findInNested($value, $ulid, $slug, $depth + 1); + if ($result !== $notFound) { return $result; } } } - - return null; + return $notFound; }; $result = $findInNested($data, $fieldUlid, $fieldSlug); + + if ($result === $notFound) { + $result = null; + $found = false; + } else { + $found = true; + } + + \Log::info("[CROP DEBUG] findFieldValues result", [ + 'found' => $found, + 'type' => gettype($result), + ]); return $result; } @@ -595,6 +739,15 @@ private static function normalizeValues(mixed $values): mixed private static function processUploadedFiles(array $files): array { + \Log::info("[CROP DEBUG] processUploadedFiles starting", [ + 'count' => count($files), + 'is_list' => array_is_list($files), + ]); + + if (! empty($files) && ! array_is_list($files)) { + $files = [$files]; + } + $media = []; foreach ($files as $index => $file) { @@ -681,19 +834,19 @@ private static function createOrUpdateMediaRecord(array $file): Model $media = $mediaModel::updateOrCreate([ 'site_ulid' => $tenantUlid, 'disk' => 'uploadcare', - 'filename' => $info['uuid'], + 'filename' => $info['uuid'] ?? ($info['fileInfo']['uuid'] ?? null), ], [ - 'original_filename' => $info['name'], + 'original_filename' => $info['name'] ?? ($info['original_filename'] ?? 'unknown'), 'uploaded_by' => Auth::id(), 'extension' => $detailedInfo['format'] ?? null, - 'mime_type' => $info['mimeType'], - 'size' => $info['size'], + 'mime_type' => $info['mimeType'] ?? ($info['mime_type'] ?? null), + 'size' => $info['size'] ?? 0, 'width' => $detailedInfo['width'] ?? null, 'height' => $detailedInfo['height'] ?? null, 'alt' => null, 'public' => config('backstage.media.visibility') === 'public', 'metadata' => $info, - 'checksum' => md5($info['uuid']), + 'checksum' => md5($info['uuid'] ?? uniqid()), ]); return $media; @@ -876,6 +1029,12 @@ private static function normalizeCurrentState(mixed $state): array public function hydrate(mixed $value, ?Model $model = null): mixed { + \Log::info("[CROP DEBUG] Uploadcare::hydrate called", [ + 'value_type' => gettype($value), + 'value_preview' => is_string($value) ? $value : (is_array($value) ? 'Array count ' . count($value) : 'Object'), + 'model_exists' => $model ? $model->exists : false, + ]); + if (empty($value)) { return null; } @@ -891,6 +1050,11 @@ public function hydrate(mixed $value, ?Model $model = null): mixed // Try to hydrate from relation $hydratedFromModel = self::hydrateFromModel($model, $value, true); + \Log::info("[CROP DEBUG] hydrateFromModel result in hydrate()", [ + 'found' => $hydratedFromModel && !empty($hydratedFromModel), + 'count' => $hydratedFromModel ? $hydratedFromModel->count() : 0, + ]); + if ($hydratedFromModel !== null && ! empty($hydratedFromModel)) { // Check config to decide if we should return single or multiple $config = $this->field_model->config ?? $model->field->config ?? []; @@ -997,22 +1161,80 @@ public function hydrate(mixed $value, ?Model $model = null): mixed return $value; } - private static function mapMediaToValue(Model | array $media): array + public static function mapMediaToValue(mixed $media): array|string { - if (is_array($media)) { - return $media; + if (! $media instanceof Model && ! is_array($media)) { + return is_string($media) ? $media : []; } - $data = $media->hydrated_edit ?? $media->edit; + $source = 'unknown'; + if (is_array($media)) { + $data = $media; + $source = 'array'; + } else { + $hasHydratedEdit = $media instanceof Model && array_key_exists('hydrated_edit', $media->getAttributes()); + $data = $hasHydratedEdit ? $media->getAttribute('hydrated_edit') : $media->getAttribute('edit'); + $source = $hasHydratedEdit ? 'hydrated_edit' : 'edit_accessor'; + + // Prioritize pivot meta if loaded, as it contains usage-specific modifiers + if ($media->relationLoaded('pivot') && $media->pivot && ! empty($media->pivot->meta)) { + $pivotMeta = $media->pivot->meta; + if (is_string($pivotMeta)) { + $pivotMeta = json_decode($pivotMeta, true); + } + + // Merge pivot meta over existing data, or use it as primary if data is empty + if (is_array($pivotMeta)) { + $data = !empty($data) && is_array($data) ? array_merge($data, $pivotMeta) : $pivotMeta; + $source = 'pivot_meta_merged'; + } + } + + $data = $data ?? $media->metadata; + if (empty($data)) $source = 'none'; - if (empty($data) && $media->relationLoaded('pivot') && $media->pivot && $media->pivot->meta) { - $data = $media->pivot->meta; + if (is_string($data)) { + $data = json_decode($data, true); + } } - $data = $data ?? $media->metadata; + \Log::info("[CROP DEBUG] mapMediaToValue source: {$source}", [ + 'has_cdnUrlModifiers' => isset($data['cdnUrlModifiers']), + 'cdnUrl' => $data['cdnUrl'] ?? null, + ]); + + if (is_array($data)) { + // Extract modifiers from cdnUrl if missing + if (isset($data['cdnUrl']) && ! isset($data['cdnUrlModifiers'])) { + $cdnUrl = $data['cdnUrl']; + // Extract UUID and modifiers from URL like: https://ucarecdn.com/{uuid}/{modifiers} + if (preg_match('/([a-f0-9-]{36})\/(.+)$/', $cdnUrl, $matches)) { + $modifiers = $matches[2]; + // Clean up trailing slash + $modifiers = rtrim($modifiers, '/'); + if (! empty($modifiers) && $modifiers !== '-/preview') { + $data['cdnUrlModifiers'] = $modifiers; + \Log::info('[CROP DEBUG] Extracted modifiers from URL', [ + 'uuid' => $matches[1], + 'modifiers' => $modifiers, + ]); + } + } + } - if (is_string($data)) { - $data = json_decode($data, true); + // Append modifiers to cdnUrl if present and not already part of the URL + if (isset($data['cdnUrl'], $data['cdnUrlModifiers']) && ! str_contains($data['cdnUrl'], '/-/')) { + $modifiers = $data['cdnUrlModifiers']; + if (str_starts_with($modifiers, '/')) { + $modifiers = substr($modifiers, 1); + } + + // Ensure cdnUrl includes modifiers + $data['cdnUrl'] = rtrim($data['cdnUrl'], '/') . '/' . $modifiers; + if (! str_ends_with($data['cdnUrl'], '/')) { + $data['cdnUrl'] .= '/'; + } + } } return is_array($data) ? $data : []; @@ -1043,8 +1265,25 @@ private static function hydrateFromModel(?Model $model, mixed $value = null, boo $media = $mediaQuery->get()->unique('ulid'); $media->each(function ($m) use ($model) { + $mediaUlid = $m->ulid ?? 'UNKNOWN'; + + \Log::info("[CROP DEBUG] Processing media {$mediaUlid} in hydrateFromModel", [ + 'has_pivot' => $m->pivot !== null, + 'has_pivot_meta' => $m->pivot && $m->pivot->meta !== null, + 'pivot_meta_type' => $m->pivot && $m->pivot->meta ? gettype($m->pivot->meta) : 'NULL', + 'pivot_meta_preview' => $m->pivot && $m->pivot->meta ? (is_string($m->pivot->meta) ? substr($m->pivot->meta, 0, 200) : json_encode($m->pivot->meta)) : null, + ]); + if ($m->pivot && $m->pivot->meta) { $pivotMeta = is_string($m->pivot->meta) ? json_decode($m->pivot->meta, true) : $m->pivot->meta; + + \Log::info("[CROP DEBUG] Decoded pivot meta for media {$mediaUlid}", [ + 'is_array' => is_array($pivotMeta), + 'has_crop' => is_array($pivotMeta) && isset($pivotMeta['crop']), + 'has_cdnUrlModifiers' => is_array($pivotMeta) && isset($pivotMeta['cdnUrlModifiers']), + 'keys' => is_array($pivotMeta) ? array_keys($pivotMeta) : 'NOT_ARRAY', + ]); + if (is_array($pivotMeta)) { $m->setAttribute('hydrated_edit', $pivotMeta); if ($model) { @@ -1052,7 +1291,11 @@ private static function hydrateFromModel(?Model $model, mixed $value = null, boo $contextModel->setRelation('pivot', $m->pivot); $m->setRelation('edits', new \Illuminate\Database\Eloquent\Collection([$contextModel])); } + + \Log::info("[CROP DEBUG] Set hydrated_edit for media {$mediaUlid}"); } + } else { + \Log::warning("[CROP DEBUG] No pivot meta found for media {$mediaUlid}"); } }); From c4d81b484fe39cac9ee08f259ed3f0ca19a2f85a Mon Sep 17 00:00:00 2001 From: Baspa <10845460+Baspa@users.noreply.github.com> Date: Mon, 5 Jan 2026 15:37:25 +0000 Subject: [PATCH 21/29] fix: styling --- src/Observers/ContentFieldValueObserver.php | 16 +- src/Uploadcare.php | 446 ++++++++++---------- 2 files changed, 238 insertions(+), 224 deletions(-) diff --git a/src/Observers/ContentFieldValueObserver.php b/src/Observers/ContentFieldValueObserver.php index cf3265f..90fa8cd 100644 --- a/src/Observers/ContentFieldValueObserver.php +++ b/src/Observers/ContentFieldValueObserver.php @@ -10,7 +10,7 @@ class ContentFieldValueObserver { public function saved(ContentFieldValue $contentFieldValue): void { - \Log::info("[OBSERVER DEBUG] ContentFieldValueObserver::saved triggered", [ + \Log::info('[OBSERVER DEBUG] ContentFieldValueObserver::saved triggered', [ 'ulid' => $contentFieldValue->ulid, 'field_ulid' => $contentFieldValue->field_ulid, 'value_type' => gettype($contentFieldValue->value), @@ -154,7 +154,7 @@ private function parseItem(mixed $item): array $modifiers = substr($modifiers, 1); } if ($item && str_contains($item, '/-/crop/')) { - \Log::info("[OBSERVER DEBUG] Found crop in URL string", ['item' => $item, 'modifiers' => $modifiers]); + \Log::info('[OBSERVER DEBUG] Found crop in URL string', ['item' => $item, 'modifiers' => $modifiers]); } $meta = [ 'cdnUrl' => $item, @@ -170,8 +170,8 @@ private function parseItem(mixed $item): array } elseif (is_array($item)) { $uuid = $item['uuid'] ?? ($item['fileInfo']['uuid'] ?? null); $meta = $item; - - \Log::info("[CROP DEBUG] ContentFieldValueObserver::parseItem processing array item", [ + + \Log::info('[CROP DEBUG] ContentFieldValueObserver::parseItem processing array item', [ 'uuid' => $uuid, 'has_cdnUrlModifiers' => isset($item['cdnUrlModifiers']), 'cdnUrlModifiers_value' => $item['cdnUrlModifiers'] ?? null, @@ -179,8 +179,8 @@ private function parseItem(mixed $item): array 'item_keys' => array_keys($item), ]); - if (!empty($item['cdnUrlModifiers'])) { - \Log::info("[OBSERVER DEBUG] Found explicit cdnUrlModifiers in array item", ['modifiers' => $item['cdnUrlModifiers']]); + if (! empty($item['cdnUrlModifiers'])) { + \Log::info('[OBSERVER DEBUG] Found explicit cdnUrlModifiers in array item', ['modifiers' => $item['cdnUrlModifiers']]); } // Try to extract modifiers from cdnUrl if not explicitly present or if we want to be sure @@ -201,8 +201,8 @@ private function parseItem(mixed $item): array } } } - - \Log::info("[CROP DEBUG] ContentFieldValueObserver::parseItem final meta", [ + + \Log::info('[CROP DEBUG] ContentFieldValueObserver::parseItem final meta', [ 'uuid' => $uuid, 'has_cdnUrlModifiers_in_meta' => isset($meta['cdnUrlModifiers']), 'cdnUrlModifiers_in_meta' => $meta['cdnUrlModifiers'] ?? null, diff --git a/src/Uploadcare.php b/src/Uploadcare.php index 5d91d01..65d9c1f 100755 --- a/src/Uploadcare.php +++ b/src/Uploadcare.php @@ -49,7 +49,7 @@ public static function make(string $name, Field $field): Input ->withMetadata() ->removeCopyright() ->dehydrateStateUsing(function ($state, $component, $record) { - \Log::info("[CROP DEBUG] dehydrateStateUsing called", [ + \Log::info('[CROP DEBUG] dehydrateStateUsing called', [ 'field' => $component->getName(), 'state_path' => $component->getStatePath(), 'state_type' => gettype($state), @@ -57,31 +57,31 @@ public static function make(string $name, Field $field): Input 'is_array' => is_array($state), 'is_collection' => $state instanceof \Illuminate\Database\Eloquent\Collection, ]); - + if (is_string($state) && json_validate($state)) { $state = json_decode($state, true); } - + // Ensure Media models are properly mapped to include crop data if ($state instanceof \Illuminate\Database\Eloquent\Collection) { return $state->map(fn ($item) => $item instanceof Model ? self::mapMediaToValue($item) : $item)->all(); } - + if (is_array($state) && array_is_list($state)) { $result = array_map(function ($item) { if ($item instanceof Model || is_array($item)) { return self::mapMediaToValue($item); } - + return $item; }, $state); - - \Log::info("[CROP DEBUG] dehydrateStateUsing returning array", [ + + \Log::info('[CROP DEBUG] dehydrateStateUsing returning array', [ 'count' => count($result), 'first_has_cdnUrlModifiers' => isset($result[0]['cdnUrlModifiers']), 'first_item_keys' => isset($result[0]) && is_array($result[0]) ? array_keys($result[0]) : 'NOT_ARRAY', ]); - + /* // Ensure we return a single object (or string) for non-multiple fields during dehydration // to prevent Filament from clearing the state. @@ -103,7 +103,6 @@ public static function make(string $name, Field $field): Input $fieldName = $component->getName(); $record = $component->getRecord(); - $newState = $state; if ($state instanceof \Illuminate\Database\Eloquent\Collection) { @@ -119,11 +118,11 @@ public static function make(string $name, Field $field): Input // Single rich object $newState = [self::mapMediaToValue($state)]; } elseif ($isList && is_string($firstItem) && preg_match('/^[0-9A-HJKMNP-TV-Z]{26}$/i', $firstItem)) { - // Resolution of ULIDs handled below - $newState = $state; + // Resolution of ULIDs handled below + $newState = $state; } elseif (is_array($firstItem)) { - // Possibly a list of something else or nested - $newState = array_map(fn ($item) => self::mapMediaToValue($item), $state); + // Possibly a list of something else or nested + $newState = array_map(fn ($item) => self::mapMediaToValue($item), $state); } } elseif ($state instanceof Model) { $newState = [self::mapMediaToValue($state)]; @@ -131,136 +130,142 @@ public static function make(string $name, Field $field): Input // Resolve ULIDs if we have a list of strings if (is_array($newState) && array_is_list($newState) && count($newState) > 0 && is_string($newState[0]) && preg_match('/^[0-9A-Z]{26}$/i', $newState[0])) { - // Resolve ULIDs - $potentialUlids = collect($newState)->filter(fn($s) => is_string($s) && preg_match('/^[0-9A-Z]{26}$/i', $s)); - $mediaModel = self::getMediaModel(); - $foundModels = new \Illuminate\Database\Eloquent\Collection(); - - if ($record && $fieldName && $potentialUlids->isNotEmpty()) { - try { - // Robust field ULID resolution (matching component logic) - $fieldUlid = $fieldName; - if (str_contains($fieldName, '.')) { - $parts = explode('.', $fieldName); - foreach ($parts as $part) { - if (preg_match('/^[0-9A-HJKMNP-TV-Z]{26}$/i', $part)) { - $fieldUlid = $part; - break; - } - } - } - - $fieldValue = \Backstage\Models\ContentFieldValue::where('content_ulid', $record->getKey()) - ->where(function($query) use ($fieldUlid) { - $query->where('field_ulid', $fieldUlid) - ->orWhere('ulid', $fieldUlid); - }) - ->first(); - - if ($fieldValue) { - $foundModels = $fieldValue->media() + // Resolve ULIDs + $potentialUlids = collect($newState)->filter(fn ($s) => is_string($s) && preg_match('/^[0-9A-Z]{26}$/i', $s)); + $mediaModel = self::getMediaModel(); + $foundModels = new \Illuminate\Database\Eloquent\Collection; + + if ($record && $fieldName && $potentialUlids->isNotEmpty()) { + try { + // Robust field ULID resolution (matching component logic) + $fieldUlid = $fieldName; + if (str_contains($fieldName, '.')) { + $parts = explode('.', $fieldName); + foreach ($parts as $part) { + if (preg_match('/^[0-9A-HJKMNP-TV-Z]{26}$/i', $part)) { + $fieldUlid = $part; + + break; + } + } + } + + $fieldValue = \Backstage\Models\ContentFieldValue::where('content_ulid', $record->getKey()) + ->where(function ($query) use ($fieldUlid) { + $query->where('field_ulid', $fieldUlid) + ->orWhere('ulid', $fieldUlid); + }) + ->first(); + + if ($fieldValue) { + $foundModels = $fieldValue->media() ->whereIn('media_ulid', $potentialUlids->toArray()) ->get(); - } - } catch (\Exception $e) {} - } - - if ($foundModels->isEmpty() && $potentialUlids->isNotEmpty()) { - $foundModels = $mediaModel::whereIn('ulid', $potentialUlids->toArray())->get(); - } - - if ($foundModels->isNotEmpty()) { - if ($record) { - $foundModels->each(function($m) use ($record, $fieldName) { - $mediaUlid = $m->ulid ?? 'UNKNOWN'; - - \Log::info("[CROP DEBUG] Hydrating media {$mediaUlid} in field {$fieldName}", [ - 'has_pivot' => $m->relationLoaded('pivot') && $m->pivot !== null, - 'has_pivot_meta' => $m->relationLoaded('pivot') && $m->pivot && $m->pivot->meta !== null, - ]); - - if ($m->relationLoaded('pivot') && $m->pivot && $m->pivot->meta) { - $meta = is_string($m->pivot->meta) ? json_decode($m->pivot->meta, true) : $m->pivot->meta; - if (is_array($meta)) { - $m->setAttribute('hydrated_edit', $meta); - } - } - $contextModel = clone $record; - if ($m->relationLoaded('pivot') && $m->pivot) { - $contextModel->setRelation('pivot', $m->pivot); - } else { - $dummyPivot = new \Backstage\Models\ContentFieldValue(); - $dummyPivot->setAttribute('meta', null); - $contextModel->setRelation('pivot', $dummyPivot); - } - $m->setRelation('edits', new \Illuminate\Database\Eloquent\Collection([$contextModel])); - }); - } - - if ($foundModels->count() === 1 && count($state) > 1) { - $newState = [self::mapMediaToValue($foundModels->first())]; - } else { - $newState = $foundModels->map(fn($m) => self::mapMediaToValue($m))->all(); - } - - } else { - // Process each item in the state array - $extractedFiles = []; - - foreach ($state as $item) { - if (is_array($item)) { - $extractedFiles[] = self::mapMediaToValue($item); - continue; - } - - if (!is_string($item)) continue; - - $uuid = null; - $cdnUrl = null; - $filename = null; - - // Check if it's a UUID - if (preg_match('/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i', $item)) { - $uuid = $item; - } - // Check if it's a CDN URL - elseif (str_contains($item, 'ucarecd.net/') && filter_var($item, FILTER_VALIDATE_URL)) { - $cdnUrl = $item; - $uuid = self::extractUuidFromString($cdnUrl); - } - // Check if it's a filename - elseif (preg_match('/\.[a-z0-9]{3,4}$/i', $item) && !str_starts_with($item, 'http')) { - $filename = $item; - } - - // If we found a UUID or CDN URL, add it to the extracted files - if ($uuid || $cdnUrl) { - $fileData = [ - 'uuid' => $uuid ?? self::extractUuidFromString($cdnUrl ?? ''), - 'cdnUrl' => $cdnUrl ?? ($uuid ? 'https://ucarecdn.com/' . $uuid . '/' : null), - 'original_filename' => $filename, - 'name' => $filename, - ]; - $extractedFiles[] = self::mapMediaToValue($fileData); - } - } - - if (!empty($extractedFiles)) { - $newState = $extractedFiles; - - } else { - if (array_is_list($state)) { - $newState = array_map(function($item) { - if (is_string($item) && json_validate($item)) { - return self::mapMediaToValue(json_decode($item, true)); - } - return self::mapMediaToValue($item); - }, $state); - } else { - $newState = self::mapMediaToValue($state); - } - } - } + } + } catch (\Exception $e) { + } + } + + if ($foundModels->isEmpty() && $potentialUlids->isNotEmpty()) { + $foundModels = $mediaModel::whereIn('ulid', $potentialUlids->toArray())->get(); + } + + if ($foundModels->isNotEmpty()) { + if ($record) { + $foundModels->each(function ($m) use ($record, $fieldName) { + $mediaUlid = $m->ulid ?? 'UNKNOWN'; + + \Log::info("[CROP DEBUG] Hydrating media {$mediaUlid} in field {$fieldName}", [ + 'has_pivot' => $m->relationLoaded('pivot') && $m->pivot !== null, + 'has_pivot_meta' => $m->relationLoaded('pivot') && $m->pivot && $m->pivot->meta !== null, + ]); + + if ($m->relationLoaded('pivot') && $m->pivot && $m->pivot->meta) { + $meta = is_string($m->pivot->meta) ? json_decode($m->pivot->meta, true) : $m->pivot->meta; + if (is_array($meta)) { + $m->setAttribute('hydrated_edit', $meta); + } + } + $contextModel = clone $record; + if ($m->relationLoaded('pivot') && $m->pivot) { + $contextModel->setRelation('pivot', $m->pivot); + } else { + $dummyPivot = new \Backstage\Models\ContentFieldValue; + $dummyPivot->setAttribute('meta', null); + $contextModel->setRelation('pivot', $dummyPivot); + } + $m->setRelation('edits', new \Illuminate\Database\Eloquent\Collection([$contextModel])); + }); + } + + if ($foundModels->count() === 1 && count($state) > 1) { + $newState = [self::mapMediaToValue($foundModels->first())]; + } else { + $newState = $foundModels->map(fn ($m) => self::mapMediaToValue($m))->all(); + } + + } else { + // Process each item in the state array + $extractedFiles = []; + + foreach ($state as $item) { + if (is_array($item)) { + $extractedFiles[] = self::mapMediaToValue($item); + + continue; + } + + if (! is_string($item)) { + continue; + } + + $uuid = null; + $cdnUrl = null; + $filename = null; + + // Check if it's a UUID + if (preg_match('/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i', $item)) { + $uuid = $item; + } + // Check if it's a CDN URL + elseif (str_contains($item, 'ucarecd.net/') && filter_var($item, FILTER_VALIDATE_URL)) { + $cdnUrl = $item; + $uuid = self::extractUuidFromString($cdnUrl); + } + // Check if it's a filename + elseif (preg_match('/\.[a-z0-9]{3,4}$/i', $item) && ! str_starts_with($item, 'http')) { + $filename = $item; + } + + // If we found a UUID or CDN URL, add it to the extracted files + if ($uuid || $cdnUrl) { + $fileData = [ + 'uuid' => $uuid ?? self::extractUuidFromString($cdnUrl ?? ''), + 'cdnUrl' => $cdnUrl ?? ($uuid ? 'https://ucarecdn.com/' . $uuid . '/' : null), + 'original_filename' => $filename, + 'name' => $filename, + ]; + $extractedFiles[] = self::mapMediaToValue($fileData); + } + } + + if (! empty($extractedFiles)) { + $newState = $extractedFiles; + + } else { + if (array_is_list($state)) { + $newState = array_map(function ($item) { + if (is_string($item) && json_validate($item)) { + return self::mapMediaToValue(json_decode($item, true)); + } + + return self::mapMediaToValue($item); + }, $state); + } else { + $newState = self::mapMediaToValue($state); + } + } + } } elseif (is_string($state) && json_validate($state)) { @@ -269,7 +274,7 @@ public static function make(string $name, Field $field): Input } - \Log::info("[CROP DEBUG] afterStateHydrated final result", [ + \Log::info('[CROP DEBUG] afterStateHydrated final result', [ 'field' => $fieldName, 'type' => gettype($newState), 'is_array' => is_array($newState), @@ -451,7 +456,7 @@ public static function mutateFormDataCallback(Model $record, Field $field, array return $data; } - \Log::info("[CROP DEBUG] mutateFormDataCallback start", [ + \Log::info('[CROP DEBUG] mutateFormDataCallback start', [ 'field' => $field->ulid, 'record_exists' => $record->exists, 'has_values_prop' => isset($record->values), @@ -463,18 +468,19 @@ public static function mutateFormDataCallback(Model $record, Field $field, array // 1. Try to get from property first (set by EditContent) if (isset($record->values) && is_array($record->values)) { $values = $record->values[$field->ulid] ?? null; - \Log::info("[CROP DEBUG] Got value from property", ['value_type' => gettype($values)]); + \Log::info('[CROP DEBUG] Got value from property', ['value_type' => gettype($values)]); } // 2. Fallback to getFieldValueFromRecord which checks relationships if ($values === null) { $values = self::getFieldValueFromRecord($record, $field); - \Log::info("[CROP DEBUG] Got value from getFieldValueFromRecord", ['value_type' => gettype($values)]); + \Log::info('[CROP DEBUG] Got value from getFieldValueFromRecord', ['value_type' => gettype($values)]); } if ($values === '' || $values === [] || $values === null || empty($values)) { $data[$record->valueColumn ?? 'values'][$field->ulid] = []; - \Log::info("[CROP DEBUG] Value empty, setting empty array"); + \Log::info('[CROP DEBUG] Value empty, setting empty array'); + return $data; } @@ -504,7 +510,7 @@ public static function mutateFormDataCallback(Model $record, Field $field, array if (empty($mediaData)) { $mediaData = self::extractMediaUrls($values, true); } - + $data[$record->valueColumn ?? 'values'][$field->ulid] = $mediaData; } else { $mediaUrls = self::extractCdnUrlsFromFileData($values); @@ -525,19 +531,18 @@ public static function mutateBeforeSaveCallback(Model $record, Field $field, arr $valueColumn = $record->valueColumn ?? 'values'; $values = self::findFieldValues($data, $field); - - \Log::info("[CROP DEBUG] mutateBeforeSaveCallback", [ + + \Log::info('[CROP DEBUG] mutateBeforeSaveCallback', [ 'field' => $field->ulid, 'values_type' => gettype($values), 'values_preview' => is_array($values) ? (array_is_list($values) ? 'list count ' . count($values) : 'assoc keys ' . implode(',', array_keys($values))) : $values, 'data_keys' => array_keys($data), ]); - if ($values === '' || $values === [] || $values === null || empty($values)) { // Check if key exists using strict check to avoid wiping out data that wasn't submitted - $fieldFound = array_key_exists($field->ulid, $data) || - array_key_exists($field->slug, $data) || + $fieldFound = array_key_exists($field->ulid, $data) || + array_key_exists($field->slug, $data) || (isset($data['values']) && is_array($data['values']) && (array_key_exists($field->ulid, $data['values']) || array_key_exists($field->slug, $data['values']))); if ($fieldFound) { @@ -664,7 +669,7 @@ private static function findFieldValues(array $data, Field $field): mixed $fieldUlid = (string) $field->ulid; $fieldSlug = (string) $field->slug; - \Log::info("[CROP DEBUG] findFieldValues searching", [ + \Log::info('[CROP DEBUG] findFieldValues searching', [ 'ulid' => $fieldUlid, 'slug' => $fieldSlug, 'data_keys' => array_keys($data), @@ -674,21 +679,26 @@ private static function findFieldValues(array $data, Field $field): mixed ]); // Try direct key first (most common) - if (array_key_exists($fieldUlid, $data)) return $data[$fieldUlid]; - if (array_key_exists($fieldSlug, $data)) return $data[$fieldSlug]; + if (array_key_exists($fieldUlid, $data)) { + return $data[$fieldUlid]; + } + if (array_key_exists($fieldSlug, $data)) { + return $data[$fieldSlug]; + } // Recursive search that correctly traverses lists (repeaters/builders) - $notFound = new \stdClass(); + $notFound = new \stdClass; $findInNested = function ($array, $ulid, $slug, $depth = 0) use (&$findInNested, $notFound) { $keys = array_keys($array); \Log::info("[CROP DEBUG] findInNested level {$depth}", [ 'keys' => $keys, - 'searching_for' => [$ulid, $slug] + 'searching_for' => [$ulid, $slug], ]); // First pass: look for direct keys at this level if (array_key_exists($ulid, $array)) { - \Log::info("[CROP DEBUG] findInNested FOUND direct key", ['key' => $ulid, 'value_type' => gettype($array[$ulid]), 'value_preview' => $array[$ulid]]); + \Log::info('[CROP DEBUG] findInNested FOUND direct key', ['key' => $ulid, 'value_type' => gettype($array[$ulid]), 'value_preview' => $array[$ulid]]); + return $array[$ulid]; } if (array_key_exists($slug, $array)) { @@ -704,19 +714,20 @@ private static function findFieldValues(array $data, Field $field): mixed } } } + return $notFound; }; $result = $findInNested($data, $fieldUlid, $fieldSlug); - + if ($result === $notFound) { $result = null; $found = false; } else { $found = true; } - - \Log::info("[CROP DEBUG] findFieldValues result", [ + + \Log::info('[CROP DEBUG] findFieldValues result', [ 'found' => $found, 'type' => gettype($result), ]); @@ -739,7 +750,7 @@ private static function normalizeValues(mixed $values): mixed private static function processUploadedFiles(array $files): array { - \Log::info("[CROP DEBUG] processUploadedFiles starting", [ + \Log::info('[CROP DEBUG] processUploadedFiles starting', [ 'count' => count($files), 'is_list' => array_is_list($files), ]); @@ -1029,7 +1040,7 @@ private static function normalizeCurrentState(mixed $state): array public function hydrate(mixed $value, ?Model $model = null): mixed { - \Log::info("[CROP DEBUG] Uploadcare::hydrate called", [ + \Log::info('[CROP DEBUG] Uploadcare::hydrate called', [ 'value_type' => gettype($value), 'value_preview' => is_string($value) ? $value : (is_array($value) ? 'Array count ' . count($value) : 'Object'), 'model_exists' => $model ? $model->exists : false, @@ -1050,8 +1061,8 @@ public function hydrate(mixed $value, ?Model $model = null): mixed // Try to hydrate from relation $hydratedFromModel = self::hydrateFromModel($model, $value, true); - \Log::info("[CROP DEBUG] hydrateFromModel result in hydrate()", [ - 'found' => $hydratedFromModel && !empty($hydratedFromModel), + \Log::info('[CROP DEBUG] hydrateFromModel result in hydrate()', [ + 'found' => $hydratedFromModel && ! empty($hydratedFromModel), 'count' => $hydratedFromModel ? $hydratedFromModel->count() : 0, ]); @@ -1063,11 +1074,12 @@ public function hydrate(mixed $value, ?Model $model = null): mixed if ($isMultiple) { return $hydratedFromModel; } + return $hydratedFromModel->first(); } $mediaModel = self::getMediaModel(); - + if (is_string($value) && ! json_validate($value)) { // Check if it's a ULID if (preg_match('/^[0-9A-HJKMNP-TV-Z]{26}$/i', $value)) { @@ -1078,7 +1090,7 @@ public function hydrate(mixed $value, ?Model $model = null): mixed $isMultiple = $config['multiple'] ?? false; if ($isMultiple && $media) { - return new \Illuminate\Database\Eloquent\Collection([$media]); + return new \Illuminate\Database\Eloquent\Collection([$media]); } return $media ? [$media] : $value; @@ -1108,12 +1120,12 @@ public function hydrate(mixed $value, ?Model $model = null): mixed 'cdnUrlModifiers' => $cdnUrlModifiers, ]); - // Check config to decide if we should return single or multiple + // Check config to decide if we should return single or multiple $config = $this->field_model->config ?? $model->field->config ?? []; $isMultiple = $config['multiple'] ?? false; if ($isMultiple) { - return new \Illuminate\Database\Eloquent\Collection([$media]); + return new \Illuminate\Database\Eloquent\Collection([$media]); } return [$media]; @@ -1131,13 +1143,14 @@ public function hydrate(mixed $value, ?Model $model = null): mixed // Check if we need to return a single item based on config, even for manual hydration // Priority: Local field model config -> Parent model field config $config = $this->field_model->config ?? $model->field->config ?? []; - - // hydrateBackstageUlids returns an array, so we check if single + + // hydrateBackstageUlids returns an array, so we check if single if (! ($config['multiple'] ?? false) && is_array($hydratedUlids) && ! empty($hydratedUlids)) { - // Wrap in collection first to match expected behavior if we were to return collection, - // but here we want single model - return $hydratedUlids[0]; + // Wrap in collection first to match expected behavior if we were to return collection, + // but here we want single model + return $hydratedUlids[0]; } + // If expected multiple, return collection return new \Illuminate\Database\Eloquent\Collection($hydratedUlids); } @@ -1148,54 +1161,57 @@ public function hydrate(mixed $value, ?Model $model = null): mixed $first = reset($value); $isString = is_string($first); $matches = $isString ? preg_match('/^[0-9A-HJKMNP-TV-Z]{26}$/i', $first) : false; - + if ($isString && $matches) { - $config = $this->field_model->config ?? $model->field->config ?? []; - if (! ($config['multiple'] ?? false)) { - return null; - } - return new \Illuminate\Database\Eloquent\Collection(); + $config = $this->field_model->config ?? $model->field->config ?? []; + if (! ($config['multiple'] ?? false)) { + return null; + } + + return new \Illuminate\Database\Eloquent\Collection; } } return $value; } - public static function mapMediaToValue(mixed $media): array|string + public static function mapMediaToValue(mixed $media): array | string { if (! $media instanceof Model && ! is_array($media)) { - return is_string($media) ? $media : []; + return is_string($media) ? $media : []; } $source = 'unknown'; if (is_array($media)) { - $data = $media; - $source = 'array'; + $data = $media; + $source = 'array'; } else { - $hasHydratedEdit = $media instanceof Model && array_key_exists('hydrated_edit', $media->getAttributes()); - $data = $hasHydratedEdit ? $media->getAttribute('hydrated_edit') : $media->getAttribute('edit'); - $source = $hasHydratedEdit ? 'hydrated_edit' : 'edit_accessor'; - - // Prioritize pivot meta if loaded, as it contains usage-specific modifiers - if ($media->relationLoaded('pivot') && $media->pivot && ! empty($media->pivot->meta)) { - $pivotMeta = $media->pivot->meta; - if (is_string($pivotMeta)) { - $pivotMeta = json_decode($pivotMeta, true); - } - - // Merge pivot meta over existing data, or use it as primary if data is empty - if (is_array($pivotMeta)) { - $data = !empty($data) && is_array($data) ? array_merge($data, $pivotMeta) : $pivotMeta; - $source = 'pivot_meta_merged'; - } - } - - $data = $data ?? $media->metadata; - if (empty($data)) $source = 'none'; - - if (is_string($data)) { - $data = json_decode($data, true); - } + $hasHydratedEdit = $media instanceof Model && array_key_exists('hydrated_edit', $media->getAttributes()); + $data = $hasHydratedEdit ? $media->getAttribute('hydrated_edit') : $media->getAttribute('edit'); + $source = $hasHydratedEdit ? 'hydrated_edit' : 'edit_accessor'; + + // Prioritize pivot meta if loaded, as it contains usage-specific modifiers + if ($media->relationLoaded('pivot') && $media->pivot && ! empty($media->pivot->meta)) { + $pivotMeta = $media->pivot->meta; + if (is_string($pivotMeta)) { + $pivotMeta = json_decode($pivotMeta, true); + } + + // Merge pivot meta over existing data, or use it as primary if data is empty + if (is_array($pivotMeta)) { + $data = ! empty($data) && is_array($data) ? array_merge($data, $pivotMeta) : $pivotMeta; + $source = 'pivot_meta_merged'; + } + } + + $data = $data ?? $media->metadata; + if (empty($data)) { + $source = 'none'; + } + + if (is_string($data)) { + $data = json_decode($data, true); + } } \Log::info("[CROP DEBUG] mapMediaToValue source: {$source}", [ @@ -1228,7 +1244,7 @@ public static function mapMediaToValue(mixed $media): array|string if (str_starts_with($modifiers, '/')) { $modifiers = substr($modifiers, 1); } - + // Ensure cdnUrl includes modifiers $data['cdnUrl'] = rtrim($data['cdnUrl'], '/') . '/' . $modifiers; if (! str_ends_with($data['cdnUrl'], '/')) { @@ -1246,7 +1262,6 @@ private static function hydrateFromModel(?Model $model, mixed $value = null, boo return null; } - $ulids = null; if (is_array($value) && ! empty($value)) { $ulids = array_filter(Arr::flatten($value), function ($item) { @@ -1266,24 +1281,24 @@ private static function hydrateFromModel(?Model $model, mixed $value = null, boo $media->each(function ($m) use ($model) { $mediaUlid = $m->ulid ?? 'UNKNOWN'; - + \Log::info("[CROP DEBUG] Processing media {$mediaUlid} in hydrateFromModel", [ 'has_pivot' => $m->pivot !== null, 'has_pivot_meta' => $m->pivot && $m->pivot->meta !== null, 'pivot_meta_type' => $m->pivot && $m->pivot->meta ? gettype($m->pivot->meta) : 'NULL', 'pivot_meta_preview' => $m->pivot && $m->pivot->meta ? (is_string($m->pivot->meta) ? substr($m->pivot->meta, 0, 200) : json_encode($m->pivot->meta)) : null, ]); - + if ($m->pivot && $m->pivot->meta) { $pivotMeta = is_string($m->pivot->meta) ? json_decode($m->pivot->meta, true) : $m->pivot->meta; - + \Log::info("[CROP DEBUG] Decoded pivot meta for media {$mediaUlid}", [ 'is_array' => is_array($pivotMeta), 'has_crop' => is_array($pivotMeta) && isset($pivotMeta['crop']), 'has_cdnUrlModifiers' => is_array($pivotMeta) && isset($pivotMeta['cdnUrlModifiers']), 'keys' => is_array($pivotMeta) ? array_keys($pivotMeta) : 'NOT_ARRAY', ]); - + if (is_array($pivotMeta)) { $m->setAttribute('hydrated_edit', $pivotMeta); if ($model) { @@ -1291,7 +1306,7 @@ private static function hydrateFromModel(?Model $model, mixed $value = null, boo $contextModel->setRelation('pivot', $m->pivot); $m->setRelation('edits', new \Illuminate\Database\Eloquent\Collection([$contextModel])); } - + \Log::info("[CROP DEBUG] Set hydrated_edit for media {$mediaUlid}"); } } else { @@ -1368,7 +1383,6 @@ private static function hydrateBackstageUlids(mixed $value): ?array return null; } - if (array_is_list($value)) { $hydrated = []; foreach ($value as $item) { From ecd94f7525bd420d8fb604a25fea4f5686c1b01e Mon Sep 17 00:00:00 2001 From: Baspa Date: Mon, 5 Jan 2026 16:41:01 +0100 Subject: [PATCH 22/29] remove logging --- src/Observers/ContentFieldValueObserver.php | 29 ----- src/Uploadcare.php | 115 +++----------------- 2 files changed, 16 insertions(+), 128 deletions(-) diff --git a/src/Observers/ContentFieldValueObserver.php b/src/Observers/ContentFieldValueObserver.php index cf3265f..a0ee1af 100644 --- a/src/Observers/ContentFieldValueObserver.php +++ b/src/Observers/ContentFieldValueObserver.php @@ -10,13 +10,6 @@ class ContentFieldValueObserver { public function saved(ContentFieldValue $contentFieldValue): void { - \Log::info("[OBSERVER DEBUG] ContentFieldValueObserver::saved triggered", [ - 'ulid' => $contentFieldValue->ulid, - 'field_ulid' => $contentFieldValue->field_ulid, - 'value_type' => gettype($contentFieldValue->value), - 'value_preview' => is_string($contentFieldValue->value) ? substr($contentFieldValue->value, 0, 100) : 'ARRAY/OBJ', - ]); - if (! $this->isValidField($contentFieldValue)) { return; } @@ -153,9 +146,6 @@ private function parseItem(mixed $item): array if (! empty($modifiers) && $modifiers[0] === '/') { $modifiers = substr($modifiers, 1); } - if ($item && str_contains($item, '/-/crop/')) { - \Log::info("[OBSERVER DEBUG] Found crop in URL string", ['item' => $item, 'modifiers' => $modifiers]); - } $meta = [ 'cdnUrl' => $item, 'cdnUrlModifiers' => $modifiers, @@ -171,18 +161,6 @@ private function parseItem(mixed $item): array $uuid = $item['uuid'] ?? ($item['fileInfo']['uuid'] ?? null); $meta = $item; - \Log::info("[CROP DEBUG] ContentFieldValueObserver::parseItem processing array item", [ - 'uuid' => $uuid, - 'has_cdnUrlModifiers' => isset($item['cdnUrlModifiers']), - 'cdnUrlModifiers_value' => $item['cdnUrlModifiers'] ?? null, - 'has_crop' => isset($item['crop']), - 'item_keys' => array_keys($item), - ]); - - if (!empty($item['cdnUrlModifiers'])) { - \Log::info("[OBSERVER DEBUG] Found explicit cdnUrlModifiers in array item", ['modifiers' => $item['cdnUrlModifiers']]); - } - // Try to extract modifiers from cdnUrl if not explicitly present or if we want to be sure if (isset($item['cdnUrl']) && is_string($item['cdnUrl']) && filter_var($item['cdnUrl'], FILTER_VALIDATE_URL)) { preg_match('/([a-f0-9-]{36})/i', $item['cdnUrl'], $matches, PREG_OFFSET_CAPTURE); @@ -201,13 +179,6 @@ private function parseItem(mixed $item): array } } } - - \Log::info("[CROP DEBUG] ContentFieldValueObserver::parseItem final meta", [ - 'uuid' => $uuid, - 'has_cdnUrlModifiers_in_meta' => isset($meta['cdnUrlModifiers']), - 'cdnUrlModifiers_in_meta' => $meta['cdnUrlModifiers'] ?? null, - 'meta_keys' => array_keys($meta), - ]); } return [$uuid, $meta]; diff --git a/src/Uploadcare.php b/src/Uploadcare.php index 5d91d01..3e90f4f 100755 --- a/src/Uploadcare.php +++ b/src/Uploadcare.php @@ -49,14 +49,6 @@ public static function make(string $name, Field $field): Input ->withMetadata() ->removeCopyright() ->dehydrateStateUsing(function ($state, $component, $record) { - \Log::info("[CROP DEBUG] dehydrateStateUsing called", [ - 'field' => $component->getName(), - 'state_path' => $component->getStatePath(), - 'state_type' => gettype($state), - 'is_string' => is_string($state), - 'is_array' => is_array($state), - 'is_collection' => $state instanceof \Illuminate\Database\Eloquent\Collection, - ]); if (is_string($state) && json_validate($state)) { $state = json_decode($state, true); @@ -76,11 +68,6 @@ public static function make(string $name, Field $field): Input return $item; }, $state); - \Log::info("[CROP DEBUG] dehydrateStateUsing returning array", [ - 'count' => count($result), - 'first_has_cdnUrlModifiers' => isset($result[0]['cdnUrlModifiers']), - 'first_item_keys' => isset($result[0]) && is_array($result[0]) ? array_keys($result[0]) : 'NOT_ARRAY', - ]); /* // Ensure we return a single object (or string) for non-multiple fields during dehydration @@ -174,10 +161,6 @@ public static function make(string $name, Field $field): Input $foundModels->each(function($m) use ($record, $fieldName) { $mediaUlid = $m->ulid ?? 'UNKNOWN'; - \Log::info("[CROP DEBUG] Hydrating media {$mediaUlid} in field {$fieldName}", [ - 'has_pivot' => $m->relationLoaded('pivot') && $m->pivot !== null, - 'has_pivot_meta' => $m->relationLoaded('pivot') && $m->pivot && $m->pivot->meta !== null, - ]); if ($m->relationLoaded('pivot') && $m->pivot && $m->pivot->meta) { $meta = is_string($m->pivot->meta) ? json_decode($m->pivot->meta, true) : $m->pivot->meta; @@ -269,14 +252,7 @@ public static function make(string $name, Field $field): Input } - \Log::info("[CROP DEBUG] afterStateHydrated final result", [ - 'field' => $fieldName, - 'type' => gettype($newState), - 'is_array' => is_array($newState), - 'is_list' => is_array($newState) ? array_is_list($newState) : 'N/A', - 'count' => is_array($newState) ? count($newState) : 'N/A', - ]); - + if ($newState !== $state) { $component->state($newState); } @@ -451,30 +427,25 @@ public static function mutateFormDataCallback(Model $record, Field $field, array return $data; } - \Log::info("[CROP DEBUG] mutateFormDataCallback start", [ - 'field' => $field->ulid, - 'record_exists' => $record->exists, - 'has_values_prop' => isset($record->values), - 'record_values_type' => isset($record->values) ? gettype($record->values) : 'null', - ]); + $values = null; // 1. Try to get from property first (set by EditContent) if (isset($record->values) && is_array($record->values)) { $values = $record->values[$field->ulid] ?? null; - \Log::info("[CROP DEBUG] Got value from property", ['value_type' => gettype($values)]); + } // 2. Fallback to getFieldValueFromRecord which checks relationships if ($values === null) { $values = self::getFieldValueFromRecord($record, $field); - \Log::info("[CROP DEBUG] Got value from getFieldValueFromRecord", ['value_type' => gettype($values)]); + } if ($values === '' || $values === [] || $values === null || empty($values)) { $data[$record->valueColumn ?? 'values'][$field->ulid] = []; - \Log::info("[CROP DEBUG] Value empty, setting empty array"); + return $data; } @@ -526,12 +497,7 @@ public static function mutateBeforeSaveCallback(Model $record, Field $field, arr $values = self::findFieldValues($data, $field); - \Log::info("[CROP DEBUG] mutateBeforeSaveCallback", [ - 'field' => $field->ulid, - 'values_type' => gettype($values), - 'values_preview' => is_array($values) ? (array_is_list($values) ? 'list count ' . count($values) : 'assoc keys ' . implode(',', array_keys($values))) : $values, - 'data_keys' => array_keys($data), - ]); + if ($values === '' || $values === [] || $values === null || empty($values)) { @@ -664,14 +630,7 @@ private static function findFieldValues(array $data, Field $field): mixed $fieldUlid = (string) $field->ulid; $fieldSlug = (string) $field->slug; - \Log::info("[CROP DEBUG] findFieldValues searching", [ - 'ulid' => $fieldUlid, - 'slug' => $fieldSlug, - 'data_keys' => array_keys($data), - 'has_values_key' => isset($data['values']), - 'values_is_array' => isset($data['values']) && is_array($data['values']), - 'values_is_string' => isset($data['values']) && is_string($data['values']), - ]); + // Try direct key first (most common) if (array_key_exists($fieldUlid, $data)) return $data[$fieldUlid]; @@ -680,15 +639,10 @@ private static function findFieldValues(array $data, Field $field): mixed // Recursive search that correctly traverses lists (repeaters/builders) $notFound = new \stdClass(); $findInNested = function ($array, $ulid, $slug, $depth = 0) use (&$findInNested, $notFound) { - $keys = array_keys($array); - \Log::info("[CROP DEBUG] findInNested level {$depth}", [ - 'keys' => $keys, - 'searching_for' => [$ulid, $slug] - ]); - + // First pass: look for direct keys at this level if (array_key_exists($ulid, $array)) { - \Log::info("[CROP DEBUG] findInNested FOUND direct key", ['key' => $ulid, 'value_type' => gettype($array[$ulid]), 'value_preview' => $array[$ulid]]); + return $array[$ulid]; } if (array_key_exists($slug, $array)) { @@ -716,10 +670,7 @@ private static function findFieldValues(array $data, Field $field): mixed $found = true; } - \Log::info("[CROP DEBUG] findFieldValues result", [ - 'found' => $found, - 'type' => gettype($result), - ]); + return $result; } @@ -737,13 +688,8 @@ private static function normalizeValues(mixed $values): mixed return $values; } - private static function processUploadedFiles(array $files): array + private static function processUploadedFiles(mixed $files): array { - \Log::info("[CROP DEBUG] processUploadedFiles starting", [ - 'count' => count($files), - 'is_list' => array_is_list($files), - ]); - if (! empty($files) && ! array_is_list($files)) { $files = [$files]; } @@ -1029,11 +975,7 @@ private static function normalizeCurrentState(mixed $state): array public function hydrate(mixed $value, ?Model $model = null): mixed { - \Log::info("[CROP DEBUG] Uploadcare::hydrate called", [ - 'value_type' => gettype($value), - 'value_preview' => is_string($value) ? $value : (is_array($value) ? 'Array count ' . count($value) : 'Object'), - 'model_exists' => $model ? $model->exists : false, - ]); + if (empty($value)) { return null; @@ -1050,10 +992,7 @@ public function hydrate(mixed $value, ?Model $model = null): mixed // Try to hydrate from relation $hydratedFromModel = self::hydrateFromModel($model, $value, true); - \Log::info("[CROP DEBUG] hydrateFromModel result in hydrate()", [ - 'found' => $hydratedFromModel && !empty($hydratedFromModel), - 'count' => $hydratedFromModel ? $hydratedFromModel->count() : 0, - ]); + if ($hydratedFromModel !== null && ! empty($hydratedFromModel)) { // Check config to decide if we should return single or multiple @@ -1198,10 +1137,7 @@ public static function mapMediaToValue(mixed $media): array|string } } - \Log::info("[CROP DEBUG] mapMediaToValue source: {$source}", [ - 'has_cdnUrlModifiers' => isset($data['cdnUrlModifiers']), - 'cdnUrl' => $data['cdnUrl'] ?? null, - ]); + if (is_array($data)) { // Extract modifiers from cdnUrl if missing @@ -1212,13 +1148,6 @@ public static function mapMediaToValue(mixed $media): array|string $modifiers = $matches[2]; // Clean up trailing slash $modifiers = rtrim($modifiers, '/'); - if (! empty($modifiers) && $modifiers !== '-/preview') { - $data['cdnUrlModifiers'] = $modifiers; - \Log::info('[CROP DEBUG] Extracted modifiers from URL', [ - 'uuid' => $matches[1], - 'modifiers' => $modifiers, - ]); - } } } @@ -1267,22 +1196,10 @@ private static function hydrateFromModel(?Model $model, mixed $value = null, boo $media->each(function ($m) use ($model) { $mediaUlid = $m->ulid ?? 'UNKNOWN'; - \Log::info("[CROP DEBUG] Processing media {$mediaUlid} in hydrateFromModel", [ - 'has_pivot' => $m->pivot !== null, - 'has_pivot_meta' => $m->pivot && $m->pivot->meta !== null, - 'pivot_meta_type' => $m->pivot && $m->pivot->meta ? gettype($m->pivot->meta) : 'NULL', - 'pivot_meta_preview' => $m->pivot && $m->pivot->meta ? (is_string($m->pivot->meta) ? substr($m->pivot->meta, 0, 200) : json_encode($m->pivot->meta)) : null, - ]); if ($m->pivot && $m->pivot->meta) { $pivotMeta = is_string($m->pivot->meta) ? json_decode($m->pivot->meta, true) : $m->pivot->meta; - \Log::info("[CROP DEBUG] Decoded pivot meta for media {$mediaUlid}", [ - 'is_array' => is_array($pivotMeta), - 'has_crop' => is_array($pivotMeta) && isset($pivotMeta['crop']), - 'has_cdnUrlModifiers' => is_array($pivotMeta) && isset($pivotMeta['cdnUrlModifiers']), - 'keys' => is_array($pivotMeta) ? array_keys($pivotMeta) : 'NOT_ARRAY', - ]); if (is_array($pivotMeta)) { $m->setAttribute('hydrated_edit', $pivotMeta); @@ -1292,10 +1209,10 @@ private static function hydrateFromModel(?Model $model, mixed $value = null, boo $m->setRelation('edits', new \Illuminate\Database\Eloquent\Collection([$contextModel])); } - \Log::info("[CROP DEBUG] Set hydrated_edit for media {$mediaUlid}"); + } } else { - \Log::warning("[CROP DEBUG] No pivot meta found for media {$mediaUlid}"); + } }); From 92c7d8da8d5757c848a367f01bff0611f422b776 Mon Sep 17 00:00:00 2001 From: Baspa Date: Tue, 6 Jan 2026 18:07:11 +0100 Subject: [PATCH 23/29] fix: accidental added code block --- src/Uploadcare.php | 47 ++-------------------------------------------- 1 file changed, 2 insertions(+), 45 deletions(-) diff --git a/src/Uploadcare.php b/src/Uploadcare.php index 2974d1c..54c71ba 100755 --- a/src/Uploadcare.php +++ b/src/Uploadcare.php @@ -190,53 +190,14 @@ public static function make(string $name, Field $field): Input } else { // Process each item in the state array $extractedFiles = []; - + foreach ($state as $item) { if (is_array($item)) { $extractedFiles[] = self::mapMediaToValue($item); + continue; } - \Log::info("[CROP DEBUG] Hydrating media {$mediaUlid} in field {$fieldName}", [ - 'has_pivot' => $m->relationLoaded('pivot') && $m->pivot !== null, - 'has_pivot_meta' => $m->relationLoaded('pivot') && $m->pivot && $m->pivot->meta !== null, - ]); - - if ($m->relationLoaded('pivot') && $m->pivot && $m->pivot->meta) { - $meta = is_string($m->pivot->meta) ? json_decode($m->pivot->meta, true) : $m->pivot->meta; - if (is_array($meta)) { - $m->setAttribute('hydrated_edit', $meta); - } - } - $contextModel = clone $record; - if ($m->relationLoaded('pivot') && $m->pivot) { - $contextModel->setRelation('pivot', $m->pivot); - } else { - $dummyPivot = new \Backstage\Models\ContentFieldValue; - $dummyPivot->setAttribute('meta', null); - $contextModel->setRelation('pivot', $dummyPivot); - } - $m->setRelation('edits', new \Illuminate\Database\Eloquent\Collection([$contextModel])); - }); - } - - if ($foundModels->count() === 1 && count($state) > 1) { - $newState = [self::mapMediaToValue($foundModels->first())]; - } else { - $newState = $foundModels->map(fn ($m) => self::mapMediaToValue($m))->all(); - } - - } else { - // Process each item in the state array - $extractedFiles = []; - - foreach ($state as $item) { - if (is_array($item)) { - $extractedFiles[] = self::mapMediaToValue($item); - - continue; - } - if (! is_string($item)) { continue; } @@ -471,8 +432,6 @@ public static function mutateFormDataCallback(Model $record, Field $field, array return $data; } - - $values = null; // 1. Try to get from property first (set by EditContent) @@ -540,8 +499,6 @@ public static function mutateBeforeSaveCallback(Model $record, Field $field, arr $valueColumn = $record->valueColumn ?? 'values'; $values = self::findFieldValues($data, $field); - - if ($values === '' || $values === [] || $values === null || empty($values)) { // Check if key exists using strict check to avoid wiping out data that wasn't submitted From dab6013dd44f852269e1fe2d07a2c00f02d1e642 Mon Sep 17 00:00:00 2001 From: Baspa <10845460+Baspa@users.noreply.github.com> Date: Tue, 6 Jan 2026 17:07:56 +0000 Subject: [PATCH 24/29] fix: styling --- src/Observers/ContentFieldValueObserver.php | 2 +- src/Uploadcare.php | 115 +++++++++----------- 2 files changed, 51 insertions(+), 66 deletions(-) diff --git a/src/Observers/ContentFieldValueObserver.php b/src/Observers/ContentFieldValueObserver.php index a0ee1af..49b1ca6 100644 --- a/src/Observers/ContentFieldValueObserver.php +++ b/src/Observers/ContentFieldValueObserver.php @@ -160,7 +160,7 @@ private function parseItem(mixed $item): array } elseif (is_array($item)) { $uuid = $item['uuid'] ?? ($item['fileInfo']['uuid'] ?? null); $meta = $item; - + // Try to extract modifiers from cdnUrl if not explicitly present or if we want to be sure if (isset($item['cdnUrl']) && is_string($item['cdnUrl']) && filter_var($item['cdnUrl'], FILTER_VALIDATE_URL)) { preg_match('/([a-f0-9-]{36})/i', $item['cdnUrl'], $matches, PREG_OFFSET_CAPTURE); diff --git a/src/Uploadcare.php b/src/Uploadcare.php index 54c71ba..43026dd 100755 --- a/src/Uploadcare.php +++ b/src/Uploadcare.php @@ -49,7 +49,7 @@ public static function make(string $name, Field $field): Input ->withMetadata() ->removeCopyright() ->dehydrateStateUsing(function ($state, $component, $record) { - + if (is_string($state) && json_validate($state)) { $state = json_decode($state, true); } @@ -67,8 +67,7 @@ public static function make(string $name, Field $field): Input return $item; }, $state); - - + /* // Ensure we return a single object (or string) for non-multiple fields during dehydration // to prevent Filament from clearing the state. @@ -157,46 +156,45 @@ public static function make(string $name, Field $field): Input $foundModels = $mediaModel::whereIn('ulid', $potentialUlids->toArray())->get(); } - if ($foundModels->isNotEmpty()) { - if ($record) { - $foundModels->each(function($m) use ($record, $fieldName) { - $mediaUlid = $m->ulid ?? 'UNKNOWN'; - - - if ($m->relationLoaded('pivot') && $m->pivot && $m->pivot->meta) { - $meta = is_string($m->pivot->meta) ? json_decode($m->pivot->meta, true) : $m->pivot->meta; - if (is_array($meta)) { - $m->setAttribute('hydrated_edit', $meta); - } - } - $contextModel = clone $record; - if ($m->relationLoaded('pivot') && $m->pivot) { - $contextModel->setRelation('pivot', $m->pivot); - } else { - $dummyPivot = new \Backstage\Models\ContentFieldValue(); - $dummyPivot->setAttribute('meta', null); - $contextModel->setRelation('pivot', $dummyPivot); - } - $m->setRelation('edits', new \Illuminate\Database\Eloquent\Collection([$contextModel])); - }); - } - - if ($foundModels->count() === 1 && count($state) > 1) { - $newState = [self::mapMediaToValue($foundModels->first())]; - } else { - $newState = $foundModels->map(fn($m) => self::mapMediaToValue($m))->all(); - } - - } else { - // Process each item in the state array - $extractedFiles = []; - - foreach ($state as $item) { - if (is_array($item)) { - $extractedFiles[] = self::mapMediaToValue($item); - - continue; - } + if ($foundModels->isNotEmpty()) { + if ($record) { + $foundModels->each(function ($m) use ($record) { + $mediaUlid = $m->ulid ?? 'UNKNOWN'; + + if ($m->relationLoaded('pivot') && $m->pivot && $m->pivot->meta) { + $meta = is_string($m->pivot->meta) ? json_decode($m->pivot->meta, true) : $m->pivot->meta; + if (is_array($meta)) { + $m->setAttribute('hydrated_edit', $meta); + } + } + $contextModel = clone $record; + if ($m->relationLoaded('pivot') && $m->pivot) { + $contextModel->setRelation('pivot', $m->pivot); + } else { + $dummyPivot = new \Backstage\Models\ContentFieldValue; + $dummyPivot->setAttribute('meta', null); + $contextModel->setRelation('pivot', $dummyPivot); + } + $m->setRelation('edits', new \Illuminate\Database\Eloquent\Collection([$contextModel])); + }); + } + + if ($foundModels->count() === 1 && count($state) > 1) { + $newState = [self::mapMediaToValue($foundModels->first())]; + } else { + $newState = $foundModels->map(fn ($m) => self::mapMediaToValue($m))->all(); + } + + } else { + // Process each item in the state array + $extractedFiles = []; + + foreach ($state as $item) { + if (is_array($item)) { + $extractedFiles[] = self::mapMediaToValue($item); + + continue; + } if (! is_string($item)) { continue; @@ -257,7 +255,6 @@ public static function make(string $name, Field $field): Input } - if ($newState !== $state) { $component->state($newState); } @@ -437,18 +434,18 @@ public static function mutateFormDataCallback(Model $record, Field $field, array // 1. Try to get from property first (set by EditContent) if (isset($record->values) && is_array($record->values)) { $values = $record->values[$field->ulid] ?? null; - + } // 2. Fallback to getFieldValueFromRecord which checks relationships if ($values === null) { $values = self::getFieldValueFromRecord($record, $field); - + } if ($values === '' || $values === [] || $values === null || empty($values)) { $data[$record->valueColumn ?? 'values'][$field->ulid] = []; - + return $data; } @@ -630,8 +627,6 @@ private static function findFieldValues(array $data, Field $field): mixed $fieldUlid = (string) $field->ulid; $fieldSlug = (string) $field->slug; - - // Try direct key first (most common) if (array_key_exists($fieldUlid, $data)) { return $data[$fieldUlid]; @@ -643,10 +638,10 @@ private static function findFieldValues(array $data, Field $field): mixed // Recursive search that correctly traverses lists (repeaters/builders) $notFound = new \stdClass; $findInNested = function ($array, $ulid, $slug, $depth = 0) use (&$findInNested, $notFound) { - + // First pass: look for direct keys at this level if (array_key_exists($ulid, $array)) { - + return $array[$ulid]; } if (array_key_exists($slug, $array)) { @@ -674,8 +669,6 @@ private static function findFieldValues(array $data, Field $field): mixed } else { $found = true; } - - return $result; } @@ -980,7 +973,6 @@ private static function normalizeCurrentState(mixed $state): array public function hydrate(mixed $value, ?Model $model = null): mixed { - if (empty($value)) { return null; @@ -997,8 +989,6 @@ public function hydrate(mixed $value, ?Model $model = null): mixed // Try to hydrate from relation $hydratedFromModel = self::hydrateFromModel($model, $value, true); - - if ($hydratedFromModel !== null && ! empty($hydratedFromModel)) { // Check config to decide if we should return single or multiple $config = $this->field_model->config ?? $model->field->config ?? []; @@ -1147,8 +1137,6 @@ public static function mapMediaToValue(mixed $media): array | string } } - - if (is_array($data)) { // Extract modifiers from cdnUrl if missing if (isset($data['cdnUrl']) && ! isset($data['cdnUrlModifiers'])) { @@ -1204,12 +1192,10 @@ private static function hydrateFromModel(?Model $model, mixed $value = null, boo $media->each(function ($m) use ($model) { $mediaUlid = $m->ulid ?? 'UNKNOWN'; - - + if ($m->pivot && $m->pivot->meta) { $pivotMeta = is_string($m->pivot->meta) ? json_decode($m->pivot->meta, true) : $m->pivot->meta; - - + if (is_array($pivotMeta)) { $m->setAttribute('hydrated_edit', $pivotMeta); if ($model) { @@ -1217,11 +1203,10 @@ private static function hydrateFromModel(?Model $model, mixed $value = null, boo $contextModel->setRelation('pivot', $m->pivot); $m->setRelation('edits', new \Illuminate\Database\Eloquent\Collection([$contextModel])); } - - + } } else { - + } }); From 5bd8d3adc3ee64dc45bc76e2fe2c5d74e05892ea Mon Sep 17 00:00:00 2001 From: Mathieu Date: Fri, 30 Jan 2026 13:04:28 +0100 Subject: [PATCH 25/29] Remove matrix in github workflows --- .github/workflows/run-tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 4eaf1ba..44456ea 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -16,7 +16,7 @@ jobs: strategy: fail-fast: true matrix: - os: [ubuntu-latest, windows-latest] + os: [ubuntu-latest] php: [8.3] laravel: [12.*] include: From 8ee82724669767fae163b0e1cbfcae0c75276c7f Mon Sep 17 00:00:00 2001 From: Baspa Date: Fri, 13 Feb 2026 09:45:51 +0100 Subject: [PATCH 26/29] fix: add safety checks for uploadcare --- src/Uploadcare.php | 28 +++++++++++++++++++++++----- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/src/Uploadcare.php b/src/Uploadcare.php index 43026dd..a2135bf 100755 --- a/src/Uploadcare.php +++ b/src/Uploadcare.php @@ -704,12 +704,18 @@ private static function processUploadedFiles(mixed $files): array if (self::isArrayOfArrays($normalizedFiles)) { foreach ($normalizedFiles as $singleFile) { if ($singleFile !== null) { - $media[] = self::createOrUpdateMediaRecord($singleFile); + $mediaRecord = self::createOrUpdateMediaRecord($singleFile); + if ($mediaRecord !== null) { + $media[] = $mediaRecord; + } } } } else { if (is_array($normalizedFiles)) { - $media[] = self::createOrUpdateMediaRecord($normalizedFiles); + $mediaRecord = self::createOrUpdateMediaRecord($normalizedFiles); + if ($mediaRecord !== null) { + $media[] = $mediaRecord; + } } } } @@ -762,7 +768,7 @@ private static function extractCdnUrl(array $file): ?string return $url && filter_var($url, FILTER_VALIDATE_URL) ? $url : null; } - private static function createOrUpdateMediaRecord(array $file): Model + private static function createOrUpdateMediaRecord(array $file): ?Model { $mediaModel = self::getMediaModel(); @@ -773,12 +779,24 @@ private static function createOrUpdateMediaRecord(array $file): Model $info = $file['fileInfo'] ?? $file; $detailedInfo = self::extractDetailedInfo($info); + // Extract UUID - this is required for Uploadcare files + $uuid = $info['uuid'] ?? ($info['fileInfo']['uuid'] ?? null); + + // Skip if no UUID is present - we can't create a valid media record without it + if (empty($uuid)) { + \Log::warning('Uploadcare: Skipping media record creation - UUID missing from file data', [ + 'file_data' => $file, + ]); + + return null; + } + $tenantUlid = Filament::getTenant()->ulid ?? null; $media = $mediaModel::updateOrCreate([ 'site_ulid' => $tenantUlid, 'disk' => 'uploadcare', - 'filename' => $info['uuid'] ?? ($info['fileInfo']['uuid'] ?? null), + 'filename' => $uuid, ], [ 'original_filename' => $info['name'] ?? ($info['original_filename'] ?? 'unknown'), 'uploaded_by' => Auth::id(), @@ -790,7 +808,7 @@ private static function createOrUpdateMediaRecord(array $file): Model 'alt' => null, 'public' => config('backstage.media.visibility') === 'public', 'metadata' => $info, - 'checksum' => md5($info['uuid'] ?? uniqid()), + 'checksum' => md5($uuid), ]); return $media; From 8c0a5bc890f7c7ceb6100113f969dda2857fe0e7 Mon Sep 17 00:00:00 2001 From: Manoj Hortulanus Date: Wed, 25 Feb 2026 10:14:50 +0100 Subject: [PATCH 27/29] feat: context-aware hydration contracts for field values Replace the request-path-sniffing `shouldHydrate()` approach with explicit hydration contracts (`HydratesValuesForFrontend`, `HydratesValuesForFilament`) so each field class declares exactly how its value should be transformed for frontend rendering vs Filament admin forms. Key changes: - Add `HydratesValuesForFrontend` and `HydratesValuesForFilament` contracts - Add `DispatchesHydration` trait for dispatching to the right contract - Implement both contracts on all built-in field types - Register all built-in fields in FieldsServiceProvider (previously only Text) - Fix Select relation resolution in nested Builder/Repeater context by preferring `$this->field_model` over the parent's model - Text field no longer wraps in HtmlString on frontend (only Textarea, MarkdownEditor, and RichEditor do) - Replace MySQL-specific FIELD() with PHP-based ordering in getContentRelation - Improve backstage:audit command: timeouts/unreachable shown as warnings, slow page detection, per-page timing, --timeout and --slow options - Move core package tests to monorepo-level test suite (SQLite :memory:) Co-Authored-By: Claude Opus 4.6 --- src/Uploadcare.php | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/Uploadcare.php b/src/Uploadcare.php index a2135bf..8b3838a 100755 --- a/src/Uploadcare.php +++ b/src/Uploadcare.php @@ -4,6 +4,8 @@ use Backstage\Fields\Contracts\FieldContract; use Backstage\Fields\Contracts\HydratesValues; +use Backstage\Fields\Contracts\HydratesValuesForFilament; +use Backstage\Fields\Contracts\HydratesValuesForFrontend; use Backstage\Fields\Fields\Base; use Backstage\Fields\Models\Field; use Backstage\Uploadcare\Enums\Style; @@ -22,7 +24,7 @@ use Illuminate\Support\Arr; use Illuminate\Support\Facades\Auth; -class Uploadcare extends Base implements FieldContract, HydratesValues +class Uploadcare extends Base implements FieldContract, HydratesValues, HydratesValuesForFilament, HydratesValuesForFrontend { public function getFieldType(): ?string { @@ -989,6 +991,16 @@ private static function normalizeCurrentState(mixed $state): array public ?Field $field_model = null; + public function hydrateForFrontend(mixed $value, ?Model $model = null): mixed + { + return $this->hydrate($value, $model); + } + + public function hydrateForFilament(mixed $value, ?Model $model = null): mixed + { + return $this->hydrate($value, $model); + } + public function hydrate(mixed $value, ?Model $model = null): mixed { From 8bb17ef2398941e0e9a55359829905ac0133b979 Mon Sep 17 00:00:00 2001 From: Baspa Date: Fri, 13 Mar 2026 09:49:39 +0100 Subject: [PATCH 28/29] chore: upgrade Laravel Pint to 1.29.0 and apply code style fixes Upgraded Pint from 1.27.1 to 1.29.0 to align local development with CI workflow. Applied new code style rules across 225 files including: - fully_qualified_strict_types - ordered_imports - braces_position - class_definition Co-Authored-By: Claude Opus 4.5 --- ..._repair_uploadcare_media_relationships.php | 5 +++-- src/Uploadcare.php | 15 ++++++++----- src/UploadcareFieldServiceProvider.php | 22 +++++++++++++------ 3 files changed, 27 insertions(+), 15 deletions(-) diff --git a/database/migrations/2025_12_17_000001_repair_uploadcare_media_relationships.php b/database/migrations/2025_12_17_000001_repair_uploadcare_media_relationships.php index 70ccf9d..d2af967 100644 --- a/database/migrations/2025_12_17_000001_repair_uploadcare_media_relationships.php +++ b/database/migrations/2025_12_17_000001_repair_uploadcare_media_relationships.php @@ -1,5 +1,6 @@ orderBy('ulid')->value('ulid'); - $mediaModelClass = config('backstage.media.model', \Backstage\Media\Models\Media::class); + $mediaModelClass = config('backstage.media.model', Media::class); if (! is_string($mediaModelClass) || ! class_exists($mediaModelClass)) { - $mediaModelClass = \Backstage\Media\Models\Media::class; + $mediaModelClass = Media::class; } $mediaTable = app($mediaModelClass)->getTable(); diff --git a/src/Uploadcare.php b/src/Uploadcare.php index 8b3838a..9037d37 100755 --- a/src/Uploadcare.php +++ b/src/Uploadcare.php @@ -8,11 +8,13 @@ use Backstage\Fields\Contracts\HydratesValuesForFrontend; use Backstage\Fields\Fields\Base; use Backstage\Fields\Models\Field; +use Backstage\Models\ContentFieldValue; use Backstage\Uploadcare\Enums\Style; use Backstage\Uploadcare\Forms\Components\Uploadcare as Input; use Backstage\UploadcareField\Forms\Components\MediaGridPicker; use Filament\Actions\Action; use Filament\Facades\Filament; +use Filament\Forms\Components\Hidden; use Filament\Forms\Components\Select; use Filament\Forms\Components\TextInput; use Filament\Forms\Components\Toggle; @@ -22,6 +24,7 @@ use Filament\Support\Icons\Heroicon; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Arr; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\Auth; class Uploadcare extends Base implements FieldContract, HydratesValues, HydratesValuesForFilament, HydratesValuesForFrontend @@ -138,7 +141,7 @@ public static function make(string $name, Field $field): Input } } - $fieldValue = \Backstage\Models\ContentFieldValue::where('content_ulid', $record->getKey()) + $fieldValue = ContentFieldValue::where('content_ulid', $record->getKey()) ->where(function ($query) use ($fieldUlid) { $query->where('field_ulid', $fieldUlid) ->orWhere('ulid', $fieldUlid); @@ -173,7 +176,7 @@ public static function make(string $name, Field $field): Input if ($m->relationLoaded('pivot') && $m->pivot) { $contextModel->setRelation('pivot', $m->pivot); } else { - $dummyPivot = new \Backstage\Models\ContentFieldValue; + $dummyPivot = new ContentFieldValue; $dummyPivot->setAttribute('meta', null); $contextModel->setRelation('pivot', $dummyPivot); } @@ -300,7 +303,7 @@ public static function make(string $name, Field $field): Input ->perPage(12) ->multiple($isMultiple) ->acceptedFileTypes($acceptedFileTypes), - \Filament\Forms\Components\Hidden::make('selected_media_uuid') + Hidden::make('selected_media_uuid') ->default(null) ->dehydrated() ->live(), @@ -457,15 +460,15 @@ public static function mutateFormDataCallback(Model $record, Field $field, array if (self::isMediaUlidArray($values)) { $mediaData = null; - if ($record->exists && class_exists(\Backstage\Models\ContentFieldValue::class)) { + if ($record->exists && class_exists(ContentFieldValue::class)) { try { - $cfv = \Backstage\Models\ContentFieldValue::where('content_ulid', $record->ulid) + $cfv = ContentFieldValue::where('content_ulid', $record->ulid) ->where('field_ulid', $field->ulid) ->first(); if ($cfv) { $models = self::hydrateFromModel($cfv, $values, true); - if ($models && $models instanceof \Illuminate\Support\Collection) { + if ($models && $models instanceof Collection) { $mediaData = $models->map(fn ($m) => self::mapMediaToValue($m))->values()->all(); } } diff --git a/src/UploadcareFieldServiceProvider.php b/src/UploadcareFieldServiceProvider.php index 8a31289..3b99da1 100644 --- a/src/UploadcareFieldServiceProvider.php +++ b/src/UploadcareFieldServiceProvider.php @@ -2,8 +2,16 @@ namespace Backstage\UploadcareField; +use Backstage\Fields\Fields; +use Backstage\Media\Events\MediaUploading; +use Backstage\Media\Models\Media; +use Backstage\Models\ContentFieldValue; +use Backstage\UploadcareField\Listeners\CreateMediaFromUploadcare; +use Backstage\UploadcareField\Livewire\MediaGridPicker; +use Backstage\UploadcareField\Observers\ContentFieldValueObserver; use Filament\Support\Assets\Css; use Filament\Support\Facades\FilamentAsset; +use Illuminate\Support\Facades\Event; use Spatie\LaravelPackageTools\Package; use Spatie\LaravelPackageTools\PackageServiceProvider; @@ -28,14 +36,14 @@ public function packageBooted(): void Css::make('uploadcare-field', __DIR__ . '/../resources/dist/uploadcare-field.css'), ], 'backstage/uploadcare-field'); - \Illuminate\Support\Facades\Event::listen( - \Backstage\Media\Events\MediaUploading::class, - \Backstage\UploadcareField\Listeners\CreateMediaFromUploadcare::class, + Event::listen( + MediaUploading::class, + CreateMediaFromUploadcare::class, ); - \Backstage\Models\ContentFieldValue::observe(\Backstage\UploadcareField\Observers\ContentFieldValueObserver::class); + ContentFieldValue::observe(ContentFieldValueObserver::class); - \Backstage\Fields\Fields::registerField(\Backstage\UploadcareField\Uploadcare::class); + Fields::registerField(Uploadcare::class); } public function bootingPackage(): void @@ -43,7 +51,7 @@ public function bootingPackage(): void $this->loadViewsFrom(__DIR__ . '/../resources/views', 'backstage-uploadcare-field'); // Register Media src resolver - \Backstage\Media\Models\Media::resolveSrcUsing(function ($media) { + Media::resolveSrcUsing(function ($media) { if ($media->metadata && isset($media->metadata['cdnUrl'])) { $cdnUrl = $media->metadata['cdnUrl']; if (filter_var($cdnUrl, FILTER_VALIDATE_URL)) { @@ -55,6 +63,6 @@ public function bootingPackage(): void }); // Register Livewire components - $this->app->make('livewire')->component('backstage-uploadcare-field::media-grid-picker', \Backstage\UploadcareField\Livewire\MediaGridPicker::class); + $this->app->make('livewire')->component('backstage-uploadcare-field::media-grid-picker', MediaGridPicker::class); } } From 3a187c1ca99e3c6babe7d2407ec0917243cd9028 Mon Sep 17 00:00:00 2001 From: markvaneijk <1925388+markvaneijk@users.noreply.github.com> Date: Tue, 31 Mar 2026 03:35:49 +0000 Subject: [PATCH 29/29] fix: styling --- ...000000_fix_uploadcare_double_encoded_json.php | 2 +- src/Listeners/CreateMediaFromUploadcare.php | 4 ++-- src/Livewire/MediaGridPicker.php | 6 +++--- src/Uploadcare.php | 16 ++++++++-------- src/UploadcareFieldServiceProvider.php | 4 ++-- tests/TestCase.php | 2 +- 6 files changed, 17 insertions(+), 17 deletions(-) diff --git a/database/migrations/2025_08_08_000000_fix_uploadcare_double_encoded_json.php b/database/migrations/2025_08_08_000000_fix_uploadcare_double_encoded_json.php index f779528..63ee34d 100644 --- a/database/migrations/2025_08_08_000000_fix_uploadcare_double_encoded_json.php +++ b/database/migrations/2025_08_08_000000_fix_uploadcare_double_encoded_json.php @@ -13,7 +13,7 @@ private function decodeAllJsonStrings($data, $path = '') { if (is_array($data)) { foreach ($data as $key => $value) { - $currentPath = $path === '' ? $key : $path . '.' . $key; + $currentPath = $path === '' ? $key : $path.'.'.$key; if (is_string($value)) { $decoded = $value; $decodeCount = 0; diff --git a/src/Listeners/CreateMediaFromUploadcare.php b/src/Listeners/CreateMediaFromUploadcare.php index c609335..e7ce9ed 100644 --- a/src/Listeners/CreateMediaFromUploadcare.php +++ b/src/Listeners/CreateMediaFromUploadcare.php @@ -138,7 +138,7 @@ private function addTenantToMediaData(array $mediaData): array } $tenantRelationship = config('backstage.media.tenant_relationship', 'site'); - $tenantField = $tenantRelationship . '_ulid'; + $tenantField = $tenantRelationship.'_ulid'; $tenantUlid = $tenant->ulid ?? (method_exists($tenant, 'getKey') ? $tenant->getKey() : ($tenant->id ?? null)); if ($tenantUlid) { @@ -160,7 +160,7 @@ private function addTenantToSearchCriteria(array $searchCriteria): array } $tenantRelationship = config('backstage.media.tenant_relationship', 'site'); - $tenantField = $tenantRelationship . '_ulid'; + $tenantField = $tenantRelationship.'_ulid'; $tenantUlid = $tenant->ulid ?? (method_exists($tenant, 'getKey') ? $tenant->getKey() : ($tenant->id ?? null)); if ($tenantUlid) { diff --git a/src/Livewire/MediaGridPicker.php b/src/Livewire/MediaGridPicker.php index ea70a7a..2543257 100644 --- a/src/Livewire/MediaGridPicker.php +++ b/src/Livewire/MediaGridPicker.php @@ -46,7 +46,7 @@ public function mediaItems(): LengthAwarePaginator // Apply search filter if (! empty($this->search)) { - $query->where('original_filename', 'like', '%' . $this->search . '%'); + $query->where('original_filename', 'like', '%'.$this->search.'%'); } // Apply accepted file types filter at query level @@ -56,7 +56,7 @@ public function mediaItems(): LengthAwarePaginator // Handle wildcard patterns like "image/*" if (str_ends_with($acceptedType, '/*')) { $baseType = substr($acceptedType, 0, -2); - $q->orWhere('mime_type', 'like', $baseType . '/%'); + $q->orWhere('mime_type', 'like', $baseType.'/%'); } // Handle exact matches else { @@ -147,7 +147,7 @@ private function matchesAcceptedFileTypes(?string $mimeType): bool // Handle wildcard patterns like "image/*" if (str_ends_with($acceptedType, '/*')) { $baseType = substr($acceptedType, 0, -2); - if (str_starts_with($mimeType, $baseType . '/')) { + if (str_starts_with($mimeType, $baseType.'/')) { return true; } } diff --git a/src/Uploadcare.php b/src/Uploadcare.php index 9037d37..ebd0a88 100755 --- a/src/Uploadcare.php +++ b/src/Uploadcare.php @@ -227,7 +227,7 @@ public static function make(string $name, Field $field): Input if ($uuid || $cdnUrl) { $fileData = [ 'uuid' => $uuid ?? self::extractUuidFromString($cdnUrl ?? ''), - 'cdnUrl' => $cdnUrl ?? ($uuid ? 'https://ucarecdn.com/' . $uuid . '/' : null), + 'cdnUrl' => $cdnUrl ?? ($uuid ? 'https://ucarecdn.com/'.$uuid.'/' : null), 'original_filename' => $filename, 'name' => $filename, ]; @@ -604,7 +604,7 @@ private static function extractMediaUrls(array $mediaUlids, bool $withMetadata = // Fallback for older records: construct a default Uploadcare URL if we only have a UUID. if (! $cdnUrl && $uuid) { - $cdnUrl = 'https://ucarecdn.com/' . $uuid . '/'; + $cdnUrl = 'https://ucarecdn.com/'.$uuid.'/'; } if (! $cdnUrl || ! filter_var($cdnUrl, FILTER_VALIDATE_URL)) { @@ -905,7 +905,7 @@ private static function resolveCdnUrl(mixed $uuid): ?string $fileUuid = $metadata['uuid'] ?? ($metadata['fileInfo']['uuid'] ?? null) ?? self::extractUuidFromString((string) ($media->filename ?? '')); if (! $cdnUrl && $fileUuid) { - $cdnUrl = 'https://ucarecdn.com/' . $fileUuid . '/'; + $cdnUrl = 'https://ucarecdn.com/'.$fileUuid.'/'; } return is_string($cdnUrl) && filter_var($cdnUrl, FILTER_VALIDATE_URL) ? $cdnUrl : null; @@ -918,14 +918,14 @@ private static function resolveCdnUrl(mixed $uuid): ?string $mediaModel = self::getMediaModel(); $media = $mediaModel::where('filename', $uuid) - ->orWhere('metadata->cdnUrl', 'like', '%' . $uuid . '%') + ->orWhere('metadata->cdnUrl', 'like', '%'.$uuid.'%') ->first(); if ($media && isset($media->metadata['cdnUrl'])) { return $media->metadata['cdnUrl']; } - return 'https://ucarecdn.com/' . $uuid . '/'; + return 'https://ucarecdn.com/'.$uuid.'/'; } private static function isValidCdnUrl(string $url): bool @@ -1131,7 +1131,7 @@ public function hydrate(mixed $value, ?Model $model = null): mixed return $value; } - public static function mapMediaToValue(mixed $media): array | string + public static function mapMediaToValue(mixed $media): array|string { if (! $media instanceof Model && ! is_array($media)) { return is_string($media) ? $media : []; @@ -1190,7 +1190,7 @@ public static function mapMediaToValue(mixed $media): array | string } // Ensure cdnUrl includes modifiers - $data['cdnUrl'] = rtrim($data['cdnUrl'], '/') . '/' . $modifiers; + $data['cdnUrl'] = rtrim($data['cdnUrl'], '/').'/'.$modifiers; if (! str_ends_with($data['cdnUrl'], '/')) { $data['cdnUrl'] .= '/'; } @@ -1218,7 +1218,7 @@ private static function hydrateFromModel(?Model $model, mixed $value = null, boo if (! empty($ulids)) { $mediaQuery->whereIn('media_ulid', $ulids) - ->orderByRaw('FIELD(media_ulid, ' . implode(',', array_fill(0, count($ulids), '?')) . ')', $ulids); + ->orderByRaw('FIELD(media_ulid, '.implode(',', array_fill(0, count($ulids), '?')).')', $ulids); } $media = $mediaQuery->get()->unique('ulid'); diff --git a/src/UploadcareFieldServiceProvider.php b/src/UploadcareFieldServiceProvider.php index 3b99da1..a2d64cf 100644 --- a/src/UploadcareFieldServiceProvider.php +++ b/src/UploadcareFieldServiceProvider.php @@ -33,7 +33,7 @@ public function configurePackage(Package $package): void public function packageBooted(): void { FilamentAsset::register([ - Css::make('uploadcare-field', __DIR__ . '/../resources/dist/uploadcare-field.css'), + Css::make('uploadcare-field', __DIR__.'/../resources/dist/uploadcare-field.css'), ], 'backstage/uploadcare-field'); Event::listen( @@ -48,7 +48,7 @@ public function packageBooted(): void public function bootingPackage(): void { - $this->loadViewsFrom(__DIR__ . '/../resources/views', 'backstage-uploadcare-field'); + $this->loadViewsFrom(__DIR__.'/../resources/views', 'backstage-uploadcare-field'); // Register Media src resolver Media::resolveSrcUsing(function ($media) { diff --git a/tests/TestCase.php b/tests/TestCase.php index d5098fc..b4007f8 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -13,7 +13,7 @@ protected function setUp(): void parent::setUp(); Factory::guessFactoryNamesUsing( - fn (string $modelName) => 'Backstage\\UploadcareField\\Database\\Factories\\' . class_basename($modelName) . 'Factory' + fn (string $modelName) => 'Backstage\\UploadcareField\\Database\\Factories\\'.class_basename($modelName).'Factory' ); }