From b2eab60342c15c4e14e9ffe38ea49b161fed2822 Mon Sep 17 00:00:00 2001 From: Bas van Dinther Date: Fri, 12 Dec 2025 12:49:39 +0100 Subject: [PATCH 01/43] 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 --- composer.json | 3 +- config/backstage/media.php | 73 + ...23430_create_media_relationships_table.php | 6 +- ...044_create_translated_attributes_table.php | 35 + ...6_120623_add_alt_column_to_media_table.php | 24 + packages/core/config/backstage/cms.php | 2 +- ...23430_create_media_relationships_table.php | 6 +- ...044_create_translated_attributes_table.php | 39 + ...6_120623_add_alt_column_to_media_table.php | 24 + ...ke_media_relationships_model_id_string.php | 25 + ...000_update_media_relationships_indexes.php | 63 + ...2_10_080001_update_translatable_column.php | 21 + .../core/src/BackstageServiceProvider.php | 94 +- packages/core/src/Models/Content.php | 13 +- .../core/src/Models/ContentFieldValue.php | 135 +- packages/core/src/Models/Media.php | 45 +- packages/fields/README.md | 27 + .../fields/src/Contracts/HydratesValues.php | 11 + .../package-lock.json | 624 +++++ .../filament-uploadcare-field/package.json | 4 +- .../resources/css/index.css | 4 + .../dist/filament-uploadcare-field.css | 3 +- .../dist/filament-uploadcare-field.js | 2 +- .../resources/js/components/uploadcare.js | 223 +- .../forms/components/uploadcare.blade.php | 8 +- .../src/Forms/Components/Uploadcare.php | 37 + .../src/UploadcareServiceProvider.php | 129 +- .../tailwind.config.js | 11 + .../Actions/PushTranslatedAttribute.php | 2 - packages/media/.github/workflows/phpstan.yml | 2 +- .../media/.github/workflows/run-tests.yml | 3 +- packages/media/README.md | 43 +- packages/media/composer.json | 11 +- packages/media/config/backstage/media.php | 2 +- .../add_alt_column_to_media_table.php.stub | 20 + .../create_media_relationships_table.php.stub | 6 +- ...dia_relationships_model_id_string.php.stub | 25 + packages/media/phpstan.neon.dist | 4 +- packages/media/src/Concerns/HasMedia.php | 3 + packages/media/src/Events/MediaUploading.php | 16 + packages/media/src/Media.php | 10 +- packages/media/src/MediaPlugin.php | 2 +- packages/media/src/MediaServiceProvider.php | 4 +- packages/media/src/Models/Media.php | 49 +- packages/media/src/Pages/Media/Library.php | 4 +- .../media/src/Resources/MediaResource.php | 650 +++++- .../Resources/MediaResource/CreateMedia.php | 21 +- .../src/Resources/MediaResource/EditMedia.php | 2 +- .../src/Resources/MediaResource/ListMedia.php | 207 +- packages/media/src/Support/FileIcons.php | 143 ++ packages/media/tests/TestCase.php | 2 + packages/uploadcare-field/README.md | 51 + ...1_normalize_uploadcare_values_to_ulids.php | 160 ++ packages/uploadcare-field/package-lock.json | 2013 +++++++++++++++++ packages/uploadcare-field/package.json | 19 + .../uploadcare-field/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 + .../Listeners/CreateMediaFromUploadcare.php | 172 ++ .../src/Livewire/MediaGridPicker.php | 175 ++ .../Observers/ContentFieldValueObserver.php | 244 ++ packages/uploadcare-field/src/Uploadcare.php | 295 ++- .../src/UploadcareFieldServiceProvider.php | 43 +- packages/uploadcare-field/tailwind.config.js | 11 + .../tests/MediaGridPickerTest.php | 80 + tests/TestCase.php | 5 + 68 files changed, 6285 insertions(+), 175 deletions(-) create mode 100644 config/backstage/media.php create mode 100644 database/migrations/2025_11_06_120044_create_translated_attributes_table.php create mode 100644 database/migrations/2025_11_06_120623_add_alt_column_to_media_table.php create mode 100644 packages/core/database/migrations/2025_11_06_120044_create_translated_attributes_table.php create mode 100644 packages/core/database/migrations/2025_11_06_120623_add_alt_column_to_media_table.php create mode 100644 packages/core/database/migrations/2025_12_08_000000_make_media_relationships_model_id_string.php create mode 100644 packages/core/database/migrations/2025_12_10_080000_update_media_relationships_indexes.php create mode 100644 packages/core/database/migrations/2025_12_10_080001_update_translatable_column.php create mode 100644 packages/fields/src/Contracts/HydratesValues.php create mode 100644 packages/filament-uploadcare-field/tailwind.config.js create mode 100644 packages/media/database/migrations/add_alt_column_to_media_table.php.stub create mode 100644 packages/media/database/migrations/make_media_relationships_model_id_string.php.stub create mode 100644 packages/media/src/Events/MediaUploading.php create mode 100644 packages/media/src/Support/FileIcons.php create mode 100644 packages/uploadcare-field/database/migrations/2025_12_08_163311_normalize_uploadcare_values_to_ulids.php create mode 100644 packages/uploadcare-field/package-lock.json create mode 100644 packages/uploadcare-field/package.json create mode 100644 packages/uploadcare-field/resources/css/index.css create mode 100644 packages/uploadcare-field/resources/dist/uploadcare-field.css create mode 100644 packages/uploadcare-field/resources/views/forms/components/media-grid-picker.blade.php create mode 100644 packages/uploadcare-field/resources/views/livewire/media-grid-picker.blade.php create mode 100644 packages/uploadcare-field/src/Forms/Components/MediaGridPicker.php create mode 100644 packages/uploadcare-field/src/Listeners/CreateMediaFromUploadcare.php create mode 100644 packages/uploadcare-field/src/Livewire/MediaGridPicker.php create mode 100644 packages/uploadcare-field/src/Observers/ContentFieldValueObserver.php create mode 100644 packages/uploadcare-field/tailwind.config.js create mode 100644 packages/uploadcare-field/tests/MediaGridPickerTest.php diff --git a/composer.json b/composer.json index 4d840df5..44f11f74 100644 --- a/composer.json +++ b/composer.json @@ -46,6 +46,7 @@ "codewithdennis/filament-select-tree": "^4.0", "filament/filament": "^4.0", "nette/php-generator": "^4.1", + "phiki/phiki": "^2.0", "saade/filament-adjacency-list": "^4.0", "spatie/laravel-package-tools": "^1.18", "spatie/once": "^3.1", @@ -124,4 +125,4 @@ ], "minimum-stability": "dev", "prefer-stable": true -} \ No newline at end of file +} diff --git a/config/backstage/media.php b/config/backstage/media.php new file mode 100644 index 00000000..13749395 --- /dev/null +++ b/config/backstage/media.php @@ -0,0 +1,73 @@ + [ + 'image/jpeg', + 'image/png', + 'image/webp', + 'image/svg+xml', + 'video/mp4', + 'video/webm', + 'audio/mpeg', + 'audio/ogg', + 'application/pdf', + ], + + 'directory' => 'media', + + 'disk' => config('filament.filesystem_disk', 'public'), + + 'should_preserve_filenames' => false, + + 'should_register_navigation' => true, + + 'visibility' => 'public', + + /* + |-------------------------------------------------------------------------- + | Tenancy + |-------------------------------------------------------------------------- + | + */ + 'is_tenant_aware' => true, + 'tenant_ownership_relationship_name' => 'site', + 'tenant_relationship' => 'site', + 'tenant_model' => Site::class, + + /* + |-------------------------------------------------------------------------- + | Model and resource + |-------------------------------------------------------------------------- + | + */ + 'model' => \Backstage\Media\Models\Media::class, + + 'user_model' => User::class, + + 'resources' => [ + 'label' => 'Media', + 'plural_label' => 'Media', + 'navigation_group' => null, + 'navigation_label' => 'Media', + 'navigation_icon' => 'heroicon-o-photo', + 'navigation_sort' => null, + 'navigation_count_badge' => false, + 'resource' => \Backstage\Media\Resources\MediaResource::class, + ], + + 'file_upload' => [ + 'models' => [ + Content::class, + ], + ], +]; diff --git a/database/migrations/2025_02_15_123430_create_media_relationships_table.php b/database/migrations/2025_02_15_123430_create_media_relationships_table.php index e7194908..f537b980 100644 --- a/database/migrations/2025_02_15_123430_create_media_relationships_table.php +++ b/database/migrations/2025_02_15_123430_create_media_relationships_table.php @@ -21,8 +21,10 @@ public function up(): void ->on(app(config('backstage.media.model', \Backstage\Media\Models\Media::class))->getTable()) ->cascadeOnDelete(); - // Polymorphic model relationship - $table->morphs('model'); + // Polymorphic model relationship (String ID support) + $table->string('model_type'); + $table->string('model_id', 36); + $table->index(['model_type', 'model_id']); // Optional position for each relationship $table->unsignedInteger('position')->nullable(); diff --git a/database/migrations/2025_11_06_120044_create_translated_attributes_table.php b/database/migrations/2025_11_06_120044_create_translated_attributes_table.php new file mode 100644 index 00000000..c20965a9 --- /dev/null +++ b/database/migrations/2025_11_06_120044_create_translated_attributes_table.php @@ -0,0 +1,35 @@ +id(); + $table->string('code', 5); + + $table->foreign('code') + ->references('code') + ->on('languages') + ->onDelete('cascade'); + + $table->ulidMorphs('translatable'); + + $table->longText('attribute'); + $table->longText('translated_attribute')->nullable(); + $table->timestamp('translated_at')->nullable(); + + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down() + { + Schema::dropIfExists('translated_attributes'); + } +}; diff --git a/database/migrations/2025_11_06_120623_add_alt_column_to_media_table.php b/database/migrations/2025_11_06_120623_add_alt_column_to_media_table.php new file mode 100644 index 00000000..8e943428 --- /dev/null +++ b/database/migrations/2025_11_06_120623_add_alt_column_to_media_table.php @@ -0,0 +1,24 @@ +getTable(), function (Blueprint $table) { + $table->text('alt')->nullable()->after('height'); + }); + } + + public function down(): void + { + $model = config('backstage.media.model'); + Schema::table((new $model)->getTable(), function (Blueprint $table) { + $table->dropColumn('alt'); + }); + } +}; diff --git a/packages/core/config/backstage/cms.php b/packages/core/config/backstage/cms.php index 4b1001c9..81e2f533 100644 --- a/packages/core/config/backstage/cms.php +++ b/packages/core/config/backstage/cms.php @@ -22,7 +22,7 @@ Backstage\Resources\SettingResource::class, Backstage\Resources\SiteResource::class, Backstage\Resources\TagResource::class, - Backstage\Media\Resources\MediaResource::class, + Backstage\Resources\MediaResource::class, // Backstage\Resources\TemplateResource::class, Backstage\Resources\TypeResource::class, Backstage\Resources\UserResource::class, diff --git a/packages/core/database/migrations/2025_02_15_123430_create_media_relationships_table.php b/packages/core/database/migrations/2025_02_15_123430_create_media_relationships_table.php index e7194908..f537b980 100644 --- a/packages/core/database/migrations/2025_02_15_123430_create_media_relationships_table.php +++ b/packages/core/database/migrations/2025_02_15_123430_create_media_relationships_table.php @@ -21,8 +21,10 @@ public function up(): void ->on(app(config('backstage.media.model', \Backstage\Media\Models\Media::class))->getTable()) ->cascadeOnDelete(); - // Polymorphic model relationship - $table->morphs('model'); + // Polymorphic model relationship (String ID support) + $table->string('model_type'); + $table->string('model_id', 36); + $table->index(['model_type', 'model_id']); // Optional position for each relationship $table->unsignedInteger('position')->nullable(); diff --git a/packages/core/database/migrations/2025_11_06_120044_create_translated_attributes_table.php b/packages/core/database/migrations/2025_11_06_120044_create_translated_attributes_table.php new file mode 100644 index 00000000..8b112883 --- /dev/null +++ b/packages/core/database/migrations/2025_11_06_120044_create_translated_attributes_table.php @@ -0,0 +1,39 @@ +id(); + $table->string('code', 5); + + $table->foreign('code') + ->references('code') + ->on('languages') + ->onDelete('cascade'); + + $table->ulidMorphs('translatable'); + + $table->longText('attribute'); + $table->longText('translated_attribute')->nullable(); + $table->timestamp('translated_at')->nullable(); + + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down() + { + Schema::dropIfExists('translated_attributes'); + } +}; diff --git a/packages/core/database/migrations/2025_11_06_120623_add_alt_column_to_media_table.php b/packages/core/database/migrations/2025_11_06_120623_add_alt_column_to_media_table.php new file mode 100644 index 00000000..8e943428 --- /dev/null +++ b/packages/core/database/migrations/2025_11_06_120623_add_alt_column_to_media_table.php @@ -0,0 +1,24 @@ +getTable(), function (Blueprint $table) { + $table->text('alt')->nullable()->after('height'); + }); + } + + public function down(): void + { + $model = config('backstage.media.model'); + Schema::table((new $model)->getTable(), function (Blueprint $table) { + $table->dropColumn('alt'); + }); + } +}; diff --git a/packages/core/database/migrations/2025_12_08_000000_make_media_relationships_model_id_string.php b/packages/core/database/migrations/2025_12_08_000000_make_media_relationships_model_id_string.php new file mode 100644 index 00000000..5173344e --- /dev/null +++ b/packages/core/database/migrations/2025_12_08_000000_make_media_relationships_model_id_string.php @@ -0,0 +1,25 @@ +string('model_id', 36)->change(); + $table->string('model_type')->change(); + }); + } + + public function down(): void + { + Schema::table('media_relationships', function (Blueprint $table) { + // Revert to typical big integer if needed (unsafe if data exists) + // $table->unsignedBigInteger('model_id')->change(); + }); + } +}; diff --git a/packages/core/database/migrations/2025_12_10_080000_update_media_relationships_indexes.php b/packages/core/database/migrations/2025_12_10_080000_update_media_relationships_indexes.php new file mode 100644 index 00000000..f94f0cf0 --- /dev/null +++ b/packages/core/database/migrations/2025_12_10_080000_update_media_relationships_indexes.php @@ -0,0 +1,63 @@ +dropForeign(['media_ulid']); + }); + } catch (\Illuminate\Database\QueryException $e) { + // Ignore if foreign key does not exist + } + + // 2. Try to drop unique index (might fail if already dropped) + try { + Schema::table('media_relationships', function (Blueprint $table) { + $table->dropUnique(['media_ulid', 'model_type', 'model_id']); + }); + } catch (\Illuminate\Database\QueryException $e) { + // Ignore if index does not exist + } + + // 3. Try to add new index (might fail if already exists) + try { + Schema::table('media_relationships', function (Blueprint $table) { + $table->index(['model_type', 'model_id', 'position']); + }); + } catch (\Illuminate\Database\QueryException $e) { + // Ignore if index already exists + } + + // 4. Re-add foreign key (using safe 'foreign' method) + Schema::table('media_relationships', function (Blueprint $table) { + // We use a separate call here to ensure we don't catch unexpected errors in the definition + // But we might want to check if it exists? + // Generically adding a FK usually fails if it exists with same name. + // Since we know we tried to drop it in step 1, this should be safe unless step 1 failed unrelatedly. + // However, to be extra safe against "Constraint already exists": + try { + $table->foreign('media_ulid') + ->references('ulid') + ->on(app(config('backstage.media.model', \Backstage\Media\Models\Media::class))->getTable()) + ->cascadeOnDelete(); + } catch (\Illuminate\Database\QueryException $e) { + // assume it exists if it fails + } + }); + } + + public function down(): void + { + Schema::table('media_relationships', function (Blueprint $table) { + $table->dropIndex(['model_type', 'model_id', 'position']); + $table->unique(['media_ulid', 'model_type', 'model_id']); + }); + } +}; diff --git a/packages/core/database/migrations/2025_12_10_080001_update_translatable_column.php b/packages/core/database/migrations/2025_12_10_080001_update_translatable_column.php new file mode 100644 index 00000000..3638e863 --- /dev/null +++ b/packages/core/database/migrations/2025_12_10_080001_update_translatable_column.php @@ -0,0 +1,21 @@ +string('translatable_id', 36)->change(); + $table->string('translatable_type', 36)->change(); + }); + } + + public function down() + { + Schema::dropIfExists('translated_attributes'); + } +}; diff --git a/packages/core/src/BackstageServiceProvider.php b/packages/core/src/BackstageServiceProvider.php index a0f46d5b..3f9c69b9 100644 --- a/packages/core/src/BackstageServiceProvider.php +++ b/packages/core/src/BackstageServiceProvider.php @@ -9,7 +9,6 @@ use Backstage\Events\FormSubmitted; use Backstage\Http\Middleware\SetLocale; use Backstage\Listeners\ExecuteFormActions; -use Backstage\Media\Resources\MediaResource; use Backstage\Models\Block; use Backstage\Models\Media; use Backstage\Models\Menu; @@ -88,6 +87,8 @@ public function configurePackage(Package $package): void $this->writeMediaPickerConfig(); + $this->writeTranslationsConfig(); + $command->callSilently('vendor:publish', [ '--tag' => 'backstage-migrations', '--force' => true, @@ -195,6 +196,7 @@ public function packageBooted(): void 'site' => 'Backstage\Models\Site', 'tag' => 'Backstage\Models\Tag', 'type' => 'Backstage\Models\Type', + 'content_field_value' => 'Backstage\Models\ContentFieldValue', 'user' => ltrim(config('auth.providers.users.model', 'Backstage\Models\User'), '\\'), ]); @@ -313,11 +315,11 @@ private function generateMediaPickerConfig(): array 'navigation_icon' => 'heroicon-o-photo', 'navigation_sort' => null, 'navigation_count_badge' => false, - 'resource' => MediaResource::class, + 'resource' => \Backstage\Media\Resources\MediaResource::class, ], ]; - config(['media-picker' => $config]); + config(['backstage.media' => $config]); return $config; } @@ -378,7 +380,7 @@ private function generateFilamentFieldsConfig(): array ], ]; - config(['fields' => $config]); + config(['backstage.fields' => $config]); return $config; } @@ -406,6 +408,90 @@ private function writeMediaPickerConfig(?string $path = null): void file_put_contents($path, $configContent); } + private function generateTranslationsConfig(): array + { + $config = [ + 'scan' => [ + 'paths' => [ + app_path(), + resource_path('views'), + base_path(''), + ], + + 'extensions' => [ + '*.php', + '*.blade.php', + '*.json', + ], + + 'functions' => [ + 'trans', + 'trans_choice', + 'Lang::transChoice', + 'Lang::trans', + 'Lang::get', + 'Lang::choice', + '@lang', + '@choice', + '__', + ], + ], + + 'eloquent' => [ + 'translatable-models' => [ + \Backstage\Models\ContentFieldValue::class, + \Backstage\Models\Tag::class, + ], + ], + + 'translators' => [ + 'default' => env('TRANSLATION_DRIVER', 'google-translate'), + + 'drivers' => [ + 'google-translate' => [ + // no options + ], + + 'ai' => [ + 'provider' => \Prism\Prism\Enums\Provider::OpenAI, + 'model' => 'gpt-5', + 'system_prompt' => 'You are an expert mathematician who explains concepts simply. The only thing you do it output what i ask. No comments, no extra information. Just the answer.', + ], + + 'deep-l' => [ + // + ], + ], + ], + ]; + + config(['translations' => $config]); + + return $config; + } + + private function writeTranslationsConfig(?string $path = null): void + { + $path ??= config_path('translations.php'); + + // Ensure directory exists + $directory = dirname($path); + if (! is_dir($directory)) { + mkdir($directory, 0755, true); + } + + // Generate the config file content + $configContent = "customVarExport($this->generateTranslationsConfig()) . ";\n"; + + file_put_contents($path, $configContent); + } + private function customVarExport($var, $indent = ''): string { switch (gettype($var)) { diff --git a/packages/core/src/Models/Content.php b/packages/core/src/Models/Content.php index 64830615..4f6460c8 100644 --- a/packages/core/src/Models/Content.php +++ b/packages/core/src/Models/Content.php @@ -192,8 +192,12 @@ protected function url(): Attribute ); } + $this->load('site'); + $url = rtrim($this->pathPrefix . $this->path, '/'); + $this->load('site'); + if ($this->site->trailing_slash) { $url .= '/'; } @@ -308,10 +312,9 @@ public function scopeExpired($query): void public function blocks(string $field): array { - return json_decode( - json: $this->values->where('field.slug', $field)->first()?->value, - associative: true - ) ?? []; + $value = $this->values->where('field.slug', $field)->first()?->value(); + + return is_array($value) ? $value : []; } /** @@ -336,6 +339,8 @@ public function blocks(string $field): array */ public function field(string $slug): Content | HtmlString | Collection | array | bool | null { + $this->load('values'); + return $this->values->where('field.slug', $slug)->first()?->value(); } diff --git a/packages/core/src/Models/ContentFieldValue.php b/packages/core/src/Models/ContentFieldValue.php index 28785f2e..d0ac4cf2 100644 --- a/packages/core/src/Models/ContentFieldValue.php +++ b/packages/core/src/Models/ContentFieldValue.php @@ -45,38 +45,39 @@ public function field(): BelongsTo return $this->belongsTo(Field::class); } - public function value(): Content | HtmlString | array | Collection | bool | null + public function media(): \Illuminate\Database\Eloquent\Relations\MorphToMany { - if ($this->field->hasRelation()) { - return static::getContentRelation($this->value); - } + return $this->morphToMany(config('backstage.media.model', \Backstage\Media\Models\Media::class), 'model', 'media_relationships', 'model_id', 'media_ulid'); + } + public function value(): Content | HtmlString | array | Collection | bool | null + { if ($this->isRichEditor()) { return new HtmlString(self::getRichEditorHtml($this->value ?? '')); } + [$hydrated, $result] = $this->tryHydrateViaClass($this->isJsonArray(), $this->field->field_type); + if ($hydrated) { + return $result; + } + + if ($this->field->hasRelation()) { + return static::getContentRelation($this->value); + } + if ($this->isCheckbox()) { return $this->value == '1'; } if ($decoded = $this->isJsonArray()) { - // For repeater and builder fields, use recursive decoding if (in_array($this->field->field_type, ['repeater', 'builder'])) { - $decoded = $this->decodeAllJsonStrings($decoded); - - if ($this->field->field_type === 'repeater') { - $decoded = $this->hydrateRepeaterRelations($decoded); - } - - return $decoded; - } else { - return $decoded; + return $this->hydrateValuesRecursively($decoded, $this->field); } + return $decoded; } // For all other cases, ensure the value is returned as a string - // This prevents automatic type casting of numeric values return new HtmlString($this->value ?? ''); } @@ -85,17 +86,117 @@ public function value(): Content | HtmlString | array | Collection | bool | null */ public static function getContentRelation(mixed $value): Content | Collection | null { - if (! json_validate($value)) { + if (is_array($value)) { + $ulids = $value; + } elseif (is_string($value) && json_validate($value)) { + $ulids = json_decode($value, true); + } else { return Content::where('ulid', $value)->first(); } - $ulids = json_decode($value); + if (empty($ulids)) { + return new Collection; + } return Content::whereIn('ulid', $ulids) ->orderByRaw('FIELD(ulid, ' . implode(',', array_fill(0, count($ulids), '?')) . ')', $ulids) ->get(); } + private function hydrateValuesRecursively(mixed $value, Field $field): mixed + { + [$hydrated, $result] = $this->tryHydrateViaClass($value, $field->field_type); + if ($hydrated) { + return $result; + } + + if ($field->hasRelation()) { + return static::getContentRelation($value); + } + + if (is_array($value) && in_array($field->field_type, ['repeater', 'builder'])) { + if ($field->field_type === 'repeater') { + if (! $field->relationLoaded('children')) { + $field->load('children'); + } + + if ($field->children->isEmpty()) { + return $value; + } + + foreach ($value as $index => &$item) { + if (! is_array($item)) { + continue; + } + $this->hydrateItemFields($item, $field->children); + } + unset($item); + } elseif ($field->field_type === 'builder') { + static $blockCache = []; + + foreach ($value as $index => &$item) { + if (! isset($item['type'], $item['data']) || ! is_array($item['data'])) { + continue; + } + + $blockSlug = $item['type']; + + if (! isset($blockCache[$blockSlug])) { + $blockCache[$blockSlug] = \Backstage\Models\Block::where('slug', $blockSlug) + ->with('fields') + ->first(); + } + + $block = $blockCache[$blockSlug]; + + if (! $block || $block->fields->isEmpty()) { + continue; + } + + $this->hydrateItemFields($item['data'], $block->fields); + } + unset($item); + } + } + + return $value; + } + + private function tryHydrateViaClass(mixed $value, string $fieldType): array + { + if ($fieldClass = \Backstage\Fields\Facades\Fields::resolveField($fieldType)) { + if (in_array(\Backstage\Fields\Contracts\HydratesValues::class, class_implements($fieldClass))) { + try { + return [true, app($fieldClass)->hydrate($value)]; + } catch (\Throwable $e) { + return [true, $value]; + } + } + } + + return [false, null]; + } + + private function hydrateItemFields(array &$data, $fields): void + { + foreach ($fields as $child) { + $key = null; + if (array_key_exists($child->ulid, $data)) { + $key = $child->ulid; + } elseif (array_key_exists($child->slug, $data)) { + $key = $child->slug; + } + + if ($key) { + if ($child->field_type === 'rich-editor') { + $data[$key] = new HtmlString(self::getRichEditorHtml($data[$key] ?? '')); + } else { + $data[$key] = $this->hydrateValuesRecursively($data[$key], $child); + } + } + } + } + private function isRichEditor(): bool { return $this->field->field_type === 'rich-editor'; diff --git a/packages/core/src/Models/Media.php b/packages/core/src/Models/Media.php index d537ea1c..6c76e49e 100644 --- a/packages/core/src/Models/Media.php +++ b/packages/core/src/Models/Media.php @@ -2,29 +2,50 @@ namespace Backstage\Models; +use Backstage\Media\Models\Media as BaseMedia; use Backstage\Shared\HasPackageFactory; -use Illuminate\Database\Eloquent\Concerns\HasUlids; -use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; -class Media extends Model +class Media extends BaseMedia { use HasPackageFactory; - use HasUlids; - protected $primaryKey = 'ulid'; - - protected $table = 'media'; + public function site(): BelongsTo + { + return $this->belongsTo(Site::class); + } - protected $guarded = []; + /** + * Discuss how we can optimize this relation (edits) + */ + public function edits(): \Illuminate\Database\Eloquent\Relations\MorphToMany + { + return $this->morphedByMany( + ContentFieldValue::class, + 'model', + 'media_relationships', + 'media_ulid', + 'model_id' + ) + ->withPivot(['meta', 'position']) + ->withTimestamps(); + } - protected function casts(): array + public function getMimeTypeAttribute(): ?string { - return []; + return $this->attributes['mime_type'] ?? null; } - public function site(): BelongsTo + public function getEditAttribute(): ?array { - return $this->belongsTo(Site::class); + $edit = $this->edits()->first(); + + if (! $edit || ! $edit->pivot || ! $edit->pivot->meta) { + return null; + } + + return is_string($edit->pivot->meta) + ? json_decode($edit->pivot->meta, true) + : $edit->pivot->meta; } } diff --git a/packages/fields/README.md b/packages/fields/README.md index c06a46cb..2e5bbc2c 100644 --- a/packages/fields/README.md +++ b/packages/fields/README.md @@ -478,6 +478,33 @@ To register your own fields, you can add them to the `fields.fields` config arra 'custom_fields' => [ App\Fields\CustomField::class, ], + +### Value Hydration + +The `hydrate` method allows you to transform the raw value stored in the database into a runtime representation. This is useful when you want to convert stored IDs into models, format dates, or process JSON data into specific objects. + +To use this feature, your field class must implement the `Backstage\Fields\Contracts\HydratesValues` interface. + +```php +use Backstage\Fields\Fields\Base; +use Backstage\Fields\Contracts\HydratesValues; +use Illuminate\Database\Eloquent\Model; + +class MyCustomField extends Base implements HydratesValues +{ + /** + * Hydrate the raw field value into its runtime representation. + */ + public function hydrate(mixed $value, ?Model $model = null): mixed + { + // Transform the raw value + // For example, convert a stored ID to a model instance + return MyModel::find($value); + } +} +``` + +The `hydrate` method is automatically called when accessing the value of the field. ``` ## Documentation diff --git a/packages/fields/src/Contracts/HydratesValues.php b/packages/fields/src/Contracts/HydratesValues.php new file mode 100644 index 00000000..19340dcf --- /dev/null +++ b/packages/fields/src/Contracts/HydratesValues.php @@ -0,0 +1,11 @@ +=4" } }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/array-buffer-byte-length": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.1.tgz", @@ -1825,6 +1840,19 @@ ], "license": "MIT" }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/bl": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/bl/-/bl-5.1.0.tgz", @@ -1949,6 +1977,31 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, "node_modules/chownr": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", @@ -1987,6 +2040,44 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cliui/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/clone": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", @@ -2191,6 +2282,16 @@ "node": ">=0.4.0" } }, + "node_modules/dependency-graph": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/dependency-graph/-/dependency-graph-1.0.0.tgz", + "integrity": "sha512-cW3gggJ28HZ/LExwxP2B++aiKxhJXMSIt9K48FOXQkm+vuG5gyatXnLsONRJdzO/7VfjDIiaOOa/bs4l464Lwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/detect-libc": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", @@ -2215,6 +2316,13 @@ "node": ">= 0.4" } }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, "node_modules/enhanced-resolve": { "version": "5.18.3", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz", @@ -2408,6 +2516,16 @@ "@esbuild/win32-x64": "0.25.0" } }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/escape-string-regexp": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", @@ -2479,6 +2597,36 @@ "node": ">= 6" } }, + "node_modules/fs-extra": { + "version": "11.3.2", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.2.tgz", + "integrity": "sha512-Xr9F6z6up6Ws+NjzMCZc6WXg2YFRlrLP9NQDO3VQrWrfiojdhS56TzueT88ze0uBdCTwEIhQ3ptnmKeWGFAe0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -2518,6 +2666,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/get-intrinsic": { "version": "1.2.6", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.6.tgz", @@ -2561,6 +2719,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/globalthis": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", @@ -2795,6 +2966,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/is-boolean-object": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.0.tgz", @@ -2901,6 +3085,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-generator-function": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.0.10.tgz", @@ -3182,6 +3376,19 @@ "dev": true, "license": "MIT" }, + "node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, "node_modules/lightningcss": { "version": "1.30.1", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.1.tgz", @@ -3431,6 +3638,19 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, "node_modules/load-json-file": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-4.0.0.tgz", @@ -3694,6 +3914,16 @@ "validate-npm-package-license": "^3.0.1" } }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/npm-run-all": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/npm-run-all/-/npm-run-all-4.1.5.tgz", @@ -3904,6 +4134,16 @@ "node": ">=0.10" } }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/possible-typed-array-names": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz", @@ -3943,6 +4183,102 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postcss-cli": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/postcss-cli/-/postcss-cli-11.0.1.tgz", + "integrity": "sha512-0UnkNPSayHKRe/tc2YGW6XnSqqOA9eqpiRMgRlV1S6HdGi16vwJBx7lviARzbV1HpQHqLLRH3o8vTcB0cLc+5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.3.0", + "dependency-graph": "^1.0.0", + "fs-extra": "^11.0.0", + "picocolors": "^1.0.0", + "postcss-load-config": "^5.0.0", + "postcss-reporter": "^7.0.0", + "pretty-hrtime": "^1.0.3", + "read-cache": "^1.0.0", + "slash": "^5.0.0", + "tinyglobby": "^0.2.12", + "yargs": "^17.0.0" + }, + "bin": { + "postcss": "index.js" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-load-config": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-5.1.0.tgz", + "integrity": "sha512-G5AJ+IX0aD0dygOE0yFZQ/huFFMSNneyfp0e3/bT05a8OfPC5FUoZRPfGijUdGOJNMewJiwzcHJXFafFzeKFVA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1", + "yaml": "^2.4.2" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + } + } + }, + "node_modules/postcss-reporter": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/postcss-reporter/-/postcss-reporter-7.1.0.tgz", + "integrity": "sha512-/eoEylGWyy6/DOiMP5lmFRdmDKThqgn7D6hP2dXKJI/0rJSO1ADFNngZfDzxL0YAxFvws+Rtpuji1YIHj4mySA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "picocolors": "^1.0.0", + "thenby": "^1.3.4" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, "node_modules/postcss-selector-parser": { "version": "6.0.10", "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz", @@ -4050,6 +4386,16 @@ } } }, + "node_modules/pretty-hrtime": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/pretty-hrtime/-/pretty-hrtime-1.0.3.tgz", + "integrity": "sha512-66hKPCr+72mlfiSjlEB1+45IjXSqvVAIy6mocupoww4tBFE9R9IhwwUGoI4G++Tc9Aq+2rxOt0RFU6gPcrte0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", @@ -4057,6 +4403,16 @@ "dev": true, "license": "MIT" }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, "node_modules/read-pkg": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-3.0.0.tgz", @@ -4087,6 +4443,19 @@ "node": ">= 6" } }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.8.tgz", @@ -4129,6 +4498,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve": { "version": "1.22.8", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", @@ -4386,6 +4765,19 @@ "dev": true, "license": "ISC" }, + "node_modules/slash": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz", + "integrity": "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -4458,6 +4850,44 @@ "safe-buffer": "~5.2.0" } }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/string.prototype.padend": { "version": "3.1.6", "resolved": "https://registry.npmjs.org/string.prototype.padend/-/string.prototype.padend-3.1.6.tgz", @@ -4620,6 +5050,61 @@ "node": ">=18" } }, + "node_modules/thenby": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/thenby/-/thenby-1.3.4.tgz", + "integrity": "sha512-89Gi5raiWA3QZ4b2ePcEwswC3me9JIg+ToSgtE0JWeCynLnLxNr/f9G+xfo9K+Oj4AFdom8YNJjibIARTJmapQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -4727,6 +5212,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -4855,6 +5350,93 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/wrap-ansi/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, "node_modules/yallist": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", @@ -4863,6 +5445,48 @@ "engines": { "node": ">=18" } + }, + "node_modules/yaml": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz", + "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + } + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } } } } diff --git a/packages/filament-uploadcare-field/package.json b/packages/filament-uploadcare-field/package.json index d026d717..c4859587 100644 --- a/packages/filament-uploadcare-field/package.json +++ b/packages/filament-uploadcare-field/package.json @@ -4,9 +4,8 @@ "scripts": { "dev:styles": "npx @tailwindcss/cli -i resources/css/index.css -o resources/dist/filament-uploadcare-field.css --watch", "dev:scripts": "node bin/build.js --dev", - "build:styles": "npx @tailwindcss/cli -i resources/css/index.css -o resources/dist/filament-uploadcare-field.css --minify && npm run purge", + "build:styles": "npx @tailwindcss/cli -i resources/css/index.css -o resources/dist/filament-uploadcare-field.css --minify", "build:scripts": "node bin/build.js", - "purge": "filament-purge -i resources/dist/filament-uploadcare-field.css -o resources/dist/filament-uploadcare-field.css -v 3.x", "dev": "npm-run-all --parallel dev:*", "build": "npm-run-all build:*" }, @@ -19,6 +18,7 @@ "esbuild": "^0.25.0", "npm-run-all": "^4.1.5", "postcss": "^8.4.26", + "postcss-cli": "^11.0.0", "prettier": "^3.0.0", "prettier-plugin-tailwindcss": "^0.6.13", "tailwindcss": "^4.1.10" diff --git a/packages/filament-uploadcare-field/resources/css/index.css b/packages/filament-uploadcare-field/resources/css/index.css index 78b1a020..a09174d2 100644 --- a/packages/filament-uploadcare-field/resources/css/index.css +++ b/packages/filament-uploadcare-field/resources/css/index.css @@ -1,3 +1,7 @@ +@import "tailwindcss"; +@config "../../tailwind.config.js"; +@source "../../../uploadcare-field/resources/views"; + .uploadcare-wrapper { all: revert; } diff --git a/packages/filament-uploadcare-field/resources/dist/filament-uploadcare-field.css b/packages/filament-uploadcare-field/resources/dist/filament-uploadcare-field.css index 0f4ba352..9e73acec 100644 --- a/packages/filament-uploadcare-field/resources/dist/filament-uploadcare-field.css +++ b/packages/filament-uploadcare-field/resources/dist/filament-uploadcare-field.css @@ -1 +1,2 @@ -.uploadcare-wrapper{all:revert}body .uploadcare-wrapper.single-source uc-start-from .uc-content,body .uploadcare-wrapper.single-source uc-file-uploader-regular .uc-start-from .uc-content,body .uploadcare-wrapper.single-source uc-file-uploader-inline .uc-start-from .uc-content{gap:calc(var(--uc-padding)*2);width:100%;height:100%;padding:calc(var(--uc-padding)*2);background-color:var(--uc-background);flex-direction:column!important;display:flex!important}body .uploadcare-wrapper:not(.single-source) uc-start-from .uc-content,body .uploadcare-wrapper:not(.single-source) uc-file-uploader-regular .uc-start-from .uc-content,body .uploadcare-wrapper:not(.single-source) uc-file-uploader-inline .uc-start-from .uc-content{gap:calc(var(--uc-padding)*2);width:100%;height:100%;padding:calc(var(--uc-padding)*2);background-color:var(--uc-background);grid-auto-flow:row;display:grid!important}.uploadcare-wrapper :where(uc-file-uploader-regular,uc-file-uploader-minimal,uc-file-uploader-inline,uc-upload-ctx-provider,uc-form-input){isolation:isolate}.uploadcare-wrapper :where(.uc-primary-btn,.uc-file-preview,.uc-dropzone){all:revert;box-sizing:border-box;font-family:inherit}.uploadcare-wrapper{z-index:1;position:relative}.uploadcare-wrapper.single-source uc-source-list{display:none!important}.uc-image_container{height:400px!important}.uc-dark{--uc-background-dark:#242424;--uc-foreground-dark:#e5e5e5;--uc-primary-oklch-dark:69% .1768 258.4;--uc-primary-dark:#a1a1a1;--uc-primary-hover-dark:#111;--uc-primary-transparent-dark:#a1a1a113;--uc-primary-foreground-dark:#fff;--uc-secondary-dark:#e5e5e512;--uc-secondary-hover-dark:#e5e5e51a;--uc-secondary-foreground-dark:#e5e5e5;--uc-muted-dark:#373737;--uc-muted-foreground-dark:#9c9c9c;--uc-destructive-dark:#ef44441a;--uc-destructive-foreground-dark:#ef4444;--uc-border-dark:#404040;--uc-dialog-shadow-dark:0px 6px 20px #00000040;--uc-simple-btn-dark:#373737;--uc-simple-btn-hover-dark:#4b4b4b;--uc-simple-btn-foreground-dark:#fff}.uc-dark .uc-dropzone{color:#e5e5e5!important;border-color:#404040!important}.uc-dark .uc-dropzone .uc-content{color:#e5e5e5!important}.uc-dark .uc-dropzone .uc-icon{color:#a1a1a1!important;fill:#a1a1a1!important}.uc-dark .uc-dropzone .uc-text{color:#9c9c9c!important}.uc-dark .uc-dropzone .uc-button{color:#e5e5e5!important;background-color:#373737!important;border-color:#404040!important}.uc-dark .uc-dropzone .uc-button:hover{color:#fff!important;background-color:#4b4b4b!important}.uc-dark .uc-start-from .uc-content{color:#e5e5e5!important}.uc-dark .uc-start-from .uc-icon{color:#a1a1a1!important;fill:#a1a1a1!important}.uc-dark .uc-start-from .uc-text{color:#9c9c9c!important}.uc-dark uc-file-uploader-regular .uc-dropzone,.uc-dark uc-file-uploader-inline .uc-dropzone{color:#e5e5e5!important;background-color:#242424!important;border:2px dashed #404040!important}.uc-dark uc-file-uploader-regular .uc-dropzone:hover,.uc-dark uc-file-uploader-inline .uc-dropzone:hover{background-color:#2d2d2d!important;border-color:#a1a1a1!important}.uc-dark .uc-inner{background-color:#181818!important;border-color:#404040!important}.uploadcare-wrapper uc-form-input{display:none!important}.uploadcare-wrapper .uc-done-btn,.uploadcare-wrapper button.uc-done-btn,.uploadcare-wrapper .uc-toolbar .uc-done-btn,.uploadcare-wrapper .uc-toolbar button.uc-done-btn,.uc-done-btn,button.uc-done-btn,.uc-toolbar .uc-done-btn,.uc-toolbar button.uc-done-btn{visibility:hidden!important;opacity:0!important;pointer-events:none!important;clip:rect(0,0,0,0)!important;color:#0000!important;background:0 0!important;border:0!important;width:0!important;height:0!important;margin:0!important;padding:0!important;font-size:0!important;line-height:0!important;display:none!important;position:absolute!important;overflow:hidden!important} \ No newline at end of file +/*! 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}:-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}.relative{position:relative}.static{position:static}.top-1{top:calc(var(--spacing)*1)}.right-1{right:calc(var(--spacing)*1)}.z-0{z-index:0}.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}.inline{display:inline}.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}.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,)}.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}.focus-within\:z-10:focus-within{z-index:10}.focus-within\:ring:focus-within{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(1px + 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)}@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\:bg-gray-900:where(.dark,.dark *){background-color:var(--color-gray-900)}.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)}}}.uploadcare-wrapper{all:revert}body .uploadcare-wrapper.single-source uc-start-from .uc-content,body .uploadcare-wrapper.single-source uc-file-uploader-regular .uc-start-from .uc-content,body .uploadcare-wrapper.single-source uc-file-uploader-inline .uc-start-from .uc-content{gap:calc(var(--uc-padding)*2);width:100%;height:100%;padding:calc(var(--uc-padding)*2);background-color:var(--uc-background);flex-direction:column!important;display:flex!important}body .uploadcare-wrapper:not(.single-source) uc-start-from .uc-content,body .uploadcare-wrapper:not(.single-source) uc-file-uploader-regular .uc-start-from .uc-content,body .uploadcare-wrapper:not(.single-source) uc-file-uploader-inline .uc-start-from .uc-content{gap:calc(var(--uc-padding)*2);width:100%;height:100%;padding:calc(var(--uc-padding)*2);background-color:var(--uc-background);grid-auto-flow:row;display:grid!important}.uploadcare-wrapper :where(uc-file-uploader-regular,uc-file-uploader-minimal,uc-file-uploader-inline,uc-upload-ctx-provider,uc-form-input){isolation:isolate}.uploadcare-wrapper :where(.uc-primary-btn,.uc-file-preview,.uc-dropzone){all:revert;box-sizing:border-box;font-family:inherit}.uploadcare-wrapper{z-index:1;position:relative}.uploadcare-wrapper.single-source uc-source-list{display:none!important}.uc-image_container{height:400px!important}.uc-dark{--uc-background-dark:#242424;--uc-foreground-dark:#e5e5e5;--uc-primary-oklch-dark:69% .1768 258.4;--uc-primary-dark:#a1a1a1;--uc-primary-hover-dark:#111;--uc-primary-transparent-dark:#a1a1a113;--uc-primary-foreground-dark:#fff;--uc-secondary-dark:#e5e5e512;--uc-secondary-hover-dark:#e5e5e51a;--uc-secondary-foreground-dark:#e5e5e5;--uc-muted-dark:#373737;--uc-muted-foreground-dark:#9c9c9c;--uc-destructive-dark:#ef44441a;--uc-destructive-foreground-dark:#ef4444;--uc-border-dark:#404040;--uc-dialog-shadow-dark:0px 6px 20px #00000040;--uc-simple-btn-dark:#373737;--uc-simple-btn-hover-dark:#4b4b4b;--uc-simple-btn-foreground-dark:#fff}.uc-dark .uc-dropzone{color:#e5e5e5!important;border-color:#404040!important}.uc-dark .uc-dropzone .uc-content{color:#e5e5e5!important}.uc-dark .uc-dropzone .uc-icon{color:#a1a1a1!important;fill:#a1a1a1!important}.uc-dark .uc-dropzone .uc-text{color:#9c9c9c!important}.uc-dark .uc-dropzone .uc-button{color:#e5e5e5!important;background-color:#373737!important;border-color:#404040!important}.uc-dark .uc-dropzone .uc-button:hover{color:#fff!important;background-color:#4b4b4b!important}.uc-dark .uc-start-from .uc-content{color:#e5e5e5!important}.uc-dark .uc-start-from .uc-icon{color:#a1a1a1!important;fill:#a1a1a1!important}.uc-dark .uc-start-from .uc-text{color:#9c9c9c!important}.uc-dark uc-file-uploader-regular .uc-dropzone,.uc-dark uc-file-uploader-inline .uc-dropzone{color:#e5e5e5!important;background-color:#242424!important;border:2px dashed #404040!important}.uc-dark uc-file-uploader-regular .uc-dropzone:hover,.uc-dark uc-file-uploader-inline .uc-dropzone:hover{background-color:#2d2d2d!important;border-color:#a1a1a1!important}.uc-dark .uc-inner{background-color:#181818!important;border-color:#404040!important}.uploadcare-wrapper uc-form-input{display:none!important}.uploadcare-wrapper .uc-done-btn,.uploadcare-wrapper button.uc-done-btn,.uploadcare-wrapper .uc-toolbar .uc-done-btn,.uploadcare-wrapper .uc-toolbar button.uc-done-btn,.uc-done-btn,button.uc-done-btn,.uc-toolbar .uc-done-btn,.uc-toolbar button.uc-done-btn{visibility:hidden!important;opacity:0!important;pointer-events:none!important;clip:rect(0,0,0,0)!important;color:#0000!important;background:0 0!important;border:0!important;width:0!important;height:0!important;margin:0!important;padding:0!important;font-size:0!important;line-height:0!important;display:none!important;position:absolute!important;overflow:hidden!important}@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/packages/filament-uploadcare-field/resources/dist/filament-uploadcare-field.js b/packages/filament-uploadcare-field/resources/dist/filament-uploadcare-field.js index 63fa01e1..fe1945d6 100644 --- a/packages/filament-uploadcare-field/resources/dist/filament-uploadcare-field.js +++ b/packages/filament-uploadcare-field/resources/dist/filament-uploadcare-field.js @@ -1 +1 @@ -var u=class{constructor(e){this.wrapper=e,this.observer=null,this.init()}init(){this.hideDoneButtons(),this.setupObserver()}setupObserver(){this.observer=new MutationObserver(e=>{e.forEach(t=>{t.type==="childList"&&t.addedNodes.forEach(i=>{if(i.nodeType===Node.ELEMENT_NODE){i.classList&&i.classList.contains("uc-done-btn")&&this.hideDoneButton(i);let s=i.querySelectorAll&&i.querySelectorAll(".uc-done-btn");s&&s.forEach(a=>this.hideDoneButton(a))}})})}),this.wrapper&&this.observer.observe(this.wrapper,{childList:!0,subtree:!0})}hideDoneButtons(){document.querySelectorAll(".uc-done-btn").forEach(t=>this.hideDoneButton(t))}hideDoneButton(e){e&&(e.style.display="none",e.style.visibility="hidden",e.style.opacity="0",e.style.pointerEvents="none",e.style.position="absolute",e.style.width="0",e.style.height="0",e.style.overflow="hidden",e.style.clip="rect(0, 0, 0, 0)",e.style.margin="0",e.style.padding="0",e.style.border="0",e.style.background="transparent",e.style.color="transparent",e.style.fontSize="0",e.style.lineHeight="0")}destroy(){this.observer&&(this.observer.disconnect(),this.observer=null)}};function f(l){return window._initializedUploadcareContexts||(window._initializedUploadcareContexts=new Set),{state:l.state,statePath:l.statePath,initialState:l.initialState,publicKey:l.publicKey,isMultiple:l.isMultiple,multipleMin:l.multipleMin,multipleMax:l.multipleMax,isImagesOnly:l.isImagesOnly,accept:l.accept,sourceList:l.sourceList,uploaderStyle:l.uploaderStyle,isWithMetadata:l.isWithMetadata,uploadedFiles:"",ctx:null,removeEventListeners:null,uniqueContextName:l.uniqueContextName,isInitialized:!1,stateHasBeenInitialized:!1,isStateWatcherActive:!1,isLocalUpdate:!1,doneButtonHider:null,documentClassObserver:null,formInputObserver:null,init(){this.isContextAlreadyInitialized()||(this.markContextAsInitialized(),this.applyTheme(),this.initUploadcare(),this.setupThemeObservers(),this.setupDoneButtonObserver())},isContextAlreadyInitialized(){return window._initializedUploadcareContexts.has(this.uniqueContextName)},markContextAsInitialized(){window._initializedUploadcareContexts.add(this.uniqueContextName)},applyTheme(){let e=this.getCurrentTheme();document.querySelectorAll(`uc-file-uploader-${this.uploaderStyle}`).forEach(i=>{i.classList.remove("uc-dark","uc-light"),i.classList.add(`uc-${e}`)})},getCurrentTheme(){return document.documentElement.classList.contains("dark")?"dark":"light"},setupThemeObservers(){window.addEventListener("storage",this.handleThemeStorageChange.bind(this)),window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change",this.handleSystemThemeChange.bind(this)),this.setupDocumentClassObserver()},handleThemeStorageChange(e){e.key==="theme"&&this.applyTheme()},handleSystemThemeChange(){localStorage.getItem("theme")==="system"&&this.applyTheme()},setupDocumentClassObserver(){this.documentClassObserver=new MutationObserver(e=>{e.forEach(t=>{if(t.type==="attributes"&&t.attributeName==="class"){let i=document.documentElement.classList.contains("dark"),s=t.oldValue&&t.oldValue.includes("dark");i!==s&&this.applyTheme()}})}),this.documentClassObserver.observe(document.documentElement,{attributes:!0,attributeOldValue:!0,attributeFilter:["class"]})},initUploadcare(){this.removeEventListeners&&this.removeEventListeners(),this.initializeUploader()},initializeUploader(e=0,t=10){if(e>=t){console.error("Failed to initialize Uploadcare after maximum retries");return}this.ctx=document.querySelector(`uc-upload-ctx-provider[ctx-name="${this.uniqueContextName}"]`);let i=this.getUploadcareApi();if(!this.isValidContext(i)){setTimeout(()=>this.initializeUploader(e+1,t),100);return}this.markAsInitialized(),this.removeRequiredAttributes(),this.initializeState(i),this.setupEventListeners(i)},getUploadcareApi(){try{return this.ctx?.getAPI()}catch{return null}},isValidContext(e){return this.ctx&&e&&e.addFileFromCdnUrl},markAsInitialized(){this.isInitialized=!0},removeRequiredAttributes(){setTimeout(()=>{let e=this.$el.closest("uc-config");document.querySelectorAll("uc-form-input input[required]").forEach(i=>i.removeAttribute("required"))},100)},initializeState(e){this.$nextTick(()=>{this.initialState&&!this.stateHasBeenInitialized&&!this.uploadedFiles?this.loadInitialState(e):!this.initialState&&!this.stateHasBeenInitialized&&(this.stateHasBeenInitialized=!0,this.uploadedFiles=this.isMultiple?"[]":"",this.isLocalUpdate=!0,this.state=this.uploadedFiles),this.setupStateWatcher()})},loadInitialState(e){try{let t=this.parseInitialState();this.addFilesFromInitialState(e,t),this.stateHasBeenInitialized=!0,this.isLocalUpdate=!0,this.state=this.uploadedFiles}catch(t){console.error("Error parsing initialState:",t)}},parseInitialState(){let e=t=>{if(typeof t=="string")try{let i=JSON.parse(t);if(typeof i=="string")try{i=JSON.parse(i)}catch(s){console.warn("Failed to parse double-encoded JSON:",s)}return i}catch(i){return console.warn("Failed to parse string as JSON:",i),t}return t};if(this.initialState&&typeof this.initialState=="object"&&!Array.isArray(this.initialState)){let t=Object.keys(this.initialState);if(t.length===1)return e(this.initialState[t[0]])}return e(this.initialState)},addFilesFromInitialState(e,t){let i=t;if(t&&typeof t=="object"&&!Array.isArray(t))try{i=Array.from(t)}catch(r){console.warn("Failed to convert Proxy to array:",r),i=[t]}else Array.isArray(t)||(i=[t]);if(Array.isArray(i)&&i.length===1&&Array.isArray(i[0])&&(i=i[0]),Array.isArray(i)&&i.length===1&&typeof i[0]=="string")try{let r=JSON.parse(i[0]);i=Array.isArray(r)?r:[r]}catch(r){console.warn("Failed to parse JSON string from filesArray[0]:",r)}Array.isArray(i)||(i=[i]);let s=(r,n=0)=>{if(!r)return;if(Array.isArray(r)){r.forEach((d,h)=>{s(d,`${n}.${h}`)});return}if(typeof r=="string")try{let d=JSON.parse(r);s(d,n);return}catch(d){console.warn(`Failed to parse string item ${n} as JSON:`,d)}let o=typeof r=="object"?r.cdnUrl:r,p=typeof r=="object"?r.cdnUrlModifiers:null;if(!o||!this.isValidUrl(o)){console.warn(`Invalid URL for file ${n}:`,o);return}let c=this.extractUuidFromUrl(o);if(c&&typeof e.addFileFromUuid=="function")try{if(p&&typeof e.addFileFromCdnUrl=="function"){let h=o.split("/-/")[0]+"/"+p;e.addFileFromCdnUrl(h)}else e.addFileFromUuid(c)}catch(d){console.error(`Failed to add file ${n} with UUID ${c}:`,d)}else console.error(c?"addFileFromUuid method not available on API":`Could not extract UUID from URL: ${o}`)};i.forEach(s);let a=this.formatFilesForState(i);this.uploadedFiles=JSON.stringify(a),this.initialState=this.uploadedFiles},isValidUrl(e){if(!e||typeof e!="string")return!1;try{return new URL(e),!0}catch{return!1}},setupStateWatcher(){this.$watch("state",e=>{if(this.isLocalUpdate){this.isLocalUpdate=!1;return}if(!this.stateHasBeenInitialized){this.stateHasBeenInitialized=!0;return}if((!e||e==="[]"||e==='""')&&!this.uploadedFiles)return;let t=this.normalizeStateValue(e),i=this.normalizeStateValue(this.uploadedFiles);t!==i&&e&&e!=="[]"&&e!=='""'&&(this.uploadedFiles=e,this.isLocalUpdate=!0)})},normalizeStateValue(e){if(!e)return"";try{let t=typeof e=="string"?JSON.parse(e):e;return JSON.stringify(this.formatFilesForState(t))}catch{return e}},isStateChanged(){let e=this.normalizeStateValue(this.state),t=this.normalizeStateValue(this.initialState);return e!==t},setupEventListeners(e){let t=this.createFileUploadSuccessHandler(),i=this.createFileUrlChangedHandler(),s=this.createFileRemovedHandler(),a=this.createFormInputChangeHandler(e);this.ctx.addEventListener("file-upload-started",r=>{let n=this.$el.closest("form");n&&n.dispatchEvent(new CustomEvent("form-processing-started",{detail:{message:"Uploading file..."}}))}),this.ctx.addEventListener("file-upload-success",t),this.ctx.addEventListener("file-url-changed",i),this.ctx.addEventListener("file-removed",s),this.$nextTick(()=>{let r=this.$el.querySelector("uc-form-input input");if(r){r.addEventListener("input",a),r.addEventListener("change",a);let n=new MutationObserver(()=>{a({target:r})});n.observe(r,{attributes:!0,attributeFilter:["value"]}),this.formInputObserver=n}else setTimeout(()=>{let n=this.$el.querySelector("uc-form-input input");n&&(n.addEventListener("input",a),n.addEventListener("change",a))},200)}),this.removeEventListeners=()=>{this.ctx.removeEventListener("file-upload-started",n=>{let o=this.$el.closest("form");o&&o.dispatchEvent(new CustomEvent("form-processing-started",{detail:{message:"Uploading file..."}}))}),this.ctx.removeEventListener("file-upload-success",t),this.ctx.removeEventListener("file-url-changed",i),this.ctx.removeEventListener("file-removed",s);let r=this.$el.querySelector("uc-form-input input");r&&(r.removeEventListener("input",a),r.removeEventListener("change",a)),this.formInputObserver&&(this.formInputObserver.disconnect(),this.formInputObserver=null)}},createFileUploadSuccessHandler(){let e=null;return t=>{e&&clearTimeout(e),e=setTimeout(()=>{let i=this.isWithMetadata?t.detail:t.detail.cdnUrl;try{let s=this.getCurrentFiles(),a=this.updateFilesList(s,i);this.updateState(a);let r=this.$el.closest("form");r&&r.dispatchEvent(new CustomEvent("form-processing-finished"))}catch(s){console.error("Error updating state after upload:",s)}},this.isMultiple?200:100)}},createFileUrlChangedHandler(){let e=null;return t=>{let i=t.detail;i.cdnUrlModifiers&&(e&&clearTimeout(e),e=setTimeout(()=>{try{let s=this.getCurrentFiles(),a=this.updateFileUrl(s,i);this.updateState(a)}catch(s){console.error("Error updating state after URL change:",s)}},100))}},createFileRemovedHandler(){let e=null;return t=>{e&&clearTimeout(e),e=setTimeout(()=>{try{let i=t.detail,s=this.getCurrentFiles(),a=this.removeFile(s,i);this.updateState(a);let r=this.getUploadcareApi();r&&setTimeout(()=>{this.syncStateWithUploadcare(r)},150)}catch(i){console.error("Error in handleFileRemoved:",i)}},100)}},createFormInputChangeHandler(e){let t=null;return i=>{t&&clearTimeout(t),t=setTimeout(()=>{this.syncStateWithUploadcare(e)},200)}},getCurrentFiles(){try{let e=this.uploadedFiles?JSON.parse(this.uploadedFiles):[];return Array.isArray(e)?e:[]}catch{return[]}},updateFilesList(e,t){return this.isMultiple?e.some(s=>{let a=typeof s=="object"?s.cdnUrl:s,r=typeof t=="object"?t.cdnUrl:t;return a===r})?e:[...e,t]:[t]},updateFileUrl(e,t){let i=this.findFileIndex(e,t.uuid);if(i===-1)return e;let s=this.isWithMetadata?t:t.cdnUrl;return this.isMultiple?(e[i]=s,e):[s]},removeFile(e,t){let i=this.findFileIndex(e,t.uuid);return i===-1?e:this.isMultiple?(e.splice(i,1),e):[]},findFileIndex(e,t){return e.findIndex(i=>{let s=typeof i=="object"?i.cdnUrl:i;return s&&s.includes(t)})},updateState(e){let t=this.formatFilesForState(e),i=JSON.stringify(t),s=this.getCurrentFiles(),a=JSON.stringify(this.formatFilesForState(s)),r=JSON.stringify(this.formatFilesForState(t));a!==r&&(this.uploadedFiles=i,this.isLocalUpdate=!0,this.state=this.uploadedFiles,this.isMultiple&&e.length>1&&this.$nextTick(()=>{this.isLocalUpdate=!1}))},formatFilesForState(e){return e.map(t=>this.isWithMetadata?t:typeof t=="object"?t.cdnUrl:t)},setupDoneButtonObserver(){let e=this.$el.closest(".uploadcare-wrapper");e&&(this.doneButtonHider=new u(e))},destroy(){this.doneButtonHider&&(this.doneButtonHider.destroy(),this.doneButtonHider=null),this.documentClassObserver&&(this.documentClassObserver.disconnect(),this.documentClassObserver=null),this.formInputObserver&&(this.formInputObserver.disconnect(),this.formInputObserver=null),this.removeEventListeners&&this.removeEventListeners()},extractUuidFromUrl(e){if(!e||typeof e!="string")return null;let t=e.match(/\/([a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})(?:\/|$)/i);return t&&t[1]?t[1]:typeof e=="object"&&e.uuid?e.uuid:null},syncStateWithUploadcare(e){try{let t=this.getCurrentFilesFromUploadcare(e),i=this.formatFilesForState(t),s=this.buildStateFromFiles(i),a=this.normalizeStateValue(this.uploadedFiles),r=this.normalizeStateValue(s);a!==r&&(this.uploadedFiles=s,this.isLocalUpdate=!0,this.state=this.uploadedFiles)}catch(t){console.error("Error syncing state with Uploadcare:",t)}},buildStateFromFiles(e){return this.isMultiple?JSON.stringify(e):e.length>0?this.isWithMetadata?JSON.stringify(e[0]):e[0]:""},getCurrentFilesFromUploadcare(e){try{let t=this.$el.querySelector("uc-form-input input");return t?this.parseFormInputValue(t.value):this.$el.querySelectorAll("uc-file-item, [data-file-item]").length===0?[]:[]}catch(t){return console.error("Error getting current files from Uploadcare:",t),[]}},parseFormInputValue(e){if(!e||typeof e=="string"&&e.trim()==="")return[];try{let t=JSON.parse(e);return Array.isArray(t)?t.filter(i=>i!==null&&i!==""):t!==null&&t!==""?[t]:[]}catch{return typeof e=="string"&&e.trim()!==""?[e]:[]}}}}export{f as default}; +var p=class{constructor(e){this.wrapper=e,this.observer=null,this.init()}init(){this.hideDoneButtons(),this.setupObserver()}setupObserver(){this.observer=new MutationObserver(e=>{e.forEach(t=>{t.type==="childList"&&t.addedNodes.forEach(i=>{if(i.nodeType===Node.ELEMENT_NODE){i.classList&&i.classList.contains("uc-done-btn")&&this.hideDoneButton(i);let r=i.querySelectorAll&&i.querySelectorAll(".uc-done-btn");r&&r.forEach(l=>this.hideDoneButton(l))}})})}),this.wrapper&&this.observer.observe(this.wrapper,{childList:!0,subtree:!0})}hideDoneButtons(){document.querySelectorAll(".uc-done-btn").forEach(t=>this.hideDoneButton(t))}hideDoneButton(e){e&&(e.style.display="none",e.style.visibility="hidden",e.style.opacity="0",e.style.pointerEvents="none",e.style.position="absolute",e.style.width="0",e.style.height="0",e.style.overflow="hidden",e.style.clip="rect(0, 0, 0, 0)",e.style.margin="0",e.style.padding="0",e.style.border="0",e.style.background="transparent",e.style.color="transparent",e.style.fontSize="0",e.style.lineHeight="0")}destroy(){this.observer&&(this.observer.disconnect(),this.observer=null)}};function f(o){return window._initializedUploadcareContexts||(window._initializedUploadcareContexts=new Set),{state:o.state,statePath:o.statePath,initialState:o.initialState,publicKey:o.publicKey,isMultiple:o.isMultiple,multipleMin:o.multipleMin,multipleMax:o.multipleMax,isImagesOnly:o.isImagesOnly,accept:o.accept,sourceList:o.sourceList,uploaderStyle:o.uploaderStyle,isWithMetadata:o.isWithMetadata,localeName:o.localeName||"en",uploadedFiles:"",ctx:null,removeEventListeners:null,uniqueContextName:o.uniqueContextName,isInitialized:!1,stateHasBeenInitialized:!1,isStateWatcherActive:!1,isLocalUpdate:!1,doneButtonHider:null,documentClassObserver:null,formInputObserver:null,async init(){this.isContextAlreadyInitialized()||(this.markContextAsInitialized(),this.applyTheme(),await this.loadAllLocales(),this.setupStateWatcher(),this.$el.addEventListener("uploadcare-state-updated",e=>{let t=e.detail.uuid;t&&this.isInitialized?this.loadFileFromUuid(t):t&&this.$nextTick(()=>{this.isInitialized&&this.loadFileFromUuid(t)})}),this.initUploadcare(),this.setupThemeObservers(),this.setupDoneButtonObserver())},isContextAlreadyInitialized(){return window._initializedUploadcareContexts.has(this.uniqueContextName)},markContextAsInitialized(){window._initializedUploadcareContexts.add(this.uniqueContextName)},async loadAllLocales(){window._uploadcareAllLocalesLoaded||await new Promise(t=>{if(window._uploadcareAllLocalesLoaded){t();return}let i=setInterval(()=>{window._uploadcareAllLocalesLoaded&&(clearInterval(i),t())},100);setTimeout(()=>{clearInterval(i),t()},5e3)});let e=["de","es","fr","he","it","nl","pl","pt","ru","tr","uk","zh-TW","zh"];document.querySelectorAll("uc-config[data-locale-name]").forEach(t=>{let i=t.getAttribute("data-locale-name");i&&e.includes(i)&&!t.getAttribute("locale-name")&&t.setAttribute("locale-name",i)})},async loadLocale(){if(this.localeName==="en"||this.localeLoaded)return;if(window._uploadcareLocales&&window._uploadcareLocales.has(this.localeName)){this.localeLoaded=!0;return}if(window._uploadcareLocales||(window._uploadcareLocales=new Set),!!["de","es","fr","he","it","nl","pl","pt","ru","tr","uk","zh-TW","zh"].includes(this.localeName))try{let i=await import(`https://cdn.jsdelivr.net/npm/@uploadcare/file-uploader@v1/locales/file-uploader/${this.localeName}.js`),r=i.default||i,l=()=>{let a=customElements.get("uc-file-uploader-inline")||customElements.get("uc-file-uploader-regular")||customElements.get("uc-file-uploader-minimal");return a&&a.UC?a.UC:window.UC},s=()=>{let a=l();return a&&typeof a.defineLocale=="function"?(a.defineLocale(this.localeName,r),window._uploadcareLocales.add(this.localeName),this.localeLoaded=!0,!0):!1};if(!s()){let a=0,n=50,u=setInterval(()=>{a++,(s()||a>=n)&&clearInterval(u)},100)}}catch(t){console.error("[Uploadcare Locale JS] Failed to load locale:",this.localeName,t)}},applyTheme(){let e=this.getCurrentTheme();document.querySelectorAll(`uc-file-uploader-${this.uploaderStyle}`).forEach(i=>{i.classList.remove("uc-dark","uc-light"),i.classList.add(`uc-${e}`)})},getCurrentTheme(){return document.documentElement.classList.contains("dark")?"dark":"light"},setupThemeObservers(){window.addEventListener("storage",this.handleThemeStorageChange.bind(this)),window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change",this.handleSystemThemeChange.bind(this)),this.setupDocumentClassObserver()},handleThemeStorageChange(e){e.key==="theme"&&this.applyTheme()},handleSystemThemeChange(){localStorage.getItem("theme")==="system"&&this.applyTheme()},setupDocumentClassObserver(){this.documentClassObserver=new MutationObserver(e=>{e.forEach(t=>{if(t.type==="attributes"&&t.attributeName==="class"){let i=document.documentElement.classList.contains("dark"),r=t.oldValue&&t.oldValue.includes("dark");i!==r&&this.applyTheme()}})}),this.documentClassObserver.observe(document.documentElement,{attributes:!0,attributeOldValue:!0,attributeFilter:["class"]})},initUploadcare(){this.removeEventListeners&&this.removeEventListeners(),this.initializeUploader()},initializeUploader(e=0,t=10){if(e>=t)return;this.ctx=document.querySelector(`uc-upload-ctx-provider[ctx-name="${this.uniqueContextName}"]`);let i=this.getUploadcareApi();if(!this.isValidContext(i)){setTimeout(()=>this.initializeUploader(e+1,t),100);return}this.markAsInitialized(),this.removeRequiredAttributes(),this.initializeState(i),this.setupEventListeners(i)},getUploadcareApi(){try{return this.ctx?.getAPI()}catch{return null}},isValidContext(e){return this.ctx&&e&&e.addFileFromCdnUrl},markAsInitialized(){this.isInitialized=!0},removeRequiredAttributes(){setTimeout(()=>{let e=this.$el.closest("uc-config");document.querySelectorAll("uc-form-input input[required]").forEach(i=>i.removeAttribute("required"))},100)},initializeState(e){this.$nextTick(()=>{this.initialState&&!this.stateHasBeenInitialized&&!this.uploadedFiles?this.loadInitialState(e):!this.initialState&&!this.stateHasBeenInitialized&&(this.stateHasBeenInitialized=!0,this.uploadedFiles=this.isMultiple?"[]":"",this.isLocalUpdate=!0,this.state=this.uploadedFiles)})},loadInitialState(e){try{let t=this.parseInitialState();this.addFilesFromInitialState(e,t),this.stateHasBeenInitialized=!0,this.isLocalUpdate=!0,this.state=this.uploadedFiles}catch(t){console.error("Error parsing initialState:",t)}},parseInitialState(){let e=t=>{if(typeof t=="string")try{let i=JSON.parse(t);if(typeof i=="string")try{i=JSON.parse(i)}catch{}return i}catch{return t}return t};if(this.initialState&&typeof this.initialState=="object"&&!Array.isArray(this.initialState)){let t=Object.keys(this.initialState);if(t.length===1)return e(this.initialState[t[0]])}return e(this.initialState)},addFilesFromInitialState(e,t){let i=t;if(t&&typeof t=="object"&&!Array.isArray(t))try{i=Array.from(t)}catch{i=[t]}else Array.isArray(t)||(i=[t]);if(Array.isArray(i)&&i.length===1&&Array.isArray(i[0])&&(i=i[0]),Array.isArray(i)&&i.length===1&&typeof i[0]=="string")try{let s=JSON.parse(i[0]);i=Array.isArray(s)?s:[s]}catch{}Array.isArray(i)||(i=[i]);let r=(s,a=0)=>{if(!s)return;if(Array.isArray(s)){s.forEach((d,h)=>{r(d,`${a}.${h}`)});return}if(typeof s=="string")try{let d=JSON.parse(s);r(d,a);return}catch{}let n=typeof s=="object"?s.cdnUrl:s,u=typeof s=="object"?s.cdnUrlModifiers:null;if(!n||!this.isValidUrl(n))return;let c=this.extractUuidFromUrl(n);if(c&&typeof e.addFileFromUuid=="function")try{if(u&&typeof e.addFileFromCdnUrl=="function"){let h=n.split("/-/")[0]+"/"+u;e.addFileFromCdnUrl(h)}else e.addFileFromUuid(c)}catch(d){console.error(`Failed to add file ${a} with UUID ${c}:`,d)}else console.error(c?"addFileFromUuid method not available on API":`Could not extract UUID from URL: ${n}`)};i.forEach(r);let l=this.formatFilesForState(i);this.uploadedFiles=JSON.stringify(l),this.initialState=this.uploadedFiles},isValidUrl(e){if(!e||typeof e!="string")return!1;try{return new URL(e),!0}catch{return!1}},setupStateWatcher(){this.$watch("state",(e,t)=>{if(this.isLocalUpdate){this.isLocalUpdate=!1;return}if(!this.stateHasBeenInitialized){this.stateHasBeenInitialized=!0;return}if((!e||e==="[]"||e==='""')&&!this.uploadedFiles)return;let i=this.normalizeStateValue(e),r=this.normalizeStateValue(this.uploadedFiles);i!==r&&e&&e!=="[]"&&e!=='""'&&this.addFilesFromState(e)})},parseStateValue(e){if(!e)return null;try{return typeof e=="string"?JSON.parse(e):e}catch{return e}},addFilesFromState(e){let i=this.parseStateValue(e);if(Array.isArray(i)||(i=[i]),i.length===0)return!1;let r=this.getUploadcareApi();if(!r||typeof r.addFileFromCdnUrl!="function")return!1;let s=this.getCurrentFiles().map(a=>typeof a=="object"?a.cdnUrl:a).filter(Boolean);return i.forEach(a=>{let n=typeof a=="object"?a.cdnUrl:a;if(n&&typeof n=="string"&&n.includes("ucarecdn.com")&&!s.some(c=>{let d=this.extractUuidFromUrl(n),h=this.extractUuidFromUrl(c);return d&&h&&d===h}))try{r.addFileFromCdnUrl(n)}catch(c){console.error("[Uploadcare] Failed to add file from URL:",n,c)}}),this.uploadedFiles=e,this.isLocalUpdate=!0,!0},normalizeStateValue(e){if(!e)return"";try{let t=typeof e=="string"?JSON.parse(e):e;return JSON.stringify(this.formatFilesForState(t))}catch{return e}},isStateChanged(){let e=this.normalizeStateValue(this.state),t=this.normalizeStateValue(this.initialState);return e!==t},setupEventListeners(e){let t=this.createFileUploadSuccessHandler(),i=this.createFileUrlChangedHandler(),r=this.createFileRemovedHandler(),l=this.createFormInputChangeHandler(e);this.ctx.addEventListener("file-upload-started",s=>{let a=this.$el.closest("form");a&&a.dispatchEvent(new CustomEvent("form-processing-started",{detail:{message:"Uploading file..."}}))}),this.ctx.addEventListener("file-upload-success",t),this.ctx.addEventListener("file-url-changed",i),this.ctx.addEventListener("file-removed",r),this.$nextTick(()=>{let s=this.$el.querySelector("uc-form-input input");if(s){s.addEventListener("input",l),s.addEventListener("change",l);let a=new MutationObserver(()=>{l({target:s})});a.observe(s,{attributes:!0,attributeFilter:["value"]}),this.formInputObserver=a}else setTimeout(()=>{let a=this.$el.querySelector("uc-form-input input");a&&(a.addEventListener("input",l),a.addEventListener("change",l))},200)}),this.removeEventListeners=()=>{this.ctx.removeEventListener("file-upload-started",a=>{let n=this.$el.closest("form");n&&n.dispatchEvent(new CustomEvent("form-processing-started",{detail:{message:"Uploading file..."}}))}),this.ctx.removeEventListener("file-upload-success",t),this.ctx.removeEventListener("file-url-changed",i),this.ctx.removeEventListener("file-removed",r);let s=this.$el.querySelector("uc-form-input input");s&&(s.removeEventListener("input",l),s.removeEventListener("change",l)),this.formInputObserver&&(this.formInputObserver.disconnect(),this.formInputObserver=null)}},createFileUploadSuccessHandler(){let e=null;return t=>{e&&clearTimeout(e),e=setTimeout(()=>{let i=this.isWithMetadata?t.detail:t.detail.cdnUrl;try{let r=this.getCurrentFiles(),l=this.updateFilesList(r,i);this.updateState(l);let s=this.$el.closest("form");s&&s.dispatchEvent(new CustomEvent("form-processing-finished"))}catch(r){console.error("[Uploadcare] Error updating state after upload:",r)}},this.isMultiple?200:100)}},createFileUrlChangedHandler(){let e=null;return t=>{let i=t.detail;!i||!i.cdnUrl||(e&&clearTimeout(e),e=setTimeout(()=>{try{let r=this.getCurrentFiles(),l=this.updateFileUrl(r,i);this.updateState(l)}catch(r){console.error("Error updating state after URL change:",r)}},100))}},createFileRemovedHandler(){let e=null;return t=>{e&&clearTimeout(e),e=setTimeout(()=>{try{let i=t.detail,r=this.getCurrentFiles(),l=this.removeFile(r,i);this.updateState(l);let s=this.getUploadcareApi();s&&setTimeout(()=>{this.syncStateWithUploadcare(s)},150)}catch(i){console.error("Error in handleFileRemoved:",i)}},100)}},createFormInputChangeHandler(e){let t=null;return i=>{t&&clearTimeout(t),t=setTimeout(()=>{this.syncStateWithUploadcare(e)},200)}},getCurrentFiles(){try{let e=this.uploadedFiles?JSON.parse(this.uploadedFiles):[];return Array.isArray(e)?e:[]}catch{return[]}},updateFilesList(e,t){return this.isMultiple?e.some(r=>{let l=typeof r=="object"?r.cdnUrl:r,s=typeof t=="object"?t.cdnUrl:t;return l===s})?e:[...e,t]:[t]},updateFileUrl(e,t){let i=t.uuid;if(!i&&t.cdnUrl&&(i=this.extractUuidFromUrl(t.cdnUrl)),!i)return e;t.uuid||(t={...t,uuid:i});let r=this.findFileIndex(e,i);if(r===-1)return e;let l;return this.isWithMetadata?l={...e[r],...t}:l=t.cdnUrl,this.isMultiple?(e[r]=l,e):[l]},removeFile(e,t){let i=this.findFileIndex(e,t.uuid);return i===-1?e:this.isMultiple?(e.splice(i,1),e):[]},findFileIndex(e,t){return e.findIndex(i=>{let r=typeof i=="object"?i.cdnUrl:i;return r&&r.includes(t)})},updateState(e){let t=this.formatFilesForState(e),i=JSON.stringify(t),r=this.getCurrentFiles(),l=JSON.stringify(this.formatFilesForState(r)),s=JSON.stringify(this.formatFilesForState(t));l!==s&&(this.uploadedFiles=i,this.isLocalUpdate=!0,this.state=this.uploadedFiles,this.isMultiple&&e.length>1&&this.$nextTick(()=>{this.isLocalUpdate=!1}))},formatFilesForState(e){return e.map(t=>this.isWithMetadata?t:typeof t=="object"?t.cdnUrl:t)},setupDoneButtonObserver(){let e=this.$el.closest(".uploadcare-wrapper");e&&(this.doneButtonHider=new p(e))},destroy(){this.doneButtonHider&&(this.doneButtonHider.destroy(),this.doneButtonHider=null),this.documentClassObserver&&(this.documentClassObserver.disconnect(),this.documentClassObserver=null),this.formInputObserver&&(this.formInputObserver.disconnect(),this.formInputObserver=null),this.removeEventListeners&&this.removeEventListeners()},extractUuidFromUrl(e){if(!e||typeof e!="string")return null;let t=e.match(/\/([a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})(?:\/|$)/i);return t&&t[1]?t[1]:typeof e=="object"&&e.uuid?e.uuid:null},syncStateWithUploadcare(e){try{let t=this.getCurrentFilesFromUploadcare(e),i=this.formatFilesForState(t),r=this.buildStateFromFiles(i),l=this.normalizeStateValue(this.uploadedFiles),s=this.normalizeStateValue(r);l!==s&&(this.uploadedFiles=r,this.isLocalUpdate=!0,this.state=this.uploadedFiles)}catch(t){console.error("Error syncing state with Uploadcare:",t)}},buildStateFromFiles(e){return this.isMultiple?JSON.stringify(e):e.length>0?this.isWithMetadata?JSON.stringify(e[0]):e[0]:""},getCurrentFilesFromUploadcare(e){try{let t=this.$el.querySelector("uc-form-input input");return t?this.parseFormInputValue(t.value):this.$el.querySelectorAll("uc-file-item, [data-file-item]").length===0?[]:[]}catch(t){return console.error("Error getting current files from Uploadcare:",t),[]}},parseFormInputValue(e){if(!e||typeof e=="string"&&e.trim()==="")return[];try{let t=JSON.parse(e);return Array.isArray(t)?t.filter(i=>i!==null&&i!==""):t!==null&&t!==""?[t]:[]}catch{return typeof e=="string"&&e.trim()!==""?[e]:[]}}}}export{f as default}; diff --git a/packages/filament-uploadcare-field/resources/js/components/uploadcare.js b/packages/filament-uploadcare-field/resources/js/components/uploadcare.js index 25eceebc..b29e7cdc 100644 --- a/packages/filament-uploadcare-field/resources/js/components/uploadcare.js +++ b/packages/filament-uploadcare-field/resources/js/components/uploadcare.js @@ -18,6 +18,7 @@ export default function uploadcareField(config) { sourceList: config.sourceList, uploaderStyle: config.uploaderStyle, isWithMetadata: config.isWithMetadata, + localeName: config.localeName || 'en', uploadedFiles: '', ctx: null, removeEventListeners: null, @@ -30,11 +31,31 @@ export default function uploadcareField(config) { documentClassObserver: null, formInputObserver: null, - init() { - if (this.isContextAlreadyInitialized()) return; + async init() { + if (this.isContextAlreadyInitialized()) { + return; + } this.markContextAsInitialized(); this.applyTheme(); + + await this.loadAllLocales(); + + this.setupStateWatcher(); + + this.$el.addEventListener('uploadcare-state-updated', (e) => { + const uuid = e.detail.uuid; + if (uuid && this.isInitialized) { + this.loadFileFromUuid(uuid); + } else if (uuid) { + this.$nextTick(() => { + if (this.isInitialized) { + this.loadFileFromUuid(uuid); + } + }); + } + }); + this.initUploadcare(); this.setupThemeObservers(); this.setupDoneButtonObserver(); @@ -48,6 +69,100 @@ export default function uploadcareField(config) { window._initializedUploadcareContexts.add(this.uniqueContextName); }, + async loadAllLocales() { + if (!window._uploadcareAllLocalesLoaded) { + await new Promise((resolve) => { + if (window._uploadcareAllLocalesLoaded) { + resolve(); + return; + } + const checkInterval = setInterval(() => { + if (window._uploadcareAllLocalesLoaded) { + clearInterval(checkInterval); + resolve(); + } + }, 100); + setTimeout(() => { + clearInterval(checkInterval); + resolve(); + }, 5000); + }); + } + + const supportedLocales = ['de', 'es', 'fr', 'he', 'it', 'nl', 'pl', 'pt', 'ru', 'tr', 'uk', 'zh-TW', 'zh']; + document.querySelectorAll('uc-config[data-locale-name]').forEach(config => { + const locale = config.getAttribute('data-locale-name'); + if (locale && supportedLocales.includes(locale) && !config.getAttribute('locale-name')) { + config.setAttribute('locale-name', locale); + } + }); + }, + + async loadLocale() { + if (this.localeName === 'en' || this.localeLoaded) { + return; + } + + if (window._uploadcareLocales && window._uploadcareLocales.has(this.localeName)) { + this.localeLoaded = true; + return; + } + + if (!window._uploadcareLocales) { + window._uploadcareLocales = new Set(); + } + + const supportedLocales = ['de', 'es', 'fr', 'he', 'it', 'nl', 'pl', 'pt', 'ru', 'tr', 'uk', 'zh-TW', 'zh']; + + if (!supportedLocales.includes(this.localeName)) { + return; + } + + try { + const localeUrl = `https://cdn.jsdelivr.net/npm/@uploadcare/file-uploader@v1/locales/file-uploader/${this.localeName}.js`; + const localeModule = await import(localeUrl); + const localeData = localeModule.default || localeModule; + + const getUC = () => { + const UploaderElement = customElements.get('uc-file-uploader-inline') || + customElements.get('uc-file-uploader-regular') || + customElements.get('uc-file-uploader-minimal'); + + if (UploaderElement && UploaderElement.UC) { + return UploaderElement.UC; + } + + return window.UC; + }; + + const registerLocale = () => { + const UC = getUC(); + if (UC && typeof UC.defineLocale === 'function') { + UC.defineLocale(this.localeName, localeData); + window._uploadcareLocales.add(this.localeName); + this.localeLoaded = true; + return true; + } + return false; + }; + + if (!registerLocale()) { + let attempts = 0; + const maxAttempts = 50; + const checkUC = setInterval(() => { + attempts++; + if (registerLocale()) { + clearInterval(checkUC); + } else if (attempts >= maxAttempts) { + clearInterval(checkUC); + } + }, 100); + } + } catch (error) { + console.error('[Uploadcare Locale JS] Failed to load locale:', this.localeName, error); + } + }, + applyTheme() { const theme = this.getCurrentTheme(); const uploaders = document.querySelectorAll(`uc-file-uploader-${this.uploaderStyle}`); @@ -116,7 +231,6 @@ export default function uploadcareField(config) { initializeUploader(retryCount = 0, maxRetries = 10) { if (retryCount >= maxRetries) { - console.error('Failed to initialize Uploadcare after maximum retries'); return; } @@ -168,7 +282,6 @@ export default function uploadcareField(config) { this.isLocalUpdate = true; this.state = this.uploadedFiles; } - this.setupStateWatcher(); }); }, @@ -194,13 +307,11 @@ export default function uploadcareField(config) { try { parsed = JSON.parse(parsed); } catch (e) { - console.warn('Failed to parse double-encoded JSON:', e); } } return parsed; } catch (e) { - console.warn('Failed to parse string as JSON:', e); return value; } } @@ -223,7 +334,6 @@ export default function uploadcareField(config) { try { filesArray = Array.from(parsedState); } catch (e) { - console.warn('Failed to convert Proxy to array:', e); filesArray = [parsedState]; } } else if (!Array.isArray(parsedState)) { @@ -239,7 +349,6 @@ export default function uploadcareField(config) { const parsed = JSON.parse(filesArray[0]); filesArray = Array.isArray(parsed) ? parsed : [parsed]; } catch (e) { - console.warn('Failed to parse JSON string from filesArray[0]:', e); } } @@ -263,7 +372,6 @@ export default function uploadcareField(config) { addFile(parsedItem, index); return; } catch (e) { - console.warn(`Failed to parse string item ${index} as JSON:`, e); } } @@ -271,7 +379,6 @@ export default function uploadcareField(config) { const cdnUrlModifiers = typeof item === 'object' ? item.cdnUrlModifiers : null; if (!url || !this.isValidUrl(url)) { - console.warn(`Invalid URL for file ${index}:`, url); return; } @@ -313,7 +420,7 @@ export default function uploadcareField(config) { }, setupStateWatcher() { - this.$watch('state', (newValue) => { + this.$watch('state', (newValue, oldValue) => { if (this.isLocalUpdate) { this.isLocalUpdate = false; return; @@ -333,11 +440,70 @@ export default function uploadcareField(config) { if (normalizedNewValue !== normalizedUploadedFiles) { if (newValue && newValue !== '[]' && newValue !== '""') { - this.uploadedFiles = newValue; - this.isLocalUpdate = true; + this.addFilesFromState(newValue); + } + } + }); + }, + + parseStateValue(value) { + if (!value) return null; + + try { + if (typeof value === 'string') { + return JSON.parse(value); + } + return value; + } catch (e) { + return value; + } + }, + + addFilesFromState(newValue) { + const parsed = this.parseStateValue(newValue); + let filesToAdd = parsed; + + if (!Array.isArray(filesToAdd)) { + filesToAdd = [filesToAdd]; + } + + if (filesToAdd.length === 0) { + return false; + } + + const api = this.getUploadcareApi(); + if (!api || typeof api.addFileFromCdnUrl !== 'function') { + return false; + } + + const currentFiles = this.getCurrentFiles(); + const currentUrls = currentFiles.map(file => { + const url = typeof file === 'object' ? file.cdnUrl : file; + return url; + }).filter(Boolean); + + filesToAdd.forEach(item => { + const url = typeof item === 'object' ? item.cdnUrl : item; + if (url && typeof url === 'string' && url.includes('ucarecdn.com')) { + const urlExists = currentUrls.some(currentUrl => { + const uuid1 = this.extractUuidFromUrl(url); + const uuid2 = this.extractUuidFromUrl(currentUrl); + return uuid1 && uuid2 && uuid1 === uuid2; + }); + + if (!urlExists) { + try { + api.addFileFromCdnUrl(url); + } catch (e) { + console.error('[Uploadcare] Failed to add file from URL:', url, e); + } } } }); + + this.uploadedFiles = newValue; + this.isLocalUpdate = true; + return true; }, normalizeStateValue(value) { @@ -440,6 +606,7 @@ export default function uploadcareField(config) { debounceTimer = setTimeout(() => { const fileData = this.isWithMetadata ? e.detail : e.detail.cdnUrl; + try { const currentFiles = this.getCurrentFiles(); const updatedFiles = this.updateFilesList(currentFiles, fileData); @@ -450,7 +617,7 @@ export default function uploadcareField(config) { form.dispatchEvent(new CustomEvent('form-processing-finished')); } } catch (error) { - console.error('Error updating state after upload:', error); + console.error('[Uploadcare] Error updating state after upload:', error); } }, this.isMultiple ? 200 : 100); }; @@ -461,7 +628,8 @@ export default function uploadcareField(config) { return (e) => { const fileDetails = e.detail; - if (!fileDetails.cdnUrlModifiers) return; + // Removed strict check for cdnUrlModifiers to allow updates if only cdnUrl has changed + if (!fileDetails || !fileDetails.cdnUrl) return; if (debounceTimer) { clearTimeout(debounceTimer); @@ -547,10 +715,31 @@ export default function uploadcareField(config) { }, updateFileUrl(currentFiles, fileDetails) { - const fileIndex = this.findFileIndex(currentFiles, fileDetails.uuid); + let uuid = fileDetails.uuid; + + if (!uuid && fileDetails.cdnUrl) { + uuid = this.extractUuidFromUrl(fileDetails.cdnUrl); + } + + if (!uuid) return currentFiles; + + // Ensure uuid is present in fileDetails for the merge + if (!fileDetails.uuid) { + fileDetails = { ...fileDetails, uuid }; + } + + const fileIndex = this.findFileIndex(currentFiles, uuid); if (fileIndex === -1) return currentFiles; - const updatedFile = this.isWithMetadata ? fileDetails : fileDetails.cdnUrl; + let updatedFile; + if (this.isWithMetadata) { + const originalFile = currentFiles[fileIndex]; + // Merge with existing file to preserve properties like uuid if missing in detail + updatedFile = { ...originalFile, ...fileDetails }; + } else { + updatedFile = fileDetails.cdnUrl; + } + if (this.isMultiple) { currentFiles[fileIndex] = updatedFile; return currentFiles; diff --git a/packages/filament-uploadcare-field/resources/views/forms/components/uploadcare.blade.php b/packages/filament-uploadcare-field/resources/views/forms/components/uploadcare.blade.php index f15202cc..305b8863 100644 --- a/packages/filament-uploadcare-field/resources/views/forms/components/uploadcare.blade.php +++ b/packages/filament-uploadcare-field/resources/views/forms/components/uploadcare.blade.php @@ -1,4 +1,6 @@ -
+
@php $sourceList = $field->getSourceList(); @@ -29,7 +31,8 @@ class="relative z-0 rounded-md bg-white dark:bg-gray-900 focus-within:ring focus isWithMetadata: @js($field->isWithMetadata()), accept: '{{ $field->getAcceptedFileTypes() }}', sourceList: '{{ $field->getSourceList() }}', - uploaderStyle: '{{ $field->getUploaderStyle() }}' + uploaderStyle: '{{ $field->getUploaderStyle() }}', + localeName: '{{ $field->getLocaleName() }}' })" x-init="init()" > @@ -44,6 +47,7 @@ class="relative z-0 rounded-md bg-white dark:bg-gray-900 focus-within:ring focus @if($field->getCropPreset()) crop-preset="{{ $field->getCropPreset() }}" @endif @if($field->shouldRemoveCopyright()) remove-copyright @endif @if($field->isRequired()) required="true" @endif + @if($field->getLocaleName() === 'en') locale-name="{{ $field->getLocaleName() }}" @else data-locale-name="{{ $field->getLocaleName() }}" @endif cdn-cname="{{ $field->getCdnCname() }}"> diff --git a/packages/filament-uploadcare-field/src/Forms/Components/Uploadcare.php b/packages/filament-uploadcare-field/src/Forms/Components/Uploadcare.php index 5cdfbb5a..9e0ec37c 100644 --- a/packages/filament-uploadcare-field/src/Forms/Components/Uploadcare.php +++ b/packages/filament-uploadcare-field/src/Forms/Components/Uploadcare.php @@ -343,6 +343,43 @@ public function transformUrlsToDb($value): mixed return $this->transformUrls($value, 'https://ucarecdn.com', $this->getDbCdnCname()); } + /** + * Get the normalized locale for Uploadcare. + * Uploadcare supports: de, en, es, fr, he, it, nl, pl, pt, ru, tr, uk, zh-TW, zh + */ + public function getLocaleName(): string + { + $locale = app()->getLocale(); + + // Normalize locale: convert 'en_US' or 'en-US' to 'en', but keep 'zh-TW' as is + $normalized = str_replace('_', '-', $locale); + + // Handle special cases + if (str_starts_with($normalized, 'zh')) { + // Check if it's zh-TW (Traditional Chinese) + if (str_contains($normalized, 'TW') || str_contains($normalized, 'tw')) { + return 'zh-TW'; + } + + // Otherwise return 'zh' (Simplified Chinese) + return 'zh'; + } + + // Extract base language code (e.g., 'en' from 'en-US' or 'en_US') + $baseLocale = explode('-', $normalized)[0]; + + // List of supported Uploadcare locales + $supportedLocales = ['de', 'en', 'es', 'fr', 'he', 'it', 'nl', 'pl', 'pt', 'ru', 'tr', 'uk', 'zh-TW', 'zh']; + + // Check if base locale is supported + if (in_array($baseLocale, $supportedLocales)) { + return $baseLocale; + } + + // Fallback to 'en' if locale is not supported + return 'en'; + } + protected function setUp(): void { parent::setUp(); diff --git a/packages/filament-uploadcare-field/src/UploadcareServiceProvider.php b/packages/filament-uploadcare-field/src/UploadcareServiceProvider.php index a8264581..34cf2b1d 100644 --- a/packages/filament-uploadcare-field/src/UploadcareServiceProvider.php +++ b/packages/filament-uploadcare-field/src/UploadcareServiceProvider.php @@ -88,54 +88,105 @@ public function packageBooted(): void FilamentView::registerRenderHook(PanelsRenderHook::HEAD_END, function () { return <<<'HTML' HTML; }); diff --git a/packages/filament-uploadcare-field/tailwind.config.js b/packages/filament-uploadcare-field/tailwind.config.js new file mode 100644 index 00000000..be6a145a --- /dev/null +++ b/packages/filament-uploadcare-field/tailwind.config.js @@ -0,0 +1,11 @@ +export default { + darkMode: 'selector', + content: [ + '../uploadcare-field/resources/views/**/*.blade.php', + './resources/views/**/*.blade.php', + ], + theme: { + extend: {}, + }, + plugins: [], +} diff --git a/packages/laravel-translations/src/Domain/Translatables/Actions/PushTranslatedAttribute.php b/packages/laravel-translations/src/Domain/Translatables/Actions/PushTranslatedAttribute.php index 12fbc316..b3ca347f 100644 --- a/packages/laravel-translations/src/Domain/Translatables/Actions/PushTranslatedAttribute.php +++ b/packages/laravel-translations/src/Domain/Translatables/Actions/PushTranslatedAttribute.php @@ -48,8 +48,6 @@ public function handle(object $model, string $attribute, mixed $translation, str public static function modifyTranslatedAttributeValue(Model $model, string $attribute, mixed $reverseMutatedAttributeValue, string $locale): void { $model->translatableAttributes()->updateOrCreate([ - 'translatable_type' => get_class($model), - 'translatable_id' => $model->getKey(), 'attribute' => $attribute, 'code' => $locale, ], [ diff --git a/packages/media/.github/workflows/phpstan.yml b/packages/media/.github/workflows/phpstan.yml index 4c2c5d54..4caa9b38 100644 --- a/packages/media/.github/workflows/phpstan.yml +++ b/packages/media/.github/workflows/phpstan.yml @@ -23,4 +23,4 @@ jobs: uses: ramsey/composer-install@v3 - name: Run PHPStan - run: ./vendor/bin/phpstan --error-format=github + run: ./vendor/bin/phpstan --error-format=github \ No newline at end of file diff --git a/packages/media/.github/workflows/run-tests.yml b/packages/media/.github/workflows/run-tests.yml index 3bd54699..98781b60 100644 --- a/packages/media/.github/workflows/run-tests.yml +++ b/packages/media/.github/workflows/run-tests.yml @@ -1,3 +1,4 @@ + name: run-tests on: @@ -30,7 +31,7 @@ jobs: uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php }} - extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv, imagick, fileinfo + extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv, imagick, fileinfo, sockets coverage: none - name: Setup problem matchers diff --git a/packages/media/README.md b/packages/media/README.md index b54ec117..2601c2c3 100644 --- a/packages/media/README.md +++ b/packages/media/README.md @@ -25,6 +25,8 @@ You can publish and run the migrations with: ```bash php artisan vendor:publish --tag="media-migrations" +php artisan vendor:publish --provider="Backstage\Translations\Laravel\TranslationServiceProvider" +php artisan migrate ``` > [!NOTE] @@ -208,6 +210,41 @@ protected function mutateFormDataBeforeSave(array $data): array } ``` +### Handling File Uploads via Events + +You can listen to the `Backstage\Media\Events\MediaUploading` event to handle file uploads via custom providers (like Uploadcare) or to perform file processing before saving. + +If your listener returns an instance of `Backstage\Media\Models\Media`, the default file handling (storing in local/S3 disk) will be skipped. + +```php +use Backstage\Media\Events\MediaUploading; +use Backstage\Media\Models\Media; + +class CreateMediaFromUploadcare +{ + public function handle(MediaUploading $event): ?Media + { + $file = $event->file; + + // ... upload logic ... + + return $media; // Return Media model to stop default processing + } +} +``` + +Register your listener in your `ServiceProvider`: + +```php +use Illuminate\Support\Facades\Event; +use Backstage\Media\Events\MediaUploading; + +Event::listen( + MediaUploading::class, + CreateMediaFromUploadcare::class, +); +``` + ## Testing ```bash @@ -228,9 +265,9 @@ Please review [our security policy](../../security/policy) on how to report secu ## Credits -- [Baspa](https://github.com/vormkracht10) -- [All Contributors](../../contributors) +- [Baspa](https://github.com/vormkracht10) +- [All Contributors](../../contributors) ## License -The MIT License (MIT). Please see [License File](LICENSE.md) for more information. +The MIT License (MIT). Please see [License File](LICENSE.md) for more information. \ No newline at end of file diff --git a/packages/media/composer.json b/packages/media/composer.json index 637a2404..04846165 100644 --- a/packages/media/composer.json +++ b/packages/media/composer.json @@ -21,9 +21,11 @@ ], "require": { "php": "^8.3", + "backstage/fields": "self.version", + "backstage/laravel-translations": "self.version", "filament/filament": "^4.0", - "spatie/laravel-package-tools": "^1.15.0", - "backstage/fields": "self.version" + "phiki/phiki": "^2.0", + "spatie/laravel-package-tools": "^1.15.0" }, "require-dev": { "nunomaduro/larastan": "^2.0.1|^3.0", @@ -59,6 +61,7 @@ "sort-packages": true, "allow-plugins": { "pestphp/pest-plugin": true, + "php-http/discovery": true, "phpstan/extension-installer": true } }, @@ -72,8 +75,6 @@ } } }, - "repositories": { - }, "minimum-stability": "dev", "prefer-stable": true -} +} \ No newline at end of file diff --git a/packages/media/config/backstage/media.php b/packages/media/config/backstage/media.php index 1fa90911..9dae3456 100644 --- a/packages/media/config/backstage/media.php +++ b/packages/media/config/backstage/media.php @@ -21,7 +21,7 @@ 'directory' => 'media', - 'disk' => env('FILAMENT_FILESYSTEM_DISK', 'public'), + 'disk' => config('filesystems.default', 'public'), 'should_preserve_filenames' => false, diff --git a/packages/media/database/migrations/add_alt_column_to_media_table.php.stub b/packages/media/database/migrations/add_alt_column_to_media_table.php.stub new file mode 100644 index 00000000..4f563e30 --- /dev/null +++ b/packages/media/database/migrations/add_alt_column_to_media_table.php.stub @@ -0,0 +1,20 @@ +getTable(), function (Blueprint $table) { + $table->text('alt')->nullable()->after('height'); + }); + } + + public function down(): void + { + Schema::dropIfExists(app(config('backstage.media.model'))->getTable()); + } +}; \ No newline at end of file diff --git a/packages/media/database/migrations/create_media_relationships_table.php.stub b/packages/media/database/migrations/create_media_relationships_table.php.stub index 48e50be7..ad83675f 100644 --- a/packages/media/database/migrations/create_media_relationships_table.php.stub +++ b/packages/media/database/migrations/create_media_relationships_table.php.stub @@ -17,8 +17,10 @@ return new class extends Migration ->on(app(config('backstage.media.model', \Backstage\Media\Models\Media::class))->getTable()) ->cascadeOnDelete(); - // Polymorphic model relationship - $table->morphs('model'); + // Polymorphic model relationship (String ID support) + $table->string('model_type'); + $table->string('model_id', 36); + $table->index(['model_type', 'model_id']); // Optional position for each relationship $table->unsignedInteger('position')->nullable(); diff --git a/packages/media/database/migrations/make_media_relationships_model_id_string.php.stub b/packages/media/database/migrations/make_media_relationships_model_id_string.php.stub new file mode 100644 index 00000000..a3a6e0ca --- /dev/null +++ b/packages/media/database/migrations/make_media_relationships_model_id_string.php.stub @@ -0,0 +1,25 @@ +string('model_id', 36)->change(); + $table->string('model_type')->change(); + }); + } + + public function down(): void + { + Schema::table('media_relationships', function (Blueprint $table) { + // Revert to typical big integer if needed (unsafe if data exists) + // $table->unsignedBigInteger('model_id')->change(); + }); + } +}; diff --git a/packages/media/phpstan.neon.dist b/packages/media/phpstan.neon.dist index a91953bd..a5b00774 100644 --- a/packages/media/phpstan.neon.dist +++ b/packages/media/phpstan.neon.dist @@ -10,5 +10,5 @@ parameters: tmpDir: build/phpstan checkOctaneCompatibility: true checkModelProperties: true - checkMissingIterableValueType: false - + ignoreErrors: + - identifier: trait.unused \ No newline at end of file diff --git a/packages/media/src/Concerns/HasMedia.php b/packages/media/src/Concerns/HasMedia.php index 20faa6f9..2c76f64c 100644 --- a/packages/media/src/Concerns/HasMedia.php +++ b/packages/media/src/Concerns/HasMedia.php @@ -6,6 +6,9 @@ use Illuminate\Database\Eloquent\Relations\MorphToMany; use Illuminate\Support\Collection; +/** + * @mixin Model + */ trait HasMedia { /** diff --git a/packages/media/src/Events/MediaUploading.php b/packages/media/src/Events/MediaUploading.php new file mode 100644 index 00000000..ceae7ce2 --- /dev/null +++ b/packages/media/src/Events/MediaUploading.php @@ -0,0 +1,16 @@ + Filament::getTenant()->ulid, + $tenant = Filament::getTenant(); + $mediaModel = Model::updateOrCreate([ + 'site_ulid' => $tenant && property_exists($tenant, 'ulid') ? $tenant->ulid : null, 'disk' => config('backstage.media.disk'), 'original_filename' => pathinfo($filename, PATHINFO_FILENAME), 'checksum' => md5_file($fullPath), ], [ 'filename' => $filename, - 'uploaded_by' => auth()->user()->id, + 'uploaded_by' => auth()->user()?->id, 'extension' => $extension, 'mime_type' => $mimeType, 'size' => $fileSize, 'width' => $fileInfo['width'] ?? null, 'height' => $fileInfo['height'] ?? null, + 'alt' => null, 'public' => config('backstage.media.visibility') === 'public', ]); + + $media[] = $mediaModel; } return $media; diff --git a/packages/media/src/MediaPlugin.php b/packages/media/src/MediaPlugin.php index 72a4cd32..a8466457 100644 --- a/packages/media/src/MediaPlugin.php +++ b/packages/media/src/MediaPlugin.php @@ -200,7 +200,7 @@ public function getTenantModel(): ?string } /** - * @return class-string + * @return class-string */ public function getModelItem(): string { diff --git a/packages/media/src/MediaServiceProvider.php b/packages/media/src/MediaServiceProvider.php index 07d7af38..e1b65b1c 100644 --- a/packages/media/src/MediaServiceProvider.php +++ b/packages/media/src/MediaServiceProvider.php @@ -81,7 +81,7 @@ public function packageBooted(): void } Relation::enforceMorphMap([ - 'media' => 'Backstage\Media\Models\Media', + 'media' => config('backstage.media.model'), ]); // Testing @@ -146,6 +146,8 @@ protected function getMigrations(): array 'create_media_table', 'create_media_relationships_table', 'add_tenant_aware_column_to_media_table', + 'add_alt_column_to_media_table', + 'make_media_relationships_model_id_string', ]; } } diff --git a/packages/media/src/Models/Media.php b/packages/media/src/Models/Media.php index 7c272e12..28c3680c 100644 --- a/packages/media/src/Models/Media.php +++ b/packages/media/src/Models/Media.php @@ -2,6 +2,8 @@ namespace Backstage\Media\Models; +use Backstage\Translations\Laravel\Contracts\TranslatesAttributes; +use Backstage\Translations\Laravel\Models\Concerns\HasTranslatableAttributes; use Filament\Facades\Filament; use Illuminate\Database\Eloquent\Concerns\HasUlids; use Illuminate\Database\Eloquent\Model; @@ -12,13 +14,34 @@ use Illuminate\Support\Facades\Storage; use Symfony\Component\HttpFoundation\StreamedResponse; -class Media extends Model +/** + * @property string $ulid + * @property string $filename + * @property string $path + * @property string $mime_type + * @property int $size + * @property int|null $width + * @property int|null $height + * @property string|null $alt + * @property array|null $metadata + * @property int|null $uploaded_by + * @property string|null $tenant_ulid + * @property \Illuminate\Support\Carbon|null $created_at + * @property \Illuminate\Support\Carbon|null $updated_at + * @property \Illuminate\Support\Carbon|null $deleted_at + * @property-read string $humanReadableSize + * @property-read string $src + */ +class Media extends Model implements TranslatesAttributes { + use HasTranslatableAttributes; use HasUlids; use SoftDeletes; protected $primaryKey = 'ulid'; + protected $table = 'media'; + public $incrementing = false; protected $keyType = 'string'; @@ -33,6 +56,7 @@ class Media extends Model 'created_at' => 'datetime:d-m-Y H:i', 'updated_at' => 'datetime:d-m-Y H:i', 'metadata' => 'array', + 'alt' => 'string', ]; protected $appends = [ @@ -40,6 +64,13 @@ class Media extends Model 'src', ]; + public function getTranslatableAttributes(): array + { + return [ + 'alt', + ]; + } + public function getRouteKeyName(): string { return 'ulid'; @@ -68,7 +99,7 @@ protected static function booted(): void if ($tenantRelationship && class_exists($tenantModel)) { $currentTenant = Filament::getTenant(); - if ($currentTenant) { + if ($currentTenant && property_exists($currentTenant, 'ulid')) { $model->{$tenantRelationship . '_ulid'} = $currentTenant->ulid; } } @@ -108,8 +139,22 @@ public function getHumanReadableSizeAttribute(): string return round($bytes, 2) . ' ' . $units[$i]; } + protected static $srcResolver; + + public static function resolveSrcUsing(callable $callback): void + { + static::$srcResolver = $callback; + } + public function getSrcAttribute(): string { + if (static::$srcResolver) { + $resolved = call_user_func(static::$srcResolver, $this); + if ($resolved) { + return $resolved; + } + } + $disk = Config::get('backstage.media.disk', 'public'); $directory = Config::get('backstage.media.directory', 'media'); diff --git a/packages/media/src/Pages/Media/Library.php b/packages/media/src/Pages/Media/Library.php index c4b40b3a..d33712bc 100644 --- a/packages/media/src/Pages/Media/Library.php +++ b/packages/media/src/Pages/Media/Library.php @@ -1,3 +1,4 @@ + getNavigationLabel() ?? Str::title(static::getPluralModelLabel()) ?? Str::title(static::getModelLabel()); + return MediaPlugin::get()->getNavigationLabel() ?? __('Media Library'); } public static function getNavigationIcon(): string diff --git a/packages/media/src/Resources/MediaResource.php b/packages/media/src/Resources/MediaResource.php index 7980de67..f0ee43e4 100644 --- a/packages/media/src/Resources/MediaResource.php +++ b/packages/media/src/Resources/MediaResource.php @@ -2,18 +2,36 @@ namespace Backstage\Media\Resources; -use Backstage\Media\Components\Media; use Backstage\Media\MediaPlugin; -use Backstage\Media\Resources\MediaResource\CreateMedia; use Backstage\Media\Resources\MediaResource\EditMedia; use Backstage\Media\Resources\MediaResource\ListMedia; +use Backstage\Media\Support\FileIcons; +use Backstage\Translations\Laravel\Contracts\TranslatesAttributes; +use Backstage\Translations\Laravel\Facades\Translator; +use Backstage\Translations\Laravel\Models\Language; +use Filament\Actions\Action; use Filament\Actions\DeleteAction; use Filament\Actions\DeleteBulkAction; +use Filament\Actions\EditAction; +use Filament\Actions\ViewAction; use Filament\Facades\Filament; +use Filament\Forms\Components\KeyValue; +use Filament\Forms\Components\TextInput; +use Filament\Infolists\Components\IconEntry; +use Filament\Infolists\Components\ImageEntry; +use Filament\Infolists\Components\KeyValueEntry; +use Filament\Infolists\Components\TextEntry; use Filament\Resources\Resource; +use Filament\Schemas\Components\Section; +use Filament\Schemas\Components\Tabs; +use Filament\Schemas\Components\Utilities\Get; +use Filament\Schemas\Components\Utilities\Set; use Filament\Schemas\Schema; +use Filament\Support\Icons\Heroicon; use Filament\Tables\Columns\IconColumn; +use Filament\Tables\Columns\ImageColumn; use Filament\Tables\Columns\TextColumn; +use Filament\Tables\Columns\TextInputColumn; use Filament\Tables\Table; use Illuminate\Support\Str; @@ -46,7 +64,7 @@ public static function getPluralModelLabel(): string public static function getNavigationLabel(): string { - return MediaPlugin::get()->getNavigationLabel() ?? Str::title(static::getPluralModelLabel()) ?? Str::title(static::getModelLabel()); + return MediaPlugin::get()->getNavigationLabel() ?: (Str::title(static::getPluralModelLabel()) ?: Str::title(static::getModelLabel())); } public static function getNavigationIcon(): string @@ -71,12 +89,16 @@ public static function getNavigationBadge(): ?string } if (Filament::hasTenancy() && config('backstage.media.is_tenant_aware')) { - return static::getEloquentQuery() - ->where(config('backstage.media.tenant_relationship') . '_ulid', Filament::getTenant()->id) + $tenant = Filament::getTenant(); + $tenantId = $tenant && property_exists($tenant, 'id') ? $tenant->id : null; + $count = static::getEloquentQuery() + ->where(config('backstage.media.tenant_relationship') . '_ulid', $tenantId) ->count(); + + return (string) $count; } - return number_format(static::getModel()::count()); + return (string) static::getModel()::count(); } public static function shouldRegisterNavigation(): bool @@ -86,10 +108,44 @@ public static function shouldRegisterNavigation(): bool public static function form(Schema $schema): Schema { + $fieldClass = config('backstage.cms.default_file_upload_field', \Backstage\Fields\Fields\FileUpload::class); + + $field = $fieldClass::make('media') + ->label(__('File(s)')) + ->multiple() + ->required() + ->columnSpanFull(); + + // Apply FileUpload-specific methods only if they exist + if (method_exists($field, 'disk')) { + $field->disk(config('backstage.media.disk')); + } + + if (method_exists($field, 'directory')) { + $field->directory(config('backstage.media.directory')); + } + + if (method_exists($field, 'preserveFilenames')) { + $field->preserveFilenames(config('backstage.media.should_preserve_filenames')); + } + + if (method_exists($field, 'visibility')) { + $field->visibility(config('backstage.media.visibility')); + } + + // Apply acceptedFileTypes if the method exists + if (method_exists($field, 'acceptedFileTypes') && config('backstage.media.accepted_file_types')) { + $field->acceptedFileTypes(config('backstage.media.accepted_file_types')); + } + + if (method_exists($field, 'storeFileNamesIn')) { + $field->storeFileNamesIn('original_filenames'); + } + return $schema ->components([ - Media::make() - ->required(), + $field, + \Filament\Forms\Components\Hidden::make('original_filenames'), ]); } @@ -97,11 +153,11 @@ public static function table(Table $table): Table { return $table ->columns([ + ImageColumn::make('src') + ->label(__('Preview')) + ->imageHeight(50) + ->getStateUsing(fn ($record) => str_starts_with($record->mime_type ?? '', 'image/') ? $record->src : FileIcons::getPlaceholderUrl($record->mime_type)), TextColumn::make('original_filename') - ->label(__('Original Filename')) - ->searchable() - ->sortable(), - TextColumn::make('filename') ->label(__('Filename')) ->searchable() ->sortable(), @@ -111,11 +167,29 @@ public static function table(Table $table): Table ->sortable(), IconColumn::make('public') ->boolean() + ->alignCenter() ->label(__('Public')) ->sortable(), ]) ->recordActions([ + ViewAction::make() + ->hiddenLabel() + ->tooltip(__('View')) + ->slideOver() + ->modalHeading(fn ($record) => $record->original_filename) + ->schema([ + ...self::getFormSchema(), + ]), + EditAction::make() + ->hiddenLabel() + ->tooltip(__('Edit')) + ->slideOver() + ->modalHeading(fn ($record) => $record->original_filename) + ->url(false) + ->fillForm(fn ($record) => self::getEditFormData($record)) + ->action(fn (array $data, $record) => self::saveEditForm($data, $record)) + ->schema(fn () => self::getEditFormSchema()), DeleteAction::make() ->hiddenLabel() ->tooltip(__('Delete')), @@ -129,11 +203,561 @@ public static function table(Table $table): Table ->recordUrl(false); } + public static function getFormSchema(): array + { + $schema = [ + Section::make(__('File Preview')) + ->schema([ + ImageEntry::make('src') + ->label(__('Preview')) + ->hiddenLabel() + ->imageHeight(200) + ->state(fn ($record) => str_starts_with($record->mime_type ?? '', 'image/') ? $record->src : FileIcons::getPlaceholderUrl($record->mime_type)), + TextEntry::make('src') + ->label(__('File URL')) + ->copyable() + ->url(fn ($state) => $state) + ->openUrlInNewTab(), + ]), + + Section::make(__('File Information')) + ->schema([ + TextEntry::make('original_filename') + ->label(__('Original Filename')) + ->copyable(), + TextEntry::make('filename') + ->label(__('Filename')) + ->copyable(), + TextEntry::make('extension') + ->label(__('Extension')) + ->badge(), + TextEntry::make('mime_type') + ->label(__('MIME Type')) + ->badge(), + TextEntry::make('size') + ->label(__('File Size')) + ->formatStateUsing(function ($state) { + if (! $state) { + return null; + } + + $units = ['B', 'KB', 'MB', 'GB', 'TB']; + $bytes = (int) $state; + + for ($i = 0; $bytes > 1024 && $i < count($units) - 1; $i++) { + $bytes /= 1024; + } + + return round($bytes, 2) . ' ' . $units[$i]; + }), + IconEntry::make('public') + ->label(__('Public')) + ->boolean(), + ]) + ->columns(2), + + Section::make(__('Alt Text')) + ->schema(function () { + try { + $languages = Language::all(); + if ($languages->isEmpty()) { + return [ + TextEntry::make('alt') + ->label(__('Alt Text')) + ->placeholder(__('No alt text set')) + ->columnSpanFull(), + ]; + } + + $defaultLanguage = $languages->firstWhere('default', true); + $otherLanguages = $languages->where('default', false); + $entries = []; + + // Add default language + if ($defaultLanguage) { + $code = $defaultLanguage->code; + $entries[] = TextEntry::make("alt_default_{$code}") + ->label(__('Alt Text') . ' (' . strtoupper($code) . ')') + ->state(function ($record) use ($code) { + if (method_exists($record, 'getTranslatedAttribute')) { + return $record->getTranslatedAttribute('alt', $code) ?? ''; + } + + return $record->alt ?? ''; + }) + ->icon(country_flag($code)) + ->placeholder(__('No alt text set')) + ->columnSpanFull(); + } + + // Add other languages + foreach ($otherLanguages as $language) { + $code = $language->code; + $entries[] = TextEntry::make("alt_lang_{$code}") + ->label(__('Alt Text') . ' (' . strtoupper($code) . ')') + ->state(function ($record) use ($code) { + if (method_exists($record, 'getTranslatedAttribute')) { + return $record->getTranslatedAttribute('alt', $code) ?? ''; + } + + return ''; + }) + ->icon(country_flag($code)) + ->placeholder(__('No translation')) + ->columnSpanFull(); + } + + return $entries; + } catch (\Exception $e) { + return [ + TextEntry::make('alt') + ->label(__('Alt Text')) + ->placeholder(__('No alt text set')) + ->columnSpanFull(), + ]; + } + }) + ->collapsible(), + + Section::make(__('Technical Details')) + ->schema([ + TextEntry::make('disk') + ->label(__('Storage Disk')) + ->badge(), + TextEntry::make('checksum') + ->label(__('Checksum')) + ->copyable() + ->visible(fn ($record) => $record && $record->checksum), + TextEntry::make('width') + ->label(__('Width')) + ->visible(fn ($record) => $record && $record->width) + ->suffix('px'), + TextEntry::make('height') + ->label(__('Height')) + ->visible(fn ($record) => $record && $record->height) + ->suffix('px'), + TextEntry::make('created_at') + ->label(__('Created At')) + ->dateTime(), + TextEntry::make('updated_at') + ->label(__('Updated At')) + ->dateTime(), + ]) + ->columns(2) + ->collapsible(), + + Section::make(__('Metadata')) + ->schema([ + KeyValueEntry::make('metadata') + ->label(__('Metadata')) + ->hiddenLabel() + ->state(fn ($record) => self::formatMetadataForKeyValueEntry($record->metadata ?? null)) + ->visible(fn ($record) => $record && $record->metadata) + ->columnSpanFull(), + ]) + ->collapsible(), + ]; + + return $schema; + } + + private static function getEditFormSchema(): array + { + // Build alt text fields + $altTextFields = []; + + try { + $languages = Language::all(); + + if (! $languages->isEmpty()) { + $defaultLanguage = $languages->firstWhere('default', true); + $otherLanguages = $languages->where('default', false); + + // Add default language alt text + if ($defaultLanguage) { + $altTextFields[] = TextInput::make('alt') + ->label(__('Alt Text') . ' (' . strtoupper($defaultLanguage->code) . ')') + ->prefixIcon(country_flag($defaultLanguage->code), true) + ->helperText(__('The alt text for the media in the default language. We can automatically translate this to other languages using AI.')) + ->columnSpanFull(); + } + + // Add other languages + foreach ($otherLanguages as $language) { + $altTextFields[] = TextInput::make('alt_text_' . $language->code) + ->label(__('Alt Text') . ' (' . strtoupper($language->code) . ')') + ->suffixActions([ + Action::make('translate_from_default') + ->visible(config('services.openai.api_key') !== null && config('services.openai.api_key') !== '') + ->icon(Heroicon::OutlinedLanguage) + ->tooltip(__('Translate from default language')) + ->action(function (Get $get, Set $set) use ($language) { + $defaultAlt = $get('alt'); + if ($defaultAlt) { + $translator = Translator::translate($defaultAlt, $language->code); + $set('alt_text_' . $language->code, $translator); + } + }), + ], true) + ->prefixIcon(country_flag($language->code), true) + ->columnSpanFull(); + } + } else { + // No languages configured, just add simple alt field + $altTextFields[] = TextInput::make('alt') + ->label(__('Alt Text')) + ->columnSpanFull(); + } + } catch (\Exception $e) { + // Fallback to simple alt field if languages can't be loaded + $altTextFields[] = TextInput::make('alt') + ->label(__('Alt Text')) + ->columnSpanFull(); + } + + return [ + Section::make() + ->schema([ + ImageEntry::make('src') + ->label(__('Preview')) + ->hiddenLabel() + ->imageHeight(200) + ->alignment('center') + ->state(fn ($record) => str_starts_with($record->mime_type ?? '', 'image/') ? $record->src : FileIcons::getPlaceholderUrl($record->mime_type)), + ]) + ->columnSpanFull(), + + Tabs::make('Edit Media') + ->tabs([ + Tabs\Tab::make(__('File Info')) + ->icon('heroicon-o-document') + ->schema([ + TextInput::make('original_filename') + ->label(__('Original Filename')) + ->required() + ->maxLength(255) + ->columnSpanFull(), + ]), + + Tabs\Tab::make(__('Alt Text')) + ->icon('heroicon-o-language') + ->schema($altTextFields), + + Tabs\Tab::make(__('Metadata')) + ->icon('heroicon-o-code-bracket') + ->schema([ + KeyValue::make('metadata') + ->label(__('Metadata')) + ->disabled() + ->dehydrated(false) + ->formatStateUsing(fn ($state) => self::formatMetadataForKeyValueEntry($state)) + ->columnSpanFull(), + ]), + ]) + ->columnSpanFull(), + ]; + } + + private static function getEditFormData($record): array + { + $data = [ + 'original_filename' => $record->original_filename, + 'alt' => $record->alt ?? '', + 'metadata' => $record->metadata ?? [], + ]; + + // Load translations if supported + if ($record instanceof TranslatesAttributes) { + $languages = Language::all(); + $defaultLanguage = $languages->firstWhere('default', true); + $otherLanguages = $languages->where('default', false); + + if ($defaultLanguage) { + $data['alt'] = $record->getTranslatedAttribute('alt', $defaultLanguage->code) ?? ''; + } + + foreach ($otherLanguages as $language) { + $data['alt_text_' . $language->code] = $record->getTranslatedAttribute('alt', $language->code) ?? ''; + } + } + + return $data; + } + + /** + * @param \Backstage\Media\Models\Media $record + */ + private static function saveEditForm(array $data, $record): void + { + // Update basic fields + $updateData = [ + 'original_filename' => $data['original_filename'], + ]; + + // Check if model supports translations + if ($record instanceof TranslatesAttributes) { + // Model has translation support + $record->updateQuietly($updateData); + + try { + $languages = Language::all(); + $defaultLanguage = $languages->firstWhere('default', true); + $otherLanguages = $languages->where('default', false); + + // Save default language translation + if ($defaultLanguage && isset($data['alt'])) { + $record->updateQuietly(['alt' => $data['alt']]); + $record->pushTranslateAttribute('alt', $data['alt'], $defaultLanguage->code); + } + + // Save other language translations + foreach ($otherLanguages as $language) { + $key = 'alt_text_' . $language->code; + if (isset($data[$key])) { + $record->pushTranslateAttribute('alt', $data[$key], $language->code); + } + } + } catch (\Exception $e) { + // + } + } else { + // Model doesn't support translations - update alt directly + $updateData['alt'] = $data['alt'] ?? ''; + $record->updateQuietly($updateData); + } + } + + private static function formatMetadataForKeyValueEntry(mixed $state): array + { + if (! $state) { + return []; + } + + // Decode JSON string if needed + if (is_string($state)) { + $decoded = json_decode($state, true); + $state = (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) ? $decoded : []; + } + + if (! is_array($state) || empty($state)) { + return []; + } + + // Flatten nested structures to dot notation with all values as strings + $flattened = []; + $flatten = function ($data, $prefix = '') use (&$flatten, &$flattened) { + if (! is_array($data) && ! is_object($data)) { + return; + } + + foreach ($data as $key => $value) { + $newKey = $prefix ? "{$prefix}.{$key}" : $key; + + if (is_array($value) || is_object($value)) { + $valueArray = (array) $value; + + // Convert indexed arrays to JSON, recursively flatten associative arrays + if (self::isIndexedArray($valueArray)) { + $flattened[$newKey] = json_encode($valueArray, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + } else { + $flatten($valueArray, $newKey); + } + } else { + $flattened[$newKey] = self::valueToString($value); + } + } + }; + + $flatten($state); + + // Final safety pass: ensure all values are strings + return array_map(fn ($value) => self::valueToString($value), $flattened); + } + + private static function isIndexedArray(array $array): bool + { + if (empty($array)) { + return true; + } + + $keys = array_keys($array); + foreach ($keys as $idx => $key) { + if (! is_int($key) || $key !== $idx) { + return false; + } + } + + return true; + } + + private static function valueToString(mixed $value): string + { + return match (true) { + is_string($value) => $value, + is_null($value) => '', + is_bool($value) => $value ? 'true' : 'false', + is_scalar($value) => (string) $value, + default => json_encode($value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), + }; + } + + public static function tableDatabase(Table $table): Table + { + $columns = [ + ImageColumn::make('src') + ->label(__('Preview')) + ->imageHeight(50) + ->getStateUsing(fn ($record) => str_starts_with($record->mime_type ?? '', 'image/') ? $record->src : FileIcons::getPlaceholderUrl($record->mime_type)), + TextInputColumn::make('original_filename') + ->label(__('Filename')) + ->searchable() + ->sortable() + ->updateStateUsing(function ($record, ?string $state) { + if ($state === null) { + return; + } + $record->update(['original_filename' => $state]); + }), + TextColumn::make('extension') + ->label(__('Extension')) + ->searchable() + ->sortable(), + IconColumn::make('public') + ->boolean() + ->label(__('Public')) + ->sortable(), + ]; + + // Add alt text columns for each language + try { + $languages = Language::all(); + if (! $languages->isEmpty()) { + foreach ($languages as $language) { + $code = $language->code; + $isDefault = $language->default; + + $columns[] = TextInputColumn::make("alt_{$code}") + ->label(__('Alt Text') . ' (' . strtoupper($code) . ')') + ->getStateUsing(function ($record) use ($code) { + if ($record instanceof TranslatesAttributes) { + if ($translated = $record->translatableAttributes()->where('attribute', 'alt')->where('code', $code)->first()) { + return $translated?->translated_attribute; + } + + return null; + } + + return $record->alt ?? null; + }) + ->updateStateUsing(function ($record, ?string $state) use ($code, $isDefault) { + if ($state === null || $code === null) { + return; + } + if ($record instanceof TranslatesAttributes) { + if ($isDefault) { + $record->updateQuietly(['alt' => $state]); + } + $record = $record->pushTranslateAttribute('alt', $state, $code); + + return; + } + + $record->update(['alt' => $state]); + }) + ->searchable(); + } + } else { + // No languages configured, add simple alt column + $columns[] = TextInputColumn::make('alt') + ->label(__('Alt Text')) + ->updateStateUsing(function ($record, ?string $state) { + if ($state === null) { + return; + } + $record->update(['alt' => $state]); + }) + ->searchable(); + } + } catch (\Exception $e) { + // Fallback to simple alt column if languages can't be loaded + $columns[] = TextInputColumn::make('alt') + ->label(__('Alt Text')) + ->updateStateUsing(function ($record, ?string $state) { + if ($state === null) { + return; + } + $record->update(['alt' => $state]); + }) + ->searchable(); + } + + return $table + ->columns($columns) + ->defaultSort('created_at', 'desc') + ->defaultPaginationPageOption(12) + ->paginationPageOptions([6, 12, 24, 48, 'all']) + ->recordUrl(false); + } + + public static function tableGrid(Table $table): Table + { + return $table + ->columns([ + \Filament\Tables\Columns\Layout\Stack::make([ + ImageColumn::make('src') + ->imageHeight('100%') + ->width('100%') + ->extraImgAttributes(['class' => 'object-cover w-full h-full aspect-square rounded-t-xl']) + ->getStateUsing(fn ($record) => str_starts_with($record->mime_type ?? '', 'image/') ? $record->src : FileIcons::getPlaceholderUrl($record->mime_type)), + \Filament\Tables\Columns\Layout\Stack::make([ + TextColumn::make('original_filename') + ->weight('bold') + ->searchable() + ->limit(20), + TextColumn::make('extension') + ->formatStateUsing(fn ($state) => strtoupper($state)) + ->color('gray') + ->limit(20), + ])->space(1)->extraAttributes(['class' => 'p-4']), + ])->space(0), + ]) + ->contentGrid([ + 'md' => 2, + 'xl' => 3, + '2xl' => 4, + ]) + ->defaultSort('created_at', 'desc') + ->defaultPaginationPageOption(12) + ->paginationPageOptions([6, 12, 24, 48, 'all']) + ->recordUrl(false) + ->recordActions([ + ViewAction::make() + ->hiddenLabel() + ->tooltip(__('View')) + ->slideOver() + ->modalHeading(fn ($record) => $record->original_filename) + ->schema([ + ...self::getFormSchema(), + ]), + EditAction::make() + ->hiddenLabel() + ->tooltip(__('Edit')) + ->slideOver() + ->modalHeading(fn ($record) => $record->original_filename) + ->url(false) + ->fillForm(fn ($record) => self::getEditFormData($record)) + ->action(fn (array $data, $record) => self::saveEditForm($data, $record)) + ->form(fn () => self::getEditFormSchema()), + DeleteAction::make() + ->hiddenLabel() + ->tooltip(__('Delete')), + ]); + } + public static function getPages(): array { return [ 'index' => ListMedia::route('/'), - 'create' => CreateMedia::route('/create'), 'edit' => EditMedia::route('/{record}/edit'), ]; } diff --git a/packages/media/src/Resources/MediaResource/CreateMedia.php b/packages/media/src/Resources/MediaResource/CreateMedia.php index a1218950..8baa1979 100644 --- a/packages/media/src/Resources/MediaResource/CreateMedia.php +++ b/packages/media/src/Resources/MediaResource/CreateMedia.php @@ -8,6 +8,7 @@ use Filament\Facades\Filament; use Filament\Resources\Pages\CreateRecord; use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Storage; class CreateMedia extends CreateRecord @@ -19,6 +20,8 @@ public static function getResource(): string public function handleRecordCreation(array $data): Model { + $firstMedia = null; + foreach ($data['media'] as $file) { // Get the full path on the configured disk $fullPath = Storage::disk(config('backstage.media.disk'))->path($file); @@ -53,22 +56,28 @@ public function handleRecordCreation(array $data): Model } } - $first = Media::create([ - 'site_ulid' => Filament::getTenant()->ulid, + $tenant = Filament::getTenant(); + + $media = Media::create([ + 'site_ulid' => $tenant && property_exists($tenant, 'ulid') ? $tenant->ulid : null, 'disk' => config('backstage.media.disk'), - 'uploaded_by' => auth()->id(), + 'uploaded_by' => Auth::id(), 'filename' => $filename, 'extension' => $extension, 'mime_type' => $mimeType, 'size' => $fileSize, 'width' => $fileInfo['width'] ?? null, 'height' => $fileInfo['height'] ?? null, + 'alt' => null, 'checksum' => md5_file($fullPath), - 'public' => config('backstage.media.visibility') === 'public', // TODO: Should be configurable in the form itself + 'public' => config('backstage.media.visibility') === 'public', ]); + + if ($firstMedia === null) { + $firstMedia = $media; + } } - return $first; - // return static::getModel()::create($data); + return $firstMedia ?? Media::first(); } } diff --git a/packages/media/src/Resources/MediaResource/EditMedia.php b/packages/media/src/Resources/MediaResource/EditMedia.php index ea17b6d5..81e5933a 100644 --- a/packages/media/src/Resources/MediaResource/EditMedia.php +++ b/packages/media/src/Resources/MediaResource/EditMedia.php @@ -23,7 +23,7 @@ public function getHeaderActions(): array Action::make('preview') ->label(__('Preview')) ->color('gray') - ->url($this->record->url, shouldOpenInNewTab: true), + ->url(fn () => (is_object($this->record) && property_exists($this->record, 'url')) ? $this->record->url : null, shouldOpenInNewTab: true), DeleteAction::make(), ]; } diff --git a/packages/media/src/Resources/MediaResource/ListMedia.php b/packages/media/src/Resources/MediaResource/ListMedia.php index 322a33dc..cd54bb55 100644 --- a/packages/media/src/Resources/MediaResource/ListMedia.php +++ b/packages/media/src/Resources/MediaResource/ListMedia.php @@ -2,12 +2,27 @@ namespace Backstage\Media\Resources\MediaResource; +use Backstage\Media\Events\MediaUploading; use Backstage\Media\MediaPlugin; -use Filament\Actions\CreateAction; +use Backstage\Media\Models\Media; +use Backstage\Media\Resources\MediaResource; +use Exception; +use Filament\Actions\Action; +use Filament\Actions\ActionGroup; +use Filament\Facades\Filament; +use Filament\Notifications\Notification; use Filament\Resources\Pages\ListRecords; +use Filament\Schemas\Schema; +use Filament\Tables\Table; +use Illuminate\Support\Facades\Event; +use Illuminate\Support\Facades\Storage; class ListMedia extends ListRecords { + public ?string $show = 'list'; + + protected $queryString = ['show']; + public static function getResource(): string { return MediaPlugin::get()->getResource(); @@ -16,7 +31,195 @@ public static function getResource(): string public function getHeaderActions(): array { return [ - CreateAction::make(), + ActionGroup::make([ + Action::make('list_view') + ->label(__('List')) + ->icon('heroicon-o-bars-3') + ->url(fn (): string => route('filament.backstage.resources.media.index', ['show' => 'list', 'tenant' => Filament::getTenant()])), + // Action::make('grid_view') + // ->label(__('Grid')) + // ->icon('heroicon-o-squares-2x2') + // ->url(fn (): string => route('filament.backstage.resources.media.index', ['show' => 'grid', 'tenant' => Filament::getTenant()])), + Action::make('database_view') + ->label(__('Database')) + ->icon('heroicon-o-circle-stack') + ->url(fn (): string => route('filament.backstage.resources.media.index', ['show' => 'database', 'tenant' => Filament::getTenant()])), + ]) + ->label(__('View')) + ->icon('heroicon-m-eye') + ->color('gray') + ->button(), + + Action::make('upload') + ->label(__('Upload Media')) + ->icon('heroicon-o-plus') + ->modalHeading(__('Upload Media')) + ->schema(fn () => MediaResource::form(Schema::make())->getComponents()) + ->action(fn (array $data) => $this->handleMediaUpload($data)), + ]; + } + + public function table(Table $table): Table + { + if ($this->show === 'database') { + return MediaResource::tableDatabase($table); + } + + if ($this->show === 'grid') { + return MediaResource::tableGrid($table); + } + + return MediaResource::table($table); + } + + private function handleMediaUpload(array $data): void + { + $files = $this->normalizeFiles($data['media'] ?? []); + $filenames = $data['original_filenames'] ?? []; + + $this->processFiles($files, $filenames); + + Notification::make() + ->title(__('Media uploaded')) + ->body(__('The media has been uploaded successfully.')) + ->success() + ->send(); + } + + private function normalizeFiles(mixed $files): array + { + if (is_string($files)) { + $decoded = json_decode($files, true); + if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) { + return $decoded; + } + + return [$files]; + } + + return is_array($files) ? $files : []; + } + + private function processFiles(array $files, array $filenames): void + { + // Reset keys to ensure we can match by index + $filenames = array_values($filenames); + + foreach ($files as $key => $file) { + // Dispatch event to allow other packages to handle the file upload (e.g. Uploadcare) + $results = Event::dispatch(new MediaUploading($file)); + + $handled = false; + foreach ($results as $result) { + if ($result instanceof Media) { + $handled = true; + + break; + } + } + + if (! $handled) { + // Try to find original filename by index + $originalFilename = $filenames[$key] ?? null; + self::createMediaFromFileUpload($file, $originalFilename); + } + } + } + + private static function createMediaFromFileUpload(string $file, ?string $originalFilename = null): ?Media + { + try { + $disk = config('backstage.media.disk'); + $fullPath = Storage::disk($disk)->path($file); + + $fileMetadata = self::extractFileMetadata($file, $disk, $fullPath); + + // Set original filename + $fileMetadata['original_filename'] = $originalFilename ?? $fileMetadata['filename']; + + $mediaData = self::buildMediaDataFromFileUpload($fileMetadata, $disk); + $mediaData = self::addTenantToMediaData($mediaData); + + return Media::create($mediaData); + } catch (Exception $e) { + return null; + } + } + + private static function extractFileMetadata(string $file, string $disk, string $fullPath): array + { + $filename = basename($file); + $mimeType = Storage::disk($disk)->mimeType($file); + $fileSize = Storage::disk($disk)->size($file); + $extension = pathinfo($filename, PATHINFO_EXTENSION); + + $dimensions = self::extractImageDimensions($mimeType, $fullPath); + + return [ + 'filename' => $filename, + 'extension' => $extension, + 'mime_type' => $mimeType, + 'size' => $fileSize, + 'width' => $dimensions['width'], + 'height' => $dimensions['height'], + 'checksum' => md5_file($fullPath), + ]; + } + + private static function extractImageDimensions(string $mimeType, string $fullPath): array + { + $dimensions = ['width' => null, 'height' => null]; + + if (str_starts_with($mimeType, 'image/')) { + try { + $imageSize = getimagesize($fullPath); + $dimensions['width'] = $imageSize[0] ?? null; + $dimensions['height'] = $imageSize[1] ?? null; + } catch (Exception $e) { + // Ignore image size extraction errors + } + } + + return $dimensions; + } + + private static function buildMediaDataFromFileUpload(array $metadata, string $disk): array + { + return [ + 'disk' => $disk, + 'uploaded_by' => auth()->id(), + 'filename' => $metadata['filename'], + 'original_filename' => $metadata['original_filename'], + 'extension' => $metadata['extension'], + 'mime_type' => $metadata['mime_type'], + 'size' => $metadata['size'], + 'width' => $metadata['width'], + 'height' => $metadata['height'], + 'alt' => null, + 'checksum' => $metadata['checksum'], + 'public' => config('backstage.media.visibility') === 'public', ]; } + + private static 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; + } } diff --git a/packages/media/src/Support/FileIcons.php b/packages/media/src/Support/FileIcons.php new file mode 100644 index 00000000..a7bac28f --- /dev/null +++ b/packages/media/src/Support/FileIcons.php @@ -0,0 +1,143 @@ + self::getPdfPlaceholder(), + 'application/zip', 'application/x-zip-compressed', 'application/x-7z-compressed', 'application/x-rar-compressed' => self::getArchivePlaceholder(), + 'application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'text/csv' => self::getSpreadsheetPlaceholder(), + 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' => self::getDocumentPlaceholder(), + 'application/vnd.ms-powerpoint', 'application/vnd.openxmlformats-officedocument.presentationml.presentation' => self::getPresentationPlaceholder(), + 'application/json', 'application/xml', 'text/html', 'text/css', 'text/javascript' => self::getCodePlaceholder(), + default => self::getDefaultPlaceholder(), + }; + } + + public static function getPdfPlaceholder(): string + { + $svg = <<<'SVG' + + + + +SVG; + + return 'data:image/svg+xml;base64,' . base64_encode($svg); + } + + public static function getVideoPlaceholder(): string + { + $svg = <<<'SVG' + + + +SVG; + + return 'data:image/svg+xml;base64,' . base64_encode($svg); + } + + public static function getAudioPlaceholder(): string + { + $svg = <<<'SVG' + + + + + +SVG; + + return 'data:image/svg+xml;base64,' . base64_encode($svg); + } + + public static function getArchivePlaceholder(): string + { + $svg = <<<'SVG' + + + + +SVG; + + return 'data:image/svg+xml;base64,' . base64_encode($svg); + } + + public static function getSpreadsheetPlaceholder(): string + { + $svg = <<<'SVG' + + + +SVG; + + return 'data:image/svg+xml;base64,' . base64_encode($svg); + } + + public static function getPresentationPlaceholder(): string + { + $svg = <<<'SVG' + + + +SVG; + + return 'data:image/svg+xml;base64,' . base64_encode($svg); + } + + public static function getDocumentPlaceholder(): string + { + $svg = <<<'SVG' + + + +SVG; + + return 'data:image/svg+xml;base64,' . base64_encode($svg); + } + + public static function getCodePlaceholder(): string + { + $svg = <<<'SVG' + + + +SVG; + + return 'data:image/svg+xml;base64,' . base64_encode($svg); + } + + public static function getTextPlaceholder(): string + { + return self::getDocumentPlaceholder(); + } + + public static function getDefaultPlaceholder(): string + { + $svg = <<<'SVG' + + + +SVG; + + return 'data:image/svg+xml;base64,' . base64_encode($svg); + } +} diff --git a/packages/media/tests/TestCase.php b/packages/media/tests/TestCase.php index e4fca905..70bb3904 100644 --- a/packages/media/tests/TestCase.php +++ b/packages/media/tests/TestCase.php @@ -20,6 +20,8 @@ class TestCase extends Orchestra { + protected static $latestResponse; + protected function setUp(): void { parent::setUp(); diff --git a/packages/uploadcare-field/README.md b/packages/uploadcare-field/README.md index f0364457..3c643032 100644 --- a/packages/uploadcare-field/README.md +++ b/packages/uploadcare-field/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/packages/uploadcare-field/database/migrations/2025_12_08_163311_normalize_uploadcare_values_to_ulids.php b/packages/uploadcare-field/database/migrations/2025_12_08_163311_normalize_uploadcare_values_to_ulids.php new file mode 100644 index 00000000..8a7a3aab --- /dev/null +++ b/packages/uploadcare-field/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/packages/uploadcare-field/package-lock.json b/packages/uploadcare-field/package-lock.json new file mode 100644 index 00000000..8c4e35d2 --- /dev/null +++ b/packages/uploadcare-field/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/packages/uploadcare-field/package.json b/packages/uploadcare-field/package.json new file mode 100644 index 00000000..3c905662 --- /dev/null +++ b/packages/uploadcare-field/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/packages/uploadcare-field/resources/css/index.css b/packages/uploadcare-field/resources/css/index.css new file mode 100644 index 00000000..27dc141c --- /dev/null +++ b/packages/uploadcare-field/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/packages/uploadcare-field/resources/dist/uploadcare-field.css b/packages/uploadcare-field/resources/dist/uploadcare-field.css new file mode 100644 index 00000000..e48fbd25 --- /dev/null +++ b/packages/uploadcare-field/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/packages/uploadcare-field/resources/views/forms/components/media-grid-picker.blade.php b/packages/uploadcare-field/resources/views/forms/components/media-grid-picker.blade.php new file mode 100644 index 00000000..8292c9df --- /dev/null +++ b/packages/uploadcare-field/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/packages/uploadcare-field/resources/views/livewire/media-grid-picker.blade.php b/packages/uploadcare-field/resources/views/livewire/media-grid-picker.blade.php new file mode 100644 index 00000000..99837c9f --- /dev/null +++ b/packages/uploadcare-field/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/packages/uploadcare-field/src/Forms/Components/MediaGridPicker.php b/packages/uploadcare-field/src/Forms/Components/MediaGridPicker.php new file mode 100644 index 00000000..453c945e --- /dev/null +++ b/packages/uploadcare-field/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/packages/uploadcare-field/src/Listeners/CreateMediaFromUploadcare.php b/packages/uploadcare-field/src/Listeners/CreateMediaFromUploadcare.php new file mode 100644 index 00000000..fca66ddb --- /dev/null +++ b/packages/uploadcare-field/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/packages/uploadcare-field/src/Livewire/MediaGridPicker.php b/packages/uploadcare-field/src/Livewire/MediaGridPicker.php new file mode 100644 index 00000000..fc2a8918 --- /dev/null +++ b/packages/uploadcare-field/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/packages/uploadcare-field/src/Observers/ContentFieldValueObserver.php b/packages/uploadcare-field/src/Observers/ContentFieldValueObserver.php new file mode 100644 index 00000000..999f905e --- /dev/null +++ b/packages/uploadcare-field/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/packages/uploadcare-field/src/Uploadcare.php b/packages/uploadcare-field/src/Uploadcare.php index 24bdf113..37844d77 100755 --- a/packages/uploadcare-field/src/Uploadcare.php +++ b/packages/uploadcare-field/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/packages/uploadcare-field/src/UploadcareFieldServiceProvider.php b/packages/uploadcare-field/src/UploadcareFieldServiceProvider.php index 6117f704..e8dce28d 100644 --- a/packages/uploadcare-field/src/UploadcareFieldServiceProvider.php +++ b/packages/uploadcare-field/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/packages/uploadcare-field/tailwind.config.js b/packages/uploadcare-field/tailwind.config.js new file mode 100644 index 00000000..8db9ef8d --- /dev/null +++ b/packages/uploadcare-field/tailwind.config.js @@ -0,0 +1,11 @@ +export default { + darkMode: 'selector', + content: [ + './src/**/*.php', + './resources/views/**/*.blade.php', + ], + theme: { + extend: {}, + }, + plugins: [], +} diff --git a/packages/uploadcare-field/tests/MediaGridPickerTest.php b/packages/uploadcare-field/tests/MediaGridPickerTest.php new file mode 100644 index 00000000..3f170502 --- /dev/null +++ b/packages/uploadcare-field/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, + ]); + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php index 45d3cfda..f486ec34 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -53,6 +53,11 @@ public function defineEnvironment($app) $app['config']->set(pathinfo($filename)['filename'], require $filename); } + foreach (glob(__DIR__ . '/../config/*/*.php') as $filename) { + $key = basename(dirname($filename)) . '.' . pathinfo($filename)['filename']; + $app['config']->set($key, require $filename); + } + } protected function setUp(): void From 804afba4244cb63db3078a96ecfad3c945773af6 Mon Sep 17 00:00:00 2001 From: Baspa Date: Fri, 12 Dec 2025 14:08:57 +0100 Subject: [PATCH 02/43] fix: nested arrays may not be passed to `whereIn` --- packages/uploadcare-field/src/Uploadcare.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/uploadcare-field/src/Uploadcare.php b/packages/uploadcare-field/src/Uploadcare.php index 37844d77..864afa9e 100755 --- a/packages/uploadcare-field/src/Uploadcare.php +++ b/packages/uploadcare-field/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 abeca616cf521d56a414f0405c9639d91f4e89b3 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/43] fix: styling --- packages/uploadcare-field/src/Uploadcare.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/uploadcare-field/src/Uploadcare.php b/packages/uploadcare-field/src/Uploadcare.php index 864afa9e..bcc9c5ac 100755 --- a/packages/uploadcare-field/src/Uploadcare.php +++ b/packages/uploadcare-field/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 5b62dfeb04327a5cab9c3ea4b53d7d1b7e557fd7 Mon Sep 17 00:00:00 2001 From: Baspa Date: Tue, 16 Dec 2025 18:25:10 +0100 Subject: [PATCH 04/43] fix: reordering builders --- packages/core/src/CustomFields/Builder.php | 28 +++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/packages/core/src/CustomFields/Builder.php b/packages/core/src/CustomFields/Builder.php index f749ef5c..4a66fb02 100644 --- a/packages/core/src/CustomFields/Builder.php +++ b/packages/core/src/CustomFields/Builder.php @@ -37,7 +37,33 @@ public static function make(string $name, ?Field $field = null): Input ->collapsible() ->blocks( self::getBlockOptions() - ), + ) + ->reorderAction(function ($action) { + return $action->action(function (array $arguments, Input $component): void { + $currentState = $component->getRawState(); + $newOrder = $arguments['items']; + + $reorderedItems = []; + + foreach ($newOrder as $key) { + if (isset($currentState[$key])) { + $reorderedItems[$key] = $currentState[$key]; + } + } + + foreach ($currentState as $key => $value) { + if (! array_key_exists($key, $reorderedItems)) { + $reorderedItems[$key] = $value; + } + } + + $component->rawState($reorderedItems); + + $component->callAfterStateUpdated(); + + $component->shouldPartiallyRenderAfterActionsCalled() ? $component->partiallyRender() : null; + }); + }), $field ); From 78bf97bfc0019f6f64fee3c8d722dc99c7617dab Mon Sep 17 00:00:00 2001 From: Baspa Date: Wed, 17 Dec 2025 16:32:44 +0100 Subject: [PATCH 05/43] fix: repair `uc` relationships, improve retreiving uuids --- composer.json | 4 +- .../src/Forms/Components/Uploadcare.php | 118 ++++++ ..._repair_uploadcare_media_relationships.php | 349 ++++++++++++++++++ .../Listeners/CreateMediaFromUploadcare.php | 4 +- .../src/Livewire/MediaGridPicker.php | 18 +- .../Observers/ContentFieldValueObserver.php | 99 ++--- packages/uploadcare-field/src/Uploadcare.php | 247 +++++++++++-- .../src/UploadcareFieldServiceProvider.php | 1 + 8 files changed, 729 insertions(+), 111 deletions(-) create mode 100644 packages/uploadcare-field/database/migrations/2025_12_17_000001_repair_uploadcare_media_relationships.php diff --git a/composer.json b/composer.json index 44f11f74..17320102 100644 --- a/composer.json +++ b/composer.json @@ -74,6 +74,7 @@ "Backstage\\Fields\\": "packages/fields/src/", "Backstage\\Fields\\Database\\Factories\\": "packages/fields/database/factories/", "Backstage\\Media\\": "packages/media/src/", + "Backstage\\UploadcareField\\": "packages/uploadcare-field/src/", "Backstage\\Database\\Factories\\": "database/factories/", "Backstage\\Database\\Seeders\\": "database/seeders/" }, @@ -83,7 +84,8 @@ }, "autoload-dev": { "psr-4": { - "Backstage\\Tests\\": "tests/" + "Backstage\\Tests\\": "tests/", + "Backstage\\UploadcareField\\Tests\\": "packages/uploadcare-field/tests/" } }, "scripts": { diff --git a/packages/filament-uploadcare-field/src/Forms/Components/Uploadcare.php b/packages/filament-uploadcare-field/src/Forms/Components/Uploadcare.php index 9e0ec37c..81f9d175 100644 --- a/packages/filament-uploadcare-field/src/Forms/Components/Uploadcare.php +++ b/packages/filament-uploadcare-field/src/Forms/Components/Uploadcare.php @@ -282,6 +282,28 @@ public function getState(): mixed { $state = parent::getState(); + // Handle double-encoded JSON or JSON strings + if (is_string($state) && json_validate($state)) { + $state = json_decode($state, true); + } + + // Resolve Backstage Media ULIDs (26-char) into Uploadcare CDN URLs / UUIDs, + // so the widget can show a preview even when the database stores ULIDs. + if (is_array($state) && ! empty($state) && self::isListOfUlids($state)) { + $resolved = self::resolveUlidsToUploadcareState($state); + if (! empty($resolved)) { + $state = $resolved; + } + } + + // Handle array of file objects (extract UUIDs / URLs) + if (is_array($state) && ! empty($state)) { + $values = self::extractValues($state); + if (! empty($values)) { + $state = $values; + } + } + if ($state === '[]' || $state === '""' || $state === null || $state === '') { return null; } @@ -298,6 +320,102 @@ public function getState(): mixed return $state; } + private static function isListOfUlids(array $state): bool + { + if (! isset($state[0]) || ! is_string($state[0])) { + return false; + } + + return (bool) preg_match('/^[0-9A-HJKMNP-TV-Z]{26}$/i', $state[0]); + } + + private static function extractUuidFromString(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; + } + + private static function resolveUlidsToUploadcareState(array $ulids): array + { + $mediaModel = config('backstage.media.model', \Backstage\Media\Models\Media::class); + + if (! is_string($mediaModel) || ! class_exists($mediaModel)) { + return []; + } + + $mediaItems = $mediaModel::whereIn('ulid', array_filter($ulids, 'is_string')) + ->get() + ->keyBy('ulid'); + + $resolved = []; + + foreach ($ulids as $ulid) { + if (! is_string($ulid)) { + continue; + } + + $media = $mediaItems->get($ulid); + if (! $media) { + continue; + } + + $metadata = $media->metadata ?? null; + if (is_string($metadata)) { + $metadata = json_decode($metadata, true); + } + $metadata = is_array($metadata) ? $metadata : []; + + $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); + + if (! $uuid && is_string($media->filename ?? null)) { + $uuid = self::extractUuidFromString($media->filename); + } + if (! $uuid && is_string($cdnUrl)) { + $uuid = self::extractUuidFromString($cdnUrl); + } + + if ((! $cdnUrl || ! filter_var($cdnUrl, FILTER_VALIDATE_URL)) && $uuid) { + $cdnUrl = 'https://ucarecdn.com/' . $uuid . '/'; + } + + if (is_string($cdnUrl) && filter_var($cdnUrl, FILTER_VALIDATE_URL)) { + $resolved[] = $cdnUrl; + } elseif ($uuid) { + $resolved[] = $uuid; + } + } + + return $resolved; + } + + private static function extractValues(array $state): array + { + $keys = ['cdnUrl', 'ucarecdn', 'uuid', 'filename']; + + foreach ($keys as $key) { + $values = \Illuminate\Support\Arr::pluck($state, $key); + $filtered = array_filter($values); + + if (! empty($filtered)) { + return array_values($filtered); + } + } + + return []; + } + private function transformUrls($value, string $from, string $to): mixed { $decodeIfJson = function ($v) use (&$decodeIfJson) { diff --git a/packages/uploadcare-field/database/migrations/2025_12_17_000001_repair_uploadcare_media_relationships.php b/packages/uploadcare-field/database/migrations/2025_12_17_000001_repair_uploadcare_media_relationships.php new file mode 100644 index 00000000..15df33ef --- /dev/null +++ b/packages/uploadcare-field/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/packages/uploadcare-field/src/Listeners/CreateMediaFromUploadcare.php b/packages/uploadcare-field/src/Listeners/CreateMediaFromUploadcare.php index fca66ddb..c6093358 100644 --- a/packages/uploadcare-field/src/Listeners/CreateMediaFromUploadcare.php +++ b/packages/uploadcare-field/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/packages/uploadcare-field/src/Livewire/MediaGridPicker.php b/packages/uploadcare-field/src/Livewire/MediaGridPicker.php index fc2a8918..ea70a7ad 100644 --- a/packages/uploadcare-field/src/Livewire/MediaGridPicker.php +++ b/packages/uploadcare-field/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/packages/uploadcare-field/src/Observers/ContentFieldValueObserver.php b/packages/uploadcare-field/src/Observers/ContentFieldValueObserver.php index 999f905e..4ebb0e94 100644 --- a/packages/uploadcare-field/src/Observers/ContentFieldValueObserver.php +++ b/packages/uploadcare-field/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/packages/uploadcare-field/src/Uploadcare.php b/packages/uploadcare-field/src/Uploadcare.php index bcc9c5ac..a0dfc836 100755 --- a/packages/uploadcare-field/src/Uploadcare.php +++ b/packages/uploadcare-field/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/packages/uploadcare-field/src/UploadcareFieldServiceProvider.php b/packages/uploadcare-field/src/UploadcareFieldServiceProvider.php index e8dce28d..8a312898 100644 --- a/packages/uploadcare-field/src/UploadcareFieldServiceProvider.php +++ b/packages/uploadcare-field/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 3a21def25a3d73f20f02114e282c29faac806d88 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 06/43] fix: styling --- .../src/Forms/Components/Uploadcare.php | 10 +++++----- ...1_repair_uploadcare_media_relationships.php | 3 +-- .../Observers/ContentFieldValueObserver.php | 2 +- packages/uploadcare-field/src/Uploadcare.php | 18 ++++++++---------- .../Resources/RoleResource/Pages/EditRole.php | 2 +- .../Resources/RoleResource/Pages/ListRoles.php | 2 +- .../Resources/RoleResource/Pages/ViewRole.php | 2 +- 7 files changed, 18 insertions(+), 21 deletions(-) diff --git a/packages/filament-uploadcare-field/src/Forms/Components/Uploadcare.php b/packages/filament-uploadcare-field/src/Forms/Components/Uploadcare.php index 81f9d175..2352e4e8 100644 --- a/packages/filament-uploadcare-field/src/Forms/Components/Uploadcare.php +++ b/packages/filament-uploadcare-field/src/Forms/Components/Uploadcare.php @@ -298,10 +298,10 @@ public function getState(): mixed // Handle array of file objects (extract UUIDs / URLs) if (is_array($state) && ! empty($state)) { - $values = self::extractValues($state); - if (! empty($values)) { - $state = $values; - } + $values = self::extractValues($state); + if (! empty($values)) { + $state = $values; + } } if ($state === '[]' || $state === '""' || $state === null || $state === '') { @@ -407,7 +407,7 @@ private static function extractValues(array $state): array foreach ($keys as $key) { $values = \Illuminate\Support\Arr::pluck($state, $key); $filtered = array_filter($values); - + if (! empty($filtered)) { return array_values($filtered); } diff --git a/packages/uploadcare-field/database/migrations/2025_12_17_000001_repair_uploadcare_media_relationships.php b/packages/uploadcare-field/database/migrations/2025_12_17_000001_repair_uploadcare_media_relationships.php index 15df33ef..70ccf9d7 100644 --- a/packages/uploadcare-field/database/migrations/2025_12_17_000001_repair_uploadcare_media_relationships.php +++ b/packages/uploadcare-field/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/packages/uploadcare-field/src/Observers/ContentFieldValueObserver.php b/packages/uploadcare-field/src/Observers/ContentFieldValueObserver.php index 4ebb0e94..2e514822 100644 --- a/packages/uploadcare-field/src/Observers/ContentFieldValueObserver.php +++ b/packages/uploadcare-field/src/Observers/ContentFieldValueObserver.php @@ -40,7 +40,7 @@ public function saved(ContentFieldValue $contentFieldValue): void return; } } - + $this->syncRelationships($contentFieldValue, $mediaData, $modifiedValue); } diff --git a/packages/uploadcare-field/src/Uploadcare.php b/packages/uploadcare-field/src/Uploadcare.php index a0dfc836..f95384ac 100755 --- a/packages/uploadcare-field/src/Uploadcare.php +++ b/packages/uploadcare-field/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; } - } diff --git a/packages/users/src/Resources/RoleResource/Pages/EditRole.php b/packages/users/src/Resources/RoleResource/Pages/EditRole.php index 626cb881..96c20a10 100644 --- a/packages/users/src/Resources/RoleResource/Pages/EditRole.php +++ b/packages/users/src/Resources/RoleResource/Pages/EditRole.php @@ -15,7 +15,7 @@ public static function getResource(): string { return config('backstage.users.resources.roles', RoleResource::class); } - + protected function getHeaderActions(): array { return [ diff --git a/packages/users/src/Resources/RoleResource/Pages/ListRoles.php b/packages/users/src/Resources/RoleResource/Pages/ListRoles.php index 8e36c72d..429d8cab 100644 --- a/packages/users/src/Resources/RoleResource/Pages/ListRoles.php +++ b/packages/users/src/Resources/RoleResource/Pages/ListRoles.php @@ -12,7 +12,7 @@ public static function getResource(): string { return config('backstage.users.resources.roles', RoleResource::class); } - + protected function getHeaderActions(): array { return [ diff --git a/packages/users/src/Resources/RoleResource/Pages/ViewRole.php b/packages/users/src/Resources/RoleResource/Pages/ViewRole.php index 720fbc0f..098cd235 100644 --- a/packages/users/src/Resources/RoleResource/Pages/ViewRole.php +++ b/packages/users/src/Resources/RoleResource/Pages/ViewRole.php @@ -12,7 +12,7 @@ public static function getResource(): string { return config('backstage.users.resources.roles', RoleResource::class); } - + protected function getHeaderActions(): array { return [ From bc3c25012cd1e9d3bc1ac8b02e50d71246be4456 Mon Sep 17 00:00:00 2001 From: Baspa Date: Mon, 22 Dec 2025 17:46:43 +0100 Subject: [PATCH 07/43] fix: saving and reading Uploadccare metadata --- .../Observers/ContentFieldValueObserver.php | 9 +- packages/uploadcare-field/src/Uploadcare.php | 149 +++++++++++++++--- 2 files changed, 135 insertions(+), 23 deletions(-) diff --git a/packages/uploadcare-field/src/Observers/ContentFieldValueObserver.php b/packages/uploadcare-field/src/Observers/ContentFieldValueObserver.php index 2e514822..4f148cf5 100644 --- a/packages/uploadcare-field/src/Observers/ContentFieldValueObserver.php +++ b/packages/uploadcare-field/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/packages/uploadcare-field/src/Uploadcare.php b/packages/uploadcare-field/src/Uploadcare.php index f95384ac..458288af 100755 --- a/packages/uploadcare-field/src/Uploadcare.php +++ b/packages/uploadcare-field/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 c320dd3c6cc1abeb16863915c4fc46ae2e432439 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 08/43] fix: styling --- packages/uploadcare-field/src/Uploadcare.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/uploadcare-field/src/Uploadcare.php b/packages/uploadcare-field/src/Uploadcare.php index 458288af..a83d13c6 100755 --- a/packages/uploadcare-field/src/Uploadcare.php +++ b/packages/uploadcare-field/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 bf05e31c5072dea8dbd75a2e95cd4fe1f3cb9138 Mon Sep 17 00:00:00 2001 From: Baspa Date: Mon, 29 Dec 2025 13:20:18 +0100 Subject: [PATCH 09/43] feat: make user email unique --- packages/users/src/Resources/UserResource/Schemas/UserForm.php | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/users/src/Resources/UserResource/Schemas/UserForm.php b/packages/users/src/Resources/UserResource/Schemas/UserForm.php index abaeb944..ae0f12b7 100644 --- a/packages/users/src/Resources/UserResource/Schemas/UserForm.php +++ b/packages/users/src/Resources/UserResource/Schemas/UserForm.php @@ -38,6 +38,7 @@ public static function configure(Schema $schema): Schema ->label(__('Email')) ->prefixIcon(fn (): BackedEnum => Heroicon::Envelope, true) ->email() + ->unique() ->required(), Select::make('roles') From 6759638e8c31b4ac955c8d93e3beaf14d7fc0e27 Mon Sep 17 00:00:00 2001 From: Baspa Date: Mon, 29 Dec 2025 14:25:01 +0100 Subject: [PATCH 10/43] fix: force casting to `string` to prevent `array` error --- packages/uploadcare-field/src/Uploadcare.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/uploadcare-field/src/Uploadcare.php b/packages/uploadcare-field/src/Uploadcare.php index a83d13c6..6d3561c6 100755 --- a/packages/uploadcare-field/src/Uploadcare.php +++ b/packages/uploadcare-field/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 d2da3db6fa31332b6543d375218f8da20efce8bb Mon Sep 17 00:00:00 2001 From: Baspa Date: Mon, 29 Dec 2025 15:48:49 +0100 Subject: [PATCH 11/43] feat: temporarily disable translation strings --- packages/uploadcare-field/src/Uploadcare.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/uploadcare-field/src/Uploadcare.php b/packages/uploadcare-field/src/Uploadcare.php index 6d3561c6..dc8dddb1 100755 --- a/packages/uploadcare-field/src/Uploadcare.php +++ b/packages/uploadcare-field/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 d4fbeb73fa2139a6a7aeb5f6cdc6adfc3c8891c1 Mon Sep 17 00:00:00 2001 From: Baspa Date: Wed, 31 Dec 2025 08:32:55 +0100 Subject: [PATCH 12/43] fix: hydrate differently for front- and backend --- packages/uploadcare-field/src/Uploadcare.php | 21 +++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/packages/uploadcare-field/src/Uploadcare.php b/packages/uploadcare-field/src/Uploadcare.php index dc8dddb1..28333f98 100755 --- a/packages/uploadcare-field/src/Uploadcare.php +++ b/packages/uploadcare-field/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 b718a86179aaec633bb70193a48c81f99de20d0d Mon Sep 17 00:00:00 2001 From: Baspa Date: Wed, 31 Dec 2025 13:59:49 +0100 Subject: [PATCH 13/43] wip --- .../dist/filament-uploadcare-field.js | 2 +- .../resources/js/components/uploadcare.js | 455 +++++++++++++++--- packages/uploadcare-field/src/Uploadcare.php | 35 +- 3 files changed, 380 insertions(+), 112 deletions(-) diff --git a/packages/filament-uploadcare-field/resources/dist/filament-uploadcare-field.js b/packages/filament-uploadcare-field/resources/dist/filament-uploadcare-field.js index fe1945d6..408c1f27 100644 --- a/packages/filament-uploadcare-field/resources/dist/filament-uploadcare-field.js +++ b/packages/filament-uploadcare-field/resources/dist/filament-uploadcare-field.js @@ -1 +1 @@ -var p=class{constructor(e){this.wrapper=e,this.observer=null,this.init()}init(){this.hideDoneButtons(),this.setupObserver()}setupObserver(){this.observer=new MutationObserver(e=>{e.forEach(t=>{t.type==="childList"&&t.addedNodes.forEach(i=>{if(i.nodeType===Node.ELEMENT_NODE){i.classList&&i.classList.contains("uc-done-btn")&&this.hideDoneButton(i);let r=i.querySelectorAll&&i.querySelectorAll(".uc-done-btn");r&&r.forEach(l=>this.hideDoneButton(l))}})})}),this.wrapper&&this.observer.observe(this.wrapper,{childList:!0,subtree:!0})}hideDoneButtons(){document.querySelectorAll(".uc-done-btn").forEach(t=>this.hideDoneButton(t))}hideDoneButton(e){e&&(e.style.display="none",e.style.visibility="hidden",e.style.opacity="0",e.style.pointerEvents="none",e.style.position="absolute",e.style.width="0",e.style.height="0",e.style.overflow="hidden",e.style.clip="rect(0, 0, 0, 0)",e.style.margin="0",e.style.padding="0",e.style.border="0",e.style.background="transparent",e.style.color="transparent",e.style.fontSize="0",e.style.lineHeight="0")}destroy(){this.observer&&(this.observer.disconnect(),this.observer=null)}};function f(o){return window._initializedUploadcareContexts||(window._initializedUploadcareContexts=new Set),{state:o.state,statePath:o.statePath,initialState:o.initialState,publicKey:o.publicKey,isMultiple:o.isMultiple,multipleMin:o.multipleMin,multipleMax:o.multipleMax,isImagesOnly:o.isImagesOnly,accept:o.accept,sourceList:o.sourceList,uploaderStyle:o.uploaderStyle,isWithMetadata:o.isWithMetadata,localeName:o.localeName||"en",uploadedFiles:"",ctx:null,removeEventListeners:null,uniqueContextName:o.uniqueContextName,isInitialized:!1,stateHasBeenInitialized:!1,isStateWatcherActive:!1,isLocalUpdate:!1,doneButtonHider:null,documentClassObserver:null,formInputObserver:null,async init(){this.isContextAlreadyInitialized()||(this.markContextAsInitialized(),this.applyTheme(),await this.loadAllLocales(),this.setupStateWatcher(),this.$el.addEventListener("uploadcare-state-updated",e=>{let t=e.detail.uuid;t&&this.isInitialized?this.loadFileFromUuid(t):t&&this.$nextTick(()=>{this.isInitialized&&this.loadFileFromUuid(t)})}),this.initUploadcare(),this.setupThemeObservers(),this.setupDoneButtonObserver())},isContextAlreadyInitialized(){return window._initializedUploadcareContexts.has(this.uniqueContextName)},markContextAsInitialized(){window._initializedUploadcareContexts.add(this.uniqueContextName)},async loadAllLocales(){window._uploadcareAllLocalesLoaded||await new Promise(t=>{if(window._uploadcareAllLocalesLoaded){t();return}let i=setInterval(()=>{window._uploadcareAllLocalesLoaded&&(clearInterval(i),t())},100);setTimeout(()=>{clearInterval(i),t()},5e3)});let e=["de","es","fr","he","it","nl","pl","pt","ru","tr","uk","zh-TW","zh"];document.querySelectorAll("uc-config[data-locale-name]").forEach(t=>{let i=t.getAttribute("data-locale-name");i&&e.includes(i)&&!t.getAttribute("locale-name")&&t.setAttribute("locale-name",i)})},async loadLocale(){if(this.localeName==="en"||this.localeLoaded)return;if(window._uploadcareLocales&&window._uploadcareLocales.has(this.localeName)){this.localeLoaded=!0;return}if(window._uploadcareLocales||(window._uploadcareLocales=new Set),!!["de","es","fr","he","it","nl","pl","pt","ru","tr","uk","zh-TW","zh"].includes(this.localeName))try{let i=await import(`https://cdn.jsdelivr.net/npm/@uploadcare/file-uploader@v1/locales/file-uploader/${this.localeName}.js`),r=i.default||i,l=()=>{let a=customElements.get("uc-file-uploader-inline")||customElements.get("uc-file-uploader-regular")||customElements.get("uc-file-uploader-minimal");return a&&a.UC?a.UC:window.UC},s=()=>{let a=l();return a&&typeof a.defineLocale=="function"?(a.defineLocale(this.localeName,r),window._uploadcareLocales.add(this.localeName),this.localeLoaded=!0,!0):!1};if(!s()){let a=0,n=50,u=setInterval(()=>{a++,(s()||a>=n)&&clearInterval(u)},100)}}catch(t){console.error("[Uploadcare Locale JS] Failed to load locale:",this.localeName,t)}},applyTheme(){let e=this.getCurrentTheme();document.querySelectorAll(`uc-file-uploader-${this.uploaderStyle}`).forEach(i=>{i.classList.remove("uc-dark","uc-light"),i.classList.add(`uc-${e}`)})},getCurrentTheme(){return document.documentElement.classList.contains("dark")?"dark":"light"},setupThemeObservers(){window.addEventListener("storage",this.handleThemeStorageChange.bind(this)),window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change",this.handleSystemThemeChange.bind(this)),this.setupDocumentClassObserver()},handleThemeStorageChange(e){e.key==="theme"&&this.applyTheme()},handleSystemThemeChange(){localStorage.getItem("theme")==="system"&&this.applyTheme()},setupDocumentClassObserver(){this.documentClassObserver=new MutationObserver(e=>{e.forEach(t=>{if(t.type==="attributes"&&t.attributeName==="class"){let i=document.documentElement.classList.contains("dark"),r=t.oldValue&&t.oldValue.includes("dark");i!==r&&this.applyTheme()}})}),this.documentClassObserver.observe(document.documentElement,{attributes:!0,attributeOldValue:!0,attributeFilter:["class"]})},initUploadcare(){this.removeEventListeners&&this.removeEventListeners(),this.initializeUploader()},initializeUploader(e=0,t=10){if(e>=t)return;this.ctx=document.querySelector(`uc-upload-ctx-provider[ctx-name="${this.uniqueContextName}"]`);let i=this.getUploadcareApi();if(!this.isValidContext(i)){setTimeout(()=>this.initializeUploader(e+1,t),100);return}this.markAsInitialized(),this.removeRequiredAttributes(),this.initializeState(i),this.setupEventListeners(i)},getUploadcareApi(){try{return this.ctx?.getAPI()}catch{return null}},isValidContext(e){return this.ctx&&e&&e.addFileFromCdnUrl},markAsInitialized(){this.isInitialized=!0},removeRequiredAttributes(){setTimeout(()=>{let e=this.$el.closest("uc-config");document.querySelectorAll("uc-form-input input[required]").forEach(i=>i.removeAttribute("required"))},100)},initializeState(e){this.$nextTick(()=>{this.initialState&&!this.stateHasBeenInitialized&&!this.uploadedFiles?this.loadInitialState(e):!this.initialState&&!this.stateHasBeenInitialized&&(this.stateHasBeenInitialized=!0,this.uploadedFiles=this.isMultiple?"[]":"",this.isLocalUpdate=!0,this.state=this.uploadedFiles)})},loadInitialState(e){try{let t=this.parseInitialState();this.addFilesFromInitialState(e,t),this.stateHasBeenInitialized=!0,this.isLocalUpdate=!0,this.state=this.uploadedFiles}catch(t){console.error("Error parsing initialState:",t)}},parseInitialState(){let e=t=>{if(typeof t=="string")try{let i=JSON.parse(t);if(typeof i=="string")try{i=JSON.parse(i)}catch{}return i}catch{return t}return t};if(this.initialState&&typeof this.initialState=="object"&&!Array.isArray(this.initialState)){let t=Object.keys(this.initialState);if(t.length===1)return e(this.initialState[t[0]])}return e(this.initialState)},addFilesFromInitialState(e,t){let i=t;if(t&&typeof t=="object"&&!Array.isArray(t))try{i=Array.from(t)}catch{i=[t]}else Array.isArray(t)||(i=[t]);if(Array.isArray(i)&&i.length===1&&Array.isArray(i[0])&&(i=i[0]),Array.isArray(i)&&i.length===1&&typeof i[0]=="string")try{let s=JSON.parse(i[0]);i=Array.isArray(s)?s:[s]}catch{}Array.isArray(i)||(i=[i]);let r=(s,a=0)=>{if(!s)return;if(Array.isArray(s)){s.forEach((d,h)=>{r(d,`${a}.${h}`)});return}if(typeof s=="string")try{let d=JSON.parse(s);r(d,a);return}catch{}let n=typeof s=="object"?s.cdnUrl:s,u=typeof s=="object"?s.cdnUrlModifiers:null;if(!n||!this.isValidUrl(n))return;let c=this.extractUuidFromUrl(n);if(c&&typeof e.addFileFromUuid=="function")try{if(u&&typeof e.addFileFromCdnUrl=="function"){let h=n.split("/-/")[0]+"/"+u;e.addFileFromCdnUrl(h)}else e.addFileFromUuid(c)}catch(d){console.error(`Failed to add file ${a} with UUID ${c}:`,d)}else console.error(c?"addFileFromUuid method not available on API":`Could not extract UUID from URL: ${n}`)};i.forEach(r);let l=this.formatFilesForState(i);this.uploadedFiles=JSON.stringify(l),this.initialState=this.uploadedFiles},isValidUrl(e){if(!e||typeof e!="string")return!1;try{return new URL(e),!0}catch{return!1}},setupStateWatcher(){this.$watch("state",(e,t)=>{if(this.isLocalUpdate){this.isLocalUpdate=!1;return}if(!this.stateHasBeenInitialized){this.stateHasBeenInitialized=!0;return}if((!e||e==="[]"||e==='""')&&!this.uploadedFiles)return;let i=this.normalizeStateValue(e),r=this.normalizeStateValue(this.uploadedFiles);i!==r&&e&&e!=="[]"&&e!=='""'&&this.addFilesFromState(e)})},parseStateValue(e){if(!e)return null;try{return typeof e=="string"?JSON.parse(e):e}catch{return e}},addFilesFromState(e){let i=this.parseStateValue(e);if(Array.isArray(i)||(i=[i]),i.length===0)return!1;let r=this.getUploadcareApi();if(!r||typeof r.addFileFromCdnUrl!="function")return!1;let s=this.getCurrentFiles().map(a=>typeof a=="object"?a.cdnUrl:a).filter(Boolean);return i.forEach(a=>{let n=typeof a=="object"?a.cdnUrl:a;if(n&&typeof n=="string"&&n.includes("ucarecdn.com")&&!s.some(c=>{let d=this.extractUuidFromUrl(n),h=this.extractUuidFromUrl(c);return d&&h&&d===h}))try{r.addFileFromCdnUrl(n)}catch(c){console.error("[Uploadcare] Failed to add file from URL:",n,c)}}),this.uploadedFiles=e,this.isLocalUpdate=!0,!0},normalizeStateValue(e){if(!e)return"";try{let t=typeof e=="string"?JSON.parse(e):e;return JSON.stringify(this.formatFilesForState(t))}catch{return e}},isStateChanged(){let e=this.normalizeStateValue(this.state),t=this.normalizeStateValue(this.initialState);return e!==t},setupEventListeners(e){let t=this.createFileUploadSuccessHandler(),i=this.createFileUrlChangedHandler(),r=this.createFileRemovedHandler(),l=this.createFormInputChangeHandler(e);this.ctx.addEventListener("file-upload-started",s=>{let a=this.$el.closest("form");a&&a.dispatchEvent(new CustomEvent("form-processing-started",{detail:{message:"Uploading file..."}}))}),this.ctx.addEventListener("file-upload-success",t),this.ctx.addEventListener("file-url-changed",i),this.ctx.addEventListener("file-removed",r),this.$nextTick(()=>{let s=this.$el.querySelector("uc-form-input input");if(s){s.addEventListener("input",l),s.addEventListener("change",l);let a=new MutationObserver(()=>{l({target:s})});a.observe(s,{attributes:!0,attributeFilter:["value"]}),this.formInputObserver=a}else setTimeout(()=>{let a=this.$el.querySelector("uc-form-input input");a&&(a.addEventListener("input",l),a.addEventListener("change",l))},200)}),this.removeEventListeners=()=>{this.ctx.removeEventListener("file-upload-started",a=>{let n=this.$el.closest("form");n&&n.dispatchEvent(new CustomEvent("form-processing-started",{detail:{message:"Uploading file..."}}))}),this.ctx.removeEventListener("file-upload-success",t),this.ctx.removeEventListener("file-url-changed",i),this.ctx.removeEventListener("file-removed",r);let s=this.$el.querySelector("uc-form-input input");s&&(s.removeEventListener("input",l),s.removeEventListener("change",l)),this.formInputObserver&&(this.formInputObserver.disconnect(),this.formInputObserver=null)}},createFileUploadSuccessHandler(){let e=null;return t=>{e&&clearTimeout(e),e=setTimeout(()=>{let i=this.isWithMetadata?t.detail:t.detail.cdnUrl;try{let r=this.getCurrentFiles(),l=this.updateFilesList(r,i);this.updateState(l);let s=this.$el.closest("form");s&&s.dispatchEvent(new CustomEvent("form-processing-finished"))}catch(r){console.error("[Uploadcare] Error updating state after upload:",r)}},this.isMultiple?200:100)}},createFileUrlChangedHandler(){let e=null;return t=>{let i=t.detail;!i||!i.cdnUrl||(e&&clearTimeout(e),e=setTimeout(()=>{try{let r=this.getCurrentFiles(),l=this.updateFileUrl(r,i);this.updateState(l)}catch(r){console.error("Error updating state after URL change:",r)}},100))}},createFileRemovedHandler(){let e=null;return t=>{e&&clearTimeout(e),e=setTimeout(()=>{try{let i=t.detail,r=this.getCurrentFiles(),l=this.removeFile(r,i);this.updateState(l);let s=this.getUploadcareApi();s&&setTimeout(()=>{this.syncStateWithUploadcare(s)},150)}catch(i){console.error("Error in handleFileRemoved:",i)}},100)}},createFormInputChangeHandler(e){let t=null;return i=>{t&&clearTimeout(t),t=setTimeout(()=>{this.syncStateWithUploadcare(e)},200)}},getCurrentFiles(){try{let e=this.uploadedFiles?JSON.parse(this.uploadedFiles):[];return Array.isArray(e)?e:[]}catch{return[]}},updateFilesList(e,t){return this.isMultiple?e.some(r=>{let l=typeof r=="object"?r.cdnUrl:r,s=typeof t=="object"?t.cdnUrl:t;return l===s})?e:[...e,t]:[t]},updateFileUrl(e,t){let i=t.uuid;if(!i&&t.cdnUrl&&(i=this.extractUuidFromUrl(t.cdnUrl)),!i)return e;t.uuid||(t={...t,uuid:i});let r=this.findFileIndex(e,i);if(r===-1)return e;let l;return this.isWithMetadata?l={...e[r],...t}:l=t.cdnUrl,this.isMultiple?(e[r]=l,e):[l]},removeFile(e,t){let i=this.findFileIndex(e,t.uuid);return i===-1?e:this.isMultiple?(e.splice(i,1),e):[]},findFileIndex(e,t){return e.findIndex(i=>{let r=typeof i=="object"?i.cdnUrl:i;return r&&r.includes(t)})},updateState(e){let t=this.formatFilesForState(e),i=JSON.stringify(t),r=this.getCurrentFiles(),l=JSON.stringify(this.formatFilesForState(r)),s=JSON.stringify(this.formatFilesForState(t));l!==s&&(this.uploadedFiles=i,this.isLocalUpdate=!0,this.state=this.uploadedFiles,this.isMultiple&&e.length>1&&this.$nextTick(()=>{this.isLocalUpdate=!1}))},formatFilesForState(e){return e.map(t=>this.isWithMetadata?t:typeof t=="object"?t.cdnUrl:t)},setupDoneButtonObserver(){let e=this.$el.closest(".uploadcare-wrapper");e&&(this.doneButtonHider=new p(e))},destroy(){this.doneButtonHider&&(this.doneButtonHider.destroy(),this.doneButtonHider=null),this.documentClassObserver&&(this.documentClassObserver.disconnect(),this.documentClassObserver=null),this.formInputObserver&&(this.formInputObserver.disconnect(),this.formInputObserver=null),this.removeEventListeners&&this.removeEventListeners()},extractUuidFromUrl(e){if(!e||typeof e!="string")return null;let t=e.match(/\/([a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})(?:\/|$)/i);return t&&t[1]?t[1]:typeof e=="object"&&e.uuid?e.uuid:null},syncStateWithUploadcare(e){try{let t=this.getCurrentFilesFromUploadcare(e),i=this.formatFilesForState(t),r=this.buildStateFromFiles(i),l=this.normalizeStateValue(this.uploadedFiles),s=this.normalizeStateValue(r);l!==s&&(this.uploadedFiles=r,this.isLocalUpdate=!0,this.state=this.uploadedFiles)}catch(t){console.error("Error syncing state with Uploadcare:",t)}},buildStateFromFiles(e){return this.isMultiple?JSON.stringify(e):e.length>0?this.isWithMetadata?JSON.stringify(e[0]):e[0]:""},getCurrentFilesFromUploadcare(e){try{let t=this.$el.querySelector("uc-form-input input");return t?this.parseFormInputValue(t.value):this.$el.querySelectorAll("uc-file-item, [data-file-item]").length===0?[]:[]}catch(t){return console.error("Error getting current files from Uploadcare:",t),[]}},parseFormInputValue(e){if(!e||typeof e=="string"&&e.trim()==="")return[];try{let t=JSON.parse(e);return Array.isArray(t)?t.filter(i=>i!==null&&i!==""):t!==null&&t!==""?[t]:[]}catch{return typeof e=="string"&&e.trim()!==""?[e]:[]}}}}export{f as default}; +var f=class{constructor(e){this.wrapper=e,this.observer=null,this.init()}init(){this.hideDoneButtons(),this.setupObserver()}setupObserver(){this.observer=new MutationObserver(e=>{e.forEach(t=>{t.type==="childList"&&t.addedNodes.forEach(i=>{if(i.nodeType===Node.ELEMENT_NODE){i.classList&&i.classList.contains("uc-done-btn")&&this.hideDoneButton(i);let a=i.querySelectorAll&&i.querySelectorAll(".uc-done-btn");a&&a.forEach(s=>this.hideDoneButton(s))}})})}),this.wrapper&&this.observer.observe(this.wrapper,{childList:!0,subtree:!0})}hideDoneButtons(){document.querySelectorAll(".uc-done-btn").forEach(t=>this.hideDoneButton(t))}hideDoneButton(e){e&&(e.style.display="none",e.style.visibility="hidden",e.style.opacity="0",e.style.pointerEvents="none",e.style.position="absolute",e.style.width="0",e.style.height="0",e.style.overflow="hidden",e.style.clip="rect(0, 0, 0, 0)",e.style.margin="0",e.style.padding="0",e.style.border="0",e.style.background="transparent",e.style.color="transparent",e.style.fontSize="0",e.style.lineHeight="0")}destroy(){this.observer&&(this.observer.disconnect(),this.observer=null)}};function g(p){return window._initializedUploadcareContexts||(window._initializedUploadcareContexts=new Set),{state:p.state,statePath:p.statePath,initialState:p.initialState,publicKey:p.publicKey,isMultiple:p.isMultiple,multipleMin:p.multipleMin,multipleMax:p.multipleMax,isImagesOnly:p.isImagesOnly,accept:p.accept,sourceList:p.sourceList,uploaderStyle:p.uploaderStyle,isWithMetadata:p.isWithMetadata,localeName:p.localeName||"en",uploadedFiles:"",ctx:null,removeEventListeners:null,uniqueContextName:p.uniqueContextName,isInitialized:!1,stateHasBeenInitialized:!1,isStateWatcherActive:!1,isLocalUpdate:!1,doneButtonHider:null,documentClassObserver:null,formInputObserver:null,async init(){this.isContextAlreadyInitialized()||(this.markContextAsInitialized(),this.applyTheme(),await this.loadAllLocales(),this.setupStateWatcher(),this.$el.addEventListener("uploadcare-state-updated",e=>{let t=e.detail.uuid;t&&this.isInitialized?this.loadFileFromUuid(t):t&&this.$nextTick(()=>{this.isInitialized&&this.loadFileFromUuid(t)})}),this.initUploadcare(),this.setupThemeObservers(),this.setupDoneButtonObserver())},isContextAlreadyInitialized(){return window._initializedUploadcareContexts.has(this.uniqueContextName)},markContextAsInitialized(){window._initializedUploadcareContexts.add(this.uniqueContextName)},async loadAllLocales(){window._uploadcareAllLocalesLoaded||await new Promise(t=>{if(window._uploadcareAllLocalesLoaded){t();return}let i=setInterval(()=>{window._uploadcareAllLocalesLoaded&&(clearInterval(i),t())},100);setTimeout(()=>{clearInterval(i),t()},5e3)});let e=["de","es","fr","he","it","nl","pl","pt","ru","tr","uk","zh-TW","zh"];document.querySelectorAll("uc-config[data-locale-name]").forEach(t=>{let i=t.getAttribute("data-locale-name");i&&e.includes(i)&&!t.getAttribute("locale-name")&&t.setAttribute("locale-name",i)})},async loadLocale(){if(this.localeName==="en"||this.localeLoaded)return;if(window._uploadcareLocales&&window._uploadcareLocales.has(this.localeName)){this.localeLoaded=!0;return}if(window._uploadcareLocales||(window._uploadcareLocales=new Set),!!["de","es","fr","he","it","nl","pl","pt","ru","tr","uk","zh-TW","zh"].includes(this.localeName))try{let i=await import(`https://cdn.jsdelivr.net/npm/@uploadcare/file-uploader@v1/locales/file-uploader/${this.localeName}.js`),a=i.default||i,s=()=>{let r=customElements.get("uc-file-uploader-inline")||customElements.get("uc-file-uploader-regular")||customElements.get("uc-file-uploader-minimal");return r&&r.UC?r.UC:window.UC},n=()=>{let r=s();return r&&typeof r.defineLocale=="function"?(r.defineLocale(this.localeName,a),window._uploadcareLocales.add(this.localeName),this.localeLoaded=!0,!0):!1};if(!n()){let r=0,l=50,d=setInterval(()=>{r++,(n()||r>=l)&&clearInterval(d)},100)}}catch(t){console.error("[Uploadcare Locale JS] Failed to load locale:",this.localeName,t)}},applyTheme(){let e=this.getCurrentTheme();document.querySelectorAll(`uc-file-uploader-${this.uploaderStyle}`).forEach(i=>{i.classList.remove("uc-dark","uc-light"),i.classList.add(`uc-${e}`)})},getCurrentTheme(){return document.documentElement.classList.contains("dark")?"dark":"light"},setupThemeObservers(){window.addEventListener("storage",this.handleThemeStorageChange.bind(this)),window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change",this.handleSystemThemeChange.bind(this)),this.setupDocumentClassObserver()},handleThemeStorageChange(e){e.key==="theme"&&this.applyTheme()},handleSystemThemeChange(){localStorage.getItem("theme")==="system"&&this.applyTheme()},setupDocumentClassObserver(){this.documentClassObserver=new MutationObserver(e=>{e.forEach(t=>{if(t.type==="attributes"&&t.attributeName==="class"){let i=document.documentElement.classList.contains("dark"),a=t.oldValue&&t.oldValue.includes("dark");i!==a&&this.applyTheme()}})}),this.documentClassObserver.observe(document.documentElement,{attributes:!0,attributeOldValue:!0,attributeFilter:["class"]})},initUploadcare(){this.removeEventListeners&&this.removeEventListeners(),this.initializeUploader()},initializeUploader(e=0,t=10){if(e>=t)return;this.ctx=document.querySelector(`uc-upload-ctx-provider[ctx-name="${this.uniqueContextName}"]`);let i=this.getUploadcareApi();if(!this.isValidContext(i)){setTimeout(()=>this.initializeUploader(e+1,t),100);return}this.markAsInitialized(),this.removeRequiredAttributes(),this.initializeState(i),this.setupEventListeners(i)},getUploadcareApi(){try{return this.ctx?.getAPI()}catch{return null}},isValidContext(e){return this.ctx&&e&&e.addFileFromCdnUrl},markAsInitialized(){this.isInitialized=!0},removeRequiredAttributes(){setTimeout(()=>{let e=this.$el.closest("uc-config");document.querySelectorAll("uc-form-input input[required]").forEach(i=>i.removeAttribute("required"))},100)},initializeState(e){this.$nextTick(()=>{console.log(`[Uploadcare ${this.statePath}] initializeState`,{hasInitialState:!!this.initialState,initialStatePreview:typeof this.initialState=="string"?this.initialState.substring(0,100):this.initialState,stateHasBeenInitialized:this.stateHasBeenInitialized,uploadedFiles:this.uploadedFiles,uniqueContextName:this.uniqueContextName}),this.initialState&&!this.stateHasBeenInitialized&&!this.uploadedFiles?this.loadInitialState(e):!this.initialState&&!this.stateHasBeenInitialized&&(this.stateHasBeenInitialized=!0,this.uploadedFiles=this.isMultiple?"[]":"",this.isLocalUpdate=!0,this.state=this.uploadedFiles)})},loadInitialState(e){try{let t=this.parseInitialState();this.addFilesFromInitialState(e,t),this.stateHasBeenInitialized=!0,this.isLocalUpdate=!0,this.state=this.uploadedFiles}catch(t){console.error("Error parsing initialState:",t)}},parseInitialState(){let e=i=>{if(typeof i=="string")try{let a=JSON.parse(i);if(typeof a=="string")try{a=JSON.parse(a)}catch{}return a}catch{return i}return i};this.initialState&&this.initialState&&typeof this.initialState=="object"&&!Array.isArray(this.initialState)&&(this.initialState=[this.initialState]);let t=this.parseStateValue(this.initialState);return console.log(`[Uploadcare ${this.statePath}] initializeState`,{hasInitialState:!!this.initialState,parsedCount:Array.isArray(t)?t.length:"n/a"}),t},addFilesFromInitialState(e,t){let i=[];if(t&&t&&typeof t=="object"&&!Array.isArray(t))try{i=Array.from(t)}catch{i=[t]}else Array.isArray(t)?i=t:t&&(i=[t]);if(Array.isArray(i)&&i.length===1&&Array.isArray(i[0])&&(i=i[0]),Array.isArray(i)&&i.length===1&&typeof i[0]=="string")try{let r=JSON.parse(i[0]);i=Array.isArray(r)?r:[r]}catch{}if(!Array.isArray(i)||i.length===0)return;Array.isArray(i)||(i=[i]);let a=(r,l=0)=>{if(!r)return;if(Array.isArray(r)){r.forEach((u,c)=>{a(u,`${l}.${c}`)});return}if(typeof r=="string")try{let u=JSON.parse(r);a(u,l);return}catch{}let d=r&&typeof r=="object"?r.cdnUrl:r,h=r&&typeof r=="object"?r.cdnUrlModifiers:null;if(console.log(`[Uploadcare ${this.statePath}] addFilesFromInitialState adding item`,{index:l,url:d}),!d||!this.isValidUrl(d))return;let o=this.extractUuidFromUrl(d);if(o&&typeof e.addFileFromUuid=="function")try{if(h&&typeof e.addFileFromCdnUrl=="function"){let c=d.split("/-/")[0]+"/"+h;e.addFileFromCdnUrl(c)}else e.addFileFromUuid(o)}catch(u){console.error(`Failed to add file ${l} with UUID ${o}:`,u)}else console.error(o?"addFileFromUuid method not available on API":`Could not extract UUID from URL: ${d}`)};i.forEach(a);let s=i.map(r=>{let l=r;if(r&&typeof r=="object")return r.uuid||(r.uuid=this.extractUuidFromUrl(r.cdnUrl)),r;if(typeof r=="string"){let d=this.extractUuidFromUrl(r);return{cdnUrl:r,uuid:d,name:"",size:0,mimeType:"",isImage:!1}}return r}),n=this.formatFilesForState(s);this.uploadedFiles=JSON.stringify(n),this.initialState=this.uploadedFiles},isValidUrl(e){if(!e||typeof e!="string")return!1;try{return new URL(e),!0}catch{return!1}},setupStateWatcher(){this.$watch("state",(e,t)=>{if(this.isLocalUpdate){this.isLocalUpdate=!1;return}if(!this.stateHasBeenInitialized){this.stateHasBeenInitialized=!0;return}if((!e||e==="[]"||e==='""')&&!this.uploadedFiles)return;let i=this.normalizeStateValue(e),a=this.normalizeStateValue(this.uploadedFiles);i!==a&&e&&e!=="[]"&&e!=='""'&&this.addFilesFromState(e)})},parseStateValue(e){if(!e)return null;try{return typeof e=="string"?JSON.parse(e):e}catch{return e}},addFilesFromState(e){let i=this.parseStateValue(e);if(Array.isArray(i)||(i=[i]),i=i.filter(o=>o!=null),i.length===0)return!1;let a=this.getUploadcareApi();if(!a||typeof a.addFileFromCdnUrl!="function")return!1;let s=this.getCurrentFiles();console.log("[Uploadcare] addFilesFromState currentFiles",s.length,s);let n=s.map(o=>o?o&&typeof o=="object"?o.cdnUrl:o:null).filter(Boolean);console.log(`[Uploadcare ${this.statePath}] addFilesFromState filesToAdd`,i.length,i),i.forEach((o,u)=>{if(!o){console.warn(`[Uploadcare] Skipping null item at index ${u}`);return}let c=o&&typeof o=="object"?o.cdnUrl:o;if(console.log(`[Uploadcare] Processing item ${u}`,{url:c,item:o}),c&&typeof c=="string"&&(c.includes("ucarecdn.com")||c.includes("ucarecd.net"))&&!n.some(m=>{let y=this.extractUuidFromUrl(c),U=this.extractUuidFromUrl(m);return y&&U&&y===U}))try{a.addFileFromCdnUrl(c)}catch(m){console.error("[Uploadcare] Failed to add file from URL:",c,m)}});let r=[],l=new Set,d=o=>{if(!o)return;let u=o&&typeof o=="object"?o.cdnUrl:o,c=this.extractUuidFromUrl(u);c&&!l.has(c)?(l.add(c),this.isWithMetadata&&typeof o!="object"?r.push({cdnUrl:o,uuid:c,name:"",size:0,mimeType:"",isImage:!1}):r.push(o)):c||r.push(o)},h=this.parseStateValue(e)||[];return(Array.isArray(h)?h:[h]).forEach(d),this.uploadedFiles=JSON.stringify(r),this.isLocalUpdate=!0,!0},normalizeStateValue(e){if(!e)return"";try{let t=typeof e=="string"?JSON.parse(e):e;if(Array.isArray(t)){if(t.every(s=>typeof s=="string"||typeof s=="object"&&s!==null&&("cdnUrl"in s||"uuid"in s)))return JSON.stringify(t);console.log("[Uploadcare] normalizing mixed/raw array",t)}let i=this.formatFilesForState(t);return console.log("[Uploadcare] normalizeStateValue result",i),JSON.stringify(i)}catch(t){return console.error("[Uploadcare] normalizeStateValue error",t),e}},isStateChanged(){let e=this.normalizeStateValue(this.state),t=this.normalizeStateValue(this.initialState);return e!==t},setupEventListeners(e){this.pendingUploads=[],this.pendingRemovals=[];let t=this.createFileUploadSuccessHandler(e),i=this.createFileUrlChangedHandler(e),a=this.createFileRemovedHandler(e),s=this.createFormInputChangeHandler(e);this.ctx.addEventListener("file-upload-started",n=>{let r=this.$el.closest("form");r&&r.dispatchEvent(new CustomEvent("form-processing-started",{detail:{message:"Uploading file..."}}))}),this.ctx.addEventListener("file-upload-success",t),this.ctx.addEventListener("file-url-changed",i),this.ctx.addEventListener("file-removed",a),this.$nextTick(()=>{let n=this.$el.querySelector("uc-form-input input");if(n){n.addEventListener("input",s),n.addEventListener("change",s);let r=new MutationObserver(()=>{s({target:n})});r.observe(n,{attributes:!0,attributeFilter:["value"]}),this.formInputObserver=r}else setTimeout(()=>{let r=this.$el.querySelector("uc-form-input input");r&&(r.addEventListener("input",s),r.addEventListener("change",s))},200)}),this.removeEventListeners=()=>{this.ctx.removeEventListener("file-upload-started",r=>{let l=this.$el.closest("form");l&&l.dispatchEvent(new CustomEvent("form-processing-started",{detail:{message:"Uploading file..."}}))}),this.ctx.removeEventListener("file-upload-success",t),this.ctx.removeEventListener("file-url-changed",i),this.ctx.removeEventListener("file-removed",a);let n=this.$el.querySelector("uc-form-input input");n&&(n.removeEventListener("input",s),n.removeEventListener("change",s)),this.formInputObserver&&(this.formInputObserver.disconnect(),this.formInputObserver=null)}},createFileUploadSuccessHandler(e){let t=null;return i=>{let a=this.isWithMetadata?i.detail:i.detail.cdnUrl;this.pendingUploads.push(a),t&&clearTimeout(t),t=setTimeout(()=>{try{let s=this.getCurrentFiles();for(let r of this.pendingUploads)s=this.updateFilesList(s,r);this.updateState(s),this.pendingUploads=[];let n=this.$el.closest("form");n&&n.dispatchEvent(new CustomEvent("form-processing-finished"))}catch(s){console.error("[Uploadcare] Error updating state after upload:",s)}},200)}},createFileUrlChangedHandler(e){let t=null;return i=>{let a=i.detail;!a||!a.cdnUrl||(t&&clearTimeout(t),t=setTimeout(()=>{try{let s=this.getCurrentFiles(),n=this.updateFileUrl(s,a);this.updateState(n)}catch(s){console.error("Error updating state after URL change:",s)}},100))}},createFileRemovedHandler(e){let t=null;return i=>{let a=i.detail;this.pendingRemovals.push(a),t&&clearTimeout(t),t=setTimeout(()=>{try{let s=this.getCurrentFiles();for(let n of this.pendingRemovals)s=this.removeFile(s,n);this.updateState(s),this.pendingRemovals=[]}catch(s){console.error("Error in handleFileRemoved:",s)}},100)}},createFormInputChangeHandler(e){return t=>{}},getCurrentFiles(){try{let e=this.uploadedFiles?JSON.parse(this.uploadedFiles):[];return Array.isArray(e)?e:[]}catch{return[]}},updateFilesList(e,t){if(this.isMultiple){let i=this.extractUuidFromUrl(t);return e.some(s=>this.extractUuidFromUrl(s)===i)?(console.log(`[Uploadcare ${this.statePath}] Skipping duplicate file`,i),e):[...e,t]}return[t]},updateFileUrl(e,t){let i=t.uuid;if(!i&&t.cdnUrl&&(i=this.extractUuidFromUrl(t.cdnUrl)),!i)return e;t.uuid||(t={...t,uuid:i});let a=this.findFileIndex(e,i);if(a===-1)return e;let s;if(this.isWithMetadata){let n=e[a];if(typeof n=="string"){let r=this.extractUuidFromUrl(n);n={cdnUrl:n,uuid:r,name:"",size:0,mimeType:"",isImage:!1}}s={...n,...t}}else s=t.cdnUrl;return this.isMultiple?(e[a]=s,e):[s]},removeFile(e,t){let i=this.findFileIndex(e,t.uuid);return i===-1?e:this.isMultiple?(e.splice(i,1),e):[]},findFileIndex(e,t){return t?e.findIndex(i=>{let a=i&&typeof i=="object"?i.cdnUrl:i;return this.extractUuidFromUrl(a)===t}):-1},updateState(e){let t=new Set,i=e.filter(h=>{let o=h&&typeof h=="object"?h.cdnUrl:h,u=this.extractUuidFromUrl(o);return u?t.has(u)?!1:(t.add(u),!0):!0});console.log(`[Uploadcare ${this.statePath}] updateState`,{incomingCount:e.length,uniqueCount:i.length,uniqueUuids:Array.from(t)});let a=this.formatFilesForState(i),s=JSON.stringify(a),n=this.getCurrentFiles(),r=JSON.stringify(this.formatFilesForState(n)),l=JSON.stringify(a);r!==l?(console.log(`[Uploadcare ${this.statePath}] State HAS changed, updating uploadedFiles`),this.uploadedFiles=s,this.isLocalUpdate=!0,this.state=this.uploadedFiles,this.isMultiple&&i.length>1&&this.$nextTick(()=>{this.isLocalUpdate=!1})):console.log(`[Uploadcare ${this.statePath}] State has NOT moved, ignoring update`)},formatFilesForState(e){if(!e)return[];if(!Array.isArray(e))if(console.warn("[Uploadcare] formatFilesForState called with non-array:",typeof e,e),typeof e=="string")try{let t=JSON.parse(e);if(Array.isArray(t))e=t;else return[]}catch{return[]}else return[];return e.map(t=>{if(t&&typeof t=="object"&&!t.cdnUrl&&!t.uuid&&"0"in t){let i=Object.keys(t);if(i.length>5&&i.includes("0")&&i.includes("1")&&i.includes("2")){let a="";if(Math.max(...i.map(n=>parseInt(n)).filter(n=>!isNaN(n)))===i.length-1){let n=new Array(i.length);for(let r=0;r0){let r=[];for(let l of t){console.log(`[Uploadcare ${this.statePath}] syncStateWithUploadcare - state item`,l);let d=l&&typeof l=="object"?l.cdnUrl:l;if(typeof d=="string"&&d.match(/[a-f0-9-]{36}~[0-9]+/)){console.log("[Uploadcare] Found group URL:",d);try{let h=await this.fetchGroupFiles(d);console.log("[Uploadcare] Expanded group to:",h.length,"files"),r.push(...h)}catch(h){console.error("[Uploadcare] Failed to expand group:",d,h),r.push(l)}}else r.push(l)}console.log("[Uploadcare] Flattened files count:",r.length),t=r}let i=this.formatFilesForState(t),a=this.buildStateFromFiles(i),s=this.normalizeStateValue(this.uploadedFiles),n=this.normalizeStateValue(a);s!==n&&(this.uploadedFiles=a,this.isLocalUpdate=!0,this.state=this.uploadedFiles)}catch(t){console.error("Error syncing state with Uploadcare:",t)}},async fetchGroupFiles(e){let t=e;if(e.includes("ucarecdn.com")||e.includes("ucarecd.net")){let s=e.match(/\/([a-f0-9-]{36}~[0-9]+)/);s&&(t=s[1])}let i=await fetch(`https://upload.uploadcare.com/group/info/?pub_key=${this.publicKey}&group_id=${t}`);if(!i.ok)throw new Error(`Failed to fetch group info: ${i.statusText}`);let a=await i.json();return a.files?a.files.map(s=>{let n=`https://ucarecdn.com/${s.uuid}/`;return this.isWithMetadata?{uuid:s.uuid,cdnUrl:n,name:s.original_filename,size:s.size,mimeType:s.mime_type,isImage:s.is_image}:n}):[]},buildStateFromFiles(e){return this.isMultiple?JSON.stringify(e):e.length>0?this.isWithMetadata?JSON.stringify(e[0]):e[0]:""},getCurrentFilesFromUploadcare(e){try{if(e&&typeof e.value=="function"){let i=e.value();return i?Array.isArray(i)?i.filter(s=>s!=null):this.parseFormInputValue(i).filter(s=>s!=null):[]}let t=this.$el.querySelector("uc-form-input input");return t?this.parseFormInputValue(t.value).filter(a=>a!=null):[]}catch(t){return console.error("Error getting current files from Uploadcare:",t),[]}},parseFormInputValue(e){if(!e||typeof e=="string"&&e.trim()==="")return[];if(typeof e=="object")return[e];try{let t=JSON.parse(e);return Array.isArray(t)?t.filter(i=>i!==null&&i!==""):t!==null&&t!==""?[t]:[]}catch{return typeof e=="string"&&e.trim()!==""?[e]:[]}}}}export{g as default}; diff --git a/packages/filament-uploadcare-field/resources/js/components/uploadcare.js b/packages/filament-uploadcare-field/resources/js/components/uploadcare.js index b29e7cdc..bff5b64b 100644 --- a/packages/filament-uploadcare-field/resources/js/components/uploadcare.js +++ b/packages/filament-uploadcare-field/resources/js/components/uploadcare.js @@ -274,6 +274,14 @@ export default function uploadcareField(config) { initializeState(api) { this.$nextTick(() => { + console.log(`[Uploadcare ${this.statePath}] initializeState`, { + hasInitialState: !!this.initialState, + initialStatePreview: typeof this.initialState === 'string' ? this.initialState.substring(0, 100) : this.initialState, + stateHasBeenInitialized: this.stateHasBeenInitialized, + uploadedFiles: this.uploadedFiles, + uniqueContextName: this.uniqueContextName + }); + if (this.initialState && !this.stateHasBeenInitialized && !this.uploadedFiles) { this.loadInitialState(api); } else if (!this.initialState && !this.stateHasBeenInitialized) { @@ -302,14 +310,9 @@ export default function uploadcareField(config) { if (typeof value === 'string') { try { let parsed = JSON.parse(value); - if (typeof parsed === 'string') { - try { - parsed = JSON.parse(parsed); - } catch (e) { - } + try { parsed = JSON.parse(parsed); } catch (e) {} } - return parsed; } catch (e) { return value; @@ -318,25 +321,27 @@ export default function uploadcareField(config) { return value; }; - if (this.initialState && typeof this.initialState === 'object' && !Array.isArray(this.initialState)) { - const keys = Object.keys(this.initialState); - if (keys.length === 1) { - return safeParse(this.initialState[keys[0]]); - } + if (this.initialState && (this.initialState && typeof this.initialState === 'object') && !Array.isArray(this.initialState)) { + this.initialState = [this.initialState]; } - - return safeParse(this.initialState); + + const parsedState = this.parseStateValue(this.initialState); + console.log(`[Uploadcare ${this.statePath}] initializeState`, { hasInitialState: !!this.initialState, parsedCount: Array.isArray(parsedState) ? parsedState.length : 'n/a' }); + + return parsedState; }, addFilesFromInitialState(api, parsedState) { - let filesArray = parsedState; - if (parsedState && typeof parsedState === 'object' && !Array.isArray(parsedState)) { + let filesArray = []; + if (parsedState && (parsedState && typeof parsedState === 'object') && !Array.isArray(parsedState)) { try { filesArray = Array.from(parsedState); } catch (e) { filesArray = [parsedState]; } - } else if (!Array.isArray(parsedState)) { + } else if (Array.isArray(parsedState)) { + filesArray = parsedState; + } else if (parsedState) { filesArray = [parsedState]; } @@ -352,6 +357,10 @@ export default function uploadcareField(config) { } } + if (!Array.isArray(filesArray) || filesArray.length === 0) { + return; + } + if (!Array.isArray(filesArray)) { filesArray = [filesArray]; } @@ -375,8 +384,9 @@ export default function uploadcareField(config) { } } - const url = typeof item === 'object' ? item.cdnUrl : item; - const cdnUrlModifiers = typeof item === 'object' ? item.cdnUrlModifiers : null; + const url = (item && typeof item === 'object') ? item.cdnUrl : item; + const cdnUrlModifiers = (item && typeof item === 'object') ? item.cdnUrlModifiers : null; + console.log(`[Uploadcare ${this.statePath}] addFilesFromInitialState adding item`, { index, url }); if (!url || !this.isValidUrl(url)) { return; @@ -404,7 +414,33 @@ export default function uploadcareField(config) { filesArray.forEach(addFile); - const formattedState = this.formatFilesForState(filesArray); + // CRITICAL FIX: If using metadata, we must ensure initial state is stored as objects, + // even if we received strings (URLs). Otherwise, subsequent updates (like updateFileUrl) + // will try to spread the string {...file}, creating a character map corruption. + let stateToStore = filesArray.map(file => { + let currentFile = file; + if (file && typeof file === 'object') { + if (!file.uuid) { + file.uuid = this.extractUuidFromUrl(file.cdnUrl); + } + return file; + } + + if (typeof file === 'string') { + const uuid = this.extractUuidFromUrl(file); + return { + cdnUrl: file, + uuid: uuid, + name: '', + size: 0, + mimeType: '', + isImage: false + }; + } + return file; + }); + + const formattedState = this.formatFilesForState(stateToStore); this.uploadedFiles = JSON.stringify(formattedState); this.initialState = this.uploadedFiles; }, @@ -466,6 +502,9 @@ export default function uploadcareField(config) { if (!Array.isArray(filesToAdd)) { filesToAdd = [filesToAdd]; } + + // Filter out nulls/undefined to prevent crashes + filesToAdd = filesToAdd.filter(item => item !== null && item !== undefined); if (filesToAdd.length === 0) { return false; @@ -477,14 +516,28 @@ export default function uploadcareField(config) { } const currentFiles = this.getCurrentFiles(); + console.log('[Uploadcare] addFilesFromState currentFiles', currentFiles.length, currentFiles); + const currentUrls = currentFiles.map(file => { - const url = typeof file === 'object' ? file.cdnUrl : file; + // FIX: Check for null/undefined file before accessing properties + if (!file) return null; + const url = (file && typeof file === 'object') ? file.cdnUrl : file; return url; - }).filter(Boolean); + }).filter(Boolean); // Filter out nulls - filesToAdd.forEach(item => { - const url = typeof item === 'object' ? item.cdnUrl : item; - if (url && typeof url === 'string' && url.includes('ucarecdn.com')) { + console.log(`[Uploadcare ${this.statePath}] addFilesFromState filesToAdd`, filesToAdd.length, filesToAdd); + + filesToAdd.forEach((item, index) => { + if (!item) { + console.warn(`[Uploadcare] Skipping null item at index ${index}`); + return; + } + + // FIX: Check for null/undefined item before accessing properties (double safety) + const url = (item && typeof item === 'object') ? item.cdnUrl : item; + + console.log(`[Uploadcare] Processing item ${index}`, { url, item }); + if (url && typeof url === 'string' && (url.includes('ucarecdn.com') || url.includes('ucarecd.net'))) { const urlExists = currentUrls.some(currentUrl => { const uuid1 = this.extractUuidFromUrl(url); const uuid2 = this.extractUuidFromUrl(currentUrl); @@ -501,7 +554,35 @@ export default function uploadcareField(config) { } }); - this.uploadedFiles = newValue; + // Deduplicate: merge newly arriving state with current, ensuring unique UUIDs + let finalStateArray = []; + const processedUuids = new Set(); + + const addUnique = (item) => { + if (!item) return; + const url = (item && typeof item === 'object') ? item.cdnUrl : item; + const uuid = this.extractUuidFromUrl(url); + if (uuid && !processedUuids.has(uuid)) { + processedUuids.add(uuid); + if (this.isWithMetadata && typeof item !== 'object') { + finalStateArray.push({ + cdnUrl: item, + uuid: uuid, + name: '', size: 0, mimeType: '', isImage: false + }); + } else { + finalStateArray.push(item); + } + } else if (!uuid) { + finalStateArray.push(item); // Fallback for things without UUIDs + } + }; + + // Re-build state from scratch to ensure uniqueness + const incomingState = this.parseStateValue(newValue) || []; + (Array.isArray(incomingState) ? incomingState : [incomingState]).forEach(addUnique); + + this.uploadedFiles = JSON.stringify(finalStateArray); this.isLocalUpdate = true; return true; }, @@ -511,8 +592,24 @@ export default function uploadcareField(config) { try { const parsed = typeof value === 'string' ? JSON.parse(value) : value; - return JSON.stringify(this.formatFilesForState(parsed)); - } catch (e) { + + // If already an array of strings or properly formatted objects, don't re-format + if (Array.isArray(parsed)) { + const allStringsOrProperObjects = parsed.every(item => + typeof item === 'string' || + (typeof item === 'object' && item !== null && ('cdnUrl' in item || 'uuid' in item)) + ); + if (allStringsOrProperObjects) { + return JSON.stringify(parsed); + } + console.log('[Uploadcare] normalizing mixed/raw array', parsed); + } + + const formatted = this.formatFilesForState(parsed); + console.log('[Uploadcare] normalizeStateValue result', formatted); + return JSON.stringify(formatted); + } catch (e) { + console.error('[Uploadcare] normalizeStateValue error', e); return value; } }, @@ -524,9 +621,12 @@ export default function uploadcareField(config) { }, setupEventListeners(api) { - const handleFileUploadSuccess = this.createFileUploadSuccessHandler(); - const handleFileUrlChanged = this.createFileUrlChangedHandler(); - const handleFileRemoved = this.createFileRemovedHandler(); + this.pendingUploads = []; + this.pendingRemovals = []; + const handleFileUploadSuccess = this.createFileUploadSuccessHandler(api); + const handleFileUrlChanged = this.createFileUrlChangedHandler(api); + const handleFileRemoved = this.createFileRemovedHandler(api); + // Form input change might still be useful for other changes, but we shouldn't rely on it for uploads if events work const handleFormInputChange = this.createFormInputChangeHandler(api); this.ctx.addEventListener('file-upload-started', (e) => { @@ -596,21 +696,30 @@ export default function uploadcareField(config) { }; }, - createFileUploadSuccessHandler() { + createFileUploadSuccessHandler(api) { let debounceTimer = null; return (e) => { + const fileData = this.isWithMetadata ? e.detail : e.detail.cdnUrl; + // Buffer the file + this.pendingUploads.push(fileData); + if (debounceTimer) { clearTimeout(debounceTimer); } debounceTimer = setTimeout(() => { - const fileData = this.isWithMetadata ? e.detail : e.detail.cdnUrl; - try { - const currentFiles = this.getCurrentFiles(); - const updatedFiles = this.updateFilesList(currentFiles, fileData); - this.updateState(updatedFiles); + let currentFiles = this.getCurrentFiles(); + + // Add all buffered files + // We use a loop or modify updateFilesList to accept array + for (const file of this.pendingUploads) { + currentFiles = this.updateFilesList(currentFiles, file); + } + + this.updateState(currentFiles); + this.pendingUploads = []; // Clear buffer const form = this.$el.closest('form'); if (form) { @@ -619,16 +728,15 @@ export default function uploadcareField(config) { } catch (error) { console.error('[Uploadcare] Error updating state after upload:', error); } - }, this.isMultiple ? 200 : 100); + }, 200); }; }, - createFileUrlChangedHandler() { + createFileUrlChangedHandler(api) { let debounceTimer = null; return (e) => { const fileDetails = e.detail; - // Removed strict check for cdnUrlModifiers to allow updates if only cdnUrl has changed if (!fileDetails || !fileDetails.cdnUrl) return; if (debounceTimer) { @@ -647,27 +755,29 @@ export default function uploadcareField(config) { }; }, - createFileRemovedHandler() { + createFileRemovedHandler(api) { let debounceTimer = null; return (e) => { + const removedFile = e.detail; + // Buffer the removal + this.pendingRemovals.push(removedFile); + if (debounceTimer) { clearTimeout(debounceTimer); } debounceTimer = setTimeout(() => { try { - const removedFile = e.detail; - const currentFiles = this.getCurrentFiles(); - const updatedFiles = this.removeFile(currentFiles, removedFile); - this.updateState(updatedFiles); + let currentFiles = this.getCurrentFiles(); - const api = this.getUploadcareApi(); - if (api) { - setTimeout(() => { - this.syncStateWithUploadcare(api); - }, 150); + // Process all buffered removals + for (const fileToRemove of this.pendingRemovals) { + currentFiles = this.removeFile(currentFiles, fileToRemove); } + + this.updateState(currentFiles); + this.pendingRemovals = []; // Clear buffer } catch (error) { console.error('Error in handleFileRemoved:', error); } @@ -676,16 +786,11 @@ export default function uploadcareField(config) { }, createFormInputChangeHandler(api) { - let debounceTimer = null; - + // Deprecated/Secondary: Only use for fallback if state is empty but input has value? + // For now, disabling auto-sync to avoid overriding the event-based source of truth + // unless we strictly handle external changes. return (e) => { - if (debounceTimer) { - clearTimeout(debounceTimer); - } - - debounceTimer = setTimeout(() => { - this.syncStateWithUploadcare(api); - }, 200); + // no-op or specific logic if needed }; }, @@ -700,15 +805,16 @@ export default function uploadcareField(config) { updateFilesList(currentFiles, newFile) { if (this.isMultiple) { + const newUuid = this.extractUuidFromUrl(newFile); + const isDuplicate = currentFiles.some(file => { - const existingUrl = typeof file === 'object' ? file.cdnUrl : file; - const newUrl = typeof newFile === 'object' ? newFile.cdnUrl : newFile; - return existingUrl === newUrl; + return this.extractUuidFromUrl(file) === newUuid; }); if (!isDuplicate) { return [...currentFiles, newFile]; } + console.log(`[Uploadcare ${this.statePath}] Skipping duplicate file`, newUuid); return currentFiles; } return [newFile]; @@ -733,7 +839,22 @@ export default function uploadcareField(config) { let updatedFile; if (this.isWithMetadata) { - const originalFile = currentFiles[fileIndex]; + let originalFile = currentFiles[fileIndex]; + + // CRITICAL FIX: Ensure originalFile is an object before spreading + // If it's a string (URL), convert it to an object first to prevent character map corruption + if (typeof originalFile === 'string') { + const uuid = this.extractUuidFromUrl(originalFile); + originalFile = { + cdnUrl: originalFile, + uuid: uuid, + name: '', + size: 0, + mimeType: '', + isImage: false + }; + } + // Merge with existing file to preserve properties like uuid if missing in detail updatedFile = { ...originalFile, ...fileDetails }; } else { @@ -759,39 +880,124 @@ export default function uploadcareField(config) { }, findFileIndex(files, uuid) { + if (!uuid) return -1; return files.findIndex(file => { - const fileUrl = typeof file === 'object' ? file.cdnUrl : file; - return fileUrl && fileUrl.includes(uuid); + const fileUrl = (file && typeof file === 'object') ? file.cdnUrl : file; + const fileUuid = this.extractUuidFromUrl(fileUrl); + return fileUuid === uuid; }); }, updateState(files) { - const finalFiles = this.formatFilesForState(files); + // Deduplicate by UUID + const processedUuids = new Set(); + const uniqueFiles = files.filter(file => { + const url = (file && typeof file === 'object') ? file.cdnUrl : file; + const uuid = this.extractUuidFromUrl(url); + if (uuid) { + if (processedUuids.has(uuid)) return false; + processedUuids.add(uuid); + return true; + } + return true; + }); + + console.log(`[Uploadcare ${this.statePath}] updateState`, { + incomingCount: files.length, + uniqueCount: uniqueFiles.length, + uniqueUuids: Array.from(processedUuids) + }); + + const finalFiles = this.formatFilesForState(uniqueFiles); const newState = JSON.stringify(finalFiles); const currentFiles = this.getCurrentFiles(); const currentStateNormalized = JSON.stringify(this.formatFilesForState(currentFiles)); - const newStateNormalized = JSON.stringify(this.formatFilesForState(finalFiles)); + const newStateNormalized = JSON.stringify(finalFiles); const hasActuallyChanged = currentStateNormalized !== newStateNormalized; if (hasActuallyChanged) { + console.log(`[Uploadcare ${this.statePath}] State HAS changed, updating uploadedFiles`); this.uploadedFiles = newState; this.isLocalUpdate = true; this.state = this.uploadedFiles; - if (this.isMultiple && files.length > 1) { + if (this.isMultiple && uniqueFiles.length > 1) { this.$nextTick(() => { this.isLocalUpdate = false; }); } + } else { + console.log(`[Uploadcare ${this.statePath}] State has NOT moved, ignoring update`); } }, formatFilesForState(files) { + // CRITICAL: Ensure files is always an array + if (!files) return []; + if (!Array.isArray(files)) { + console.warn('[Uploadcare] formatFilesForState called with non-array:', typeof files, files); + // If it's a string, try to parse it + if (typeof files === 'string') { + try { + const parsed = JSON.parse(files); + if (Array.isArray(parsed)) { + files = parsed; + } else { + return []; + } + } catch { + return []; + } + } else { + return []; + } + } + return files.map(file => { + // SELF-HEALING: Detect character-mapped strings (e.g. {"0":"h","1":"t".,..}) + if (file && typeof file === 'object' && !file.cdnUrl && !file.uuid && '0' in file) { + const keys = Object.keys(file); + // If it looks like an array-like object with sequential keys (0, 1, 2...) + if (keys.length > 5 && keys.includes('0') && keys.includes('1') && keys.includes('2')) { + // Attempt to reconstruct the string + let reconstructed = ''; + // We can't rely on Object.values order, so we accept iteration if keys are sequential-ish, + // but standard Object.values works for integer keys usually. + // Safer: assume it's array-like + const maxKey = Math.max(...keys.map(k => parseInt(k)).filter(n => !isNaN(n))); + if (maxKey === keys.length - 1) { + const arr = new Array(keys.length); + for (let i = 0; i < keys.length; i++) { + arr[i] = file[i]; + } + reconstructed = arr.join(''); + } else { + // Fallback to simple join if needed + reconstructed = Object.values(file).join(''); + } + + if (reconstructed.match(/^https?:\/\//)) { + console.warn('[Uploadcare] SELF-HEALED CORRUPTED STRING:', reconstructed); + if (this.isWithMetadata) { + const uuid = this.extractUuidFromUrl(reconstructed); + return { + cdnUrl: reconstructed, + uuid: uuid, + name: '', + size: 0, + mimeType: '', + isImage: false + }; + } + return reconstructed; + } + } + } + if (this.isWithMetadata) { return file; } - return typeof file === 'object' ? file.cdnUrl : file; + return (file && typeof file === 'object') ? file.cdnUrl : file; }); }, @@ -823,27 +1029,64 @@ export default function uploadcareField(config) { } }, - extractUuidFromUrl(url) { + extractUuidFromUrl(urlOrObject) { + if (!urlOrObject) return null; + + let url = urlOrObject; + if (typeof urlOrObject === 'object') { + if (urlOrObject.uuid) return urlOrObject.uuid; + url = urlOrObject.cdnUrl || ''; + } + if (!url || typeof url !== 'string') { return null; } + // Check if string is just a UUID + const uuidPattern = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i; + if (uuidPattern.test(url)) { + return url; + } + const uuidMatch = url.match(/\/([a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})(?:\/|$)/i); if (uuidMatch && uuidMatch[1]) { return uuidMatch[1]; } - if (typeof url === 'object' && url.uuid) { - return url.uuid; - } - return null; }, - syncStateWithUploadcare(api) { + + async syncStateWithUploadcare(api) { try { - const currentFiles = this.getCurrentFilesFromUploadcare(api); + let currentFiles = this.getCurrentFilesFromUploadcare(api); + + // Handle Group URLs + if (currentFiles.length > 0) { + const flattenedFiles = []; + for (const file of currentFiles) { + console.log(`[Uploadcare ${this.statePath}] syncStateWithUploadcare - state item`, file); + const url = (file && typeof file === 'object') ? file.cdnUrl : file; + // Check for Group UUID or URL (uuid~count) + if (typeof url === 'string' && url.match(/[a-f0-9-]{36}~[0-9]+/)) { + console.log('[Uploadcare] Found group URL:', url); + try { + const groupFiles = await this.fetchGroupFiles(url); + console.log('[Uploadcare] Expanded group to:', groupFiles.length, 'files'); + flattenedFiles.push(...groupFiles); + } catch (e) { + console.error('[Uploadcare] Failed to expand group:', url, e); + flattenedFiles.push(file); // Fallback to original + } + } else { + flattenedFiles.push(file); + } + } + console.log('[Uploadcare] Flattened files count:', flattenedFiles.length); + currentFiles = flattenedFiles; + } + const formattedFiles = this.formatFilesForState(currentFiles); const newState = this.buildStateFromFiles(formattedFiles); const currentStateNormalized = this.normalizeStateValue(this.uploadedFiles); @@ -859,6 +1102,44 @@ export default function uploadcareField(config) { } }, + async fetchGroupFiles(groupUrlOrUuid) { + // Extract group ID (uuid~count) + let groupId = groupUrlOrUuid; + if (groupUrlOrUuid.includes('ucarecdn.com') || groupUrlOrUuid.includes('ucarecd.net')) { + const match = groupUrlOrUuid.match(/\/([a-f0-9-]{36}~[0-9]+)/); + if (match) { + groupId = match[1]; + } + } + + // Use Upload API to get group info as CDN endpoint returns HTML widget + const response = await fetch(`https://upload.uploadcare.com/group/info/?pub_key=${this.publicKey}&group_id=${groupId}`); + if (!response.ok) { + throw new Error(`Failed to fetch group info: ${response.statusText}`); + } + + const data = await response.json(); + if (!data.files) { + return []; + } + + // Map to the format expected by the component + return data.files.map(file => { + const cdnUrl = `https://ucarecdn.com/${file.uuid}/`; + if (this.isWithMetadata) { + return { + uuid: file.uuid, + cdnUrl: cdnUrl, + name: file.original_filename, + size: file.size, + mimeType: file.mime_type, + isImage: file.is_image, + }; + } + return cdnUrl; + }); + }, + buildStateFromFiles(formattedFiles) { if (this.isMultiple) { return JSON.stringify(formattedFiles); @@ -873,14 +1154,27 @@ export default function uploadcareField(config) { getCurrentFilesFromUploadcare(api) { try { + // Use widget API directly if available + if (api && typeof api.value === 'function') { + const value = api.value(); + if (value) { + if (Array.isArray(value)) { + return value.filter(item => item !== null && item !== undefined); + } + const parsed = this.parseFormInputValue(value); + return parsed.filter(item => item !== null && item !== undefined); + } + return []; + } + const formInput = this.$el.querySelector('uc-form-input input'); if (formInput) { - return this.parseFormInputValue(formInput.value); + const parsed = this.parseFormInputValue(formInput.value); + return parsed.filter(item => item !== null && item !== undefined); } - const fileItems = this.$el.querySelectorAll('uc-file-item, [data-file-item]'); - return fileItems.length === 0 ? [] : []; + return []; } catch (error) { console.error('Error getting current files from Uploadcare:', error); return []; @@ -892,6 +1186,11 @@ export default function uploadcareField(config) { return []; } + // If it's a raw Uploadcare object/collection + if (typeof inputValue === 'object') { + return [inputValue]; // Or handle collection + } + try { const parsed = JSON.parse(inputValue); diff --git a/packages/uploadcare-field/src/Uploadcare.php b/packages/uploadcare-field/src/Uploadcare.php index 28333f98..41c2bd22 100755 --- a/packages/uploadcare-field/src/Uploadcare.php +++ b/packages/uploadcare-field/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 02ac1641849c99accf1e264cf0d224bb169a169b Mon Sep 17 00:00:00 2001 From: Baspa Date: Wed, 31 Dec 2025 14:31:02 +0100 Subject: [PATCH 14/43] wip --- .../dist/filament-uploadcare-field.js | 2 +- .../resources/js/components/uploadcare.js | 91 ++++++++----------- .../Observers/ContentFieldValueObserver.php | 24 +++-- 3 files changed, 49 insertions(+), 68 deletions(-) diff --git a/packages/filament-uploadcare-field/resources/dist/filament-uploadcare-field.js b/packages/filament-uploadcare-field/resources/dist/filament-uploadcare-field.js index 408c1f27..345cad42 100644 --- a/packages/filament-uploadcare-field/resources/dist/filament-uploadcare-field.js +++ b/packages/filament-uploadcare-field/resources/dist/filament-uploadcare-field.js @@ -1 +1 @@ -var f=class{constructor(e){this.wrapper=e,this.observer=null,this.init()}init(){this.hideDoneButtons(),this.setupObserver()}setupObserver(){this.observer=new MutationObserver(e=>{e.forEach(t=>{t.type==="childList"&&t.addedNodes.forEach(i=>{if(i.nodeType===Node.ELEMENT_NODE){i.classList&&i.classList.contains("uc-done-btn")&&this.hideDoneButton(i);let a=i.querySelectorAll&&i.querySelectorAll(".uc-done-btn");a&&a.forEach(s=>this.hideDoneButton(s))}})})}),this.wrapper&&this.observer.observe(this.wrapper,{childList:!0,subtree:!0})}hideDoneButtons(){document.querySelectorAll(".uc-done-btn").forEach(t=>this.hideDoneButton(t))}hideDoneButton(e){e&&(e.style.display="none",e.style.visibility="hidden",e.style.opacity="0",e.style.pointerEvents="none",e.style.position="absolute",e.style.width="0",e.style.height="0",e.style.overflow="hidden",e.style.clip="rect(0, 0, 0, 0)",e.style.margin="0",e.style.padding="0",e.style.border="0",e.style.background="transparent",e.style.color="transparent",e.style.fontSize="0",e.style.lineHeight="0")}destroy(){this.observer&&(this.observer.disconnect(),this.observer=null)}};function g(p){return window._initializedUploadcareContexts||(window._initializedUploadcareContexts=new Set),{state:p.state,statePath:p.statePath,initialState:p.initialState,publicKey:p.publicKey,isMultiple:p.isMultiple,multipleMin:p.multipleMin,multipleMax:p.multipleMax,isImagesOnly:p.isImagesOnly,accept:p.accept,sourceList:p.sourceList,uploaderStyle:p.uploaderStyle,isWithMetadata:p.isWithMetadata,localeName:p.localeName||"en",uploadedFiles:"",ctx:null,removeEventListeners:null,uniqueContextName:p.uniqueContextName,isInitialized:!1,stateHasBeenInitialized:!1,isStateWatcherActive:!1,isLocalUpdate:!1,doneButtonHider:null,documentClassObserver:null,formInputObserver:null,async init(){this.isContextAlreadyInitialized()||(this.markContextAsInitialized(),this.applyTheme(),await this.loadAllLocales(),this.setupStateWatcher(),this.$el.addEventListener("uploadcare-state-updated",e=>{let t=e.detail.uuid;t&&this.isInitialized?this.loadFileFromUuid(t):t&&this.$nextTick(()=>{this.isInitialized&&this.loadFileFromUuid(t)})}),this.initUploadcare(),this.setupThemeObservers(),this.setupDoneButtonObserver())},isContextAlreadyInitialized(){return window._initializedUploadcareContexts.has(this.uniqueContextName)},markContextAsInitialized(){window._initializedUploadcareContexts.add(this.uniqueContextName)},async loadAllLocales(){window._uploadcareAllLocalesLoaded||await new Promise(t=>{if(window._uploadcareAllLocalesLoaded){t();return}let i=setInterval(()=>{window._uploadcareAllLocalesLoaded&&(clearInterval(i),t())},100);setTimeout(()=>{clearInterval(i),t()},5e3)});let e=["de","es","fr","he","it","nl","pl","pt","ru","tr","uk","zh-TW","zh"];document.querySelectorAll("uc-config[data-locale-name]").forEach(t=>{let i=t.getAttribute("data-locale-name");i&&e.includes(i)&&!t.getAttribute("locale-name")&&t.setAttribute("locale-name",i)})},async loadLocale(){if(this.localeName==="en"||this.localeLoaded)return;if(window._uploadcareLocales&&window._uploadcareLocales.has(this.localeName)){this.localeLoaded=!0;return}if(window._uploadcareLocales||(window._uploadcareLocales=new Set),!!["de","es","fr","he","it","nl","pl","pt","ru","tr","uk","zh-TW","zh"].includes(this.localeName))try{let i=await import(`https://cdn.jsdelivr.net/npm/@uploadcare/file-uploader@v1/locales/file-uploader/${this.localeName}.js`),a=i.default||i,s=()=>{let r=customElements.get("uc-file-uploader-inline")||customElements.get("uc-file-uploader-regular")||customElements.get("uc-file-uploader-minimal");return r&&r.UC?r.UC:window.UC},n=()=>{let r=s();return r&&typeof r.defineLocale=="function"?(r.defineLocale(this.localeName,a),window._uploadcareLocales.add(this.localeName),this.localeLoaded=!0,!0):!1};if(!n()){let r=0,l=50,d=setInterval(()=>{r++,(n()||r>=l)&&clearInterval(d)},100)}}catch(t){console.error("[Uploadcare Locale JS] Failed to load locale:",this.localeName,t)}},applyTheme(){let e=this.getCurrentTheme();document.querySelectorAll(`uc-file-uploader-${this.uploaderStyle}`).forEach(i=>{i.classList.remove("uc-dark","uc-light"),i.classList.add(`uc-${e}`)})},getCurrentTheme(){return document.documentElement.classList.contains("dark")?"dark":"light"},setupThemeObservers(){window.addEventListener("storage",this.handleThemeStorageChange.bind(this)),window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change",this.handleSystemThemeChange.bind(this)),this.setupDocumentClassObserver()},handleThemeStorageChange(e){e.key==="theme"&&this.applyTheme()},handleSystemThemeChange(){localStorage.getItem("theme")==="system"&&this.applyTheme()},setupDocumentClassObserver(){this.documentClassObserver=new MutationObserver(e=>{e.forEach(t=>{if(t.type==="attributes"&&t.attributeName==="class"){let i=document.documentElement.classList.contains("dark"),a=t.oldValue&&t.oldValue.includes("dark");i!==a&&this.applyTheme()}})}),this.documentClassObserver.observe(document.documentElement,{attributes:!0,attributeOldValue:!0,attributeFilter:["class"]})},initUploadcare(){this.removeEventListeners&&this.removeEventListeners(),this.initializeUploader()},initializeUploader(e=0,t=10){if(e>=t)return;this.ctx=document.querySelector(`uc-upload-ctx-provider[ctx-name="${this.uniqueContextName}"]`);let i=this.getUploadcareApi();if(!this.isValidContext(i)){setTimeout(()=>this.initializeUploader(e+1,t),100);return}this.markAsInitialized(),this.removeRequiredAttributes(),this.initializeState(i),this.setupEventListeners(i)},getUploadcareApi(){try{return this.ctx?.getAPI()}catch{return null}},isValidContext(e){return this.ctx&&e&&e.addFileFromCdnUrl},markAsInitialized(){this.isInitialized=!0},removeRequiredAttributes(){setTimeout(()=>{let e=this.$el.closest("uc-config");document.querySelectorAll("uc-form-input input[required]").forEach(i=>i.removeAttribute("required"))},100)},initializeState(e){this.$nextTick(()=>{console.log(`[Uploadcare ${this.statePath}] initializeState`,{hasInitialState:!!this.initialState,initialStatePreview:typeof this.initialState=="string"?this.initialState.substring(0,100):this.initialState,stateHasBeenInitialized:this.stateHasBeenInitialized,uploadedFiles:this.uploadedFiles,uniqueContextName:this.uniqueContextName}),this.initialState&&!this.stateHasBeenInitialized&&!this.uploadedFiles?this.loadInitialState(e):!this.initialState&&!this.stateHasBeenInitialized&&(this.stateHasBeenInitialized=!0,this.uploadedFiles=this.isMultiple?"[]":"",this.isLocalUpdate=!0,this.state=this.uploadedFiles)})},loadInitialState(e){try{let t=this.parseInitialState();this.addFilesFromInitialState(e,t),this.stateHasBeenInitialized=!0,this.isLocalUpdate=!0,this.state=this.uploadedFiles}catch(t){console.error("Error parsing initialState:",t)}},parseInitialState(){let e=i=>{if(typeof i=="string")try{let a=JSON.parse(i);if(typeof a=="string")try{a=JSON.parse(a)}catch{}return a}catch{return i}return i};this.initialState&&this.initialState&&typeof this.initialState=="object"&&!Array.isArray(this.initialState)&&(this.initialState=[this.initialState]);let t=this.parseStateValue(this.initialState);return console.log(`[Uploadcare ${this.statePath}] initializeState`,{hasInitialState:!!this.initialState,parsedCount:Array.isArray(t)?t.length:"n/a"}),t},addFilesFromInitialState(e,t){let i=[];if(t&&t&&typeof t=="object"&&!Array.isArray(t))try{i=Array.from(t)}catch{i=[t]}else Array.isArray(t)?i=t:t&&(i=[t]);if(Array.isArray(i)&&i.length===1&&Array.isArray(i[0])&&(i=i[0]),Array.isArray(i)&&i.length===1&&typeof i[0]=="string")try{let r=JSON.parse(i[0]);i=Array.isArray(r)?r:[r]}catch{}if(!Array.isArray(i)||i.length===0)return;Array.isArray(i)||(i=[i]);let a=(r,l=0)=>{if(!r)return;if(Array.isArray(r)){r.forEach((u,c)=>{a(u,`${l}.${c}`)});return}if(typeof r=="string")try{let u=JSON.parse(r);a(u,l);return}catch{}let d=r&&typeof r=="object"?r.cdnUrl:r,h=r&&typeof r=="object"?r.cdnUrlModifiers:null;if(console.log(`[Uploadcare ${this.statePath}] addFilesFromInitialState adding item`,{index:l,url:d}),!d||!this.isValidUrl(d))return;let o=this.extractUuidFromUrl(d);if(o&&typeof e.addFileFromUuid=="function")try{if(h&&typeof e.addFileFromCdnUrl=="function"){let c=d.split("/-/")[0]+"/"+h;e.addFileFromCdnUrl(c)}else e.addFileFromUuid(o)}catch(u){console.error(`Failed to add file ${l} with UUID ${o}:`,u)}else console.error(o?"addFileFromUuid method not available on API":`Could not extract UUID from URL: ${d}`)};i.forEach(a);let s=i.map(r=>{let l=r;if(r&&typeof r=="object")return r.uuid||(r.uuid=this.extractUuidFromUrl(r.cdnUrl)),r;if(typeof r=="string"){let d=this.extractUuidFromUrl(r);return{cdnUrl:r,uuid:d,name:"",size:0,mimeType:"",isImage:!1}}return r}),n=this.formatFilesForState(s);this.uploadedFiles=JSON.stringify(n),this.initialState=this.uploadedFiles},isValidUrl(e){if(!e||typeof e!="string")return!1;try{return new URL(e),!0}catch{return!1}},setupStateWatcher(){this.$watch("state",(e,t)=>{if(this.isLocalUpdate){this.isLocalUpdate=!1;return}if(!this.stateHasBeenInitialized){this.stateHasBeenInitialized=!0;return}if((!e||e==="[]"||e==='""')&&!this.uploadedFiles)return;let i=this.normalizeStateValue(e),a=this.normalizeStateValue(this.uploadedFiles);i!==a&&e&&e!=="[]"&&e!=='""'&&this.addFilesFromState(e)})},parseStateValue(e){if(!e)return null;try{return typeof e=="string"?JSON.parse(e):e}catch{return e}},addFilesFromState(e){let i=this.parseStateValue(e);if(Array.isArray(i)||(i=[i]),i=i.filter(o=>o!=null),i.length===0)return!1;let a=this.getUploadcareApi();if(!a||typeof a.addFileFromCdnUrl!="function")return!1;let s=this.getCurrentFiles();console.log("[Uploadcare] addFilesFromState currentFiles",s.length,s);let n=s.map(o=>o?o&&typeof o=="object"?o.cdnUrl:o:null).filter(Boolean);console.log(`[Uploadcare ${this.statePath}] addFilesFromState filesToAdd`,i.length,i),i.forEach((o,u)=>{if(!o){console.warn(`[Uploadcare] Skipping null item at index ${u}`);return}let c=o&&typeof o=="object"?o.cdnUrl:o;if(console.log(`[Uploadcare] Processing item ${u}`,{url:c,item:o}),c&&typeof c=="string"&&(c.includes("ucarecdn.com")||c.includes("ucarecd.net"))&&!n.some(m=>{let y=this.extractUuidFromUrl(c),U=this.extractUuidFromUrl(m);return y&&U&&y===U}))try{a.addFileFromCdnUrl(c)}catch(m){console.error("[Uploadcare] Failed to add file from URL:",c,m)}});let r=[],l=new Set,d=o=>{if(!o)return;let u=o&&typeof o=="object"?o.cdnUrl:o,c=this.extractUuidFromUrl(u);c&&!l.has(c)?(l.add(c),this.isWithMetadata&&typeof o!="object"?r.push({cdnUrl:o,uuid:c,name:"",size:0,mimeType:"",isImage:!1}):r.push(o)):c||r.push(o)},h=this.parseStateValue(e)||[];return(Array.isArray(h)?h:[h]).forEach(d),this.uploadedFiles=JSON.stringify(r),this.isLocalUpdate=!0,!0},normalizeStateValue(e){if(!e)return"";try{let t=typeof e=="string"?JSON.parse(e):e;if(Array.isArray(t)){if(t.every(s=>typeof s=="string"||typeof s=="object"&&s!==null&&("cdnUrl"in s||"uuid"in s)))return JSON.stringify(t);console.log("[Uploadcare] normalizing mixed/raw array",t)}let i=this.formatFilesForState(t);return console.log("[Uploadcare] normalizeStateValue result",i),JSON.stringify(i)}catch(t){return console.error("[Uploadcare] normalizeStateValue error",t),e}},isStateChanged(){let e=this.normalizeStateValue(this.state),t=this.normalizeStateValue(this.initialState);return e!==t},setupEventListeners(e){this.pendingUploads=[],this.pendingRemovals=[];let t=this.createFileUploadSuccessHandler(e),i=this.createFileUrlChangedHandler(e),a=this.createFileRemovedHandler(e),s=this.createFormInputChangeHandler(e);this.ctx.addEventListener("file-upload-started",n=>{let r=this.$el.closest("form");r&&r.dispatchEvent(new CustomEvent("form-processing-started",{detail:{message:"Uploading file..."}}))}),this.ctx.addEventListener("file-upload-success",t),this.ctx.addEventListener("file-url-changed",i),this.ctx.addEventListener("file-removed",a),this.$nextTick(()=>{let n=this.$el.querySelector("uc-form-input input");if(n){n.addEventListener("input",s),n.addEventListener("change",s);let r=new MutationObserver(()=>{s({target:n})});r.observe(n,{attributes:!0,attributeFilter:["value"]}),this.formInputObserver=r}else setTimeout(()=>{let r=this.$el.querySelector("uc-form-input input");r&&(r.addEventListener("input",s),r.addEventListener("change",s))},200)}),this.removeEventListeners=()=>{this.ctx.removeEventListener("file-upload-started",r=>{let l=this.$el.closest("form");l&&l.dispatchEvent(new CustomEvent("form-processing-started",{detail:{message:"Uploading file..."}}))}),this.ctx.removeEventListener("file-upload-success",t),this.ctx.removeEventListener("file-url-changed",i),this.ctx.removeEventListener("file-removed",a);let n=this.$el.querySelector("uc-form-input input");n&&(n.removeEventListener("input",s),n.removeEventListener("change",s)),this.formInputObserver&&(this.formInputObserver.disconnect(),this.formInputObserver=null)}},createFileUploadSuccessHandler(e){let t=null;return i=>{let a=this.isWithMetadata?i.detail:i.detail.cdnUrl;this.pendingUploads.push(a),t&&clearTimeout(t),t=setTimeout(()=>{try{let s=this.getCurrentFiles();for(let r of this.pendingUploads)s=this.updateFilesList(s,r);this.updateState(s),this.pendingUploads=[];let n=this.$el.closest("form");n&&n.dispatchEvent(new CustomEvent("form-processing-finished"))}catch(s){console.error("[Uploadcare] Error updating state after upload:",s)}},200)}},createFileUrlChangedHandler(e){let t=null;return i=>{let a=i.detail;!a||!a.cdnUrl||(t&&clearTimeout(t),t=setTimeout(()=>{try{let s=this.getCurrentFiles(),n=this.updateFileUrl(s,a);this.updateState(n)}catch(s){console.error("Error updating state after URL change:",s)}},100))}},createFileRemovedHandler(e){let t=null;return i=>{let a=i.detail;this.pendingRemovals.push(a),t&&clearTimeout(t),t=setTimeout(()=>{try{let s=this.getCurrentFiles();for(let n of this.pendingRemovals)s=this.removeFile(s,n);this.updateState(s),this.pendingRemovals=[]}catch(s){console.error("Error in handleFileRemoved:",s)}},100)}},createFormInputChangeHandler(e){return t=>{}},getCurrentFiles(){try{let e=this.uploadedFiles?JSON.parse(this.uploadedFiles):[];return Array.isArray(e)?e:[]}catch{return[]}},updateFilesList(e,t){if(this.isMultiple){let i=this.extractUuidFromUrl(t);return e.some(s=>this.extractUuidFromUrl(s)===i)?(console.log(`[Uploadcare ${this.statePath}] Skipping duplicate file`,i),e):[...e,t]}return[t]},updateFileUrl(e,t){let i=t.uuid;if(!i&&t.cdnUrl&&(i=this.extractUuidFromUrl(t.cdnUrl)),!i)return e;t.uuid||(t={...t,uuid:i});let a=this.findFileIndex(e,i);if(a===-1)return e;let s;if(this.isWithMetadata){let n=e[a];if(typeof n=="string"){let r=this.extractUuidFromUrl(n);n={cdnUrl:n,uuid:r,name:"",size:0,mimeType:"",isImage:!1}}s={...n,...t}}else s=t.cdnUrl;return this.isMultiple?(e[a]=s,e):[s]},removeFile(e,t){let i=this.findFileIndex(e,t.uuid);return i===-1?e:this.isMultiple?(e.splice(i,1),e):[]},findFileIndex(e,t){return t?e.findIndex(i=>{let a=i&&typeof i=="object"?i.cdnUrl:i;return this.extractUuidFromUrl(a)===t}):-1},updateState(e){let t=new Set,i=e.filter(h=>{let o=h&&typeof h=="object"?h.cdnUrl:h,u=this.extractUuidFromUrl(o);return u?t.has(u)?!1:(t.add(u),!0):!0});console.log(`[Uploadcare ${this.statePath}] updateState`,{incomingCount:e.length,uniqueCount:i.length,uniqueUuids:Array.from(t)});let a=this.formatFilesForState(i),s=JSON.stringify(a),n=this.getCurrentFiles(),r=JSON.stringify(this.formatFilesForState(n)),l=JSON.stringify(a);r!==l?(console.log(`[Uploadcare ${this.statePath}] State HAS changed, updating uploadedFiles`),this.uploadedFiles=s,this.isLocalUpdate=!0,this.state=this.uploadedFiles,this.isMultiple&&i.length>1&&this.$nextTick(()=>{this.isLocalUpdate=!1})):console.log(`[Uploadcare ${this.statePath}] State has NOT moved, ignoring update`)},formatFilesForState(e){if(!e)return[];if(!Array.isArray(e))if(console.warn("[Uploadcare] formatFilesForState called with non-array:",typeof e,e),typeof e=="string")try{let t=JSON.parse(e);if(Array.isArray(t))e=t;else return[]}catch{return[]}else return[];return e.map(t=>{if(t&&typeof t=="object"&&!t.cdnUrl&&!t.uuid&&"0"in t){let i=Object.keys(t);if(i.length>5&&i.includes("0")&&i.includes("1")&&i.includes("2")){let a="";if(Math.max(...i.map(n=>parseInt(n)).filter(n=>!isNaN(n)))===i.length-1){let n=new Array(i.length);for(let r=0;r0){let r=[];for(let l of t){console.log(`[Uploadcare ${this.statePath}] syncStateWithUploadcare - state item`,l);let d=l&&typeof l=="object"?l.cdnUrl:l;if(typeof d=="string"&&d.match(/[a-f0-9-]{36}~[0-9]+/)){console.log("[Uploadcare] Found group URL:",d);try{let h=await this.fetchGroupFiles(d);console.log("[Uploadcare] Expanded group to:",h.length,"files"),r.push(...h)}catch(h){console.error("[Uploadcare] Failed to expand group:",d,h),r.push(l)}}else r.push(l)}console.log("[Uploadcare] Flattened files count:",r.length),t=r}let i=this.formatFilesForState(t),a=this.buildStateFromFiles(i),s=this.normalizeStateValue(this.uploadedFiles),n=this.normalizeStateValue(a);s!==n&&(this.uploadedFiles=a,this.isLocalUpdate=!0,this.state=this.uploadedFiles)}catch(t){console.error("Error syncing state with Uploadcare:",t)}},async fetchGroupFiles(e){let t=e;if(e.includes("ucarecdn.com")||e.includes("ucarecd.net")){let s=e.match(/\/([a-f0-9-]{36}~[0-9]+)/);s&&(t=s[1])}let i=await fetch(`https://upload.uploadcare.com/group/info/?pub_key=${this.publicKey}&group_id=${t}`);if(!i.ok)throw new Error(`Failed to fetch group info: ${i.statusText}`);let a=await i.json();return a.files?a.files.map(s=>{let n=`https://ucarecdn.com/${s.uuid}/`;return this.isWithMetadata?{uuid:s.uuid,cdnUrl:n,name:s.original_filename,size:s.size,mimeType:s.mime_type,isImage:s.is_image}:n}):[]},buildStateFromFiles(e){return this.isMultiple?JSON.stringify(e):e.length>0?this.isWithMetadata?JSON.stringify(e[0]):e[0]:""},getCurrentFilesFromUploadcare(e){try{if(e&&typeof e.value=="function"){let i=e.value();return i?Array.isArray(i)?i.filter(s=>s!=null):this.parseFormInputValue(i).filter(s=>s!=null):[]}let t=this.$el.querySelector("uc-form-input input");return t?this.parseFormInputValue(t.value).filter(a=>a!=null):[]}catch(t){return console.error("Error getting current files from Uploadcare:",t),[]}},parseFormInputValue(e){if(!e||typeof e=="string"&&e.trim()==="")return[];if(typeof e=="object")return[e];try{let t=JSON.parse(e);return Array.isArray(t)?t.filter(i=>i!==null&&i!==""):t!==null&&t!==""?[t]:[]}catch{return typeof e=="string"&&e.trim()!==""?[e]:[]}}}}export{g as default}; +var f=class{constructor(t){this.wrapper=t,this.observer=null,this.init()}init(){this.hideDoneButtons(),this.setupObserver()}setupObserver(){this.observer=new MutationObserver(t=>{t.forEach(e=>{e.type==="childList"&&e.addedNodes.forEach(i=>{if(i.nodeType===Node.ELEMENT_NODE){i.classList&&i.classList.contains("uc-done-btn")&&this.hideDoneButton(i);let s=i.querySelectorAll&&i.querySelectorAll(".uc-done-btn");s&&s.forEach(a=>this.hideDoneButton(a))}})})}),this.wrapper&&this.observer.observe(this.wrapper,{childList:!0,subtree:!0})}hideDoneButtons(){document.querySelectorAll(".uc-done-btn").forEach(e=>this.hideDoneButton(e))}hideDoneButton(t){t&&(t.style.display="none",t.style.visibility="hidden",t.style.opacity="0",t.style.pointerEvents="none",t.style.position="absolute",t.style.width="0",t.style.height="0",t.style.overflow="hidden",t.style.clip="rect(0, 0, 0, 0)",t.style.margin="0",t.style.padding="0",t.style.border="0",t.style.background="transparent",t.style.color="transparent",t.style.fontSize="0",t.style.lineHeight="0")}destroy(){this.observer&&(this.observer.disconnect(),this.observer=null)}};function U(p){return window._initializedUploadcareContexts||(window._initializedUploadcareContexts=new Set),{state:p.state,statePath:p.statePath,initialState:p.initialState,publicKey:p.publicKey,isMultiple:p.isMultiple,multipleMin:p.multipleMin,multipleMax:p.multipleMax,isImagesOnly:p.isImagesOnly,accept:p.accept,sourceList:p.sourceList,uploaderStyle:p.uploaderStyle,isWithMetadata:p.isWithMetadata,localeName:p.localeName||"en",uploadedFiles:"",ctx:null,removeEventListeners:null,uniqueContextName:p.uniqueContextName,pendingUploads:[],pendingRemovals:[],instanceId:Math.random().toString(36).substring(7),isInitialized:!1,stateHasBeenInitialized:!1,isStateWatcherActive:!1,isLocalUpdate:!1,doneButtonHider:null,documentClassObserver:null,formInputObserver:null,async init(){this.isContextAlreadyInitialized()||(this.markContextAsInitialized(),this.applyTheme(),await this.loadAllLocales(),this.setupStateWatcher(),this.$el.addEventListener("uploadcare-state-updated",t=>{let e=t.detail.uuid;e&&this.isInitialized?this.loadFileFromUuid(e):e&&this.$nextTick(()=>{this.isInitialized&&this.loadFileFromUuid(e)})}),this.initUploadcare(),this.setupThemeObservers(),this.setupDoneButtonObserver())},isContextAlreadyInitialized(){return window._initializedUploadcareContexts.has(this.uniqueContextName)},markContextAsInitialized(){window._initializedUploadcareContexts.add(this.uniqueContextName)},async loadAllLocales(){window._uploadcareAllLocalesLoaded||await new Promise(e=>{if(window._uploadcareAllLocalesLoaded){e();return}let i=setInterval(()=>{window._uploadcareAllLocalesLoaded&&(clearInterval(i),e())},100);setTimeout(()=>{clearInterval(i),e()},5e3)});let t=["de","es","fr","he","it","nl","pl","pt","ru","tr","uk","zh-TW","zh"];document.querySelectorAll("uc-config[data-locale-name]").forEach(e=>{let i=e.getAttribute("data-locale-name");i&&t.includes(i)&&!e.getAttribute("locale-name")&&e.setAttribute("locale-name",i)})},async loadLocale(){if(this.localeName==="en"||this.localeLoaded)return;if(window._uploadcareLocales&&window._uploadcareLocales.has(this.localeName)){this.localeLoaded=!0;return}if(window._uploadcareLocales||(window._uploadcareLocales=new Set),!!["de","es","fr","he","it","nl","pl","pt","ru","tr","uk","zh-TW","zh"].includes(this.localeName))try{let i=await import(`https://cdn.jsdelivr.net/npm/@uploadcare/file-uploader@v1/locales/file-uploader/${this.localeName}.js`),s=i.default||i,a=()=>{let r=customElements.get("uc-file-uploader-inline")||customElements.get("uc-file-uploader-regular")||customElements.get("uc-file-uploader-minimal");return r&&r.UC?r.UC:window.UC},n=()=>{let r=a();return r&&typeof r.defineLocale=="function"?(r.defineLocale(this.localeName,s),window._uploadcareLocales.add(this.localeName),this.localeLoaded=!0,!0):!1};if(!n()){let r=0,l=50,d=setInterval(()=>{r++,(n()||r>=l)&&clearInterval(d)},100)}}catch(e){console.error("[Uploadcare Locale JS] Failed to load locale:",this.localeName,e)}},applyTheme(){let t=this.getCurrentTheme();this.$el.querySelectorAll(`uc-file-uploader-${this.uploaderStyle}`).forEach(i=>{i.classList.remove("uc-dark","uc-light"),i.classList.add(`uc-${t}`)})},getCurrentTheme(){return document.documentElement.classList.contains("dark")?"dark":"light"},setupThemeObservers(){window.addEventListener("storage",this.handleThemeStorageChange.bind(this)),window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change",this.handleSystemThemeChange.bind(this)),this.setupDocumentClassObserver()},handleThemeStorageChange(t){t.key==="theme"&&this.applyTheme()},handleSystemThemeChange(){localStorage.getItem("theme")==="system"&&this.applyTheme()},setupDocumentClassObserver(){this.documentClassObserver=new MutationObserver(t=>{t.forEach(e=>{if(e.type==="attributes"&&e.attributeName==="class"){let i=document.documentElement.classList.contains("dark"),s=e.oldValue&&e.oldValue.includes("dark");i!==s&&this.applyTheme()}})}),this.documentClassObserver.observe(document.documentElement,{attributes:!0,attributeOldValue:!0,attributeFilter:["class"]})},initUploadcare(){this.removeEventListeners&&this.removeEventListeners(),this.initializeUploader()},initializeUploader(t=0,e=10){if(t>=e)return;this.ctx=this.$el.querySelector(`uc-upload-ctx-provider[ctx-name="${this.uniqueContextName}"]`);let i=this.getUploadcareApi();if(!this.isValidContext(i)){setTimeout(()=>this.initializeUploader(t+1,e),100);return}this.markAsInitialized(),this.removeRequiredAttributes(),this.initializeState(i),this.setupEventListeners(i)},getUploadcareApi(){try{return this.ctx?.getAPI()}catch{return null}},isValidContext(t){return this.ctx&&t&&t.addFileFromCdnUrl},markAsInitialized(){this.isInitialized=!0},removeRequiredAttributes(){setTimeout(()=>{let t=this.$el.closest("uc-config");document.querySelectorAll("uc-form-input input[required]").forEach(i=>i.removeAttribute("required"))},100)},initializeState(t){console.log(`[Uploadcare ${this.statePath}] [${this.instanceId}] initializeState`,{hasInitialState:!!this.initialState,initialStatePreview:typeof this.initialState=="string"?this.initialState.substring(0,100):this.initialState,stateHasBeenInitialized:this.stateHasBeenInitialized,uploadedFiles:this.uploadedFiles,uniqueContextName:this.uniqueContextName,instanceId:this.instanceId}),this.initialState&&!this.stateHasBeenInitialized&&!this.uploadedFiles?this.loadInitialState(t):!this.initialState&&!this.stateHasBeenInitialized&&(this.stateHasBeenInitialized=!0,this.uploadedFiles=this.isMultiple?"[]":"",this.isLocalUpdate=!0,this.state=this.uploadedFiles)},loadInitialState(t){try{let e=this.parseInitialState();this.addFilesFromInitialState(t,e),this.stateHasBeenInitialized=!0,this.isLocalUpdate=!0,this.state=this.uploadedFiles}catch(e){console.error("Error parsing initialState:",e)}},parseInitialState(){let t=i=>{if(typeof i=="string")try{let s=JSON.parse(i);if(typeof s=="string")try{s=JSON.parse(s)}catch{}return s}catch{return i}return i};this.initialState&&this.initialState&&typeof this.initialState=="object"&&!Array.isArray(this.initialState)&&(this.initialState=[this.initialState]);let e=this.parseStateValue(this.initialState);return console.log(`[Uploadcare ${this.statePath}] initializeState`,{hasInitialState:!!this.initialState,parsedCount:Array.isArray(e)?e.length:"n/a"}),e},addFilesFromInitialState(t,e){let i=[];if(e&&e&&typeof e=="object"&&!Array.isArray(e))try{i=Array.from(e)}catch{i=[e]}else Array.isArray(e)?i=e:e&&(i=[e]);if(Array.isArray(i)&&i.length===1&&Array.isArray(i[0])&&(i=i[0]),Array.isArray(i)&&i.length===1&&typeof i[0]=="string")try{let r=JSON.parse(i[0]);i=Array.isArray(r)?r:[r]}catch{}if(!Array.isArray(i)||i.length===0)return;Array.isArray(i)||(i=[i]);let s=(r,l=0)=>{if(!r)return;if(Array.isArray(r)){r.forEach((u,c)=>{s(u,`${l}.${c}`)});return}if(typeof r=="string")try{let u=JSON.parse(r);s(u,l);return}catch{}let d=r&&typeof r=="object"?r.cdnUrl:r,h=r&&typeof r=="object"?r.cdnUrlModifiers:null;if(console.log(`[Uploadcare ${this.statePath}] addFilesFromInitialState adding item`,{index:l,url:d}),!d||!this.isValidUrl(d))return;let o=this.extractUuidFromUrl(d);if(o&&typeof t.addFileFromUuid=="function")try{if(h&&typeof t.addFileFromCdnUrl=="function"){let c=d.split("/-/")[0]+"/"+h;t.addFileFromCdnUrl(c)}else t.addFileFromUuid(o)}catch(u){console.error(`Failed to add file ${l} with UUID ${o}:`,u)}else console.error(o?"addFileFromUuid method not available on API":`Could not extract UUID from URL: ${d}`)};i.forEach(s);let a=i.map(r=>{let l=r;if(r&&typeof r=="object")return r.uuid||(r.uuid=this.extractUuidFromUrl(r.cdnUrl)),r;if(typeof r=="string"){let d=this.extractUuidFromUrl(r);return{cdnUrl:r,uuid:d,name:"",size:0,mimeType:"",isImage:!1}}return r}),n=this.formatFilesForState(a);this.uploadedFiles=JSON.stringify(n),this.initialState=this.uploadedFiles},isValidUrl(t){if(!t||typeof t!="string")return!1;try{return new URL(t),!0}catch{return!1}},setupStateWatcher(){this.$watch("state",(t,e)=>{if(console.log(`[Uploadcare ${this.statePath}] [${this.instanceId}] WATCHER state changed`,{isLocalUpdate:this.isLocalUpdate,newValue:typeof t=="string"?t.substring(0,100):t,oldValue:typeof e=="string"?e.substring(0,100):e}),this.isLocalUpdate){this.isLocalUpdate=!1;return}if(!this.stateHasBeenInitialized){this.stateHasBeenInitialized=!0;return}if((!t||t==="[]"||t==='""')&&!this.uploadedFiles)return;let i=this.normalizeStateValue(t),s=this.normalizeStateValue(this.uploadedFiles);i!==s&&(console.log(`[Uploadcare ${this.statePath}] State difference detected, adding files`,{normalizedNewValue:i,normalizedUploadedFiles:s}),t&&t!=="[]"&&t!=='""'&&this.addFilesFromState(t))})},parseStateValue(t){if(!t)return null;try{return typeof t=="string"?JSON.parse(t):t}catch{return t}},addFilesFromState(t){let i=this.parseStateValue(t);if(Array.isArray(i)||(i=[i]),i=i.filter(o=>o!=null),i.length===0)return!1;let s=this.getUploadcareApi();if(!s||typeof s.addFileFromCdnUrl!="function")return!1;let a=this.getCurrentFiles();console.log("[Uploadcare] addFilesFromState currentFiles",a.length,a);let n=a.map(o=>o?o&&typeof o=="object"?o.cdnUrl:o:null).filter(Boolean);console.log(`[Uploadcare ${this.statePath}] addFilesFromState filesToAdd`,i.length,i),i.forEach((o,u)=>{if(!o){console.warn(`[Uploadcare] Skipping null item at index ${u}`);return}let c=o&&typeof o=="object"?o.cdnUrl:o;if(console.log(`[Uploadcare] Processing item ${u}`,{url:c,item:o}),c&&typeof c=="string"&&(c.includes("ucarecdn.com")||c.includes("ucarecd.net"))&&!n.some(m=>{let y=this.extractUuidFromUrl(c),g=this.extractUuidFromUrl(m);return y&&g&&y===g}))try{s.addFileFromCdnUrl(c)}catch(m){console.error("[Uploadcare] Failed to add file from URL:",c,m)}});let r=[],l=new Set,d=o=>{if(!o)return;let u=o&&typeof o=="object"?o.cdnUrl:o,c=this.extractUuidFromUrl(u);c&&!l.has(c)?(l.add(c),this.isWithMetadata&&typeof o!="object"?r.push({cdnUrl:o,uuid:c,name:"",size:0,mimeType:"",isImage:!1}):r.push(o)):c||r.push(o)},h=this.parseStateValue(t)||[];return(Array.isArray(h)?h:[h]).forEach(d),console.log(`[Uploadcare ${this.statePath}] addFilesFromState finalized`,{finalCount:r.length,finalUuids:Array.from(l)}),this.uploadedFiles=JSON.stringify(r),this.isLocalUpdate=!0,!0},normalizeStateValue(t){if(!t)return"";try{let e=typeof t=="string"?JSON.parse(t):t;if(Array.isArray(e)){if(e.every(a=>typeof a=="string"||typeof a=="object"&&a!==null&&("cdnUrl"in a||"uuid"in a)))return JSON.stringify(e);console.log("[Uploadcare] normalizing mixed/raw array",e)}let i=this.formatFilesForState(e);return console.log("[Uploadcare] normalizeStateValue result",i),JSON.stringify(i)}catch(e){return console.error("[Uploadcare] normalizeStateValue error",e),t}},isStateChanged(){let t=this.normalizeStateValue(this.state),e=this.normalizeStateValue(this.initialState);return t!==e},setupEventListeners(t){console.log(`[Uploadcare ${this.statePath}] [${this.instanceId}] Setting up event listeners for context: ${this.uniqueContextName}`),this.pendingUploads=[],this.pendingRemovals=[];let e=this.createFileUploadSuccessHandler(t),i=this.createFileUrlChangedHandler(t),s=this.createFileRemovedHandler(t),a=this.createFormInputChangeHandler(t),n=r=>{if(r.target!==this.ctx&&!this.ctx.contains(r.target))return;let l=this.$el.closest("form");l&&l.dispatchEvent(new CustomEvent("form-processing-started",{detail:{message:"Uploading file..."}}))};this.ctx.addEventListener("file-upload-started",n),this.ctx.addEventListener("file-upload-success",e),this.ctx.addEventListener("file-url-changed",i),this.ctx.addEventListener("file-removed",s),this.$nextTick(()=>{let r=this.$el.querySelector("uc-form-input input");if(r){r.addEventListener("input",a),r.addEventListener("change",a);let l=new MutationObserver(()=>{a({target:r})});l.observe(r,{attributes:!0,attributeFilter:["value"]}),this.formInputObserver=l}}),this.removeEventListeners=()=>{console.log(`[Uploadcare ${this.statePath}] Removing event listeners for context: ${this.uniqueContextName}`),this.ctx.removeEventListener("file-upload-started",n),this.ctx.removeEventListener("file-upload-success",e),this.ctx.removeEventListener("file-url-changed",i),this.ctx.removeEventListener("file-removed",s);let r=this.$el.querySelector("uc-form-input input");r&&(r.removeEventListener("input",a),r.removeEventListener("change",a)),this.formInputObserver&&(this.formInputObserver.disconnect(),this.formInputObserver=null)}},createFileUploadSuccessHandler(t){let e=null;return i=>{let s=i.target.getAttribute("ctx-name");if(console.log(`[Uploadcare ${this.statePath}] [${this.instanceId}] SUCCESS EVENT RECEIVED`,{targetCtxName:s,instanceCtxName:this.uniqueContextName,match:s===this.uniqueContextName,instanceId:this.instanceId}),s!==this.uniqueContextName&&i.target!==this.ctx&&!this.ctx.contains(i.target)){console.log(`[Uploadcare ${this.statePath}] [${this.instanceId}] Ignoring event for different context: ${s}`);return}let a=this.isWithMetadata?i.detail:i.detail.cdnUrl,n=this.extractUuidFromUrl(a);console.log(`[Uploadcare ${this.statePath}] [${this.instanceId}] PUSHING to pendingUploads`,{uuid:n,currentPendingCount:this.pendingUploads.length}),this.pendingUploads.push(a),e&&clearTimeout(e),e=setTimeout(()=>{try{let r=this.getCurrentFiles();console.log(`[Uploadcare ${this.statePath}] [${this.instanceId}] PROCESSING pendingUploads`,{pendingCount:this.pendingUploads.length,currentFilesCount:r.length});for(let d of this.pendingUploads)r=this.updateFilesList(r,d);this.updateState(r),this.pendingUploads=[];let l=this.$el.closest("form");l&&l.dispatchEvent(new CustomEvent("form-processing-finished"))}catch(r){console.error("[Uploadcare] Error updating state after upload:",r)}},200)}},createFileUrlChangedHandler(t){let e=null;return i=>{if(i.target.getAttribute("ctx-name")!==this.uniqueContextName&&i.target!==this.ctx&&!this.ctx.contains(i.target))return;let a=i.detail;e&&clearTimeout(e),e=setTimeout(()=>{try{let n=this.getCurrentFiles(),r=this.updateFileUrl(n,a);this.updateState(r)}catch(n){console.error("Error updating state after URL change:",n)}},100)}},createFileRemovedHandler(t){let e=null;return i=>{if(i.target.getAttribute("ctx-name")!==this.uniqueContextName&&i.target!==this.ctx&&!this.ctx.contains(i.target))return;let a=i.detail;this.pendingRemovals.push(a),e&&clearTimeout(e),e=setTimeout(()=>{try{let n=this.getCurrentFiles();for(let r of this.pendingRemovals)n=this.removeFile(n,r);this.updateState(n),this.pendingRemovals=[]}catch(n){console.error("Error in handleFileRemoved:",n)}},100)}},createFormInputChangeHandler(t){return e=>{}},getCurrentFiles(){try{let t=this.uploadedFiles?JSON.parse(this.uploadedFiles):[];return Array.isArray(t)?t:[]}catch{return[]}},updateFilesList(t,e){if(this.isMultiple){let i=this.extractUuidFromUrl(e);return t.some(a=>this.extractUuidFromUrl(a)===i)?(console.log(`[Uploadcare ${this.statePath}] [${this.instanceId}] Skipping duplicate file`,i),t):[...t,e]}return[e]},updateFileUrl(t,e){let i=e.uuid;if(!i&&e.cdnUrl&&(i=this.extractUuidFromUrl(e.cdnUrl)),!i)return t;e.uuid||(e={...e,uuid:i});let s=this.findFileIndex(t,i);if(s===-1)return t;let a;if(this.isWithMetadata){let n=t[s];if(typeof n=="string"){let r=this.extractUuidFromUrl(n);n={cdnUrl:n,uuid:r,name:"",size:0,mimeType:"",isImage:!1}}a={...n,...e}}else a=e.cdnUrl;return this.isMultiple?(t[s]=a,t):[a]},removeFile(t,e){let i=this.findFileIndex(t,e.uuid);return i===-1?t:this.isMultiple?(t.splice(i,1),t):[]},findFileIndex(t,e){return e?t.findIndex(i=>{let s=i&&typeof i=="object"?i.cdnUrl:i;return this.extractUuidFromUrl(s)===e}):-1},updateState(t){let e=new Set,i=t.filter(h=>{let o=h&&typeof h=="object"?h.cdnUrl:h,u=this.extractUuidFromUrl(o);return u?e.has(u)?!1:(e.add(u),!0):!0});console.log(`[Uploadcare ${this.statePath}] [${this.instanceId}] updateState`,{incomingCount:t.length,uniqueCount:i.length,uniqueUuids:Array.from(e),instanceId:this.instanceId});let s=this.formatFilesForState(i),a=JSON.stringify(s),n=this.getCurrentFiles(),r=JSON.stringify(this.formatFilesForState(n)),l=JSON.stringify(s);r!==l?(console.log(`[Uploadcare ${this.statePath}] [${this.instanceId}] State HAS changed, updating uploadedFiles`),this.uploadedFiles=a,this.isLocalUpdate=!0,this.state=this.uploadedFiles,this.isMultiple&&i.length>1&&this.$nextTick(()=>{this.isLocalUpdate=!1})):console.log(`[Uploadcare ${this.statePath}] [${this.instanceId}] State has NOT moved, ignoring update`)},formatFilesForState(t){if(!t)return[];if(!Array.isArray(t))if(console.warn("[Uploadcare] formatFilesForState called with non-array:",typeof t,t),typeof t=="string")try{let e=JSON.parse(t);if(Array.isArray(e))t=e;else return[]}catch{return[]}else return[];return t.map(e=>{if(e&&typeof e=="object"&&!e.cdnUrl&&!e.uuid&&"0"in e){let i=Object.keys(e);if(i.length>5&&i.includes("0")&&i.includes("1")&&i.includes("2")){let s="";if(Math.max(...i.map(n=>parseInt(n)).filter(n=>!isNaN(n)))===i.length-1){let n=new Array(i.length);for(let r=0;r0){let r=[];for(let l of e){console.log(`[Uploadcare ${this.statePath}] syncStateWithUploadcare - state item`,l);let d=l&&typeof l=="object"?l.cdnUrl:l;if(typeof d=="string"&&d.match(/[a-f0-9-]{36}~[0-9]+/)){console.log("[Uploadcare] Found group URL:",d);try{let h=await this.fetchGroupFiles(d);console.log("[Uploadcare] Expanded group to:",h.length,"files"),r.push(...h)}catch(h){console.error("[Uploadcare] Failed to expand group:",d,h),r.push(l)}}else r.push(l)}console.log("[Uploadcare] Flattened files count:",r.length),e=r}let i=this.formatFilesForState(e),s=this.buildStateFromFiles(i),a=this.normalizeStateValue(this.uploadedFiles),n=this.normalizeStateValue(s);a!==n&&(this.uploadedFiles=s,this.isLocalUpdate=!0,this.state=this.uploadedFiles)}catch(e){console.error("Error syncing state with Uploadcare:",e)}},async fetchGroupFiles(t){let e=t;if(t.includes("ucarecdn.com")||t.includes("ucarecd.net")){let a=t.match(/\/([a-f0-9-]{36}~[0-9]+)/);a&&(e=a[1])}let i=await fetch(`https://upload.uploadcare.com/group/info/?pub_key=${this.publicKey}&group_id=${e}`);if(!i.ok)throw new Error(`Failed to fetch group info: ${i.statusText}`);let s=await i.json();return s.files?s.files.map(a=>{let n=`https://ucarecdn.com/${a.uuid}/`;return this.isWithMetadata?{uuid:a.uuid,cdnUrl:n,name:a.original_filename,size:a.size,mimeType:a.mime_type,isImage:a.is_image}:n}):[]},buildStateFromFiles(t){return this.isMultiple?JSON.stringify(t):t.length>0?this.isWithMetadata?JSON.stringify(t[0]):t[0]:""},getCurrentFilesFromUploadcare(t){try{if(t&&typeof t.value=="function"){let i=t.value();return i?Array.isArray(i)?i.filter(a=>a!=null):this.parseFormInputValue(i).filter(a=>a!=null):[]}let e=this.$el.querySelector("uc-form-input input");return e?this.parseFormInputValue(e.value).filter(s=>s!=null):[]}catch(e){return console.error("Error getting current files from Uploadcare:",e),[]}},parseFormInputValue(t){if(!t||typeof t=="string"&&t.trim()==="")return[];if(typeof t=="object")return[t];try{let e=JSON.parse(t);return Array.isArray(e)?e.filter(i=>i!==null&&i!==""):e!==null&&e!==""?[e]:[]}catch{return typeof t=="string"&&t.trim()!==""?[t]:[]}}}}export{U as default}; diff --git a/packages/filament-uploadcare-field/resources/js/components/uploadcare.js b/packages/filament-uploadcare-field/resources/js/components/uploadcare.js index bff5b64b..85bb2744 100644 --- a/packages/filament-uploadcare-field/resources/js/components/uploadcare.js +++ b/packages/filament-uploadcare-field/resources/js/components/uploadcare.js @@ -23,6 +23,8 @@ export default function uploadcareField(config) { ctx: null, removeEventListeners: null, uniqueContextName: config.uniqueContextName, + pendingUploads: [], + pendingRemovals: [], isInitialized: false, stateHasBeenInitialized: false, isStateWatcherActive: false, @@ -165,7 +167,7 @@ export default function uploadcareField(config) { applyTheme() { const theme = this.getCurrentTheme(); - const uploaders = document.querySelectorAll(`uc-file-uploader-${this.uploaderStyle}`); + const uploaders = this.$el.querySelectorAll(`uc-file-uploader-${this.uploaderStyle}`); uploaders.forEach(uploader => { uploader.classList.remove('uc-dark', 'uc-light'); uploader.classList.add(`uc-${theme}`); @@ -234,7 +236,7 @@ export default function uploadcareField(config) { return; } - this.ctx = document.querySelector(`uc-upload-ctx-provider[ctx-name="${this.uniqueContextName}"]`); + this.ctx = this.$el.querySelector(`uc-upload-ctx-provider[ctx-name="${this.uniqueContextName}"]`); const api = this.getUploadcareApi(); if (!this.isValidContext(api)) { @@ -273,24 +275,14 @@ export default function uploadcareField(config) { }, initializeState(api) { - this.$nextTick(() => { - console.log(`[Uploadcare ${this.statePath}] initializeState`, { - hasInitialState: !!this.initialState, - initialStatePreview: typeof this.initialState === 'string' ? this.initialState.substring(0, 100) : this.initialState, - stateHasBeenInitialized: this.stateHasBeenInitialized, - uploadedFiles: this.uploadedFiles, - uniqueContextName: this.uniqueContextName - }); - - if (this.initialState && !this.stateHasBeenInitialized && !this.uploadedFiles) { - this.loadInitialState(api); - } else if (!this.initialState && !this.stateHasBeenInitialized) { - this.stateHasBeenInitialized = true; - this.uploadedFiles = this.isMultiple ? '[]' : ''; - this.isLocalUpdate = true; - this.state = this.uploadedFiles; - } - }); + if (this.initialState && !this.stateHasBeenInitialized && !this.uploadedFiles) { + this.loadInitialState(api); + } else if (!this.initialState && !this.stateHasBeenInitialized) { + this.stateHasBeenInitialized = true; + this.uploadedFiles = this.isMultiple ? '[]' : ''; + this.isLocalUpdate = true; + this.state = this.uploadedFiles; + } }, loadInitialState(api) { @@ -623,13 +615,16 @@ export default function uploadcareField(config) { setupEventListeners(api) { this.pendingUploads = []; this.pendingRemovals = []; + const handleFileUploadSuccess = this.createFileUploadSuccessHandler(api); const handleFileUrlChanged = this.createFileUrlChangedHandler(api); const handleFileRemoved = this.createFileRemovedHandler(api); - // Form input change might still be useful for other changes, but we shouldn't rely on it for uploads if events work const handleFormInputChange = this.createFormInputChangeHandler(api); - this.ctx.addEventListener('file-upload-started', (e) => { + const handleFileUploadStarted = (e) => { + // Verify event target belongs to this instance + if (e.target !== this.ctx && !this.ctx.contains(e.target)) return; + const form = this.$el.closest('form'); if (form) { form.dispatchEvent(new CustomEvent('form-processing-started', { @@ -638,8 +633,9 @@ export default function uploadcareField(config) { } })); } - }); + }; + this.ctx.addEventListener('file-upload-started', handleFileUploadStarted); this.ctx.addEventListener('file-upload-success', handleFileUploadSuccess); this.ctx.addEventListener('file-url-changed', handleFileUrlChanged); this.ctx.addEventListener('file-removed', handleFileRemoved); @@ -657,28 +653,11 @@ export default function uploadcareField(config) { attributeFilter: ['value'] }); this.formInputObserver = observer; - } else { - setTimeout(() => { - const formInput = this.$el.querySelector('uc-form-input input'); - if (formInput) { - formInput.addEventListener('input', handleFormInputChange); - formInput.addEventListener('change', handleFormInputChange); - } - }, 200); } }); this.removeEventListeners = () => { - this.ctx.removeEventListener('file-upload-started', (e) => { - const form = this.$el.closest('form'); - if (form) { - form.dispatchEvent(new CustomEvent('form-processing-started', { - detail: { - message: 'Uploading file...', - } - })); - } - }); + this.ctx.removeEventListener('file-upload-started', handleFileUploadStarted); this.ctx.removeEventListener('file-upload-success', handleFileUploadSuccess); this.ctx.removeEventListener('file-url-changed', handleFileUrlChanged); this.ctx.removeEventListener('file-removed', handleFileRemoved); @@ -700,8 +679,16 @@ export default function uploadcareField(config) { let debounceTimer = null; return (e) => { + const eventCtxName = e.target.getAttribute('ctx-name'); + + // CRITICAL ISOLATION CHECK: Ensure this event is intended for THIS field instance + if (eventCtxName !== this.uniqueContextName && e.target !== this.ctx && !this.ctx.contains(e.target)) { + return; + } + const fileData = this.isWithMetadata ? e.detail : e.detail.cdnUrl; - // Buffer the file + const fileUuid = this.extractUuidFromUrl(fileData); + this.pendingUploads.push(fileData); if (debounceTimer) { @@ -713,7 +700,6 @@ export default function uploadcareField(config) { let currentFiles = this.getCurrentFiles(); // Add all buffered files - // We use a loop or modify updateFilesList to accept array for (const file of this.pendingUploads) { currentFiles = this.updateFilesList(currentFiles, file); } @@ -736,8 +722,11 @@ export default function uploadcareField(config) { let debounceTimer = null; return (e) => { + const eventCtxName = e.target.getAttribute('ctx-name'); + // CRITICAL ISOLATION CHECK + if (eventCtxName !== this.uniqueContextName && e.target !== this.ctx && !this.ctx.contains(e.target)) return; + const fileDetails = e.detail; - if (!fileDetails || !fileDetails.cdnUrl) return; if (debounceTimer) { clearTimeout(debounceTimer); @@ -759,6 +748,10 @@ export default function uploadcareField(config) { let debounceTimer = null; return (e) => { + const eventCtxName = e.target.getAttribute('ctx-name'); + // CRITICAL ISOLATION CHECK + if (eventCtxName !== this.uniqueContextName && e.target !== this.ctx && !this.ctx.contains(e.target)) return; + const removedFile = e.detail; // Buffer the removal this.pendingRemovals.push(removedFile); @@ -814,7 +807,6 @@ export default function uploadcareField(config) { if (!isDuplicate) { return [...currentFiles, newFile]; } - console.log(`[Uploadcare ${this.statePath}] Skipping duplicate file`, newUuid); return currentFiles; } return [newFile]; @@ -902,12 +894,6 @@ export default function uploadcareField(config) { return true; }); - console.log(`[Uploadcare ${this.statePath}] updateState`, { - incomingCount: files.length, - uniqueCount: uniqueFiles.length, - uniqueUuids: Array.from(processedUuids) - }); - const finalFiles = this.formatFilesForState(uniqueFiles); const newState = JSON.stringify(finalFiles); const currentFiles = this.getCurrentFiles(); @@ -916,7 +902,6 @@ export default function uploadcareField(config) { const hasActuallyChanged = currentStateNormalized !== newStateNormalized; if (hasActuallyChanged) { - console.log(`[Uploadcare ${this.statePath}] State HAS changed, updating uploadedFiles`); this.uploadedFiles = newState; this.isLocalUpdate = true; this.state = this.uploadedFiles; @@ -926,8 +911,6 @@ export default function uploadcareField(config) { this.isLocalUpdate = false; }); } - } else { - console.log(`[Uploadcare ${this.statePath}] State has NOT moved, ignoring update`); } }, diff --git a/packages/uploadcare-field/src/Observers/ContentFieldValueObserver.php b/packages/uploadcare-field/src/Observers/ContentFieldValueObserver.php index 4f148cf5..a14e8008 100644 --- a/packages/uploadcare-field/src/Observers/ContentFieldValueObserver.php +++ b/packages/uploadcare-field/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 d3e413aa528d1690cc57eb7ede3f2605d884d28d Mon Sep 17 00:00:00 2001 From: Baspa Date: Fri, 2 Jan 2026 11:13:47 +0100 Subject: [PATCH 15/43] fix: refreshing and persisting form states (create & create another) --- .../core/src/Resources/ContentResource.php | 60 ++- .../ContentResource/Pages/CreateContent.php | 49 ++- .../ContentResource/Pages/EditContent.php | 115 +---- .../src/Concerns/CanMapDynamicFields.php | 416 +++++------------- .../src/Concerns/PersistsContentData.php | 122 +++++ .../dist/filament-uploadcare-field.js | 2 +- .../resources/js/components/uploadcare.js | 101 ++++- packages/uploadcare-field/src/Uploadcare.php | 19 +- 8 files changed, 439 insertions(+), 445 deletions(-) create mode 100644 packages/fields/src/Concerns/PersistsContentData.php diff --git a/packages/core/src/Resources/ContentResource.php b/packages/core/src/Resources/ContentResource.php index 833ed22f..b8581880 100644 --- a/packages/core/src/Resources/ContentResource.php +++ b/packages/core/src/Resources/ContentResource.php @@ -199,17 +199,26 @@ public static function form(Schema $schema): Schema ->schema([ Tabs::make('Tabs') ->columnSpan(8) + ->key(fn ($livewire) => 'main-tabs-' . ($livewire->formVersion ?? 0)) ->tabs([ Tab::make(self::$type->slug) ->icon('heroicon-o-' . self::$type->icon) ->label(__(self::$type->name)) + ->key(function ($livewire) { + $v = $livewire->formVersion ?? 0; + return 'tab-' . self::$type->slug . '-' . $v; + }) ->schema([ Hidden::make('type_slug') ->default(self::$type->slug), Grid::make() ->columns(1) - ->schema(function () { - return self::getTypeInputs(); + ->key(function ($livewire) { + $v = $livewire->formVersion ?? 0; + return 'dynamic-fields-grid-' . $v; + }) + ->schema(function ($livewire) { + return self::getTypeInputs($livewire); }), ]), Tab::make('meta') @@ -628,6 +637,11 @@ private static function resolveFormFields(mixed $record = null): array return $instance->traitResolveFormFields($record); } + public static function setStaticType(?Type $type): void + { + self::$type = $type; + } + private static function resolveFieldInput(mixed $field, Collection $customFields, mixed $record = null, bool $isNested = false): ?object { $instance = new self; @@ -635,32 +649,51 @@ private static function resolveFieldInput(mixed $field, Collection $customFields return $instance->traitResolveFieldInput($field, $customFields, $record, $isNested); } - public static function getTypeInputs() + public static function getTypeInputs($livewire = null) { + $v = $livewire->formVersion ?? 0; + $typeSlug = self::$type->slug ?? 'NULL'; + + $groups = []; - collect(self::$type->fields) - ->filter(fn ($field) => self::$type->name_field !== $field->slug) - ->each(function ($field) use (&$groups) { + $fields = self::$type->fields; + + if ($fields instanceof \Illuminate\Database\Eloquent\Collection) { + $fields = $fields->unique('ulid'); + } else { + $fields = collect($fields)->unique('ulid'); + } + + $fields->filter(fn ($field) => self::$type->name_field !== $field->slug) + ->each(function ($field) use (&$groups, $v) { $resolvedField = self::resolveFieldInput($field, collect(Fields::getFields()), self::$type); if ($resolvedField) { + if (method_exists($resolvedField, 'key')) { + $resolvedField->key($field->ulid . '-' . $v); + } + if (method_exists($resolvedField, 'id')) { + $resolvedField->id($field->ulid . '-' . $v); + } + $groups[$field->group ?? null][] = $resolvedField; } }); - return collect($groups)->map(function ($fields, $group) { + return collect($groups)->map(function ($fields, $group) use ($v) { if (empty($group)) { - return Grid::make(1)->schema($fields); + return Grid::make(1) + ->key('dynamic-group-default-' . $v) + ->schema($fields); } return Section::make($group) + ->key('dynamic-group-' . Str::slug($group) . '-' . $v) ->collapsible() ->collapsed() ->compact() ->label(__($group)) - ->schema([ - Grid::make(1)->schema($fields), - ]); - })->values()->toArray(); + ->schema($fields); + })->values()->all(); } public static function tableDatabase(Table $table, Type $type): Table @@ -1108,7 +1141,7 @@ protected static function getFileUploadField() return $state; } - if (! $type->og_image_fields || empty($type->og_image_fields) || ! $record) { + if (! $type || ! $type->og_image_fields || empty($type->og_image_fields) || ! $record) { return []; } @@ -1135,6 +1168,7 @@ protected static function getFileUploadField() return $field; } + /** * Extract plain text from rich editor content array. */ diff --git a/packages/core/src/Resources/ContentResource/Pages/CreateContent.php b/packages/core/src/Resources/ContentResource/Pages/CreateContent.php index f33f9666..0b83ab18 100644 --- a/packages/core/src/Resources/ContentResource/Pages/CreateContent.php +++ b/packages/core/src/Resources/ContentResource/Pages/CreateContent.php @@ -3,6 +3,7 @@ namespace Backstage\Resources\ContentResource\Pages; use Backstage\Fields\Concerns\CanMapDynamicFields; +use Backstage\Fields\Concerns\PersistsContentData; use Backstage\Models\Tag; use Backstage\Resources\ContentResource; use Filament\Resources\Pages\CreateRecord; @@ -10,9 +11,12 @@ class CreateContent extends CreateRecord { + public int $formVersion = 0; + protected static string $resource = ContentResource::class; use CanMapDynamicFields; + use PersistsContentData; protected static ?string $slug = 'content/create/{type}'; @@ -34,30 +38,26 @@ public function mount(): void protected function mutateFormDataBeforeCreate(array $data): array { + $this->record = $this->getModel()::make($data); + + if (! $this->record->type_slug && isset($this->data['type_slug'])) { + $this->record->type_slug = $this->data['type_slug']; + } + + $data = $this->mutateBeforeSave($data); + + $this->data['values'] = $data['values'] ?? []; + unset($data['tags']); unset($data['values']); - unset($data['media']); - return $data; } protected function afterCreate(): void { - collect($this->data['tags'] ?? []) - ->filter(fn ($tag) => filled($tag)) - ->map(fn (string $tag) => $this->record->tags()->updateOrCreate([ - 'name' => $tag, - 'slug' => Str::slug($tag), - ])) - ->each(fn (Tag $tag) => $tag->sites()->syncWithoutDetaching($this->record->site)); - - collect($this->data['values'] ?? []) - ->filter(fn (string | array | null $value) => filled($value)) - ->each(fn (string | array $value, $field) => $this->record->values()->create([ - 'field_ulid' => $field, - 'value' => is_array($value) ? json_encode($value) : $value, - ])); + $this->handleTags(); + $this->handleValues(); $this->getRecord()->update([ 'creator_id' => auth()->id(), @@ -65,5 +65,22 @@ protected function afterCreate(): void ]); $this->getRecord()->authors()->attach(auth()->id()); + + // Reset state for "Create Another" + $typeSlug = $this->data['type_slug'] ?? null; + + $this->data = [ + 'type_slug' => $typeSlug, + 'values' => [], + ]; + + $this->record = $this->getModel()::make(['type_slug' => $typeSlug]); + + // Re-initialize static type property to prevent it being null during fill() hydration + ContentResource::setStaticType(\Backstage\Models\Type::firstWhere('slug', $typeSlug)); + + $this->form->fill([]); + + $this->formVersion++; } } diff --git a/packages/core/src/Resources/ContentResource/Pages/EditContent.php b/packages/core/src/Resources/ContentResource/Pages/EditContent.php index 88c0d78a..4e092bcb 100644 --- a/packages/core/src/Resources/ContentResource/Pages/EditContent.php +++ b/packages/core/src/Resources/ContentResource/Pages/EditContent.php @@ -5,6 +5,7 @@ use BackedEnum; use Backstage\Actions\Content\DuplicateContentAction; use Backstage\Fields\Concerns\CanMapDynamicFields; +use Backstage\Fields\Concerns\PersistsContentData; use Backstage\Models\Content; use Backstage\Models\Language; use Backstage\Models\Tag; @@ -26,7 +27,10 @@ class EditContent extends EditRecord { + public int $formVersion = 0; + use CanMapDynamicFields; + use PersistsContentData; protected static string $resource = ContentResource::class; @@ -242,88 +246,6 @@ protected function afterSave(): void $this->syncAuthors(); } - private function handleTags(): void - { - $tags = collect($this->data['tags'] ?? []) - ->filter(fn ($tag) => filled($tag)) - ->map(fn (string $tag) => $this->record->tags()->updateOrCreate([ - 'name' => $tag, - 'slug' => Str::slug($tag), - ])) - ->each(fn (Tag $tag) => $tag->sites()->syncWithoutDetaching($this->record->site)); - - $this->record->tags()->sync($tags->pluck('ulid')->toArray()); - } - - private function handleValues(): void - { - collect($this->data['values'] ?? []) - ->each(function ($value, $field) { - $fieldModel = \Backstage\Fields\Models\Field::where('ulid', $field)->first(); - - $value = $this->prepareValue($value); - - if ($this->shouldDeleteValue($value)) { - $this->deleteValue($field); - - return; - } - - if ($fieldModel && $fieldModel->field_type === 'builder') { - $this->handleBuilderField($value, $field); - - return; - } - - $this->updateOrCreateValue($value, $field); - }); - } - - private function prepareValue($value) - { - return isset($value['value']) && is_array($value['value']) ? json_encode($value['value']) : $value; - } - - private function shouldDeleteValue($value): bool - { - return blank($value); - } - - private function deleteValue($field): void - { - $this->getRecord()->values()->where([ - 'content_ulid' => $this->getRecord()->getKey(), - 'field_ulid' => $field, - ])->delete(); - } - - private function handleBuilderField($value, $field): void - { - $value = $this->decodeAllJsonStrings($value); - - $this->getRecord()->values()->updateOrCreate([ - 'content_ulid' => $this->getRecord()->getKey(), - 'field_ulid' => $field, - ], [ - 'value' => is_array($value) ? json_encode($value) : $value, - ]); - } - - private function updateOrCreateValue($value, $field): void - { - $this->getRecord()->values()->updateOrCreate([ - 'content_ulid' => $this->getRecord()->getKey(), - 'field_ulid' => $field, - ], [ - 'value' => is_array($value) ? json_encode($value) : $value, - ]); - } - - private function syncAuthors(): void - { - $this->getRecord()->authors()->syncWithoutDetaching(Auth::id()); - } - protected function mutateFormDataBeforeSave(array $data): array { $data = $this->mutateBeforeSave($data); @@ -335,33 +257,4 @@ protected function mutateFormDataBeforeSave(array $data): array return $data; } - - private function decodeAllJsonStrings($data, $path = '') - { - if (is_array($data)) { - foreach ($data as $key => $value) { - $currentPath = $path === '' ? $key : $path . '.' . $key; - if (is_string($value)) { - $decoded = $value; - $decodeCount = 0; - while (is_string($decoded)) { - $json = json_decode($decoded, true); - if ($json !== null && (is_array($json) || is_object($json))) { - $decoded = $json; - $decodeCount++; - } else { - break; - } - } - if ($decodeCount > 0) { - $data[$key] = $this->decodeAllJsonStrings($decoded, $currentPath); - } - } elseif (is_array($value)) { - $data[$key] = $this->decodeAllJsonStrings($value, $currentPath); - } - } - } - - return $data; - } } diff --git a/packages/fields/src/Concerns/CanMapDynamicFields.php b/packages/fields/src/Concerns/CanMapDynamicFields.php index 62520b66..419776b9 100644 --- a/packages/fields/src/Concerns/CanMapDynamicFields.php +++ b/packages/fields/src/Concerns/CanMapDynamicFields.php @@ -26,13 +26,6 @@ /** * Trait for handling dynamic field mapping and data mutation in forms. - * - * This trait provides functionality to: - * - Map database field configurations to form input components - * - Mutate form data before filling (loading from database) - * - Mutate form data before saving (processing user input) - * - Handle nested fields and builder blocks - * - Resolve custom field types and configurations */ trait CanMapDynamicFields { @@ -67,40 +60,22 @@ public function refresh(): void // } - /** - * Mutate form data before filling the form with existing values. - * - * This method processes the record's field values and applies any custom - * transformation logic defined in field classes before populating the form. - * - * @param array $data The form data array - * @return array The mutated form data - */ protected function mutateBeforeFill(array $data): array { if (! $this->hasValidRecordWithFields()) { return $data; } - // Extract builder blocks from record values - $builderBlocks = $this->extractBuilderBlocksFromRecord(); - $allFields = $this->getAllFieldsIncludingBuilderFields($builderBlocks); + $containerData = $this->extractContainerDataFromRecord(); + $allFields = $this->getAllFieldsIncludingNested($containerData); - return $this->mutateFormData($data, $allFields, function ($field, $fieldConfig, $fieldInstance, $data) use ($builderBlocks) { - return $this->applyFieldFillMutation($field, $fieldConfig, $fieldInstance, $data, $builderBlocks); + return $this->mutateFormData($data, $allFields, function ($field, $fieldConfig, $fieldInstance, $data) use ($containerData) { + return $this->applyFieldFillMutation($field, $fieldConfig, $fieldInstance, $data, $containerData); }); + + return $mutatedData; } - /** - * Mutate form data before saving to the database. - * - * This method processes user input and applies any custom transformation logic - * defined in field classes. It also handles special cases for builder blocks - * and nested fields. - * - * @param array $data The form data array - * @return array The mutated form data ready for saving - */ protected function mutateBeforeSave(array $data): array { if (! $this->hasValidRecord()) { @@ -112,13 +87,14 @@ protected function mutateBeforeSave(array $data): array return $data; } - $builderBlocks = $this->extractBuilderBlocks($values); - - $allFields = $this->getAllFieldsIncludingBuilderFields($builderBlocks); - - return $this->mutateFormData($data, $allFields, function ($field, $fieldConfig, $fieldInstance, $data) use ($builderBlocks) { - return $this->applyFieldSaveMutation($field, $fieldConfig, $fieldInstance, $data, $builderBlocks); + $containerData = $this->extractContainerData($values); + $allFields = $this->getAllFieldsIncludingNested($containerData); + + return $this->mutateFormData($data, $allFields, function ($field, $fieldConfig, $fieldInstance, $data) { + return $this->applyFieldSaveMutation($field, $fieldConfig, $fieldInstance, $data); }); + + return $mutatedData; } private function hasValidRecordWithFields(): bool @@ -136,123 +112,72 @@ private function extractFormValues(array $data): array return isset($data[$this->record?->valueColumn]) ? $data[$this->record?->valueColumn] : []; } - /** - * Extract builder blocks from form values. - * - * Builder blocks are special field types that contain nested fields. - * This method identifies and extracts them for special processing. - * - * @param array $values The form values - * @return array The builder blocks - */ - private function extractBuilderBlocks(array $values): array + private function extractContainerData(array $values): array { - $builderFieldUlids = ModelsField::whereIn('ulid', array_keys($values)) - ->where('field_type', 'builder') + $containerFieldUlids = ModelsField::whereIn('ulid', array_keys($values)) + ->whereIn('field_type', ['builder', 'repeater']) ->pluck('ulid') ->toArray(); return collect($values) - ->filter(fn ($value, $key) => in_array($key, $builderFieldUlids)) + ->filter(fn ($value, $key) => in_array($key, $containerFieldUlids)) ->toArray(); } - /** - * Get all fields including those from builder blocks. - * - * @param array $builderBlocks The builder blocks - * @return Collection All fields to process - */ - private function getAllFieldsIncludingBuilderFields(array $builderBlocks): Collection + private function getAllFieldsIncludingNested(array $containerData): Collection { return $this->record->fields->merge( - $this->getFieldsFromBlocks($builderBlocks) - ); + $this->getNestedFieldsFromContainerData($containerData) + )->unique('ulid'); } - /** - * Apply field-specific mutation logic for form filling. - * - * @param Model $field The field model - * @param array $fieldConfig The field configuration - * @param object $fieldInstance The field instance - * @param array $data The form data - * @param array $builderBlocks The builder blocks - * @return array The mutated data - */ - private function applyFieldFillMutation(Model $field, array $fieldConfig, object $fieldInstance, array $data, array $builderBlocks): array + private function applyFieldFillMutation(Model $field, array $fieldConfig, object $fieldInstance, array $data, array $containerData): array { if (! empty($fieldConfig['methods']['mutateFormDataCallback'])) { - $fieldLocation = $this->determineFieldLocation($field, $builderBlocks); + $fieldLocation = $this->determineFieldLocation($field, $containerData); - if ($fieldLocation['isInBuilder']) { - return $this->processBuilderFieldFillMutation($field, $fieldInstance, $data, $fieldLocation['builderData'], $builderBlocks); + if ($fieldLocation['isInContainer']) { + return $this->processContainerFieldFillMutation($field, $fieldInstance, $data, $fieldLocation); } return $fieldInstance->mutateFormDataCallback($this->record, $field, $data); } - // Default behavior: copy value from record to form data $data[$this->record->valueColumn][$field->ulid] = $this->record->values[$field->ulid] ?? null; return $data; } - /** - * Extract builder blocks from record values. - * - * @return array The builder blocks - */ - private function extractBuilderBlocksFromRecord(): array + private function extractContainerDataFromRecord(): array { if (! isset($this->record->values) || ! is_array($this->record->values)) { return []; } - $builderFieldUlids = ModelsField::whereIn('ulid', array_keys($this->record->values)) - ->where('field_type', 'builder') + $containerFieldUlids = ModelsField::whereIn('ulid', array_keys($this->record->values)) + ->whereIn('field_type', ['builder', 'repeater']) ->pluck('ulid') ->toArray(); return collect($this->record->values) - ->filter(fn ($value, $key) => in_array($key, $builderFieldUlids)) + ->filter(fn ($value, $key) => in_array($key, $containerFieldUlids)) ->toArray(); } - /** - * Process fill mutation for fields inside builder blocks. - * - * @param Model $field The field model - * @param object $fieldInstance The field instance - * @param array $data The form data - * @param array $builderData The builder block data - * @param array $builderBlocks All builder blocks - * @return array The updated form data - */ - private function processBuilderFieldFillMutation(Model $field, object $fieldInstance, array $data, array $builderData, array $builderBlocks): array + private function processContainerFieldFillMutation(Model $field, object $fieldInstance, array $data, array $fieldLocation): array { - // Create a mock record with the builder data for the callback - $mockRecord = $this->createMockRecordForBuilder($builderData); - - // Create a temporary data structure for the callback - $tempData = [$this->record->valueColumn => $builderData]; + $mockRecord = $this->createMockRecordForBuilder($fieldLocation['containerData']); + $tempData = [$this->record->valueColumn => $fieldLocation['containerData']]; $tempData = $fieldInstance->mutateFormDataCallback($mockRecord, $field, $tempData); - // Update the original data structure with the mutated values - $this->updateBuilderBlocksWithMutatedData($builderBlocks, $field, $tempData); - - // Update the main data structure - $data[$this->record->valueColumn] = array_merge($data[$this->record->valueColumn], $builderBlocks); + if (isset($tempData[$this->record->valueColumn][$field->ulid])) { + $mutatedValue = $tempData[$this->record->valueColumn][$field->ulid]; + $this->updateDataAtPath($data[$this->record->valueColumn], $fieldLocation['fullPath'], $fieldLocation['fieldKey'], $mutatedValue); + } return $data; } - /** - * Create a mock record for builder field processing. - * - * @param array $builderData The builder block data - * @return object The mock record - */ private function createMockRecordForBuilder(array $builderData): object { $mockRecord = clone $this->record; @@ -261,134 +186,56 @@ private function createMockRecordForBuilder(array $builderData): object return $mockRecord; } - /** - * Update builder blocks with mutated field data. - * - * @param array $builderBlocks The builder blocks to update - * @param Model $field The field being processed - * @param array $tempData The temporary data containing mutated values - */ - private function updateBuilderBlocksWithMutatedData(array &$builderBlocks, Model $field, array $tempData): void - { - foreach ($builderBlocks as $builderUlid => &$builderBlocks) { - if (is_array($builderBlocks)) { - foreach ($builderBlocks as &$block) { - if (isset($block['data']) && is_array($block['data']) && isset($block['data'][$field->ulid])) { - $block['data'][$field->ulid] = $tempData[$this->record->valueColumn][$field->ulid] ?? $block['data'][$field->ulid]; - } - } - } - } - } - - /** - * Resolve field configuration and create an instance. - * - * This method determines whether to use a custom field implementation - * or fall back to the default field type mapping. - * - * @param Model $field The field model - * @return array Array containing 'config' and 'instance' keys - */ private function resolveFieldConfigAndInstance(Model $field): array { - // Try to resolve from custom fields first $fieldConfig = Fields::resolveField($field->field_type) ? $this->fieldInspector->initializeCustomField($field->field_type) : $this->fieldInspector->initializeDefaultField($field->field_type); - + return [ 'config' => $fieldConfig, 'instance' => new $fieldConfig['class'], ]; } - /** - * Extract field models from builder blocks. - * - * Builder blocks contain nested fields that need to be processed. - * This method extracts those field models for processing. - * - * @param array $blocks The builder blocks - * @return Collection The field models from blocks - */ - protected function getFieldsFromBlocks(array $blocks): Collection + protected function getNestedFieldsFromContainerData(array $containerData): Collection { $processedFields = collect(); - collect($blocks)->map(function ($block) use (&$processedFields) { - foreach ($block as $key => $values) { - if (! is_array($values) || ! isset($values['data'])) { - continue; + foreach ($containerData as $rows) { + if (! is_array($rows)) continue; + foreach ($rows as $item) { + $itemData = isset($item['data']) ? $item['data'] : $item; + + if (is_array($itemData)) { + $fields = ModelsField::whereIn('ulid', array_keys($itemData)) + ->orWhereIn('slug', array_keys($itemData)) + ->get(); + + $processedFields = $processedFields->merge($fields); + + // Recursive search + $nestedContainers = $this->extractContainerData($itemData); + if (! empty($nestedContainers)) { + $processedFields = $processedFields->merge($this->getNestedFieldsFromContainerData($nestedContainers)); + } } - - $fields = $values['data']; - $fields = ModelsField::whereIn('ulid', array_keys($fields))->get(); - - $processedFields = $processedFields->merge($fields); } - }); + } - return $processedFields; + return $processedFields->unique('ulid'); } - /** - * Apply mutation strategy to all fields recursively. - * - * This method processes each field and its nested children using the provided - * mutation strategy. It handles the hierarchical nature of fields. - * - * @param array $data The form data - * @param Collection $fields The fields to process - * @param callable $mutationStrategy The strategy to apply to each field - * @return array The mutated form data - */ protected function mutateFormData(array $data, Collection $fields, callable $mutationStrategy): array { foreach ($fields as $field) { - $field->load('children'); - ['config' => $fieldConfig, 'instance' => $fieldInstance] = $this->resolveFieldConfigAndInstance($field); $data = $mutationStrategy($field, $fieldConfig, $fieldInstance, $data); - - $data = $this->processNestedFields($field, $data, $mutationStrategy); } return $data; } - /** - * Process nested fields (children) of a parent field. - * - * @param Model $field The parent field - * @param array $data The form data - * @param callable $mutationStrategy The mutation strategy - * @return array The updated form data - */ - private function processNestedFields(Model $field, array $data, callable $mutationStrategy): array - { - if (empty($field->children)) { - return $data; - } - - foreach ($field->children as $nestedField) { - ['config' => $nestedFieldConfig, 'instance' => $nestedFieldInstance] = $this->resolveFieldConfigAndInstance($nestedField); - $data = $mutationStrategy($nestedField, $nestedFieldConfig, $nestedFieldInstance, $data); - } - - return $data; - } - - /** - * Resolve form field inputs for rendering. - * - * This method converts field models into form input components - * that can be rendered in the UI. - * - * @param mixed $record The record containing fields - * @param bool $isNested Whether this is a nested field - * @return array Array of form input components - */ private function resolveFormFields(mixed $record = null, bool $isNested = false): array { $record = $record ?? $this->record; @@ -412,30 +259,15 @@ private function resolveCustomFields(): Collection ->map(fn ($fieldClass) => new $fieldClass); } - /** - * Resolve a single field input component. - * - * This method creates the appropriate form input component for a field, - * prioritizing custom field implementations over default ones. - * - * @param Model $field The field model - * @param Collection $customFields Available custom fields - * @param mixed $record The record - * @param bool $isNested Whether this is a nested field - * @return object|null The form input component or null if not found - */ private function resolveFieldInput(Model $field, Collection $customFields, mixed $record = null, bool $isNested = false): ?object { $record = $record ?? $this->record; - $inputName = $this->generateInputName($field, $record, $isNested); - // Try to resolve from custom fields first (giving them priority) if ($customField = $customFields->get($field->field_type)) { return $customField::make($inputName, $field); } - // Fall back to standard field type map if no custom field found if ($fieldClass = self::FIELD_TYPE_MAP[$field->field_type] ?? null) { return $fieldClass::make(name: $inputName, field: $field); } @@ -448,103 +280,93 @@ private function generateInputName(Model $field, mixed $record, bool $isNested): return $isNested ? "{$field->ulid}" : "{$record->valueColumn}.{$field->ulid}"; } - /** - * Apply field-specific mutation logic for form saving. - * - * This method handles both regular fields and fields within builder blocks. - * Builder blocks require special processing because they contain nested data structures. - * - * @param Model $field The field model - * @param array $fieldConfig The field configuration - * @param object $fieldInstance The field instance - * @param array $data The form data - * @param array $builderBlocks The builder blocks - * @return array The mutated data - */ - private function applyFieldSaveMutation(Model $field, array $fieldConfig, object $fieldInstance, array $data, array $builderBlocks): array + private function applyFieldSaveMutation(Model $field, array $fieldConfig, object $fieldInstance, array $data): array { if (empty($fieldConfig['methods']['mutateBeforeSaveCallback'])) { return $data; } - $fieldLocation = $this->determineFieldLocation($field, $builderBlocks); + $values = $this->extractFormValues($data); + $containerData = $this->extractContainerData($values); + $fieldLocation = $this->determineFieldLocation($field, $containerData); - if ($fieldLocation['isInBuilder']) { - return $this->processBuilderFieldMutation($field, $fieldInstance, $data, $fieldLocation['builderData'], $builderBlocks); + if ($fieldLocation['isInContainer']) { + return $this->processContainerFieldMutation($field, $fieldInstance, $data, $fieldLocation); } - // Regular field processing return $fieldInstance->mutateBeforeSaveCallback($this->record, $field, $data); } - /** - * Determine if a field is inside a builder block and extract its data. - * - * @param Model $field The field to check - * @param array $builderBlocks The builder blocks - * @return array Location information with 'isInBuilder' and 'builderData' keys - */ - private function determineFieldLocation(Model $field, array $builderBlocks): array + private function determineFieldLocation(Model $field, array $containers, array $path = []): array { - foreach ($builderBlocks as $builderUlid => $builderBlocks) { - if (is_array($builderBlocks)) { - foreach ($builderBlocks as $block) { - if (isset($block['data']) && is_array($block['data']) && isset($block['data'][$field->ulid])) { - return [ - 'isInBuilder' => true, - 'builderData' => $block['data'], - 'builderUlid' => $builderUlid, - 'blockIndex' => array_search($block, $builderBlocks), - ]; + foreach ($containers as $containerUlid => $rows) { + if (is_array($rows)) { + foreach ($rows as $index => $item) { + $itemData = isset($item['data']) ? $item['data'] : $item; + + if (is_array($itemData)) { + if (isset($itemData[$field->ulid]) || isset($itemData[$field->slug])) { + return [ + 'isInContainer' => true, + 'containerData' => $itemData, + 'fieldKey' => isset($itemData[$field->ulid]) ? $field->ulid : $field->slug, + 'containerUlid' => $containerUlid, + 'rowIndex' => $index, + 'fullPath' => array_merge($path, [$containerUlid, $index]), + ]; + } + + $nestedContainers = $this->extractContainerData($itemData); + if (! empty($nestedContainers)) { + $result = $this->determineFieldLocation($field, $nestedContainers, array_merge($path, [$containerUlid, $index])); + if ($result['isInContainer']) { + return $result; + } + } } } } } return [ - 'isInBuilder' => false, - 'builderData' => null, - 'builderUlid' => null, - 'blockIndex' => null, + 'isInContainer' => false, + 'containerData' => null, + 'containerUlid' => null, + 'rowIndex' => null, + 'fullPath' => [], ]; } - /** - * Process mutation for fields inside builder blocks. - * - * Builder fields require special handling because they're nested within - * a complex data structure that needs to be updated in place. - * - * @param Model $field The field model - * @param object $fieldInstance The field instance - * @param array $data The form data - * @param array $builderData The builder block data - * @param array $builderBlocks All builder blocks - * @return array The updated form data - */ - private function processBuilderFieldMutation(Model $field, object $fieldInstance, array $data, array $builderData, array $builderBlocks): array + private function processContainerFieldMutation(Model $field, object $fieldInstance, array $data, array $fieldLocation): array { - foreach ($builderBlocks as $builderUlid => &$blocks) { - if (is_array($blocks)) { - foreach ($blocks as &$block) { - if (isset($block['data']) && is_array($block['data']) && isset($block['data'][$field->ulid])) { - // Create a mock record with the block data for the callback - $mockRecord = $this->createMockRecordForBuilder($block['data']); - - // Create a temporary data structure for the callback - $tempData = [$this->record->valueColumn => $block['data']]; - $tempData = $fieldInstance->mutateBeforeSaveCallback($mockRecord, $field, $tempData); - - if (isset($tempData[$this->record->valueColumn][$field->ulid])) { - $block['data'][$field->ulid] = $tempData[$this->record->valueColumn][$field->ulid]; - } - } - } - } - } + $mockRecord = $this->createMockRecordForBuilder($fieldLocation['containerData']); + $tempData = [$this->record->valueColumn => $fieldLocation['containerData']]; + $tempData = $fieldInstance->mutateBeforeSaveCallback($mockRecord, $field, $tempData); - $data[$this->record->valueColumn] = array_merge($data[$this->record->valueColumn], $builderBlocks); + if (isset($tempData[$this->record->valueColumn][$field->ulid])) { + $mutatedValue = $tempData[$this->record->valueColumn][$field->ulid]; + $this->updateDataAtPath($data[$this->record->valueColumn], $fieldLocation['fullPath'], $fieldLocation['fieldKey'], $mutatedValue); + } return $data; } + + private function updateDataAtPath(array &$data, array $path, string $fieldKey, mixed $value): void + { + $current = &$data; + foreach ($path as $key) { + if (is_array($current) && isset($current[$key])) { + $current = &$current[$key]; + } else { + return; + } + } + + // If 'data' key exists, it's a builder block row + if (is_array($current) && isset($current['data'])) { + $current['data'][$fieldKey] = $value; + } elseif (is_array($current)) { + $current[$fieldKey] = $value; + } + } } diff --git a/packages/fields/src/Concerns/PersistsContentData.php b/packages/fields/src/Concerns/PersistsContentData.php new file mode 100644 index 00000000..2273175f --- /dev/null +++ b/packages/fields/src/Concerns/PersistsContentData.php @@ -0,0 +1,122 @@ +data['tags'] ?? []) + ->filter(fn ($tag) => filled($tag)) + ->map(fn (string $tag) => $this->record->tags()->updateOrCreate([ + 'name' => $tag, + 'slug' => Str::slug($tag), + ])) + ->each(fn (Tag $tag) => $tag->sites()->syncWithoutDetaching($this->record->site)); + + $this->record->tags()->sync($tags->pluck('ulid')->toArray()); + } + + protected function handleValues(): void + { + collect($this->data['values'] ?? []) + ->each(function ($value, $field) { + $fieldModel = ModelsField::where('ulid', $field)->first(); + + $value = $this->prepareValue($value); + + if ($this->shouldDeleteValue($value)) { + $this->deleteValue($field); + + return; + } + + if ($fieldModel && in_array($fieldModel->field_type, ['builder', 'repeater'])) { + $this->handleContainerField($value, $field); + + return; + } + + $this->updateOrCreateValue($value, $field); + }); + } + + protected function prepareValue($value) + { + return isset($value['value']) && is_array($value['value']) ? json_encode($value['value']) : $value; + } + + protected function shouldDeleteValue($value): bool + { + return blank($value); + } + + protected function deleteValue($field): void + { + $this->record->values()->where([ + 'content_ulid' => $this->record->getKey(), + 'field_ulid' => $field, + ])->delete(); + } + + protected function handleContainerField($value, $field): void + { + $value = $this->decodeAllJsonStrings($value); + + $this->record->values()->updateOrCreate([ + 'content_ulid' => $this->record->getKey(), + 'field_ulid' => $field, + ], [ + 'value' => is_array($value) ? json_encode($value) : $value, + ]); + } + + protected function updateOrCreateValue($value, $field): void + { + $this->record->values()->updateOrCreate([ + 'content_ulid' => $this->record->getKey(), + 'field_ulid' => $field, + ], [ + 'value' => is_array($value) ? json_encode($value) : $value, + ]); + } + + protected function syncAuthors(): void + { + $this->record->authors()->syncWithoutDetaching(Auth::id()); + } + + protected function decodeAllJsonStrings($data, $path = '') + { + if (is_array($data)) { + foreach ($data as $key => $value) { + $currentPath = $path === '' ? $key : $path . '.' . $key; + if (is_string($value)) { + $decoded = $value; + $decodeCount = 0; + while (is_string($decoded)) { + $json = json_decode($decoded, true); + if ($json !== null && (is_array($json) || is_object($json))) { + $decoded = $json; + $decodeCount++; + } else { + break; + } + } + if ($decodeCount > 0) { + $data[$key] = $this->decodeAllJsonStrings($decoded, $currentPath); + } + } elseif (is_array($value)) { + $data[$key] = $this->decodeAllJsonStrings($value, $currentPath); + } + } + } + + return $data; + } +} diff --git a/packages/filament-uploadcare-field/resources/dist/filament-uploadcare-field.js b/packages/filament-uploadcare-field/resources/dist/filament-uploadcare-field.js index 345cad42..1f91c23b 100644 --- a/packages/filament-uploadcare-field/resources/dist/filament-uploadcare-field.js +++ b/packages/filament-uploadcare-field/resources/dist/filament-uploadcare-field.js @@ -1 +1 @@ -var f=class{constructor(t){this.wrapper=t,this.observer=null,this.init()}init(){this.hideDoneButtons(),this.setupObserver()}setupObserver(){this.observer=new MutationObserver(t=>{t.forEach(e=>{e.type==="childList"&&e.addedNodes.forEach(i=>{if(i.nodeType===Node.ELEMENT_NODE){i.classList&&i.classList.contains("uc-done-btn")&&this.hideDoneButton(i);let s=i.querySelectorAll&&i.querySelectorAll(".uc-done-btn");s&&s.forEach(a=>this.hideDoneButton(a))}})})}),this.wrapper&&this.observer.observe(this.wrapper,{childList:!0,subtree:!0})}hideDoneButtons(){document.querySelectorAll(".uc-done-btn").forEach(e=>this.hideDoneButton(e))}hideDoneButton(t){t&&(t.style.display="none",t.style.visibility="hidden",t.style.opacity="0",t.style.pointerEvents="none",t.style.position="absolute",t.style.width="0",t.style.height="0",t.style.overflow="hidden",t.style.clip="rect(0, 0, 0, 0)",t.style.margin="0",t.style.padding="0",t.style.border="0",t.style.background="transparent",t.style.color="transparent",t.style.fontSize="0",t.style.lineHeight="0")}destroy(){this.observer&&(this.observer.disconnect(),this.observer=null)}};function U(p){return window._initializedUploadcareContexts||(window._initializedUploadcareContexts=new Set),{state:p.state,statePath:p.statePath,initialState:p.initialState,publicKey:p.publicKey,isMultiple:p.isMultiple,multipleMin:p.multipleMin,multipleMax:p.multipleMax,isImagesOnly:p.isImagesOnly,accept:p.accept,sourceList:p.sourceList,uploaderStyle:p.uploaderStyle,isWithMetadata:p.isWithMetadata,localeName:p.localeName||"en",uploadedFiles:"",ctx:null,removeEventListeners:null,uniqueContextName:p.uniqueContextName,pendingUploads:[],pendingRemovals:[],instanceId:Math.random().toString(36).substring(7),isInitialized:!1,stateHasBeenInitialized:!1,isStateWatcherActive:!1,isLocalUpdate:!1,doneButtonHider:null,documentClassObserver:null,formInputObserver:null,async init(){this.isContextAlreadyInitialized()||(this.markContextAsInitialized(),this.applyTheme(),await this.loadAllLocales(),this.setupStateWatcher(),this.$el.addEventListener("uploadcare-state-updated",t=>{let e=t.detail.uuid;e&&this.isInitialized?this.loadFileFromUuid(e):e&&this.$nextTick(()=>{this.isInitialized&&this.loadFileFromUuid(e)})}),this.initUploadcare(),this.setupThemeObservers(),this.setupDoneButtonObserver())},isContextAlreadyInitialized(){return window._initializedUploadcareContexts.has(this.uniqueContextName)},markContextAsInitialized(){window._initializedUploadcareContexts.add(this.uniqueContextName)},async loadAllLocales(){window._uploadcareAllLocalesLoaded||await new Promise(e=>{if(window._uploadcareAllLocalesLoaded){e();return}let i=setInterval(()=>{window._uploadcareAllLocalesLoaded&&(clearInterval(i),e())},100);setTimeout(()=>{clearInterval(i),e()},5e3)});let t=["de","es","fr","he","it","nl","pl","pt","ru","tr","uk","zh-TW","zh"];document.querySelectorAll("uc-config[data-locale-name]").forEach(e=>{let i=e.getAttribute("data-locale-name");i&&t.includes(i)&&!e.getAttribute("locale-name")&&e.setAttribute("locale-name",i)})},async loadLocale(){if(this.localeName==="en"||this.localeLoaded)return;if(window._uploadcareLocales&&window._uploadcareLocales.has(this.localeName)){this.localeLoaded=!0;return}if(window._uploadcareLocales||(window._uploadcareLocales=new Set),!!["de","es","fr","he","it","nl","pl","pt","ru","tr","uk","zh-TW","zh"].includes(this.localeName))try{let i=await import(`https://cdn.jsdelivr.net/npm/@uploadcare/file-uploader@v1/locales/file-uploader/${this.localeName}.js`),s=i.default||i,a=()=>{let r=customElements.get("uc-file-uploader-inline")||customElements.get("uc-file-uploader-regular")||customElements.get("uc-file-uploader-minimal");return r&&r.UC?r.UC:window.UC},n=()=>{let r=a();return r&&typeof r.defineLocale=="function"?(r.defineLocale(this.localeName,s),window._uploadcareLocales.add(this.localeName),this.localeLoaded=!0,!0):!1};if(!n()){let r=0,l=50,d=setInterval(()=>{r++,(n()||r>=l)&&clearInterval(d)},100)}}catch(e){console.error("[Uploadcare Locale JS] Failed to load locale:",this.localeName,e)}},applyTheme(){let t=this.getCurrentTheme();this.$el.querySelectorAll(`uc-file-uploader-${this.uploaderStyle}`).forEach(i=>{i.classList.remove("uc-dark","uc-light"),i.classList.add(`uc-${t}`)})},getCurrentTheme(){return document.documentElement.classList.contains("dark")?"dark":"light"},setupThemeObservers(){window.addEventListener("storage",this.handleThemeStorageChange.bind(this)),window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change",this.handleSystemThemeChange.bind(this)),this.setupDocumentClassObserver()},handleThemeStorageChange(t){t.key==="theme"&&this.applyTheme()},handleSystemThemeChange(){localStorage.getItem("theme")==="system"&&this.applyTheme()},setupDocumentClassObserver(){this.documentClassObserver=new MutationObserver(t=>{t.forEach(e=>{if(e.type==="attributes"&&e.attributeName==="class"){let i=document.documentElement.classList.contains("dark"),s=e.oldValue&&e.oldValue.includes("dark");i!==s&&this.applyTheme()}})}),this.documentClassObserver.observe(document.documentElement,{attributes:!0,attributeOldValue:!0,attributeFilter:["class"]})},initUploadcare(){this.removeEventListeners&&this.removeEventListeners(),this.initializeUploader()},initializeUploader(t=0,e=10){if(t>=e)return;this.ctx=this.$el.querySelector(`uc-upload-ctx-provider[ctx-name="${this.uniqueContextName}"]`);let i=this.getUploadcareApi();if(!this.isValidContext(i)){setTimeout(()=>this.initializeUploader(t+1,e),100);return}this.markAsInitialized(),this.removeRequiredAttributes(),this.initializeState(i),this.setupEventListeners(i)},getUploadcareApi(){try{return this.ctx?.getAPI()}catch{return null}},isValidContext(t){return this.ctx&&t&&t.addFileFromCdnUrl},markAsInitialized(){this.isInitialized=!0},removeRequiredAttributes(){setTimeout(()=>{let t=this.$el.closest("uc-config");document.querySelectorAll("uc-form-input input[required]").forEach(i=>i.removeAttribute("required"))},100)},initializeState(t){console.log(`[Uploadcare ${this.statePath}] [${this.instanceId}] initializeState`,{hasInitialState:!!this.initialState,initialStatePreview:typeof this.initialState=="string"?this.initialState.substring(0,100):this.initialState,stateHasBeenInitialized:this.stateHasBeenInitialized,uploadedFiles:this.uploadedFiles,uniqueContextName:this.uniqueContextName,instanceId:this.instanceId}),this.initialState&&!this.stateHasBeenInitialized&&!this.uploadedFiles?this.loadInitialState(t):!this.initialState&&!this.stateHasBeenInitialized&&(this.stateHasBeenInitialized=!0,this.uploadedFiles=this.isMultiple?"[]":"",this.isLocalUpdate=!0,this.state=this.uploadedFiles)},loadInitialState(t){try{let e=this.parseInitialState();this.addFilesFromInitialState(t,e),this.stateHasBeenInitialized=!0,this.isLocalUpdate=!0,this.state=this.uploadedFiles}catch(e){console.error("Error parsing initialState:",e)}},parseInitialState(){let t=i=>{if(typeof i=="string")try{let s=JSON.parse(i);if(typeof s=="string")try{s=JSON.parse(s)}catch{}return s}catch{return i}return i};this.initialState&&this.initialState&&typeof this.initialState=="object"&&!Array.isArray(this.initialState)&&(this.initialState=[this.initialState]);let e=this.parseStateValue(this.initialState);return console.log(`[Uploadcare ${this.statePath}] initializeState`,{hasInitialState:!!this.initialState,parsedCount:Array.isArray(e)?e.length:"n/a"}),e},addFilesFromInitialState(t,e){let i=[];if(e&&e&&typeof e=="object"&&!Array.isArray(e))try{i=Array.from(e)}catch{i=[e]}else Array.isArray(e)?i=e:e&&(i=[e]);if(Array.isArray(i)&&i.length===1&&Array.isArray(i[0])&&(i=i[0]),Array.isArray(i)&&i.length===1&&typeof i[0]=="string")try{let r=JSON.parse(i[0]);i=Array.isArray(r)?r:[r]}catch{}if(!Array.isArray(i)||i.length===0)return;Array.isArray(i)||(i=[i]);let s=(r,l=0)=>{if(!r)return;if(Array.isArray(r)){r.forEach((u,c)=>{s(u,`${l}.${c}`)});return}if(typeof r=="string")try{let u=JSON.parse(r);s(u,l);return}catch{}let d=r&&typeof r=="object"?r.cdnUrl:r,h=r&&typeof r=="object"?r.cdnUrlModifiers:null;if(console.log(`[Uploadcare ${this.statePath}] addFilesFromInitialState adding item`,{index:l,url:d}),!d||!this.isValidUrl(d))return;let o=this.extractUuidFromUrl(d);if(o&&typeof t.addFileFromUuid=="function")try{if(h&&typeof t.addFileFromCdnUrl=="function"){let c=d.split("/-/")[0]+"/"+h;t.addFileFromCdnUrl(c)}else t.addFileFromUuid(o)}catch(u){console.error(`Failed to add file ${l} with UUID ${o}:`,u)}else console.error(o?"addFileFromUuid method not available on API":`Could not extract UUID from URL: ${d}`)};i.forEach(s);let a=i.map(r=>{let l=r;if(r&&typeof r=="object")return r.uuid||(r.uuid=this.extractUuidFromUrl(r.cdnUrl)),r;if(typeof r=="string"){let d=this.extractUuidFromUrl(r);return{cdnUrl:r,uuid:d,name:"",size:0,mimeType:"",isImage:!1}}return r}),n=this.formatFilesForState(a);this.uploadedFiles=JSON.stringify(n),this.initialState=this.uploadedFiles},isValidUrl(t){if(!t||typeof t!="string")return!1;try{return new URL(t),!0}catch{return!1}},setupStateWatcher(){this.$watch("state",(t,e)=>{if(console.log(`[Uploadcare ${this.statePath}] [${this.instanceId}] WATCHER state changed`,{isLocalUpdate:this.isLocalUpdate,newValue:typeof t=="string"?t.substring(0,100):t,oldValue:typeof e=="string"?e.substring(0,100):e}),this.isLocalUpdate){this.isLocalUpdate=!1;return}if(!this.stateHasBeenInitialized){this.stateHasBeenInitialized=!0;return}if((!t||t==="[]"||t==='""')&&!this.uploadedFiles)return;let i=this.normalizeStateValue(t),s=this.normalizeStateValue(this.uploadedFiles);i!==s&&(console.log(`[Uploadcare ${this.statePath}] State difference detected, adding files`,{normalizedNewValue:i,normalizedUploadedFiles:s}),t&&t!=="[]"&&t!=='""'&&this.addFilesFromState(t))})},parseStateValue(t){if(!t)return null;try{return typeof t=="string"?JSON.parse(t):t}catch{return t}},addFilesFromState(t){let i=this.parseStateValue(t);if(Array.isArray(i)||(i=[i]),i=i.filter(o=>o!=null),i.length===0)return!1;let s=this.getUploadcareApi();if(!s||typeof s.addFileFromCdnUrl!="function")return!1;let a=this.getCurrentFiles();console.log("[Uploadcare] addFilesFromState currentFiles",a.length,a);let n=a.map(o=>o?o&&typeof o=="object"?o.cdnUrl:o:null).filter(Boolean);console.log(`[Uploadcare ${this.statePath}] addFilesFromState filesToAdd`,i.length,i),i.forEach((o,u)=>{if(!o){console.warn(`[Uploadcare] Skipping null item at index ${u}`);return}let c=o&&typeof o=="object"?o.cdnUrl:o;if(console.log(`[Uploadcare] Processing item ${u}`,{url:c,item:o}),c&&typeof c=="string"&&(c.includes("ucarecdn.com")||c.includes("ucarecd.net"))&&!n.some(m=>{let y=this.extractUuidFromUrl(c),g=this.extractUuidFromUrl(m);return y&&g&&y===g}))try{s.addFileFromCdnUrl(c)}catch(m){console.error("[Uploadcare] Failed to add file from URL:",c,m)}});let r=[],l=new Set,d=o=>{if(!o)return;let u=o&&typeof o=="object"?o.cdnUrl:o,c=this.extractUuidFromUrl(u);c&&!l.has(c)?(l.add(c),this.isWithMetadata&&typeof o!="object"?r.push({cdnUrl:o,uuid:c,name:"",size:0,mimeType:"",isImage:!1}):r.push(o)):c||r.push(o)},h=this.parseStateValue(t)||[];return(Array.isArray(h)?h:[h]).forEach(d),console.log(`[Uploadcare ${this.statePath}] addFilesFromState finalized`,{finalCount:r.length,finalUuids:Array.from(l)}),this.uploadedFiles=JSON.stringify(r),this.isLocalUpdate=!0,!0},normalizeStateValue(t){if(!t)return"";try{let e=typeof t=="string"?JSON.parse(t):t;if(Array.isArray(e)){if(e.every(a=>typeof a=="string"||typeof a=="object"&&a!==null&&("cdnUrl"in a||"uuid"in a)))return JSON.stringify(e);console.log("[Uploadcare] normalizing mixed/raw array",e)}let i=this.formatFilesForState(e);return console.log("[Uploadcare] normalizeStateValue result",i),JSON.stringify(i)}catch(e){return console.error("[Uploadcare] normalizeStateValue error",e),t}},isStateChanged(){let t=this.normalizeStateValue(this.state),e=this.normalizeStateValue(this.initialState);return t!==e},setupEventListeners(t){console.log(`[Uploadcare ${this.statePath}] [${this.instanceId}] Setting up event listeners for context: ${this.uniqueContextName}`),this.pendingUploads=[],this.pendingRemovals=[];let e=this.createFileUploadSuccessHandler(t),i=this.createFileUrlChangedHandler(t),s=this.createFileRemovedHandler(t),a=this.createFormInputChangeHandler(t),n=r=>{if(r.target!==this.ctx&&!this.ctx.contains(r.target))return;let l=this.$el.closest("form");l&&l.dispatchEvent(new CustomEvent("form-processing-started",{detail:{message:"Uploading file..."}}))};this.ctx.addEventListener("file-upload-started",n),this.ctx.addEventListener("file-upload-success",e),this.ctx.addEventListener("file-url-changed",i),this.ctx.addEventListener("file-removed",s),this.$nextTick(()=>{let r=this.$el.querySelector("uc-form-input input");if(r){r.addEventListener("input",a),r.addEventListener("change",a);let l=new MutationObserver(()=>{a({target:r})});l.observe(r,{attributes:!0,attributeFilter:["value"]}),this.formInputObserver=l}}),this.removeEventListeners=()=>{console.log(`[Uploadcare ${this.statePath}] Removing event listeners for context: ${this.uniqueContextName}`),this.ctx.removeEventListener("file-upload-started",n),this.ctx.removeEventListener("file-upload-success",e),this.ctx.removeEventListener("file-url-changed",i),this.ctx.removeEventListener("file-removed",s);let r=this.$el.querySelector("uc-form-input input");r&&(r.removeEventListener("input",a),r.removeEventListener("change",a)),this.formInputObserver&&(this.formInputObserver.disconnect(),this.formInputObserver=null)}},createFileUploadSuccessHandler(t){let e=null;return i=>{let s=i.target.getAttribute("ctx-name");if(console.log(`[Uploadcare ${this.statePath}] [${this.instanceId}] SUCCESS EVENT RECEIVED`,{targetCtxName:s,instanceCtxName:this.uniqueContextName,match:s===this.uniqueContextName,instanceId:this.instanceId}),s!==this.uniqueContextName&&i.target!==this.ctx&&!this.ctx.contains(i.target)){console.log(`[Uploadcare ${this.statePath}] [${this.instanceId}] Ignoring event for different context: ${s}`);return}let a=this.isWithMetadata?i.detail:i.detail.cdnUrl,n=this.extractUuidFromUrl(a);console.log(`[Uploadcare ${this.statePath}] [${this.instanceId}] PUSHING to pendingUploads`,{uuid:n,currentPendingCount:this.pendingUploads.length}),this.pendingUploads.push(a),e&&clearTimeout(e),e=setTimeout(()=>{try{let r=this.getCurrentFiles();console.log(`[Uploadcare ${this.statePath}] [${this.instanceId}] PROCESSING pendingUploads`,{pendingCount:this.pendingUploads.length,currentFilesCount:r.length});for(let d of this.pendingUploads)r=this.updateFilesList(r,d);this.updateState(r),this.pendingUploads=[];let l=this.$el.closest("form");l&&l.dispatchEvent(new CustomEvent("form-processing-finished"))}catch(r){console.error("[Uploadcare] Error updating state after upload:",r)}},200)}},createFileUrlChangedHandler(t){let e=null;return i=>{if(i.target.getAttribute("ctx-name")!==this.uniqueContextName&&i.target!==this.ctx&&!this.ctx.contains(i.target))return;let a=i.detail;e&&clearTimeout(e),e=setTimeout(()=>{try{let n=this.getCurrentFiles(),r=this.updateFileUrl(n,a);this.updateState(r)}catch(n){console.error("Error updating state after URL change:",n)}},100)}},createFileRemovedHandler(t){let e=null;return i=>{if(i.target.getAttribute("ctx-name")!==this.uniqueContextName&&i.target!==this.ctx&&!this.ctx.contains(i.target))return;let a=i.detail;this.pendingRemovals.push(a),e&&clearTimeout(e),e=setTimeout(()=>{try{let n=this.getCurrentFiles();for(let r of this.pendingRemovals)n=this.removeFile(n,r);this.updateState(n),this.pendingRemovals=[]}catch(n){console.error("Error in handleFileRemoved:",n)}},100)}},createFormInputChangeHandler(t){return e=>{}},getCurrentFiles(){try{let t=this.uploadedFiles?JSON.parse(this.uploadedFiles):[];return Array.isArray(t)?t:[]}catch{return[]}},updateFilesList(t,e){if(this.isMultiple){let i=this.extractUuidFromUrl(e);return t.some(a=>this.extractUuidFromUrl(a)===i)?(console.log(`[Uploadcare ${this.statePath}] [${this.instanceId}] Skipping duplicate file`,i),t):[...t,e]}return[e]},updateFileUrl(t,e){let i=e.uuid;if(!i&&e.cdnUrl&&(i=this.extractUuidFromUrl(e.cdnUrl)),!i)return t;e.uuid||(e={...e,uuid:i});let s=this.findFileIndex(t,i);if(s===-1)return t;let a;if(this.isWithMetadata){let n=t[s];if(typeof n=="string"){let r=this.extractUuidFromUrl(n);n={cdnUrl:n,uuid:r,name:"",size:0,mimeType:"",isImage:!1}}a={...n,...e}}else a=e.cdnUrl;return this.isMultiple?(t[s]=a,t):[a]},removeFile(t,e){let i=this.findFileIndex(t,e.uuid);return i===-1?t:this.isMultiple?(t.splice(i,1),t):[]},findFileIndex(t,e){return e?t.findIndex(i=>{let s=i&&typeof i=="object"?i.cdnUrl:i;return this.extractUuidFromUrl(s)===e}):-1},updateState(t){let e=new Set,i=t.filter(h=>{let o=h&&typeof h=="object"?h.cdnUrl:h,u=this.extractUuidFromUrl(o);return u?e.has(u)?!1:(e.add(u),!0):!0});console.log(`[Uploadcare ${this.statePath}] [${this.instanceId}] updateState`,{incomingCount:t.length,uniqueCount:i.length,uniqueUuids:Array.from(e),instanceId:this.instanceId});let s=this.formatFilesForState(i),a=JSON.stringify(s),n=this.getCurrentFiles(),r=JSON.stringify(this.formatFilesForState(n)),l=JSON.stringify(s);r!==l?(console.log(`[Uploadcare ${this.statePath}] [${this.instanceId}] State HAS changed, updating uploadedFiles`),this.uploadedFiles=a,this.isLocalUpdate=!0,this.state=this.uploadedFiles,this.isMultiple&&i.length>1&&this.$nextTick(()=>{this.isLocalUpdate=!1})):console.log(`[Uploadcare ${this.statePath}] [${this.instanceId}] State has NOT moved, ignoring update`)},formatFilesForState(t){if(!t)return[];if(!Array.isArray(t))if(console.warn("[Uploadcare] formatFilesForState called with non-array:",typeof t,t),typeof t=="string")try{let e=JSON.parse(t);if(Array.isArray(e))t=e;else return[]}catch{return[]}else return[];return t.map(e=>{if(e&&typeof e=="object"&&!e.cdnUrl&&!e.uuid&&"0"in e){let i=Object.keys(e);if(i.length>5&&i.includes("0")&&i.includes("1")&&i.includes("2")){let s="";if(Math.max(...i.map(n=>parseInt(n)).filter(n=>!isNaN(n)))===i.length-1){let n=new Array(i.length);for(let r=0;r0){let r=[];for(let l of e){console.log(`[Uploadcare ${this.statePath}] syncStateWithUploadcare - state item`,l);let d=l&&typeof l=="object"?l.cdnUrl:l;if(typeof d=="string"&&d.match(/[a-f0-9-]{36}~[0-9]+/)){console.log("[Uploadcare] Found group URL:",d);try{let h=await this.fetchGroupFiles(d);console.log("[Uploadcare] Expanded group to:",h.length,"files"),r.push(...h)}catch(h){console.error("[Uploadcare] Failed to expand group:",d,h),r.push(l)}}else r.push(l)}console.log("[Uploadcare] Flattened files count:",r.length),e=r}let i=this.formatFilesForState(e),s=this.buildStateFromFiles(i),a=this.normalizeStateValue(this.uploadedFiles),n=this.normalizeStateValue(s);a!==n&&(this.uploadedFiles=s,this.isLocalUpdate=!0,this.state=this.uploadedFiles)}catch(e){console.error("Error syncing state with Uploadcare:",e)}},async fetchGroupFiles(t){let e=t;if(t.includes("ucarecdn.com")||t.includes("ucarecd.net")){let a=t.match(/\/([a-f0-9-]{36}~[0-9]+)/);a&&(e=a[1])}let i=await fetch(`https://upload.uploadcare.com/group/info/?pub_key=${this.publicKey}&group_id=${e}`);if(!i.ok)throw new Error(`Failed to fetch group info: ${i.statusText}`);let s=await i.json();return s.files?s.files.map(a=>{let n=`https://ucarecdn.com/${a.uuid}/`;return this.isWithMetadata?{uuid:a.uuid,cdnUrl:n,name:a.original_filename,size:a.size,mimeType:a.mime_type,isImage:a.is_image}:n}):[]},buildStateFromFiles(t){return this.isMultiple?JSON.stringify(t):t.length>0?this.isWithMetadata?JSON.stringify(t[0]):t[0]:""},getCurrentFilesFromUploadcare(t){try{if(t&&typeof t.value=="function"){let i=t.value();return i?Array.isArray(i)?i.filter(a=>a!=null):this.parseFormInputValue(i).filter(a=>a!=null):[]}let e=this.$el.querySelector("uc-form-input input");return e?this.parseFormInputValue(e.value).filter(s=>s!=null):[]}catch(e){return console.error("Error getting current files from Uploadcare:",e),[]}},parseFormInputValue(t){if(!t||typeof t=="string"&&t.trim()==="")return[];if(typeof t=="object")return[t];try{let e=JSON.parse(t);return Array.isArray(e)?e.filter(i=>i!==null&&i!==""):e!==null&&e!==""?[e]:[]}catch{return typeof t=="string"&&t.trim()!==""?[t]:[]}}}}export{U as default}; +var f=class{constructor(e){this.wrapper=e,this.observer=null,this.init()}init(){this.hideDoneButtons(),this.setupObserver()}setupObserver(){this.observer=new MutationObserver(e=>{e.forEach(t=>{t.type==="childList"&&t.addedNodes.forEach(i=>{if(i.nodeType===Node.ELEMENT_NODE){i.classList&&i.classList.contains("uc-done-btn")&&this.hideDoneButton(i);let s=i.querySelectorAll&&i.querySelectorAll(".uc-done-btn");s&&s.forEach(a=>this.hideDoneButton(a))}})})}),this.wrapper&&this.observer.observe(this.wrapper,{childList:!0,subtree:!0})}hideDoneButtons(){document.querySelectorAll(".uc-done-btn").forEach(t=>this.hideDoneButton(t))}hideDoneButton(e){e&&(e.style.display="none",e.style.visibility="hidden",e.style.opacity="0",e.style.pointerEvents="none",e.style.position="absolute",e.style.width="0",e.style.height="0",e.style.overflow="hidden",e.style.clip="rect(0, 0, 0, 0)",e.style.margin="0",e.style.padding="0",e.style.border="0",e.style.background="transparent",e.style.color="transparent",e.style.fontSize="0",e.style.lineHeight="0")}destroy(){this.observer&&(this.observer.disconnect(),this.observer=null)}};function U(p){return window._initializedUploadcareContexts||(window._initializedUploadcareContexts=new Set),{state:p.state,statePath:p.statePath,initialState:p.initialState,publicKey:p.publicKey,isMultiple:p.isMultiple,multipleMin:p.multipleMin,multipleMax:p.multipleMax,isImagesOnly:p.isImagesOnly,accept:p.accept,sourceList:p.sourceList,uploaderStyle:p.uploaderStyle,isWithMetadata:p.isWithMetadata,localeName:p.localeName||"en",uploadedFiles:"",ctx:null,removeEventListeners:null,uniqueContextName:p.uniqueContextName,pendingUploads:[],pendingRemovals:[],isInitialized:!1,stateHasBeenInitialized:!1,isStateWatcherActive:!1,isLocalUpdate:!1,doneButtonHider:null,documentClassObserver:null,formInputObserver:null,log(e,t={}){console.log(`[UC-DEBUG] [${this.uniqueContextName}] [${new Date().toISOString().split("T")[1]}] ${e}`,t)},async init(){if(this.log("INIT STARTED",{state:this.state,isConnected:this.$el.isConnected,isInitialized:this.isInitialized}),this.isContextAlreadyInitialized()){this.log("Context already initialized, skipping");return}if(this.markContextAsInitialized(),this.applyTheme(),await this.loadAllLocales(),!this.$el.isConnected){this.log("Component disconnected after loadAllLocales, aborting init (ZOMBIE KILL)");return}this.log("Locales loaded",{isConnected:this.$el.isConnected}),this.setupStateWatcher(),this.$el.addEventListener("uploadcare-state-updated",e=>{this.log("uploadcare-state-updated event",e.detail);let t=e.detail.uuid;t&&this.isInitialized?this.loadFileFromUuid(t):t&&this.$nextTick(()=>{this.isInitialized&&this.loadFileFromUuid(t)})}),this.initUploadcare(),this.setupThemeObservers(),this.setupDoneButtonObserver(),(!this.state||this.state==="[]"||this.state==='""')&&this.$nextTick(()=>{this.isInitialized&&this.getCurrentFiles().length>0&&(this.log("Proactive clear triggering clearAllFiles(SILENT)"),this.clearAllFiles(!1))})},isContextAlreadyInitialized(){return window._initializedUploadcareContexts.has(this.uniqueContextName)},markContextAsInitialized(){window._initializedUploadcareContexts.add(this.uniqueContextName)},async loadAllLocales(){window._uploadcareAllLocalesLoaded||await new Promise(t=>{if(window._uploadcareAllLocalesLoaded){t();return}let i=setInterval(()=>{window._uploadcareAllLocalesLoaded&&(clearInterval(i),t())},100);setTimeout(()=>{clearInterval(i),t()},5e3)});let e=["de","es","fr","he","it","nl","pl","pt","ru","tr","uk","zh-TW","zh"];document.querySelectorAll("uc-config[data-locale-name]").forEach(t=>{let i=t.getAttribute("data-locale-name");i&&e.includes(i)&&!t.getAttribute("locale-name")&&t.setAttribute("locale-name",i)})},async loadLocale(){if(this.localeName==="en"||this.localeLoaded)return;if(window._uploadcareLocales&&window._uploadcareLocales.has(this.localeName)){this.localeLoaded=!0;return}if(window._uploadcareLocales||(window._uploadcareLocales=new Set),!!["de","es","fr","he","it","nl","pl","pt","ru","tr","uk","zh-TW","zh"].includes(this.localeName))try{let i=await import(`https://cdn.jsdelivr.net/npm/@uploadcare/file-uploader@v1/locales/file-uploader/${this.localeName}.js`),s=i.default||i,a=()=>{let r=customElements.get("uc-file-uploader-inline")||customElements.get("uc-file-uploader-regular")||customElements.get("uc-file-uploader-minimal");return r&&r.UC?r.UC:window.UC},n=()=>{let r=a();return r&&typeof r.defineLocale=="function"?(r.defineLocale(this.localeName,s),window._uploadcareLocales.add(this.localeName),this.localeLoaded=!0,!0):!1};if(!n()){let r=0,o=50,c=setInterval(()=>{r++,(n()||r>=o)&&clearInterval(c)},100)}}catch(t){console.error("[Uploadcare Locale JS] Failed to load locale:",this.localeName,t)}},applyTheme(){let e=this.getCurrentTheme();this.$el.querySelectorAll(`uc-file-uploader-${this.uploaderStyle}`).forEach(i=>{i.classList.remove("uc-dark","uc-light"),i.classList.add(`uc-${e}`)})},getCurrentTheme(){return document.documentElement.classList.contains("dark")?"dark":"light"},setupThemeObservers(){window.addEventListener("storage",this.handleThemeStorageChange.bind(this)),window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change",this.handleSystemThemeChange.bind(this)),this.setupDocumentClassObserver()},handleThemeStorageChange(e){e.key==="theme"&&this.applyTheme()},handleSystemThemeChange(){localStorage.getItem("theme")==="system"&&this.applyTheme()},setupDocumentClassObserver(){this.documentClassObserver=new MutationObserver(e=>{e.forEach(t=>{if(t.type==="attributes"&&t.attributeName==="class"){let i=document.documentElement.classList.contains("dark"),s=t.oldValue&&t.oldValue.includes("dark");i!==s&&this.applyTheme()}})}),this.documentClassObserver.observe(document.documentElement,{attributes:!0,attributeOldValue:!0,attributeFilter:["class"]})},initUploadcare(){this.removeEventListeners&&this.removeEventListeners(),this.initializeUploader()},initializeUploader(e=0,t=10){if(e>=t)return;this.ctx=this.$el.querySelector(`uc-upload-ctx-provider[ctx-name="${this.uniqueContextName}"]`);let i=this.getUploadcareApi();if(!this.isValidContext(i)){setTimeout(()=>this.initializeUploader(e+1,t),100);return}this.markAsInitialized(),this.removeRequiredAttributes(),this.initializeState(i),this.setupEventListeners(i)},getUploadcareApi(){try{return this.ctx?.getAPI()}catch{return null}},isValidContext(e){return this.ctx&&e&&e.addFileFromCdnUrl},markAsInitialized(){this.isInitialized=!0},removeRequiredAttributes(){setTimeout(()=>{let e=this.$el.closest("uc-config");document.querySelectorAll("uc-form-input input[required]").forEach(i=>i.removeAttribute("required"))},100)},initializeState(e){this.initialState&&!this.stateHasBeenInitialized&&!this.uploadedFiles?this.loadInitialState(e):!this.initialState&&!this.stateHasBeenInitialized&&(this.stateHasBeenInitialized=!0,this.uploadedFiles=this.isMultiple?"[]":"",this.isLocalUpdate=!0,this.state=this.uploadedFiles)},loadInitialState(e){try{let t=this.parseInitialState();this.addFilesFromInitialState(e,t),this.stateHasBeenInitialized=!0,this.isLocalUpdate=!0,this.state=this.uploadedFiles}catch(t){console.error("Error parsing initialState:",t)}},parseInitialState(){let e=i=>{if(typeof i=="string")try{let s=JSON.parse(i);if(typeof s=="string")try{s=JSON.parse(s)}catch{}return s}catch{return i}return i};this.initialState&&this.initialState&&typeof this.initialState=="object"&&!Array.isArray(this.initialState)&&(this.initialState=[this.initialState]);let t=this.parseStateValue(this.initialState);return console.log(`[Uploadcare ${this.statePath}] initializeState`,{hasInitialState:!!this.initialState,parsedCount:Array.isArray(t)?t.length:"n/a"}),t},addFilesFromInitialState(e,t){let i=[];if(t&&t&&typeof t=="object"&&!Array.isArray(t))try{i=Array.from(t)}catch{i=[t]}else Array.isArray(t)?i=t:t&&(i=[t]);if(Array.isArray(i)&&i.length===1&&Array.isArray(i[0])&&(i=i[0]),Array.isArray(i)&&i.length===1&&typeof i[0]=="string")try{let r=JSON.parse(i[0]);i=Array.isArray(r)?r:[r]}catch{}if(!Array.isArray(i)||i.length===0)return;Array.isArray(i)||(i=[i]);let s=(r,o=0)=>{if(!r)return;if(Array.isArray(r)){r.forEach((u,d)=>{s(u,`${o}.${d}`)});return}if(typeof r=="string")try{let u=JSON.parse(r);s(u,o);return}catch{}let c=r&&typeof r=="object"?r.cdnUrl:r,h=r&&typeof r=="object"?r.cdnUrlModifiers:null;if(console.log(`[Uploadcare ${this.statePath}] addFilesFromInitialState adding item`,{index:o,url:c}),!c||!this.isValidUrl(c))return;let l=this.extractUuidFromUrl(c);if(l&&typeof e.addFileFromUuid=="function")try{if(h&&typeof e.addFileFromCdnUrl=="function"){let d=c.split("/-/")[0]+"/"+h;e.addFileFromCdnUrl(d)}else e.addFileFromUuid(l)}catch(u){console.error(`Failed to add file ${o} with UUID ${l}:`,u)}else console.error(l?"addFileFromUuid method not available on API":`Could not extract UUID from URL: ${c}`)};i.forEach(s);let a=i.map(r=>{let o=r;if(r&&typeof r=="object")return r.uuid||(r.uuid=this.extractUuidFromUrl(r.cdnUrl)),r;if(typeof r=="string"){let c=this.extractUuidFromUrl(r);return{cdnUrl:r,uuid:c,name:"",size:0,mimeType:"",isImage:!1}}return r}),n=this.formatFilesForState(a);this.uploadedFiles=JSON.stringify(n),this.initialState=this.uploadedFiles},isValidUrl(e){if(!e||typeof e!="string")return!1;try{return new URL(e),!0}catch{return!1}},setupStateWatcher(){this.$watch("state",(e,t)=>{if(this.isLocalUpdate){this.isLocalUpdate=!1;return}if(!this.$el.isConnected){this.log("WATCHER: Component detached, ignoring change");return}if(!this.stateHasBeenInitialized){this.stateHasBeenInitialized=!0,this.log("WATCHER: State initialized (first run)");return}if(this.log("WATCHER: State changed",{newValue:typeof e=="string"?e.substring(0,100):e,oldValue:typeof t=="string"?t.substring(0,100):t,currentUploadedFiles:this.uploadedFiles.substring(0,100),isLocalUpdate:this.isLocalUpdate}),!e||e==="[]"||e==='""'||Array.isArray(e)&&e.length===0){this.uploadedFiles&&this.uploadedFiles!=="[]"&&this.uploadedFiles!=='""'&&this.getCurrentFiles().length>0&&(this.log("State cleared (server sent empty), calling clearAllFiles(SILENT)"),this.clearAllFiles(!1));return}let i=this.normalizeStateValue(e),s=this.normalizeStateValue(this.uploadedFiles);i!==s&&e&&e!=="[]"&&e!=='""'&&this.addFilesFromState(e)})},parseStateValue(e){if(!e)return null;try{return typeof e=="string"?JSON.parse(e):e}catch{return e}},addFilesFromState(e){let i=this.parseStateValue(e);if(Array.isArray(i)||(i=[i]),i=i.filter(l=>l!=null),i.length===0)return!1;let s=this.getUploadcareApi();if(!s||typeof s.addFileFromCdnUrl!="function")return!1;let a=this.getCurrentFiles();console.log("[Uploadcare] addFilesFromState currentFiles",a.length,a);let n=a.map(l=>l?l&&typeof l=="object"?l.cdnUrl:l:null).filter(Boolean);console.log(`[Uploadcare ${this.statePath}] addFilesFromState filesToAdd`,i.length,i),i.forEach((l,u)=>{if(!l){console.warn(`[Uploadcare] Skipping null item at index ${u}`);return}let d=l&&typeof l=="object"?l.cdnUrl:l;if(console.log(`[Uploadcare] Processing item ${u}`,{url:d,item:l}),d&&typeof d=="string"&&(d.includes("ucarecdn.com")||d.includes("ucarecd.net"))&&!n.some(m=>{let y=this.extractUuidFromUrl(d),g=this.extractUuidFromUrl(m);return y&&g&&y===g}))try{s.addFileFromCdnUrl(d)}catch(m){console.error("[Uploadcare] Failed to add file from URL:",d,m)}});let r=[],o=new Set,c=l=>{if(!l)return;let u=l&&typeof l=="object"?l.cdnUrl:l,d=this.extractUuidFromUrl(u);d&&!o.has(d)?(o.add(d),this.isWithMetadata&&typeof l!="object"?r.push({cdnUrl:l,uuid:d,name:"",size:0,mimeType:"",isImage:!1}):r.push(l)):d||r.push(l)},h=this.parseStateValue(e)||[];return(Array.isArray(h)?h:[h]).forEach(c),this.uploadedFiles=JSON.stringify(r),this.isLocalUpdate=!0,!0},normalizeStateValue(e){if(!e)return"";try{let t=typeof e=="string"?JSON.parse(e):e;if(Array.isArray(t)){if(t.every(a=>typeof a=="string"||typeof a=="object"&&a!==null&&("cdnUrl"in a||"uuid"in a)))return JSON.stringify(t);console.log("[Uploadcare] normalizing mixed/raw array",t)}let i=this.formatFilesForState(t);return console.log("[Uploadcare] normalizeStateValue result",i),JSON.stringify(i)}catch(t){return console.error("[Uploadcare] normalizeStateValue error",t),e}},isStateChanged(){let e=this.normalizeStateValue(this.state),t=this.normalizeStateValue(this.initialState);return e!==t},setupEventListeners(e){this.pendingUploads=[],this.pendingRemovals=[];let t=this.createFileUploadSuccessHandler(e),i=this.createFileUrlChangedHandler(e),s=this.createFileRemovedHandler(e),a=this.createFormInputChangeHandler(e),n=r=>{if(r.target!==this.ctx&&!this.ctx.contains(r.target))return;let o=this.$el.closest("form");o&&o.dispatchEvent(new CustomEvent("form-processing-started",{detail:{message:"Uploading file..."}}))};this.ctx.addEventListener("file-upload-started",n),this.ctx.addEventListener("file-upload-success",t),this.ctx.addEventListener("file-url-changed",i),this.ctx.addEventListener("file-removed",s),this.$nextTick(()=>{let r=this.$el.querySelector("uc-form-input input");if(r){r.addEventListener("input",a),r.addEventListener("change",a);let o=new MutationObserver(()=>{a({target:r})});o.observe(r,{attributes:!0,attributeFilter:["value"]}),this.formInputObserver=o}}),this.removeEventListeners=()=>{this.ctx.removeEventListener("file-upload-started",n),this.ctx.removeEventListener("file-upload-success",t),this.ctx.removeEventListener("file-url-changed",i),this.ctx.removeEventListener("file-removed",s);let r=this.$el.querySelector("uc-form-input input");r&&(r.removeEventListener("input",a),r.removeEventListener("change",a)),this.formInputObserver&&(this.formInputObserver.disconnect(),this.formInputObserver=null)}},createFileUploadSuccessHandler(e){let t=null;return i=>{if(i.target.getAttribute("ctx-name")!==this.uniqueContextName&&i.target!==this.ctx&&!this.ctx.contains(i.target))return;let a=this.isWithMetadata?i.detail:i.detail.cdnUrl,n=this.extractUuidFromUrl(a);this.pendingUploads.push(a),t&&clearTimeout(t),t=setTimeout(()=>{try{let r=this.getCurrentFiles();for(let c of this.pendingUploads)r=this.updateFilesList(r,c);this.updateState(r),this.pendingUploads=[];let o=this.$el.closest("form");o&&o.dispatchEvent(new CustomEvent("form-processing-finished"))}catch(r){console.error("[Uploadcare] Error updating state after upload:",r)}},200)}},createFileUrlChangedHandler(e){let t=null;return i=>{if(i.target.getAttribute("ctx-name")!==this.uniqueContextName&&i.target!==this.ctx&&!this.ctx.contains(i.target))return;let a=i.detail;t&&clearTimeout(t),t=setTimeout(()=>{try{let n=this.getCurrentFiles(),r=this.updateFileUrl(n,a);this.updateState(r)}catch(n){console.error("Error updating state after URL change:",n)}},100)}},createFileRemovedHandler(e){let t=null;return i=>{if(i.target.getAttribute("ctx-name")!==this.uniqueContextName&&i.target!==this.ctx&&!this.ctx.contains(i.target))return;let a=i.detail;this.pendingRemovals.push(a),t&&clearTimeout(t),t=setTimeout(()=>{try{let n=this.getCurrentFiles();for(let r of this.pendingRemovals)n=this.removeFile(n,r);this.updateState(n),this.pendingRemovals=[]}catch(n){console.error("Error in handleFileRemoved:",n)}},100)}},createFormInputChangeHandler(e){return t=>{}},getCurrentFiles(){try{let e=this.uploadedFiles?JSON.parse(this.uploadedFiles):[];return Array.isArray(e)?e:[]}catch{return[]}},updateFilesList(e,t){if(this.isMultiple){let i=this.extractUuidFromUrl(t);return e.some(a=>this.extractUuidFromUrl(a)===i)?e:[...e,t]}return[t]},updateFileUrl(e,t){let i=t.uuid;if(!i&&t.cdnUrl&&(i=this.extractUuidFromUrl(t.cdnUrl)),!i)return e;t.uuid||(t={...t,uuid:i});let s=this.findFileIndex(e,i);if(s===-1)return e;let a;if(this.isWithMetadata){let n=e[s];if(typeof n=="string"){let r=this.extractUuidFromUrl(n);n={cdnUrl:n,uuid:r,name:"",size:0,mimeType:"",isImage:!1}}a={...n,...t}}else a=t.cdnUrl;return this.isMultiple?(e[s]=a,e):[a]},removeFile(e,t){let i=this.findFileIndex(e,t.uuid);return i===-1?e:this.isMultiple?(e.splice(i,1),e):[]},findFileIndex(e,t){return t?e.findIndex(i=>{let s=i&&typeof i=="object"?i.cdnUrl:i;return this.extractUuidFromUrl(s)===t}):-1},updateState(e){let t=new Set,i=e.filter(h=>{let l=h&&typeof h=="object"?h.cdnUrl:h,u=this.extractUuidFromUrl(l);return u?t.has(u)?!1:(t.add(u),!0):!0}),s=this.formatFilesForState(i),a=JSON.stringify(s),n=this.getCurrentFiles(),r=JSON.stringify(this.formatFilesForState(n)),o=JSON.stringify(s);r!==o&&(this.uploadedFiles=a,this.isLocalUpdate=!0,this.state=this.uploadedFiles,this.isMultiple&&i.length>1&&this.$nextTick(()=>{this.isLocalUpdate=!1}))},formatFilesForState(e){if(!e)return[];if(!Array.isArray(e))if(console.warn("[Uploadcare] formatFilesForState called with non-array:",typeof e,e),typeof e=="string")try{let t=JSON.parse(e);if(Array.isArray(t))e=t;else return[]}catch{return[]}else return[];return e.map(t=>{if(t&&typeof t=="object"&&!t.cdnUrl&&!t.uuid&&"0"in t){let i=Object.keys(t);if(i.length>5&&i.includes("0")&&i.includes("1")&&i.includes("2")){let s="";if(Math.max(...i.map(n=>parseInt(n)).filter(n=>!isNaN(n)))===i.length-1){let n=new Array(i.length);for(let r=0;r0){let r=[];for(let o of t){console.log(`[Uploadcare ${this.statePath}] syncStateWithUploadcare - state item`,o);let c=o&&typeof o=="object"?o.cdnUrl:o;if(typeof c=="string"&&c.match(/[a-f0-9-]{36}~[0-9]+/)){console.log("[Uploadcare] Found group URL:",c);try{let h=await this.fetchGroupFiles(c);console.log("[Uploadcare] Expanded group to:",h.length,"files"),r.push(...h)}catch(h){console.error("[Uploadcare] Failed to expand group:",c,h),r.push(o)}}else r.push(o)}console.log("[Uploadcare] Flattened files count:",r.length),t=r}let i=this.formatFilesForState(t),s=this.buildStateFromFiles(i),a=this.normalizeStateValue(this.uploadedFiles),n=this.normalizeStateValue(s);a!==n&&(this.uploadedFiles=s,this.isLocalUpdate=!0,this.state=this.uploadedFiles)}catch(t){console.error("Error syncing state with Uploadcare:",t)}},async fetchGroupFiles(e){let t=e;if(e.includes("ucarecdn.com")||e.includes("ucarecd.net")){let a=e.match(/\/([a-f0-9-]{36}~[0-9]+)/);a&&(t=a[1])}let i=await fetch(`https://upload.uploadcare.com/group/info/?pub_key=${this.publicKey}&group_id=${t}`);if(!i.ok)throw new Error(`Failed to fetch group info: ${i.statusText}`);let s=await i.json();return s.files?s.files.map(a=>{let n=`https://ucarecdn.com/${a.uuid}/`;return this.isWithMetadata?{uuid:a.uuid,cdnUrl:n,name:a.original_filename,size:a.size,mimeType:a.mime_type,isImage:a.is_image}:n}):[]},buildStateFromFiles(e){return this.isMultiple?JSON.stringify(e):e.length>0?this.isWithMetadata?JSON.stringify(e[0]):e[0]:""},getCurrentFilesFromUploadcare(e){try{if(e&&typeof e.value=="function"){let i=e.value();return i?Array.isArray(i)?i.filter(a=>a!=null):this.parseFormInputValue(i).filter(a=>a!=null):[]}let t=this.$el.querySelector("uc-form-input input");return t?this.parseFormInputValue(t.value).filter(s=>s!=null):[]}catch(t){return console.error("Error getting current files from Uploadcare:",t),[]}},parseFormInputValue(e){if(!e||typeof e=="string"&&e.trim()==="")return[];if(typeof e=="object")return[e];try{let t=JSON.parse(e);return Array.isArray(t)?t.filter(i=>i!==null&&i!==""):t!==null&&t!==""?[t]:[]}catch{return typeof e=="string"&&e.trim()!==""?[e]:[]}},clearAllFiles(e=!0){let t=this.statePath||"unknown";this.log("clearAllFiles called",{uploadedFiles:this.uploadedFiles,state:this.state,emitStateChange:e});let i=this.getUploadcareApi();if(i){this.log("API found, attempting clear",{hasCollection:!!i.collection,hasGetCollection:typeof i.getCollection=="function",hasRemoveAll:typeof i.removeAllFiles=="function"});try{if(i.collection&&typeof i.collection.clear=="function")i.collection.clear();else if(typeof i.getCollection=="function"){let s=i.getCollection();s&&typeof s.clear=="function"&&s.clear()}}catch(s){console.warn(`[Uploadcare ${t}] collection clear error:`,s)}try{typeof i.removeAllFiles=="function"&&i.removeAllFiles()}catch{}try{typeof i.value=="function"?i.value([]):i.value=[]}catch{}}else console.warn(`[Uploadcare ${t}] No API discovered for clearing`);try{let s=this.$el.querySelector("uc-form-input");if(s&&typeof s.getAPI=="function"){let a=s.getAPI();a&&(a.value=this.isMultiple?[]:"")}}catch{}this.uploadedFiles!==(this.isMultiple?"[]":"")&&(this.uploadedFiles=this.isMultiple?"[]":"",this.isLocalUpdate=!0,e&&(this.state=this.uploadedFiles))}}}export{U as default}; diff --git a/packages/filament-uploadcare-field/resources/js/components/uploadcare.js b/packages/filament-uploadcare-field/resources/js/components/uploadcare.js index 85bb2744..d4b7eecc 100644 --- a/packages/filament-uploadcare-field/resources/js/components/uploadcare.js +++ b/packages/filament-uploadcare-field/resources/js/components/uploadcare.js @@ -43,6 +43,11 @@ export default function uploadcareField(config) { await this.loadAllLocales(); + // ZOMBIE CHECK: If component was removed while loading locales, abort. + if (!this.$el.isConnected) { + return; + } + this.setupStateWatcher(); this.$el.addEventListener('uploadcare-state-updated', (e) => { @@ -61,6 +66,21 @@ export default function uploadcareField(config) { this.initUploadcare(); this.setupThemeObservers(); this.setupDoneButtonObserver(); + + // PROACTIVE CLEAR: If we are initializing and state is already empty/null, + // ensure the widget is also cleared (covers some re-init scenarios). + // Especially helpful when using "Create & Create Another". + if (!this.state || this.state === '[]' || this.state === '""') { + this.$nextTick(() => { + if (this.isInitialized) { + // Only verify if we really need to clear visually + const current = this.getCurrentFiles(); + if (current.length > 0) { + this.clearAllFiles(false); + } + } + }); + } }, isContextAlreadyInitialized() { @@ -454,12 +474,27 @@ export default function uploadcareField(config) { return; } + // ZOMBIE PROOFING: If component is detached, stop watching/syncing immediately. + if (!this.$el.isConnected) { + return; + } + if (!this.stateHasBeenInitialized) { this.stateHasBeenInitialized = true; return; } - if ((!newValue || newValue === '[]' || newValue === '""') && !this.uploadedFiles) { + if (!newValue || newValue === '[]' || newValue === '""' || (Array.isArray(newValue) && newValue.length === 0)) { + // Only clear if we actually have files to clear. + // Prevents infinite loop of: Server(null) -> Watcher -> clearAllFiles -> State([]) -> Server(null) + if (this.uploadedFiles && this.uploadedFiles !== '[]' && this.uploadedFiles !== '""') { + // Double check we aren't just initialized with empty + const current = this.getCurrentFiles(); + if (current.length > 0) { + console.log(`[Uploadcare ${this.statePath}] State cleared (server sent empty), calling clearAllFiles(SILENT)`); + this.clearAllFiles(false); + } + } return; } @@ -1193,6 +1228,70 @@ export default function uploadcareField(config) { return []; } + }, + + clearAllFiles(emitStateChange = true) { + const path = this.statePath || 'unknown'; + + const api = this.getUploadcareApi(); + if (api) { + console.log(`[Uploadcare ${path}] API found. Attempting clear methods.`, { + hasCollection: !!api.collection, + hasGetCollection: typeof api.getCollection === 'function', + hasRemoveAll: typeof api.removeAllFiles === 'function' + }); + + // 1. Try Collection Clear (Standard for Blocks) + try { + if (api.collection && typeof api.collection.clear === 'function') { + api.collection.clear(); + } else if (typeof api.getCollection === 'function') { + const coll = api.getCollection(); + if (coll && typeof coll.clear === 'function') coll.clear(); + } + } catch (e) { + console.warn(`[Uploadcare ${path}] collection clear error:`, e); + } + + // 2. Try removeAllFiles + try { + if (typeof api.removeAllFiles === 'function') { + api.removeAllFiles(); + } + } catch (e) {} + + // 3. Try value reset + try { + if (typeof api.value === 'function') { + api.value([]); + } else { + api.value = []; + } + } catch (e) {} + } else { + console.warn(`[Uploadcare ${path}] No API discovered for clearing`); + } + + // Also try to reach into form-input if possible + try { + const formInput = this.$el.querySelector('uc-form-input'); + if (formInput && typeof formInput.getAPI === 'function') { + const fiApi = formInput.getAPI(); + if (fiApi) { + // console.log(`[Uploadcare ${path}] resetting uc-form-input via API`); + fiApi.value = this.isMultiple ? [] : ''; + } + } + } catch (e) {} + + if (this.uploadedFiles !== (this.isMultiple ? '[]' : '')) { + this.uploadedFiles = this.isMultiple ? '[]' : ''; + this.isLocalUpdate = true; + + if (emitStateChange) { + this.state = this.uploadedFiles; + } + } } }; } \ No newline at end of file diff --git a/packages/uploadcare-field/src/Uploadcare.php b/packages/uploadcare-field/src/Uploadcare.php index 41c2bd22..7a7e2332 100755 --- a/packages/uploadcare-field/src/Uploadcare.php +++ b/packages/uploadcare-field/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 abf545c17724d9a4d34766378c1e8773d1cc411b Mon Sep 17 00:00:00 2001 From: Baspa Date: Fri, 2 Jan 2026 12:49:59 +0100 Subject: [PATCH 16/43] fix: applying modifiers --- packages/core/src/Models/Content.php | 16 ++-- .../core/src/Models/ContentFieldValue.php | 2 +- .../dist/filament-uploadcare-field.js | 2 +- .../resources/js/components/uploadcare.js | 58 +++++++----- .../src/Forms/Components/Uploadcare.php | 40 ++++++-- .../Observers/ContentFieldValueObserver.php | 4 - packages/uploadcare-field/src/Uploadcare.php | 93 +++++-------------- 7 files changed, 105 insertions(+), 110 deletions(-) diff --git a/packages/core/src/Models/Content.php b/packages/core/src/Models/Content.php index 4f6460c8..e159096a 100644 --- a/packages/core/src/Models/Content.php +++ b/packages/core/src/Models/Content.php @@ -143,15 +143,19 @@ public function getFormattedFieldValues(): array if (! $value->field) { return []; } - $value->value = json_decode($value->value, true) ?? $value->value; - // Recursively decode nested JSON strings only for repeater and builder fields - if (in_array($value->field->field_type, ['repeater', 'builder'])) { - $value->value = $this->decodeAllJsonStrings($value->value); + // Use the value() accessor logic which performs hydration logic + // including resolving Media objects via Uploadcare's hydration logic. + // This ensures pivot metadata (crops) are included. + $hydratedValue = $value->value(); + + // If it's an HtmlString (e.g. RichEditor), we ensure string content + if ($hydratedValue instanceof \Illuminate\Support\HtmlString) { + $hydratedValue = (string) $hydratedValue; } - return [$value->field->ulid => $value->value]; - })->toArray(); + return [$value->field->ulid => $hydratedValue]; + })->all(); } public function fields(): HasManyThrough diff --git a/packages/core/src/Models/ContentFieldValue.php b/packages/core/src/Models/ContentFieldValue.php index d0ac4cf2..c0b62051 100644 --- a/packages/core/src/Models/ContentFieldValue.php +++ b/packages/core/src/Models/ContentFieldValue.php @@ -167,7 +167,7 @@ private function tryHydrateViaClass(mixed $value, string $fieldType): array if ($fieldClass = \Backstage\Fields\Facades\Fields::resolveField($fieldType)) { if (in_array(\Backstage\Fields\Contracts\HydratesValues::class, class_implements($fieldClass))) { try { - return [true, app($fieldClass)->hydrate($value)]; + return [true, app($fieldClass)->hydrate($value, $this)]; } catch (\Throwable $e) { return [true, $value]; } diff --git a/packages/filament-uploadcare-field/resources/dist/filament-uploadcare-field.js b/packages/filament-uploadcare-field/resources/dist/filament-uploadcare-field.js index 1f91c23b..7e511645 100644 --- a/packages/filament-uploadcare-field/resources/dist/filament-uploadcare-field.js +++ b/packages/filament-uploadcare-field/resources/dist/filament-uploadcare-field.js @@ -1 +1 @@ -var f=class{constructor(e){this.wrapper=e,this.observer=null,this.init()}init(){this.hideDoneButtons(),this.setupObserver()}setupObserver(){this.observer=new MutationObserver(e=>{e.forEach(t=>{t.type==="childList"&&t.addedNodes.forEach(i=>{if(i.nodeType===Node.ELEMENT_NODE){i.classList&&i.classList.contains("uc-done-btn")&&this.hideDoneButton(i);let s=i.querySelectorAll&&i.querySelectorAll(".uc-done-btn");s&&s.forEach(a=>this.hideDoneButton(a))}})})}),this.wrapper&&this.observer.observe(this.wrapper,{childList:!0,subtree:!0})}hideDoneButtons(){document.querySelectorAll(".uc-done-btn").forEach(t=>this.hideDoneButton(t))}hideDoneButton(e){e&&(e.style.display="none",e.style.visibility="hidden",e.style.opacity="0",e.style.pointerEvents="none",e.style.position="absolute",e.style.width="0",e.style.height="0",e.style.overflow="hidden",e.style.clip="rect(0, 0, 0, 0)",e.style.margin="0",e.style.padding="0",e.style.border="0",e.style.background="transparent",e.style.color="transparent",e.style.fontSize="0",e.style.lineHeight="0")}destroy(){this.observer&&(this.observer.disconnect(),this.observer=null)}};function U(p){return window._initializedUploadcareContexts||(window._initializedUploadcareContexts=new Set),{state:p.state,statePath:p.statePath,initialState:p.initialState,publicKey:p.publicKey,isMultiple:p.isMultiple,multipleMin:p.multipleMin,multipleMax:p.multipleMax,isImagesOnly:p.isImagesOnly,accept:p.accept,sourceList:p.sourceList,uploaderStyle:p.uploaderStyle,isWithMetadata:p.isWithMetadata,localeName:p.localeName||"en",uploadedFiles:"",ctx:null,removeEventListeners:null,uniqueContextName:p.uniqueContextName,pendingUploads:[],pendingRemovals:[],isInitialized:!1,stateHasBeenInitialized:!1,isStateWatcherActive:!1,isLocalUpdate:!1,doneButtonHider:null,documentClassObserver:null,formInputObserver:null,log(e,t={}){console.log(`[UC-DEBUG] [${this.uniqueContextName}] [${new Date().toISOString().split("T")[1]}] ${e}`,t)},async init(){if(this.log("INIT STARTED",{state:this.state,isConnected:this.$el.isConnected,isInitialized:this.isInitialized}),this.isContextAlreadyInitialized()){this.log("Context already initialized, skipping");return}if(this.markContextAsInitialized(),this.applyTheme(),await this.loadAllLocales(),!this.$el.isConnected){this.log("Component disconnected after loadAllLocales, aborting init (ZOMBIE KILL)");return}this.log("Locales loaded",{isConnected:this.$el.isConnected}),this.setupStateWatcher(),this.$el.addEventListener("uploadcare-state-updated",e=>{this.log("uploadcare-state-updated event",e.detail);let t=e.detail.uuid;t&&this.isInitialized?this.loadFileFromUuid(t):t&&this.$nextTick(()=>{this.isInitialized&&this.loadFileFromUuid(t)})}),this.initUploadcare(),this.setupThemeObservers(),this.setupDoneButtonObserver(),(!this.state||this.state==="[]"||this.state==='""')&&this.$nextTick(()=>{this.isInitialized&&this.getCurrentFiles().length>0&&(this.log("Proactive clear triggering clearAllFiles(SILENT)"),this.clearAllFiles(!1))})},isContextAlreadyInitialized(){return window._initializedUploadcareContexts.has(this.uniqueContextName)},markContextAsInitialized(){window._initializedUploadcareContexts.add(this.uniqueContextName)},async loadAllLocales(){window._uploadcareAllLocalesLoaded||await new Promise(t=>{if(window._uploadcareAllLocalesLoaded){t();return}let i=setInterval(()=>{window._uploadcareAllLocalesLoaded&&(clearInterval(i),t())},100);setTimeout(()=>{clearInterval(i),t()},5e3)});let e=["de","es","fr","he","it","nl","pl","pt","ru","tr","uk","zh-TW","zh"];document.querySelectorAll("uc-config[data-locale-name]").forEach(t=>{let i=t.getAttribute("data-locale-name");i&&e.includes(i)&&!t.getAttribute("locale-name")&&t.setAttribute("locale-name",i)})},async loadLocale(){if(this.localeName==="en"||this.localeLoaded)return;if(window._uploadcareLocales&&window._uploadcareLocales.has(this.localeName)){this.localeLoaded=!0;return}if(window._uploadcareLocales||(window._uploadcareLocales=new Set),!!["de","es","fr","he","it","nl","pl","pt","ru","tr","uk","zh-TW","zh"].includes(this.localeName))try{let i=await import(`https://cdn.jsdelivr.net/npm/@uploadcare/file-uploader@v1/locales/file-uploader/${this.localeName}.js`),s=i.default||i,a=()=>{let r=customElements.get("uc-file-uploader-inline")||customElements.get("uc-file-uploader-regular")||customElements.get("uc-file-uploader-minimal");return r&&r.UC?r.UC:window.UC},n=()=>{let r=a();return r&&typeof r.defineLocale=="function"?(r.defineLocale(this.localeName,s),window._uploadcareLocales.add(this.localeName),this.localeLoaded=!0,!0):!1};if(!n()){let r=0,o=50,c=setInterval(()=>{r++,(n()||r>=o)&&clearInterval(c)},100)}}catch(t){console.error("[Uploadcare Locale JS] Failed to load locale:",this.localeName,t)}},applyTheme(){let e=this.getCurrentTheme();this.$el.querySelectorAll(`uc-file-uploader-${this.uploaderStyle}`).forEach(i=>{i.classList.remove("uc-dark","uc-light"),i.classList.add(`uc-${e}`)})},getCurrentTheme(){return document.documentElement.classList.contains("dark")?"dark":"light"},setupThemeObservers(){window.addEventListener("storage",this.handleThemeStorageChange.bind(this)),window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change",this.handleSystemThemeChange.bind(this)),this.setupDocumentClassObserver()},handleThemeStorageChange(e){e.key==="theme"&&this.applyTheme()},handleSystemThemeChange(){localStorage.getItem("theme")==="system"&&this.applyTheme()},setupDocumentClassObserver(){this.documentClassObserver=new MutationObserver(e=>{e.forEach(t=>{if(t.type==="attributes"&&t.attributeName==="class"){let i=document.documentElement.classList.contains("dark"),s=t.oldValue&&t.oldValue.includes("dark");i!==s&&this.applyTheme()}})}),this.documentClassObserver.observe(document.documentElement,{attributes:!0,attributeOldValue:!0,attributeFilter:["class"]})},initUploadcare(){this.removeEventListeners&&this.removeEventListeners(),this.initializeUploader()},initializeUploader(e=0,t=10){if(e>=t)return;this.ctx=this.$el.querySelector(`uc-upload-ctx-provider[ctx-name="${this.uniqueContextName}"]`);let i=this.getUploadcareApi();if(!this.isValidContext(i)){setTimeout(()=>this.initializeUploader(e+1,t),100);return}this.markAsInitialized(),this.removeRequiredAttributes(),this.initializeState(i),this.setupEventListeners(i)},getUploadcareApi(){try{return this.ctx?.getAPI()}catch{return null}},isValidContext(e){return this.ctx&&e&&e.addFileFromCdnUrl},markAsInitialized(){this.isInitialized=!0},removeRequiredAttributes(){setTimeout(()=>{let e=this.$el.closest("uc-config");document.querySelectorAll("uc-form-input input[required]").forEach(i=>i.removeAttribute("required"))},100)},initializeState(e){this.initialState&&!this.stateHasBeenInitialized&&!this.uploadedFiles?this.loadInitialState(e):!this.initialState&&!this.stateHasBeenInitialized&&(this.stateHasBeenInitialized=!0,this.uploadedFiles=this.isMultiple?"[]":"",this.isLocalUpdate=!0,this.state=this.uploadedFiles)},loadInitialState(e){try{let t=this.parseInitialState();this.addFilesFromInitialState(e,t),this.stateHasBeenInitialized=!0,this.isLocalUpdate=!0,this.state=this.uploadedFiles}catch(t){console.error("Error parsing initialState:",t)}},parseInitialState(){let e=i=>{if(typeof i=="string")try{let s=JSON.parse(i);if(typeof s=="string")try{s=JSON.parse(s)}catch{}return s}catch{return i}return i};this.initialState&&this.initialState&&typeof this.initialState=="object"&&!Array.isArray(this.initialState)&&(this.initialState=[this.initialState]);let t=this.parseStateValue(this.initialState);return console.log(`[Uploadcare ${this.statePath}] initializeState`,{hasInitialState:!!this.initialState,parsedCount:Array.isArray(t)?t.length:"n/a"}),t},addFilesFromInitialState(e,t){let i=[];if(t&&t&&typeof t=="object"&&!Array.isArray(t))try{i=Array.from(t)}catch{i=[t]}else Array.isArray(t)?i=t:t&&(i=[t]);if(Array.isArray(i)&&i.length===1&&Array.isArray(i[0])&&(i=i[0]),Array.isArray(i)&&i.length===1&&typeof i[0]=="string")try{let r=JSON.parse(i[0]);i=Array.isArray(r)?r:[r]}catch{}if(!Array.isArray(i)||i.length===0)return;Array.isArray(i)||(i=[i]);let s=(r,o=0)=>{if(!r)return;if(Array.isArray(r)){r.forEach((u,d)=>{s(u,`${o}.${d}`)});return}if(typeof r=="string")try{let u=JSON.parse(r);s(u,o);return}catch{}let c=r&&typeof r=="object"?r.cdnUrl:r,h=r&&typeof r=="object"?r.cdnUrlModifiers:null;if(console.log(`[Uploadcare ${this.statePath}] addFilesFromInitialState adding item`,{index:o,url:c}),!c||!this.isValidUrl(c))return;let l=this.extractUuidFromUrl(c);if(l&&typeof e.addFileFromUuid=="function")try{if(h&&typeof e.addFileFromCdnUrl=="function"){let d=c.split("/-/")[0]+"/"+h;e.addFileFromCdnUrl(d)}else e.addFileFromUuid(l)}catch(u){console.error(`Failed to add file ${o} with UUID ${l}:`,u)}else console.error(l?"addFileFromUuid method not available on API":`Could not extract UUID from URL: ${c}`)};i.forEach(s);let a=i.map(r=>{let o=r;if(r&&typeof r=="object")return r.uuid||(r.uuid=this.extractUuidFromUrl(r.cdnUrl)),r;if(typeof r=="string"){let c=this.extractUuidFromUrl(r);return{cdnUrl:r,uuid:c,name:"",size:0,mimeType:"",isImage:!1}}return r}),n=this.formatFilesForState(a);this.uploadedFiles=JSON.stringify(n),this.initialState=this.uploadedFiles},isValidUrl(e){if(!e||typeof e!="string")return!1;try{return new URL(e),!0}catch{return!1}},setupStateWatcher(){this.$watch("state",(e,t)=>{if(this.isLocalUpdate){this.isLocalUpdate=!1;return}if(!this.$el.isConnected){this.log("WATCHER: Component detached, ignoring change");return}if(!this.stateHasBeenInitialized){this.stateHasBeenInitialized=!0,this.log("WATCHER: State initialized (first run)");return}if(this.log("WATCHER: State changed",{newValue:typeof e=="string"?e.substring(0,100):e,oldValue:typeof t=="string"?t.substring(0,100):t,currentUploadedFiles:this.uploadedFiles.substring(0,100),isLocalUpdate:this.isLocalUpdate}),!e||e==="[]"||e==='""'||Array.isArray(e)&&e.length===0){this.uploadedFiles&&this.uploadedFiles!=="[]"&&this.uploadedFiles!=='""'&&this.getCurrentFiles().length>0&&(this.log("State cleared (server sent empty), calling clearAllFiles(SILENT)"),this.clearAllFiles(!1));return}let i=this.normalizeStateValue(e),s=this.normalizeStateValue(this.uploadedFiles);i!==s&&e&&e!=="[]"&&e!=='""'&&this.addFilesFromState(e)})},parseStateValue(e){if(!e)return null;try{return typeof e=="string"?JSON.parse(e):e}catch{return e}},addFilesFromState(e){let i=this.parseStateValue(e);if(Array.isArray(i)||(i=[i]),i=i.filter(l=>l!=null),i.length===0)return!1;let s=this.getUploadcareApi();if(!s||typeof s.addFileFromCdnUrl!="function")return!1;let a=this.getCurrentFiles();console.log("[Uploadcare] addFilesFromState currentFiles",a.length,a);let n=a.map(l=>l?l&&typeof l=="object"?l.cdnUrl:l:null).filter(Boolean);console.log(`[Uploadcare ${this.statePath}] addFilesFromState filesToAdd`,i.length,i),i.forEach((l,u)=>{if(!l){console.warn(`[Uploadcare] Skipping null item at index ${u}`);return}let d=l&&typeof l=="object"?l.cdnUrl:l;if(console.log(`[Uploadcare] Processing item ${u}`,{url:d,item:l}),d&&typeof d=="string"&&(d.includes("ucarecdn.com")||d.includes("ucarecd.net"))&&!n.some(m=>{let y=this.extractUuidFromUrl(d),g=this.extractUuidFromUrl(m);return y&&g&&y===g}))try{s.addFileFromCdnUrl(d)}catch(m){console.error("[Uploadcare] Failed to add file from URL:",d,m)}});let r=[],o=new Set,c=l=>{if(!l)return;let u=l&&typeof l=="object"?l.cdnUrl:l,d=this.extractUuidFromUrl(u);d&&!o.has(d)?(o.add(d),this.isWithMetadata&&typeof l!="object"?r.push({cdnUrl:l,uuid:d,name:"",size:0,mimeType:"",isImage:!1}):r.push(l)):d||r.push(l)},h=this.parseStateValue(e)||[];return(Array.isArray(h)?h:[h]).forEach(c),this.uploadedFiles=JSON.stringify(r),this.isLocalUpdate=!0,!0},normalizeStateValue(e){if(!e)return"";try{let t=typeof e=="string"?JSON.parse(e):e;if(Array.isArray(t)){if(t.every(a=>typeof a=="string"||typeof a=="object"&&a!==null&&("cdnUrl"in a||"uuid"in a)))return JSON.stringify(t);console.log("[Uploadcare] normalizing mixed/raw array",t)}let i=this.formatFilesForState(t);return console.log("[Uploadcare] normalizeStateValue result",i),JSON.stringify(i)}catch(t){return console.error("[Uploadcare] normalizeStateValue error",t),e}},isStateChanged(){let e=this.normalizeStateValue(this.state),t=this.normalizeStateValue(this.initialState);return e!==t},setupEventListeners(e){this.pendingUploads=[],this.pendingRemovals=[];let t=this.createFileUploadSuccessHandler(e),i=this.createFileUrlChangedHandler(e),s=this.createFileRemovedHandler(e),a=this.createFormInputChangeHandler(e),n=r=>{if(r.target!==this.ctx&&!this.ctx.contains(r.target))return;let o=this.$el.closest("form");o&&o.dispatchEvent(new CustomEvent("form-processing-started",{detail:{message:"Uploading file..."}}))};this.ctx.addEventListener("file-upload-started",n),this.ctx.addEventListener("file-upload-success",t),this.ctx.addEventListener("file-url-changed",i),this.ctx.addEventListener("file-removed",s),this.$nextTick(()=>{let r=this.$el.querySelector("uc-form-input input");if(r){r.addEventListener("input",a),r.addEventListener("change",a);let o=new MutationObserver(()=>{a({target:r})});o.observe(r,{attributes:!0,attributeFilter:["value"]}),this.formInputObserver=o}}),this.removeEventListeners=()=>{this.ctx.removeEventListener("file-upload-started",n),this.ctx.removeEventListener("file-upload-success",t),this.ctx.removeEventListener("file-url-changed",i),this.ctx.removeEventListener("file-removed",s);let r=this.$el.querySelector("uc-form-input input");r&&(r.removeEventListener("input",a),r.removeEventListener("change",a)),this.formInputObserver&&(this.formInputObserver.disconnect(),this.formInputObserver=null)}},createFileUploadSuccessHandler(e){let t=null;return i=>{if(i.target.getAttribute("ctx-name")!==this.uniqueContextName&&i.target!==this.ctx&&!this.ctx.contains(i.target))return;let a=this.isWithMetadata?i.detail:i.detail.cdnUrl,n=this.extractUuidFromUrl(a);this.pendingUploads.push(a),t&&clearTimeout(t),t=setTimeout(()=>{try{let r=this.getCurrentFiles();for(let c of this.pendingUploads)r=this.updateFilesList(r,c);this.updateState(r),this.pendingUploads=[];let o=this.$el.closest("form");o&&o.dispatchEvent(new CustomEvent("form-processing-finished"))}catch(r){console.error("[Uploadcare] Error updating state after upload:",r)}},200)}},createFileUrlChangedHandler(e){let t=null;return i=>{if(i.target.getAttribute("ctx-name")!==this.uniqueContextName&&i.target!==this.ctx&&!this.ctx.contains(i.target))return;let a=i.detail;t&&clearTimeout(t),t=setTimeout(()=>{try{let n=this.getCurrentFiles(),r=this.updateFileUrl(n,a);this.updateState(r)}catch(n){console.error("Error updating state after URL change:",n)}},100)}},createFileRemovedHandler(e){let t=null;return i=>{if(i.target.getAttribute("ctx-name")!==this.uniqueContextName&&i.target!==this.ctx&&!this.ctx.contains(i.target))return;let a=i.detail;this.pendingRemovals.push(a),t&&clearTimeout(t),t=setTimeout(()=>{try{let n=this.getCurrentFiles();for(let r of this.pendingRemovals)n=this.removeFile(n,r);this.updateState(n),this.pendingRemovals=[]}catch(n){console.error("Error in handleFileRemoved:",n)}},100)}},createFormInputChangeHandler(e){return t=>{}},getCurrentFiles(){try{let e=this.uploadedFiles?JSON.parse(this.uploadedFiles):[];return Array.isArray(e)?e:[]}catch{return[]}},updateFilesList(e,t){if(this.isMultiple){let i=this.extractUuidFromUrl(t);return e.some(a=>this.extractUuidFromUrl(a)===i)?e:[...e,t]}return[t]},updateFileUrl(e,t){let i=t.uuid;if(!i&&t.cdnUrl&&(i=this.extractUuidFromUrl(t.cdnUrl)),!i)return e;t.uuid||(t={...t,uuid:i});let s=this.findFileIndex(e,i);if(s===-1)return e;let a;if(this.isWithMetadata){let n=e[s];if(typeof n=="string"){let r=this.extractUuidFromUrl(n);n={cdnUrl:n,uuid:r,name:"",size:0,mimeType:"",isImage:!1}}a={...n,...t}}else a=t.cdnUrl;return this.isMultiple?(e[s]=a,e):[a]},removeFile(e,t){let i=this.findFileIndex(e,t.uuid);return i===-1?e:this.isMultiple?(e.splice(i,1),e):[]},findFileIndex(e,t){return t?e.findIndex(i=>{let s=i&&typeof i=="object"?i.cdnUrl:i;return this.extractUuidFromUrl(s)===t}):-1},updateState(e){let t=new Set,i=e.filter(h=>{let l=h&&typeof h=="object"?h.cdnUrl:h,u=this.extractUuidFromUrl(l);return u?t.has(u)?!1:(t.add(u),!0):!0}),s=this.formatFilesForState(i),a=JSON.stringify(s),n=this.getCurrentFiles(),r=JSON.stringify(this.formatFilesForState(n)),o=JSON.stringify(s);r!==o&&(this.uploadedFiles=a,this.isLocalUpdate=!0,this.state=this.uploadedFiles,this.isMultiple&&i.length>1&&this.$nextTick(()=>{this.isLocalUpdate=!1}))},formatFilesForState(e){if(!e)return[];if(!Array.isArray(e))if(console.warn("[Uploadcare] formatFilesForState called with non-array:",typeof e,e),typeof e=="string")try{let t=JSON.parse(e);if(Array.isArray(t))e=t;else return[]}catch{return[]}else return[];return e.map(t=>{if(t&&typeof t=="object"&&!t.cdnUrl&&!t.uuid&&"0"in t){let i=Object.keys(t);if(i.length>5&&i.includes("0")&&i.includes("1")&&i.includes("2")){let s="";if(Math.max(...i.map(n=>parseInt(n)).filter(n=>!isNaN(n)))===i.length-1){let n=new Array(i.length);for(let r=0;r0){let r=[];for(let o of t){console.log(`[Uploadcare ${this.statePath}] syncStateWithUploadcare - state item`,o);let c=o&&typeof o=="object"?o.cdnUrl:o;if(typeof c=="string"&&c.match(/[a-f0-9-]{36}~[0-9]+/)){console.log("[Uploadcare] Found group URL:",c);try{let h=await this.fetchGroupFiles(c);console.log("[Uploadcare] Expanded group to:",h.length,"files"),r.push(...h)}catch(h){console.error("[Uploadcare] Failed to expand group:",c,h),r.push(o)}}else r.push(o)}console.log("[Uploadcare] Flattened files count:",r.length),t=r}let i=this.formatFilesForState(t),s=this.buildStateFromFiles(i),a=this.normalizeStateValue(this.uploadedFiles),n=this.normalizeStateValue(s);a!==n&&(this.uploadedFiles=s,this.isLocalUpdate=!0,this.state=this.uploadedFiles)}catch(t){console.error("Error syncing state with Uploadcare:",t)}},async fetchGroupFiles(e){let t=e;if(e.includes("ucarecdn.com")||e.includes("ucarecd.net")){let a=e.match(/\/([a-f0-9-]{36}~[0-9]+)/);a&&(t=a[1])}let i=await fetch(`https://upload.uploadcare.com/group/info/?pub_key=${this.publicKey}&group_id=${t}`);if(!i.ok)throw new Error(`Failed to fetch group info: ${i.statusText}`);let s=await i.json();return s.files?s.files.map(a=>{let n=`https://ucarecdn.com/${a.uuid}/`;return this.isWithMetadata?{uuid:a.uuid,cdnUrl:n,name:a.original_filename,size:a.size,mimeType:a.mime_type,isImage:a.is_image}:n}):[]},buildStateFromFiles(e){return this.isMultiple?JSON.stringify(e):e.length>0?this.isWithMetadata?JSON.stringify(e[0]):e[0]:""},getCurrentFilesFromUploadcare(e){try{if(e&&typeof e.value=="function"){let i=e.value();return i?Array.isArray(i)?i.filter(a=>a!=null):this.parseFormInputValue(i).filter(a=>a!=null):[]}let t=this.$el.querySelector("uc-form-input input");return t?this.parseFormInputValue(t.value).filter(s=>s!=null):[]}catch(t){return console.error("Error getting current files from Uploadcare:",t),[]}},parseFormInputValue(e){if(!e||typeof e=="string"&&e.trim()==="")return[];if(typeof e=="object")return[e];try{let t=JSON.parse(e);return Array.isArray(t)?t.filter(i=>i!==null&&i!==""):t!==null&&t!==""?[t]:[]}catch{return typeof e=="string"&&e.trim()!==""?[e]:[]}},clearAllFiles(e=!0){let t=this.statePath||"unknown";this.log("clearAllFiles called",{uploadedFiles:this.uploadedFiles,state:this.state,emitStateChange:e});let i=this.getUploadcareApi();if(i){this.log("API found, attempting clear",{hasCollection:!!i.collection,hasGetCollection:typeof i.getCollection=="function",hasRemoveAll:typeof i.removeAllFiles=="function"});try{if(i.collection&&typeof i.collection.clear=="function")i.collection.clear();else if(typeof i.getCollection=="function"){let s=i.getCollection();s&&typeof s.clear=="function"&&s.clear()}}catch(s){console.warn(`[Uploadcare ${t}] collection clear error:`,s)}try{typeof i.removeAllFiles=="function"&&i.removeAllFiles()}catch{}try{typeof i.value=="function"?i.value([]):i.value=[]}catch{}}else console.warn(`[Uploadcare ${t}] No API discovered for clearing`);try{let s=this.$el.querySelector("uc-form-input");if(s&&typeof s.getAPI=="function"){let a=s.getAPI();a&&(a.value=this.isMultiple?[]:"")}}catch{}this.uploadedFiles!==(this.isMultiple?"[]":"")&&(this.uploadedFiles=this.isMultiple?"[]":"",this.isLocalUpdate=!0,e&&(this.state=this.uploadedFiles))}}}export{U as default}; +var m=class{constructor(e){this.wrapper=e,this.observer=null,this.init()}init(){this.hideDoneButtons(),this.setupObserver()}setupObserver(){this.observer=new MutationObserver(e=>{e.forEach(t=>{t.type==="childList"&&t.addedNodes.forEach(i=>{if(i.nodeType===Node.ELEMENT_NODE){i.classList&&i.classList.contains("uc-done-btn")&&this.hideDoneButton(i);let s=i.querySelectorAll&&i.querySelectorAll(".uc-done-btn");s&&s.forEach(a=>this.hideDoneButton(a))}})})}),this.wrapper&&this.observer.observe(this.wrapper,{childList:!0,subtree:!0})}hideDoneButtons(){document.querySelectorAll(".uc-done-btn").forEach(t=>this.hideDoneButton(t))}hideDoneButton(e){e&&(e.style.display="none",e.style.visibility="hidden",e.style.opacity="0",e.style.pointerEvents="none",e.style.position="absolute",e.style.width="0",e.style.height="0",e.style.overflow="hidden",e.style.clip="rect(0, 0, 0, 0)",e.style.margin="0",e.style.padding="0",e.style.border="0",e.style.background="transparent",e.style.color="transparent",e.style.fontSize="0",e.style.lineHeight="0")}destroy(){this.observer&&(this.observer.disconnect(),this.observer=null)}};function F(p){return window._initializedUploadcareContexts||(window._initializedUploadcareContexts=new Set),{state:p.state,statePath:p.statePath,initialState:p.initialState,publicKey:p.publicKey,isMultiple:p.isMultiple,multipleMin:p.multipleMin,multipleMax:p.multipleMax,isImagesOnly:p.isImagesOnly,accept:p.accept,sourceList:p.sourceList,uploaderStyle:p.uploaderStyle,isWithMetadata:p.isWithMetadata,localeName:p.localeName||"en",uploadedFiles:"",ctx:null,removeEventListeners:null,uniqueContextName:p.uniqueContextName,pendingUploads:[],pendingRemovals:[],isInitialized:!1,stateHasBeenInitialized:!1,isStateWatcherActive:!1,isLocalUpdate:!1,doneButtonHider:null,documentClassObserver:null,formInputObserver:null,async init(){this.isContextAlreadyInitialized()||(this.markContextAsInitialized(),this.applyTheme(),await this.loadAllLocales(),this.$el.isConnected&&(this.setupStateWatcher(),this.$el.addEventListener("uploadcare-state-updated",e=>{let t=e.detail.uuid;t&&this.isInitialized?this.loadFileFromUuid(t):t&&this.$nextTick(()=>{this.isInitialized&&this.loadFileFromUuid(t)})}),this.initUploadcare(),this.setupThemeObservers(),this.setupDoneButtonObserver(),(!this.state||this.state==="[]"||this.state==='""')&&this.$nextTick(()=>{this.isInitialized&&this.getCurrentFiles().length>0&&this.clearAllFiles(!1)})))},isContextAlreadyInitialized(){return window._initializedUploadcareContexts.has(this.uniqueContextName)},markContextAsInitialized(){window._initializedUploadcareContexts.add(this.uniqueContextName)},async loadAllLocales(){window._uploadcareAllLocalesLoaded||await new Promise(t=>{if(window._uploadcareAllLocalesLoaded){t();return}let i=setInterval(()=>{window._uploadcareAllLocalesLoaded&&(clearInterval(i),t())},100);setTimeout(()=>{clearInterval(i),t()},5e3)});let e=["de","es","fr","he","it","nl","pl","pt","ru","tr","uk","zh-TW","zh"];document.querySelectorAll("uc-config[data-locale-name]").forEach(t=>{let i=t.getAttribute("data-locale-name");i&&e.includes(i)&&!t.getAttribute("locale-name")&&t.setAttribute("locale-name",i)})},async loadLocale(){if(this.localeName==="en"||this.localeLoaded)return;if(window._uploadcareLocales&&window._uploadcareLocales.has(this.localeName)){this.localeLoaded=!0;return}if(window._uploadcareLocales||(window._uploadcareLocales=new Set),!!["de","es","fr","he","it","nl","pl","pt","ru","tr","uk","zh-TW","zh"].includes(this.localeName))try{let i=await import(`https://cdn.jsdelivr.net/npm/@uploadcare/file-uploader@v1/locales/file-uploader/${this.localeName}.js`),s=i.default||i,a=()=>{let r=customElements.get("uc-file-uploader-inline")||customElements.get("uc-file-uploader-regular")||customElements.get("uc-file-uploader-minimal");return r&&r.UC?r.UC:window.UC},n=()=>{let r=a();return r&&typeof r.defineLocale=="function"?(r.defineLocale(this.localeName,s),window._uploadcareLocales.add(this.localeName),this.localeLoaded=!0,!0):!1};if(!n()){let r=0,o=50,c=setInterval(()=>{r++,(n()||r>=o)&&clearInterval(c)},100)}}catch(t){console.error("[Uploadcare Locale JS] Failed to load locale:",this.localeName,t)}},applyTheme(){let e=this.getCurrentTheme();this.$el.querySelectorAll(`uc-file-uploader-${this.uploaderStyle}`).forEach(i=>{i.classList.remove("uc-dark","uc-light"),i.classList.add(`uc-${e}`)})},getCurrentTheme(){return document.documentElement.classList.contains("dark")?"dark":"light"},setupThemeObservers(){window.addEventListener("storage",this.handleThemeStorageChange.bind(this)),window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change",this.handleSystemThemeChange.bind(this)),this.setupDocumentClassObserver()},handleThemeStorageChange(e){e.key==="theme"&&this.applyTheme()},handleSystemThemeChange(){localStorage.getItem("theme")==="system"&&this.applyTheme()},setupDocumentClassObserver(){this.documentClassObserver=new MutationObserver(e=>{e.forEach(t=>{if(t.type==="attributes"&&t.attributeName==="class"){let i=document.documentElement.classList.contains("dark"),s=t.oldValue&&t.oldValue.includes("dark");i!==s&&this.applyTheme()}})}),this.documentClassObserver.observe(document.documentElement,{attributes:!0,attributeOldValue:!0,attributeFilter:["class"]})},initUploadcare(){this.removeEventListeners&&this.removeEventListeners(),this.initializeUploader()},initializeUploader(e=0,t=10){if(e>=t)return;this.ctx=this.$el.querySelector(`uc-upload-ctx-provider[ctx-name="${this.uniqueContextName}"]`);let i=this.getUploadcareApi();if(!this.isValidContext(i)){setTimeout(()=>this.initializeUploader(e+1,t),100);return}this.markAsInitialized(),this.removeRequiredAttributes(),this.initializeState(i),this.setupEventListeners(i)},getUploadcareApi(){try{return this.ctx?.getAPI()}catch{return null}},isValidContext(e){return this.ctx&&e&&e.addFileFromCdnUrl},markAsInitialized(){this.isInitialized=!0},removeRequiredAttributes(){setTimeout(()=>{let e=this.$el.closest("uc-config");document.querySelectorAll("uc-form-input input[required]").forEach(i=>i.removeAttribute("required"))},100)},initializeState(e){this.initialState&&!this.stateHasBeenInitialized&&!this.uploadedFiles?this.loadInitialState(e):!this.initialState&&!this.stateHasBeenInitialized&&(this.stateHasBeenInitialized=!0,this.uploadedFiles=this.isMultiple?"[]":"",this.isLocalUpdate=!0,this.state=this.uploadedFiles)},loadInitialState(e){try{let t=this.parseInitialState();this.addFilesFromInitialState(e,t),this.stateHasBeenInitialized=!0,this.isLocalUpdate=!0,this.state=this.uploadedFiles}catch(t){console.error("Error parsing initialState:",t)}},parseInitialState(){let e=i=>{if(typeof i=="string")try{let s=JSON.parse(i);if(typeof s=="string")try{s=JSON.parse(s)}catch{}return s}catch{return i}return i};return this.initialState&&this.initialState&&typeof this.initialState=="object"&&!Array.isArray(this.initialState)&&(this.initialState=[this.initialState]),this.parseStateValue(this.initialState)},addFilesFromInitialState(e,t){let i=[];if(t&&t&&typeof t=="object"&&!Array.isArray(t))try{i=Array.from(t)}catch{i=[t]}else Array.isArray(t)?i=t:t&&(i=[t]);if(Array.isArray(i)&&i.length===1&&Array.isArray(i[0])&&(i=i[0]),Array.isArray(i)&&i.length===1&&typeof i[0]=="string")try{let r=JSON.parse(i[0]);i=Array.isArray(r)?r:[r]}catch{}if(!Array.isArray(i)||i.length===0)return;Array.isArray(i)||(i=[i]);let s=(r,o=0)=>{if(!r)return;if(Array.isArray(r)){r.forEach((h,d)=>{s(h,`${o}.${d}`)});return}if(typeof r=="string")try{let h=JSON.parse(r);s(h,o);return}catch{}let c=r&&typeof r=="object"?r.cdnUrl:r,u=r&&typeof r=="object"?r.cdnUrlModifiers:null;if(!c||!this.isValidUrl(c))return;let l=this.extractUuidFromUrl(c);if(l&&typeof e.addFileFromUuid=="function")try{if((u||c&&c.includes("/-/"))&&typeof e.addFileFromCdnUrl=="function"){let d=c;if(u){let y=c.split("/-/")[0],f=u;f.startsWith("/")&&(f=f.substring(1)),d=y+(y.endsWith("/")?"":"/")+f}e.addFileFromCdnUrl(d)}else e.addFileFromUuid(l)}catch(h){console.error(`Failed to add file ${o} with UUID ${l}:`,h)}else console.error(l?"addFileFromUuid method not available on API":`Could not extract UUID from URL: ${c}`)};i.forEach(s);let a=i.map(r=>{let o=r;if(r&&typeof r=="object")return r.uuid||(r.uuid=this.extractUuidFromUrl(r.cdnUrl)),r;if(typeof r=="string"){let c=this.extractUuidFromUrl(r);return{cdnUrl:r,uuid:c,name:"",size:0,mimeType:"",isImage:!1}}return r}),n=this.formatFilesForState(a);this.uploadedFiles=JSON.stringify(n),this.initialState=this.uploadedFiles},isValidUrl(e){if(!e||typeof e!="string")return!1;try{return new URL(e),!0}catch{return!1}},setupStateWatcher(){this.$watch("state",(e,t)=>{if(this.isLocalUpdate){this.isLocalUpdate=!1;return}if(!this.$el.isConnected)return;if(!this.stateHasBeenInitialized){this.stateHasBeenInitialized=!0;return}if(!e||e==="[]"||e==='""'||Array.isArray(e)&&e.length===0){this.uploadedFiles&&this.uploadedFiles!=="[]"&&this.uploadedFiles!=='""'&&this.getCurrentFiles().length>0&&this.clearAllFiles(!1);return}let i=this.normalizeStateValue(e),s=this.normalizeStateValue(this.uploadedFiles);i!==s&&e&&e!=="[]"&&e!=='""'&&this.addFilesFromState(e)})},parseStateValue(e){if(!e)return null;try{return typeof e=="string"?JSON.parse(e):e}catch{return e}},addFilesFromState(e){let i=this.parseStateValue(e);if(Array.isArray(i)||(i=[i]),i=i.filter(l=>l!=null),i.length===0)return!1;let s=this.getUploadcareApi();if(!s||typeof s.addFileFromCdnUrl!="function")return!1;let n=this.getCurrentFiles().map(l=>l?l&&typeof l=="object"?l.cdnUrl:l:null).filter(Boolean);i.forEach((l,h)=>{if(!l){console.warn(`[Uploadcare] Skipping null item at index ${h}`);return}let d=l&&typeof l=="object"?l.cdnUrl:l;if(d&&typeof d=="string"&&(d.includes("ucarecdn.com")||d.includes("ucarecd.net"))&&!n.some(f=>{let U=this.extractUuidFromUrl(d),g=this.extractUuidFromUrl(f);return U&&g&&U===g}))try{s.addFileFromCdnUrl(d)}catch(f){console.error("[Uploadcare] Failed to add file from URL:",d,f)}});let r=[],o=new Set,c=l=>{if(!l)return;let h=l&&typeof l=="object"?l.cdnUrl:l,d=this.extractUuidFromUrl(h);d&&!o.has(d)?(o.add(d),this.isWithMetadata&&typeof l!="object"?r.push({cdnUrl:l,uuid:d,name:"",size:0,mimeType:"",isImage:!1}):r.push(l)):d||r.push(l)},u=this.parseStateValue(e)||[];return(Array.isArray(u)?u:[u]).forEach(c),this.uploadedFiles=JSON.stringify(r),this.isLocalUpdate=!0,!0},normalizeStateValue(e){if(!e)return"";try{let t=typeof e=="string"?JSON.parse(e):e;if(Array.isArray(t)&&t.every(a=>typeof a=="string"||typeof a=="object"&&a!==null&&("cdnUrl"in a||"uuid"in a)))return JSON.stringify(t);let i=this.formatFilesForState(t);return JSON.stringify(i)}catch(t){return console.error("[Uploadcare] normalizeStateValue error",t),e}},isStateChanged(){let e=this.normalizeStateValue(this.state),t=this.normalizeStateValue(this.initialState);return e!==t},setupEventListeners(e){this.pendingUploads=[],this.pendingRemovals=[];let t=this.createFileUploadSuccessHandler(e),i=this.createFileUrlChangedHandler(e),s=this.createFileRemovedHandler(e),a=this.createFormInputChangeHandler(e),n=r=>{if(r.target!==this.ctx&&!this.ctx.contains(r.target))return;let o=this.$el.closest("form");o&&o.dispatchEvent(new CustomEvent("form-processing-started",{detail:{message:"Uploading file..."}}))};this.ctx.addEventListener("file-upload-started",n),this.ctx.addEventListener("file-upload-success",t),this.ctx.addEventListener("file-url-changed",i),this.ctx.addEventListener("file-removed",s),this.$nextTick(()=>{let r=this.$el.querySelector("uc-form-input input");if(r){r.addEventListener("input",a),r.addEventListener("change",a);let o=new MutationObserver(()=>{a({target:r})});o.observe(r,{attributes:!0,attributeFilter:["value"]}),this.formInputObserver=o}}),this.removeEventListeners=()=>{this.ctx.removeEventListener("file-upload-started",n),this.ctx.removeEventListener("file-upload-success",t),this.ctx.removeEventListener("file-url-changed",i),this.ctx.removeEventListener("file-removed",s);let r=this.$el.querySelector("uc-form-input input");r&&(r.removeEventListener("input",a),r.removeEventListener("change",a)),this.formInputObserver&&(this.formInputObserver.disconnect(),this.formInputObserver=null)}},createFileUploadSuccessHandler(e){let t=null;return i=>{if(i.target.getAttribute("ctx-name")!==this.uniqueContextName&&i.target!==this.ctx&&!this.ctx.contains(i.target))return;let a=this.isWithMetadata?i.detail:i.detail.cdnUrl,n=this.extractUuidFromUrl(a);this.pendingUploads.push(a),t&&clearTimeout(t),t=setTimeout(()=>{try{let r=this.getCurrentFiles();for(let c of this.pendingUploads)r=this.updateFilesList(r,c);this.updateState(r),this.pendingUploads=[];let o=this.$el.closest("form");o&&o.dispatchEvent(new CustomEvent("form-processing-finished"))}catch(r){console.error("[Uploadcare] Error updating state after upload:",r)}},200)}},createFileUrlChangedHandler(e){let t=null;return i=>{if(i.target.getAttribute("ctx-name")!==this.uniqueContextName&&i.target!==this.ctx&&!this.ctx.contains(i.target))return;let a=i.detail;t&&clearTimeout(t),t=setTimeout(()=>{try{let n=this.getCurrentFiles(),r=this.updateFileUrl(n,a);this.updateState(r)}catch(n){console.error("Error updating state after URL change:",n)}},100)}},createFileRemovedHandler(e){let t=null;return i=>{if(i.target.getAttribute("ctx-name")!==this.uniqueContextName&&i.target!==this.ctx&&!this.ctx.contains(i.target))return;let a=i.detail;this.pendingRemovals.push(a),t&&clearTimeout(t),t=setTimeout(()=>{try{let n=this.getCurrentFiles();for(let r of this.pendingRemovals)n=this.removeFile(n,r);this.updateState(n),this.pendingRemovals=[]}catch(n){console.error("Error in handleFileRemoved:",n)}},100)}},createFormInputChangeHandler(e){return t=>{}},getCurrentFiles(){try{let e=this.uploadedFiles?JSON.parse(this.uploadedFiles):[];return Array.isArray(e)?e:[]}catch{return[]}},updateFilesList(e,t){if(this.isMultiple){let i=this.extractUuidFromUrl(t);return e.some(a=>this.extractUuidFromUrl(a)===i)?e:[...e,t]}return[t]},updateFileUrl(e,t){let i=t.uuid;if(!i&&t.cdnUrl&&(i=this.extractUuidFromUrl(t.cdnUrl)),!i)return e;t.uuid||(t={...t,uuid:i});let s=this.findFileIndex(e,i);if(s===-1)return e;let a;if(this.isWithMetadata){let n=e[s];if(typeof n=="string"){let r=this.extractUuidFromUrl(n);n={cdnUrl:n,uuid:r,name:"",size:0,mimeType:"",isImage:!1}}if(a={...n,...t},a.cdnUrl){let r=this.extractModifiersFromUrl(a.cdnUrl);r&&(a.cdnUrlModifiers=r)}}else a=t.cdnUrl;return this.isMultiple?(e[s]=a,e):[a]},removeFile(e,t){let i=this.findFileIndex(e,t.uuid);return i===-1?e:this.isMultiple?(e.splice(i,1),e):[]},findFileIndex(e,t){return t?e.findIndex(i=>{let s=i&&typeof i=="object"?i.cdnUrl:i;return this.extractUuidFromUrl(s)===t}):-1},updateState(e){let t=new Set,i=e.filter(u=>{let l=u&&typeof u=="object"?u.cdnUrl:u,h=this.extractUuidFromUrl(l);return h?t.has(h)?!1:(t.add(h),!0):!0}),s=this.formatFilesForState(i),a=JSON.stringify(s),n=this.getCurrentFiles(),r=JSON.stringify(this.formatFilesForState(n)),o=JSON.stringify(s);r!==o&&(this.uploadedFiles=a,this.isLocalUpdate=!0,this.state=this.uploadedFiles,this.isMultiple&&i.length>1&&this.$nextTick(()=>{this.isLocalUpdate=!1}))},formatFilesForState(e){if(!e)return[];if(!Array.isArray(e))if(console.warn("[Uploadcare] formatFilesForState called with non-array:",typeof e,e),typeof e=="string")try{let t=JSON.parse(e);if(Array.isArray(t))e=t;else return[]}catch{return[]}else return[];return e.map(t=>{if(t&&typeof t=="object"&&!t.cdnUrl&&!t.uuid&&"0"in t){let i=Object.keys(t);if(i.length>5&&i.includes("0")&&i.includes("1")&&i.includes("2")){let s="";if(Math.max(...i.map(n=>parseInt(n)).filter(n=>!isNaN(n)))===i.length-1){let n=new Array(i.length);for(let r=0;r0){let r=[];for(let o of t){let c=o&&typeof o=="object"?o.cdnUrl:o;if(typeof c=="string"&&c.match(/[a-f0-9-]{36}~[0-9]+/))try{let u=await this.fetchGroupFiles(c);r.push(...u)}catch(u){console.error("[Uploadcare] Failed to expand group:",c,u),r.push(o)}else r.push(o)}t=r}let i=this.formatFilesForState(t),s=this.buildStateFromFiles(i),a=this.normalizeStateValue(this.uploadedFiles),n=this.normalizeStateValue(s);a!==n&&(this.uploadedFiles=s,this.isLocalUpdate=!0,this.state=this.uploadedFiles)}catch(t){console.error("Error syncing state with Uploadcare:",t)}},async fetchGroupFiles(e){let t=e;if(e.includes("ucarecdn.com")||e.includes("ucarecd.net")){let a=e.match(/\/([a-f0-9-]{36}~[0-9]+)/);a&&(t=a[1])}let i=await fetch(`https://upload.uploadcare.com/group/info/?pub_key=${this.publicKey}&group_id=${t}`);if(!i.ok)throw new Error(`Failed to fetch group info: ${i.statusText}`);let s=await i.json();return s.files?s.files.map(a=>{let n=`https://ucarecdn.com/${a.uuid}/`;return this.isWithMetadata?{uuid:a.uuid,cdnUrl:n,name:a.original_filename,size:a.size,mimeType:a.mime_type,isImage:a.is_image}:n}):[]},buildStateFromFiles(e){return this.isMultiple?JSON.stringify(e):e.length>0?this.isWithMetadata?JSON.stringify(e[0]):e[0]:""},getCurrentFilesFromUploadcare(e){try{if(e&&typeof e.value=="function"){let i=e.value();return i?Array.isArray(i)?i.filter(a=>a!=null):this.parseFormInputValue(i).filter(a=>a!=null):[]}let t=this.$el.querySelector("uc-form-input input");return t?this.parseFormInputValue(t.value).filter(s=>s!=null):[]}catch(t){return console.error("Error getting current files from Uploadcare:",t),[]}},parseFormInputValue(e){if(!e||typeof e=="string"&&e.trim()==="")return[];if(typeof e=="object")return[e];try{let t=JSON.parse(e);return Array.isArray(t)?t.filter(i=>i!==null&&i!==""):t!==null&&t!==""?[t]:[]}catch{return typeof e=="string"&&e.trim()!==""?[e]:[]}},clearAllFiles(e=!0){let t=this.statePath||"unknown",i=this.getUploadcareApi();if(i){try{if(i.collection&&typeof i.collection.clear=="function")i.collection.clear();else if(typeof i.getCollection=="function"){let s=i.getCollection();s&&typeof s.clear=="function"&&s.clear()}}catch(s){console.warn(`[Uploadcare ${t}] collection clear error:`,s)}try{typeof i.removeAllFiles=="function"&&i.removeAllFiles()}catch{}try{typeof i.value=="function"?i.value([]):i.value=[]}catch{}}else console.warn(`[Uploadcare ${t}] No API discovered for clearing`);try{let s=this.$el.querySelector("uc-form-input");if(s&&typeof s.getAPI=="function"){let a=s.getAPI();a&&(a.value=this.isMultiple?[]:"")}}catch{}this.uploadedFiles!==(this.isMultiple?"[]":"")&&(this.uploadedFiles=this.isMultiple?"[]":"",this.isLocalUpdate=!0,e&&(this.state=this.uploadedFiles))}}}export{F as default}; diff --git a/packages/filament-uploadcare-field/resources/js/components/uploadcare.js b/packages/filament-uploadcare-field/resources/js/components/uploadcare.js index d4b7eecc..76dbe445 100644 --- a/packages/filament-uploadcare-field/resources/js/components/uploadcare.js +++ b/packages/filament-uploadcare-field/resources/js/components/uploadcare.js @@ -338,7 +338,6 @@ export default function uploadcareField(config) { } const parsedState = this.parseStateValue(this.initialState); - console.log(`[Uploadcare ${this.statePath}] initializeState`, { hasInitialState: !!this.initialState, parsedCount: Array.isArray(parsedState) ? parsedState.length : 'n/a' }); return parsedState; }, @@ -398,7 +397,6 @@ export default function uploadcareField(config) { const url = (item && typeof item === 'object') ? item.cdnUrl : item; const cdnUrlModifiers = (item && typeof item === 'object') ? item.cdnUrlModifiers : null; - console.log(`[Uploadcare ${this.statePath}] addFilesFromInitialState adding item`, { index, url }); if (!url || !this.isValidUrl(url)) { return; @@ -407,9 +405,19 @@ export default function uploadcareField(config) { const uuid = this.extractUuidFromUrl(url); if (uuid && typeof api.addFileFromUuid === 'function') { try { - if (cdnUrlModifiers && typeof api.addFileFromCdnUrl === 'function') { - const baseUrl = url.split('/-/')[0]; - const fullUrl = baseUrl + '/' + cdnUrlModifiers; + const hasModifiers = cdnUrlModifiers || (url && url.includes('/-/')); + + if (hasModifiers && typeof api.addFileFromCdnUrl === 'function') { + let fullUrl = url; + + if (cdnUrlModifiers) { + const baseUrl = url.split('/-/')[0]; + // Ensure strict reconstruction if explicit modifiers are provided + let modifiers = cdnUrlModifiers; + if (modifiers.startsWith('/')) modifiers = modifiers.substring(1); + fullUrl = baseUrl + (baseUrl.endsWith('/') ? '' : '/') + modifiers; + } + api.addFileFromCdnUrl(fullUrl); } else { api.addFileFromUuid(uuid); @@ -491,7 +499,6 @@ export default function uploadcareField(config) { // Double check we aren't just initialized with empty const current = this.getCurrentFiles(); if (current.length > 0) { - console.log(`[Uploadcare ${this.statePath}] State cleared (server sent empty), calling clearAllFiles(SILENT)`); this.clearAllFiles(false); } } @@ -543,7 +550,6 @@ export default function uploadcareField(config) { } const currentFiles = this.getCurrentFiles(); - console.log('[Uploadcare] addFilesFromState currentFiles', currentFiles.length, currentFiles); const currentUrls = currentFiles.map(file => { // FIX: Check for null/undefined file before accessing properties @@ -552,7 +558,6 @@ export default function uploadcareField(config) { return url; }).filter(Boolean); // Filter out nulls - console.log(`[Uploadcare ${this.statePath}] addFilesFromState filesToAdd`, filesToAdd.length, filesToAdd); filesToAdd.forEach((item, index) => { if (!item) { @@ -563,7 +568,6 @@ export default function uploadcareField(config) { // FIX: Check for null/undefined item before accessing properties (double safety) const url = (item && typeof item === 'object') ? item.cdnUrl : item; - console.log(`[Uploadcare] Processing item ${index}`, { url, item }); if (url && typeof url === 'string' && (url.includes('ucarecdn.com') || url.includes('ucarecd.net'))) { const urlExists = currentUrls.some(currentUrl => { const uuid1 = this.extractUuidFromUrl(url); @@ -629,11 +633,9 @@ export default function uploadcareField(config) { if (allStringsOrProperObjects) { return JSON.stringify(parsed); } - console.log('[Uploadcare] normalizing mixed/raw array', parsed); } const formatted = this.formatFilesForState(parsed); - console.log('[Uploadcare] normalizeStateValue result', formatted); return JSON.stringify(formatted); } catch (e) { console.error('[Uploadcare] normalizeStateValue error', e); @@ -884,6 +886,14 @@ export default function uploadcareField(config) { // Merge with existing file to preserve properties like uuid if missing in detail updatedFile = { ...originalFile, ...fileDetails }; + + // Extract and persist modifiers from the new URL if present + if (updatedFile.cdnUrl) { + const extractedModifiers = this.extractModifiersFromUrl(updatedFile.cdnUrl); + if (extractedModifiers) { + updatedFile.cdnUrlModifiers = extractedModifiers; + } + } } else { updatedFile = fileDetails.cdnUrl; } @@ -1075,6 +1085,22 @@ export default function uploadcareField(config) { return null; }, + extractModifiersFromUrl(url) { + if (!url || typeof url !== 'string') return ''; + + const uuid = this.extractUuidFromUrl(url); + if (!uuid) return ''; + + const parts = url.split(uuid); + if (parts.length < 2) return ''; + + let modifiers = parts[1]; + if (modifiers.startsWith('/')) modifiers = modifiers.substring(1); + if (modifiers.endsWith('/')) modifiers = modifiers.substring(0, modifiers.length - 1); + + return modifiers; + }, + async syncStateWithUploadcare(api) { try { @@ -1084,14 +1110,11 @@ export default function uploadcareField(config) { if (currentFiles.length > 0) { const flattenedFiles = []; for (const file of currentFiles) { - console.log(`[Uploadcare ${this.statePath}] syncStateWithUploadcare - state item`, file); const url = (file && typeof file === 'object') ? file.cdnUrl : file; // Check for Group UUID or URL (uuid~count) if (typeof url === 'string' && url.match(/[a-f0-9-]{36}~[0-9]+/)) { - console.log('[Uploadcare] Found group URL:', url); try { const groupFiles = await this.fetchGroupFiles(url); - console.log('[Uploadcare] Expanded group to:', groupFiles.length, 'files'); flattenedFiles.push(...groupFiles); } catch (e) { console.error('[Uploadcare] Failed to expand group:', url, e); @@ -1101,7 +1124,6 @@ export default function uploadcareField(config) { flattenedFiles.push(file); } } - console.log('[Uploadcare] Flattened files count:', flattenedFiles.length); currentFiles = flattenedFiles; } @@ -1235,11 +1257,6 @@ export default function uploadcareField(config) { const api = this.getUploadcareApi(); if (api) { - console.log(`[Uploadcare ${path}] API found. Attempting clear methods.`, { - hasCollection: !!api.collection, - hasGetCollection: typeof api.getCollection === 'function', - hasRemoveAll: typeof api.removeAllFiles === 'function' - }); // 1. Try Collection Clear (Standard for Blocks) try { @@ -1278,7 +1295,6 @@ export default function uploadcareField(config) { if (formInput && typeof formInput.getAPI === 'function') { const fiApi = formInput.getAPI(); if (fiApi) { - // console.log(`[Uploadcare ${path}] resetting uc-form-input via API`); fiApi.value = this.isMultiple ? [] : ''; } } diff --git a/packages/filament-uploadcare-field/src/Forms/Components/Uploadcare.php b/packages/filament-uploadcare-field/src/Forms/Components/Uploadcare.php index 2352e4e8..eb6566bd 100644 --- a/packages/filament-uploadcare-field/src/Forms/Components/Uploadcare.php +++ b/packages/filament-uploadcare-field/src/Forms/Components/Uploadcare.php @@ -402,18 +402,42 @@ private static function resolveUlidsToUploadcareState(array $ulids): array private static function extractValues(array $state): array { - $keys = ['cdnUrl', 'ucarecdn', 'uuid', 'filename']; + return array_values(array_filter(array_map(function ($item) { + if (is_string($item)) { + return $item; + } - foreach ($keys as $key) { - $values = \Illuminate\Support\Arr::pluck($state, $key); - $filtered = array_filter($values); + if (! is_array($item)) { + return null; + } - if (! empty($filtered)) { - return array_values($filtered); + // Check for 'edit' meta which contains cropped URL from our backend hydration + $cdnUrl = null; + $edit = $item['edit'] ?? null; + if ($edit) { + $edit = is_string($edit) ? json_decode($edit, true) : $edit; + $cdnUrl = $edit['cdnUrl'] ?? null; + } + + if (! $cdnUrl) { + // Fallback to metadata + $meta = $item['metadata'] ?? null; + if ($meta) { + $meta = is_string($meta) ? json_decode($meta, true) : $meta; + $cdnUrl = $meta['cdnUrl'] ?? null; + } + } + + if (! $cdnUrl) { + $cdnUrl = $item['cdnUrl'] ?? $item['ucarecdn'] ?? null; + } + + if ($cdnUrl) { + return $cdnUrl; } - } - return []; + return $item['uuid'] ?? $item['filename'] ?? null; + }, $state))); } private function transformUrls($value, string $from, string $to): mixed diff --git a/packages/uploadcare-field/src/Observers/ContentFieldValueObserver.php b/packages/uploadcare-field/src/Observers/ContentFieldValueObserver.php index a14e8008..fc885d8c 100644 --- a/packages/uploadcare-field/src/Observers/ContentFieldValueObserver.php +++ b/packages/uploadcare-field/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/packages/uploadcare-field/src/Uploadcare.php b/packages/uploadcare-field/src/Uploadcare.php index 7a7e2332..38ffeaa9 100755 --- a/packages/uploadcare-field/src/Uploadcare.php +++ b/packages/uploadcare-field/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 6536b432abbe29287c39ee308b55629305674adc 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 17/43] fix: styling --- .../core/src/Resources/ContentResource.php | 8 +++---- .../ContentResource/Pages/CreateContent.php | 8 +++---- .../ContentResource/Pages/EditContent.php | 3 --- .../src/Concerns/CanMapDynamicFields.php | 10 ++++---- .../src/Forms/Components/Uploadcare.php | 4 ++-- packages/uploadcare-field/src/Uploadcare.php | 24 +++++++++---------- 6 files changed, 27 insertions(+), 30 deletions(-) diff --git a/packages/core/src/Resources/ContentResource.php b/packages/core/src/Resources/ContentResource.php index b8581880..6efa6507 100644 --- a/packages/core/src/Resources/ContentResource.php +++ b/packages/core/src/Resources/ContentResource.php @@ -206,6 +206,7 @@ public static function form(Schema $schema): Schema ->label(__(self::$type->name)) ->key(function ($livewire) { $v = $livewire->formVersion ?? 0; + return 'tab-' . self::$type->slug . '-' . $v; }) ->schema([ @@ -215,6 +216,7 @@ public static function form(Schema $schema): Schema ->columns(1) ->key(function ($livewire) { $v = $livewire->formVersion ?? 0; + return 'dynamic-fields-grid-' . $v; }) ->schema(function ($livewire) { @@ -653,11 +655,10 @@ public static function getTypeInputs($livewire = null) { $v = $livewire->formVersion ?? 0; $typeSlug = self::$type->slug ?? 'NULL'; - - + $groups = []; $fields = self::$type->fields; - + if ($fields instanceof \Illuminate\Database\Eloquent\Collection) { $fields = $fields->unique('ulid'); } else { @@ -1168,7 +1169,6 @@ protected static function getFileUploadField() return $field; } - /** * Extract plain text from rich editor content array. */ diff --git a/packages/core/src/Resources/ContentResource/Pages/CreateContent.php b/packages/core/src/Resources/ContentResource/Pages/CreateContent.php index 0b83ab18..3b1d2525 100644 --- a/packages/core/src/Resources/ContentResource/Pages/CreateContent.php +++ b/packages/core/src/Resources/ContentResource/Pages/CreateContent.php @@ -4,10 +4,8 @@ use Backstage\Fields\Concerns\CanMapDynamicFields; use Backstage\Fields\Concerns\PersistsContentData; -use Backstage\Models\Tag; use Backstage\Resources\ContentResource; use Filament\Resources\Pages\CreateRecord; -use Illuminate\Support\Str; class CreateContent extends CreateRecord { @@ -39,7 +37,7 @@ public function mount(): void protected function mutateFormDataBeforeCreate(array $data): array { $this->record = $this->getModel()::make($data); - + if (! $this->record->type_slug && isset($this->data['type_slug'])) { $this->record->type_slug = $this->data['type_slug']; } @@ -68,12 +66,12 @@ protected function afterCreate(): void // Reset state for "Create Another" $typeSlug = $this->data['type_slug'] ?? null; - + $this->data = [ 'type_slug' => $typeSlug, 'values' => [], ]; - + $this->record = $this->getModel()::make(['type_slug' => $typeSlug]); // Re-initialize static type property to prevent it being null during fill() hydration diff --git a/packages/core/src/Resources/ContentResource/Pages/EditContent.php b/packages/core/src/Resources/ContentResource/Pages/EditContent.php index 4e092bcb..3ab54ba8 100644 --- a/packages/core/src/Resources/ContentResource/Pages/EditContent.php +++ b/packages/core/src/Resources/ContentResource/Pages/EditContent.php @@ -8,7 +8,6 @@ use Backstage\Fields\Concerns\PersistsContentData; use Backstage\Models\Content; use Backstage\Models\Language; -use Backstage\Models\Tag; use Backstage\Models\Type; use Backstage\Resources\ContentResource; use Backstage\Translations\Laravel\Facades\Translator; @@ -22,8 +21,6 @@ use Filament\Support\Enums\IconSize; use Filament\Support\Enums\Width; use Filament\Support\Icons\Heroicon; -use Illuminate\Support\Facades\Auth; -use Illuminate\Support\Str; class EditContent extends EditRecord { diff --git a/packages/fields/src/Concerns/CanMapDynamicFields.php b/packages/fields/src/Concerns/CanMapDynamicFields.php index 419776b9..35c0637d 100644 --- a/packages/fields/src/Concerns/CanMapDynamicFields.php +++ b/packages/fields/src/Concerns/CanMapDynamicFields.php @@ -89,7 +89,7 @@ protected function mutateBeforeSave(array $data): array $containerData = $this->extractContainerData($values); $allFields = $this->getAllFieldsIncludingNested($containerData); - + return $this->mutateFormData($data, $allFields, function ($field, $fieldConfig, $fieldInstance, $data) { return $this->applyFieldSaveMutation($field, $fieldConfig, $fieldInstance, $data); }); @@ -191,7 +191,7 @@ private function resolveFieldConfigAndInstance(Model $field): array $fieldConfig = Fields::resolveField($field->field_type) ? $this->fieldInspector->initializeCustomField($field->field_type) : $this->fieldInspector->initializeDefaultField($field->field_type); - + return [ 'config' => $fieldConfig, 'instance' => new $fieldConfig['class'], @@ -203,7 +203,9 @@ protected function getNestedFieldsFromContainerData(array $containerData): Colle $processedFields = collect(); foreach ($containerData as $rows) { - if (! is_array($rows)) continue; + if (! is_array($rows)) { + continue; + } foreach ($rows as $item) { $itemData = isset($item['data']) ? $item['data'] : $item; @@ -213,7 +215,7 @@ protected function getNestedFieldsFromContainerData(array $containerData): Colle ->get(); $processedFields = $processedFields->merge($fields); - + // Recursive search $nestedContainers = $this->extractContainerData($itemData); if (! empty($nestedContainers)) { diff --git a/packages/filament-uploadcare-field/src/Forms/Components/Uploadcare.php b/packages/filament-uploadcare-field/src/Forms/Components/Uploadcare.php index eb6566bd..3b082e77 100644 --- a/packages/filament-uploadcare-field/src/Forms/Components/Uploadcare.php +++ b/packages/filament-uploadcare-field/src/Forms/Components/Uploadcare.php @@ -415,8 +415,8 @@ private static function extractValues(array $state): array $cdnUrl = null; $edit = $item['edit'] ?? null; if ($edit) { - $edit = is_string($edit) ? json_decode($edit, true) : $edit; - $cdnUrl = $edit['cdnUrl'] ?? null; + $edit = is_string($edit) ? json_decode($edit, true) : $edit; + $cdnUrl = $edit['cdnUrl'] ?? null; } if (! $cdnUrl) { diff --git a/packages/uploadcare-field/src/Uploadcare.php b/packages/uploadcare-field/src/Uploadcare.php index 38ffeaa9..03d3b2d2 100755 --- a/packages/uploadcare-field/src/Uploadcare.php +++ b/packages/uploadcare-field/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 656d7cba2e6b842ee01bce4a3ec665ba81697171 Mon Sep 17 00:00:00 2001 From: Baspa Date: Fri, 2 Jan 2026 15:23:32 +0100 Subject: [PATCH 18/43] fix: hydrated values and relation fields --- packages/core/src/Models/Content.php | 8 +-- .../core/src/Models/ContentFieldValue.php | 72 ++++++++++++++----- .../src/Concerns/CanMapDynamicFields.php | 21 +++++- packages/fields/src/Fields/Base.php | 42 +++++++++++ packages/fields/src/Fields/Radio.php | 55 ++++++++++++++ packages/fields/src/Fields/RichEditor.php | 32 +-------- packages/fields/src/Fields/Select.php | 24 +++++-- .../src/Forms/Components/Uploadcare.php | 4 +- packages/uploadcare-field/src/Uploadcare.php | 28 +++++--- 9 files changed, 217 insertions(+), 69 deletions(-) diff --git a/packages/core/src/Models/Content.php b/packages/core/src/Models/Content.php index e159096a..aed831e7 100644 --- a/packages/core/src/Models/Content.php +++ b/packages/core/src/Models/Content.php @@ -147,7 +147,7 @@ public function getFormattedFieldValues(): array // Use the value() accessor logic which performs hydration logic // including resolving Media objects via Uploadcare's hydration logic. // This ensures pivot metadata (crops) are included. - $hydratedValue = $value->value(); + $hydratedValue = $value->getHydratedValue(); // If it's an HtmlString (e.g. RichEditor), we ensure string content if ($hydratedValue instanceof \Illuminate\Support\HtmlString) { @@ -316,7 +316,7 @@ public function scopeExpired($query): void public function blocks(string $field): array { - $value = $this->values->where('field.slug', $field)->first()?->value(); + $value = $this->values->where('field.slug', $field)->first()?->getHydratedValue(); return is_array($value) ? $value : []; } @@ -338,14 +338,14 @@ public function blocks(string $field): array * Toggle * Uploadcare * - * @see \Backstage\Models\ContentFieldValue::value() + * @see \Backstage\Models\ContentFieldValue::getHydratedValue() * @see https://docs.backstagephp.com/03-fields/01-introduction.html */ public function field(string $slug): Content | HtmlString | Collection | array | bool | null { $this->load('values'); - return $this->values->where('field.slug', $slug)->first()?->value(); + return $this->values->where('field.slug', $slug)->first()?->getHydratedValue(); } public function rawField(string $field): mixed diff --git a/packages/core/src/Models/ContentFieldValue.php b/packages/core/src/Models/ContentFieldValue.php index c0b62051..5d7a72c3 100644 --- a/packages/core/src/Models/ContentFieldValue.php +++ b/packages/core/src/Models/ContentFieldValue.php @@ -35,9 +35,9 @@ protected function casts(): array return []; } - public function content(): BelongsTo + public function contentRelation(): BelongsTo { - return $this->belongsTo(Content::class); + return $this->belongsTo(Content::class, 'content_ulid'); } public function field(): BelongsTo @@ -50,18 +50,21 @@ public function media(): \Illuminate\Database\Eloquent\Relations\MorphToMany return $this->morphToMany(config('backstage.media.model', \Backstage\Media\Models\Media::class), 'model', 'media_relationships', 'model_id', 'media_ulid'); } - public function value(): Content | HtmlString | array | Collection | bool | null + public function getHydratedValue(): Content | HtmlString | array | Collection | bool | string | null { if ($this->isRichEditor()) { - return new HtmlString(self::getRichEditorHtml($this->value ?? '')); + $html = self::getRichEditorHtml($this->value ?? '') ?? ''; + return $this->shouldHydrate() ? new HtmlString($html) : $html; } - [$hydrated, $result] = $this->tryHydrateViaClass($this->isJsonArray(), $this->field->field_type); - if ($hydrated) { - return $result; + if ($this->shouldHydrate()) { + [$hydrated, $result] = $this->tryHydrateViaClass($this->isJsonArray(), $this->field->field_type); + if ($hydrated) { + return $result; + } } - if ($this->field->hasRelation()) { + if ($this->shouldHydrate() && $this->field->hasRelation()) { return static::getContentRelation($this->value); } @@ -77,8 +80,10 @@ public function value(): Content | HtmlString | array | Collection | bool | null return $decoded; } - // For all other cases, ensure the value is returned as a string - return new HtmlString($this->value ?? ''); + // For all other cases, ensure the value is returned as a string (HTML string in frontend) + $val = $this->value ?? ''; + $res = $this->shouldHydrate() ? new HtmlString($val) : $val; + return $res; } /** @@ -105,12 +110,19 @@ public static function getContentRelation(mixed $value): Content | Collection | private function hydrateValuesRecursively(mixed $value, Field $field): mixed { - [$hydrated, $result] = $this->tryHydrateViaClass($value, $field->field_type); - if ($hydrated) { - return $result; + // Handle case where Content relationship was incorrectly loaded for rich-editor fields with slug 'content' + if ($field->field_type === 'rich-editor' && $value instanceof \Backstage\Models\Content) { + return ''; // Reset to empty string as rich-editor shouldn't store Content objects + } + + if ($this->shouldHydrate()) { + [$hydrated, $result] = $this->tryHydrateViaClass($value, $field->field_type); + if ($hydrated) { + return $result; + } } - if ($field->hasRelation()) { + if ($this->shouldHydrate() && $field->hasRelation()) { return static::getContentRelation($value); } @@ -189,13 +201,39 @@ private function hydrateItemFields(array &$data, $fields): void if ($key) { if ($child->field_type === 'rich-editor') { - $data[$key] = new HtmlString(self::getRichEditorHtml($data[$key] ?? '')); + $html = self::getRichEditorHtml($data[$key] ?? '') ?? ''; + $data[$key] = $this->shouldHydrate() ? new HtmlString($html) : $html; } else { $data[$key] = $this->hydrateValuesRecursively($data[$key], $child); } } } } + + private function shouldHydrate(): bool + { + if (app()->runningInConsole()) { + return false; + } + + if (! request()) { + return true; + } + + $path = request()->path(); + + // Broad check for admin/cms/livewire paths + if (str($path)->contains(['admin', 'backstage', 'filament', 'livewire']) || request()->headers->has('X-Livewire-Id')) { + return false; + } + + // Check if there is a Filament panel active + if (class_exists(\Filament\Facades\Filament::class) && \Filament\Facades\Filament::getCurrentPanel()) { + return false; + } + + return true; + } private function isRichEditor(): bool { @@ -209,7 +247,9 @@ private function isCheckbox(): bool private function isJsonArray(): ?array { - $decoded = json_decode($this->value, true); + // Use getRawOriginal to bypass the accessor and prevent relationship hydration + $rawValue = $this->getRawOriginal('value'); + $decoded = json_decode($rawValue, true); return is_array($decoded) ? $decoded : null; } diff --git a/packages/fields/src/Concerns/CanMapDynamicFields.php b/packages/fields/src/Concerns/CanMapDynamicFields.php index 419776b9..4feb4110 100644 --- a/packages/fields/src/Concerns/CanMapDynamicFields.php +++ b/packages/fields/src/Concerns/CanMapDynamicFields.php @@ -143,7 +143,7 @@ private function applyFieldFillMutation(Model $field, array $fieldConfig, object return $fieldInstance->mutateFormDataCallback($this->record, $field, $data); } - $data[$this->record->valueColumn][$field->ulid] = $this->record->values[$field->ulid] ?? null; + $data[$this->record->valueColumn][$field->ulid] = $fieldInstance->getFieldValueFromRecord($this->record, $field); return $data; } @@ -170,8 +170,10 @@ private function processContainerFieldFillMutation(Model $field, object $fieldIn $tempData = [$this->record->valueColumn => $fieldLocation['containerData']]; $tempData = $fieldInstance->mutateFormDataCallback($mockRecord, $field, $tempData); - if (isset($tempData[$this->record->valueColumn][$field->ulid])) { - $mutatedValue = $tempData[$this->record->valueColumn][$field->ulid]; + // Check for both ULID and slug keys (nested fields use slug) + $mutatedValue = $tempData[$this->record->valueColumn][$field->ulid] ?? $tempData[$this->record->valueColumn][$field->slug] ?? null; + + if ($mutatedValue !== null || isset($tempData[$this->record->valueColumn][$field->ulid]) || isset($tempData[$this->record->valueColumn][$field->slug])) { $this->updateDataAtPath($data[$this->record->valueColumn], $fieldLocation['fullPath'], $fieldLocation['fieldKey'], $mutatedValue); } @@ -230,7 +232,20 @@ protected function mutateFormData(array $data, Collection $fields, callable $mut { foreach ($fields as $field) { ['config' => $fieldConfig, 'instance' => $fieldInstance] = $this->resolveFieldConfigAndInstance($field); + + $valueColumn = $this->record->valueColumn ?? 'values'; + $oldValue = $data[$valueColumn][$field->ulid] ?? $data[$valueColumn][$field->slug] ?? 'NOT_SET'; + $data = $mutationStrategy($field, $fieldConfig, $fieldInstance, $data); + + $newValue = $data[$valueColumn][$field->ulid] ?? $data[$valueColumn][$field->slug] ?? 'NOT_SET'; + + if ($newValue === true) { + \Log::warning("Field {$field->ulid} (slug: {$field->slug}, type: {$field->field_type}) mutated to TRUE", [ + 'old_value' => $oldValue, + 'instance_class' => get_class($fieldInstance), + ]); + } } return $data; diff --git a/packages/fields/src/Fields/Base.php b/packages/fields/src/Fields/Base.php index b0bda95b..ac51d8b8 100644 --- a/packages/fields/src/Fields/Base.php +++ b/packages/fields/src/Fields/Base.php @@ -16,6 +16,7 @@ use Filament\Schemas\Components\Grid; use Filament\Schemas\Components\Utilities\Get; use Filament\Support\Colors\Color; +use Illuminate\Database\Eloquent\Model; use ReflectionObject; abstract class Base implements FieldContract @@ -190,4 +191,45 @@ protected static function applyAdditionalValidation($input, ?Field $field = null return $input; } + + public static function getFieldValueFromRecord(Model $record, Field $field): mixed + { + $result = null; + + // Check if record has values method + if (method_exists($record, 'values')) { + $values = $record->values(); + + // Handle relationship-based values (like Content model) + if (self::isRelationship($values)) { + $fieldValue = $values->where('field_ulid', $field->ulid)->first(); + $result = $fieldValue ? self::resolveHydratedValue($fieldValue) : null; + } + } + + if ($result === null) { + $values = $record->values ?? []; + + // Handle array/collection-based values (like Settings model) + if (is_array($values) || $values instanceof \Illuminate\Support\Collection) { + $result = $values[$field->ulid] ?? $values[$field->slug] ?? null; + } + } + + return $result; + } + + protected static function isRelationship(mixed $values): bool + { + return $values instanceof \Illuminate\Database\Eloquent\Relations\Relation; + } + + protected static function resolveHydratedValue(Model $fieldValue): mixed + { + if (method_exists($fieldValue, 'getHydratedValue')) { + return $fieldValue->getHydratedValue(); + } + + return $fieldValue->value ?? null; + } } diff --git a/packages/fields/src/Fields/Radio.php b/packages/fields/src/Fields/Radio.php index 1dd48d7b..af54aefd 100644 --- a/packages/fields/src/Fields/Radio.php +++ b/packages/fields/src/Fields/Radio.php @@ -9,6 +9,8 @@ use Filament\Forms\Components\Toggle; use Filament\Schemas\Components\Tabs; use Filament\Schemas\Components\Tabs\Tab; +use Illuminate\Database\Eloquent\Collection; +use Illuminate\Database\Eloquent\Model; class Radio extends Base implements FieldContract { @@ -44,6 +46,59 @@ public static function make(string $name, ?Field $field = null): Input return $input; } + public static function mutateFormDataCallback(Model $record, Field $field, array $data): array + { + if (! property_exists($record, 'valueColumn')) { + return $data; + } + + $value = self::getFieldValueFromRecord($record, $field); + + if ($value === null) { + return $data; + } + + $data[$record->valueColumn][$field->ulid] = self::normalizeValue($value, $field); + + return $data; + } + + public static function mutateBeforeSaveCallback(Model $record, Field $field, array $data): array + { + if (! property_exists($record, 'valueColumn')) { + return $data; + } + + $value = $data[$record->valueColumn][$field->ulid] ?? $data[$record->valueColumn][$field->slug] ?? null; + + if ($value === null && ! isset($data[$record->valueColumn][$field->ulid]) && ! isset($data[$record->valueColumn][$field->slug])) { + return $data; + } + + $data[$record->valueColumn][$field->ulid] = self::normalizeValue($value, $field); + + return $data; + } + + protected static function normalizeValue($value, Field $field): mixed + { + if ($value instanceof Collection) { + $value = $value->toArray(); + } + + // Handle JSON string values + if (is_string($value) && json_validate($value)) { + $value = json_decode($value, true); + } + + // Convert array to single value for Radio + if (is_array($value)) { + $value = empty($value) ? null : reset($value); + } + + return $value; + } + public function getForm(): array { return [ diff --git a/packages/fields/src/Fields/RichEditor.php b/packages/fields/src/Fields/RichEditor.php index 4f3b6ffa..a2ee5c40 100644 --- a/packages/fields/src/Fields/RichEditor.php +++ b/packages/fields/src/Fields/RichEditor.php @@ -158,46 +158,16 @@ private static function normalizeDynamicFieldValue(Model $record, array $data, F public static function mutateFormDataCallback(Model $record, Field $field, array $data): array { + $valueColumn = $record->valueColumn ?? 'values'; $rawValue = self::getFieldValueFromRecord($record, $field); if ($rawValue !== null) { - $valueColumn = $record->valueColumn ?? 'values'; $data[$valueColumn][$field->ulid] = $rawValue; } return $data; } - private static function getFieldValueFromRecord(Model $record, Field $field): mixed - { - // Check if record has values method - if (! method_exists($record, 'values')) { - return null; - } - - $values = $record->values(); - - // Handle relationship-based values (like Content model) - if (self::isRelationship($values)) { - return $values->where('field_ulid', $field->ulid)->first()?->value; - } - - // Handle array/collection-based values (like Settings model) - if (is_array($values) || $values instanceof \Illuminate\Support\Collection) { - return $values[$field->ulid] ?? null; - } - - return $record->values[$field->ulid] ?? null; - } - - private static function isRelationship(mixed $values): bool - { - return is_object($values) - && method_exists($values, 'where') - && method_exists($values, 'get') - && ! ($values instanceof \Illuminate\Support\Collection); - } - public function getForm(): array { return [ diff --git a/packages/fields/src/Fields/Select.php b/packages/fields/src/Fields/Select.php index e3580243..a235b5a0 100644 --- a/packages/fields/src/Fields/Select.php +++ b/packages/fields/src/Fields/Select.php @@ -13,6 +13,7 @@ use Filament\Schemas\Components\Tabs; use Filament\Schemas\Components\Tabs\Tab; use Filament\Schemas\Components\Utilities\Get; +use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Model; class Select extends Base implements FieldContract @@ -90,13 +91,19 @@ public static function make(string $name, ?Field $field = null): Input return $input; } + public static function mutateFormDataCallback(Model $record, Field $field, array $data): array { - if (! property_exists($record, 'valueColumn') || ! isset($record->values[$field->ulid])) { + if (! property_exists($record, 'valueColumn')) { + return $data; + } + + $value = self::getFieldValueFromRecord($record, $field); + + if ($value === null) { return $data; } - $value = $record->values[$field->ulid]; $data[$record->valueColumn][$field->ulid] = self::normalizeSelectValue($value, $field); return $data; @@ -104,11 +111,16 @@ public static function mutateFormDataCallback(Model $record, Field $field, array public static function mutateBeforeSaveCallback(Model $record, Field $field, array $data): array { - if (! property_exists($record, 'valueColumn') || ! isset($data[$record->valueColumn][$field->ulid])) { + if (! property_exists($record, 'valueColumn')) { + return $data; + } + + $value = $data[$record->valueColumn][$field->ulid] ?? $data[$record->valueColumn][$field->slug] ?? null; + + if ($value === null && ! isset($data[$record->valueColumn][$field->ulid]) && ! isset($data[$record->valueColumn][$field->slug])) { return $data; } - $value = $data[$record->valueColumn][$field->ulid]; $data[$record->valueColumn][$field->ulid] = self::normalizeSelectValue($value, $field); return $data; @@ -120,6 +132,10 @@ public static function mutateBeforeSaveCallback(Model $record, Field $field, arr */ protected static function normalizeSelectValue($value, Field $field): mixed { + if ($value instanceof Collection) { + $value = $value->toArray(); + } + $isMultiple = $field->config['multiple'] ?? false; // Handle JSON string values diff --git a/packages/filament-uploadcare-field/src/Forms/Components/Uploadcare.php b/packages/filament-uploadcare-field/src/Forms/Components/Uploadcare.php index eb6566bd..36e8438a 100644 --- a/packages/filament-uploadcare-field/src/Forms/Components/Uploadcare.php +++ b/packages/filament-uploadcare-field/src/Forms/Components/Uploadcare.php @@ -407,7 +407,8 @@ private static function extractValues(array $state): array return $item; } - if (! is_array($item)) { + // Allow objects (Models) if they implement ArrayAccess or are just objects we can read properties from + if (! is_array($item) && ! is_object($item)) { return null; } @@ -429,6 +430,7 @@ private static function extractValues(array $state): array } if (! $cdnUrl) { + // Safely access array/object keys $cdnUrl = $item['cdnUrl'] ?? $item['ucarecdn'] ?? null; } diff --git a/packages/uploadcare-field/src/Uploadcare.php b/packages/uploadcare-field/src/Uploadcare.php index 38ffeaa9..4058a9b1 100755 --- a/packages/uploadcare-field/src/Uploadcare.php +++ b/packages/uploadcare-field/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 6146a7be4f1b6e667eb40b50c663675400322fed 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 19/43] fix: styling --- packages/core/src/Models/ContentFieldValue.php | 6 ++++-- packages/fields/src/Concerns/CanMapDynamicFields.php | 10 +++++----- packages/fields/src/Fields/Radio.php | 2 +- packages/fields/src/Fields/Select.php | 5 ++--- packages/uploadcare-field/src/Uploadcare.php | 1 + 5 files changed, 13 insertions(+), 11 deletions(-) diff --git a/packages/core/src/Models/ContentFieldValue.php b/packages/core/src/Models/ContentFieldValue.php index 5d7a72c3..e94e5cef 100644 --- a/packages/core/src/Models/ContentFieldValue.php +++ b/packages/core/src/Models/ContentFieldValue.php @@ -54,6 +54,7 @@ public function getHydratedValue(): Content | HtmlString | array | Collection | { if ($this->isRichEditor()) { $html = self::getRichEditorHtml($this->value ?? '') ?? ''; + return $this->shouldHydrate() ? new HtmlString($html) : $html; } @@ -83,6 +84,7 @@ public function getHydratedValue(): Content | HtmlString | array | Collection | // For all other cases, ensure the value is returned as a string (HTML string in frontend) $val = $this->value ?? ''; $res = $this->shouldHydrate() ? new HtmlString($val) : $val; + return $res; } @@ -209,7 +211,7 @@ private function hydrateItemFields(array &$data, $fields): void } } } - + private function shouldHydrate(): bool { if (app()->runningInConsole()) { @@ -221,7 +223,7 @@ private function shouldHydrate(): bool } $path = request()->path(); - + // Broad check for admin/cms/livewire paths if (str($path)->contains(['admin', 'backstage', 'filament', 'livewire']) || request()->headers->has('X-Livewire-Id')) { return false; diff --git a/packages/fields/src/Concerns/CanMapDynamicFields.php b/packages/fields/src/Concerns/CanMapDynamicFields.php index e59215fb..b8474d76 100644 --- a/packages/fields/src/Concerns/CanMapDynamicFields.php +++ b/packages/fields/src/Concerns/CanMapDynamicFields.php @@ -172,7 +172,7 @@ private function processContainerFieldFillMutation(Model $field, object $fieldIn // Check for both ULID and slug keys (nested fields use slug) $mutatedValue = $tempData[$this->record->valueColumn][$field->ulid] ?? $tempData[$this->record->valueColumn][$field->slug] ?? null; - + if ($mutatedValue !== null || isset($tempData[$this->record->valueColumn][$field->ulid]) || isset($tempData[$this->record->valueColumn][$field->slug])) { $this->updateDataAtPath($data[$this->record->valueColumn], $fieldLocation['fullPath'], $fieldLocation['fieldKey'], $mutatedValue); } @@ -234,14 +234,14 @@ protected function mutateFormData(array $data, Collection $fields, callable $mut { foreach ($fields as $field) { ['config' => $fieldConfig, 'instance' => $fieldInstance] = $this->resolveFieldConfigAndInstance($field); - + $valueColumn = $this->record->valueColumn ?? 'values'; $oldValue = $data[$valueColumn][$field->ulid] ?? $data[$valueColumn][$field->slug] ?? 'NOT_SET'; - + $data = $mutationStrategy($field, $fieldConfig, $fieldInstance, $data); - + $newValue = $data[$valueColumn][$field->ulid] ?? $data[$valueColumn][$field->slug] ?? 'NOT_SET'; - + if ($newValue === true) { \Log::warning("Field {$field->ulid} (slug: {$field->slug}, type: {$field->field_type}) mutated to TRUE", [ 'old_value' => $oldValue, diff --git a/packages/fields/src/Fields/Radio.php b/packages/fields/src/Fields/Radio.php index af54aefd..e70a9483 100644 --- a/packages/fields/src/Fields/Radio.php +++ b/packages/fields/src/Fields/Radio.php @@ -68,7 +68,7 @@ public static function mutateBeforeSaveCallback(Model $record, Field $field, arr if (! property_exists($record, 'valueColumn')) { return $data; } - + $value = $data[$record->valueColumn][$field->ulid] ?? $data[$record->valueColumn][$field->slug] ?? null; if ($value === null && ! isset($data[$record->valueColumn][$field->ulid]) && ! isset($data[$record->valueColumn][$field->slug])) { diff --git a/packages/fields/src/Fields/Select.php b/packages/fields/src/Fields/Select.php index a235b5a0..fbf8f951 100644 --- a/packages/fields/src/Fields/Select.php +++ b/packages/fields/src/Fields/Select.php @@ -91,7 +91,6 @@ public static function make(string $name, ?Field $field = null): Input return $input; } - public static function mutateFormDataCallback(Model $record, Field $field, array $data): array { if (! property_exists($record, 'valueColumn')) { @@ -99,7 +98,7 @@ public static function mutateFormDataCallback(Model $record, Field $field, array } $value = self::getFieldValueFromRecord($record, $field); - + if ($value === null) { return $data; } @@ -114,7 +113,7 @@ public static function mutateBeforeSaveCallback(Model $record, Field $field, arr if (! property_exists($record, 'valueColumn')) { return $data; } - + $value = $data[$record->valueColumn][$field->ulid] ?? $data[$record->valueColumn][$field->slug] ?? null; if ($value === null && ! isset($data[$record->valueColumn][$field->ulid]) && ! isset($data[$record->valueColumn][$field->slug])) { diff --git a/packages/uploadcare-field/src/Uploadcare.php b/packages/uploadcare-field/src/Uploadcare.php index 8ad28d42..049f2091 100755 --- a/packages/uploadcare-field/src/Uploadcare.php +++ b/packages/uploadcare-field/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 4fb5496f83872470bceacd824a793d8eca3b9a57 Mon Sep 17 00:00:00 2001 From: Mathieu Date: Fri, 2 Jan 2026 16:15:37 +0100 Subject: [PATCH 20/43] Feature/2.x demo release (#37) * Remove publishing of translation config in backstage:install * Dont add default vk10 users * Remove translation config & option for creating user in install * Add secret as password * Remove vk10 users * fix: styling * email and pw required * RedirectFactory * Dont duplicate pw hash * fix: styling * Feedback * fix: styling --------- Co-authored-by: Casmo <385764+Casmo@users.noreply.github.com> --- database/seeders/BackstageSeeder.php | 43 ------- packages/core/config/backstage/cms.php | 2 +- .../core/database/seeders/BackstageSeeder.php | 43 ------- .../core/src/BackstageServiceProvider.php | 113 +++--------------- .../database/factories/RedirectFactory.php | 21 ++++ 5 files changed, 40 insertions(+), 182 deletions(-) create mode 100644 packages/laravel-redirects/database/factories/RedirectFactory.php diff --git a/database/seeders/BackstageSeeder.php b/database/seeders/BackstageSeeder.php index e23fc063..f66e65de 100644 --- a/database/seeders/BackstageSeeder.php +++ b/database/seeders/BackstageSeeder.php @@ -12,7 +12,6 @@ use Backstage\Models\Language; use Backstage\Models\Site; use Backstage\Models\Type; -use Backstage\Models\User; use Illuminate\Database\Seeder; use Illuminate\Support\Str; @@ -173,47 +172,5 @@ public function run(): void (string) Str::uuid() => ['type' => 'form', 'data' => ['slug' => 'contact']], ]), ]), 'values')->create(); - - User::factory([ - 'name' => 'Mark', - 'email' => 'mark@vk10.nl', - 'password' => 'mark@vk10.nl', - ])->create(); - - User::factory([ - 'name' => 'Rob', - 'email' => 'rob@vk10.nl', - 'password' => 'rob@vk10.nl', - ])->create(); - - User::factory([ - 'name' => 'Mathieu', - 'email' => 'mathieu@vk10.nl', - 'password' => 'mathieu@vk10.nl', - ])->create(); - - User::factory([ - 'name' => 'Bas', - 'email' => 'bas@vk10.nl', - 'password' => 'bas@vk10.nl', - ])->create(); - - User::factory([ - 'name' => 'Yoni', - 'email' => 'yoni@vk10.nl', - 'password' => 'yoni@vk10.nl', - ])->create(); - - User::factory([ - 'name' => 'Patrick', - 'email' => 'patrick@vk10.nl', - 'password' => 'patrick@vk10.nl', - ])->create(); - - User::factory([ - 'name' => 'Sandro', - 'email' => 'sandro@vk10.nl', - 'password' => 'sandro@vk10.nl', - ])->create(); } } diff --git a/packages/core/config/backstage/cms.php b/packages/core/config/backstage/cms.php index 81e2f533..9578e907 100644 --- a/packages/core/config/backstage/cms.php +++ b/packages/core/config/backstage/cms.php @@ -22,7 +22,7 @@ Backstage\Resources\SettingResource::class, Backstage\Resources\SiteResource::class, Backstage\Resources\TagResource::class, - Backstage\Resources\MediaResource::class, + // Backstage\Resources\MediaResource::class, // Backstage\Resources\TemplateResource::class, Backstage\Resources\TypeResource::class, Backstage\Resources\UserResource::class, diff --git a/packages/core/database/seeders/BackstageSeeder.php b/packages/core/database/seeders/BackstageSeeder.php index e23fc063..f66e65de 100644 --- a/packages/core/database/seeders/BackstageSeeder.php +++ b/packages/core/database/seeders/BackstageSeeder.php @@ -12,7 +12,6 @@ use Backstage\Models\Language; use Backstage\Models\Site; use Backstage\Models\Type; -use Backstage\Models\User; use Illuminate\Database\Seeder; use Illuminate\Support\Str; @@ -173,47 +172,5 @@ public function run(): void (string) Str::uuid() => ['type' => 'form', 'data' => ['slug' => 'contact']], ]), ]), 'values')->create(); - - User::factory([ - 'name' => 'Mark', - 'email' => 'mark@vk10.nl', - 'password' => 'mark@vk10.nl', - ])->create(); - - User::factory([ - 'name' => 'Rob', - 'email' => 'rob@vk10.nl', - 'password' => 'rob@vk10.nl', - ])->create(); - - User::factory([ - 'name' => 'Mathieu', - 'email' => 'mathieu@vk10.nl', - 'password' => 'mathieu@vk10.nl', - ])->create(); - - User::factory([ - 'name' => 'Bas', - 'email' => 'bas@vk10.nl', - 'password' => 'bas@vk10.nl', - ])->create(); - - User::factory([ - 'name' => 'Yoni', - 'email' => 'yoni@vk10.nl', - 'password' => 'yoni@vk10.nl', - ])->create(); - - User::factory([ - 'name' => 'Patrick', - 'email' => 'patrick@vk10.nl', - 'password' => 'patrick@vk10.nl', - ])->create(); - - User::factory([ - 'name' => 'Sandro', - 'email' => 'sandro@vk10.nl', - 'password' => 'sandro@vk10.nl', - ])->create(); } } diff --git a/packages/core/src/BackstageServiceProvider.php b/packages/core/src/BackstageServiceProvider.php index 3f9c69b9..3172da1c 100644 --- a/packages/core/src/BackstageServiceProvider.php +++ b/packages/core/src/BackstageServiceProvider.php @@ -66,18 +66,12 @@ public function configurePackage(Package $package): void ->startWith(function (InstallCommand $command) { $command->info('Welcome to the Backstage setup process.'); $command->comment("Don't trip over the wires; this is where the magic happens."); - $command->comment('Let\'s get started!'); + $command->comment("Let's get started!"); - // if ($command->confirm('Would you like us to install Backstage for you?', true)) { $command->comment('Lights, camera, action! Setting up for the show...'); $command->comment('Preparing stage...'); - $command->callSilently('vendor:publish', [ - '--tag' => 'translations-config', - '--force' => true, - ]); - $command->callSilently('vendor:publish', [ '--tag' => 'backstage-config', '--force' => true, @@ -87,8 +81,6 @@ public function configurePackage(Package $package): void $this->writeMediaPickerConfig(); - $this->writeTranslationsConfig(); - $command->callSilently('vendor:publish', [ '--tag' => 'backstage-migrations', '--force' => true, @@ -120,12 +112,27 @@ public function configurePackage(Package $package): void $path = app()->environmentFilePath(); file_put_contents($path, file_get_contents($path) . PHP_EOL . $key . '=' . $value); + if ($command->confirm('Would you like to create a user?', true)) { + $command->comment('Our next performer is...'); + $user = $command->ask('Your name?'); + $email = $command->ask('Your email?'); + $password = $command->secret('Your password?'); + if ($email && $password) { + User::factory()->create([ + 'name' => $user, + 'email' => $email, + 'password' => $password, + ]); + } else { + $command->error('Stage frights! User not created.'); + } + } + $command->comment('Raise the curtain...'); - // } }) ->endWith(function (InstallCommand $command) { $command->info('The stage is cleared for a fresh start'); - $command->comment('You can now go on stage and start creating!'); + $command->comment('You can now go on stage (/backstage) and start creating!'); }) ->askToStarRepoOnGitHub('backstage/cms'); }); @@ -408,90 +415,6 @@ private function writeMediaPickerConfig(?string $path = null): void file_put_contents($path, $configContent); } - private function generateTranslationsConfig(): array - { - $config = [ - 'scan' => [ - 'paths' => [ - app_path(), - resource_path('views'), - base_path(''), - ], - - 'extensions' => [ - '*.php', - '*.blade.php', - '*.json', - ], - - 'functions' => [ - 'trans', - 'trans_choice', - 'Lang::transChoice', - 'Lang::trans', - 'Lang::get', - 'Lang::choice', - '@lang', - '@choice', - '__', - ], - ], - - 'eloquent' => [ - 'translatable-models' => [ - \Backstage\Models\ContentFieldValue::class, - \Backstage\Models\Tag::class, - ], - ], - - 'translators' => [ - 'default' => env('TRANSLATION_DRIVER', 'google-translate'), - - 'drivers' => [ - 'google-translate' => [ - // no options - ], - - 'ai' => [ - 'provider' => \Prism\Prism\Enums\Provider::OpenAI, - 'model' => 'gpt-5', - 'system_prompt' => 'You are an expert mathematician who explains concepts simply. The only thing you do it output what i ask. No comments, no extra information. Just the answer.', - ], - - 'deep-l' => [ - // - ], - ], - ], - ]; - - config(['translations' => $config]); - - return $config; - } - - private function writeTranslationsConfig(?string $path = null): void - { - $path ??= config_path('translations.php'); - - // Ensure directory exists - $directory = dirname($path); - if (! is_dir($directory)) { - mkdir($directory, 0755, true); - } - - // Generate the config file content - $configContent = "customVarExport($this->generateTranslationsConfig()) . ";\n"; - - file_put_contents($path, $configContent); - } - private function customVarExport($var, $indent = ''): string { switch (gettype($var)) { diff --git a/packages/laravel-redirects/database/factories/RedirectFactory.php b/packages/laravel-redirects/database/factories/RedirectFactory.php new file mode 100644 index 00000000..c3d98c82 --- /dev/null +++ b/packages/laravel-redirects/database/factories/RedirectFactory.php @@ -0,0 +1,21 @@ + $this->faker->url(), + 'destination' => $this->faker->url(), + 'code' => $this->faker->numberBetween(301, 302), + 'hits' => $this->faker->numberBetween(0, 1000), + ]; + } +} From 61003b99cc0b3acb394cc00b264532bb46c9ffdf Mon Sep 17 00:00:00 2001 From: Mathieu Date: Fri, 2 Jan 2026 16:23:51 +0100 Subject: [PATCH 21/43] Correct file paths --- .../database/factories/RedirectFactory.php | 19 ++++++++++++------- .../laravel-redirects/src/Models/Redirect.php | 6 ++++++ 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/packages/laravel-redirects/database/factories/RedirectFactory.php b/packages/laravel-redirects/database/factories/RedirectFactory.php index c3d98c82..b1931bfb 100644 --- a/packages/laravel-redirects/database/factories/RedirectFactory.php +++ b/packages/laravel-redirects/database/factories/RedirectFactory.php @@ -1,21 +1,26 @@ + */ + public function definition(): array { return [ - 'source' => $this->faker->url(), - 'destination' => $this->faker->url(), - 'code' => $this->faker->numberBetween(301, 302), - 'hits' => $this->faker->numberBetween(0, 1000), + 'source' => '/' . $this->faker->unique()->slug(), + 'destination' => '/' . $this->faker->slug(), + 'code' => 301, + 'hits' => 0 ]; } } diff --git a/packages/laravel-redirects/src/Models/Redirect.php b/packages/laravel-redirects/src/Models/Redirect.php index 92c2b5c4..51144ce2 100644 --- a/packages/laravel-redirects/src/Models/Redirect.php +++ b/packages/laravel-redirects/src/Models/Redirect.php @@ -2,6 +2,7 @@ namespace Backstage\Redirects\Laravel\Models; +use Backstage\Redirects\Laravel\Database\Factories\RedirectFactory; use Illuminate\Database\Eloquent\Concerns\HasUlids; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -22,6 +23,11 @@ class Redirect extends Model 'code', ]; + protected static function newFactory() + { + return RedirectFactory::new(); + } + public function redirect(Request $request): ?RedirectResponse { $this->increment('hits'); From 222208e712f8abd77d1c6ce9730ce8655682dee8 Mon Sep 17 00:00:00 2001 From: Casmo <385764+Casmo@users.noreply.github.com> Date: Fri, 2 Jan 2026 15:24:22 +0000 Subject: [PATCH 22/43] fix: styling --- .../laravel-redirects/database/factories/RedirectFactory.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/laravel-redirects/database/factories/RedirectFactory.php b/packages/laravel-redirects/database/factories/RedirectFactory.php index b1931bfb..c71c9086 100644 --- a/packages/laravel-redirects/database/factories/RedirectFactory.php +++ b/packages/laravel-redirects/database/factories/RedirectFactory.php @@ -20,7 +20,7 @@ public function definition(): array 'source' => '/' . $this->faker->unique()->slug(), 'destination' => '/' . $this->faker->slug(), 'code' => 301, - 'hits' => 0 + 'hits' => 0, ]; } } From 9478981bae0ae17ad60b5e7ec8182733667f25c0 Mon Sep 17 00:00:00 2001 From: Baspa Date: Mon, 5 Jan 2026 09:33:43 +0100 Subject: [PATCH 23/43] wip --- packages/core/src/Models/Content.php | 7 +- .../core/src/Models/ContentFieldValue.php | 42 +++++-- packages/fields/src/Fields/Repeater.php | 57 ++++++++- packages/uploadcare-field/src/Uploadcare.php | 118 +++++++++++++++--- 4 files changed, 194 insertions(+), 30 deletions(-) diff --git a/packages/core/src/Models/Content.php b/packages/core/src/Models/Content.php index aed831e7..d4d944c3 100644 --- a/packages/core/src/Models/Content.php +++ b/packages/core/src/Models/Content.php @@ -341,11 +341,14 @@ public function blocks(string $field): array * @see \Backstage\Models\ContentFieldValue::getHydratedValue() * @see https://docs.backstagephp.com/03-fields/01-introduction.html */ - public function field(string $slug): Content | HtmlString | Collection | array | bool | null + + public function field(string $slug): Content | HtmlString | Collection | array | bool | string | Model | null { $this->load('values'); - return $this->values->where('field.slug', $slug)->first()?->getHydratedValue(); + $val = $this->values->where('field.slug', $slug)->first()?->getHydratedValue(); + + return $val; } public function rawField(string $field): mixed diff --git a/packages/core/src/Models/ContentFieldValue.php b/packages/core/src/Models/ContentFieldValue.php index 5d7a72c3..7dbd680a 100644 --- a/packages/core/src/Models/ContentFieldValue.php +++ b/packages/core/src/Models/ContentFieldValue.php @@ -11,6 +11,7 @@ use Illuminate\Database\Eloquent\Concerns\HasUlids; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\Pivot; +use Illuminate\Database\Eloquent\Model; use Illuminate\Support\HtmlString; /** @@ -50,15 +51,22 @@ public function media(): \Illuminate\Database\Eloquent\Relations\MorphToMany return $this->morphToMany(config('backstage.media.model', \Backstage\Media\Models\Media::class), 'model', 'media_relationships', 'model_id', 'media_ulid'); } - public function getHydratedValue(): Content | HtmlString | array | Collection | bool | string | null + + public function getHydratedValue(): Content | HtmlString | array | Collection | bool | string | Model | null { + if ($this->isRichEditor()) { $html = self::getRichEditorHtml($this->value ?? '') ?? ''; return $this->shouldHydrate() ? new HtmlString($html) : $html; } - if ($this->shouldHydrate()) { - [$hydrated, $result] = $this->tryHydrateViaClass($this->isJsonArray(), $this->field->field_type); + $shouldHydrate = $this->shouldHydrate(); + $isUploadcare = $this->field->field_type === 'uploadcare'; + + if ($shouldHydrate || $isUploadcare) { + [$hydrated, $result] = $this->tryHydrateViaClass($this->isJsonArray(), $this->field->field_type, $this->field); + + if ($hydrated) { return $result; } @@ -83,9 +91,16 @@ public function getHydratedValue(): Content | HtmlString | array | Collection | // For all other cases, ensure the value is returned as a string (HTML string in frontend) $val = $this->value ?? ''; $res = $this->shouldHydrate() ? new HtmlString($val) : $val; + + if ($this->field->slug === 'banner-image') { + \Illuminate\Support\Facades\Log::info("[CFV::getHydratedValue] Returning default. Result Type: " . gettype($res)); + } + + // file_put_contents('/tmp/cfv_return.log', "Field: {$this->field->slug} | Returning default: " . gettype($res) . "\n", FILE_APPEND); return $res; } + /** * Get the relation value */ @@ -116,7 +131,7 @@ private function hydrateValuesRecursively(mixed $value, Field $field): mixed } if ($this->shouldHydrate()) { - [$hydrated, $result] = $this->tryHydrateViaClass($value, $field->field_type); + [$hydrated, $result] = $this->tryHydrateViaClass($value, $field->field_type, $field); if ($hydrated) { return $result; } @@ -174,16 +189,27 @@ private function hydrateValuesRecursively(mixed $value, Field $field): mixed return $value; } - private function tryHydrateViaClass(mixed $value, string $fieldType): array + private function tryHydrateViaClass(mixed $value, string $fieldType, ?Field $fieldModel = null): array { - if ($fieldClass = \Backstage\Fields\Facades\Fields::resolveField($fieldType)) { + $fieldClass = \Backstage\Fields\Facades\Fields::resolveField($fieldType); + + if ($fieldClass) { if (in_array(\Backstage\Fields\Contracts\HydratesValues::class, class_implements($fieldClass))) { try { - return [true, app($fieldClass)->hydrate($value, $this)]; + $instance = app($fieldClass); + if ($fieldModel && property_exists($instance, 'field_model')) { + $instance->field_model = $fieldModel; + } + return [true, $instance->hydrate($value, $this)]; } catch (\Throwable $e) { + file_put_contents('/tmp/hydration_error.log', "Hydration error for $fieldType: " . $e->getMessage() . "\n" . $e->getTraceAsString() . "\n", FILE_APPEND); return [true, $value]; } + } else { + file_put_contents('/tmp/cfv_override_debug.log', "Class $fieldClass does not implement HydratesValues\n", FILE_APPEND); } + } else { + file_put_contents('/tmp/cfv_override_debug.log', "Could not resolve field class for $fieldType\n", FILE_APPEND); } return [false, null]; @@ -210,7 +236,7 @@ private function hydrateItemFields(array &$data, $fields): void } } - private function shouldHydrate(): bool + public function shouldHydrate(): bool { if (app()->runningInConsole()) { return false; diff --git a/packages/fields/src/Fields/Repeater.php b/packages/fields/src/Fields/Repeater.php index b7ee36b8..cb47f0ea 100644 --- a/packages/fields/src/Fields/Repeater.php +++ b/packages/fields/src/Fields/Repeater.php @@ -25,7 +25,10 @@ use Illuminate\Support\Str; use Saade\FilamentAdjacencyList\Forms\Components\AdjacencyList; -class Repeater extends Base implements FieldContract +use Backstage\Fields\Contracts\HydratesValues; +use Illuminate\Database\Eloquent\Model; + +class Repeater extends Base implements FieldContract, HydratesValues { use HasConfigurableFields; use HasFieldTypeResolver; @@ -36,6 +39,58 @@ public function getFieldType(): ?string return 'repeater'; } + public function hydrate(mixed $value, ?Model $model = null): mixed + { + if (! is_array($value)) { + return $value; + } + + if (empty($this->field_model)) { + file_put_contents('/tmp/repeater_debug.log', "Field model missing for repeater.\n", FILE_APPEND); + return $value; + } + + $children = $this->field_model->children->keyBy('ulid'); + $slugMap = $this->field_model->children->pluck('ulid', 'slug'); + + file_put_contents('/tmp/repeater_debug.log', "Hydrating Repeater " . $this->field_model->ulid . " with children slugs: " . implode(', ', $slugMap->keys()->toArray()) . "\n", FILE_APPEND); + + $hydrated = []; + + foreach ($value as $key => $row) { + $hydratedRow = $row; + + if (is_array($row)) { + foreach ($row as $fieldSlug => $fieldValue) { + $fieldUlid = $slugMap[$fieldSlug] ?? null; + if ($fieldUlid && isset($children[$fieldUlid])) { + $fieldModel = $children[$fieldUlid]; + $fieldClass = self::resolveFieldTypeClassName($fieldModel->field_type); + + file_put_contents('/tmp/repeater_debug.log', " > Hydrating field $fieldSlug ($fieldModel->field_type) using $fieldClass\n", FILE_APPEND); + + if ($fieldClass && in_array(HydratesValues::class, class_implements($fieldClass))) { + // Instantiate the field class to access its hydrate method + // We need to set the field model on the instance if possible, + // or at least pass context if needed. + // Assuming simpler 'make' or instantiation works for hydration context. + $fieldInstance = new $fieldClass(); + if (property_exists($fieldInstance, 'field_model')) { + $fieldInstance->field_model = $fieldModel; + } + + $hydratedRow[$fieldSlug] = $fieldInstance->hydrate($fieldValue, $model); + } + } + } + } + + $hydrated[$key] = $hydratedRow; + } + + return $hydrated; + } + public static function getDefaultConfig(): array { return [ diff --git a/packages/uploadcare-field/src/Uploadcare.php b/packages/uploadcare-field/src/Uploadcare.php index 8ad28d42..52ba6cb9 100755 --- a/packages/uploadcare-field/src/Uploadcare.php +++ b/packages/uploadcare-field/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 4f634ab9b5a351befc2f00d264a81bbdbc9b439b Mon Sep 17 00:00:00 2001 From: Baspa Date: Mon, 5 Jan 2026 11:42:56 +0100 Subject: [PATCH 24/43] fix: loading crops --- .../core/src/Models/ContentFieldValue.php | 6 +- packages/core/src/Models/Media.php | 14 +- .../dist/filament-uploadcare-field.js | 2 +- .../resources/js/components/uploadcare.js | 8 +- packages/uploadcare-field/src/Uploadcare.php | 227 ++++++++++++++---- 5 files changed, 196 insertions(+), 61 deletions(-) diff --git a/packages/core/src/Models/ContentFieldValue.php b/packages/core/src/Models/ContentFieldValue.php index 7dbd680a..218aeef8 100644 --- a/packages/core/src/Models/ContentFieldValue.php +++ b/packages/core/src/Models/ContentFieldValue.php @@ -48,25 +48,25 @@ public function field(): BelongsTo public function media(): \Illuminate\Database\Eloquent\Relations\MorphToMany { - return $this->morphToMany(config('backstage.media.model', \Backstage\Media\Models\Media::class), 'model', 'media_relationships', 'model_id', 'media_ulid'); + return $this->morphToMany(config('backstage.media.model', \Backstage\Media\Models\Media::class), 'model', 'media_relationships', 'model_id', 'media_ulid') + ->withPivot(['position', 'meta']); } public function getHydratedValue(): Content | HtmlString | array | Collection | bool | string | Model | null { - if ($this->isRichEditor()) { $html = self::getRichEditorHtml($this->value ?? '') ?? ''; return $this->shouldHydrate() ? new HtmlString($html) : $html; } $shouldHydrate = $this->shouldHydrate(); + // TODO (IMPORTANT): This should be fixed in the Uploadcare package itself. $isUploadcare = $this->field->field_type === 'uploadcare'; if ($shouldHydrate || $isUploadcare) { [$hydrated, $result] = $this->tryHydrateViaClass($this->isJsonArray(), $this->field->field_type, $this->field); - if ($hydrated) { return $result; } diff --git a/packages/core/src/Models/Media.php b/packages/core/src/Models/Media.php index 6c76e49e..3b189574 100644 --- a/packages/core/src/Models/Media.php +++ b/packages/core/src/Models/Media.php @@ -38,14 +38,20 @@ public function getMimeTypeAttribute(): ?string public function getEditAttribute(): ?array { - $edit = $this->edits()->first(); + if ($this->relationLoaded('edits')) { + $edit = $this->edits->first(); + if (! $edit || ! $edit->relationLoaded('pivot') || ! $edit->pivot || ! $edit->pivot->meta) { + return null; + } + + return is_string($edit->pivot->meta) ? json_decode($edit->pivot->meta, true) : $edit->pivot->meta; + } + $edit = $this->edits()->first(); if (! $edit || ! $edit->pivot || ! $edit->pivot->meta) { return null; } - return is_string($edit->pivot->meta) - ? json_decode($edit->pivot->meta, true) - : $edit->pivot->meta; + return is_string($edit->pivot->meta) ? json_decode($edit->pivot->meta, true) : $edit->pivot->meta; } } diff --git a/packages/filament-uploadcare-field/resources/dist/filament-uploadcare-field.js b/packages/filament-uploadcare-field/resources/dist/filament-uploadcare-field.js index 7e511645..e925063b 100644 --- a/packages/filament-uploadcare-field/resources/dist/filament-uploadcare-field.js +++ b/packages/filament-uploadcare-field/resources/dist/filament-uploadcare-field.js @@ -1 +1 @@ -var m=class{constructor(e){this.wrapper=e,this.observer=null,this.init()}init(){this.hideDoneButtons(),this.setupObserver()}setupObserver(){this.observer=new MutationObserver(e=>{e.forEach(t=>{t.type==="childList"&&t.addedNodes.forEach(i=>{if(i.nodeType===Node.ELEMENT_NODE){i.classList&&i.classList.contains("uc-done-btn")&&this.hideDoneButton(i);let s=i.querySelectorAll&&i.querySelectorAll(".uc-done-btn");s&&s.forEach(a=>this.hideDoneButton(a))}})})}),this.wrapper&&this.observer.observe(this.wrapper,{childList:!0,subtree:!0})}hideDoneButtons(){document.querySelectorAll(".uc-done-btn").forEach(t=>this.hideDoneButton(t))}hideDoneButton(e){e&&(e.style.display="none",e.style.visibility="hidden",e.style.opacity="0",e.style.pointerEvents="none",e.style.position="absolute",e.style.width="0",e.style.height="0",e.style.overflow="hidden",e.style.clip="rect(0, 0, 0, 0)",e.style.margin="0",e.style.padding="0",e.style.border="0",e.style.background="transparent",e.style.color="transparent",e.style.fontSize="0",e.style.lineHeight="0")}destroy(){this.observer&&(this.observer.disconnect(),this.observer=null)}};function F(p){return window._initializedUploadcareContexts||(window._initializedUploadcareContexts=new Set),{state:p.state,statePath:p.statePath,initialState:p.initialState,publicKey:p.publicKey,isMultiple:p.isMultiple,multipleMin:p.multipleMin,multipleMax:p.multipleMax,isImagesOnly:p.isImagesOnly,accept:p.accept,sourceList:p.sourceList,uploaderStyle:p.uploaderStyle,isWithMetadata:p.isWithMetadata,localeName:p.localeName||"en",uploadedFiles:"",ctx:null,removeEventListeners:null,uniqueContextName:p.uniqueContextName,pendingUploads:[],pendingRemovals:[],isInitialized:!1,stateHasBeenInitialized:!1,isStateWatcherActive:!1,isLocalUpdate:!1,doneButtonHider:null,documentClassObserver:null,formInputObserver:null,async init(){this.isContextAlreadyInitialized()||(this.markContextAsInitialized(),this.applyTheme(),await this.loadAllLocales(),this.$el.isConnected&&(this.setupStateWatcher(),this.$el.addEventListener("uploadcare-state-updated",e=>{let t=e.detail.uuid;t&&this.isInitialized?this.loadFileFromUuid(t):t&&this.$nextTick(()=>{this.isInitialized&&this.loadFileFromUuid(t)})}),this.initUploadcare(),this.setupThemeObservers(),this.setupDoneButtonObserver(),(!this.state||this.state==="[]"||this.state==='""')&&this.$nextTick(()=>{this.isInitialized&&this.getCurrentFiles().length>0&&this.clearAllFiles(!1)})))},isContextAlreadyInitialized(){return window._initializedUploadcareContexts.has(this.uniqueContextName)},markContextAsInitialized(){window._initializedUploadcareContexts.add(this.uniqueContextName)},async loadAllLocales(){window._uploadcareAllLocalesLoaded||await new Promise(t=>{if(window._uploadcareAllLocalesLoaded){t();return}let i=setInterval(()=>{window._uploadcareAllLocalesLoaded&&(clearInterval(i),t())},100);setTimeout(()=>{clearInterval(i),t()},5e3)});let e=["de","es","fr","he","it","nl","pl","pt","ru","tr","uk","zh-TW","zh"];document.querySelectorAll("uc-config[data-locale-name]").forEach(t=>{let i=t.getAttribute("data-locale-name");i&&e.includes(i)&&!t.getAttribute("locale-name")&&t.setAttribute("locale-name",i)})},async loadLocale(){if(this.localeName==="en"||this.localeLoaded)return;if(window._uploadcareLocales&&window._uploadcareLocales.has(this.localeName)){this.localeLoaded=!0;return}if(window._uploadcareLocales||(window._uploadcareLocales=new Set),!!["de","es","fr","he","it","nl","pl","pt","ru","tr","uk","zh-TW","zh"].includes(this.localeName))try{let i=await import(`https://cdn.jsdelivr.net/npm/@uploadcare/file-uploader@v1/locales/file-uploader/${this.localeName}.js`),s=i.default||i,a=()=>{let r=customElements.get("uc-file-uploader-inline")||customElements.get("uc-file-uploader-regular")||customElements.get("uc-file-uploader-minimal");return r&&r.UC?r.UC:window.UC},n=()=>{let r=a();return r&&typeof r.defineLocale=="function"?(r.defineLocale(this.localeName,s),window._uploadcareLocales.add(this.localeName),this.localeLoaded=!0,!0):!1};if(!n()){let r=0,o=50,c=setInterval(()=>{r++,(n()||r>=o)&&clearInterval(c)},100)}}catch(t){console.error("[Uploadcare Locale JS] Failed to load locale:",this.localeName,t)}},applyTheme(){let e=this.getCurrentTheme();this.$el.querySelectorAll(`uc-file-uploader-${this.uploaderStyle}`).forEach(i=>{i.classList.remove("uc-dark","uc-light"),i.classList.add(`uc-${e}`)})},getCurrentTheme(){return document.documentElement.classList.contains("dark")?"dark":"light"},setupThemeObservers(){window.addEventListener("storage",this.handleThemeStorageChange.bind(this)),window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change",this.handleSystemThemeChange.bind(this)),this.setupDocumentClassObserver()},handleThemeStorageChange(e){e.key==="theme"&&this.applyTheme()},handleSystemThemeChange(){localStorage.getItem("theme")==="system"&&this.applyTheme()},setupDocumentClassObserver(){this.documentClassObserver=new MutationObserver(e=>{e.forEach(t=>{if(t.type==="attributes"&&t.attributeName==="class"){let i=document.documentElement.classList.contains("dark"),s=t.oldValue&&t.oldValue.includes("dark");i!==s&&this.applyTheme()}})}),this.documentClassObserver.observe(document.documentElement,{attributes:!0,attributeOldValue:!0,attributeFilter:["class"]})},initUploadcare(){this.removeEventListeners&&this.removeEventListeners(),this.initializeUploader()},initializeUploader(e=0,t=10){if(e>=t)return;this.ctx=this.$el.querySelector(`uc-upload-ctx-provider[ctx-name="${this.uniqueContextName}"]`);let i=this.getUploadcareApi();if(!this.isValidContext(i)){setTimeout(()=>this.initializeUploader(e+1,t),100);return}this.markAsInitialized(),this.removeRequiredAttributes(),this.initializeState(i),this.setupEventListeners(i)},getUploadcareApi(){try{return this.ctx?.getAPI()}catch{return null}},isValidContext(e){return this.ctx&&e&&e.addFileFromCdnUrl},markAsInitialized(){this.isInitialized=!0},removeRequiredAttributes(){setTimeout(()=>{let e=this.$el.closest("uc-config");document.querySelectorAll("uc-form-input input[required]").forEach(i=>i.removeAttribute("required"))},100)},initializeState(e){this.initialState&&!this.stateHasBeenInitialized&&!this.uploadedFiles?this.loadInitialState(e):!this.initialState&&!this.stateHasBeenInitialized&&(this.stateHasBeenInitialized=!0,this.uploadedFiles=this.isMultiple?"[]":"",this.isLocalUpdate=!0,this.state=this.uploadedFiles)},loadInitialState(e){try{let t=this.parseInitialState();this.addFilesFromInitialState(e,t),this.stateHasBeenInitialized=!0,this.isLocalUpdate=!0,this.state=this.uploadedFiles}catch(t){console.error("Error parsing initialState:",t)}},parseInitialState(){let e=i=>{if(typeof i=="string")try{let s=JSON.parse(i);if(typeof s=="string")try{s=JSON.parse(s)}catch{}return s}catch{return i}return i};return this.initialState&&this.initialState&&typeof this.initialState=="object"&&!Array.isArray(this.initialState)&&(this.initialState=[this.initialState]),this.parseStateValue(this.initialState)},addFilesFromInitialState(e,t){let i=[];if(t&&t&&typeof t=="object"&&!Array.isArray(t))try{i=Array.from(t)}catch{i=[t]}else Array.isArray(t)?i=t:t&&(i=[t]);if(Array.isArray(i)&&i.length===1&&Array.isArray(i[0])&&(i=i[0]),Array.isArray(i)&&i.length===1&&typeof i[0]=="string")try{let r=JSON.parse(i[0]);i=Array.isArray(r)?r:[r]}catch{}if(!Array.isArray(i)||i.length===0)return;Array.isArray(i)||(i=[i]);let s=(r,o=0)=>{if(!r)return;if(Array.isArray(r)){r.forEach((h,d)=>{s(h,`${o}.${d}`)});return}if(typeof r=="string")try{let h=JSON.parse(r);s(h,o);return}catch{}let c=r&&typeof r=="object"?r.cdnUrl:r,u=r&&typeof r=="object"?r.cdnUrlModifiers:null;if(!c||!this.isValidUrl(c))return;let l=this.extractUuidFromUrl(c);if(l&&typeof e.addFileFromUuid=="function")try{if((u||c&&c.includes("/-/"))&&typeof e.addFileFromCdnUrl=="function"){let d=c;if(u){let y=c.split("/-/")[0],f=u;f.startsWith("/")&&(f=f.substring(1)),d=y+(y.endsWith("/")?"":"/")+f}e.addFileFromCdnUrl(d)}else e.addFileFromUuid(l)}catch(h){console.error(`Failed to add file ${o} with UUID ${l}:`,h)}else console.error(l?"addFileFromUuid method not available on API":`Could not extract UUID from URL: ${c}`)};i.forEach(s);let a=i.map(r=>{let o=r;if(r&&typeof r=="object")return r.uuid||(r.uuid=this.extractUuidFromUrl(r.cdnUrl)),r;if(typeof r=="string"){let c=this.extractUuidFromUrl(r);return{cdnUrl:r,uuid:c,name:"",size:0,mimeType:"",isImage:!1}}return r}),n=this.formatFilesForState(a);this.uploadedFiles=JSON.stringify(n),this.initialState=this.uploadedFiles},isValidUrl(e){if(!e||typeof e!="string")return!1;try{return new URL(e),!0}catch{return!1}},setupStateWatcher(){this.$watch("state",(e,t)=>{if(this.isLocalUpdate){this.isLocalUpdate=!1;return}if(!this.$el.isConnected)return;if(!this.stateHasBeenInitialized){this.stateHasBeenInitialized=!0;return}if(!e||e==="[]"||e==='""'||Array.isArray(e)&&e.length===0){this.uploadedFiles&&this.uploadedFiles!=="[]"&&this.uploadedFiles!=='""'&&this.getCurrentFiles().length>0&&this.clearAllFiles(!1);return}let i=this.normalizeStateValue(e),s=this.normalizeStateValue(this.uploadedFiles);i!==s&&e&&e!=="[]"&&e!=='""'&&this.addFilesFromState(e)})},parseStateValue(e){if(!e)return null;try{return typeof e=="string"?JSON.parse(e):e}catch{return e}},addFilesFromState(e){let i=this.parseStateValue(e);if(Array.isArray(i)||(i=[i]),i=i.filter(l=>l!=null),i.length===0)return!1;let s=this.getUploadcareApi();if(!s||typeof s.addFileFromCdnUrl!="function")return!1;let n=this.getCurrentFiles().map(l=>l?l&&typeof l=="object"?l.cdnUrl:l:null).filter(Boolean);i.forEach((l,h)=>{if(!l){console.warn(`[Uploadcare] Skipping null item at index ${h}`);return}let d=l&&typeof l=="object"?l.cdnUrl:l;if(d&&typeof d=="string"&&(d.includes("ucarecdn.com")||d.includes("ucarecd.net"))&&!n.some(f=>{let U=this.extractUuidFromUrl(d),g=this.extractUuidFromUrl(f);return U&&g&&U===g}))try{s.addFileFromCdnUrl(d)}catch(f){console.error("[Uploadcare] Failed to add file from URL:",d,f)}});let r=[],o=new Set,c=l=>{if(!l)return;let h=l&&typeof l=="object"?l.cdnUrl:l,d=this.extractUuidFromUrl(h);d&&!o.has(d)?(o.add(d),this.isWithMetadata&&typeof l!="object"?r.push({cdnUrl:l,uuid:d,name:"",size:0,mimeType:"",isImage:!1}):r.push(l)):d||r.push(l)},u=this.parseStateValue(e)||[];return(Array.isArray(u)?u:[u]).forEach(c),this.uploadedFiles=JSON.stringify(r),this.isLocalUpdate=!0,!0},normalizeStateValue(e){if(!e)return"";try{let t=typeof e=="string"?JSON.parse(e):e;if(Array.isArray(t)&&t.every(a=>typeof a=="string"||typeof a=="object"&&a!==null&&("cdnUrl"in a||"uuid"in a)))return JSON.stringify(t);let i=this.formatFilesForState(t);return JSON.stringify(i)}catch(t){return console.error("[Uploadcare] normalizeStateValue error",t),e}},isStateChanged(){let e=this.normalizeStateValue(this.state),t=this.normalizeStateValue(this.initialState);return e!==t},setupEventListeners(e){this.pendingUploads=[],this.pendingRemovals=[];let t=this.createFileUploadSuccessHandler(e),i=this.createFileUrlChangedHandler(e),s=this.createFileRemovedHandler(e),a=this.createFormInputChangeHandler(e),n=r=>{if(r.target!==this.ctx&&!this.ctx.contains(r.target))return;let o=this.$el.closest("form");o&&o.dispatchEvent(new CustomEvent("form-processing-started",{detail:{message:"Uploading file..."}}))};this.ctx.addEventListener("file-upload-started",n),this.ctx.addEventListener("file-upload-success",t),this.ctx.addEventListener("file-url-changed",i),this.ctx.addEventListener("file-removed",s),this.$nextTick(()=>{let r=this.$el.querySelector("uc-form-input input");if(r){r.addEventListener("input",a),r.addEventListener("change",a);let o=new MutationObserver(()=>{a({target:r})});o.observe(r,{attributes:!0,attributeFilter:["value"]}),this.formInputObserver=o}}),this.removeEventListeners=()=>{this.ctx.removeEventListener("file-upload-started",n),this.ctx.removeEventListener("file-upload-success",t),this.ctx.removeEventListener("file-url-changed",i),this.ctx.removeEventListener("file-removed",s);let r=this.$el.querySelector("uc-form-input input");r&&(r.removeEventListener("input",a),r.removeEventListener("change",a)),this.formInputObserver&&(this.formInputObserver.disconnect(),this.formInputObserver=null)}},createFileUploadSuccessHandler(e){let t=null;return i=>{if(i.target.getAttribute("ctx-name")!==this.uniqueContextName&&i.target!==this.ctx&&!this.ctx.contains(i.target))return;let a=this.isWithMetadata?i.detail:i.detail.cdnUrl,n=this.extractUuidFromUrl(a);this.pendingUploads.push(a),t&&clearTimeout(t),t=setTimeout(()=>{try{let r=this.getCurrentFiles();for(let c of this.pendingUploads)r=this.updateFilesList(r,c);this.updateState(r),this.pendingUploads=[];let o=this.$el.closest("form");o&&o.dispatchEvent(new CustomEvent("form-processing-finished"))}catch(r){console.error("[Uploadcare] Error updating state after upload:",r)}},200)}},createFileUrlChangedHandler(e){let t=null;return i=>{if(i.target.getAttribute("ctx-name")!==this.uniqueContextName&&i.target!==this.ctx&&!this.ctx.contains(i.target))return;let a=i.detail;t&&clearTimeout(t),t=setTimeout(()=>{try{let n=this.getCurrentFiles(),r=this.updateFileUrl(n,a);this.updateState(r)}catch(n){console.error("Error updating state after URL change:",n)}},100)}},createFileRemovedHandler(e){let t=null;return i=>{if(i.target.getAttribute("ctx-name")!==this.uniqueContextName&&i.target!==this.ctx&&!this.ctx.contains(i.target))return;let a=i.detail;this.pendingRemovals.push(a),t&&clearTimeout(t),t=setTimeout(()=>{try{let n=this.getCurrentFiles();for(let r of this.pendingRemovals)n=this.removeFile(n,r);this.updateState(n),this.pendingRemovals=[]}catch(n){console.error("Error in handleFileRemoved:",n)}},100)}},createFormInputChangeHandler(e){return t=>{}},getCurrentFiles(){try{let e=this.uploadedFiles?JSON.parse(this.uploadedFiles):[];return Array.isArray(e)?e:[]}catch{return[]}},updateFilesList(e,t){if(this.isMultiple){let i=this.extractUuidFromUrl(t);return e.some(a=>this.extractUuidFromUrl(a)===i)?e:[...e,t]}return[t]},updateFileUrl(e,t){let i=t.uuid;if(!i&&t.cdnUrl&&(i=this.extractUuidFromUrl(t.cdnUrl)),!i)return e;t.uuid||(t={...t,uuid:i});let s=this.findFileIndex(e,i);if(s===-1)return e;let a;if(this.isWithMetadata){let n=e[s];if(typeof n=="string"){let r=this.extractUuidFromUrl(n);n={cdnUrl:n,uuid:r,name:"",size:0,mimeType:"",isImage:!1}}if(a={...n,...t},a.cdnUrl){let r=this.extractModifiersFromUrl(a.cdnUrl);r&&(a.cdnUrlModifiers=r)}}else a=t.cdnUrl;return this.isMultiple?(e[s]=a,e):[a]},removeFile(e,t){let i=this.findFileIndex(e,t.uuid);return i===-1?e:this.isMultiple?(e.splice(i,1),e):[]},findFileIndex(e,t){return t?e.findIndex(i=>{let s=i&&typeof i=="object"?i.cdnUrl:i;return this.extractUuidFromUrl(s)===t}):-1},updateState(e){let t=new Set,i=e.filter(u=>{let l=u&&typeof u=="object"?u.cdnUrl:u,h=this.extractUuidFromUrl(l);return h?t.has(h)?!1:(t.add(h),!0):!0}),s=this.formatFilesForState(i),a=JSON.stringify(s),n=this.getCurrentFiles(),r=JSON.stringify(this.formatFilesForState(n)),o=JSON.stringify(s);r!==o&&(this.uploadedFiles=a,this.isLocalUpdate=!0,this.state=this.uploadedFiles,this.isMultiple&&i.length>1&&this.$nextTick(()=>{this.isLocalUpdate=!1}))},formatFilesForState(e){if(!e)return[];if(!Array.isArray(e))if(console.warn("[Uploadcare] formatFilesForState called with non-array:",typeof e,e),typeof e=="string")try{let t=JSON.parse(e);if(Array.isArray(t))e=t;else return[]}catch{return[]}else return[];return e.map(t=>{if(t&&typeof t=="object"&&!t.cdnUrl&&!t.uuid&&"0"in t){let i=Object.keys(t);if(i.length>5&&i.includes("0")&&i.includes("1")&&i.includes("2")){let s="";if(Math.max(...i.map(n=>parseInt(n)).filter(n=>!isNaN(n)))===i.length-1){let n=new Array(i.length);for(let r=0;r0){let r=[];for(let o of t){let c=o&&typeof o=="object"?o.cdnUrl:o;if(typeof c=="string"&&c.match(/[a-f0-9-]{36}~[0-9]+/))try{let u=await this.fetchGroupFiles(c);r.push(...u)}catch(u){console.error("[Uploadcare] Failed to expand group:",c,u),r.push(o)}else r.push(o)}t=r}let i=this.formatFilesForState(t),s=this.buildStateFromFiles(i),a=this.normalizeStateValue(this.uploadedFiles),n=this.normalizeStateValue(s);a!==n&&(this.uploadedFiles=s,this.isLocalUpdate=!0,this.state=this.uploadedFiles)}catch(t){console.error("Error syncing state with Uploadcare:",t)}},async fetchGroupFiles(e){let t=e;if(e.includes("ucarecdn.com")||e.includes("ucarecd.net")){let a=e.match(/\/([a-f0-9-]{36}~[0-9]+)/);a&&(t=a[1])}let i=await fetch(`https://upload.uploadcare.com/group/info/?pub_key=${this.publicKey}&group_id=${t}`);if(!i.ok)throw new Error(`Failed to fetch group info: ${i.statusText}`);let s=await i.json();return s.files?s.files.map(a=>{let n=`https://ucarecdn.com/${a.uuid}/`;return this.isWithMetadata?{uuid:a.uuid,cdnUrl:n,name:a.original_filename,size:a.size,mimeType:a.mime_type,isImage:a.is_image}:n}):[]},buildStateFromFiles(e){return this.isMultiple?JSON.stringify(e):e.length>0?this.isWithMetadata?JSON.stringify(e[0]):e[0]:""},getCurrentFilesFromUploadcare(e){try{if(e&&typeof e.value=="function"){let i=e.value();return i?Array.isArray(i)?i.filter(a=>a!=null):this.parseFormInputValue(i).filter(a=>a!=null):[]}let t=this.$el.querySelector("uc-form-input input");return t?this.parseFormInputValue(t.value).filter(s=>s!=null):[]}catch(t){return console.error("Error getting current files from Uploadcare:",t),[]}},parseFormInputValue(e){if(!e||typeof e=="string"&&e.trim()==="")return[];if(typeof e=="object")return[e];try{let t=JSON.parse(e);return Array.isArray(t)?t.filter(i=>i!==null&&i!==""):t!==null&&t!==""?[t]:[]}catch{return typeof e=="string"&&e.trim()!==""?[e]:[]}},clearAllFiles(e=!0){let t=this.statePath||"unknown",i=this.getUploadcareApi();if(i){try{if(i.collection&&typeof i.collection.clear=="function")i.collection.clear();else if(typeof i.getCollection=="function"){let s=i.getCollection();s&&typeof s.clear=="function"&&s.clear()}}catch(s){console.warn(`[Uploadcare ${t}] collection clear error:`,s)}try{typeof i.removeAllFiles=="function"&&i.removeAllFiles()}catch{}try{typeof i.value=="function"?i.value([]):i.value=[]}catch{}}else console.warn(`[Uploadcare ${t}] No API discovered for clearing`);try{let s=this.$el.querySelector("uc-form-input");if(s&&typeof s.getAPI=="function"){let a=s.getAPI();a&&(a.value=this.isMultiple?[]:"")}}catch{}this.uploadedFiles!==(this.isMultiple?"[]":"")&&(this.uploadedFiles=this.isMultiple?"[]":"",this.isLocalUpdate=!0,e&&(this.state=this.uploadedFiles))}}}export{F as default}; +var m=class{constructor(e){this.wrapper=e,this.observer=null,this.init()}init(){this.hideDoneButtons(),this.setupObserver()}setupObserver(){this.observer=new MutationObserver(e=>{e.forEach(t=>{t.type==="childList"&&t.addedNodes.forEach(i=>{if(i.nodeType===Node.ELEMENT_NODE){i.classList&&i.classList.contains("uc-done-btn")&&this.hideDoneButton(i);let s=i.querySelectorAll&&i.querySelectorAll(".uc-done-btn");s&&s.forEach(a=>this.hideDoneButton(a))}})})}),this.wrapper&&this.observer.observe(this.wrapper,{childList:!0,subtree:!0})}hideDoneButtons(){document.querySelectorAll(".uc-done-btn").forEach(t=>this.hideDoneButton(t))}hideDoneButton(e){e&&(e.style.display="none",e.style.visibility="hidden",e.style.opacity="0",e.style.pointerEvents="none",e.style.position="absolute",e.style.width="0",e.style.height="0",e.style.overflow="hidden",e.style.clip="rect(0, 0, 0, 0)",e.style.margin="0",e.style.padding="0",e.style.border="0",e.style.background="transparent",e.style.color="transparent",e.style.fontSize="0",e.style.lineHeight="0")}destroy(){this.observer&&(this.observer.disconnect(),this.observer=null)}};function F(h){return window._initializedUploadcareContexts||(window._initializedUploadcareContexts=new Set),{state:h.state,statePath:h.statePath,initialState:h.initialState,publicKey:h.publicKey,isMultiple:h.isMultiple,multipleMin:h.multipleMin,multipleMax:h.multipleMax,isImagesOnly:h.isImagesOnly,accept:h.accept,sourceList:h.sourceList,uploaderStyle:h.uploaderStyle,isWithMetadata:h.isWithMetadata,localeName:h.localeName||"en",uploadedFiles:"",ctx:null,removeEventListeners:null,uniqueContextName:h.uniqueContextName,pendingUploads:[],pendingRemovals:[],isInitialized:!1,stateHasBeenInitialized:!1,isStateWatcherActive:!1,isLocalUpdate:!1,doneButtonHider:null,documentClassObserver:null,formInputObserver:null,async init(){this.isContextAlreadyInitialized()||(this.markContextAsInitialized(),this.applyTheme(),await this.loadAllLocales(),this.$el.isConnected&&(this.setupStateWatcher(),this.$el.addEventListener("uploadcare-state-updated",e=>{let t=e.detail.uuid;t&&this.isInitialized?this.loadFileFromUuid(t):t&&this.$nextTick(()=>{this.isInitialized&&this.loadFileFromUuid(t)})}),this.initUploadcare(),this.setupThemeObservers(),this.setupDoneButtonObserver(),(!this.state||this.state==="[]"||this.state==='""')&&this.$nextTick(()=>{this.isInitialized&&this.getCurrentFiles().length>0&&this.clearAllFiles(!1)})))},isContextAlreadyInitialized(){return window._initializedUploadcareContexts.has(this.uniqueContextName)},markContextAsInitialized(){window._initializedUploadcareContexts.add(this.uniqueContextName)},async loadAllLocales(){window._uploadcareAllLocalesLoaded||await new Promise(t=>{if(window._uploadcareAllLocalesLoaded){t();return}let i=setInterval(()=>{window._uploadcareAllLocalesLoaded&&(clearInterval(i),t())},100);setTimeout(()=>{clearInterval(i),t()},5e3)});let e=["de","es","fr","he","it","nl","pl","pt","ru","tr","uk","zh-TW","zh"];document.querySelectorAll("uc-config[data-locale-name]").forEach(t=>{let i=t.getAttribute("data-locale-name");i&&e.includes(i)&&!t.getAttribute("locale-name")&&t.setAttribute("locale-name",i)})},async loadLocale(){if(this.localeName==="en"||this.localeLoaded)return;if(window._uploadcareLocales&&window._uploadcareLocales.has(this.localeName)){this.localeLoaded=!0;return}if(window._uploadcareLocales||(window._uploadcareLocales=new Set),!!["de","es","fr","he","it","nl","pl","pt","ru","tr","uk","zh-TW","zh"].includes(this.localeName))try{let i=await import(`https://cdn.jsdelivr.net/npm/@uploadcare/file-uploader@v1/locales/file-uploader/${this.localeName}.js`),s=i.default||i,a=()=>{let r=customElements.get("uc-file-uploader-inline")||customElements.get("uc-file-uploader-regular")||customElements.get("uc-file-uploader-minimal");return r&&r.UC?r.UC:window.UC},n=()=>{let r=a();return r&&typeof r.defineLocale=="function"?(r.defineLocale(this.localeName,s),window._uploadcareLocales.add(this.localeName),this.localeLoaded=!0,!0):!1};if(!n()){let r=0,o=50,c=setInterval(()=>{r++,(n()||r>=o)&&clearInterval(c)},100)}}catch(t){console.error("[Uploadcare Locale JS] Failed to load locale:",this.localeName,t)}},applyTheme(){let e=this.getCurrentTheme();this.$el.querySelectorAll(`uc-file-uploader-${this.uploaderStyle}`).forEach(i=>{i.classList.remove("uc-dark","uc-light"),i.classList.add(`uc-${e}`)})},getCurrentTheme(){return document.documentElement.classList.contains("dark")?"dark":"light"},setupThemeObservers(){window.addEventListener("storage",this.handleThemeStorageChange.bind(this)),window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change",this.handleSystemThemeChange.bind(this)),this.setupDocumentClassObserver()},handleThemeStorageChange(e){e.key==="theme"&&this.applyTheme()},handleSystemThemeChange(){localStorage.getItem("theme")==="system"&&this.applyTheme()},setupDocumentClassObserver(){this.documentClassObserver=new MutationObserver(e=>{e.forEach(t=>{if(t.type==="attributes"&&t.attributeName==="class"){let i=document.documentElement.classList.contains("dark"),s=t.oldValue&&t.oldValue.includes("dark");i!==s&&this.applyTheme()}})}),this.documentClassObserver.observe(document.documentElement,{attributes:!0,attributeOldValue:!0,attributeFilter:["class"]})},initUploadcare(){this.removeEventListeners&&this.removeEventListeners(),this.initializeUploader()},initializeUploader(e=0,t=10){if(e>=t)return;this.ctx=this.$el.querySelector(`uc-upload-ctx-provider[ctx-name="${this.uniqueContextName}"]`);let i=this.getUploadcareApi();if(!this.isValidContext(i)){setTimeout(()=>this.initializeUploader(e+1,t),100);return}this.markAsInitialized(),this.removeRequiredAttributes(),this.initializeState(i),this.setupEventListeners(i)},getUploadcareApi(){try{return this.ctx?.getAPI()}catch{return null}},isValidContext(e){return this.ctx&&e&&e.addFileFromCdnUrl},markAsInitialized(){this.isInitialized=!0},removeRequiredAttributes(){setTimeout(()=>{let e=this.$el.closest("uc-config");document.querySelectorAll("uc-form-input input[required]").forEach(i=>i.removeAttribute("required"))},100)},initializeState(e){this.initialState&&!this.stateHasBeenInitialized&&!this.uploadedFiles?this.loadInitialState(e):!this.initialState&&!this.stateHasBeenInitialized&&(this.stateHasBeenInitialized=!0,this.uploadedFiles=this.isMultiple?"[]":"",this.isLocalUpdate=!0,this.state=this.uploadedFiles)},loadInitialState(e){try{let t=this.parseInitialState();this.addFilesFromInitialState(e,t),this.stateHasBeenInitialized=!0,this.isLocalUpdate=!0,this.state=this.uploadedFiles}catch(t){console.error("Error parsing initialState:",t)}},parseInitialState(){let e=i=>{if(typeof i=="string")try{let s=JSON.parse(i);if(typeof s=="string")try{s=JSON.parse(s)}catch{}return s}catch{return i}return i};return this.initialState&&this.initialState&&typeof this.initialState=="object"&&!Array.isArray(this.initialState)&&(this.initialState=[this.initialState]),this.parseStateValue(this.initialState)},addFilesFromInitialState(e,t){let i=[];if(t&&t&&typeof t=="object"&&!Array.isArray(t))try{i=Array.from(t)}catch{i=[t]}else Array.isArray(t)?i=t:t&&(i=[t]);if(Array.isArray(i)&&i.length===1&&Array.isArray(i[0])&&(i=i[0]),Array.isArray(i)&&i.length===1&&typeof i[0]=="string")try{let r=JSON.parse(i[0]);i=Array.isArray(r)?r:[r]}catch{}if(!Array.isArray(i)||i.length===0)return;Array.isArray(i)||(i=[i]);let s=(r,o=0)=>{if(!r)return;if(Array.isArray(r)){r.forEach((p,d)=>{s(p,`${o}.${d}`)});return}if(typeof r=="string")try{let p=JSON.parse(r);s(p,o);return}catch{}let c=r&&typeof r=="object"?r.cdnUrl:r,u=r&&typeof r=="object"?r.cdnUrlModifiers:null;if(!c||!this.isValidUrl(c))return;let l=this.extractUuidFromUrl(c);if(l&&typeof e.addFileFromUuid=="function")try{if((u||c&&c.includes("/-/"))&&typeof e.addFileFromCdnUrl=="function"){let d=c;if(u){let y=c.split("/-/")[0],f=u;f.startsWith("/")&&(f=f.substring(1)),d=y+(y.endsWith("/")?"":"/")+f}e.addFileFromCdnUrl(d)}else e.addFileFromUuid(l)}catch{}else console.error(l?"addFileFromUuid method not available on API":`Could not extract UUID from URL: ${c}`)};i.forEach(s);let a=i.map(r=>{let o=r;if(r&&typeof r=="object")return r.uuid||(r.uuid=this.extractUuidFromUrl(r.cdnUrl)),r;if(typeof r=="string"){let c=this.extractUuidFromUrl(r);return{cdnUrl:r,uuid:c,name:"",size:0,mimeType:"",isImage:!1}}return r}),n=this.formatFilesForState(a);this.uploadedFiles=JSON.stringify(n),this.initialState=this.uploadedFiles},isValidUrl(e){if(!e||typeof e!="string")return!1;try{return new URL(e),!0}catch{return!1}},setupStateWatcher(){this.$watch("state",(e,t)=>{if(this.isLocalUpdate){this.isLocalUpdate=!1;return}if(!this.$el.isConnected)return;if(!this.stateHasBeenInitialized){this.stateHasBeenInitialized=!0;return}if(!e||e==="[]"||e==='""'||Array.isArray(e)&&e.length===0){this.uploadedFiles&&this.uploadedFiles!=="[]"&&this.uploadedFiles!=='""'&&this.getCurrentFiles().length>0&&this.clearAllFiles(!1);return}let i=this.normalizeStateValue(e),s=this.normalizeStateValue(this.uploadedFiles);i!==s&&e&&e!=="[]"&&e!=='""'&&this.addFilesFromState(e)})},parseStateValue(e){if(!e)return null;try{return typeof e=="string"?JSON.parse(e):e}catch{return e}},addFilesFromState(e){let i=this.parseStateValue(e);if(Array.isArray(i)||(i=[i]),i=i.filter(l=>l!=null),i.length===0)return!1;let s=this.getUploadcareApi();if(!s||typeof s.addFileFromCdnUrl!="function")return!1;let n=this.getCurrentFiles().map(l=>l?l&&typeof l=="object"?l.cdnUrl:l:null).filter(Boolean);i.forEach((l,p)=>{if(!l){console.warn(`[Uploadcare] Skipping null item at index ${p}`);return}let d=l&&typeof l=="object"?l.cdnUrl:l;if(d&&typeof d=="string"&&(d.includes("ucarecdn.com")||d.includes("ucarecd.net"))&&!n.some(f=>{let U=this.extractUuidFromUrl(d),g=this.extractUuidFromUrl(f);return U&&g&&U===g}))try{s.addFileFromCdnUrl(d)}catch(f){console.error("[Uploadcare] Failed to add file from URL:",d,f)}});let r=[],o=new Set,c=l=>{if(!l)return;let p=l&&typeof l=="object"?l.cdnUrl:l,d=this.extractUuidFromUrl(p);d&&!o.has(d)?(o.add(d),this.isWithMetadata&&typeof l!="object"?r.push({cdnUrl:l,uuid:d,name:"",size:0,mimeType:"",isImage:!1}):r.push(l)):d||r.push(l)},u=this.parseStateValue(e)||[];return(Array.isArray(u)?u:[u]).forEach(c),this.uploadedFiles=JSON.stringify(r),this.isLocalUpdate=!0,!0},normalizeStateValue(e){if(!e)return"";try{let t=typeof e=="string"?JSON.parse(e):e;if(Array.isArray(t)&&t.every(a=>typeof a=="string"||typeof a=="object"&&a!==null&&("cdnUrl"in a||"uuid"in a)))return JSON.stringify(t);let i=this.formatFilesForState(t);return JSON.stringify(i)}catch(t){return console.error("[Uploadcare] normalizeStateValue error",t),e}},isStateChanged(){let e=this.normalizeStateValue(this.state),t=this.normalizeStateValue(this.initialState);return e!==t},setupEventListeners(e){this.pendingUploads=[],this.pendingRemovals=[];let t=this.createFileUploadSuccessHandler(e),i=this.createFileUrlChangedHandler(e),s=this.createFileRemovedHandler(e),a=this.createFormInputChangeHandler(e),n=r=>{if(r.target!==this.ctx&&!this.ctx.contains(r.target))return;let o=this.$el.closest("form");o&&o.dispatchEvent(new CustomEvent("form-processing-started",{detail:{message:"Uploading file..."}}))};this.ctx.addEventListener("file-upload-started",n),this.ctx.addEventListener("file-upload-success",t),this.ctx.addEventListener("file-url-changed",i),this.ctx.addEventListener("file-removed",s),this.$nextTick(()=>{let r=this.$el.querySelector("uc-form-input input");if(r){r.addEventListener("input",a),r.addEventListener("change",a);let o=new MutationObserver(()=>{a({target:r})});o.observe(r,{attributes:!0,attributeFilter:["value"]}),this.formInputObserver=o}}),this.removeEventListeners=()=>{this.ctx.removeEventListener("file-upload-started",n),this.ctx.removeEventListener("file-upload-success",t),this.ctx.removeEventListener("file-url-changed",i),this.ctx.removeEventListener("file-removed",s);let r=this.$el.querySelector("uc-form-input input");r&&(r.removeEventListener("input",a),r.removeEventListener("change",a)),this.formInputObserver&&(this.formInputObserver.disconnect(),this.formInputObserver=null)}},createFileUploadSuccessHandler(e){let t=null;return i=>{if(i.target.getAttribute("ctx-name")!==this.uniqueContextName&&i.target!==this.ctx&&!this.ctx.contains(i.target))return;let a=this.isWithMetadata?i.detail:i.detail.cdnUrl,n=this.extractUuidFromUrl(a);this.pendingUploads.push(a),t&&clearTimeout(t),t=setTimeout(()=>{try{let r=this.getCurrentFiles();for(let c of this.pendingUploads)r=this.updateFilesList(r,c);this.updateState(r),this.pendingUploads=[];let o=this.$el.closest("form");o&&o.dispatchEvent(new CustomEvent("form-processing-finished"))}catch(r){console.error("[Uploadcare] Error updating state after upload:",r)}},200)}},createFileUrlChangedHandler(e){let t=null;return i=>{if(i.target.getAttribute("ctx-name")!==this.uniqueContextName&&i.target!==this.ctx&&!this.ctx.contains(i.target))return;let a=i.detail;t&&clearTimeout(t),t=setTimeout(()=>{try{let n=this.getCurrentFiles(),r=this.updateFileUrl(n,a);this.updateState(r)}catch(n){console.error("Error updating state after URL change:",n)}},100)}},createFileRemovedHandler(e){let t=null;return i=>{if(i.target.getAttribute("ctx-name")!==this.uniqueContextName&&i.target!==this.ctx&&!this.ctx.contains(i.target))return;let a=i.detail;this.pendingRemovals.push(a),t&&clearTimeout(t),t=setTimeout(()=>{try{let n=this.getCurrentFiles();for(let r of this.pendingRemovals)n=this.removeFile(n,r);this.updateState(n),this.pendingRemovals=[]}catch(n){console.error("Error in handleFileRemoved:",n)}},100)}},createFormInputChangeHandler(e){return t=>{}},getCurrentFiles(){try{let e=this.uploadedFiles?JSON.parse(this.uploadedFiles):[];return Array.isArray(e)?e:[]}catch{return[]}},updateFilesList(e,t){if(this.isMultiple){let i=this.extractUuidFromUrl(t);return e.some(a=>this.extractUuidFromUrl(a)===i)?e:[...e,t]}return[t]},updateFileUrl(e,t){let i=t.uuid;if(!i&&t.cdnUrl&&(i=this.extractUuidFromUrl(t.cdnUrl)),!i)return e;t.uuid||(t={...t,uuid:i});let s=this.findFileIndex(e,i);if(s===-1)return e;let a;if(this.isWithMetadata){let n=e[s];if(typeof n=="string"){let r=this.extractUuidFromUrl(n);n={cdnUrl:n,uuid:r,name:"",size:0,mimeType:"",isImage:!1}}if(a={...n,...t},a.cdnUrl){let r=this.extractModifiersFromUrl(a.cdnUrl);r?a.cdnUrlModifiers=r:(a.cdnUrlModifiers=null,delete a.cdnUrlModifiers)}}else a=t.cdnUrl;return this.isMultiple?(e[s]=a,e):[a]},removeFile(e,t){let i=this.findFileIndex(e,t.uuid);return i===-1?e:this.isMultiple?(e.splice(i,1),e):[]},findFileIndex(e,t){return t?e.findIndex(i=>{let s=i&&typeof i=="object"?i.cdnUrl:i;return this.extractUuidFromUrl(s)===t}):-1},updateState(e){let t=new Set,i=e.filter(u=>{let l=u&&typeof u=="object"?u.cdnUrl:u,p=this.extractUuidFromUrl(l);return p?t.has(p)?!1:(t.add(p),!0):!0}),s=this.formatFilesForState(i),a=JSON.stringify(s),n=this.getCurrentFiles(),r=JSON.stringify(this.formatFilesForState(n)),o=JSON.stringify(s);r!==o&&(this.uploadedFiles=a,this.isLocalUpdate=!0,this.state=this.uploadedFiles,this.isMultiple&&i.length>1&&this.$nextTick(()=>{this.isLocalUpdate=!1}))},formatFilesForState(e){if(!e)return[];if(!Array.isArray(e))if(console.warn("[Uploadcare] formatFilesForState called with non-array:",typeof e,e),typeof e=="string")try{let t=JSON.parse(e);if(Array.isArray(t))e=t;else return[]}catch{return[]}else return[];return e.map(t=>{if(t&&typeof t=="object"&&!t.cdnUrl&&!t.uuid&&"0"in t){let i=Object.keys(t);if(i.length>5&&i.includes("0")&&i.includes("1")&&i.includes("2")){let s="";if(Math.max(...i.map(n=>parseInt(n)).filter(n=>!isNaN(n)))===i.length-1){let n=new Array(i.length);for(let r=0;r0){let r=[];for(let o of t){let c=o&&typeof o=="object"?o.cdnUrl:o;if(typeof c=="string"&&c.match(/[a-f0-9-]{36}~[0-9]+/))try{let u=await this.fetchGroupFiles(c);r.push(...u)}catch(u){console.error("[Uploadcare] Failed to expand group:",c,u),r.push(o)}else r.push(o)}t=r}let i=this.formatFilesForState(t),s=this.buildStateFromFiles(i),a=this.normalizeStateValue(this.uploadedFiles),n=this.normalizeStateValue(s);a!==n&&(this.uploadedFiles=s,this.isLocalUpdate=!0,this.state=this.uploadedFiles)}catch(t){console.error("Error syncing state with Uploadcare:",t)}},async fetchGroupFiles(e){let t=e;if(e.includes("ucarecdn.com")||e.includes("ucarecd.net")){let a=e.match(/\/([a-f0-9-]{36}~[0-9]+)/);a&&(t=a[1])}let i=await fetch(`https://upload.uploadcare.com/group/info/?pub_key=${this.publicKey}&group_id=${t}`);if(!i.ok)throw new Error(`Failed to fetch group info: ${i.statusText}`);let s=await i.json();return s.files?s.files.map(a=>{let n=`https://ucarecdn.com/${a.uuid}/`;return this.isWithMetadata?{uuid:a.uuid,cdnUrl:n,name:a.original_filename,size:a.size,mimeType:a.mime_type,isImage:a.is_image}:n}):[]},buildStateFromFiles(e){return this.isMultiple?JSON.stringify(e):e.length>0?this.isWithMetadata?JSON.stringify(e[0]):e[0]:""},getCurrentFilesFromUploadcare(e){try{if(e&&typeof e.value=="function"){let i=e.value();return i?Array.isArray(i)?i.filter(a=>a!=null):this.parseFormInputValue(i).filter(a=>a!=null):[]}let t=this.$el.querySelector("uc-form-input input");return t?this.parseFormInputValue(t.value).filter(s=>s!=null):[]}catch(t){return console.error("Error getting current files from Uploadcare:",t),[]}},parseFormInputValue(e){if(!e||typeof e=="string"&&e.trim()==="")return[];if(typeof e=="object")return[e];try{let t=JSON.parse(e);return Array.isArray(t)?t.filter(i=>i!==null&&i!==""):t!==null&&t!==""?[t]:[]}catch{return typeof e=="string"&&e.trim()!==""?[e]:[]}},clearAllFiles(e=!0){let t=this.statePath||"unknown",i=this.getUploadcareApi();if(i){try{if(i.collection&&typeof i.collection.clear=="function")i.collection.clear();else if(typeof i.getCollection=="function"){let s=i.getCollection();s&&typeof s.clear=="function"&&s.clear()}}catch(s){console.warn(`[Uploadcare ${t}] collection clear error:`,s)}try{typeof i.removeAllFiles=="function"&&i.removeAllFiles()}catch{}try{typeof i.value=="function"?i.value([]):i.value=[]}catch{}}else console.warn(`[Uploadcare ${t}] No API discovered for clearing`);try{let s=this.$el.querySelector("uc-form-input");if(s&&typeof s.getAPI=="function"){let a=s.getAPI();a&&(a.value=this.isMultiple?[]:"")}}catch{}this.uploadedFiles!==(this.isMultiple?"[]":"")&&(this.uploadedFiles=this.isMultiple?"[]":"",this.isLocalUpdate=!0,e&&(this.state=this.uploadedFiles))}}}export{F as default}; diff --git a/packages/filament-uploadcare-field/resources/js/components/uploadcare.js b/packages/filament-uploadcare-field/resources/js/components/uploadcare.js index 76dbe445..f4e580c9 100644 --- a/packages/filament-uploadcare-field/resources/js/components/uploadcare.js +++ b/packages/filament-uploadcare-field/resources/js/components/uploadcare.js @@ -423,7 +423,7 @@ export default function uploadcareField(config) { api.addFileFromUuid(uuid); } } catch (e) { - console.error(`Failed to add file ${index} with UUID ${uuid}:`, e); + // console.error(`Failed to add file ${index} with UUID ${uuid}:`, e); } } else if (!uuid) { console.error(`Could not extract UUID from URL: ${url}`); @@ -884,7 +884,7 @@ export default function uploadcareField(config) { }; } - // Merge with existing file to preserve properties like uuid if missing in detail + // Merge with existing file to preserve properties like uuid if missing ‘in detail updatedFile = { ...originalFile, ...fileDetails }; // Extract and persist modifiers from the new URL if present @@ -892,6 +892,10 @@ export default function uploadcareField(config) { const extractedModifiers = this.extractModifiersFromUrl(updatedFile.cdnUrl); if (extractedModifiers) { updatedFile.cdnUrlModifiers = extractedModifiers; + } else { + // Explicitly clear modifiers if URL is clean (user removed crop) + updatedFile.cdnUrlModifiers = null; + delete updatedFile.cdnUrlModifiers; } } } else { diff --git a/packages/uploadcare-field/src/Uploadcare.php b/packages/uploadcare-field/src/Uploadcare.php index 52ba6cb9..f40f44ea 100755 --- a/packages/uploadcare-field/src/Uploadcare.php +++ b/packages/uploadcare-field/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 8d4c920b6920d3efd7b0d4bb848484fa902f92b1 Mon Sep 17 00:00:00 2001 From: Baspa Date: Mon, 5 Jan 2026 16:28:27 +0100 Subject: [PATCH 25/43] wip --- packages/core/src/Models/Media.php | 27 +- .../ContentResource/Pages/CreateContent.php | 16 +- packages/fields/src/Fields/Base.php | 14 +- .../dist/filament-uploadcare-field.js | 2 +- .../resources/js/components/uploadcare.js | 448 ++++++----------- .../src/Forms/Components/Uploadcare.php | 296 ++++++++--- .../Observers/ContentFieldValueObserver.php | 68 ++- packages/uploadcare-field/src/Uploadcare.php | 469 +++++++++++++----- 8 files changed, 819 insertions(+), 521 deletions(-) diff --git a/packages/core/src/Models/Media.php b/packages/core/src/Models/Media.php index 3b189574..be81399b 100644 --- a/packages/core/src/Models/Media.php +++ b/packages/core/src/Models/Media.php @@ -38,20 +38,43 @@ public function getMimeTypeAttribute(): ?string public function getEditAttribute(): ?array { + $mediaUlid = $this->ulid ?? 'UNKNOWN'; + if ($this->relationLoaded('edits')) { $edit = $this->edits->first(); if (! $edit || ! $edit->relationLoaded('pivot') || ! $edit->pivot || ! $edit->pivot->meta) { + \Log::info("[CROP DEBUG] Media::getEditAttribute (via edits relation) for {$mediaUlid}: NO META", [ + 'has_edit' => $edit !== null, + 'has_pivot_relation' => $edit && $edit->relationLoaded('pivot'), + 'has_pivot' => $edit && $edit->pivot !== null, + 'has_meta' => $edit && $edit->pivot && $edit->pivot->meta !== null, + ]); return null; } - return is_string($edit->pivot->meta) ? json_decode($edit->pivot->meta, true) : $edit->pivot->meta; + $result = is_string($edit->pivot->meta) ? json_decode($edit->pivot->meta, true) : $edit->pivot->meta; + \Log::info("[CROP DEBUG] Media::getEditAttribute (via edits relation) for {$mediaUlid}: FOUND", [ + 'has_crop' => is_array($result) && isset($result['crop']), + 'has_cdnUrlModifiers' => is_array($result) && isset($result['cdnUrlModifiers']), + ]); + return $result; } $edit = $this->edits()->first(); if (! $edit || ! $edit->pivot || ! $edit->pivot->meta) { + \Log::info("[CROP DEBUG] Media::getEditAttribute (via edits query) for {$mediaUlid}: NO META", [ + 'has_edit' => $edit !== null, + 'has_pivot' => $edit && $edit->pivot !== null, + 'has_meta' => $edit && $edit->pivot && $edit->pivot->meta !== null, + ]); return null; } - return is_string($edit->pivot->meta) ? json_decode($edit->pivot->meta, true) : $edit->pivot->meta; + $result = is_string($edit->pivot->meta) ? json_decode($edit->pivot->meta, true) : $edit->pivot->meta; + \Log::info("[CROP DEBUG] Media::getEditAttribute (via edits query) for {$mediaUlid}: FOUND", [ + 'has_crop' => is_array($result) && isset($result['crop']), + 'has_cdnUrlModifiers' => is_array($result) && isset($result['cdnUrlModifiers']), + ]); + return $result; } } diff --git a/packages/core/src/Resources/ContentResource/Pages/CreateContent.php b/packages/core/src/Resources/ContentResource/Pages/CreateContent.php index 3b1d2525..e408ccce 100644 --- a/packages/core/src/Resources/ContentResource/Pages/CreateContent.php +++ b/packages/core/src/Resources/ContentResource/Pages/CreateContent.php @@ -63,7 +63,13 @@ protected function afterCreate(): void ]); $this->getRecord()->authors()->attach(auth()->id()); - + } + + protected function getRedirectUrl(): string + { + // Store the created record before resetting for "Create Another" + $createdRecord = $this->getRecord(); + // Reset state for "Create Another" $typeSlug = $this->data['type_slug'] ?? null; @@ -72,13 +78,17 @@ protected function afterCreate(): void 'values' => [], ]; - $this->record = $this->getModel()::make(['type_slug' => $typeSlug]); - // Re-initialize static type property to prevent it being null during fill() hydration ContentResource::setStaticType(\Backstage\Models\Type::firstWhere('slug', $typeSlug)); $this->form->fill([]); $this->formVersion++; + + // Temporarily restore the created record for URL generation + $this->record = $createdRecord; + + // Get the default redirect URL (to edit page) + return parent::getRedirectUrl(); } } diff --git a/packages/fields/src/Fields/Base.php b/packages/fields/src/Fields/Base.php index ac51d8b8..2dc029e2 100644 --- a/packages/fields/src/Fields/Base.php +++ b/packages/fields/src/Fields/Base.php @@ -202,7 +202,19 @@ public static function getFieldValueFromRecord(Model $record, Field $field): mix // Handle relationship-based values (like Content model) if (self::isRelationship($values)) { - $fieldValue = $values->where('field_ulid', $field->ulid)->first(); + $fieldValue = $values->where(function ($query) use ($field) { + $query->where('field_ulid', $field->ulid) + ->orWhere('ulid', $field->ulid); + })->first(); + + if ($field->slug === 'banner-image') { + \Illuminate\Support\Facades\Log::info("[BASE DEBUG] getFieldValueFromRecord relation check", [ + 'field_ulid' => $field->ulid, + 'record_key' => $record->getKey(), + 'found' => (bool) $fieldValue, + 'sql' => $values->where('field_ulid', $field->ulid)->toSql(), + ]); + } $result = $fieldValue ? self::resolveHydratedValue($fieldValue) : null; } } diff --git a/packages/filament-uploadcare-field/resources/dist/filament-uploadcare-field.js b/packages/filament-uploadcare-field/resources/dist/filament-uploadcare-field.js index e925063b..ab6d3875 100644 --- a/packages/filament-uploadcare-field/resources/dist/filament-uploadcare-field.js +++ b/packages/filament-uploadcare-field/resources/dist/filament-uploadcare-field.js @@ -1 +1 @@ -var m=class{constructor(e){this.wrapper=e,this.observer=null,this.init()}init(){this.hideDoneButtons(),this.setupObserver()}setupObserver(){this.observer=new MutationObserver(e=>{e.forEach(t=>{t.type==="childList"&&t.addedNodes.forEach(i=>{if(i.nodeType===Node.ELEMENT_NODE){i.classList&&i.classList.contains("uc-done-btn")&&this.hideDoneButton(i);let s=i.querySelectorAll&&i.querySelectorAll(".uc-done-btn");s&&s.forEach(a=>this.hideDoneButton(a))}})})}),this.wrapper&&this.observer.observe(this.wrapper,{childList:!0,subtree:!0})}hideDoneButtons(){document.querySelectorAll(".uc-done-btn").forEach(t=>this.hideDoneButton(t))}hideDoneButton(e){e&&(e.style.display="none",e.style.visibility="hidden",e.style.opacity="0",e.style.pointerEvents="none",e.style.position="absolute",e.style.width="0",e.style.height="0",e.style.overflow="hidden",e.style.clip="rect(0, 0, 0, 0)",e.style.margin="0",e.style.padding="0",e.style.border="0",e.style.background="transparent",e.style.color="transparent",e.style.fontSize="0",e.style.lineHeight="0")}destroy(){this.observer&&(this.observer.disconnect(),this.observer=null)}};function F(h){return window._initializedUploadcareContexts||(window._initializedUploadcareContexts=new Set),{state:h.state,statePath:h.statePath,initialState:h.initialState,publicKey:h.publicKey,isMultiple:h.isMultiple,multipleMin:h.multipleMin,multipleMax:h.multipleMax,isImagesOnly:h.isImagesOnly,accept:h.accept,sourceList:h.sourceList,uploaderStyle:h.uploaderStyle,isWithMetadata:h.isWithMetadata,localeName:h.localeName||"en",uploadedFiles:"",ctx:null,removeEventListeners:null,uniqueContextName:h.uniqueContextName,pendingUploads:[],pendingRemovals:[],isInitialized:!1,stateHasBeenInitialized:!1,isStateWatcherActive:!1,isLocalUpdate:!1,doneButtonHider:null,documentClassObserver:null,formInputObserver:null,async init(){this.isContextAlreadyInitialized()||(this.markContextAsInitialized(),this.applyTheme(),await this.loadAllLocales(),this.$el.isConnected&&(this.setupStateWatcher(),this.$el.addEventListener("uploadcare-state-updated",e=>{let t=e.detail.uuid;t&&this.isInitialized?this.loadFileFromUuid(t):t&&this.$nextTick(()=>{this.isInitialized&&this.loadFileFromUuid(t)})}),this.initUploadcare(),this.setupThemeObservers(),this.setupDoneButtonObserver(),(!this.state||this.state==="[]"||this.state==='""')&&this.$nextTick(()=>{this.isInitialized&&this.getCurrentFiles().length>0&&this.clearAllFiles(!1)})))},isContextAlreadyInitialized(){return window._initializedUploadcareContexts.has(this.uniqueContextName)},markContextAsInitialized(){window._initializedUploadcareContexts.add(this.uniqueContextName)},async loadAllLocales(){window._uploadcareAllLocalesLoaded||await new Promise(t=>{if(window._uploadcareAllLocalesLoaded){t();return}let i=setInterval(()=>{window._uploadcareAllLocalesLoaded&&(clearInterval(i),t())},100);setTimeout(()=>{clearInterval(i),t()},5e3)});let e=["de","es","fr","he","it","nl","pl","pt","ru","tr","uk","zh-TW","zh"];document.querySelectorAll("uc-config[data-locale-name]").forEach(t=>{let i=t.getAttribute("data-locale-name");i&&e.includes(i)&&!t.getAttribute("locale-name")&&t.setAttribute("locale-name",i)})},async loadLocale(){if(this.localeName==="en"||this.localeLoaded)return;if(window._uploadcareLocales&&window._uploadcareLocales.has(this.localeName)){this.localeLoaded=!0;return}if(window._uploadcareLocales||(window._uploadcareLocales=new Set),!!["de","es","fr","he","it","nl","pl","pt","ru","tr","uk","zh-TW","zh"].includes(this.localeName))try{let i=await import(`https://cdn.jsdelivr.net/npm/@uploadcare/file-uploader@v1/locales/file-uploader/${this.localeName}.js`),s=i.default||i,a=()=>{let r=customElements.get("uc-file-uploader-inline")||customElements.get("uc-file-uploader-regular")||customElements.get("uc-file-uploader-minimal");return r&&r.UC?r.UC:window.UC},n=()=>{let r=a();return r&&typeof r.defineLocale=="function"?(r.defineLocale(this.localeName,s),window._uploadcareLocales.add(this.localeName),this.localeLoaded=!0,!0):!1};if(!n()){let r=0,o=50,c=setInterval(()=>{r++,(n()||r>=o)&&clearInterval(c)},100)}}catch(t){console.error("[Uploadcare Locale JS] Failed to load locale:",this.localeName,t)}},applyTheme(){let e=this.getCurrentTheme();this.$el.querySelectorAll(`uc-file-uploader-${this.uploaderStyle}`).forEach(i=>{i.classList.remove("uc-dark","uc-light"),i.classList.add(`uc-${e}`)})},getCurrentTheme(){return document.documentElement.classList.contains("dark")?"dark":"light"},setupThemeObservers(){window.addEventListener("storage",this.handleThemeStorageChange.bind(this)),window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change",this.handleSystemThemeChange.bind(this)),this.setupDocumentClassObserver()},handleThemeStorageChange(e){e.key==="theme"&&this.applyTheme()},handleSystemThemeChange(){localStorage.getItem("theme")==="system"&&this.applyTheme()},setupDocumentClassObserver(){this.documentClassObserver=new MutationObserver(e=>{e.forEach(t=>{if(t.type==="attributes"&&t.attributeName==="class"){let i=document.documentElement.classList.contains("dark"),s=t.oldValue&&t.oldValue.includes("dark");i!==s&&this.applyTheme()}})}),this.documentClassObserver.observe(document.documentElement,{attributes:!0,attributeOldValue:!0,attributeFilter:["class"]})},initUploadcare(){this.removeEventListeners&&this.removeEventListeners(),this.initializeUploader()},initializeUploader(e=0,t=10){if(e>=t)return;this.ctx=this.$el.querySelector(`uc-upload-ctx-provider[ctx-name="${this.uniqueContextName}"]`);let i=this.getUploadcareApi();if(!this.isValidContext(i)){setTimeout(()=>this.initializeUploader(e+1,t),100);return}this.markAsInitialized(),this.removeRequiredAttributes(),this.initializeState(i),this.setupEventListeners(i)},getUploadcareApi(){try{return this.ctx?.getAPI()}catch{return null}},isValidContext(e){return this.ctx&&e&&e.addFileFromCdnUrl},markAsInitialized(){this.isInitialized=!0},removeRequiredAttributes(){setTimeout(()=>{let e=this.$el.closest("uc-config");document.querySelectorAll("uc-form-input input[required]").forEach(i=>i.removeAttribute("required"))},100)},initializeState(e){this.initialState&&!this.stateHasBeenInitialized&&!this.uploadedFiles?this.loadInitialState(e):!this.initialState&&!this.stateHasBeenInitialized&&(this.stateHasBeenInitialized=!0,this.uploadedFiles=this.isMultiple?"[]":"",this.isLocalUpdate=!0,this.state=this.uploadedFiles)},loadInitialState(e){try{let t=this.parseInitialState();this.addFilesFromInitialState(e,t),this.stateHasBeenInitialized=!0,this.isLocalUpdate=!0,this.state=this.uploadedFiles}catch(t){console.error("Error parsing initialState:",t)}},parseInitialState(){let e=i=>{if(typeof i=="string")try{let s=JSON.parse(i);if(typeof s=="string")try{s=JSON.parse(s)}catch{}return s}catch{return i}return i};return this.initialState&&this.initialState&&typeof this.initialState=="object"&&!Array.isArray(this.initialState)&&(this.initialState=[this.initialState]),this.parseStateValue(this.initialState)},addFilesFromInitialState(e,t){let i=[];if(t&&t&&typeof t=="object"&&!Array.isArray(t))try{i=Array.from(t)}catch{i=[t]}else Array.isArray(t)?i=t:t&&(i=[t]);if(Array.isArray(i)&&i.length===1&&Array.isArray(i[0])&&(i=i[0]),Array.isArray(i)&&i.length===1&&typeof i[0]=="string")try{let r=JSON.parse(i[0]);i=Array.isArray(r)?r:[r]}catch{}if(!Array.isArray(i)||i.length===0)return;Array.isArray(i)||(i=[i]);let s=(r,o=0)=>{if(!r)return;if(Array.isArray(r)){r.forEach((p,d)=>{s(p,`${o}.${d}`)});return}if(typeof r=="string")try{let p=JSON.parse(r);s(p,o);return}catch{}let c=r&&typeof r=="object"?r.cdnUrl:r,u=r&&typeof r=="object"?r.cdnUrlModifiers:null;if(!c||!this.isValidUrl(c))return;let l=this.extractUuidFromUrl(c);if(l&&typeof e.addFileFromUuid=="function")try{if((u||c&&c.includes("/-/"))&&typeof e.addFileFromCdnUrl=="function"){let d=c;if(u){let y=c.split("/-/")[0],f=u;f.startsWith("/")&&(f=f.substring(1)),d=y+(y.endsWith("/")?"":"/")+f}e.addFileFromCdnUrl(d)}else e.addFileFromUuid(l)}catch{}else console.error(l?"addFileFromUuid method not available on API":`Could not extract UUID from URL: ${c}`)};i.forEach(s);let a=i.map(r=>{let o=r;if(r&&typeof r=="object")return r.uuid||(r.uuid=this.extractUuidFromUrl(r.cdnUrl)),r;if(typeof r=="string"){let c=this.extractUuidFromUrl(r);return{cdnUrl:r,uuid:c,name:"",size:0,mimeType:"",isImage:!1}}return r}),n=this.formatFilesForState(a);this.uploadedFiles=JSON.stringify(n),this.initialState=this.uploadedFiles},isValidUrl(e){if(!e||typeof e!="string")return!1;try{return new URL(e),!0}catch{return!1}},setupStateWatcher(){this.$watch("state",(e,t)=>{if(this.isLocalUpdate){this.isLocalUpdate=!1;return}if(!this.$el.isConnected)return;if(!this.stateHasBeenInitialized){this.stateHasBeenInitialized=!0;return}if(!e||e==="[]"||e==='""'||Array.isArray(e)&&e.length===0){this.uploadedFiles&&this.uploadedFiles!=="[]"&&this.uploadedFiles!=='""'&&this.getCurrentFiles().length>0&&this.clearAllFiles(!1);return}let i=this.normalizeStateValue(e),s=this.normalizeStateValue(this.uploadedFiles);i!==s&&e&&e!=="[]"&&e!=='""'&&this.addFilesFromState(e)})},parseStateValue(e){if(!e)return null;try{return typeof e=="string"?JSON.parse(e):e}catch{return e}},addFilesFromState(e){let i=this.parseStateValue(e);if(Array.isArray(i)||(i=[i]),i=i.filter(l=>l!=null),i.length===0)return!1;let s=this.getUploadcareApi();if(!s||typeof s.addFileFromCdnUrl!="function")return!1;let n=this.getCurrentFiles().map(l=>l?l&&typeof l=="object"?l.cdnUrl:l:null).filter(Boolean);i.forEach((l,p)=>{if(!l){console.warn(`[Uploadcare] Skipping null item at index ${p}`);return}let d=l&&typeof l=="object"?l.cdnUrl:l;if(d&&typeof d=="string"&&(d.includes("ucarecdn.com")||d.includes("ucarecd.net"))&&!n.some(f=>{let U=this.extractUuidFromUrl(d),g=this.extractUuidFromUrl(f);return U&&g&&U===g}))try{s.addFileFromCdnUrl(d)}catch(f){console.error("[Uploadcare] Failed to add file from URL:",d,f)}});let r=[],o=new Set,c=l=>{if(!l)return;let p=l&&typeof l=="object"?l.cdnUrl:l,d=this.extractUuidFromUrl(p);d&&!o.has(d)?(o.add(d),this.isWithMetadata&&typeof l!="object"?r.push({cdnUrl:l,uuid:d,name:"",size:0,mimeType:"",isImage:!1}):r.push(l)):d||r.push(l)},u=this.parseStateValue(e)||[];return(Array.isArray(u)?u:[u]).forEach(c),this.uploadedFiles=JSON.stringify(r),this.isLocalUpdate=!0,!0},normalizeStateValue(e){if(!e)return"";try{let t=typeof e=="string"?JSON.parse(e):e;if(Array.isArray(t)&&t.every(a=>typeof a=="string"||typeof a=="object"&&a!==null&&("cdnUrl"in a||"uuid"in a)))return JSON.stringify(t);let i=this.formatFilesForState(t);return JSON.stringify(i)}catch(t){return console.error("[Uploadcare] normalizeStateValue error",t),e}},isStateChanged(){let e=this.normalizeStateValue(this.state),t=this.normalizeStateValue(this.initialState);return e!==t},setupEventListeners(e){this.pendingUploads=[],this.pendingRemovals=[];let t=this.createFileUploadSuccessHandler(e),i=this.createFileUrlChangedHandler(e),s=this.createFileRemovedHandler(e),a=this.createFormInputChangeHandler(e),n=r=>{if(r.target!==this.ctx&&!this.ctx.contains(r.target))return;let o=this.$el.closest("form");o&&o.dispatchEvent(new CustomEvent("form-processing-started",{detail:{message:"Uploading file..."}}))};this.ctx.addEventListener("file-upload-started",n),this.ctx.addEventListener("file-upload-success",t),this.ctx.addEventListener("file-url-changed",i),this.ctx.addEventListener("file-removed",s),this.$nextTick(()=>{let r=this.$el.querySelector("uc-form-input input");if(r){r.addEventListener("input",a),r.addEventListener("change",a);let o=new MutationObserver(()=>{a({target:r})});o.observe(r,{attributes:!0,attributeFilter:["value"]}),this.formInputObserver=o}}),this.removeEventListeners=()=>{this.ctx.removeEventListener("file-upload-started",n),this.ctx.removeEventListener("file-upload-success",t),this.ctx.removeEventListener("file-url-changed",i),this.ctx.removeEventListener("file-removed",s);let r=this.$el.querySelector("uc-form-input input");r&&(r.removeEventListener("input",a),r.removeEventListener("change",a)),this.formInputObserver&&(this.formInputObserver.disconnect(),this.formInputObserver=null)}},createFileUploadSuccessHandler(e){let t=null;return i=>{if(i.target.getAttribute("ctx-name")!==this.uniqueContextName&&i.target!==this.ctx&&!this.ctx.contains(i.target))return;let a=this.isWithMetadata?i.detail:i.detail.cdnUrl,n=this.extractUuidFromUrl(a);this.pendingUploads.push(a),t&&clearTimeout(t),t=setTimeout(()=>{try{let r=this.getCurrentFiles();for(let c of this.pendingUploads)r=this.updateFilesList(r,c);this.updateState(r),this.pendingUploads=[];let o=this.$el.closest("form");o&&o.dispatchEvent(new CustomEvent("form-processing-finished"))}catch(r){console.error("[Uploadcare] Error updating state after upload:",r)}},200)}},createFileUrlChangedHandler(e){let t=null;return i=>{if(i.target.getAttribute("ctx-name")!==this.uniqueContextName&&i.target!==this.ctx&&!this.ctx.contains(i.target))return;let a=i.detail;t&&clearTimeout(t),t=setTimeout(()=>{try{let n=this.getCurrentFiles(),r=this.updateFileUrl(n,a);this.updateState(r)}catch(n){console.error("Error updating state after URL change:",n)}},100)}},createFileRemovedHandler(e){let t=null;return i=>{if(i.target.getAttribute("ctx-name")!==this.uniqueContextName&&i.target!==this.ctx&&!this.ctx.contains(i.target))return;let a=i.detail;this.pendingRemovals.push(a),t&&clearTimeout(t),t=setTimeout(()=>{try{let n=this.getCurrentFiles();for(let r of this.pendingRemovals)n=this.removeFile(n,r);this.updateState(n),this.pendingRemovals=[]}catch(n){console.error("Error in handleFileRemoved:",n)}},100)}},createFormInputChangeHandler(e){return t=>{}},getCurrentFiles(){try{let e=this.uploadedFiles?JSON.parse(this.uploadedFiles):[];return Array.isArray(e)?e:[]}catch{return[]}},updateFilesList(e,t){if(this.isMultiple){let i=this.extractUuidFromUrl(t);return e.some(a=>this.extractUuidFromUrl(a)===i)?e:[...e,t]}return[t]},updateFileUrl(e,t){let i=t.uuid;if(!i&&t.cdnUrl&&(i=this.extractUuidFromUrl(t.cdnUrl)),!i)return e;t.uuid||(t={...t,uuid:i});let s=this.findFileIndex(e,i);if(s===-1)return e;let a;if(this.isWithMetadata){let n=e[s];if(typeof n=="string"){let r=this.extractUuidFromUrl(n);n={cdnUrl:n,uuid:r,name:"",size:0,mimeType:"",isImage:!1}}if(a={...n,...t},a.cdnUrl){let r=this.extractModifiersFromUrl(a.cdnUrl);r?a.cdnUrlModifiers=r:(a.cdnUrlModifiers=null,delete a.cdnUrlModifiers)}}else a=t.cdnUrl;return this.isMultiple?(e[s]=a,e):[a]},removeFile(e,t){let i=this.findFileIndex(e,t.uuid);return i===-1?e:this.isMultiple?(e.splice(i,1),e):[]},findFileIndex(e,t){return t?e.findIndex(i=>{let s=i&&typeof i=="object"?i.cdnUrl:i;return this.extractUuidFromUrl(s)===t}):-1},updateState(e){let t=new Set,i=e.filter(u=>{let l=u&&typeof u=="object"?u.cdnUrl:u,p=this.extractUuidFromUrl(l);return p?t.has(p)?!1:(t.add(p),!0):!0}),s=this.formatFilesForState(i),a=JSON.stringify(s),n=this.getCurrentFiles(),r=JSON.stringify(this.formatFilesForState(n)),o=JSON.stringify(s);r!==o&&(this.uploadedFiles=a,this.isLocalUpdate=!0,this.state=this.uploadedFiles,this.isMultiple&&i.length>1&&this.$nextTick(()=>{this.isLocalUpdate=!1}))},formatFilesForState(e){if(!e)return[];if(!Array.isArray(e))if(console.warn("[Uploadcare] formatFilesForState called with non-array:",typeof e,e),typeof e=="string")try{let t=JSON.parse(e);if(Array.isArray(t))e=t;else return[]}catch{return[]}else return[];return e.map(t=>{if(t&&typeof t=="object"&&!t.cdnUrl&&!t.uuid&&"0"in t){let i=Object.keys(t);if(i.length>5&&i.includes("0")&&i.includes("1")&&i.includes("2")){let s="";if(Math.max(...i.map(n=>parseInt(n)).filter(n=>!isNaN(n)))===i.length-1){let n=new Array(i.length);for(let r=0;r0){let r=[];for(let o of t){let c=o&&typeof o=="object"?o.cdnUrl:o;if(typeof c=="string"&&c.match(/[a-f0-9-]{36}~[0-9]+/))try{let u=await this.fetchGroupFiles(c);r.push(...u)}catch(u){console.error("[Uploadcare] Failed to expand group:",c,u),r.push(o)}else r.push(o)}t=r}let i=this.formatFilesForState(t),s=this.buildStateFromFiles(i),a=this.normalizeStateValue(this.uploadedFiles),n=this.normalizeStateValue(s);a!==n&&(this.uploadedFiles=s,this.isLocalUpdate=!0,this.state=this.uploadedFiles)}catch(t){console.error("Error syncing state with Uploadcare:",t)}},async fetchGroupFiles(e){let t=e;if(e.includes("ucarecdn.com")||e.includes("ucarecd.net")){let a=e.match(/\/([a-f0-9-]{36}~[0-9]+)/);a&&(t=a[1])}let i=await fetch(`https://upload.uploadcare.com/group/info/?pub_key=${this.publicKey}&group_id=${t}`);if(!i.ok)throw new Error(`Failed to fetch group info: ${i.statusText}`);let s=await i.json();return s.files?s.files.map(a=>{let n=`https://ucarecdn.com/${a.uuid}/`;return this.isWithMetadata?{uuid:a.uuid,cdnUrl:n,name:a.original_filename,size:a.size,mimeType:a.mime_type,isImage:a.is_image}:n}):[]},buildStateFromFiles(e){return this.isMultiple?JSON.stringify(e):e.length>0?this.isWithMetadata?JSON.stringify(e[0]):e[0]:""},getCurrentFilesFromUploadcare(e){try{if(e&&typeof e.value=="function"){let i=e.value();return i?Array.isArray(i)?i.filter(a=>a!=null):this.parseFormInputValue(i).filter(a=>a!=null):[]}let t=this.$el.querySelector("uc-form-input input");return t?this.parseFormInputValue(t.value).filter(s=>s!=null):[]}catch(t){return console.error("Error getting current files from Uploadcare:",t),[]}},parseFormInputValue(e){if(!e||typeof e=="string"&&e.trim()==="")return[];if(typeof e=="object")return[e];try{let t=JSON.parse(e);return Array.isArray(t)?t.filter(i=>i!==null&&i!==""):t!==null&&t!==""?[t]:[]}catch{return typeof e=="string"&&e.trim()!==""?[e]:[]}},clearAllFiles(e=!0){let t=this.statePath||"unknown",i=this.getUploadcareApi();if(i){try{if(i.collection&&typeof i.collection.clear=="function")i.collection.clear();else if(typeof i.getCollection=="function"){let s=i.getCollection();s&&typeof s.clear=="function"&&s.clear()}}catch(s){console.warn(`[Uploadcare ${t}] collection clear error:`,s)}try{typeof i.removeAllFiles=="function"&&i.removeAllFiles()}catch{}try{typeof i.value=="function"?i.value([]):i.value=[]}catch{}}else console.warn(`[Uploadcare ${t}] No API discovered for clearing`);try{let s=this.$el.querySelector("uc-form-input");if(s&&typeof s.getAPI=="function"){let a=s.getAPI();a&&(a.value=this.isMultiple?[]:"")}}catch{}this.uploadedFiles!==(this.isMultiple?"[]":"")&&(this.uploadedFiles=this.isMultiple?"[]":"",this.isLocalUpdate=!0,e&&(this.state=this.uploadedFiles))}}}export{F as default}; +var m=class{constructor(e){this.wrapper=e,this.observer=null,this.init()}init(){this.hideDoneButtons(),this.setupObserver()}setupObserver(){this.observer=new MutationObserver(e=>{e.forEach(t=>{t.type==="childList"&&t.addedNodes.forEach(i=>{if(i.nodeType===Node.ELEMENT_NODE){i.classList&&i.classList.contains("uc-done-btn")&&this.hideDoneButton(i);let a=i.querySelectorAll&&i.querySelectorAll(".uc-done-btn");a&&a.forEach(s=>this.hideDoneButton(s))}})})}),this.wrapper&&this.observer.observe(this.wrapper,{childList:!0,subtree:!0})}hideDoneButtons(){document.querySelectorAll(".uc-done-btn").forEach(t=>this.hideDoneButton(t))}hideDoneButton(e){e&&(e.style.display="none",e.style.visibility="hidden",e.style.opacity="0",e.style.pointerEvents="none",e.style.position="absolute",e.style.width="0",e.style.height="0",e.style.overflow="hidden",e.style.clip="rect(0, 0, 0, 0)",e.style.margin="0",e.style.padding="0",e.style.border="0",e.style.background="transparent",e.style.color="transparent",e.style.fontSize="0",e.style.lineHeight="0")}destroy(){this.observer&&(this.observer.disconnect(),this.observer=null)}};function F(u){return window._initializedUploadcareContexts||(window._initializedUploadcareContexts=new Set),{name:u.statePath||"unknown",state:u.state,statePath:u.statePath,initialState:u.initialState,publicKey:u.publicKey,isMultiple:u.isMultiple,multipleMin:u.multipleMin,multipleMax:u.multipleMax,isImagesOnly:u.isImagesOnly,accept:u.accept,sourceList:u.sourceList,uploaderStyle:u.uploaderStyle,isWithMetadata:u.isWithMetadata,localeName:u.localeName||"en",uploadedFiles:"",ctx:null,removeEventListeners:null,uniqueContextName:u.uniqueContextName,pendingUploads:[],pendingRemovals:[],isInitialized:!1,stateHasBeenInitialized:!1,isStateWatcherActive:!1,isLocalUpdate:!1,doneButtonHider:null,documentClassObserver:null,formInputObserver:null,isUpdatingState:!1,async init(){this.isContextAlreadyInitialized()||(this.markContextAsInitialized(),this.applyTheme(),await this.loadAllLocales(),this.$el.isConnected&&(this.setupStateWatcher(),this.$el.addEventListener("uploadcare-state-updated",e=>{let t=e.detail.uuid;t&&this.isInitialized?this.loadFileFromUuid(t):t&&this.$nextTick(()=>{this.isInitialized&&this.loadFileFromUuid(t)})}),this.initUploadcare(),this.setupThemeObservers(),this.setupDoneButtonObserver(),(!this.state||this.state==="[]"||this.state==='""')&&this.$nextTick(()=>{this.isInitialized&&this.getCurrentFiles().length>0&&this.clearAllFiles(!1)})))},isContextAlreadyInitialized(){return window._initializedUploadcareContexts.has(this.uniqueContextName)},markContextAsInitialized(){window._initializedUploadcareContexts.add(this.uniqueContextName)},async loadAllLocales(){window._uploadcareAllLocalesLoaded||await new Promise(t=>{if(window._uploadcareAllLocalesLoaded){t();return}let i=setInterval(()=>{window._uploadcareAllLocalesLoaded&&(clearInterval(i),t())},100);setTimeout(()=>{clearInterval(i),t()},5e3)});let e=["de","es","fr","he","it","nl","pl","pt","ru","tr","uk","zh-TW","zh"];document.querySelectorAll("uc-config[data-locale-name]").forEach(t=>{let i=t.getAttribute("data-locale-name");i&&e.includes(i)&&!t.getAttribute("locale-name")&&t.setAttribute("locale-name",i)})},async loadLocale(){if(this.localeName==="en"||this.localeLoaded)return;if(window._uploadcareLocales&&window._uploadcareLocales.has(this.localeName)){this.localeLoaded=!0;return}if(window._uploadcareLocales||(window._uploadcareLocales=new Set),!!["de","es","fr","he","it","nl","pl","pt","ru","tr","uk","zh-TW","zh"].includes(this.localeName))try{let i=await import(`https://cdn.jsdelivr.net/npm/@uploadcare/file-uploader@v1/locales/file-uploader/${this.localeName}.js`),a=i.default||i,s=()=>{let r=customElements.get("uc-file-uploader-inline")||customElements.get("uc-file-uploader-regular")||customElements.get("uc-file-uploader-minimal");return r&&r.UC?r.UC:window.UC},n=()=>{let r=s();return r&&typeof r.defineLocale=="function"?(r.defineLocale(this.localeName,a),window._uploadcareLocales.add(this.localeName),this.localeLoaded=!0,!0):!1};if(!n()){let r=0,d=50,o=setInterval(()=>{r++,(n()||r>=d)&&clearInterval(o)},100)}}catch(t){console.error("[Uploadcare Locale JS] Failed to load locale:",this.localeName,t)}},applyTheme(){let e=this.getCurrentTheme();this.$el.querySelectorAll(`uc-file-uploader-${this.uploaderStyle}`).forEach(i=>{i.classList.remove("uc-dark","uc-light"),i.classList.add(`uc-${e}`)})},getCurrentTheme(){return document.documentElement.classList.contains("dark")?"dark":"light"},setupThemeObservers(){window.addEventListener("storage",this.handleThemeStorageChange.bind(this)),window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change",this.handleSystemThemeChange.bind(this)),this.setupDocumentClassObserver()},handleThemeStorageChange(e){e.key==="theme"&&this.applyTheme()},handleSystemThemeChange(){localStorage.getItem("theme")==="system"&&this.applyTheme()},setupDocumentClassObserver(){this.documentClassObserver=new MutationObserver(e=>{e.forEach(t=>{if(t.type==="attributes"&&t.attributeName==="class"){let i=document.documentElement.classList.contains("dark"),a=t.oldValue&&t.oldValue.includes("dark");i!==a&&this.applyTheme()}})}),this.documentClassObserver.observe(document.documentElement,{attributes:!0,attributeOldValue:!0,attributeFilter:["class"]})},initUploadcare(){this.removeEventListeners&&this.removeEventListeners(),this.initializeUploader()},initializeUploader(e=0,t=10){if(e>=t)return;this.ctx=this.$el.querySelector(`uc-upload-ctx-provider[ctx-name="${this.uniqueContextName}"]`);let i=this.getUploadcareApi();if(!this.isValidContext(i)){setTimeout(()=>this.initializeUploader(e+1,t),100);return}this.markAsInitialized(),this.removeRequiredAttributes(),this.initializeState(i),this.setupEventListeners(i)},getUploadcareApi(){try{return this.ctx?.getAPI()}catch{return null}},isValidContext(e){return this.ctx&&e&&e.addFileFromCdnUrl},markAsInitialized(){this.isInitialized=!0},removeRequiredAttributes(){setTimeout(()=>{let e=this.$el.closest("uc-config");document.querySelectorAll("uc-form-input input[required]").forEach(i=>i.removeAttribute("required"))},100)},initializeState(e){this.initialState&&!this.stateHasBeenInitialized&&!this.uploadedFiles?this.loadInitialState(e):!this.initialState&&!this.stateHasBeenInitialized&&(this.stateHasBeenInitialized=!0,this.uploadedFiles=this.isMultiple||this.isWithMetadata?"[]":"",this.isLocalUpdate=!0,this.state=this.uploadedFiles)},loadInitialState(e){try{let t=this.parseInitialState();this.addFilesFromInitialState(e,t),this.stateHasBeenInitialized=!0,this.isLocalUpdate=!0,this.state=this.uploadedFiles}catch(t){console.error("Error parsing initialState:",t)}},parseInitialState(){let e=i=>{if(typeof i=="string")try{let a=JSON.parse(i);if(typeof a=="string")try{a=JSON.parse(a)}catch{}return a}catch{return i}return i};return this.initialState&&this.initialState&&typeof this.initialState=="object"&&!Array.isArray(this.initialState)&&(this.initialState=[this.initialState]),this.parseStateValue(this.initialState)},addFilesFromInitialState(e,t){let i=[];if(t&&t&&typeof t=="object"&&!Array.isArray(t))try{i=Array.from(t)}catch{i=[t]}else Array.isArray(t)?i=t:t&&(i=[t]);if(Array.isArray(i)&&i.length===1&&Array.isArray(i[0])&&(i=i[0]),Array.isArray(i)&&i.length===1&&typeof i[0]=="string")try{let r=JSON.parse(i[0]);i=Array.isArray(r)?r:[r]}catch{}if(!Array.isArray(i)||i.length===0)return;Array.isArray(i)||(i=[i]);let a=(r,d=0)=>{if(!r)return;if(Array.isArray(r)){r.forEach((h,c)=>{a(h,`${d}.${c}`)});return}if(typeof r=="string")try{let h=JSON.parse(r);a(h,d);return}catch{}let o=r&&typeof r=="object"?r.cdnUrl:r,f=r&&typeof r=="object"?r.cdnUrlModifiers:null;if(console.log(`[CROP DEBUG JS] ${this.name} addFile (initialState)`,{index:d,url:o,cdnUrlModifiers:f,has_modifiers_in_url:o&&o.includes("/-/"),item:JSON.stringify(r).substring(0,500)}),!o||!this.isValidUrl(o))return;let l=this.extractUuidFromUrl(o);if(l&&typeof e.addFileFromUuid=="function")try{if((f||o&&o.includes("/-/"))&&typeof e.addFileFromCdnUrl=="function"){let c=o;if(f){let y=o.split("/-/")[0],p=f;p.startsWith("/")&&(p=p.substring(1)),c=y+(y.endsWith("/")?"":"/")+(p.startsWith("-/")?"":"-/")+p}console.log(`[CROP DEBUG JS] ${this.name} api.addFileFromCdnUrl`,{fullUrl:c}),e.addFileFromCdnUrl(c)}else e.addFileFromUuid(l)}catch{}else console.error(l?"addFileFromUuid method not available on API":`Could not extract UUID from URL: ${o}`)};i.forEach(a);let s=i.map(r=>{let d=r;if(r&&typeof r=="object")return r.uuid||(r.uuid=this.extractUuidFromUrl(r.cdnUrl)),r;if(typeof r=="string"){let o=this.extractUuidFromUrl(r);return{cdnUrl:r,uuid:o,name:"",size:0,mimeType:"",isImage:!1}}return r}),n=this.formatFilesForState(s);this.uploadedFiles=JSON.stringify(n),this.initialState=this.uploadedFiles},isValidUrl(e){if(!e||typeof e!="string")return!1;try{return new URL(e),!0}catch{return!1}},setupStateWatcher(){},parseStateValue(e){if(!e)return null;try{return typeof e=="string"?JSON.parse(e):e}catch{return e}},addFilesFromState(e){let t=this.parseStateValue(e),i=t;if(console.log("[CROP DEBUG JS] addFilesFromState called",{newValue_type:typeof e,parsed_length:Array.isArray(t)?t.length:"not_array",first_item:t&&t[0]?{has_cdnUrlModifiers:!!t[0].cdnUrlModifiers,cdnUrlModifiers:t[0].cdnUrlModifiers,cdnUrl:t[0].cdnUrl,keys:Object.keys(t[0])}:null}),Array.isArray(i)||(i=[i]),i=i.filter(l=>l!=null),i.length===0)return!1;let a=this.getUploadcareApi();if(!a||typeof a.addFileFromCdnUrl!="function")return!1;let n=this.getCurrentFiles().map(l=>l?l&&typeof l=="object"?l.cdnUrl:l:null).filter(Boolean);i.forEach((l,h)=>{if(!l){console.warn(`[Uploadcare] Skipping null item at index ${h}`);return}let c=l&&typeof l=="object"?l.cdnUrl:l;if(c&&typeof c=="string"&&(c.includes("ucarecdn.com")||c.includes("ucarecd.net"))&&!n.some(p=>{let U=this.extractUuidFromUrl(c),g=this.extractUuidFromUrl(p);return U&&g&&U===g})){console.log("[CROP DEBUG JS] Adding file to Uploadcare",{url:c,has_cdnUrlModifiers:!!l.cdnUrlModifiers,cdnUrlModifiers:l.cdnUrlModifiers,url_includes_modifiers:c.includes("-/")});try{a.addFileFromCdnUrl(c)}catch(p){console.error("[Uploadcare] Failed to add file from URL:",c,p)}}});let r=[],d=new Set,o=l=>{if(!l)return;let h=l&&typeof l=="object"?l.cdnUrl:l,c=this.extractUuidFromUrl(h);c&&!d.has(c)?(d.add(c),this.isWithMetadata&&typeof l!="object"?r.push({cdnUrl:l,uuid:c,name:"",size:0,mimeType:"",isImage:!1}):r.push(l)):c||r.push(l)},f=this.parseStateValue(e)||[];return(Array.isArray(f)?f:[f]).forEach(o),this.uploadedFiles=JSON.stringify(r),this.isLocalUpdate=!0,!0},normalizeStateValue(e){if(!e)return"";try{let t=typeof e=="string"?JSON.parse(e):e;if(Array.isArray(t)&&t.every(s=>typeof s=="string"||typeof s=="object"&&s!==null&&("cdnUrl"in s||"uuid"in s)))return JSON.stringify(t);let i=this.formatFilesForState(t);return JSON.stringify(i)}catch(t){return console.error("[Uploadcare] normalizeStateValue error",t),e}},isStateChanged(){let e=this.normalizeStateValue(this.state),t=this.normalizeStateValue(this.initialState);return e!==t},setupEventListeners(e){this.pendingUploads=[],this.pendingRemovals=[];let t=this.createFileUploadSuccessHandler(e),i=this.createFileUrlChangedHandler(e),a=this.createFileRemovedHandler(e),s=this.createFormInputChangeHandler(e),n=r=>{if(r.target!==this.ctx&&!this.ctx.contains(r.target))return;let d=this.$el.closest("form");d&&d.dispatchEvent(new CustomEvent("form-processing-started",{detail:{message:"Uploading file..."}}))};this.ctx.addEventListener("file-upload-started",n),this.ctx.addEventListener("file-upload-success",t),this.ctx.addEventListener("file-url-changed",i),this.ctx.addEventListener("file-removed",a),this.$nextTick(()=>{let r=this.$el.querySelector("uc-form-input input");if(r){r.addEventListener("input",s),r.addEventListener("change",s);let d=new MutationObserver(()=>{s({target:r})});d.observe(r,{attributes:!0,attributeFilter:["value"]}),this.formInputObserver=d}}),this.removeEventListeners=()=>{this.ctx.removeEventListener("file-upload-started",n),this.ctx.removeEventListener("file-upload-success",t),this.ctx.removeEventListener("file-url-changed",i),this.ctx.removeEventListener("file-removed",a);let r=this.$el.querySelector("uc-form-input input");r&&(r.removeEventListener("input",s),r.removeEventListener("change",s)),this.formInputObserver&&(this.formInputObserver.disconnect(),this.formInputObserver=null)}},createFileUploadSuccessHandler(e){let t=null;return i=>{if(i.target.getAttribute("ctx-name")!==this.uniqueContextName&&i.target!==this.ctx&&!this.ctx.contains(i.target))return;let s=this.isWithMetadata?i.detail:i.detail.cdnUrl;this.pendingUploads.push(s),t&&clearTimeout(t),t=setTimeout(()=>{try{let n=this.getCurrentFiles();for(let d of this.pendingUploads)n=this.updateFilesList(n,d);this.updateState(n),this.pendingUploads=[];let r=this.$el.closest("form");r&&r.dispatchEvent(new CustomEvent("form-processing-finished"))}catch(n){console.error("[Uploadcare] Error updating state after upload:",n)}},200)}},createFileUrlChangedHandler(e){let t=null;return i=>{if(i.target.getAttribute("ctx-name")!==this.uniqueContextName&&i.target!==this.ctx&&!this.ctx.contains(i.target))return;let a=i.detail;t&&clearTimeout(t),t=setTimeout(()=>{try{let s=this.getCurrentFiles(),n=this.updateFileUrl(s,a);console.log("[CROP DEBUG JS] File URL changed",{uuid:a.uuid,new_url:a.cdnUrl,has_modifiers:a.cdnUrl?.includes("-/")}),this.updateState(n)}catch(s){console.error("Error updating state after URL change:",s)}},100)}},createFileRemovedHandler(e){let t=null;return i=>{if(i.target.getAttribute("ctx-name")!==this.uniqueContextName&&i.target!==this.ctx&&!this.ctx.contains(i.target))return;let a=i.detail;this.pendingRemovals.push(a),t&&clearTimeout(t),t=setTimeout(()=>{try{let s=this.getCurrentFiles();for(let n of this.pendingRemovals)s=this.removeFile(s,n);this.updateState(s),this.pendingRemovals=[]}catch(s){console.error("Error in handleFileRemoved:",s)}},100)}},createFormInputChangeHandler(e){return t=>{}},getCurrentFiles(){try{let e=this.uploadedFiles?JSON.parse(this.uploadedFiles):[];return Array.isArray(e)?e:[]}catch{return[]}},updateFilesList(e,t){if(this.isMultiple){let i=this.extractUuidFromUrl(t);return e.some(s=>this.extractUuidFromUrl(s)===i)?e:[...e,t]}return[t]},updateFileUrl(e,t){let i=t.uuid;if(!i&&t.cdnUrl&&(i=this.extractUuidFromUrl(t.cdnUrl)),!i)return e;t.uuid||(t={...t,uuid:i});let a=this.findFileIndex(e,i);if(a===-1)return e;let s;if(this.isWithMetadata){let n=e[a];if(typeof n=="string"){let r=this.extractUuidFromUrl(n);n={cdnUrl:n,uuid:r,name:"",size:0,mimeType:"",isImage:!1}}if(s={...n,...t},s.cdnUrl){let r=this.extractModifiersFromUrl(s.cdnUrl);r?s.cdnUrlModifiers=r:(s.cdnUrlModifiers=null,delete s.cdnUrlModifiers)}}else s=t.cdnUrl;if(this.isMultiple){let n=[...e];return n[a]=s,n}return[s]},removeFile(e,t){let i=this.findFileIndex(e,t.uuid);if(i===-1)return e;if(this.isMultiple){let a=[...e];return a.splice(i,1),a}return[]},findFileIndex(e,t){return t?e.findIndex(i=>{let a=i&&typeof i=="object"?i.cdnUrl:i;return this.extractUuidFromUrl(a)===t}):-1},updateState(e){if(!this.isUpdatingState){this.isUpdatingState=!0;try{let t=new Set,i=e.filter(o=>{let f=o&&typeof o=="object"?o.cdnUrl:o,l=this.extractUuidFromUrl(f);return l?t.has(l)?!1:(t.add(l),!0):!0});console.log(`[CROP DEBUG JS] ${this.name} updateState`,{count:i.length,first_has_modifiers:!!i[0]?.cdnUrlModifiers,first_modifiers:i[0]?.cdnUrlModifiers});let a=this.formatFilesForState(i),s=this.buildStateFromFiles(a),n=this.normalizeStateValue(this.uploadedFiles),r=this.normalizeStateValue(s);n!==r&&(this.uploadedFiles=s,this.isLocalUpdate=!0,this.state=this.uploadedFiles,this.isMultiple&&i.length>1&&this.$nextTick(()=>{this.isLocalUpdate=!1}))}finally{this.isUpdatingState=!1}}},formatFilesForState(e){return e?Array.isArray(e)?e.map(t=>this.isWithMetadata?t:t&&typeof t=="object"?t.cdnUrl:t):[]:[]},setupDoneButtonObserver(){let e=this.$el.closest(".uploadcare-wrapper");e&&(this.doneButtonHider=new m(e))},destroy(){this.doneButtonHider&&(this.doneButtonHider.destroy(),this.doneButtonHider=null),this.documentClassObserver&&(this.documentClassObserver.disconnect(),this.documentClassObserver=null),this.formInputObserver&&(this.formInputObserver.disconnect(),this.formInputObserver=null),this.removeEventListeners&&this.removeEventListeners()},extractUuidFromUrl(e){if(!e)return null;let t=e;if(typeof e=="object"){if(e.uuid)return e.uuid;t=e.cdnUrl||""}if(!t||typeof t!="string")return null;if(/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(t))return t;let a=t.match(/\/([a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})(?:\/|$)/i);return a?a[1]:null},extractModifiersFromUrl(e){if(!e||typeof e!="string")return"";let t=this.extractUuidFromUrl(e);if(!t)return"";let i=e.split(t);if(i.length<2)return"";let a=i[1];return a.startsWith("/")&&(a=a.substring(1)),a.endsWith("/")&&(a=a.substring(0,a.length-1)),a},async syncStateWithUploadcare(e){try{let t=this.getCurrentFilesFromUploadcare(e);if(t.length>0){let s=[];for(let n of t){let r=n&&typeof n=="object"?n.cdnUrl:n;if(typeof r=="string"&&r.match(/[a-f0-9-]{36}~[0-9]+/))try{let d=await this.fetchGroupFiles(r);s.push(...d)}catch{s.push(n)}else s.push(n)}t=s}let i=this.formatFilesForState(t),a=this.buildStateFromFiles(i);this.normalizeStateValue(this.uploadedFiles)!==this.normalizeStateValue(a)&&(this.uploadedFiles=a,this.isLocalUpdate=!0,this.state=this.uploadedFiles)}catch(t){console.error("Error syncing state with Uploadcare:",t)}},async fetchGroupFiles(e){let t=e;if(e.includes("ucarecdn.com")||e.includes("ucarecd.net")){let s=e.match(/\/([a-f0-9-]{36}~[0-9]+)/);s&&(t=s[1])}let i=await fetch(`https://upload.uploadcare.com/group/info/?pub_key=${this.publicKey}&group_id=${t}`);if(!i.ok)throw new Error(`Failed to fetch group info: ${i.statusText}`);let a=await i.json();return a.files?a.files.map(s=>{let n=`https://ucarecdn.com/${s.uuid}/`;return this.isWithMetadata?{uuid:s.uuid,cdnUrl:n,name:s.original_filename,size:s.size,mimeType:s.mime_type,isImage:s.is_image}:n}):[]},buildStateFromFiles(e){return this.isMultiple||this.isWithMetadata?JSON.stringify(e):e.length>0?e[0]:""},getCurrentFilesFromUploadcare(e){try{if(e&&typeof e.value=="function"){let i=e.value();if(i)return(Array.isArray(i)?i:this.parseFormInputValue(i)).filter(s=>s!=null)}let t=this.$el.querySelector("uc-form-input input");return t?this.parseFormInputValue(t.value).filter(i=>i!=null):[]}catch(t){return console.error("Error getting current files from Uploadcare:",t),[]}},parseFormInputValue(e){if(!e||typeof e=="string"&&e.trim()==="")return[];if(typeof e=="object")return[e];try{let t=JSON.parse(e);return Array.isArray(t)?t.filter(i=>i!==null&&i!==""):t!==null&&t!==""?[t]:[]}catch{return typeof e=="string"&&e.trim()!==""?[e]:[]}},clearAllFiles(e=!0){let t=this.getUploadcareApi();if(t){try{if(t.collection&&typeof t.collection.clear=="function")t.collection.clear();else if(typeof t.getCollection=="function"){let i=t.getCollection();i&&typeof i.clear=="function"&&i.clear()}}catch{}try{typeof t.removeAllFiles=="function"&&t.removeAllFiles()}catch{}try{typeof t.value=="function"&&t.value(this.isMultiple?[]:"")}catch{}}this.uploadedFiles!==(this.isMultiple||this.isWithMetadata?"[]":"")&&(this.uploadedFiles=this.isMultiple||this.isWithMetadata?"[]":"",this.isLocalUpdate=!0,e&&(this.state=this.uploadedFiles))}}}export{F as default}; diff --git a/packages/filament-uploadcare-field/resources/js/components/uploadcare.js b/packages/filament-uploadcare-field/resources/js/components/uploadcare.js index f4e580c9..23dde988 100644 --- a/packages/filament-uploadcare-field/resources/js/components/uploadcare.js +++ b/packages/filament-uploadcare-field/resources/js/components/uploadcare.js @@ -6,6 +6,7 @@ export default function uploadcareField(config) { } return { + name: config.statePath || 'unknown', state: config.state, statePath: config.statePath, initialState: config.initialState, @@ -32,9 +33,12 @@ export default function uploadcareField(config) { doneButtonHider: null, documentClassObserver: null, formInputObserver: null, + isUpdatingState: false, async init() { + if (this.isContextAlreadyInitialized()) { + return; } @@ -45,6 +49,7 @@ export default function uploadcareField(config) { // ZOMBIE CHECK: If component was removed while loading locales, abort. if (!this.$el.isConnected) { + return; } @@ -73,7 +78,6 @@ export default function uploadcareField(config) { if (!this.state || this.state === '[]' || this.state === '""') { this.$nextTick(() => { if (this.isInitialized) { - // Only verify if we really need to clear visually const current = this.getCurrentFiles(); if (current.length > 0) { this.clearAllFiles(false); @@ -299,7 +303,7 @@ export default function uploadcareField(config) { this.loadInitialState(api); } else if (!this.initialState && !this.stateHasBeenInitialized) { this.stateHasBeenInitialized = true; - this.uploadedFiles = this.isMultiple ? '[]' : ''; + this.uploadedFiles = (this.isMultiple || this.isWithMetadata) ? '[]' : ''; this.isLocalUpdate = true; this.state = this.uploadedFiles; } @@ -398,6 +402,14 @@ export default function uploadcareField(config) { const url = (item && typeof item === 'object') ? item.cdnUrl : item; const cdnUrlModifiers = (item && typeof item === 'object') ? item.cdnUrlModifiers : null; + console.log(`[CROP DEBUG JS] ${this.name} addFile (initialState)`, { + index, + url, + cdnUrlModifiers, + has_modifiers_in_url: url && url.includes('/-/'), + item: JSON.stringify(item).substring(0, 500) // Show partial item to avoid huge logs + }); + if (!url || !this.isValidUrl(url)) { return; } @@ -415,9 +427,10 @@ export default function uploadcareField(config) { // Ensure strict reconstruction if explicit modifiers are provided let modifiers = cdnUrlModifiers; if (modifiers.startsWith('/')) modifiers = modifiers.substring(1); - fullUrl = baseUrl + (baseUrl.endsWith('/') ? '' : '/') + modifiers; + fullUrl = baseUrl + (baseUrl.endsWith('/') ? '' : '/') + (modifiers.startsWith('-/') ? '' : '-/') + modifiers; } + console.log(`[CROP DEBUG JS] ${this.name} api.addFileFromCdnUrl`, { fullUrl }); api.addFileFromCdnUrl(fullUrl); } else { api.addFileFromUuid(uuid); @@ -476,44 +489,7 @@ export default function uploadcareField(config) { }, setupStateWatcher() { - this.$watch('state', (newValue, oldValue) => { - if (this.isLocalUpdate) { - this.isLocalUpdate = false; - return; - } - - // ZOMBIE PROOFING: If component is detached, stop watching/syncing immediately. - if (!this.$el.isConnected) { - return; - } - - if (!this.stateHasBeenInitialized) { - this.stateHasBeenInitialized = true; - return; - } - - if (!newValue || newValue === '[]' || newValue === '""' || (Array.isArray(newValue) && newValue.length === 0)) { - // Only clear if we actually have files to clear. - // Prevents infinite loop of: Server(null) -> Watcher -> clearAllFiles -> State([]) -> Server(null) - if (this.uploadedFiles && this.uploadedFiles !== '[]' && this.uploadedFiles !== '""') { - // Double check we aren't just initialized with empty - const current = this.getCurrentFiles(); - if (current.length > 0) { - this.clearAllFiles(false); - } - } - return; - } - - const normalizedNewValue = this.normalizeStateValue(newValue); - const normalizedUploadedFiles = this.normalizeStateValue(this.uploadedFiles); - - if (normalizedNewValue !== normalizedUploadedFiles) { - if (newValue && newValue !== '[]' && newValue !== '""') { - this.addFilesFromState(newValue); - } - } - }); + // State watcher removed - handled by Alpine's x-model }, parseStateValue(value) { @@ -533,6 +509,17 @@ export default function uploadcareField(config) { const parsed = this.parseStateValue(newValue); let filesToAdd = parsed; + console.log('[CROP DEBUG JS] addFilesFromState called', { + newValue_type: typeof newValue, + parsed_length: Array.isArray(parsed) ? parsed.length : 'not_array', + first_item: parsed && parsed[0] ? { + has_cdnUrlModifiers: !!parsed[0].cdnUrlModifiers, + cdnUrlModifiers: parsed[0].cdnUrlModifiers, + cdnUrl: parsed[0].cdnUrl, + keys: Object.keys(parsed[0]) + } : null + }); + if (!Array.isArray(filesToAdd)) { filesToAdd = [filesToAdd]; } @@ -576,6 +563,12 @@ export default function uploadcareField(config) { }); if (!urlExists) { + console.log('[CROP DEBUG JS] Adding file to Uploadcare', { + url: url, + has_cdnUrlModifiers: !!item.cdnUrlModifiers, + cdnUrlModifiers: item.cdnUrlModifiers, + url_includes_modifiers: url.includes('-/'), + }); try { api.addFileFromCdnUrl(url); } catch (e) { @@ -724,8 +717,6 @@ export default function uploadcareField(config) { } const fileData = this.isWithMetadata ? e.detail : e.detail.cdnUrl; - const fileUuid = this.extractUuidFromUrl(fileData); - this.pendingUploads.push(fileData); if (debounceTimer) { @@ -736,14 +727,13 @@ export default function uploadcareField(config) { try { let currentFiles = this.getCurrentFiles(); - // Add all buffered files for (const file of this.pendingUploads) { - currentFiles = this.updateFilesList(currentFiles, file); + currentFiles = this.updateFilesList(currentFiles, file); } this.updateState(currentFiles); - this.pendingUploads = []; // Clear buffer - + this.pendingUploads = []; + const form = this.$el.closest('form'); if (form) { form.dispatchEvent(new CustomEvent('form-processing-finished')); @@ -759,12 +749,12 @@ export default function uploadcareField(config) { let debounceTimer = null; return (e) => { - const eventCtxName = e.target.getAttribute('ctx-name'); - // CRITICAL ISOLATION CHECK - if (eventCtxName !== this.uniqueContextName && e.target !== this.ctx && !this.ctx.contains(e.target)) return; + if (e.target.getAttribute('ctx-name') !== this.uniqueContextName && e.target !== this.ctx && !this.ctx.contains(e.target)) { + return; + } const fileDetails = e.detail; - + if (debounceTimer) { clearTimeout(debounceTimer); } @@ -773,9 +763,16 @@ export default function uploadcareField(config) { try { const currentFiles = this.getCurrentFiles(); const updatedFiles = this.updateFileUrl(currentFiles, fileDetails); + + console.log('[CROP DEBUG JS] File URL changed', { + uuid: fileDetails.uuid, + new_url: fileDetails.cdnUrl, + has_modifiers: fileDetails.cdnUrl?.includes('-/') + }); + this.updateState(updatedFiles); - } catch (error) { - console.error('Error updating state after URL change:', error); + } catch (n) { + console.error('Error updating state after URL change:', n); } }, 100); }; @@ -785,12 +782,11 @@ export default function uploadcareField(config) { let debounceTimer = null; return (e) => { - const eventCtxName = e.target.getAttribute('ctx-name'); - // CRITICAL ISOLATION CHECK - if (eventCtxName !== this.uniqueContextName && e.target !== this.ctx && !this.ctx.contains(e.target)) return; + if (e.target.getAttribute('ctx-name') !== this.uniqueContextName && e.target !== this.ctx && !this.ctx.contains(e.target)) { + return; + } const removedFile = e.detail; - // Buffer the removal this.pendingRemovals.push(removedFile); if (debounceTimer) { @@ -800,51 +796,36 @@ export default function uploadcareField(config) { debounceTimer = setTimeout(() => { try { let currentFiles = this.getCurrentFiles(); - - // Process all buffered removals - for (const fileToRemove of this.pendingRemovals) { - currentFiles = this.removeFile(currentFiles, fileToRemove); + for (const r of this.pendingRemovals) { + currentFiles = this.removeFile(currentFiles, r); } - this.updateState(currentFiles); - this.pendingRemovals = []; // Clear buffer - } catch (error) { - console.error('Error in handleFileRemoved:', error); + this.pendingRemovals = []; + } catch (n) { + console.error('Error in handleFileRemoved:', n); } }, 100); }; }, createFormInputChangeHandler(api) { - // Deprecated/Secondary: Only use for fallback if state is empty but input has value? - // For now, disabling auto-sync to avoid overriding the event-based source of truth - // unless we strictly handle external changes. - return (e) => { - // no-op or specific logic if needed - }; + return (t) => {}; }, getCurrentFiles() { try { - const files = this.uploadedFiles ? JSON.parse(this.uploadedFiles) : []; + let files = this.uploadedFiles ? JSON.parse(this.uploadedFiles) : []; return Array.isArray(files) ? files : []; - } catch (error) { + } catch (e) { return []; } }, updateFilesList(currentFiles, newFile) { if (this.isMultiple) { - const newUuid = this.extractUuidFromUrl(newFile); - - const isDuplicate = currentFiles.some(file => { - return this.extractUuidFromUrl(file) === newUuid; - }); - - if (!isDuplicate) { - return [...currentFiles, newFile]; - } - return currentFiles; + const uuid = this.extractUuidFromUrl(newFile); + const isDuplicate = currentFiles.some(file => this.extractUuidFromUrl(file) === uuid); + return isDuplicate ? currentFiles : [...currentFiles, newFile]; } return [newFile]; }, @@ -871,7 +852,6 @@ export default function uploadcareField(config) { let originalFile = currentFiles[fileIndex]; // CRITICAL FIX: Ensure originalFile is an object before spreading - // If it's a string (URL), convert it to an object first to prevent character map corruption if (typeof originalFile === 'string') { const uuid = this.extractUuidFromUrl(originalFile); originalFile = { @@ -884,7 +864,6 @@ export default function uploadcareField(config) { }; } - // Merge with existing file to preserve properties like uuid if missing ‘in detail updatedFile = { ...originalFile, ...fileDetails }; // Extract and persist modifiers from the new URL if present @@ -893,9 +872,8 @@ export default function uploadcareField(config) { if (extractedModifiers) { updatedFile.cdnUrlModifiers = extractedModifiers; } else { - // Explicitly clear modifiers if URL is clean (user removed crop) updatedFile.cdnUrlModifiers = null; - delete updatedFile.cdnUrlModifiers; + delete updatedFile.cdnUrlModifiers; } } } else { @@ -903,8 +881,9 @@ export default function uploadcareField(config) { } if (this.isMultiple) { - currentFiles[fileIndex] = updatedFile; - return currentFiles; + const newFiles = [...currentFiles]; + newFiles[fileIndex] = updatedFile; + return newFiles; } return [updatedFile]; }, @@ -914,8 +893,9 @@ export default function uploadcareField(config) { if (index === -1) return currentFiles; if (this.isMultiple) { - currentFiles.splice(index, 1); - return currentFiles; + const newFiles = [...currentFiles]; + newFiles.splice(index, 1); + return newFiles; } return []; }, @@ -930,102 +910,57 @@ export default function uploadcareField(config) { }, updateState(files) { - // Deduplicate by UUID - const processedUuids = new Set(); - const uniqueFiles = files.filter(file => { - const url = (file && typeof file === 'object') ? file.cdnUrl : file; - const uuid = this.extractUuidFromUrl(url); - if (uuid) { - if (processedUuids.has(uuid)) return false; - processedUuids.add(uuid); + if (this.isUpdatingState) return; + this.isUpdatingState = true; + + try { + // Deduplicate by UUID + const processedUuids = new Set(); + const uniqueFiles = files.filter(file => { + const url = (file && typeof file === 'object') ? file.cdnUrl : file; + const uuid = this.extractUuidFromUrl(url); + if (uuid) { + if (processedUuids.has(uuid)) return false; + processedUuids.add(uuid); + return true; + } return true; - } - return true; - }); + }); - const finalFiles = this.formatFilesForState(uniqueFiles); - const newState = JSON.stringify(finalFiles); - const currentFiles = this.getCurrentFiles(); - const currentStateNormalized = JSON.stringify(this.formatFilesForState(currentFiles)); - const newStateNormalized = JSON.stringify(finalFiles); - const hasActuallyChanged = currentStateNormalized !== newStateNormalized; + console.log(`[CROP DEBUG JS] ${this.name} updateState`, { + count: uniqueFiles.length, + first_has_modifiers: !!uniqueFiles[0]?.cdnUrlModifiers, + first_modifiers: uniqueFiles[0]?.cdnUrlModifiers + }); - if (hasActuallyChanged) { - this.uploadedFiles = newState; - this.isLocalUpdate = true; - this.state = this.uploadedFiles; + const finalFiles = this.formatFilesForState(uniqueFiles); + const newState = this.buildStateFromFiles(finalFiles); + + const currentStateNormalized = this.normalizeStateValue(this.uploadedFiles); + const newStateNormalized = this.normalizeStateValue(newState); + const hasActuallyChanged = currentStateNormalized !== newStateNormalized; - if (this.isMultiple && uniqueFiles.length > 1) { - this.$nextTick(() => { - this.isLocalUpdate = false; - }); + if (hasActuallyChanged) { + this.uploadedFiles = newState; + this.isLocalUpdate = true; + this.state = this.uploadedFiles; + + if (this.isMultiple && uniqueFiles.length > 1) { + this.$nextTick(() => { + this.isLocalUpdate = false; + }); + } } + } finally { + this.isUpdatingState = false; } }, formatFilesForState(files) { - // CRITICAL: Ensure files is always an array if (!files) return []; - if (!Array.isArray(files)) { - console.warn('[Uploadcare] formatFilesForState called with non-array:', typeof files, files); - // If it's a string, try to parse it - if (typeof files === 'string') { - try { - const parsed = JSON.parse(files); - if (Array.isArray(parsed)) { - files = parsed; - } else { - return []; - } - } catch { - return []; - } - } else { - return []; - } - } + if (!Array.isArray(files)) return []; return files.map(file => { - // SELF-HEALING: Detect character-mapped strings (e.g. {"0":"h","1":"t".,..}) - if (file && typeof file === 'object' && !file.cdnUrl && !file.uuid && '0' in file) { - const keys = Object.keys(file); - // If it looks like an array-like object with sequential keys (0, 1, 2...) - if (keys.length > 5 && keys.includes('0') && keys.includes('1') && keys.includes('2')) { - // Attempt to reconstruct the string - let reconstructed = ''; - // We can't rely on Object.values order, so we accept iteration if keys are sequential-ish, - // but standard Object.values works for integer keys usually. - // Safer: assume it's array-like - const maxKey = Math.max(...keys.map(k => parseInt(k)).filter(n => !isNaN(n))); - if (maxKey === keys.length - 1) { - const arr = new Array(keys.length); - for (let i = 0; i < keys.length; i++) { - arr[i] = file[i]; - } - reconstructed = arr.join(''); - } else { - // Fallback to simple join if needed - reconstructed = Object.values(file).join(''); - } - - if (reconstructed.match(/^https?:\/\//)) { - console.warn('[Uploadcare] SELF-HEALED CORRUPTED STRING:', reconstructed); - if (this.isWithMetadata) { - const uuid = this.extractUuidFromUrl(reconstructed); - return { - cdnUrl: reconstructed, - uuid: uuid, - name: '', - size: 0, - mimeType: '', - isImage: false - }; - } - return reconstructed; - } - } - } - if (this.isWithMetadata) { return file; } @@ -1074,19 +1009,13 @@ export default function uploadcareField(config) { return null; } - // Check if string is just a UUID const uuidPattern = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i; if (uuidPattern.test(url)) { return url; } const uuidMatch = url.match(/\/([a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})(?:\/|$)/i); - - if (uuidMatch && uuidMatch[1]) { - return uuidMatch[1]; - } - - return null; + return uuidMatch ? uuidMatch[1] : null; }, extractModifiersFromUrl(url) { @@ -1105,24 +1034,20 @@ export default function uploadcareField(config) { return modifiers; }, - async syncStateWithUploadcare(api) { try { let currentFiles = this.getCurrentFilesFromUploadcare(api); - // Handle Group URLs if (currentFiles.length > 0) { const flattenedFiles = []; for (const file of currentFiles) { const url = (file && typeof file === 'object') ? file.cdnUrl : file; - // Check for Group UUID or URL (uuid~count) if (typeof url === 'string' && url.match(/[a-f0-9-]{36}~[0-9]+/)) { try { const groupFiles = await this.fetchGroupFiles(url); flattenedFiles.push(...groupFiles); } catch (e) { - console.error('[Uploadcare] Failed to expand group:', url, e); - flattenedFiles.push(file); // Fallback to original + flattenedFiles.push(file); } } else { flattenedFiles.push(file); @@ -1133,10 +1058,8 @@ export default function uploadcareField(config) { const formattedFiles = this.formatFilesForState(currentFiles); const newState = this.buildStateFromFiles(formattedFiles); - const currentStateNormalized = this.normalizeStateValue(this.uploadedFiles); - const newStateNormalized = this.normalizeStateValue(newState); - if (currentStateNormalized !== newStateNormalized) { + if (this.normalizeStateValue(this.uploadedFiles) !== this.normalizeStateValue(newState)) { this.uploadedFiles = newState; this.isLocalUpdate = true; this.state = this.uploadedFiles; @@ -1147,77 +1070,51 @@ export default function uploadcareField(config) { }, async fetchGroupFiles(groupUrlOrUuid) { - // Extract group ID (uuid~count) let groupId = groupUrlOrUuid; if (groupUrlOrUuid.includes('ucarecdn.com') || groupUrlOrUuid.includes('ucarecd.net')) { const match = groupUrlOrUuid.match(/\/([a-f0-9-]{36}~[0-9]+)/); - if (match) { - groupId = match[1]; - } + if (match) groupId = match[1]; } - // Use Upload API to get group info as CDN endpoint returns HTML widget const response = await fetch(`https://upload.uploadcare.com/group/info/?pub_key=${this.publicKey}&group_id=${groupId}`); - if (!response.ok) { - throw new Error(`Failed to fetch group info: ${response.statusText}`); - } + if (!response.ok) throw new Error(`Failed to fetch group info: ${response.statusText}`); const data = await response.json(); - if (!data.files) { - return []; - } + if (!data.files) return []; - // Map to the format expected by the component return data.files.map(file => { const cdnUrl = `https://ucarecdn.com/${file.uuid}/`; - if (this.isWithMetadata) { - return { - uuid: file.uuid, - cdnUrl: cdnUrl, - name: file.original_filename, - size: file.size, - mimeType: file.mime_type, - isImage: file.is_image, - }; - } - return cdnUrl; + return this.isWithMetadata ? { + uuid: file.uuid, + cdnUrl: cdnUrl, + name: file.original_filename, + size: file.size, + mimeType: file.mime_type, + isImage: file.is_image, + } : cdnUrl; }); }, buildStateFromFiles(formattedFiles) { - if (this.isMultiple) { - return JSON.stringify(formattedFiles); - } - - if (formattedFiles.length > 0) { - return this.isWithMetadata ? JSON.stringify(formattedFiles[0]) : formattedFiles[0]; - } - + if (this.isMultiple || this.isWithMetadata) return JSON.stringify(formattedFiles); + if (formattedFiles.length > 0) return formattedFiles[0]; return ''; }, getCurrentFilesFromUploadcare(api) { try { - // Use widget API directly if available if (api && typeof api.value === 'function') { const value = api.value(); if (value) { - if (Array.isArray(value)) { - return value.filter(item => item !== null && item !== undefined); - } - const parsed = this.parseFormInputValue(value); - return parsed.filter(item => item !== null && item !== undefined); + const files = Array.isArray(value) ? value : this.parseFormInputValue(value); + return files.filter(item => item != null); } - return []; } const formInput = this.$el.querySelector('uc-form-input input'); - if (formInput) { - const parsed = this.parseFormInputValue(formInput.value); - return parsed.filter(item => item !== null && item !== undefined); + return this.parseFormInputValue(formInput.value).filter(item => item != null); } - return []; } catch (error) { console.error('Error getting current files from Uploadcare:', error); @@ -1226,92 +1123,37 @@ export default function uploadcareField(config) { }, parseFormInputValue(inputValue) { - if (!inputValue || (typeof inputValue === 'string' && inputValue.trim() === '')) { - return []; - } - - // If it's a raw Uploadcare object/collection - if (typeof inputValue === 'object') { - return [inputValue]; // Or handle collection - } + if (!inputValue || (typeof inputValue === 'string' && inputValue.trim() === '')) return []; + if (typeof inputValue === 'object') return [inputValue]; try { const parsed = JSON.parse(inputValue); - - if (Array.isArray(parsed)) { - return parsed.filter(file => file !== null && file !== ''); - } - - if (parsed !== null && parsed !== '') { - return [parsed]; - } - - return []; + if (Array.isArray(parsed)) return parsed.filter(file => file !== null && file !== ''); + return (parsed !== null && parsed !== '') ? [parsed] : []; } catch (e) { - if (typeof inputValue === 'string' && inputValue.trim() !== '') { - return [inputValue]; - } - - return []; + return (typeof inputValue === 'string' && inputValue.trim() !== '') ? [inputValue] : []; } }, clearAllFiles(emitStateChange = true) { - const path = this.statePath || 'unknown'; - const api = this.getUploadcareApi(); if (api) { - - // 1. Try Collection Clear (Standard for Blocks) try { - if (api.collection && typeof api.collection.clear === 'function') { - api.collection.clear(); - } else if (typeof api.getCollection === 'function') { - const coll = api.getCollection(); - if (coll && typeof coll.clear === 'function') coll.clear(); - } - } catch (e) { - console.warn(`[Uploadcare ${path}] collection clear error:`, e); - } - - // 2. Try removeAllFiles - try { - if (typeof api.removeAllFiles === 'function') { - api.removeAllFiles(); + if (api.collection && typeof api.collection.clear === 'function') api.collection.clear(); + else if (typeof api.getCollection === 'function') { + const collection = api.getCollection(); + if (collection && typeof collection.clear === 'function') collection.clear(); } } catch (e) {} - - // 3. Try value reset - try { - if (typeof api.value === 'function') { - api.value([]); - } else { - api.value = []; - } - } catch (e) {} - } else { - console.warn(`[Uploadcare ${path}] No API discovered for clearing`); + try { if (typeof api.removeAllFiles === 'function') api.removeAllFiles(); } catch (e) {} + try { if (typeof api.value === 'function') api.value(this.isMultiple ? [] : ''); } catch (e) {} } - - // Also try to reach into form-input if possible - try { - const formInput = this.$el.querySelector('uc-form-input'); - if (formInput && typeof formInput.getAPI === 'function') { - const fiApi = formInput.getAPI(); - if (fiApi) { - fiApi.value = this.isMultiple ? [] : ''; - } - } - } catch (e) {} - - if (this.uploadedFiles !== (this.isMultiple ? '[]' : '')) { - this.uploadedFiles = this.isMultiple ? '[]' : ''; + + if (this.uploadedFiles !== ((this.isMultiple || this.isWithMetadata) ? '[]' : '')) { + this.uploadedFiles = (this.isMultiple || this.isWithMetadata) ? '[]' : ''; this.isLocalUpdate = true; - - if (emitStateChange) { - this.state = this.uploadedFiles; - } + if (emitStateChange) this.state = this.uploadedFiles; } } }; -} \ No newline at end of file +} diff --git a/packages/filament-uploadcare-field/src/Forms/Components/Uploadcare.php b/packages/filament-uploadcare-field/src/Forms/Components/Uploadcare.php index 6d4e114c..f250e68d 100644 --- a/packages/filament-uploadcare-field/src/Forms/Components/Uploadcare.php +++ b/packages/filament-uploadcare-field/src/Forms/Components/Uploadcare.php @@ -3,7 +3,9 @@ namespace Backstage\Uploadcare\Forms\Components; use Backstage\Uploadcare\Enums\Style; +use Backstage\UploadcareField\Uploadcare as Factory; use Filament\Forms\Components\Field; +use Illuminate\Database\Eloquent\Model; use InvalidArgumentException; class Uploadcare extends Field @@ -22,6 +24,8 @@ class Uploadcare extends Field protected bool $withMetadata = false; + protected string $fieldUlid = ''; + protected Style $uploaderStyle = Style::INLINE; protected array $sourceList = [ @@ -96,6 +100,33 @@ public function getPublicKey(): string return $this->publicKey; } + public function fieldUlid(string $ulid): static + { + $this->fieldUlid = $ulid; + + return $this; + } + + public function getFieldUlid(): string + { + if ($this->fieldUlid) { + return $this->fieldUlid; + } + + $name = $this->getName(); + if (str_contains($name, '.')) { + $parts = explode('.', $name); + foreach ($parts as $part) { + if (preg_match('/^[0-9A-HJKMNP-TV-Z]{26}$/i', $part)) { + return $part; + } + } + return end($parts); + } + + return $name; + } + public function isMultiple(): bool { return $this->multiple; @@ -281,52 +312,70 @@ public function maxLocalFileSize(string $size): static public function getState(): mixed { $state = parent::getState(); + + \Log::info("[CROP DEBUG] Uploadcare::getState called", [ + 'field' => $this->getName(), + 'type' => gettype($state), + 'is_array' => is_array($state), + 'raw_state' => is_string($state) ? (json_validate($state) ? 'JSON STRING' : substr($state, 0, 100)) : (is_array($state) ? (array_is_list($state) ? 'LIST count ' . count($state) : 'ASSOC keys ' . implode(',', array_keys($state))) : $state), + ]); // Handle double-encoded JSON or JSON strings if (is_string($state) && json_validate($state)) { - $state = json_decode($state, true); + $decoded = json_decode($state, true); + if (is_array($decoded)) { + $state = $decoded; + } } - // Resolve Backstage Media ULIDs (26-char) into Uploadcare CDN URLs / UUIDs, - // so the widget can show a preview even when the database stores ULIDs. - if (is_array($state) && ! empty($state) && self::isListOfUlids($state)) { - $resolved = self::resolveUlidsToUploadcareState($state); - if (! empty($resolved)) { - $state = $resolved; - } + if ($state === null || $state === '' || $state === []) { + return $state; } - // Handle array of file objects (extract UUIDs / URLs) - if (is_array($state) && ! empty($state)) { - $values = self::extractValues($state); - if (! empty($values)) { - $state = $values; - } + // If it's already a rich object (single file field), we're done resolving. + if (is_array($state) && ! array_is_list($state) && (isset($state['uuid']) || isset($state['cdnUrl']))) { + return $state; } - if ($state === '[]' || $state === '""' || $state === null || $state === '') { - return null; + // If it's a list where the first item is already a rich object, we're done resolving. + if (is_array($state) && array_is_list($state) && ! empty($state) && is_array($state[0]) && (isset($state[0]['cdnUrl']) || isset($state[0]['uuid']))) { + return $this->isMultiple() ? $state : $state[0]; } + // Normalize to list for resolution to avoid shredding associative arrays + $wasList = is_array($state) && array_is_list($state); + $items = $wasList ? $state : [$state]; + + // Resolve Backstage Media ULIDs or Models into Uploadcare rich objects. + $resolved = self::resolveUlidsToUploadcareState($items, $this->getRecord(), $this->getFieldUlid()); + // Transform URLs from database format back to ucarecdn.com format for the widget - if ($this->shouldTransformUrlsForDb() && ! empty($state)) { - $state = $this->transformUrlsFromDb($state); + if ($this->shouldTransformUrlsForDb()) { + $resolved = $this->transformUrlsFromDb($resolved); } - if (! is_array($state)) { - $state = [$state]; + \Log::info("[CROP DEBUG] Uploadcare::getState result", [ + 'field' => $this->getName(), + 'resolved_count' => count($resolved), + 'first_url' => is_array($resolved[0] ?? null) ? ($resolved[0]['cdnUrl'] ?? null) : ($resolved[0] ?? null), + ]); + + // Final return format based on isMultiple() + if ($this->isMultiple()) { + return array_values($resolved); } - return $state; + return $resolved[0] ?? null; } - private static function isListOfUlids(array $state): bool { - if (! isset($state[0]) || ! is_string($state[0])) { - return false; - } + if (empty($state) || ! array_is_list($state)) return false; + + $first = $state[0]; + if ($first instanceof Model) return true; + if (!is_string($first)) return false; - return (bool) preg_match('/^[0-9A-HJKMNP-TV-Z]{26}$/i', $state[0]); + return (bool) preg_match('/^[0-9A-HJKMNP-TV-Z]{26}$/i', $first); } private static function extractUuidFromString(string $value): ?string @@ -338,75 +387,160 @@ private static function extractUuidFromString(string $value): ?string return null; } - private static function resolveUlidsToUploadcareState(array $ulids): array + private static function resolveUlidsToUploadcareState(array $items, ?Model $record = null, ?string $fieldName = null): array { - $mediaModel = config('backstage.media.model', \Backstage\Media\Models\Media::class); - - if (! is_string($mediaModel) || ! class_exists($mediaModel)) { + if (empty($items)) { return []; } - $mediaItems = $mediaModel::whereIn('ulid', array_filter($ulids, 'is_string')) - ->get() - ->keyBy('ulid'); - + \Log::info("[CROP DEBUG] resolveUlidsToUploadcareState starting", [ + 'field' => $fieldName, + 'count' => count($items), + ]); $resolved = []; - - foreach ($ulids as $ulid) { - if (! is_string($ulid)) { - continue; - } - - $media = $mediaItems->get($ulid); - if (! $media) { - continue; + $ulidsToResolve = []; + $preResolvedModels = []; + + foreach ($items as $index => $item) { + if ($item instanceof Model) { + $preResolvedModels[$index] = $item; + } elseif (is_string($item) && ! empty($item)) { + $ulidsToResolve[$index] = $item; + } elseif (is_array($item)) { + if (isset($item['cdnUrl']) || isset($item['uuid'])) { + $resolved[$index] = $item; // Already a rich object + } elseif (isset($item['ulid'])) { + $ulidsToResolve[$index] = $item['ulid']; + } } + } - $metadata = $media->metadata ?? null; - if (is_string($metadata)) { - $metadata = json_decode($metadata, true); + if (! empty($ulidsToResolve)) { + $mediaItems = null; + $mediaModel = config('backstage.media.model', \Backstage\Media\Models\Media::class); + + // If we have a record and it has a values relationship (Backstage CMS), use it to get pivot metadata + if ($record && $fieldName && method_exists($record, 'values')) { + try { + $fieldSlug = $fieldName; + if (str_contains($fieldName, '.')) { + $parts = explode('.', $fieldName); + foreach ($parts as $part) { + if (preg_match('/^[0-9A-HJKMNP-TV-Z]{26}$/i', $part)) { + $fieldSlug = $part; + break; + } + } + if ($fieldSlug === $fieldName) { + $fieldSlug = end($parts); + } + } + + \Log::info("[CROP DEBUG] resolveUlidsToUploadcareState searching for CFV", [ + 'record_ulid' => $record->ulid, + 'field_slug' => $fieldSlug, + 'original_field_name' => $fieldName, + ]); + + $fieldValue = $record->values() + ->where(function ($query) use ($fieldSlug) { + $query->whereHas('field', function ($q) use ($fieldSlug) { + $q->where('slug', $fieldSlug) + ->orWhere('ulid', $fieldSlug); + }) + ->orWhere('ulid', $fieldSlug); + }) + ->first(); + + if ($fieldValue) { + $mediaItems = $fieldValue->media() + ->withPivot(['meta', 'position']) + ->whereIn('media_ulid', array_values($ulidsToResolve)) + ->get() + ->keyBy('ulid'); + + \Log::info("[CROP DEBUG] resolveUlidsToUploadcareState: Loaded " . $mediaItems->count() . " media items via CFV"); + } + } catch (\Exception $e) {} } - $metadata = is_array($metadata) ? $metadata : []; - $editMeta = $media->edit ?? null; - if (is_string($editMeta)) { - $editMeta = json_decode($editMeta, true); - } - if (is_array($editMeta)) { - $metadata = array_merge($metadata, $editMeta); + // Fallback for record media or direct query + if ((! $mediaItems || $mediaItems->isEmpty()) && $record && method_exists($record, 'media')) { + try { + $mediaItems = $record->media() + ->withPivot(['meta', 'position']) + ->whereIn('media_ulid', array_values($ulidsToResolve)) + ->get() + ->keyBy('ulid'); + \Log::info("[CROP DEBUG] resolveUlidsToUploadcareState: Loaded " . ($mediaItems ? $mediaItems->count() : 0) . " media items via record fallback"); + } catch (\Exception $e) {} } - $cdnUrl = $metadata['cdnUrl'] ?? ($metadata['fileInfo']['cdnUrl'] ?? null); - $uuid = $metadata['uuid'] ?? ($metadata['fileInfo']['uuid'] ?? null); - - if (! $uuid && is_string($media->filename ?? null)) { - $uuid = self::extractUuidFromString($media->filename); - } - if (! $uuid && is_string($cdnUrl)) { - $uuid = self::extractUuidFromString($cdnUrl); + if (! $mediaItems || $mediaItems->isEmpty()) { + $mediaItems = $mediaModel::whereIn('ulid', array_values($ulidsToResolve))->get()->keyBy('ulid'); + \Log::info("[CROP DEBUG] resolveUlidsToUploadcareState: Loaded " . ($mediaItems ? $mediaItems->count() : 0) . " media items via direct query fallback"); } - if ((! $cdnUrl || ! filter_var($cdnUrl, FILTER_VALIDATE_URL)) && $uuid) { - $cdnUrl = 'https://ucarecdn.com/' . $uuid . '/'; + foreach ($ulidsToResolve as $index => $ulid) { + $media = $mediaItems->get($ulid); + if ($media) { + $resolved[$index] = Factory::mapMediaToValue($media); + } else { + $resolved[$index] = $ulid; // Keep as string if not found + } } + } - if (is_string($cdnUrl) && filter_var($cdnUrl, FILTER_VALIDATE_URL)) { - $resolved[] = $cdnUrl; - } elseif ($uuid) { - $resolved[] = $uuid; - } + foreach ($preResolvedModels as $index => $model) { + $resolved[$index] = Factory::mapMediaToValue($model); } - return $resolved; + ksort($resolved); // Restore original order + + $final = array_values($resolved); + + // Deduplicate by UUID to prevent same file appearing twice + $uniqueUuids = []; + $final = array_filter($final, function($item) use (&$uniqueUuids) { + $uuid = is_array($item) ? ($item['uuid'] ?? null) : (is_string($item) ? $item : null); + if (!$uuid) return true; + if (in_array($uuid, $uniqueUuids)) return false; + $uniqueUuids[] = $uuid; + return true; + }); + $final = array_values($final); + + \Log::info("[CROP DEBUG] resolveUlidsToUploadcareState finished", [ + 'field' => $fieldName, + 'count' => count($final), + ]); + + return $final; } private static function extractValues(array $state): array { + if (! array_is_list($state)) { + if (isset($state['uuid']) || isset($state['cdnUrl'])) { + return [$state]; + } + return []; + } + return array_values(array_filter(array_map(function ($item) { if (is_string($item)) { return $item; } + // If it's already a structured object, keep it. + if (is_array($item) && (isset($item['cdnUrl']) || isset($item['uuid']))) { + return $item; + } + + if (is_object($item) && (isset($item->cdnUrl) || isset($item->uuid))) { + return $item; + } + // Allow objects (Models) if they implement ArrayAccess or are just objects we can read properties from if (! is_array($item) && ! is_object($item)) { return null; @@ -456,25 +590,29 @@ private function transformUrls($value, string $from, string $to): mixed return $v; }; - $replaceCdn = function ($v) use ($from, $to) { + $replaceCdn = function ($v) use ($from, $to, &$replaceCdn) { if (is_string($v)) { return str_replace($from, $to, $v); } + if (is_array($v)) { + if (array_is_list($v)) { + return array_map($replaceCdn, $v); + } + + // Protect associative arrays from shredding via array_map + foreach ($v as $key => $subValue) { + $v[$key] = $replaceCdn($subValue); + } + return $v; + } + return $v; }; $value = $decodeIfJson($value); - if (is_string($value)) { - return $replaceCdn($value); - } - - if (is_array($value)) { - return array_map($replaceCdn, $value); - } - - return $value; + return $replaceCdn($value); } public function transformUrlsFromDb($value): mixed diff --git a/packages/uploadcare-field/src/Observers/ContentFieldValueObserver.php b/packages/uploadcare-field/src/Observers/ContentFieldValueObserver.php index fc885d8c..cf3265fe 100644 --- a/packages/uploadcare-field/src/Observers/ContentFieldValueObserver.php +++ b/packages/uploadcare-field/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/packages/uploadcare-field/src/Uploadcare.php b/packages/uploadcare-field/src/Uploadcare.php index f40f44ea..5d91d01c 100755 --- a/packages/uploadcare-field/src/Uploadcare.php +++ b/packages/uploadcare-field/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 5cf1f57e9b11a046f2bc72f6a4756b708f53fe83 Mon Sep 17 00:00:00 2001 From: Baspa Date: Mon, 5 Jan 2026 16:33:37 +0100 Subject: [PATCH 26/43] fix: clearing files on "create & create another" --- .../dist/filament-uploadcare-field.js | 2 +- .../resources/js/components/uploadcare.js | 18 +++++++++++++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/packages/filament-uploadcare-field/resources/dist/filament-uploadcare-field.js b/packages/filament-uploadcare-field/resources/dist/filament-uploadcare-field.js index ab6d3875..a353aa3c 100644 --- a/packages/filament-uploadcare-field/resources/dist/filament-uploadcare-field.js +++ b/packages/filament-uploadcare-field/resources/dist/filament-uploadcare-field.js @@ -1 +1 @@ -var m=class{constructor(e){this.wrapper=e,this.observer=null,this.init()}init(){this.hideDoneButtons(),this.setupObserver()}setupObserver(){this.observer=new MutationObserver(e=>{e.forEach(t=>{t.type==="childList"&&t.addedNodes.forEach(i=>{if(i.nodeType===Node.ELEMENT_NODE){i.classList&&i.classList.contains("uc-done-btn")&&this.hideDoneButton(i);let a=i.querySelectorAll&&i.querySelectorAll(".uc-done-btn");a&&a.forEach(s=>this.hideDoneButton(s))}})})}),this.wrapper&&this.observer.observe(this.wrapper,{childList:!0,subtree:!0})}hideDoneButtons(){document.querySelectorAll(".uc-done-btn").forEach(t=>this.hideDoneButton(t))}hideDoneButton(e){e&&(e.style.display="none",e.style.visibility="hidden",e.style.opacity="0",e.style.pointerEvents="none",e.style.position="absolute",e.style.width="0",e.style.height="0",e.style.overflow="hidden",e.style.clip="rect(0, 0, 0, 0)",e.style.margin="0",e.style.padding="0",e.style.border="0",e.style.background="transparent",e.style.color="transparent",e.style.fontSize="0",e.style.lineHeight="0")}destroy(){this.observer&&(this.observer.disconnect(),this.observer=null)}};function F(u){return window._initializedUploadcareContexts||(window._initializedUploadcareContexts=new Set),{name:u.statePath||"unknown",state:u.state,statePath:u.statePath,initialState:u.initialState,publicKey:u.publicKey,isMultiple:u.isMultiple,multipleMin:u.multipleMin,multipleMax:u.multipleMax,isImagesOnly:u.isImagesOnly,accept:u.accept,sourceList:u.sourceList,uploaderStyle:u.uploaderStyle,isWithMetadata:u.isWithMetadata,localeName:u.localeName||"en",uploadedFiles:"",ctx:null,removeEventListeners:null,uniqueContextName:u.uniqueContextName,pendingUploads:[],pendingRemovals:[],isInitialized:!1,stateHasBeenInitialized:!1,isStateWatcherActive:!1,isLocalUpdate:!1,doneButtonHider:null,documentClassObserver:null,formInputObserver:null,isUpdatingState:!1,async init(){this.isContextAlreadyInitialized()||(this.markContextAsInitialized(),this.applyTheme(),await this.loadAllLocales(),this.$el.isConnected&&(this.setupStateWatcher(),this.$el.addEventListener("uploadcare-state-updated",e=>{let t=e.detail.uuid;t&&this.isInitialized?this.loadFileFromUuid(t):t&&this.$nextTick(()=>{this.isInitialized&&this.loadFileFromUuid(t)})}),this.initUploadcare(),this.setupThemeObservers(),this.setupDoneButtonObserver(),(!this.state||this.state==="[]"||this.state==='""')&&this.$nextTick(()=>{this.isInitialized&&this.getCurrentFiles().length>0&&this.clearAllFiles(!1)})))},isContextAlreadyInitialized(){return window._initializedUploadcareContexts.has(this.uniqueContextName)},markContextAsInitialized(){window._initializedUploadcareContexts.add(this.uniqueContextName)},async loadAllLocales(){window._uploadcareAllLocalesLoaded||await new Promise(t=>{if(window._uploadcareAllLocalesLoaded){t();return}let i=setInterval(()=>{window._uploadcareAllLocalesLoaded&&(clearInterval(i),t())},100);setTimeout(()=>{clearInterval(i),t()},5e3)});let e=["de","es","fr","he","it","nl","pl","pt","ru","tr","uk","zh-TW","zh"];document.querySelectorAll("uc-config[data-locale-name]").forEach(t=>{let i=t.getAttribute("data-locale-name");i&&e.includes(i)&&!t.getAttribute("locale-name")&&t.setAttribute("locale-name",i)})},async loadLocale(){if(this.localeName==="en"||this.localeLoaded)return;if(window._uploadcareLocales&&window._uploadcareLocales.has(this.localeName)){this.localeLoaded=!0;return}if(window._uploadcareLocales||(window._uploadcareLocales=new Set),!!["de","es","fr","he","it","nl","pl","pt","ru","tr","uk","zh-TW","zh"].includes(this.localeName))try{let i=await import(`https://cdn.jsdelivr.net/npm/@uploadcare/file-uploader@v1/locales/file-uploader/${this.localeName}.js`),a=i.default||i,s=()=>{let r=customElements.get("uc-file-uploader-inline")||customElements.get("uc-file-uploader-regular")||customElements.get("uc-file-uploader-minimal");return r&&r.UC?r.UC:window.UC},n=()=>{let r=s();return r&&typeof r.defineLocale=="function"?(r.defineLocale(this.localeName,a),window._uploadcareLocales.add(this.localeName),this.localeLoaded=!0,!0):!1};if(!n()){let r=0,d=50,o=setInterval(()=>{r++,(n()||r>=d)&&clearInterval(o)},100)}}catch(t){console.error("[Uploadcare Locale JS] Failed to load locale:",this.localeName,t)}},applyTheme(){let e=this.getCurrentTheme();this.$el.querySelectorAll(`uc-file-uploader-${this.uploaderStyle}`).forEach(i=>{i.classList.remove("uc-dark","uc-light"),i.classList.add(`uc-${e}`)})},getCurrentTheme(){return document.documentElement.classList.contains("dark")?"dark":"light"},setupThemeObservers(){window.addEventListener("storage",this.handleThemeStorageChange.bind(this)),window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change",this.handleSystemThemeChange.bind(this)),this.setupDocumentClassObserver()},handleThemeStorageChange(e){e.key==="theme"&&this.applyTheme()},handleSystemThemeChange(){localStorage.getItem("theme")==="system"&&this.applyTheme()},setupDocumentClassObserver(){this.documentClassObserver=new MutationObserver(e=>{e.forEach(t=>{if(t.type==="attributes"&&t.attributeName==="class"){let i=document.documentElement.classList.contains("dark"),a=t.oldValue&&t.oldValue.includes("dark");i!==a&&this.applyTheme()}})}),this.documentClassObserver.observe(document.documentElement,{attributes:!0,attributeOldValue:!0,attributeFilter:["class"]})},initUploadcare(){this.removeEventListeners&&this.removeEventListeners(),this.initializeUploader()},initializeUploader(e=0,t=10){if(e>=t)return;this.ctx=this.$el.querySelector(`uc-upload-ctx-provider[ctx-name="${this.uniqueContextName}"]`);let i=this.getUploadcareApi();if(!this.isValidContext(i)){setTimeout(()=>this.initializeUploader(e+1,t),100);return}this.markAsInitialized(),this.removeRequiredAttributes(),this.initializeState(i),this.setupEventListeners(i)},getUploadcareApi(){try{return this.ctx?.getAPI()}catch{return null}},isValidContext(e){return this.ctx&&e&&e.addFileFromCdnUrl},markAsInitialized(){this.isInitialized=!0},removeRequiredAttributes(){setTimeout(()=>{let e=this.$el.closest("uc-config");document.querySelectorAll("uc-form-input input[required]").forEach(i=>i.removeAttribute("required"))},100)},initializeState(e){this.initialState&&!this.stateHasBeenInitialized&&!this.uploadedFiles?this.loadInitialState(e):!this.initialState&&!this.stateHasBeenInitialized&&(this.stateHasBeenInitialized=!0,this.uploadedFiles=this.isMultiple||this.isWithMetadata?"[]":"",this.isLocalUpdate=!0,this.state=this.uploadedFiles)},loadInitialState(e){try{let t=this.parseInitialState();this.addFilesFromInitialState(e,t),this.stateHasBeenInitialized=!0,this.isLocalUpdate=!0,this.state=this.uploadedFiles}catch(t){console.error("Error parsing initialState:",t)}},parseInitialState(){let e=i=>{if(typeof i=="string")try{let a=JSON.parse(i);if(typeof a=="string")try{a=JSON.parse(a)}catch{}return a}catch{return i}return i};return this.initialState&&this.initialState&&typeof this.initialState=="object"&&!Array.isArray(this.initialState)&&(this.initialState=[this.initialState]),this.parseStateValue(this.initialState)},addFilesFromInitialState(e,t){let i=[];if(t&&t&&typeof t=="object"&&!Array.isArray(t))try{i=Array.from(t)}catch{i=[t]}else Array.isArray(t)?i=t:t&&(i=[t]);if(Array.isArray(i)&&i.length===1&&Array.isArray(i[0])&&(i=i[0]),Array.isArray(i)&&i.length===1&&typeof i[0]=="string")try{let r=JSON.parse(i[0]);i=Array.isArray(r)?r:[r]}catch{}if(!Array.isArray(i)||i.length===0)return;Array.isArray(i)||(i=[i]);let a=(r,d=0)=>{if(!r)return;if(Array.isArray(r)){r.forEach((h,c)=>{a(h,`${d}.${c}`)});return}if(typeof r=="string")try{let h=JSON.parse(r);a(h,d);return}catch{}let o=r&&typeof r=="object"?r.cdnUrl:r,f=r&&typeof r=="object"?r.cdnUrlModifiers:null;if(console.log(`[CROP DEBUG JS] ${this.name} addFile (initialState)`,{index:d,url:o,cdnUrlModifiers:f,has_modifiers_in_url:o&&o.includes("/-/"),item:JSON.stringify(r).substring(0,500)}),!o||!this.isValidUrl(o))return;let l=this.extractUuidFromUrl(o);if(l&&typeof e.addFileFromUuid=="function")try{if((f||o&&o.includes("/-/"))&&typeof e.addFileFromCdnUrl=="function"){let c=o;if(f){let y=o.split("/-/")[0],p=f;p.startsWith("/")&&(p=p.substring(1)),c=y+(y.endsWith("/")?"":"/")+(p.startsWith("-/")?"":"-/")+p}console.log(`[CROP DEBUG JS] ${this.name} api.addFileFromCdnUrl`,{fullUrl:c}),e.addFileFromCdnUrl(c)}else e.addFileFromUuid(l)}catch{}else console.error(l?"addFileFromUuid method not available on API":`Could not extract UUID from URL: ${o}`)};i.forEach(a);let s=i.map(r=>{let d=r;if(r&&typeof r=="object")return r.uuid||(r.uuid=this.extractUuidFromUrl(r.cdnUrl)),r;if(typeof r=="string"){let o=this.extractUuidFromUrl(r);return{cdnUrl:r,uuid:o,name:"",size:0,mimeType:"",isImage:!1}}return r}),n=this.formatFilesForState(s);this.uploadedFiles=JSON.stringify(n),this.initialState=this.uploadedFiles},isValidUrl(e){if(!e||typeof e!="string")return!1;try{return new URL(e),!0}catch{return!1}},setupStateWatcher(){},parseStateValue(e){if(!e)return null;try{return typeof e=="string"?JSON.parse(e):e}catch{return e}},addFilesFromState(e){let t=this.parseStateValue(e),i=t;if(console.log("[CROP DEBUG JS] addFilesFromState called",{newValue_type:typeof e,parsed_length:Array.isArray(t)?t.length:"not_array",first_item:t&&t[0]?{has_cdnUrlModifiers:!!t[0].cdnUrlModifiers,cdnUrlModifiers:t[0].cdnUrlModifiers,cdnUrl:t[0].cdnUrl,keys:Object.keys(t[0])}:null}),Array.isArray(i)||(i=[i]),i=i.filter(l=>l!=null),i.length===0)return!1;let a=this.getUploadcareApi();if(!a||typeof a.addFileFromCdnUrl!="function")return!1;let n=this.getCurrentFiles().map(l=>l?l&&typeof l=="object"?l.cdnUrl:l:null).filter(Boolean);i.forEach((l,h)=>{if(!l){console.warn(`[Uploadcare] Skipping null item at index ${h}`);return}let c=l&&typeof l=="object"?l.cdnUrl:l;if(c&&typeof c=="string"&&(c.includes("ucarecdn.com")||c.includes("ucarecd.net"))&&!n.some(p=>{let U=this.extractUuidFromUrl(c),g=this.extractUuidFromUrl(p);return U&&g&&U===g})){console.log("[CROP DEBUG JS] Adding file to Uploadcare",{url:c,has_cdnUrlModifiers:!!l.cdnUrlModifiers,cdnUrlModifiers:l.cdnUrlModifiers,url_includes_modifiers:c.includes("-/")});try{a.addFileFromCdnUrl(c)}catch(p){console.error("[Uploadcare] Failed to add file from URL:",c,p)}}});let r=[],d=new Set,o=l=>{if(!l)return;let h=l&&typeof l=="object"?l.cdnUrl:l,c=this.extractUuidFromUrl(h);c&&!d.has(c)?(d.add(c),this.isWithMetadata&&typeof l!="object"?r.push({cdnUrl:l,uuid:c,name:"",size:0,mimeType:"",isImage:!1}):r.push(l)):c||r.push(l)},f=this.parseStateValue(e)||[];return(Array.isArray(f)?f:[f]).forEach(o),this.uploadedFiles=JSON.stringify(r),this.isLocalUpdate=!0,!0},normalizeStateValue(e){if(!e)return"";try{let t=typeof e=="string"?JSON.parse(e):e;if(Array.isArray(t)&&t.every(s=>typeof s=="string"||typeof s=="object"&&s!==null&&("cdnUrl"in s||"uuid"in s)))return JSON.stringify(t);let i=this.formatFilesForState(t);return JSON.stringify(i)}catch(t){return console.error("[Uploadcare] normalizeStateValue error",t),e}},isStateChanged(){let e=this.normalizeStateValue(this.state),t=this.normalizeStateValue(this.initialState);return e!==t},setupEventListeners(e){this.pendingUploads=[],this.pendingRemovals=[];let t=this.createFileUploadSuccessHandler(e),i=this.createFileUrlChangedHandler(e),a=this.createFileRemovedHandler(e),s=this.createFormInputChangeHandler(e),n=r=>{if(r.target!==this.ctx&&!this.ctx.contains(r.target))return;let d=this.$el.closest("form");d&&d.dispatchEvent(new CustomEvent("form-processing-started",{detail:{message:"Uploading file..."}}))};this.ctx.addEventListener("file-upload-started",n),this.ctx.addEventListener("file-upload-success",t),this.ctx.addEventListener("file-url-changed",i),this.ctx.addEventListener("file-removed",a),this.$nextTick(()=>{let r=this.$el.querySelector("uc-form-input input");if(r){r.addEventListener("input",s),r.addEventListener("change",s);let d=new MutationObserver(()=>{s({target:r})});d.observe(r,{attributes:!0,attributeFilter:["value"]}),this.formInputObserver=d}}),this.removeEventListeners=()=>{this.ctx.removeEventListener("file-upload-started",n),this.ctx.removeEventListener("file-upload-success",t),this.ctx.removeEventListener("file-url-changed",i),this.ctx.removeEventListener("file-removed",a);let r=this.$el.querySelector("uc-form-input input");r&&(r.removeEventListener("input",s),r.removeEventListener("change",s)),this.formInputObserver&&(this.formInputObserver.disconnect(),this.formInputObserver=null)}},createFileUploadSuccessHandler(e){let t=null;return i=>{if(i.target.getAttribute("ctx-name")!==this.uniqueContextName&&i.target!==this.ctx&&!this.ctx.contains(i.target))return;let s=this.isWithMetadata?i.detail:i.detail.cdnUrl;this.pendingUploads.push(s),t&&clearTimeout(t),t=setTimeout(()=>{try{let n=this.getCurrentFiles();for(let d of this.pendingUploads)n=this.updateFilesList(n,d);this.updateState(n),this.pendingUploads=[];let r=this.$el.closest("form");r&&r.dispatchEvent(new CustomEvent("form-processing-finished"))}catch(n){console.error("[Uploadcare] Error updating state after upload:",n)}},200)}},createFileUrlChangedHandler(e){let t=null;return i=>{if(i.target.getAttribute("ctx-name")!==this.uniqueContextName&&i.target!==this.ctx&&!this.ctx.contains(i.target))return;let a=i.detail;t&&clearTimeout(t),t=setTimeout(()=>{try{let s=this.getCurrentFiles(),n=this.updateFileUrl(s,a);console.log("[CROP DEBUG JS] File URL changed",{uuid:a.uuid,new_url:a.cdnUrl,has_modifiers:a.cdnUrl?.includes("-/")}),this.updateState(n)}catch(s){console.error("Error updating state after URL change:",s)}},100)}},createFileRemovedHandler(e){let t=null;return i=>{if(i.target.getAttribute("ctx-name")!==this.uniqueContextName&&i.target!==this.ctx&&!this.ctx.contains(i.target))return;let a=i.detail;this.pendingRemovals.push(a),t&&clearTimeout(t),t=setTimeout(()=>{try{let s=this.getCurrentFiles();for(let n of this.pendingRemovals)s=this.removeFile(s,n);this.updateState(s),this.pendingRemovals=[]}catch(s){console.error("Error in handleFileRemoved:",s)}},100)}},createFormInputChangeHandler(e){return t=>{}},getCurrentFiles(){try{let e=this.uploadedFiles?JSON.parse(this.uploadedFiles):[];return Array.isArray(e)?e:[]}catch{return[]}},updateFilesList(e,t){if(this.isMultiple){let i=this.extractUuidFromUrl(t);return e.some(s=>this.extractUuidFromUrl(s)===i)?e:[...e,t]}return[t]},updateFileUrl(e,t){let i=t.uuid;if(!i&&t.cdnUrl&&(i=this.extractUuidFromUrl(t.cdnUrl)),!i)return e;t.uuid||(t={...t,uuid:i});let a=this.findFileIndex(e,i);if(a===-1)return e;let s;if(this.isWithMetadata){let n=e[a];if(typeof n=="string"){let r=this.extractUuidFromUrl(n);n={cdnUrl:n,uuid:r,name:"",size:0,mimeType:"",isImage:!1}}if(s={...n,...t},s.cdnUrl){let r=this.extractModifiersFromUrl(s.cdnUrl);r?s.cdnUrlModifiers=r:(s.cdnUrlModifiers=null,delete s.cdnUrlModifiers)}}else s=t.cdnUrl;if(this.isMultiple){let n=[...e];return n[a]=s,n}return[s]},removeFile(e,t){let i=this.findFileIndex(e,t.uuid);if(i===-1)return e;if(this.isMultiple){let a=[...e];return a.splice(i,1),a}return[]},findFileIndex(e,t){return t?e.findIndex(i=>{let a=i&&typeof i=="object"?i.cdnUrl:i;return this.extractUuidFromUrl(a)===t}):-1},updateState(e){if(!this.isUpdatingState){this.isUpdatingState=!0;try{let t=new Set,i=e.filter(o=>{let f=o&&typeof o=="object"?o.cdnUrl:o,l=this.extractUuidFromUrl(f);return l?t.has(l)?!1:(t.add(l),!0):!0});console.log(`[CROP DEBUG JS] ${this.name} updateState`,{count:i.length,first_has_modifiers:!!i[0]?.cdnUrlModifiers,first_modifiers:i[0]?.cdnUrlModifiers});let a=this.formatFilesForState(i),s=this.buildStateFromFiles(a),n=this.normalizeStateValue(this.uploadedFiles),r=this.normalizeStateValue(s);n!==r&&(this.uploadedFiles=s,this.isLocalUpdate=!0,this.state=this.uploadedFiles,this.isMultiple&&i.length>1&&this.$nextTick(()=>{this.isLocalUpdate=!1}))}finally{this.isUpdatingState=!1}}},formatFilesForState(e){return e?Array.isArray(e)?e.map(t=>this.isWithMetadata?t:t&&typeof t=="object"?t.cdnUrl:t):[]:[]},setupDoneButtonObserver(){let e=this.$el.closest(".uploadcare-wrapper");e&&(this.doneButtonHider=new m(e))},destroy(){this.doneButtonHider&&(this.doneButtonHider.destroy(),this.doneButtonHider=null),this.documentClassObserver&&(this.documentClassObserver.disconnect(),this.documentClassObserver=null),this.formInputObserver&&(this.formInputObserver.disconnect(),this.formInputObserver=null),this.removeEventListeners&&this.removeEventListeners()},extractUuidFromUrl(e){if(!e)return null;let t=e;if(typeof e=="object"){if(e.uuid)return e.uuid;t=e.cdnUrl||""}if(!t||typeof t!="string")return null;if(/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(t))return t;let a=t.match(/\/([a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})(?:\/|$)/i);return a?a[1]:null},extractModifiersFromUrl(e){if(!e||typeof e!="string")return"";let t=this.extractUuidFromUrl(e);if(!t)return"";let i=e.split(t);if(i.length<2)return"";let a=i[1];return a.startsWith("/")&&(a=a.substring(1)),a.endsWith("/")&&(a=a.substring(0,a.length-1)),a},async syncStateWithUploadcare(e){try{let t=this.getCurrentFilesFromUploadcare(e);if(t.length>0){let s=[];for(let n of t){let r=n&&typeof n=="object"?n.cdnUrl:n;if(typeof r=="string"&&r.match(/[a-f0-9-]{36}~[0-9]+/))try{let d=await this.fetchGroupFiles(r);s.push(...d)}catch{s.push(n)}else s.push(n)}t=s}let i=this.formatFilesForState(t),a=this.buildStateFromFiles(i);this.normalizeStateValue(this.uploadedFiles)!==this.normalizeStateValue(a)&&(this.uploadedFiles=a,this.isLocalUpdate=!0,this.state=this.uploadedFiles)}catch(t){console.error("Error syncing state with Uploadcare:",t)}},async fetchGroupFiles(e){let t=e;if(e.includes("ucarecdn.com")||e.includes("ucarecd.net")){let s=e.match(/\/([a-f0-9-]{36}~[0-9]+)/);s&&(t=s[1])}let i=await fetch(`https://upload.uploadcare.com/group/info/?pub_key=${this.publicKey}&group_id=${t}`);if(!i.ok)throw new Error(`Failed to fetch group info: ${i.statusText}`);let a=await i.json();return a.files?a.files.map(s=>{let n=`https://ucarecdn.com/${s.uuid}/`;return this.isWithMetadata?{uuid:s.uuid,cdnUrl:n,name:s.original_filename,size:s.size,mimeType:s.mime_type,isImage:s.is_image}:n}):[]},buildStateFromFiles(e){return this.isMultiple||this.isWithMetadata?JSON.stringify(e):e.length>0?e[0]:""},getCurrentFilesFromUploadcare(e){try{if(e&&typeof e.value=="function"){let i=e.value();if(i)return(Array.isArray(i)?i:this.parseFormInputValue(i)).filter(s=>s!=null)}let t=this.$el.querySelector("uc-form-input input");return t?this.parseFormInputValue(t.value).filter(i=>i!=null):[]}catch(t){return console.error("Error getting current files from Uploadcare:",t),[]}},parseFormInputValue(e){if(!e||typeof e=="string"&&e.trim()==="")return[];if(typeof e=="object")return[e];try{let t=JSON.parse(e);return Array.isArray(t)?t.filter(i=>i!==null&&i!==""):t!==null&&t!==""?[t]:[]}catch{return typeof e=="string"&&e.trim()!==""?[e]:[]}},clearAllFiles(e=!0){let t=this.getUploadcareApi();if(t){try{if(t.collection&&typeof t.collection.clear=="function")t.collection.clear();else if(typeof t.getCollection=="function"){let i=t.getCollection();i&&typeof i.clear=="function"&&i.clear()}}catch{}try{typeof t.removeAllFiles=="function"&&t.removeAllFiles()}catch{}try{typeof t.value=="function"&&t.value(this.isMultiple?[]:"")}catch{}}this.uploadedFiles!==(this.isMultiple||this.isWithMetadata?"[]":"")&&(this.uploadedFiles=this.isMultiple||this.isWithMetadata?"[]":"",this.isLocalUpdate=!0,e&&(this.state=this.uploadedFiles))}}}export{F as default}; +var m=class{constructor(e){this.wrapper=e,this.observer=null,this.init()}init(){this.hideDoneButtons(),this.setupObserver()}setupObserver(){this.observer=new MutationObserver(e=>{e.forEach(t=>{t.type==="childList"&&t.addedNodes.forEach(i=>{if(i.nodeType===Node.ELEMENT_NODE){i.classList&&i.classList.contains("uc-done-btn")&&this.hideDoneButton(i);let a=i.querySelectorAll&&i.querySelectorAll(".uc-done-btn");a&&a.forEach(s=>this.hideDoneButton(s))}})})}),this.wrapper&&this.observer.observe(this.wrapper,{childList:!0,subtree:!0})}hideDoneButtons(){document.querySelectorAll(".uc-done-btn").forEach(t=>this.hideDoneButton(t))}hideDoneButton(e){e&&(e.style.display="none",e.style.visibility="hidden",e.style.opacity="0",e.style.pointerEvents="none",e.style.position="absolute",e.style.width="0",e.style.height="0",e.style.overflow="hidden",e.style.clip="rect(0, 0, 0, 0)",e.style.margin="0",e.style.padding="0",e.style.border="0",e.style.background="transparent",e.style.color="transparent",e.style.fontSize="0",e.style.lineHeight="0")}destroy(){this.observer&&(this.observer.disconnect(),this.observer=null)}};function F(u){return window._initializedUploadcareContexts||(window._initializedUploadcareContexts=new Set),{name:u.statePath||"unknown",state:u.state,statePath:u.statePath,initialState:u.initialState,publicKey:u.publicKey,isMultiple:u.isMultiple,multipleMin:u.multipleMin,multipleMax:u.multipleMax,isImagesOnly:u.isImagesOnly,accept:u.accept,sourceList:u.sourceList,uploaderStyle:u.uploaderStyle,isWithMetadata:u.isWithMetadata,localeName:u.localeName||"en",uploadedFiles:"",ctx:null,removeEventListeners:null,uniqueContextName:u.uniqueContextName,pendingUploads:[],pendingRemovals:[],isInitialized:!1,stateHasBeenInitialized:!1,isStateWatcherActive:!1,isLocalUpdate:!1,doneButtonHider:null,documentClassObserver:null,formInputObserver:null,isUpdatingState:!1,async init(){this.isContextAlreadyInitialized()||(this.markContextAsInitialized(),this.applyTheme(),await this.loadAllLocales(),this.$el.isConnected&&(this.setupStateWatcher(),this.$el.addEventListener("uploadcare-state-updated",e=>{let t=e.detail.uuid;t&&this.isInitialized?this.loadFileFromUuid(t):t&&this.$nextTick(()=>{this.isInitialized&&this.loadFileFromUuid(t)})}),this.initUploadcare(),this.setupThemeObservers(),this.setupDoneButtonObserver(),(!this.state||this.state==="[]"||this.state==='""')&&this.$nextTick(()=>{this.isInitialized&&this.getCurrentFiles().length>0&&this.clearAllFiles(!1)})))},isContextAlreadyInitialized(){return window._initializedUploadcareContexts.has(this.uniqueContextName)},markContextAsInitialized(){window._initializedUploadcareContexts.add(this.uniqueContextName)},async loadAllLocales(){window._uploadcareAllLocalesLoaded||await new Promise(t=>{if(window._uploadcareAllLocalesLoaded){t();return}let i=setInterval(()=>{window._uploadcareAllLocalesLoaded&&(clearInterval(i),t())},100);setTimeout(()=>{clearInterval(i),t()},5e3)});let e=["de","es","fr","he","it","nl","pl","pt","ru","tr","uk","zh-TW","zh"];document.querySelectorAll("uc-config[data-locale-name]").forEach(t=>{let i=t.getAttribute("data-locale-name");i&&e.includes(i)&&!t.getAttribute("locale-name")&&t.setAttribute("locale-name",i)})},async loadLocale(){if(this.localeName==="en"||this.localeLoaded)return;if(window._uploadcareLocales&&window._uploadcareLocales.has(this.localeName)){this.localeLoaded=!0;return}if(window._uploadcareLocales||(window._uploadcareLocales=new Set),!!["de","es","fr","he","it","nl","pl","pt","ru","tr","uk","zh-TW","zh"].includes(this.localeName))try{let i=await import(`https://cdn.jsdelivr.net/npm/@uploadcare/file-uploader@v1/locales/file-uploader/${this.localeName}.js`),a=i.default||i,s=()=>{let r=customElements.get("uc-file-uploader-inline")||customElements.get("uc-file-uploader-regular")||customElements.get("uc-file-uploader-minimal");return r&&r.UC?r.UC:window.UC},n=()=>{let r=s();return r&&typeof r.defineLocale=="function"?(r.defineLocale(this.localeName,a),window._uploadcareLocales.add(this.localeName),this.localeLoaded=!0,!0):!1};if(!n()){let r=0,d=50,o=setInterval(()=>{r++,(n()||r>=d)&&clearInterval(o)},100)}}catch(t){console.error("[Uploadcare Locale JS] Failed to load locale:",this.localeName,t)}},applyTheme(){let e=this.getCurrentTheme();this.$el.querySelectorAll(`uc-file-uploader-${this.uploaderStyle}`).forEach(i=>{i.classList.remove("uc-dark","uc-light"),i.classList.add(`uc-${e}`)})},getCurrentTheme(){return document.documentElement.classList.contains("dark")?"dark":"light"},setupThemeObservers(){window.addEventListener("storage",this.handleThemeStorageChange.bind(this)),window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change",this.handleSystemThemeChange.bind(this)),this.setupDocumentClassObserver()},handleThemeStorageChange(e){e.key==="theme"&&this.applyTheme()},handleSystemThemeChange(){localStorage.getItem("theme")==="system"&&this.applyTheme()},setupDocumentClassObserver(){this.documentClassObserver=new MutationObserver(e=>{e.forEach(t=>{if(t.type==="attributes"&&t.attributeName==="class"){let i=document.documentElement.classList.contains("dark"),a=t.oldValue&&t.oldValue.includes("dark");i!==a&&this.applyTheme()}})}),this.documentClassObserver.observe(document.documentElement,{attributes:!0,attributeOldValue:!0,attributeFilter:["class"]})},initUploadcare(){this.removeEventListeners&&this.removeEventListeners(),this.initializeUploader()},initializeUploader(e=0,t=10){if(e>=t)return;this.ctx=this.$el.querySelector(`uc-upload-ctx-provider[ctx-name="${this.uniqueContextName}"]`);let i=this.getUploadcareApi();if(!this.isValidContext(i)){setTimeout(()=>this.initializeUploader(e+1,t),100);return}this.markAsInitialized(),this.removeRequiredAttributes(),this.initializeState(i),this.setupEventListeners(i)},getUploadcareApi(){try{return this.ctx?.getAPI()}catch{return null}},isValidContext(e){return this.ctx&&e&&e.addFileFromCdnUrl},markAsInitialized(){this.isInitialized=!0},removeRequiredAttributes(){setTimeout(()=>{let e=this.$el.closest("uc-config");document.querySelectorAll("uc-form-input input[required]").forEach(i=>i.removeAttribute("required"))},100)},initializeState(e){this.initialState&&!this.stateHasBeenInitialized&&!this.uploadedFiles?this.loadInitialState(e):!this.initialState&&!this.stateHasBeenInitialized&&(this.stateHasBeenInitialized=!0,this.uploadedFiles=this.isMultiple||this.isWithMetadata?"[]":"",this.isLocalUpdate=!0,this.state=this.uploadedFiles)},loadInitialState(e){try{let t=this.parseInitialState();this.addFilesFromInitialState(e,t),this.stateHasBeenInitialized=!0,this.isLocalUpdate=!0,this.state=this.uploadedFiles}catch(t){console.error("Error parsing initialState:",t)}},parseInitialState(){let e=i=>{if(typeof i=="string")try{let a=JSON.parse(i);if(typeof a=="string")try{a=JSON.parse(a)}catch{}return a}catch{return i}return i};return this.initialState&&this.initialState&&typeof this.initialState=="object"&&!Array.isArray(this.initialState)&&(this.initialState=[this.initialState]),this.parseStateValue(this.initialState)},addFilesFromInitialState(e,t){let i=[];if(t&&t&&typeof t=="object"&&!Array.isArray(t))try{i=Array.from(t)}catch{i=[t]}else Array.isArray(t)?i=t:t&&(i=[t]);if(Array.isArray(i)&&i.length===1&&Array.isArray(i[0])&&(i=i[0]),Array.isArray(i)&&i.length===1&&typeof i[0]=="string")try{let r=JSON.parse(i[0]);i=Array.isArray(r)?r:[r]}catch{}if(!Array.isArray(i)||i.length===0)return;Array.isArray(i)||(i=[i]);let a=(r,d=0)=>{if(!r)return;if(Array.isArray(r)){r.forEach((h,c)=>{a(h,`${d}.${c}`)});return}if(typeof r=="string")try{let h=JSON.parse(r);a(h,d);return}catch{}let o=r&&typeof r=="object"?r.cdnUrl:r,f=r&&typeof r=="object"?r.cdnUrlModifiers:null;if(console.log(`[CROP DEBUG JS] ${this.name} addFile (initialState)`,{index:d,url:o,cdnUrlModifiers:f,has_modifiers_in_url:o&&o.includes("/-/"),item:JSON.stringify(r).substring(0,500)}),!o||!this.isValidUrl(o))return;let l=this.extractUuidFromUrl(o);if(l&&typeof e.addFileFromUuid=="function")try{if((f||o&&o.includes("/-/"))&&typeof e.addFileFromCdnUrl=="function"){let c=o;if(f){let y=o.split("/-/")[0],p=f;p.startsWith("/")&&(p=p.substring(1)),c=y+(y.endsWith("/")?"":"/")+(p.startsWith("-/")?"":"-/")+p}console.log(`[CROP DEBUG JS] ${this.name} api.addFileFromCdnUrl`,{fullUrl:c}),e.addFileFromCdnUrl(c)}else e.addFileFromUuid(l)}catch{}else console.error(l?"addFileFromUuid method not available on API":`Could not extract UUID from URL: ${o}`)};i.forEach(a);let s=i.map(r=>{let d=r;if(r&&typeof r=="object")return r.uuid||(r.uuid=this.extractUuidFromUrl(r.cdnUrl)),r;if(typeof r=="string"){let o=this.extractUuidFromUrl(r);return{cdnUrl:r,uuid:o,name:"",size:0,mimeType:"",isImage:!1}}return r}),n=this.formatFilesForState(s);this.uploadedFiles=JSON.stringify(n),this.initialState=this.uploadedFiles},isValidUrl(e){if(!e||typeof e!="string")return!1;try{return new URL(e),!0}catch{return!1}},setupStateWatcher(){this.$watch("state",e=>{if(this.isLocalUpdate){this.isLocalUpdate=!1;return}e==null||e===""||e==="[]"||Array.isArray(e)&&e.length===0?this.clearAllFiles(!1):e&&this.isInitialized&&this.addFilesFromState(e)})},parseStateValue(e){if(!e)return null;try{return typeof e=="string"?JSON.parse(e):e}catch{return e}},addFilesFromState(e){let t=this.parseStateValue(e),i=t;if(console.log("[CROP DEBUG JS] addFilesFromState called",{newValue_type:typeof e,parsed_length:Array.isArray(t)?t.length:"not_array",first_item:t&&t[0]?{has_cdnUrlModifiers:!!t[0].cdnUrlModifiers,cdnUrlModifiers:t[0].cdnUrlModifiers,cdnUrl:t[0].cdnUrl,keys:Object.keys(t[0])}:null}),Array.isArray(i)||(i=[i]),i=i.filter(l=>l!=null),i.length===0)return!1;let a=this.getUploadcareApi();if(!a||typeof a.addFileFromCdnUrl!="function")return!1;let n=this.getCurrentFiles().map(l=>l?l&&typeof l=="object"?l.cdnUrl:l:null).filter(Boolean);i.forEach((l,h)=>{if(!l){console.warn(`[Uploadcare] Skipping null item at index ${h}`);return}let c=l&&typeof l=="object"?l.cdnUrl:l;if(c&&typeof c=="string"&&(c.includes("ucarecdn.com")||c.includes("ucarecd.net"))&&!n.some(p=>{let U=this.extractUuidFromUrl(c),g=this.extractUuidFromUrl(p);return U&&g&&U===g})){console.log("[CROP DEBUG JS] Adding file to Uploadcare",{url:c,has_cdnUrlModifiers:!!l.cdnUrlModifiers,cdnUrlModifiers:l.cdnUrlModifiers,url_includes_modifiers:c.includes("-/")});try{a.addFileFromCdnUrl(c)}catch(p){console.error("[Uploadcare] Failed to add file from URL:",c,p)}}});let r=[],d=new Set,o=l=>{if(!l)return;let h=l&&typeof l=="object"?l.cdnUrl:l,c=this.extractUuidFromUrl(h);c&&!d.has(c)?(d.add(c),this.isWithMetadata&&typeof l!="object"?r.push({cdnUrl:l,uuid:c,name:"",size:0,mimeType:"",isImage:!1}):r.push(l)):c||r.push(l)},f=this.parseStateValue(e)||[];return(Array.isArray(f)?f:[f]).forEach(o),this.uploadedFiles=JSON.stringify(r),this.isLocalUpdate=!0,!0},normalizeStateValue(e){if(!e)return"";try{let t=typeof e=="string"?JSON.parse(e):e;if(Array.isArray(t)&&t.every(s=>typeof s=="string"||typeof s=="object"&&s!==null&&("cdnUrl"in s||"uuid"in s)))return JSON.stringify(t);let i=this.formatFilesForState(t);return JSON.stringify(i)}catch(t){return console.error("[Uploadcare] normalizeStateValue error",t),e}},isStateChanged(){let e=this.normalizeStateValue(this.state),t=this.normalizeStateValue(this.initialState);return e!==t},setupEventListeners(e){this.pendingUploads=[],this.pendingRemovals=[];let t=this.createFileUploadSuccessHandler(e),i=this.createFileUrlChangedHandler(e),a=this.createFileRemovedHandler(e),s=this.createFormInputChangeHandler(e),n=r=>{if(r.target!==this.ctx&&!this.ctx.contains(r.target))return;let d=this.$el.closest("form");d&&d.dispatchEvent(new CustomEvent("form-processing-started",{detail:{message:"Uploading file..."}}))};this.ctx.addEventListener("file-upload-started",n),this.ctx.addEventListener("file-upload-success",t),this.ctx.addEventListener("file-url-changed",i),this.ctx.addEventListener("file-removed",a),this.$nextTick(()=>{let r=this.$el.querySelector("uc-form-input input");if(r){r.addEventListener("input",s),r.addEventListener("change",s);let d=new MutationObserver(()=>{s({target:r})});d.observe(r,{attributes:!0,attributeFilter:["value"]}),this.formInputObserver=d}}),this.removeEventListeners=()=>{this.ctx.removeEventListener("file-upload-started",n),this.ctx.removeEventListener("file-upload-success",t),this.ctx.removeEventListener("file-url-changed",i),this.ctx.removeEventListener("file-removed",a);let r=this.$el.querySelector("uc-form-input input");r&&(r.removeEventListener("input",s),r.removeEventListener("change",s)),this.formInputObserver&&(this.formInputObserver.disconnect(),this.formInputObserver=null)}},createFileUploadSuccessHandler(e){let t=null;return i=>{if(i.target.getAttribute("ctx-name")!==this.uniqueContextName&&i.target!==this.ctx&&!this.ctx.contains(i.target))return;let s=this.isWithMetadata?i.detail:i.detail.cdnUrl;this.pendingUploads.push(s),t&&clearTimeout(t),t=setTimeout(()=>{try{let n=this.getCurrentFiles();for(let d of this.pendingUploads)n=this.updateFilesList(n,d);this.updateState(n),this.pendingUploads=[];let r=this.$el.closest("form");r&&r.dispatchEvent(new CustomEvent("form-processing-finished"))}catch(n){console.error("[Uploadcare] Error updating state after upload:",n)}},200)}},createFileUrlChangedHandler(e){let t=null;return i=>{if(i.target.getAttribute("ctx-name")!==this.uniqueContextName&&i.target!==this.ctx&&!this.ctx.contains(i.target))return;let a=i.detail;t&&clearTimeout(t),t=setTimeout(()=>{try{let s=this.getCurrentFiles(),n=this.updateFileUrl(s,a);console.log("[CROP DEBUG JS] File URL changed",{uuid:a.uuid,new_url:a.cdnUrl,has_modifiers:a.cdnUrl?.includes("-/")}),this.updateState(n)}catch(s){console.error("Error updating state after URL change:",s)}},100)}},createFileRemovedHandler(e){let t=null;return i=>{if(i.target.getAttribute("ctx-name")!==this.uniqueContextName&&i.target!==this.ctx&&!this.ctx.contains(i.target))return;let a=i.detail;this.pendingRemovals.push(a),t&&clearTimeout(t),t=setTimeout(()=>{try{let s=this.getCurrentFiles();for(let n of this.pendingRemovals)s=this.removeFile(s,n);this.updateState(s),this.pendingRemovals=[]}catch(s){console.error("Error in handleFileRemoved:",s)}},100)}},createFormInputChangeHandler(e){return t=>{}},getCurrentFiles(){try{let e=this.uploadedFiles?JSON.parse(this.uploadedFiles):[];return Array.isArray(e)?e:[]}catch{return[]}},updateFilesList(e,t){if(this.isMultiple){let i=this.extractUuidFromUrl(t);return e.some(s=>this.extractUuidFromUrl(s)===i)?e:[...e,t]}return[t]},updateFileUrl(e,t){let i=t.uuid;if(!i&&t.cdnUrl&&(i=this.extractUuidFromUrl(t.cdnUrl)),!i)return e;t.uuid||(t={...t,uuid:i});let a=this.findFileIndex(e,i);if(a===-1)return e;let s;if(this.isWithMetadata){let n=e[a];if(typeof n=="string"){let r=this.extractUuidFromUrl(n);n={cdnUrl:n,uuid:r,name:"",size:0,mimeType:"",isImage:!1}}if(s={...n,...t},s.cdnUrl){let r=this.extractModifiersFromUrl(s.cdnUrl);r?s.cdnUrlModifiers=r:(s.cdnUrlModifiers=null,delete s.cdnUrlModifiers)}}else s=t.cdnUrl;if(this.isMultiple){let n=[...e];return n[a]=s,n}return[s]},removeFile(e,t){let i=this.findFileIndex(e,t.uuid);if(i===-1)return e;if(this.isMultiple){let a=[...e];return a.splice(i,1),a}return[]},findFileIndex(e,t){return t?e.findIndex(i=>{let a=i&&typeof i=="object"?i.cdnUrl:i;return this.extractUuidFromUrl(a)===t}):-1},updateState(e){if(!this.isUpdatingState){this.isUpdatingState=!0;try{let t=new Set,i=e.filter(o=>{let f=o&&typeof o=="object"?o.cdnUrl:o,l=this.extractUuidFromUrl(f);return l?t.has(l)?!1:(t.add(l),!0):!0});console.log(`[CROP DEBUG JS] ${this.name} updateState`,{count:i.length,first_has_modifiers:!!i[0]?.cdnUrlModifiers,first_modifiers:i[0]?.cdnUrlModifiers});let a=this.formatFilesForState(i),s=this.buildStateFromFiles(a),n=this.normalizeStateValue(this.uploadedFiles),r=this.normalizeStateValue(s);n!==r&&(this.uploadedFiles=s,this.isLocalUpdate=!0,this.state=this.uploadedFiles,this.isMultiple&&i.length>1&&this.$nextTick(()=>{this.isLocalUpdate=!1}))}finally{this.isUpdatingState=!1}}},formatFilesForState(e){return e?Array.isArray(e)?e.map(t=>this.isWithMetadata?t:t&&typeof t=="object"?t.cdnUrl:t):[]:[]},setupDoneButtonObserver(){let e=this.$el.closest(".uploadcare-wrapper");e&&(this.doneButtonHider=new m(e))},destroy(){this.doneButtonHider&&(this.doneButtonHider.destroy(),this.doneButtonHider=null),this.documentClassObserver&&(this.documentClassObserver.disconnect(),this.documentClassObserver=null),this.formInputObserver&&(this.formInputObserver.disconnect(),this.formInputObserver=null),this.removeEventListeners&&this.removeEventListeners()},extractUuidFromUrl(e){if(!e)return null;let t=e;if(typeof e=="object"){if(e.uuid)return e.uuid;t=e.cdnUrl||""}if(!t||typeof t!="string")return null;if(/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(t))return t;let a=t.match(/\/([a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})(?:\/|$)/i);return a?a[1]:null},extractModifiersFromUrl(e){if(!e||typeof e!="string")return"";let t=this.extractUuidFromUrl(e);if(!t)return"";let i=e.split(t);if(i.length<2)return"";let a=i[1];return a.startsWith("/")&&(a=a.substring(1)),a.endsWith("/")&&(a=a.substring(0,a.length-1)),a},async syncStateWithUploadcare(e){try{let t=this.getCurrentFilesFromUploadcare(e);if(t.length>0){let s=[];for(let n of t){let r=n&&typeof n=="object"?n.cdnUrl:n;if(typeof r=="string"&&r.match(/[a-f0-9-]{36}~[0-9]+/))try{let d=await this.fetchGroupFiles(r);s.push(...d)}catch{s.push(n)}else s.push(n)}t=s}let i=this.formatFilesForState(t),a=this.buildStateFromFiles(i);this.normalizeStateValue(this.uploadedFiles)!==this.normalizeStateValue(a)&&(this.uploadedFiles=a,this.isLocalUpdate=!0,this.state=this.uploadedFiles)}catch(t){console.error("Error syncing state with Uploadcare:",t)}},async fetchGroupFiles(e){let t=e;if(e.includes("ucarecdn.com")||e.includes("ucarecd.net")){let s=e.match(/\/([a-f0-9-]{36}~[0-9]+)/);s&&(t=s[1])}let i=await fetch(`https://upload.uploadcare.com/group/info/?pub_key=${this.publicKey}&group_id=${t}`);if(!i.ok)throw new Error(`Failed to fetch group info: ${i.statusText}`);let a=await i.json();return a.files?a.files.map(s=>{let n=`https://ucarecdn.com/${s.uuid}/`;return this.isWithMetadata?{uuid:s.uuid,cdnUrl:n,name:s.original_filename,size:s.size,mimeType:s.mime_type,isImage:s.is_image}:n}):[]},buildStateFromFiles(e){return this.isMultiple||this.isWithMetadata?JSON.stringify(e):e.length>0?e[0]:""},getCurrentFilesFromUploadcare(e){try{if(e&&typeof e.value=="function"){let i=e.value();if(i)return(Array.isArray(i)?i:this.parseFormInputValue(i)).filter(s=>s!=null)}let t=this.$el.querySelector("uc-form-input input");return t?this.parseFormInputValue(t.value).filter(i=>i!=null):[]}catch(t){return console.error("Error getting current files from Uploadcare:",t),[]}},parseFormInputValue(e){if(!e||typeof e=="string"&&e.trim()==="")return[];if(typeof e=="object")return[e];try{let t=JSON.parse(e);return Array.isArray(t)?t.filter(i=>i!==null&&i!==""):t!==null&&t!==""?[t]:[]}catch{return typeof e=="string"&&e.trim()!==""?[e]:[]}},clearAllFiles(e=!0){let t=this.getUploadcareApi();if(t){try{if(t.collection&&typeof t.collection.clear=="function")t.collection.clear();else if(typeof t.getCollection=="function"){let i=t.getCollection();i&&typeof i.clear=="function"&&i.clear()}}catch{}try{typeof t.removeAllFiles=="function"&&t.removeAllFiles()}catch{}try{typeof t.value=="function"&&t.value(this.isMultiple?[]:"")}catch{}}this.uploadedFiles!==(this.isMultiple||this.isWithMetadata?"[]":"")&&(this.uploadedFiles=this.isMultiple||this.isWithMetadata?"[]":"",this.isLocalUpdate=!0,e&&(this.state=this.uploadedFiles))}}}export{F as default}; diff --git a/packages/filament-uploadcare-field/resources/js/components/uploadcare.js b/packages/filament-uploadcare-field/resources/js/components/uploadcare.js index 23dde988..062c0d90 100644 --- a/packages/filament-uploadcare-field/resources/js/components/uploadcare.js +++ b/packages/filament-uploadcare-field/resources/js/components/uploadcare.js @@ -489,7 +489,23 @@ export default function uploadcareField(config) { }, setupStateWatcher() { - // State watcher removed - handled by Alpine's x-model + this.$watch('state', (value) => { + if (this.isLocalUpdate) { + this.isLocalUpdate = false; + return; + } + + // Initial basic logic: Clear files if state becomes empty + if (value === null || value === undefined || value === '' || value === '[]' || (Array.isArray(value) && value.length === 0)) { + this.clearAllFiles(false); + } else if (value) { + // Try to re-sync or add files if state changes externally + // This handles cases where state is set externally to a new non-empty value + if (this.isInitialized) { + this.addFilesFromState(value); + } + } + }); }, parseStateValue(value) { From 964ed895c46317f51c79d4475ef2d6d3e473c30a 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 27/43] fix: styling --- packages/core/src/Models/Content.php | 1 - .../core/src/Models/ContentFieldValue.php | 20 +- packages/core/src/Models/Media.php | 6 +- .../ContentResource/Pages/CreateContent.php | 8 +- packages/fields/src/Fields/Base.php | 14 +- packages/fields/src/Fields/Repeater.php | 16 +- .../src/Forms/Components/Uploadcare.php | 68 ++- .../Observers/ContentFieldValueObserver.php | 16 +- packages/uploadcare-field/src/Uploadcare.php | 446 +++++++++--------- 9 files changed, 315 insertions(+), 280 deletions(-) diff --git a/packages/core/src/Models/Content.php b/packages/core/src/Models/Content.php index d4d944c3..e781dac8 100644 --- a/packages/core/src/Models/Content.php +++ b/packages/core/src/Models/Content.php @@ -341,7 +341,6 @@ public function blocks(string $field): array * @see \Backstage\Models\ContentFieldValue::getHydratedValue() * @see https://docs.backstagephp.com/03-fields/01-introduction.html */ - public function field(string $slug): Content | HtmlString | Collection | array | bool | string | Model | null { $this->load('values'); diff --git a/packages/core/src/Models/ContentFieldValue.php b/packages/core/src/Models/ContentFieldValue.php index d14c4118..d03957db 100644 --- a/packages/core/src/Models/ContentFieldValue.php +++ b/packages/core/src/Models/ContentFieldValue.php @@ -9,9 +9,9 @@ use Filament\Forms\Components\RichEditor\RichContentRenderer; use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Concerns\HasUlids; +use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\Pivot; -use Illuminate\Database\Eloquent\Model; use Illuminate\Support\HtmlString; /** @@ -52,7 +52,6 @@ public function media(): \Illuminate\Database\Eloquent\Relations\MorphToMany ->withPivot(['position', 'meta']); } - public function getHydratedValue(): Content | HtmlString | array | Collection | bool | string | Model | null { if ($this->isRichEditor()) { @@ -62,12 +61,12 @@ public function getHydratedValue(): Content | HtmlString | array | Collection | } $shouldHydrate = $this->shouldHydrate(); - // TODO (IMPORTANT): This should be fixed in the Uploadcare package itself. + // TODO (IMPORTANT): This should be fixed in the Uploadcare package itself. $isUploadcare = $this->field->field_type === 'uploadcare'; if ($shouldHydrate || $isUploadcare) { [$hydrated, $result] = $this->tryHydrateViaClass($this->isJsonArray(), $this->field->field_type, $this->field); - + if ($hydrated) { return $result; } @@ -92,11 +91,10 @@ public function getHydratedValue(): Content | HtmlString | array | Collection | // For all other cases, ensure the value is returned as a string (HTML string in frontend) $val = $this->value ?? ''; $res = $this->shouldHydrate() ? new HtmlString($val) : $val; - + return $res; } - /** * Get the relation value */ @@ -188,7 +186,7 @@ private function hydrateValuesRecursively(mixed $value, Field $field): mixed private function tryHydrateViaClass(mixed $value, string $fieldType, ?Field $fieldModel = null): array { $fieldClass = \Backstage\Fields\Facades\Fields::resolveField($fieldType); - + if ($fieldClass) { if (in_array(\Backstage\Fields\Contracts\HydratesValues::class, class_implements($fieldClass))) { try { @@ -196,16 +194,18 @@ private function tryHydrateViaClass(mixed $value, string $fieldType, ?Field $fie if ($fieldModel && property_exists($instance, 'field_model')) { $instance->field_model = $fieldModel; } + return [true, $instance->hydrate($value, $this)]; } catch (\Throwable $e) { file_put_contents('/tmp/hydration_error.log', "Hydration error for $fieldType: " . $e->getMessage() . "\n" . $e->getTraceAsString() . "\n", FILE_APPEND); + return [true, $value]; } } else { - file_put_contents('/tmp/cfv_override_debug.log', "Class $fieldClass does not implement HydratesValues\n", FILE_APPEND); + file_put_contents('/tmp/cfv_override_debug.log', "Class $fieldClass does not implement HydratesValues\n", FILE_APPEND); } } else { - file_put_contents('/tmp/cfv_override_debug.log', "Could not resolve field class for $fieldType\n", FILE_APPEND); + file_put_contents('/tmp/cfv_override_debug.log', "Could not resolve field class for $fieldType\n", FILE_APPEND); } return [false, null]; @@ -231,7 +231,7 @@ private function hydrateItemFields(array &$data, $fields): void } } } - + public function shouldHydrate(): bool { if (app()->runningInConsole()) { diff --git a/packages/core/src/Models/Media.php b/packages/core/src/Models/Media.php index be81399b..a4a8d10a 100644 --- a/packages/core/src/Models/Media.php +++ b/packages/core/src/Models/Media.php @@ -39,7 +39,7 @@ public function getMimeTypeAttribute(): ?string public function getEditAttribute(): ?array { $mediaUlid = $this->ulid ?? 'UNKNOWN'; - + if ($this->relationLoaded('edits')) { $edit = $this->edits->first(); if (! $edit || ! $edit->relationLoaded('pivot') || ! $edit->pivot || ! $edit->pivot->meta) { @@ -49,6 +49,7 @@ public function getEditAttribute(): ?array 'has_pivot' => $edit && $edit->pivot !== null, 'has_meta' => $edit && $edit->pivot && $edit->pivot->meta !== null, ]); + return null; } @@ -57,6 +58,7 @@ public function getEditAttribute(): ?array 'has_crop' => is_array($result) && isset($result['crop']), 'has_cdnUrlModifiers' => is_array($result) && isset($result['cdnUrlModifiers']), ]); + return $result; } @@ -67,6 +69,7 @@ public function getEditAttribute(): ?array 'has_pivot' => $edit && $edit->pivot !== null, 'has_meta' => $edit && $edit->pivot && $edit->pivot->meta !== null, ]); + return null; } @@ -75,6 +78,7 @@ public function getEditAttribute(): ?array 'has_crop' => is_array($result) && isset($result['crop']), 'has_cdnUrlModifiers' => is_array($result) && isset($result['cdnUrlModifiers']), ]); + return $result; } } diff --git a/packages/core/src/Resources/ContentResource/Pages/CreateContent.php b/packages/core/src/Resources/ContentResource/Pages/CreateContent.php index e408ccce..772e33d6 100644 --- a/packages/core/src/Resources/ContentResource/Pages/CreateContent.php +++ b/packages/core/src/Resources/ContentResource/Pages/CreateContent.php @@ -64,12 +64,12 @@ protected function afterCreate(): void $this->getRecord()->authors()->attach(auth()->id()); } - + protected function getRedirectUrl(): string { // Store the created record before resetting for "Create Another" $createdRecord = $this->getRecord(); - + // Reset state for "Create Another" $typeSlug = $this->data['type_slug'] ?? null; @@ -84,10 +84,10 @@ protected function getRedirectUrl(): string $this->form->fill([]); $this->formVersion++; - + // Temporarily restore the created record for URL generation $this->record = $createdRecord; - + // Get the default redirect URL (to edit page) return parent::getRedirectUrl(); } diff --git a/packages/fields/src/Fields/Base.php b/packages/fields/src/Fields/Base.php index 2dc029e2..0df44409 100644 --- a/packages/fields/src/Fields/Base.php +++ b/packages/fields/src/Fields/Base.php @@ -204,16 +204,16 @@ public static function getFieldValueFromRecord(Model $record, Field $field): mix if (self::isRelationship($values)) { $fieldValue = $values->where(function ($query) use ($field) { $query->where('field_ulid', $field->ulid) - ->orWhere('ulid', $field->ulid); + ->orWhere('ulid', $field->ulid); })->first(); if ($field->slug === 'banner-image') { - \Illuminate\Support\Facades\Log::info("[BASE DEBUG] getFieldValueFromRecord relation check", [ - 'field_ulid' => $field->ulid, - 'record_key' => $record->getKey(), - 'found' => (bool) $fieldValue, - 'sql' => $values->where('field_ulid', $field->ulid)->toSql(), - ]); + \Illuminate\Support\Facades\Log::info('[BASE DEBUG] getFieldValueFromRecord relation check', [ + 'field_ulid' => $field->ulid, + 'record_key' => $record->getKey(), + 'found' => (bool) $fieldValue, + 'sql' => $values->where('field_ulid', $field->ulid)->toSql(), + ]); } $result = $fieldValue ? self::resolveHydratedValue($fieldValue) : null; } diff --git a/packages/fields/src/Fields/Repeater.php b/packages/fields/src/Fields/Repeater.php index cb47f0ea..db609a48 100644 --- a/packages/fields/src/Fields/Repeater.php +++ b/packages/fields/src/Fields/Repeater.php @@ -6,6 +6,7 @@ use Backstage\Fields\Concerns\HasFieldTypeResolver; use Backstage\Fields\Concerns\HasOptions; use Backstage\Fields\Contracts\FieldContract; +use Backstage\Fields\Contracts\HydratesValues; use Backstage\Fields\Enums\Field as FieldEnum; use Backstage\Fields\Facades\Fields; use Backstage\Fields\Models\Field; @@ -21,13 +22,11 @@ use Filament\Schemas\Components\Tabs\Tab; use Filament\Schemas\Components\Utilities\Get; use Filament\Schemas\Components\Utilities\Set; +use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Collection; use Illuminate\Support\Str; use Saade\FilamentAdjacencyList\Forms\Components\AdjacencyList; -use Backstage\Fields\Contracts\HydratesValues; -use Illuminate\Database\Eloquent\Model; - class Repeater extends Base implements FieldContract, HydratesValues { use HasConfigurableFields; @@ -47,13 +46,14 @@ public function hydrate(mixed $value, ?Model $model = null): mixed if (empty($this->field_model)) { file_put_contents('/tmp/repeater_debug.log', "Field model missing for repeater.\n", FILE_APPEND); + return $value; } $children = $this->field_model->children->keyBy('ulid'); $slugMap = $this->field_model->children->pluck('ulid', 'slug'); - file_put_contents('/tmp/repeater_debug.log', "Hydrating Repeater " . $this->field_model->ulid . " with children slugs: " . implode(', ', $slugMap->keys()->toArray()) . "\n", FILE_APPEND); + file_put_contents('/tmp/repeater_debug.log', 'Hydrating Repeater ' . $this->field_model->ulid . ' with children slugs: ' . implode(', ', $slugMap->keys()->toArray()) . "\n", FILE_APPEND); $hydrated = []; @@ -66,19 +66,19 @@ public function hydrate(mixed $value, ?Model $model = null): mixed if ($fieldUlid && isset($children[$fieldUlid])) { $fieldModel = $children[$fieldUlid]; $fieldClass = self::resolveFieldTypeClassName($fieldModel->field_type); - + file_put_contents('/tmp/repeater_debug.log', " > Hydrating field $fieldSlug ($fieldModel->field_type) using $fieldClass\n", FILE_APPEND); if ($fieldClass && in_array(HydratesValues::class, class_implements($fieldClass))) { // Instantiate the field class to access its hydrate method - // We need to set the field model on the instance if possible, + // We need to set the field model on the instance if possible, // or at least pass context if needed. // Assuming simpler 'make' or instantiation works for hydration context. - $fieldInstance = new $fieldClass(); + $fieldInstance = new $fieldClass; if (property_exists($fieldInstance, 'field_model')) { $fieldInstance->field_model = $fieldModel; } - + $hydratedRow[$fieldSlug] = $fieldInstance->hydrate($fieldValue, $model); } } diff --git a/packages/filament-uploadcare-field/src/Forms/Components/Uploadcare.php b/packages/filament-uploadcare-field/src/Forms/Components/Uploadcare.php index f250e68d..541d786a 100644 --- a/packages/filament-uploadcare-field/src/Forms/Components/Uploadcare.php +++ b/packages/filament-uploadcare-field/src/Forms/Components/Uploadcare.php @@ -121,6 +121,7 @@ public function getFieldUlid(): string return $part; } } + return end($parts); } @@ -312,8 +313,8 @@ public function maxLocalFileSize(string $size): static public function getState(): mixed { $state = parent::getState(); - - \Log::info("[CROP DEBUG] Uploadcare::getState called", [ + + \Log::info('[CROP DEBUG] Uploadcare::getState called', [ 'field' => $this->getName(), 'type' => gettype($state), 'is_array' => is_array($state), @@ -329,7 +330,7 @@ public function getState(): mixed } if ($state === null || $state === '' || $state === []) { - return $state; + return $state; } // If it's already a rich object (single file field), we're done resolving. @@ -348,13 +349,13 @@ public function getState(): mixed // Resolve Backstage Media ULIDs or Models into Uploadcare rich objects. $resolved = self::resolveUlidsToUploadcareState($items, $this->getRecord(), $this->getFieldUlid()); - + // Transform URLs from database format back to ucarecdn.com format for the widget if ($this->shouldTransformUrlsForDb()) { $resolved = $this->transformUrlsFromDb($resolved); } - \Log::info("[CROP DEBUG] Uploadcare::getState result", [ + \Log::info('[CROP DEBUG] Uploadcare::getState result', [ 'field' => $this->getName(), 'resolved_count' => count($resolved), 'first_url' => is_array($resolved[0] ?? null) ? ($resolved[0]['cdnUrl'] ?? null) : ($resolved[0] ?? null), @@ -367,13 +368,20 @@ public function getState(): mixed return $resolved[0] ?? null; } + private static function isListOfUlids(array $state): bool { - if (empty($state) || ! array_is_list($state)) return false; - + if (empty($state) || ! array_is_list($state)) { + return false; + } + $first = $state[0]; - if ($first instanceof Model) return true; - if (!is_string($first)) return false; + if ($first instanceof Model) { + return true; + } + if (! is_string($first)) { + return false; + } return (bool) preg_match('/^[0-9A-HJKMNP-TV-Z]{26}$/i', $first); } @@ -393,7 +401,7 @@ private static function resolveUlidsToUploadcareState(array $items, ?Model $reco return []; } - \Log::info("[CROP DEBUG] resolveUlidsToUploadcareState starting", [ + \Log::info('[CROP DEBUG] resolveUlidsToUploadcareState starting', [ 'field' => $fieldName, 'count' => count($items), ]); @@ -428,6 +436,7 @@ private static function resolveUlidsToUploadcareState(array $items, ?Model $reco foreach ($parts as $part) { if (preg_match('/^[0-9A-HJKMNP-TV-Z]{26}$/i', $part)) { $fieldSlug = $part; + break; } } @@ -436,7 +445,7 @@ private static function resolveUlidsToUploadcareState(array $items, ?Model $reco } } - \Log::info("[CROP DEBUG] resolveUlidsToUploadcareState searching for CFV", [ + \Log::info('[CROP DEBUG] resolveUlidsToUploadcareState searching for CFV', [ 'record_ulid' => $record->ulid, 'field_slug' => $fieldSlug, 'original_field_name' => $fieldName, @@ -446,9 +455,9 @@ private static function resolveUlidsToUploadcareState(array $items, ?Model $reco ->where(function ($query) use ($fieldSlug) { $query->whereHas('field', function ($q) use ($fieldSlug) { $q->where('slug', $fieldSlug) - ->orWhere('ulid', $fieldSlug); + ->orWhere('ulid', $fieldSlug); }) - ->orWhere('ulid', $fieldSlug); + ->orWhere('ulid', $fieldSlug); }) ->first(); @@ -458,10 +467,11 @@ private static function resolveUlidsToUploadcareState(array $items, ?Model $reco ->whereIn('media_ulid', array_values($ulidsToResolve)) ->get() ->keyBy('ulid'); - - \Log::info("[CROP DEBUG] resolveUlidsToUploadcareState: Loaded " . $mediaItems->count() . " media items via CFV"); + + \Log::info('[CROP DEBUG] resolveUlidsToUploadcareState: Loaded ' . $mediaItems->count() . ' media items via CFV'); } - } catch (\Exception $e) {} + } catch (\Exception $e) { + } } // Fallback for record media or direct query @@ -472,13 +482,14 @@ private static function resolveUlidsToUploadcareState(array $items, ?Model $reco ->whereIn('media_ulid', array_values($ulidsToResolve)) ->get() ->keyBy('ulid'); - \Log::info("[CROP DEBUG] resolveUlidsToUploadcareState: Loaded " . ($mediaItems ? $mediaItems->count() : 0) . " media items via record fallback"); - } catch (\Exception $e) {} + \Log::info('[CROP DEBUG] resolveUlidsToUploadcareState: Loaded ' . ($mediaItems ? $mediaItems->count() : 0) . ' media items via record fallback'); + } catch (\Exception $e) { + } } if (! $mediaItems || $mediaItems->isEmpty()) { $mediaItems = $mediaModel::whereIn('ulid', array_values($ulidsToResolve))->get()->keyBy('ulid'); - \Log::info("[CROP DEBUG] resolveUlidsToUploadcareState: Loaded " . ($mediaItems ? $mediaItems->count() : 0) . " media items via direct query fallback"); + \Log::info('[CROP DEBUG] resolveUlidsToUploadcareState: Loaded ' . ($mediaItems ? $mediaItems->count() : 0) . ' media items via direct query fallback'); } foreach ($ulidsToResolve as $index => $ulid) { @@ -496,21 +507,26 @@ private static function resolveUlidsToUploadcareState(array $items, ?Model $reco } ksort($resolved); // Restore original order - + $final = array_values($resolved); - + // Deduplicate by UUID to prevent same file appearing twice $uniqueUuids = []; - $final = array_filter($final, function($item) use (&$uniqueUuids) { + $final = array_filter($final, function ($item) use (&$uniqueUuids) { $uuid = is_array($item) ? ($item['uuid'] ?? null) : (is_string($item) ? $item : null); - if (!$uuid) return true; - if (in_array($uuid, $uniqueUuids)) return false; + if (! $uuid) { + return true; + } + if (in_array($uuid, $uniqueUuids)) { + return false; + } $uniqueUuids[] = $uuid; + return true; }); $final = array_values($final); - \Log::info("[CROP DEBUG] resolveUlidsToUploadcareState finished", [ + \Log::info('[CROP DEBUG] resolveUlidsToUploadcareState finished', [ 'field' => $fieldName, 'count' => count($final), ]); @@ -524,6 +540,7 @@ private static function extractValues(array $state): array if (isset($state['uuid']) || isset($state['cdnUrl'])) { return [$state]; } + return []; } @@ -604,6 +621,7 @@ private function transformUrls($value, string $from, string $to): mixed foreach ($v as $key => $subValue) { $v[$key] = $replaceCdn($subValue); } + return $v; } diff --git a/packages/uploadcare-field/src/Observers/ContentFieldValueObserver.php b/packages/uploadcare-field/src/Observers/ContentFieldValueObserver.php index cf3265fe..90fa8cde 100644 --- a/packages/uploadcare-field/src/Observers/ContentFieldValueObserver.php +++ b/packages/uploadcare-field/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/packages/uploadcare-field/src/Uploadcare.php b/packages/uploadcare-field/src/Uploadcare.php index 5d91d01c..65d9c1f3 100755 --- a/packages/uploadcare-field/src/Uploadcare.php +++ b/packages/uploadcare-field/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 806cdbc862708853006e7358057d06a712877fb4 Mon Sep 17 00:00:00 2001 From: Baspa Date: Mon, 5 Jan 2026 16:41:01 +0100 Subject: [PATCH 28/43] remove logging --- packages/core/src/Models/Media.php | 19 --- packages/fields/src/Fields/Base.php | 8 -- .../dist/filament-uploadcare-field.js | 2 +- .../resources/js/components/uploadcare.js | 35 ------ .../src/Forms/Components/Uploadcare.php | 31 ----- .../Observers/ContentFieldValueObserver.php | 29 ----- packages/uploadcare-field/src/Uploadcare.php | 115 +++--------------- 7 files changed, 17 insertions(+), 222 deletions(-) diff --git a/packages/core/src/Models/Media.php b/packages/core/src/Models/Media.php index be81399b..a482c0de 100644 --- a/packages/core/src/Models/Media.php +++ b/packages/core/src/Models/Media.php @@ -43,38 +43,19 @@ public function getEditAttribute(): ?array if ($this->relationLoaded('edits')) { $edit = $this->edits->first(); if (! $edit || ! $edit->relationLoaded('pivot') || ! $edit->pivot || ! $edit->pivot->meta) { - \Log::info("[CROP DEBUG] Media::getEditAttribute (via edits relation) for {$mediaUlid}: NO META", [ - 'has_edit' => $edit !== null, - 'has_pivot_relation' => $edit && $edit->relationLoaded('pivot'), - 'has_pivot' => $edit && $edit->pivot !== null, - 'has_meta' => $edit && $edit->pivot && $edit->pivot->meta !== null, - ]); return null; } $result = is_string($edit->pivot->meta) ? json_decode($edit->pivot->meta, true) : $edit->pivot->meta; - \Log::info("[CROP DEBUG] Media::getEditAttribute (via edits relation) for {$mediaUlid}: FOUND", [ - 'has_crop' => is_array($result) && isset($result['crop']), - 'has_cdnUrlModifiers' => is_array($result) && isset($result['cdnUrlModifiers']), - ]); return $result; } $edit = $this->edits()->first(); if (! $edit || ! $edit->pivot || ! $edit->pivot->meta) { - \Log::info("[CROP DEBUG] Media::getEditAttribute (via edits query) for {$mediaUlid}: NO META", [ - 'has_edit' => $edit !== null, - 'has_pivot' => $edit && $edit->pivot !== null, - 'has_meta' => $edit && $edit->pivot && $edit->pivot->meta !== null, - ]); return null; } $result = is_string($edit->pivot->meta) ? json_decode($edit->pivot->meta, true) : $edit->pivot->meta; - \Log::info("[CROP DEBUG] Media::getEditAttribute (via edits query) for {$mediaUlid}: FOUND", [ - 'has_crop' => is_array($result) && isset($result['crop']), - 'has_cdnUrlModifiers' => is_array($result) && isset($result['cdnUrlModifiers']), - ]); return $result; } } diff --git a/packages/fields/src/Fields/Base.php b/packages/fields/src/Fields/Base.php index 2dc029e2..b756af88 100644 --- a/packages/fields/src/Fields/Base.php +++ b/packages/fields/src/Fields/Base.php @@ -207,14 +207,6 @@ public static function getFieldValueFromRecord(Model $record, Field $field): mix ->orWhere('ulid', $field->ulid); })->first(); - if ($field->slug === 'banner-image') { - \Illuminate\Support\Facades\Log::info("[BASE DEBUG] getFieldValueFromRecord relation check", [ - 'field_ulid' => $field->ulid, - 'record_key' => $record->getKey(), - 'found' => (bool) $fieldValue, - 'sql' => $values->where('field_ulid', $field->ulid)->toSql(), - ]); - } $result = $fieldValue ? self::resolveHydratedValue($fieldValue) : null; } } diff --git a/packages/filament-uploadcare-field/resources/dist/filament-uploadcare-field.js b/packages/filament-uploadcare-field/resources/dist/filament-uploadcare-field.js index a353aa3c..2c8afe2e 100644 --- a/packages/filament-uploadcare-field/resources/dist/filament-uploadcare-field.js +++ b/packages/filament-uploadcare-field/resources/dist/filament-uploadcare-field.js @@ -1 +1 @@ -var m=class{constructor(e){this.wrapper=e,this.observer=null,this.init()}init(){this.hideDoneButtons(),this.setupObserver()}setupObserver(){this.observer=new MutationObserver(e=>{e.forEach(t=>{t.type==="childList"&&t.addedNodes.forEach(i=>{if(i.nodeType===Node.ELEMENT_NODE){i.classList&&i.classList.contains("uc-done-btn")&&this.hideDoneButton(i);let a=i.querySelectorAll&&i.querySelectorAll(".uc-done-btn");a&&a.forEach(s=>this.hideDoneButton(s))}})})}),this.wrapper&&this.observer.observe(this.wrapper,{childList:!0,subtree:!0})}hideDoneButtons(){document.querySelectorAll(".uc-done-btn").forEach(t=>this.hideDoneButton(t))}hideDoneButton(e){e&&(e.style.display="none",e.style.visibility="hidden",e.style.opacity="0",e.style.pointerEvents="none",e.style.position="absolute",e.style.width="0",e.style.height="0",e.style.overflow="hidden",e.style.clip="rect(0, 0, 0, 0)",e.style.margin="0",e.style.padding="0",e.style.border="0",e.style.background="transparent",e.style.color="transparent",e.style.fontSize="0",e.style.lineHeight="0")}destroy(){this.observer&&(this.observer.disconnect(),this.observer=null)}};function F(u){return window._initializedUploadcareContexts||(window._initializedUploadcareContexts=new Set),{name:u.statePath||"unknown",state:u.state,statePath:u.statePath,initialState:u.initialState,publicKey:u.publicKey,isMultiple:u.isMultiple,multipleMin:u.multipleMin,multipleMax:u.multipleMax,isImagesOnly:u.isImagesOnly,accept:u.accept,sourceList:u.sourceList,uploaderStyle:u.uploaderStyle,isWithMetadata:u.isWithMetadata,localeName:u.localeName||"en",uploadedFiles:"",ctx:null,removeEventListeners:null,uniqueContextName:u.uniqueContextName,pendingUploads:[],pendingRemovals:[],isInitialized:!1,stateHasBeenInitialized:!1,isStateWatcherActive:!1,isLocalUpdate:!1,doneButtonHider:null,documentClassObserver:null,formInputObserver:null,isUpdatingState:!1,async init(){this.isContextAlreadyInitialized()||(this.markContextAsInitialized(),this.applyTheme(),await this.loadAllLocales(),this.$el.isConnected&&(this.setupStateWatcher(),this.$el.addEventListener("uploadcare-state-updated",e=>{let t=e.detail.uuid;t&&this.isInitialized?this.loadFileFromUuid(t):t&&this.$nextTick(()=>{this.isInitialized&&this.loadFileFromUuid(t)})}),this.initUploadcare(),this.setupThemeObservers(),this.setupDoneButtonObserver(),(!this.state||this.state==="[]"||this.state==='""')&&this.$nextTick(()=>{this.isInitialized&&this.getCurrentFiles().length>0&&this.clearAllFiles(!1)})))},isContextAlreadyInitialized(){return window._initializedUploadcareContexts.has(this.uniqueContextName)},markContextAsInitialized(){window._initializedUploadcareContexts.add(this.uniqueContextName)},async loadAllLocales(){window._uploadcareAllLocalesLoaded||await new Promise(t=>{if(window._uploadcareAllLocalesLoaded){t();return}let i=setInterval(()=>{window._uploadcareAllLocalesLoaded&&(clearInterval(i),t())},100);setTimeout(()=>{clearInterval(i),t()},5e3)});let e=["de","es","fr","he","it","nl","pl","pt","ru","tr","uk","zh-TW","zh"];document.querySelectorAll("uc-config[data-locale-name]").forEach(t=>{let i=t.getAttribute("data-locale-name");i&&e.includes(i)&&!t.getAttribute("locale-name")&&t.setAttribute("locale-name",i)})},async loadLocale(){if(this.localeName==="en"||this.localeLoaded)return;if(window._uploadcareLocales&&window._uploadcareLocales.has(this.localeName)){this.localeLoaded=!0;return}if(window._uploadcareLocales||(window._uploadcareLocales=new Set),!!["de","es","fr","he","it","nl","pl","pt","ru","tr","uk","zh-TW","zh"].includes(this.localeName))try{let i=await import(`https://cdn.jsdelivr.net/npm/@uploadcare/file-uploader@v1/locales/file-uploader/${this.localeName}.js`),a=i.default||i,s=()=>{let r=customElements.get("uc-file-uploader-inline")||customElements.get("uc-file-uploader-regular")||customElements.get("uc-file-uploader-minimal");return r&&r.UC?r.UC:window.UC},n=()=>{let r=s();return r&&typeof r.defineLocale=="function"?(r.defineLocale(this.localeName,a),window._uploadcareLocales.add(this.localeName),this.localeLoaded=!0,!0):!1};if(!n()){let r=0,d=50,o=setInterval(()=>{r++,(n()||r>=d)&&clearInterval(o)},100)}}catch(t){console.error("[Uploadcare Locale JS] Failed to load locale:",this.localeName,t)}},applyTheme(){let e=this.getCurrentTheme();this.$el.querySelectorAll(`uc-file-uploader-${this.uploaderStyle}`).forEach(i=>{i.classList.remove("uc-dark","uc-light"),i.classList.add(`uc-${e}`)})},getCurrentTheme(){return document.documentElement.classList.contains("dark")?"dark":"light"},setupThemeObservers(){window.addEventListener("storage",this.handleThemeStorageChange.bind(this)),window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change",this.handleSystemThemeChange.bind(this)),this.setupDocumentClassObserver()},handleThemeStorageChange(e){e.key==="theme"&&this.applyTheme()},handleSystemThemeChange(){localStorage.getItem("theme")==="system"&&this.applyTheme()},setupDocumentClassObserver(){this.documentClassObserver=new MutationObserver(e=>{e.forEach(t=>{if(t.type==="attributes"&&t.attributeName==="class"){let i=document.documentElement.classList.contains("dark"),a=t.oldValue&&t.oldValue.includes("dark");i!==a&&this.applyTheme()}})}),this.documentClassObserver.observe(document.documentElement,{attributes:!0,attributeOldValue:!0,attributeFilter:["class"]})},initUploadcare(){this.removeEventListeners&&this.removeEventListeners(),this.initializeUploader()},initializeUploader(e=0,t=10){if(e>=t)return;this.ctx=this.$el.querySelector(`uc-upload-ctx-provider[ctx-name="${this.uniqueContextName}"]`);let i=this.getUploadcareApi();if(!this.isValidContext(i)){setTimeout(()=>this.initializeUploader(e+1,t),100);return}this.markAsInitialized(),this.removeRequiredAttributes(),this.initializeState(i),this.setupEventListeners(i)},getUploadcareApi(){try{return this.ctx?.getAPI()}catch{return null}},isValidContext(e){return this.ctx&&e&&e.addFileFromCdnUrl},markAsInitialized(){this.isInitialized=!0},removeRequiredAttributes(){setTimeout(()=>{let e=this.$el.closest("uc-config");document.querySelectorAll("uc-form-input input[required]").forEach(i=>i.removeAttribute("required"))},100)},initializeState(e){this.initialState&&!this.stateHasBeenInitialized&&!this.uploadedFiles?this.loadInitialState(e):!this.initialState&&!this.stateHasBeenInitialized&&(this.stateHasBeenInitialized=!0,this.uploadedFiles=this.isMultiple||this.isWithMetadata?"[]":"",this.isLocalUpdate=!0,this.state=this.uploadedFiles)},loadInitialState(e){try{let t=this.parseInitialState();this.addFilesFromInitialState(e,t),this.stateHasBeenInitialized=!0,this.isLocalUpdate=!0,this.state=this.uploadedFiles}catch(t){console.error("Error parsing initialState:",t)}},parseInitialState(){let e=i=>{if(typeof i=="string")try{let a=JSON.parse(i);if(typeof a=="string")try{a=JSON.parse(a)}catch{}return a}catch{return i}return i};return this.initialState&&this.initialState&&typeof this.initialState=="object"&&!Array.isArray(this.initialState)&&(this.initialState=[this.initialState]),this.parseStateValue(this.initialState)},addFilesFromInitialState(e,t){let i=[];if(t&&t&&typeof t=="object"&&!Array.isArray(t))try{i=Array.from(t)}catch{i=[t]}else Array.isArray(t)?i=t:t&&(i=[t]);if(Array.isArray(i)&&i.length===1&&Array.isArray(i[0])&&(i=i[0]),Array.isArray(i)&&i.length===1&&typeof i[0]=="string")try{let r=JSON.parse(i[0]);i=Array.isArray(r)?r:[r]}catch{}if(!Array.isArray(i)||i.length===0)return;Array.isArray(i)||(i=[i]);let a=(r,d=0)=>{if(!r)return;if(Array.isArray(r)){r.forEach((h,c)=>{a(h,`${d}.${c}`)});return}if(typeof r=="string")try{let h=JSON.parse(r);a(h,d);return}catch{}let o=r&&typeof r=="object"?r.cdnUrl:r,f=r&&typeof r=="object"?r.cdnUrlModifiers:null;if(console.log(`[CROP DEBUG JS] ${this.name} addFile (initialState)`,{index:d,url:o,cdnUrlModifiers:f,has_modifiers_in_url:o&&o.includes("/-/"),item:JSON.stringify(r).substring(0,500)}),!o||!this.isValidUrl(o))return;let l=this.extractUuidFromUrl(o);if(l&&typeof e.addFileFromUuid=="function")try{if((f||o&&o.includes("/-/"))&&typeof e.addFileFromCdnUrl=="function"){let c=o;if(f){let y=o.split("/-/")[0],p=f;p.startsWith("/")&&(p=p.substring(1)),c=y+(y.endsWith("/")?"":"/")+(p.startsWith("-/")?"":"-/")+p}console.log(`[CROP DEBUG JS] ${this.name} api.addFileFromCdnUrl`,{fullUrl:c}),e.addFileFromCdnUrl(c)}else e.addFileFromUuid(l)}catch{}else console.error(l?"addFileFromUuid method not available on API":`Could not extract UUID from URL: ${o}`)};i.forEach(a);let s=i.map(r=>{let d=r;if(r&&typeof r=="object")return r.uuid||(r.uuid=this.extractUuidFromUrl(r.cdnUrl)),r;if(typeof r=="string"){let o=this.extractUuidFromUrl(r);return{cdnUrl:r,uuid:o,name:"",size:0,mimeType:"",isImage:!1}}return r}),n=this.formatFilesForState(s);this.uploadedFiles=JSON.stringify(n),this.initialState=this.uploadedFiles},isValidUrl(e){if(!e||typeof e!="string")return!1;try{return new URL(e),!0}catch{return!1}},setupStateWatcher(){this.$watch("state",e=>{if(this.isLocalUpdate){this.isLocalUpdate=!1;return}e==null||e===""||e==="[]"||Array.isArray(e)&&e.length===0?this.clearAllFiles(!1):e&&this.isInitialized&&this.addFilesFromState(e)})},parseStateValue(e){if(!e)return null;try{return typeof e=="string"?JSON.parse(e):e}catch{return e}},addFilesFromState(e){let t=this.parseStateValue(e),i=t;if(console.log("[CROP DEBUG JS] addFilesFromState called",{newValue_type:typeof e,parsed_length:Array.isArray(t)?t.length:"not_array",first_item:t&&t[0]?{has_cdnUrlModifiers:!!t[0].cdnUrlModifiers,cdnUrlModifiers:t[0].cdnUrlModifiers,cdnUrl:t[0].cdnUrl,keys:Object.keys(t[0])}:null}),Array.isArray(i)||(i=[i]),i=i.filter(l=>l!=null),i.length===0)return!1;let a=this.getUploadcareApi();if(!a||typeof a.addFileFromCdnUrl!="function")return!1;let n=this.getCurrentFiles().map(l=>l?l&&typeof l=="object"?l.cdnUrl:l:null).filter(Boolean);i.forEach((l,h)=>{if(!l){console.warn(`[Uploadcare] Skipping null item at index ${h}`);return}let c=l&&typeof l=="object"?l.cdnUrl:l;if(c&&typeof c=="string"&&(c.includes("ucarecdn.com")||c.includes("ucarecd.net"))&&!n.some(p=>{let U=this.extractUuidFromUrl(c),g=this.extractUuidFromUrl(p);return U&&g&&U===g})){console.log("[CROP DEBUG JS] Adding file to Uploadcare",{url:c,has_cdnUrlModifiers:!!l.cdnUrlModifiers,cdnUrlModifiers:l.cdnUrlModifiers,url_includes_modifiers:c.includes("-/")});try{a.addFileFromCdnUrl(c)}catch(p){console.error("[Uploadcare] Failed to add file from URL:",c,p)}}});let r=[],d=new Set,o=l=>{if(!l)return;let h=l&&typeof l=="object"?l.cdnUrl:l,c=this.extractUuidFromUrl(h);c&&!d.has(c)?(d.add(c),this.isWithMetadata&&typeof l!="object"?r.push({cdnUrl:l,uuid:c,name:"",size:0,mimeType:"",isImage:!1}):r.push(l)):c||r.push(l)},f=this.parseStateValue(e)||[];return(Array.isArray(f)?f:[f]).forEach(o),this.uploadedFiles=JSON.stringify(r),this.isLocalUpdate=!0,!0},normalizeStateValue(e){if(!e)return"";try{let t=typeof e=="string"?JSON.parse(e):e;if(Array.isArray(t)&&t.every(s=>typeof s=="string"||typeof s=="object"&&s!==null&&("cdnUrl"in s||"uuid"in s)))return JSON.stringify(t);let i=this.formatFilesForState(t);return JSON.stringify(i)}catch(t){return console.error("[Uploadcare] normalizeStateValue error",t),e}},isStateChanged(){let e=this.normalizeStateValue(this.state),t=this.normalizeStateValue(this.initialState);return e!==t},setupEventListeners(e){this.pendingUploads=[],this.pendingRemovals=[];let t=this.createFileUploadSuccessHandler(e),i=this.createFileUrlChangedHandler(e),a=this.createFileRemovedHandler(e),s=this.createFormInputChangeHandler(e),n=r=>{if(r.target!==this.ctx&&!this.ctx.contains(r.target))return;let d=this.$el.closest("form");d&&d.dispatchEvent(new CustomEvent("form-processing-started",{detail:{message:"Uploading file..."}}))};this.ctx.addEventListener("file-upload-started",n),this.ctx.addEventListener("file-upload-success",t),this.ctx.addEventListener("file-url-changed",i),this.ctx.addEventListener("file-removed",a),this.$nextTick(()=>{let r=this.$el.querySelector("uc-form-input input");if(r){r.addEventListener("input",s),r.addEventListener("change",s);let d=new MutationObserver(()=>{s({target:r})});d.observe(r,{attributes:!0,attributeFilter:["value"]}),this.formInputObserver=d}}),this.removeEventListeners=()=>{this.ctx.removeEventListener("file-upload-started",n),this.ctx.removeEventListener("file-upload-success",t),this.ctx.removeEventListener("file-url-changed",i),this.ctx.removeEventListener("file-removed",a);let r=this.$el.querySelector("uc-form-input input");r&&(r.removeEventListener("input",s),r.removeEventListener("change",s)),this.formInputObserver&&(this.formInputObserver.disconnect(),this.formInputObserver=null)}},createFileUploadSuccessHandler(e){let t=null;return i=>{if(i.target.getAttribute("ctx-name")!==this.uniqueContextName&&i.target!==this.ctx&&!this.ctx.contains(i.target))return;let s=this.isWithMetadata?i.detail:i.detail.cdnUrl;this.pendingUploads.push(s),t&&clearTimeout(t),t=setTimeout(()=>{try{let n=this.getCurrentFiles();for(let d of this.pendingUploads)n=this.updateFilesList(n,d);this.updateState(n),this.pendingUploads=[];let r=this.$el.closest("form");r&&r.dispatchEvent(new CustomEvent("form-processing-finished"))}catch(n){console.error("[Uploadcare] Error updating state after upload:",n)}},200)}},createFileUrlChangedHandler(e){let t=null;return i=>{if(i.target.getAttribute("ctx-name")!==this.uniqueContextName&&i.target!==this.ctx&&!this.ctx.contains(i.target))return;let a=i.detail;t&&clearTimeout(t),t=setTimeout(()=>{try{let s=this.getCurrentFiles(),n=this.updateFileUrl(s,a);console.log("[CROP DEBUG JS] File URL changed",{uuid:a.uuid,new_url:a.cdnUrl,has_modifiers:a.cdnUrl?.includes("-/")}),this.updateState(n)}catch(s){console.error("Error updating state after URL change:",s)}},100)}},createFileRemovedHandler(e){let t=null;return i=>{if(i.target.getAttribute("ctx-name")!==this.uniqueContextName&&i.target!==this.ctx&&!this.ctx.contains(i.target))return;let a=i.detail;this.pendingRemovals.push(a),t&&clearTimeout(t),t=setTimeout(()=>{try{let s=this.getCurrentFiles();for(let n of this.pendingRemovals)s=this.removeFile(s,n);this.updateState(s),this.pendingRemovals=[]}catch(s){console.error("Error in handleFileRemoved:",s)}},100)}},createFormInputChangeHandler(e){return t=>{}},getCurrentFiles(){try{let e=this.uploadedFiles?JSON.parse(this.uploadedFiles):[];return Array.isArray(e)?e:[]}catch{return[]}},updateFilesList(e,t){if(this.isMultiple){let i=this.extractUuidFromUrl(t);return e.some(s=>this.extractUuidFromUrl(s)===i)?e:[...e,t]}return[t]},updateFileUrl(e,t){let i=t.uuid;if(!i&&t.cdnUrl&&(i=this.extractUuidFromUrl(t.cdnUrl)),!i)return e;t.uuid||(t={...t,uuid:i});let a=this.findFileIndex(e,i);if(a===-1)return e;let s;if(this.isWithMetadata){let n=e[a];if(typeof n=="string"){let r=this.extractUuidFromUrl(n);n={cdnUrl:n,uuid:r,name:"",size:0,mimeType:"",isImage:!1}}if(s={...n,...t},s.cdnUrl){let r=this.extractModifiersFromUrl(s.cdnUrl);r?s.cdnUrlModifiers=r:(s.cdnUrlModifiers=null,delete s.cdnUrlModifiers)}}else s=t.cdnUrl;if(this.isMultiple){let n=[...e];return n[a]=s,n}return[s]},removeFile(e,t){let i=this.findFileIndex(e,t.uuid);if(i===-1)return e;if(this.isMultiple){let a=[...e];return a.splice(i,1),a}return[]},findFileIndex(e,t){return t?e.findIndex(i=>{let a=i&&typeof i=="object"?i.cdnUrl:i;return this.extractUuidFromUrl(a)===t}):-1},updateState(e){if(!this.isUpdatingState){this.isUpdatingState=!0;try{let t=new Set,i=e.filter(o=>{let f=o&&typeof o=="object"?o.cdnUrl:o,l=this.extractUuidFromUrl(f);return l?t.has(l)?!1:(t.add(l),!0):!0});console.log(`[CROP DEBUG JS] ${this.name} updateState`,{count:i.length,first_has_modifiers:!!i[0]?.cdnUrlModifiers,first_modifiers:i[0]?.cdnUrlModifiers});let a=this.formatFilesForState(i),s=this.buildStateFromFiles(a),n=this.normalizeStateValue(this.uploadedFiles),r=this.normalizeStateValue(s);n!==r&&(this.uploadedFiles=s,this.isLocalUpdate=!0,this.state=this.uploadedFiles,this.isMultiple&&i.length>1&&this.$nextTick(()=>{this.isLocalUpdate=!1}))}finally{this.isUpdatingState=!1}}},formatFilesForState(e){return e?Array.isArray(e)?e.map(t=>this.isWithMetadata?t:t&&typeof t=="object"?t.cdnUrl:t):[]:[]},setupDoneButtonObserver(){let e=this.$el.closest(".uploadcare-wrapper");e&&(this.doneButtonHider=new m(e))},destroy(){this.doneButtonHider&&(this.doneButtonHider.destroy(),this.doneButtonHider=null),this.documentClassObserver&&(this.documentClassObserver.disconnect(),this.documentClassObserver=null),this.formInputObserver&&(this.formInputObserver.disconnect(),this.formInputObserver=null),this.removeEventListeners&&this.removeEventListeners()},extractUuidFromUrl(e){if(!e)return null;let t=e;if(typeof e=="object"){if(e.uuid)return e.uuid;t=e.cdnUrl||""}if(!t||typeof t!="string")return null;if(/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(t))return t;let a=t.match(/\/([a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})(?:\/|$)/i);return a?a[1]:null},extractModifiersFromUrl(e){if(!e||typeof e!="string")return"";let t=this.extractUuidFromUrl(e);if(!t)return"";let i=e.split(t);if(i.length<2)return"";let a=i[1];return a.startsWith("/")&&(a=a.substring(1)),a.endsWith("/")&&(a=a.substring(0,a.length-1)),a},async syncStateWithUploadcare(e){try{let t=this.getCurrentFilesFromUploadcare(e);if(t.length>0){let s=[];for(let n of t){let r=n&&typeof n=="object"?n.cdnUrl:n;if(typeof r=="string"&&r.match(/[a-f0-9-]{36}~[0-9]+/))try{let d=await this.fetchGroupFiles(r);s.push(...d)}catch{s.push(n)}else s.push(n)}t=s}let i=this.formatFilesForState(t),a=this.buildStateFromFiles(i);this.normalizeStateValue(this.uploadedFiles)!==this.normalizeStateValue(a)&&(this.uploadedFiles=a,this.isLocalUpdate=!0,this.state=this.uploadedFiles)}catch(t){console.error("Error syncing state with Uploadcare:",t)}},async fetchGroupFiles(e){let t=e;if(e.includes("ucarecdn.com")||e.includes("ucarecd.net")){let s=e.match(/\/([a-f0-9-]{36}~[0-9]+)/);s&&(t=s[1])}let i=await fetch(`https://upload.uploadcare.com/group/info/?pub_key=${this.publicKey}&group_id=${t}`);if(!i.ok)throw new Error(`Failed to fetch group info: ${i.statusText}`);let a=await i.json();return a.files?a.files.map(s=>{let n=`https://ucarecdn.com/${s.uuid}/`;return this.isWithMetadata?{uuid:s.uuid,cdnUrl:n,name:s.original_filename,size:s.size,mimeType:s.mime_type,isImage:s.is_image}:n}):[]},buildStateFromFiles(e){return this.isMultiple||this.isWithMetadata?JSON.stringify(e):e.length>0?e[0]:""},getCurrentFilesFromUploadcare(e){try{if(e&&typeof e.value=="function"){let i=e.value();if(i)return(Array.isArray(i)?i:this.parseFormInputValue(i)).filter(s=>s!=null)}let t=this.$el.querySelector("uc-form-input input");return t?this.parseFormInputValue(t.value).filter(i=>i!=null):[]}catch(t){return console.error("Error getting current files from Uploadcare:",t),[]}},parseFormInputValue(e){if(!e||typeof e=="string"&&e.trim()==="")return[];if(typeof e=="object")return[e];try{let t=JSON.parse(e);return Array.isArray(t)?t.filter(i=>i!==null&&i!==""):t!==null&&t!==""?[t]:[]}catch{return typeof e=="string"&&e.trim()!==""?[e]:[]}},clearAllFiles(e=!0){let t=this.getUploadcareApi();if(t){try{if(t.collection&&typeof t.collection.clear=="function")t.collection.clear();else if(typeof t.getCollection=="function"){let i=t.getCollection();i&&typeof i.clear=="function"&&i.clear()}}catch{}try{typeof t.removeAllFiles=="function"&&t.removeAllFiles()}catch{}try{typeof t.value=="function"&&t.value(this.isMultiple?[]:"")}catch{}}this.uploadedFiles!==(this.isMultiple||this.isWithMetadata?"[]":"")&&(this.uploadedFiles=this.isMultiple||this.isWithMetadata?"[]":"",this.isLocalUpdate=!0,e&&(this.state=this.uploadedFiles))}}}export{F as default}; +var m=class{constructor(t){this.wrapper=t,this.observer=null,this.init()}init(){this.hideDoneButtons(),this.setupObserver()}setupObserver(){this.observer=new MutationObserver(t=>{t.forEach(e=>{e.type==="childList"&&e.addedNodes.forEach(i=>{if(i.nodeType===Node.ELEMENT_NODE){i.classList&&i.classList.contains("uc-done-btn")&&this.hideDoneButton(i);let a=i.querySelectorAll&&i.querySelectorAll(".uc-done-btn");a&&a.forEach(s=>this.hideDoneButton(s))}})})}),this.wrapper&&this.observer.observe(this.wrapper,{childList:!0,subtree:!0})}hideDoneButtons(){document.querySelectorAll(".uc-done-btn").forEach(e=>this.hideDoneButton(e))}hideDoneButton(t){t&&(t.style.display="none",t.style.visibility="hidden",t.style.opacity="0",t.style.pointerEvents="none",t.style.position="absolute",t.style.width="0",t.style.height="0",t.style.overflow="hidden",t.style.clip="rect(0, 0, 0, 0)",t.style.margin="0",t.style.padding="0",t.style.border="0",t.style.background="transparent",t.style.color="transparent",t.style.fontSize="0",t.style.lineHeight="0")}destroy(){this.observer&&(this.observer.disconnect(),this.observer=null)}};function g(u){return window._initializedUploadcareContexts||(window._initializedUploadcareContexts=new Set),{name:u.statePath||"unknown",state:u.state,statePath:u.statePath,initialState:u.initialState,publicKey:u.publicKey,isMultiple:u.isMultiple,multipleMin:u.multipleMin,multipleMax:u.multipleMax,isImagesOnly:u.isImagesOnly,accept:u.accept,sourceList:u.sourceList,uploaderStyle:u.uploaderStyle,isWithMetadata:u.isWithMetadata,localeName:u.localeName||"en",uploadedFiles:"",ctx:null,removeEventListeners:null,uniqueContextName:u.uniqueContextName,pendingUploads:[],pendingRemovals:[],isInitialized:!1,stateHasBeenInitialized:!1,isStateWatcherActive:!1,isLocalUpdate:!1,doneButtonHider:null,documentClassObserver:null,formInputObserver:null,isUpdatingState:!1,async init(){this.isContextAlreadyInitialized()||(this.markContextAsInitialized(),this.applyTheme(),await this.loadAllLocales(),this.$el.isConnected&&(this.setupStateWatcher(),this.$el.addEventListener("uploadcare-state-updated",t=>{let e=t.detail.uuid;e&&this.isInitialized?this.loadFileFromUuid(e):e&&this.$nextTick(()=>{this.isInitialized&&this.loadFileFromUuid(e)})}),this.initUploadcare(),this.setupThemeObservers(),this.setupDoneButtonObserver(),(!this.state||this.state==="[]"||this.state==='""')&&this.$nextTick(()=>{this.isInitialized&&this.getCurrentFiles().length>0&&this.clearAllFiles(!1)})))},isContextAlreadyInitialized(){return window._initializedUploadcareContexts.has(this.uniqueContextName)},markContextAsInitialized(){window._initializedUploadcareContexts.add(this.uniqueContextName)},async loadAllLocales(){window._uploadcareAllLocalesLoaded||await new Promise(e=>{if(window._uploadcareAllLocalesLoaded){e();return}let i=setInterval(()=>{window._uploadcareAllLocalesLoaded&&(clearInterval(i),e())},100);setTimeout(()=>{clearInterval(i),e()},5e3)});let t=["de","es","fr","he","it","nl","pl","pt","ru","tr","uk","zh-TW","zh"];document.querySelectorAll("uc-config[data-locale-name]").forEach(e=>{let i=e.getAttribute("data-locale-name");i&&t.includes(i)&&!e.getAttribute("locale-name")&&e.setAttribute("locale-name",i)})},async loadLocale(){if(this.localeName==="en"||this.localeLoaded)return;if(window._uploadcareLocales&&window._uploadcareLocales.has(this.localeName)){this.localeLoaded=!0;return}if(window._uploadcareLocales||(window._uploadcareLocales=new Set),!!["de","es","fr","he","it","nl","pl","pt","ru","tr","uk","zh-TW","zh"].includes(this.localeName))try{let i=await import(`https://cdn.jsdelivr.net/npm/@uploadcare/file-uploader@v1/locales/file-uploader/${this.localeName}.js`),a=i.default||i,s=()=>{let r=customElements.get("uc-file-uploader-inline")||customElements.get("uc-file-uploader-regular")||customElements.get("uc-file-uploader-minimal");return r&&r.UC?r.UC:window.UC},n=()=>{let r=s();return r&&typeof r.defineLocale=="function"?(r.defineLocale(this.localeName,a),window._uploadcareLocales.add(this.localeName),this.localeLoaded=!0,!0):!1};if(!n()){let r=0,o=50,c=setInterval(()=>{r++,(n()||r>=o)&&clearInterval(c)},100)}}catch(e){console.error("[Uploadcare Locale JS] Failed to load locale:",this.localeName,e)}},applyTheme(){let t=this.getCurrentTheme();this.$el.querySelectorAll(`uc-file-uploader-${this.uploaderStyle}`).forEach(i=>{i.classList.remove("uc-dark","uc-light"),i.classList.add(`uc-${t}`)})},getCurrentTheme(){return document.documentElement.classList.contains("dark")?"dark":"light"},setupThemeObservers(){window.addEventListener("storage",this.handleThemeStorageChange.bind(this)),window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change",this.handleSystemThemeChange.bind(this)),this.setupDocumentClassObserver()},handleThemeStorageChange(t){t.key==="theme"&&this.applyTheme()},handleSystemThemeChange(){localStorage.getItem("theme")==="system"&&this.applyTheme()},setupDocumentClassObserver(){this.documentClassObserver=new MutationObserver(t=>{t.forEach(e=>{if(e.type==="attributes"&&e.attributeName==="class"){let i=document.documentElement.classList.contains("dark"),a=e.oldValue&&e.oldValue.includes("dark");i!==a&&this.applyTheme()}})}),this.documentClassObserver.observe(document.documentElement,{attributes:!0,attributeOldValue:!0,attributeFilter:["class"]})},initUploadcare(){this.removeEventListeners&&this.removeEventListeners(),this.initializeUploader()},initializeUploader(t=0,e=10){if(t>=e)return;this.ctx=this.$el.querySelector(`uc-upload-ctx-provider[ctx-name="${this.uniqueContextName}"]`);let i=this.getUploadcareApi();if(!this.isValidContext(i)){setTimeout(()=>this.initializeUploader(t+1,e),100);return}this.markAsInitialized(),this.removeRequiredAttributes(),this.initializeState(i),this.setupEventListeners(i)},getUploadcareApi(){try{return this.ctx?.getAPI()}catch{return null}},isValidContext(t){return this.ctx&&t&&t.addFileFromCdnUrl},markAsInitialized(){this.isInitialized=!0},removeRequiredAttributes(){setTimeout(()=>{let t=this.$el.closest("uc-config");document.querySelectorAll("uc-form-input input[required]").forEach(i=>i.removeAttribute("required"))},100)},initializeState(t){this.initialState&&!this.stateHasBeenInitialized&&!this.uploadedFiles?this.loadInitialState(t):!this.initialState&&!this.stateHasBeenInitialized&&(this.stateHasBeenInitialized=!0,this.uploadedFiles=this.isMultiple||this.isWithMetadata?"[]":"",this.isLocalUpdate=!0,this.state=this.uploadedFiles)},loadInitialState(t){try{let e=this.parseInitialState();this.addFilesFromInitialState(t,e),this.stateHasBeenInitialized=!0,this.isLocalUpdate=!0,this.state=this.uploadedFiles}catch(e){console.error("Error parsing initialState:",e)}},parseInitialState(){let t=i=>{if(typeof i=="string")try{let a=JSON.parse(i);if(typeof a=="string")try{a=JSON.parse(a)}catch{}return a}catch{return i}return i};return this.initialState&&this.initialState&&typeof this.initialState=="object"&&!Array.isArray(this.initialState)&&(this.initialState=[this.initialState]),this.parseStateValue(this.initialState)},addFilesFromInitialState(t,e){let i=[];if(e&&e&&typeof e=="object"&&!Array.isArray(e))try{i=Array.from(e)}catch{i=[e]}else Array.isArray(e)?i=e:e&&(i=[e]);if(Array.isArray(i)&&i.length===1&&Array.isArray(i[0])&&(i=i[0]),Array.isArray(i)&&i.length===1&&typeof i[0]=="string")try{let r=JSON.parse(i[0]);i=Array.isArray(r)?r:[r]}catch{}if(!Array.isArray(i)||i.length===0)return;Array.isArray(i)||(i=[i]);let a=(r,o=0)=>{if(!r)return;if(Array.isArray(r)){r.forEach((h,d)=>{a(h,`${o}.${d}`)});return}if(typeof r=="string")try{let h=JSON.parse(r);a(h,o);return}catch{}let c=r&&typeof r=="object"?r.cdnUrl:r,p=r&&typeof r=="object"?r.cdnUrlModifiers:null;if(!c||!this.isValidUrl(c))return;let l=this.extractUuidFromUrl(c);if(l&&typeof t.addFileFromUuid=="function")try{if((p||c&&c.includes("/-/"))&&typeof t.addFileFromCdnUrl=="function"){let d=c;if(p){let y=c.split("/-/")[0],f=p;f.startsWith("/")&&(f=f.substring(1)),d=y+(y.endsWith("/")?"":"/")+(f.startsWith("-/")?"":"-/")+f}t.addFileFromCdnUrl(d)}else t.addFileFromUuid(l)}catch{}else console.error(l?"addFileFromUuid method not available on API":`Could not extract UUID from URL: ${c}`)};i.forEach(a);let s=i.map(r=>{let o=r;if(r&&typeof r=="object")return r.uuid||(r.uuid=this.extractUuidFromUrl(r.cdnUrl)),r;if(typeof r=="string"){let c=this.extractUuidFromUrl(r);return{cdnUrl:r,uuid:c,name:"",size:0,mimeType:"",isImage:!1}}return r}),n=this.formatFilesForState(s);this.uploadedFiles=JSON.stringify(n),this.initialState=this.uploadedFiles},isValidUrl(t){if(!t||typeof t!="string")return!1;try{return new URL(t),!0}catch{return!1}},setupStateWatcher(){this.$watch("state",t=>{if(this.isLocalUpdate){this.isLocalUpdate=!1;return}t==null||t===""||t==="[]"||Array.isArray(t)&&t.length===0?this.clearAllFiles(!1):t&&this.isInitialized&&this.addFilesFromState(t)})},parseStateValue(t){if(!t)return null;try{return typeof t=="string"?JSON.parse(t):t}catch{return t}},addFilesFromState(t){let i=this.parseStateValue(t);if(Array.isArray(i)||(i=[i]),i=i.filter(l=>l!=null),i.length===0)return!1;let a=this.getUploadcareApi();if(!a||typeof a.addFileFromCdnUrl!="function")return!1;let n=this.getCurrentFiles().map(l=>l?l&&typeof l=="object"?l.cdnUrl:l:null).filter(Boolean);i.forEach((l,h)=>{if(!l){console.warn(`[Uploadcare] Skipping null item at index ${h}`);return}let d=l&&typeof l=="object"?l.cdnUrl:l;if(d&&typeof d=="string"&&(d.includes("ucarecdn.com")||d.includes("ucarecd.net"))&&!n.some(f=>{let U=this.extractUuidFromUrl(d),F=this.extractUuidFromUrl(f);return U&&F&&U===F}))try{a.addFileFromCdnUrl(d)}catch(f){console.error("[Uploadcare] Failed to add file from URL:",d,f)}});let r=[],o=new Set,c=l=>{if(!l)return;let h=l&&typeof l=="object"?l.cdnUrl:l,d=this.extractUuidFromUrl(h);d&&!o.has(d)?(o.add(d),this.isWithMetadata&&typeof l!="object"?r.push({cdnUrl:l,uuid:d,name:"",size:0,mimeType:"",isImage:!1}):r.push(l)):d||r.push(l)},p=this.parseStateValue(t)||[];return(Array.isArray(p)?p:[p]).forEach(c),this.uploadedFiles=JSON.stringify(r),this.isLocalUpdate=!0,!0},normalizeStateValue(t){if(!t)return"";try{let e=typeof t=="string"?JSON.parse(t):t;if(Array.isArray(e)&&e.every(s=>typeof s=="string"||typeof s=="object"&&s!==null&&("cdnUrl"in s||"uuid"in s)))return JSON.stringify(e);let i=this.formatFilesForState(e);return JSON.stringify(i)}catch(e){return console.error("[Uploadcare] normalizeStateValue error",e),t}},isStateChanged(){let t=this.normalizeStateValue(this.state),e=this.normalizeStateValue(this.initialState);return t!==e},setupEventListeners(t){this.pendingUploads=[],this.pendingRemovals=[];let e=this.createFileUploadSuccessHandler(t),i=this.createFileUrlChangedHandler(t),a=this.createFileRemovedHandler(t),s=this.createFormInputChangeHandler(t),n=r=>{if(r.target!==this.ctx&&!this.ctx.contains(r.target))return;let o=this.$el.closest("form");o&&o.dispatchEvent(new CustomEvent("form-processing-started",{detail:{message:"Uploading file..."}}))};this.ctx.addEventListener("file-upload-started",n),this.ctx.addEventListener("file-upload-success",e),this.ctx.addEventListener("file-url-changed",i),this.ctx.addEventListener("file-removed",a),this.$nextTick(()=>{let r=this.$el.querySelector("uc-form-input input");if(r){r.addEventListener("input",s),r.addEventListener("change",s);let o=new MutationObserver(()=>{s({target:r})});o.observe(r,{attributes:!0,attributeFilter:["value"]}),this.formInputObserver=o}}),this.removeEventListeners=()=>{this.ctx.removeEventListener("file-upload-started",n),this.ctx.removeEventListener("file-upload-success",e),this.ctx.removeEventListener("file-url-changed",i),this.ctx.removeEventListener("file-removed",a);let r=this.$el.querySelector("uc-form-input input");r&&(r.removeEventListener("input",s),r.removeEventListener("change",s)),this.formInputObserver&&(this.formInputObserver.disconnect(),this.formInputObserver=null)}},createFileUploadSuccessHandler(t){let e=null;return i=>{if(i.target.getAttribute("ctx-name")!==this.uniqueContextName&&i.target!==this.ctx&&!this.ctx.contains(i.target))return;let s=this.isWithMetadata?i.detail:i.detail.cdnUrl;this.pendingUploads.push(s),e&&clearTimeout(e),e=setTimeout(()=>{try{let n=this.getCurrentFiles();for(let o of this.pendingUploads)n=this.updateFilesList(n,o);this.updateState(n),this.pendingUploads=[];let r=this.$el.closest("form");r&&r.dispatchEvent(new CustomEvent("form-processing-finished"))}catch(n){console.error("[Uploadcare] Error updating state after upload:",n)}},200)}},createFileUrlChangedHandler(t){let e=null;return i=>{if(i.target.getAttribute("ctx-name")!==this.uniqueContextName&&i.target!==this.ctx&&!this.ctx.contains(i.target))return;let a=i.detail;e&&clearTimeout(e),e=setTimeout(()=>{try{let s=this.getCurrentFiles(),n=this.updateFileUrl(s,a);this.updateState(n)}catch(s){console.error("Error updating state after URL change:",s)}},100)}},createFileRemovedHandler(t){let e=null;return i=>{if(i.target.getAttribute("ctx-name")!==this.uniqueContextName&&i.target!==this.ctx&&!this.ctx.contains(i.target))return;let a=i.detail;this.pendingRemovals.push(a),e&&clearTimeout(e),e=setTimeout(()=>{try{let s=this.getCurrentFiles();for(let n of this.pendingRemovals)s=this.removeFile(s,n);this.updateState(s),this.pendingRemovals=[]}catch(s){console.error("Error in handleFileRemoved:",s)}},100)}},createFormInputChangeHandler(t){return e=>{}},getCurrentFiles(){try{let t=this.uploadedFiles?JSON.parse(this.uploadedFiles):[];return Array.isArray(t)?t:[]}catch{return[]}},updateFilesList(t,e){if(this.isMultiple){let i=this.extractUuidFromUrl(e);return t.some(s=>this.extractUuidFromUrl(s)===i)?t:[...t,e]}return[e]},updateFileUrl(t,e){let i=e.uuid;if(!i&&e.cdnUrl&&(i=this.extractUuidFromUrl(e.cdnUrl)),!i)return t;e.uuid||(e={...e,uuid:i});let a=this.findFileIndex(t,i);if(a===-1)return t;let s;if(this.isWithMetadata){let n=t[a];if(typeof n=="string"){let r=this.extractUuidFromUrl(n);n={cdnUrl:n,uuid:r,name:"",size:0,mimeType:"",isImage:!1}}if(s={...n,...e},s.cdnUrl){let r=this.extractModifiersFromUrl(s.cdnUrl);r?s.cdnUrlModifiers=r:(s.cdnUrlModifiers=null,delete s.cdnUrlModifiers)}}else s=e.cdnUrl;if(this.isMultiple){let n=[...t];return n[a]=s,n}return[s]},removeFile(t,e){let i=this.findFileIndex(t,e.uuid);if(i===-1)return t;if(this.isMultiple){let a=[...t];return a.splice(i,1),a}return[]},findFileIndex(t,e){return e?t.findIndex(i=>{let a=i&&typeof i=="object"?i.cdnUrl:i;return this.extractUuidFromUrl(a)===e}):-1},updateState(t){if(!this.isUpdatingState){this.isUpdatingState=!0;try{let e=new Set,i=t.filter(c=>{let p=c&&typeof c=="object"?c.cdnUrl:c,l=this.extractUuidFromUrl(p);return l?e.has(l)?!1:(e.add(l),!0):!0}),a=this.formatFilesForState(i),s=this.buildStateFromFiles(a),n=this.normalizeStateValue(this.uploadedFiles),r=this.normalizeStateValue(s);n!==r&&(this.uploadedFiles=s,this.isLocalUpdate=!0,this.state=this.uploadedFiles,this.isMultiple&&i.length>1&&this.$nextTick(()=>{this.isLocalUpdate=!1}))}finally{this.isUpdatingState=!1}}},formatFilesForState(t){return t?Array.isArray(t)?t.map(e=>this.isWithMetadata?e:e&&typeof e=="object"?e.cdnUrl:e):[]:[]},setupDoneButtonObserver(){let t=this.$el.closest(".uploadcare-wrapper");t&&(this.doneButtonHider=new m(t))},destroy(){this.doneButtonHider&&(this.doneButtonHider.destroy(),this.doneButtonHider=null),this.documentClassObserver&&(this.documentClassObserver.disconnect(),this.documentClassObserver=null),this.formInputObserver&&(this.formInputObserver.disconnect(),this.formInputObserver=null),this.removeEventListeners&&this.removeEventListeners()},extractUuidFromUrl(t){if(!t)return null;let e=t;if(typeof t=="object"){if(t.uuid)return t.uuid;e=t.cdnUrl||""}if(!e||typeof e!="string")return null;if(/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(e))return e;let a=e.match(/\/([a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})(?:\/|$)/i);return a?a[1]:null},extractModifiersFromUrl(t){if(!t||typeof t!="string")return"";let e=this.extractUuidFromUrl(t);if(!e)return"";let i=t.split(e);if(i.length<2)return"";let a=i[1];return a.startsWith("/")&&(a=a.substring(1)),a.endsWith("/")&&(a=a.substring(0,a.length-1)),a},async syncStateWithUploadcare(t){try{let e=this.getCurrentFilesFromUploadcare(t);if(e.length>0){let s=[];for(let n of e){let r=n&&typeof n=="object"?n.cdnUrl:n;if(typeof r=="string"&&r.match(/[a-f0-9-]{36}~[0-9]+/))try{let o=await this.fetchGroupFiles(r);s.push(...o)}catch{s.push(n)}else s.push(n)}e=s}let i=this.formatFilesForState(e),a=this.buildStateFromFiles(i);this.normalizeStateValue(this.uploadedFiles)!==this.normalizeStateValue(a)&&(this.uploadedFiles=a,this.isLocalUpdate=!0,this.state=this.uploadedFiles)}catch(e){console.error("Error syncing state with Uploadcare:",e)}},async fetchGroupFiles(t){let e=t;if(t.includes("ucarecdn.com")||t.includes("ucarecd.net")){let s=t.match(/\/([a-f0-9-]{36}~[0-9]+)/);s&&(e=s[1])}let i=await fetch(`https://upload.uploadcare.com/group/info/?pub_key=${this.publicKey}&group_id=${e}`);if(!i.ok)throw new Error(`Failed to fetch group info: ${i.statusText}`);let a=await i.json();return a.files?a.files.map(s=>{let n=`https://ucarecdn.com/${s.uuid}/`;return this.isWithMetadata?{uuid:s.uuid,cdnUrl:n,name:s.original_filename,size:s.size,mimeType:s.mime_type,isImage:s.is_image}:n}):[]},buildStateFromFiles(t){return this.isMultiple||this.isWithMetadata?JSON.stringify(t):t.length>0?t[0]:""},getCurrentFilesFromUploadcare(t){try{if(t&&typeof t.value=="function"){let i=t.value();if(i)return(Array.isArray(i)?i:this.parseFormInputValue(i)).filter(s=>s!=null)}let e=this.$el.querySelector("uc-form-input input");return e?this.parseFormInputValue(e.value).filter(i=>i!=null):[]}catch(e){return console.error("Error getting current files from Uploadcare:",e),[]}},parseFormInputValue(t){if(!t||typeof t=="string"&&t.trim()==="")return[];if(typeof t=="object")return[t];try{let e=JSON.parse(t);return Array.isArray(e)?e.filter(i=>i!==null&&i!==""):e!==null&&e!==""?[e]:[]}catch{return typeof t=="string"&&t.trim()!==""?[t]:[]}},clearAllFiles(t=!0){let e=this.getUploadcareApi();if(e){try{if(e.collection&&typeof e.collection.clear=="function")e.collection.clear();else if(typeof e.getCollection=="function"){let i=e.getCollection();i&&typeof i.clear=="function"&&i.clear()}}catch{}try{typeof e.removeAllFiles=="function"&&e.removeAllFiles()}catch{}try{typeof e.value=="function"&&e.value(this.isMultiple?[]:"")}catch{}}this.uploadedFiles!==(this.isMultiple||this.isWithMetadata?"[]":"")&&(this.uploadedFiles=this.isMultiple||this.isWithMetadata?"[]":"",this.isLocalUpdate=!0,t&&(this.state=this.uploadedFiles))}}}export{g as default}; diff --git a/packages/filament-uploadcare-field/resources/js/components/uploadcare.js b/packages/filament-uploadcare-field/resources/js/components/uploadcare.js index 062c0d90..1c434b86 100644 --- a/packages/filament-uploadcare-field/resources/js/components/uploadcare.js +++ b/packages/filament-uploadcare-field/resources/js/components/uploadcare.js @@ -402,14 +402,6 @@ export default function uploadcareField(config) { const url = (item && typeof item === 'object') ? item.cdnUrl : item; const cdnUrlModifiers = (item && typeof item === 'object') ? item.cdnUrlModifiers : null; - console.log(`[CROP DEBUG JS] ${this.name} addFile (initialState)`, { - index, - url, - cdnUrlModifiers, - has_modifiers_in_url: url && url.includes('/-/'), - item: JSON.stringify(item).substring(0, 500) // Show partial item to avoid huge logs - }); - if (!url || !this.isValidUrl(url)) { return; } @@ -430,7 +422,6 @@ export default function uploadcareField(config) { fullUrl = baseUrl + (baseUrl.endsWith('/') ? '' : '/') + (modifiers.startsWith('-/') ? '' : '-/') + modifiers; } - console.log(`[CROP DEBUG JS] ${this.name} api.addFileFromCdnUrl`, { fullUrl }); api.addFileFromCdnUrl(fullUrl); } else { api.addFileFromUuid(uuid); @@ -525,16 +516,6 @@ export default function uploadcareField(config) { const parsed = this.parseStateValue(newValue); let filesToAdd = parsed; - console.log('[CROP DEBUG JS] addFilesFromState called', { - newValue_type: typeof newValue, - parsed_length: Array.isArray(parsed) ? parsed.length : 'not_array', - first_item: parsed && parsed[0] ? { - has_cdnUrlModifiers: !!parsed[0].cdnUrlModifiers, - cdnUrlModifiers: parsed[0].cdnUrlModifiers, - cdnUrl: parsed[0].cdnUrl, - keys: Object.keys(parsed[0]) - } : null - }); if (!Array.isArray(filesToAdd)) { filesToAdd = [filesToAdd]; @@ -579,12 +560,6 @@ export default function uploadcareField(config) { }); if (!urlExists) { - console.log('[CROP DEBUG JS] Adding file to Uploadcare', { - url: url, - has_cdnUrlModifiers: !!item.cdnUrlModifiers, - cdnUrlModifiers: item.cdnUrlModifiers, - url_includes_modifiers: url.includes('-/'), - }); try { api.addFileFromCdnUrl(url); } catch (e) { @@ -780,11 +755,6 @@ export default function uploadcareField(config) { const currentFiles = this.getCurrentFiles(); const updatedFiles = this.updateFileUrl(currentFiles, fileDetails); - console.log('[CROP DEBUG JS] File URL changed', { - uuid: fileDetails.uuid, - new_url: fileDetails.cdnUrl, - has_modifiers: fileDetails.cdnUrl?.includes('-/') - }); this.updateState(updatedFiles); } catch (n) { @@ -943,11 +913,6 @@ export default function uploadcareField(config) { return true; }); - console.log(`[CROP DEBUG JS] ${this.name} updateState`, { - count: uniqueFiles.length, - first_has_modifiers: !!uniqueFiles[0]?.cdnUrlModifiers, - first_modifiers: uniqueFiles[0]?.cdnUrlModifiers - }); const finalFiles = this.formatFilesForState(uniqueFiles); const newState = this.buildStateFromFiles(finalFiles); diff --git a/packages/filament-uploadcare-field/src/Forms/Components/Uploadcare.php b/packages/filament-uploadcare-field/src/Forms/Components/Uploadcare.php index f250e68d..6360d531 100644 --- a/packages/filament-uploadcare-field/src/Forms/Components/Uploadcare.php +++ b/packages/filament-uploadcare-field/src/Forms/Components/Uploadcare.php @@ -313,13 +313,6 @@ public function getState(): mixed { $state = parent::getState(); - \Log::info("[CROP DEBUG] Uploadcare::getState called", [ - 'field' => $this->getName(), - 'type' => gettype($state), - 'is_array' => is_array($state), - 'raw_state' => is_string($state) ? (json_validate($state) ? 'JSON STRING' : substr($state, 0, 100)) : (is_array($state) ? (array_is_list($state) ? 'LIST count ' . count($state) : 'ASSOC keys ' . implode(',', array_keys($state))) : $state), - ]); - // Handle double-encoded JSON or JSON strings if (is_string($state) && json_validate($state)) { $decoded = json_decode($state, true); @@ -354,12 +347,6 @@ public function getState(): mixed $resolved = $this->transformUrlsFromDb($resolved); } - \Log::info("[CROP DEBUG] Uploadcare::getState result", [ - 'field' => $this->getName(), - 'resolved_count' => count($resolved), - 'first_url' => is_array($resolved[0] ?? null) ? ($resolved[0]['cdnUrl'] ?? null) : ($resolved[0] ?? null), - ]); - // Final return format based on isMultiple() if ($this->isMultiple()) { return array_values($resolved); @@ -393,10 +380,6 @@ private static function resolveUlidsToUploadcareState(array $items, ?Model $reco return []; } - \Log::info("[CROP DEBUG] resolveUlidsToUploadcareState starting", [ - 'field' => $fieldName, - 'count' => count($items), - ]); $resolved = []; $ulidsToResolve = []; $preResolvedModels = []; @@ -436,12 +419,6 @@ private static function resolveUlidsToUploadcareState(array $items, ?Model $reco } } - \Log::info("[CROP DEBUG] resolveUlidsToUploadcareState searching for CFV", [ - 'record_ulid' => $record->ulid, - 'field_slug' => $fieldSlug, - 'original_field_name' => $fieldName, - ]); - $fieldValue = $record->values() ->where(function ($query) use ($fieldSlug) { $query->whereHas('field', function ($q) use ($fieldSlug) { @@ -459,7 +436,6 @@ private static function resolveUlidsToUploadcareState(array $items, ?Model $reco ->get() ->keyBy('ulid'); - \Log::info("[CROP DEBUG] resolveUlidsToUploadcareState: Loaded " . $mediaItems->count() . " media items via CFV"); } } catch (\Exception $e) {} } @@ -472,13 +448,11 @@ private static function resolveUlidsToUploadcareState(array $items, ?Model $reco ->whereIn('media_ulid', array_values($ulidsToResolve)) ->get() ->keyBy('ulid'); - \Log::info("[CROP DEBUG] resolveUlidsToUploadcareState: Loaded " . ($mediaItems ? $mediaItems->count() : 0) . " media items via record fallback"); } catch (\Exception $e) {} } if (! $mediaItems || $mediaItems->isEmpty()) { $mediaItems = $mediaModel::whereIn('ulid', array_values($ulidsToResolve))->get()->keyBy('ulid'); - \Log::info("[CROP DEBUG] resolveUlidsToUploadcareState: Loaded " . ($mediaItems ? $mediaItems->count() : 0) . " media items via direct query fallback"); } foreach ($ulidsToResolve as $index => $ulid) { @@ -510,11 +484,6 @@ private static function resolveUlidsToUploadcareState(array $items, ?Model $reco }); $final = array_values($final); - \Log::info("[CROP DEBUG] resolveUlidsToUploadcareState finished", [ - 'field' => $fieldName, - 'count' => count($final), - ]); - return $final; } diff --git a/packages/uploadcare-field/src/Observers/ContentFieldValueObserver.php b/packages/uploadcare-field/src/Observers/ContentFieldValueObserver.php index cf3265fe..a0ee1aff 100644 --- a/packages/uploadcare-field/src/Observers/ContentFieldValueObserver.php +++ b/packages/uploadcare-field/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/packages/uploadcare-field/src/Uploadcare.php b/packages/uploadcare-field/src/Uploadcare.php index 5d91d01c..3e90f4ff 100755 --- a/packages/uploadcare-field/src/Uploadcare.php +++ b/packages/uploadcare-field/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 ae81c3e524ae75bb8b022c6557f5ed81eaeb6832 Mon Sep 17 00:00:00 2001 From: Baspa Date: Tue, 6 Jan 2026 17:54:29 +0100 Subject: [PATCH 29/43] fix: attach media files to duplicated content --- .../Content/DuplicateContentAction.php | 25 ++++++++++++++----- 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/packages/core/src/Actions/Content/DuplicateContentAction.php b/packages/core/src/Actions/Content/DuplicateContentAction.php index d19fb11f..42863ddd 100644 --- a/packages/core/src/Actions/Content/DuplicateContentAction.php +++ b/packages/core/src/Actions/Content/DuplicateContentAction.php @@ -40,14 +40,27 @@ protected function setUp(): void } }) ->after(function (Model $replica): void { + $this->getRecord()->load('values.media'); + $replica->tags()->sync($this->getRecord()->tags->pluck('ulid')->toArray()); - $this->getRecord()->values->each(fn ($value) => $replica->values()->updateOrCreate([ - 'content_ulid' => $replica->getKey(), - 'field_ulid' => $value->field_ulid, - ], [ - 'value' => $value->value, - ])); + $this->getRecord()->values->each(function ($value) use ($replica) { + $newValue = $replica->values()->updateOrCreate([ + 'content_ulid' => $replica->getKey(), + 'field_ulid' => $value->field_ulid, + ], [ + 'value' => $value->value, + ]); + + if ($value->media->isNotEmpty()) { + $value->media->each(function ($mediaItem) use ($newValue) { + $newValue->media()->attach($mediaItem->ulid, [ + 'position' => $mediaItem->pivot->position ?? 1, + 'meta' => $mediaItem->pivot->meta ?? [], + ]); + }); + } + }); }) // ->modalHeading(function () { // return "Duplicate {$this->getRecord()->name} {$this->getRecord()->type->name}"; From 68cb7c6ef6a14209f8e013f86cefd4a3a7edafde Mon Sep 17 00:00:00 2001 From: Baspa Date: Tue, 6 Jan 2026 18:07:11 +0100 Subject: [PATCH 30/43] fix: accidental added code block --- packages/uploadcare-field/src/Uploadcare.php | 47 +------------------- 1 file changed, 2 insertions(+), 45 deletions(-) diff --git a/packages/uploadcare-field/src/Uploadcare.php b/packages/uploadcare-field/src/Uploadcare.php index 2974d1c1..54c71ba9 100755 --- a/packages/uploadcare-field/src/Uploadcare.php +++ b/packages/uploadcare-field/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 f429ac814a6c58773ecad114303e5ff9af03d251 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 31/43] fix: styling --- packages/core/src/Models/Media.php | 2 + .../src/Forms/Components/Uploadcare.php | 7 +- .../Observers/ContentFieldValueObserver.php | 2 +- packages/uploadcare-field/src/Uploadcare.php | 115 ++++++++---------- 4 files changed, 57 insertions(+), 69 deletions(-) diff --git a/packages/core/src/Models/Media.php b/packages/core/src/Models/Media.php index e35b2745..899308a2 100644 --- a/packages/core/src/Models/Media.php +++ b/packages/core/src/Models/Media.php @@ -47,6 +47,7 @@ public function getEditAttribute(): ?array } $result = is_string($edit->pivot->meta) ? json_decode($edit->pivot->meta, true) : $edit->pivot->meta; + return $result; } @@ -56,6 +57,7 @@ public function getEditAttribute(): ?array } $result = is_string($edit->pivot->meta) ? json_decode($edit->pivot->meta, true) : $edit->pivot->meta; + return $result; } } diff --git a/packages/filament-uploadcare-field/src/Forms/Components/Uploadcare.php b/packages/filament-uploadcare-field/src/Forms/Components/Uploadcare.php index 724787b0..80ac4123 100644 --- a/packages/filament-uploadcare-field/src/Forms/Components/Uploadcare.php +++ b/packages/filament-uploadcare-field/src/Forms/Components/Uploadcare.php @@ -313,7 +313,7 @@ public function maxLocalFileSize(string $size): static public function getState(): mixed { $state = parent::getState(); - + // Handle double-encoded JSON or JSON strings if (is_string($state) && json_validate($state)) { $decoded = json_decode($state, true); @@ -444,7 +444,7 @@ private static function resolveUlidsToUploadcareState(array $items, ?Model $reco ->whereIn('media_ulid', array_values($ulidsToResolve)) ->get() ->keyBy('ulid'); - + } } catch (\Exception $e) { } @@ -458,7 +458,8 @@ private static function resolveUlidsToUploadcareState(array $items, ?Model $reco ->whereIn('media_ulid', array_values($ulidsToResolve)) ->get() ->keyBy('ulid'); - } catch (\Exception $e) {} + } catch (\Exception $e) { + } } if (! $mediaItems || $mediaItems->isEmpty()) { diff --git a/packages/uploadcare-field/src/Observers/ContentFieldValueObserver.php b/packages/uploadcare-field/src/Observers/ContentFieldValueObserver.php index a0ee1aff..49b1ca6c 100644 --- a/packages/uploadcare-field/src/Observers/ContentFieldValueObserver.php +++ b/packages/uploadcare-field/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/packages/uploadcare-field/src/Uploadcare.php b/packages/uploadcare-field/src/Uploadcare.php index 54c71ba9..43026dde 100755 --- a/packages/uploadcare-field/src/Uploadcare.php +++ b/packages/uploadcare-field/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 85c85ec1cfe63e67fae2bc496172b3fa94e30c6a Mon Sep 17 00:00:00 2001 From: Yoni van Haarlem Date: Wed, 7 Jan 2026 15:00:44 +0100 Subject: [PATCH 32/43] Stacks instead of slots --- packages/core/resources/views/components/blocks.blade.php | 4 ++-- packages/core/resources/views/components/page.blade.php | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/core/resources/views/components/blocks.blade.php b/packages/core/resources/views/components/blocks.blade.php index af693453..eb416abe 100644 --- a/packages/core/resources/views/components/blocks.blade.php +++ b/packages/core/resources/views/components/blocks.blade.php @@ -1,5 +1,5 @@
- {{ $before ?? '' }} + @stack('blocks-first') @if ($blocks) @foreach ($blocks as $block) @php($className = \Backstage\Facades\Backstage::resolveComponent($block['type'])) @@ -10,5 +10,5 @@ @endif @endforeach @endif - {{ $after ?? '' }} + @stack('blocks-last')
diff --git a/packages/core/resources/views/components/page.blade.php b/packages/core/resources/views/components/page.blade.php index 745cbbde..da122ed7 100644 --- a/packages/core/resources/views/components/page.blade.php +++ b/packages/core/resources/views/components/page.blade.php @@ -5,7 +5,7 @@ {!! trim($pageTitle ?? $content->pageTitle) !!} - {{ $headFirst ?? '' }} + @stack('head-first') @@ -63,13 +63,13 @@ @endif - {{ $headLast ?? '' }} + @stack('head-last') - {{ $bodyFirst ?? '' }} + @stack('body-first') {{ $slot }} - {{ $bodyLast ?? '' }} + @stack('body-last') From 26f7986071888266a8d5a5ea5d65a2d00313fb39 Mon Sep 17 00:00:00 2001 From: Yoni van Haarlem Date: Wed, 7 Jan 2026 15:04:43 +0100 Subject: [PATCH 33/43] Undo block stacks, use slots instead --- packages/core/resources/views/components/blocks.blade.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core/resources/views/components/blocks.blade.php b/packages/core/resources/views/components/blocks.blade.php index eb416abe..af693453 100644 --- a/packages/core/resources/views/components/blocks.blade.php +++ b/packages/core/resources/views/components/blocks.blade.php @@ -1,5 +1,5 @@
- @stack('blocks-first') + {{ $before ?? '' }} @if ($blocks) @foreach ($blocks as $block) @php($className = \Backstage\Facades\Backstage::resolveComponent($block['type'])) @@ -10,5 +10,5 @@ @endif @endforeach @endif - @stack('blocks-last') + {{ $after ?? '' }}
From 2e22f94eab581cdb5f24cc172dc2427244e1f311 Mon Sep 17 00:00:00 2001 From: Mathieu Date: Fri, 9 Jan 2026 12:14:04 +0100 Subject: [PATCH 34/43] Add helpers.php --- packages/media/composer.json | 5 ++++- packages/media/src/helpers.php | 14 ++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) create mode 100644 packages/media/src/helpers.php diff --git a/packages/media/composer.json b/packages/media/composer.json index 04846165..348ef75b 100644 --- a/packages/media/composer.json +++ b/packages/media/composer.json @@ -43,7 +43,10 @@ "psr-4": { "Backstage\\Media\\": "src/", "Backstage\\Media\\Database\\Factories\\": "database/factories/" - } + }, + "files": [ + "src/helpers.php" + ] }, "autoload-dev": { "psr-4": { diff --git a/packages/media/src/helpers.php b/packages/media/src/helpers.php new file mode 100644 index 00000000..1f0fc57f --- /dev/null +++ b/packages/media/src/helpers.php @@ -0,0 +1,14 @@ + Date: Fri, 9 Jan 2026 11:14:34 +0000 Subject: [PATCH 35/43] fix: styling --- packages/media/src/helpers.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/media/src/helpers.php b/packages/media/src/helpers.php index 1f0fc57f..fbafde19 100644 --- a/packages/media/src/helpers.php +++ b/packages/media/src/helpers.php @@ -11,4 +11,4 @@ function country_flag($code) return 'flag-country-' . $code; } -} \ No newline at end of file +} From 6ff4b307277a95d7d8ffbb9856ccc456b7da8f49 Mon Sep 17 00:00:00 2001 From: Mathieu Date: Fri, 9 Jan 2026 13:09:51 +0100 Subject: [PATCH 36/43] Upgrade to laravel 12.x --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 17320102..65fe0d66 100644 --- a/composer.json +++ b/composer.json @@ -57,7 +57,7 @@ "laravel/pint": "^1.14", "nunomaduro/collision": "^8.1.1||^7.10.0", "larastan/larastan": "^3.7", - "orchestra/testbench": "^9.0.0||^8.22.0", + "orchestra/testbench": "^10.0", "pestphp/pest": "^4.1", "pestphp/pest-plugin-arch": "^4.0", "pestphp/pest-plugin-laravel": "^4.0", From 3b26427f500d80ad00b535187179c5dc86a7b84d Mon Sep 17 00:00:00 2001 From: Mathieu Date: Fri, 9 Jan 2026 14:37:18 +0100 Subject: [PATCH 37/43] Remove console check --- packages/core/src/Models/ContentFieldValue.php | 4 ---- 1 file changed, 4 deletions(-) diff --git a/packages/core/src/Models/ContentFieldValue.php b/packages/core/src/Models/ContentFieldValue.php index d03957db..d3e9519e 100644 --- a/packages/core/src/Models/ContentFieldValue.php +++ b/packages/core/src/Models/ContentFieldValue.php @@ -234,10 +234,6 @@ private function hydrateItemFields(array &$data, $fields): void public function shouldHydrate(): bool { - if (app()->runningInConsole()) { - return false; - } - if (! request()) { return true; } From 5527ba94a9bef150640c7f3f34386b943299f167 Mon Sep 17 00:00:00 2001 From: Mathieu Date: Fri, 9 Jan 2026 14:48:32 +0100 Subject: [PATCH 38/43] Fix setup --- .github/workflows/setup-in-laravel.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/setup-in-laravel.yml b/.github/workflows/setup-in-laravel.yml index ed52424e..adc09007 100644 --- a/.github/workflows/setup-in-laravel.yml +++ b/.github/workflows/setup-in-laravel.yml @@ -61,6 +61,6 @@ jobs: composer config minimum-stability dev git clone --branch ${{ steps.extract_branch.outputs.branch }} https://github.com/backstagephp/cms.git composer config repositories.backstage-packages path "cms/packages/*" - composer require backstage/cms:dev-${{ steps.extract_branch.outputs.branch }} + composer require backstage/cms:${{ steps.extract_branch.outputs.branch }}-dev composer update --no-interaction php artisan backstage:install From d931ce53a75de7d623696d59439b49d68b11d69a Mon Sep 17 00:00:00 2001 From: Mathieu Date: Fri, 9 Jan 2026 16:48:51 +0100 Subject: [PATCH 39/43] Add hydrate for field interface --- packages/core/src/Models/Content.php | 2 +- packages/core/src/Models/ContentFieldValue.php | 12 ++++++++++++ packages/core/src/Resources/ContentResource.php | 4 ---- packages/fields/src/Fields/Base.php | 5 ++++- packages/fields/src/Fields/Text.php | 9 ++++++++- packages/fields/src/FieldsServiceProvider.php | 17 +++++++++++++++++ 6 files changed, 42 insertions(+), 7 deletions(-) diff --git a/packages/core/src/Models/Content.php b/packages/core/src/Models/Content.php index e781dac8..6492bb99 100644 --- a/packages/core/src/Models/Content.php +++ b/packages/core/src/Models/Content.php @@ -343,7 +343,7 @@ public function blocks(string $field): array */ public function field(string $slug): Content | HtmlString | Collection | array | bool | string | Model | null { - $this->load('values'); + $this->load('values.field'); $val = $this->values->where('field.slug', $slug)->first()?->getHydratedValue(); diff --git a/packages/core/src/Models/ContentFieldValue.php b/packages/core/src/Models/ContentFieldValue.php index d3e9519e..e76f2939 100644 --- a/packages/core/src/Models/ContentFieldValue.php +++ b/packages/core/src/Models/ContentFieldValue.php @@ -54,6 +54,18 @@ public function media(): \Illuminate\Database\Eloquent\Relations\MorphToMany public function getHydratedValue(): Content | HtmlString | array | Collection | bool | string | Model | null { + $fieldClass = \Backstage\Fields\Facades\Fields::resolveField($this->field?->field_type ?? ''); + + if ($fieldClass) { + if (in_array(\Backstage\Fields\Contracts\HydratesValues::class, class_implements($fieldClass))) { + $instance = app($fieldClass); + + return $instance->hydrate($this->value, $this); + } + } + + trigger_error('use hydrate() of field instead.', E_USER_DEPRECATED); + if ($this->isRichEditor()) { $html = self::getRichEditorHtml($this->value ?? '') ?? ''; diff --git a/packages/core/src/Resources/ContentResource.php b/packages/core/src/Resources/ContentResource.php index 6efa6507..4bfb1f66 100644 --- a/packages/core/src/Resources/ContentResource.php +++ b/packages/core/src/Resources/ContentResource.php @@ -403,10 +403,6 @@ function (Set $set, $component) { ->formatStateUsing(fn ($state, ?Content $record) => $state ?? $record->language_code ?? null), ]), ]), - // Tab::make('microdata') - // ->label(__('Microdata')) - // ->icon('heroicon-o-code-bracket-square') - // ->schema([]), Tab::make('template') ->label(__('Template')) ->icon('heroicon-o-clipboard') diff --git a/packages/fields/src/Fields/Base.php b/packages/fields/src/Fields/Base.php index 36594474..e2b62a96 100644 --- a/packages/fields/src/Fields/Base.php +++ b/packages/fields/src/Fields/Base.php @@ -219,7 +219,10 @@ public static function getFieldValueFromRecord(Model $record, Field $field): mix $result = $values[$field->ulid] ?? $values[$field->slug] ?? null; } } - + if ($result instanceof \Illuminate\Support\HtmlString) { + $result = (string) $result; + } + return $result; } diff --git a/packages/fields/src/Fields/Text.php b/packages/fields/src/Fields/Text.php index b934a188..1abd86f2 100644 --- a/packages/fields/src/Fields/Text.php +++ b/packages/fields/src/Fields/Text.php @@ -5,6 +5,7 @@ use Backstage\Fields\Concerns\HasAffixes; use Backstage\Fields\Concerns\HasDatalist; use Backstage\Fields\Contracts\FieldContract; +use Backstage\Fields\Contracts\HydratesValues; use Backstage\Fields\Models\Field; use Filament\Forms\Components\Select; use Filament\Forms\Components\TextInput as Input; @@ -13,12 +14,18 @@ use Filament\Schemas\Components\Tabs; use Filament\Schemas\Components\Tabs\Tab; use Filament\Schemas\Components\Utilities\Get; +use Illuminate\Support\HtmlString; -class Text extends Base implements FieldContract +class Text extends Base implements FieldContract, HydratesValues { use HasAffixes; use HasDatalist; + public function hydrate(mixed $value, ?\Illuminate\Database\Eloquent\Model $model = null): mixed + { + return new HtmlString($value); + } + public static function getDefaultConfig(): array { return [ diff --git a/packages/fields/src/FieldsServiceProvider.php b/packages/fields/src/FieldsServiceProvider.php index 8773fa13..79b4f928 100644 --- a/packages/fields/src/FieldsServiceProvider.php +++ b/packages/fields/src/FieldsServiceProvider.php @@ -3,6 +3,21 @@ namespace Backstage\Fields; use Backstage\Fields\Contracts\FieldInspector; +use Backstage\Fields\Fields\Checkbox; +use Backstage\Fields\Fields\CheckboxList; +use Backstage\Fields\Fields\Color; +use Backstage\Fields\Fields\DateTime; +use Backstage\Fields\Fields\FileUpload; +use Backstage\Fields\Fields\KeyValue; +use Backstage\Fields\Fields\MarkdownEditor; +use Backstage\Fields\Fields\Radio; +use Backstage\Fields\Fields\Repeater; +use Backstage\Fields\Fields\RichEditor; +use Backstage\Fields\Fields\Select; +use Backstage\Fields\Fields\Tags; +use Backstage\Fields\Fields\Text; +use Backstage\Fields\Fields\Textarea; +use Backstage\Fields\Fields\Toggle; use Backstage\Fields\Services\FieldInspectionService; use Backstage\Fields\Testing\TestsFields; use Filament\Support\Assets\Asset; @@ -94,6 +109,8 @@ public function packageBooted(): void $this->app->bind(FieldInspector::class, FieldInspectionService::class); + Fields::registerField(Text::class); + collect($this->app['config']['backstage.fields.custom_fields'] ?? []) ->each(function ($field) { Fields::registerField($field); From a1edae7ebbb87373061c330cd0f85aa398f3cde5 Mon Sep 17 00:00:00 2001 From: Casmo <385764+Casmo@users.noreply.github.com> Date: Fri, 9 Jan 2026 15:49:23 +0000 Subject: [PATCH 40/43] fix: styling --- packages/fields/src/Fields/Base.php | 2 +- packages/fields/src/FieldsServiceProvider.php | 14 -------------- 2 files changed, 1 insertion(+), 15 deletions(-) diff --git a/packages/fields/src/Fields/Base.php b/packages/fields/src/Fields/Base.php index e2b62a96..df49a32f 100644 --- a/packages/fields/src/Fields/Base.php +++ b/packages/fields/src/Fields/Base.php @@ -222,7 +222,7 @@ public static function getFieldValueFromRecord(Model $record, Field $field): mix if ($result instanceof \Illuminate\Support\HtmlString) { $result = (string) $result; } - + return $result; } diff --git a/packages/fields/src/FieldsServiceProvider.php b/packages/fields/src/FieldsServiceProvider.php index 79b4f928..3d22079a 100644 --- a/packages/fields/src/FieldsServiceProvider.php +++ b/packages/fields/src/FieldsServiceProvider.php @@ -3,21 +3,7 @@ namespace Backstage\Fields; use Backstage\Fields\Contracts\FieldInspector; -use Backstage\Fields\Fields\Checkbox; -use Backstage\Fields\Fields\CheckboxList; -use Backstage\Fields\Fields\Color; -use Backstage\Fields\Fields\DateTime; -use Backstage\Fields\Fields\FileUpload; -use Backstage\Fields\Fields\KeyValue; -use Backstage\Fields\Fields\MarkdownEditor; -use Backstage\Fields\Fields\Radio; -use Backstage\Fields\Fields\Repeater; -use Backstage\Fields\Fields\RichEditor; -use Backstage\Fields\Fields\Select; -use Backstage\Fields\Fields\Tags; use Backstage\Fields\Fields\Text; -use Backstage\Fields\Fields\Textarea; -use Backstage\Fields\Fields\Toggle; use Backstage\Fields\Services\FieldInspectionService; use Backstage\Fields\Testing\TestsFields; use Filament\Support\Assets\Asset; From 78061d7603e2053adfacd7192741eee0d0abdbee Mon Sep 17 00:00:00 2001 From: Mathieu Date: Mon, 12 Jan 2026 12:57:17 +0100 Subject: [PATCH 41/43] Media uploaded eventfor uploadcare --- .../src/Events/MediaUploading.php | 16 ++++++++++++++++ .../src/Forms/Components/Uploadcare.php | 8 ++++++++ 2 files changed, 24 insertions(+) create mode 100644 packages/filament-uploadcare-field/src/Events/MediaUploading.php diff --git a/packages/filament-uploadcare-field/src/Events/MediaUploading.php b/packages/filament-uploadcare-field/src/Events/MediaUploading.php new file mode 100644 index 00000000..59bc8c69 --- /dev/null +++ b/packages/filament-uploadcare-field/src/Events/MediaUploading.php @@ -0,0 +1,16 @@ +afterStateUpdated(function (Uploadcare $component, $state, $old) { + if ($state !== $old && !empty($state)) { + Event::dispatch(new MediaUploading($state)); + } + }); } } From d36713399940457462aef56b9f8227f044e6917c Mon Sep 17 00:00:00 2001 From: Casmo <385764+Casmo@users.noreply.github.com> Date: Mon, 12 Jan 2026 11:57:51 +0000 Subject: [PATCH 42/43] fix: styling --- .../src/Forms/Components/Uploadcare.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/filament-uploadcare-field/src/Forms/Components/Uploadcare.php b/packages/filament-uploadcare-field/src/Forms/Components/Uploadcare.php index 1e29e04a..2ea42055 100644 --- a/packages/filament-uploadcare-field/src/Forms/Components/Uploadcare.php +++ b/packages/filament-uploadcare-field/src/Forms/Components/Uploadcare.php @@ -673,7 +673,7 @@ protected function setUp(): void }); $this->afterStateUpdated(function (Uploadcare $component, $state, $old) { - if ($state !== $old && !empty($state)) { + if ($state !== $old && ! empty($state)) { Event::dispatch(new MediaUploading($state)); } }); From ac23698217fcdf72b1151413843286c2b3bbb9e8 Mon Sep 17 00:00:00 2001 From: Mathieu Date: Mon, 12 Jan 2026 14:58:11 +0100 Subject: [PATCH 43/43] Remove helpers --- packages/media/composer.json | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/media/composer.json b/packages/media/composer.json index 348ef75b..8c3498c2 100644 --- a/packages/media/composer.json +++ b/packages/media/composer.json @@ -45,7 +45,6 @@ "Backstage\\Media\\Database\\Factories\\": "database/factories/" }, "files": [ - "src/helpers.php" ] }, "autoload-dev": {