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 diff --git a/composer.json b/composer.json index 4d840df5..65fe0d66 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", @@ -56,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", @@ -73,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/" }, @@ -82,7 +84,8 @@ }, "autoload-dev": { "psr-4": { - "Backstage\\Tests\\": "tests/" + "Backstage\\Tests\\": "tests/", + "Backstage\\UploadcareField\\Tests\\": "packages/uploadcare-field/tests/" } }, "scripts": { @@ -124,4 +127,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/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 4b1001c9..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\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/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/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') 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}"; diff --git a/packages/core/src/BackstageServiceProvider.php b/packages/core/src/BackstageServiceProvider.php index a0f46d5b..3172da1c 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; @@ -67,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, @@ -119,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'); }); @@ -195,6 +203,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 +322,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 +387,7 @@ private function generateFilamentFieldsConfig(): array ], ]; - config(['fields' => $config]); + config(['backstage.fields' => $config]); return $config; } 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 ); diff --git a/packages/core/src/Models/Content.php b/packages/core/src/Models/Content.php index 64830615..6492bb99 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->getHydratedValue(); + + // 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 @@ -192,8 +196,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 +316,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()?->getHydratedValue(); + + return is_array($value) ? $value : []; } /** @@ -331,12 +338,16 @@ 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 + public function field(string $slug): Content | HtmlString | Collection | array | bool | string | Model | null { - return $this->values->where('field.slug', $slug)->first()?->value(); + $this->load('values.field'); + + $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 28785f2e..e76f2939 100644 --- a/packages/core/src/Models/ContentFieldValue.php +++ b/packages/core/src/Models/ContentFieldValue.php @@ -9,6 +9,7 @@ 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\Support\HtmlString; @@ -35,9 +36,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 @@ -45,14 +46,46 @@ 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') + ->withPivot(['position', 'meta']); + } + + 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()) { - return new HtmlString(self::getRichEditorHtml($this->value ?? '')); + $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; + } + } + + if ($this->shouldHydrate() && $this->field->hasRelation()) { + return static::getContentRelation($this->value); } if ($this->isCheckbox()) { @@ -60,24 +93,18 @@ public function value(): Content | HtmlString | array | Collection | bool | null } 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 ?? ''); + // 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; } /** @@ -85,17 +112,159 @@ 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 + { + // 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, $field); + if ($hydrated) { + return $result; + } + } + + if ($this->shouldHydrate() && $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, ?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 { + $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]; + } + + 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') { + $html = self::getRichEditorHtml($data[$key] ?? '') ?? ''; + $data[$key] = $this->shouldHydrate() ? new HtmlString($html) : $html; + } else { + $data[$key] = $this->hydrateValuesRecursively($data[$key], $child); + } + } + } + } + + public function shouldHydrate(): bool + { + 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 { return $this->field->field_type === 'rich-editor'; @@ -108,7 +277,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/core/src/Models/Media.php b/packages/core/src/Models/Media.php index d537ea1c..899308a2 100644 --- a/packages/core/src/Models/Media.php +++ b/packages/core/src/Models/Media.php @@ -2,29 +2,62 @@ 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); + $mediaUlid = $this->ulid ?? 'UNKNOWN'; + + if ($this->relationLoaded('edits')) { + $edit = $this->edits->first(); + if (! $edit || ! $edit->relationLoaded('pivot') || ! $edit->pivot || ! $edit->pivot->meta) { + return null; + } + + $result = is_string($edit->pivot->meta) ? json_decode($edit->pivot->meta, true) : $edit->pivot->meta; + + return $result; + } + + $edit = $this->edits()->first(); + if (! $edit || ! $edit->pivot || ! $edit->pivot->meta) { + return null; + } + + $result = is_string($edit->pivot->meta) ? json_decode($edit->pivot->meta, true) : $edit->pivot->meta; + + return $result; } } diff --git a/packages/core/src/Resources/ContentResource.php b/packages/core/src/Resources/ContentResource.php index 833ed22f..4bfb1f66 100644 --- a/packages/core/src/Resources/ContentResource.php +++ b/packages/core/src/Resources/ContentResource.php @@ -199,17 +199,28 @@ 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') @@ -392,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') @@ -628,6 +635,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 +647,50 @@ 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 +1138,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 []; } diff --git a/packages/core/src/Resources/ContentResource/Pages/CreateContent.php b/packages/core/src/Resources/ContentResource/Pages/CreateContent.php index f33f9666..772e33d6 100644 --- a/packages/core/src/Resources/ContentResource/Pages/CreateContent.php +++ b/packages/core/src/Resources/ContentResource/Pages/CreateContent.php @@ -3,16 +3,18 @@ namespace Backstage\Resources\ContentResource\Pages; use Backstage\Fields\Concerns\CanMapDynamicFields; -use Backstage\Models\Tag; +use Backstage\Fields\Concerns\PersistsContentData; use Backstage\Resources\ContentResource; use Filament\Resources\Pages\CreateRecord; -use Illuminate\Support\Str; 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 +36,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(), @@ -66,4 +64,31 @@ 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; + + $this->data = [ + 'type_slug' => $typeSlug, + 'values' => [], + ]; + + // 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/core/src/Resources/ContentResource/Pages/EditContent.php b/packages/core/src/Resources/ContentResource/Pages/EditContent.php index 88c0d78a..3ab54ba8 100644 --- a/packages/core/src/Resources/ContentResource/Pages/EditContent.php +++ b/packages/core/src/Resources/ContentResource/Pages/EditContent.php @@ -5,9 +5,9 @@ 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; use Backstage\Models\Type; use Backstage\Resources\ContentResource; use Backstage\Translations\Laravel\Facades\Translator; @@ -21,12 +21,13 @@ 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 { + public int $formVersion = 0; + use CanMapDynamicFields; + use PersistsContentData; protected static string $resource = ContentResource::class; @@ -242,88 +243,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 +254,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/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/Concerns/CanMapDynamicFields.php b/packages/fields/src/Concerns/CanMapDynamicFields.php index 62520b66..b8474d76 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); + $containerData = $this->extractContainerData($values); + $allFields = $this->getAllFieldsIncludingNested($containerData); - return $this->mutateFormData($data, $allFields, function ($field, $fieldConfig, $fieldInstance, $data) use ($builderBlocks) { - return $this->applyFieldSaveMutation($field, $fieldConfig, $fieldInstance, $data, $builderBlocks); + 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,74 @@ 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; + $data[$this->record->valueColumn][$field->ulid] = $fieldInstance->getFieldValueFromRecord($this->record, $field); 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); + // 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; - // Update the main data structure - $data[$this->record->valueColumn] = array_merge($data[$this->record->valueColumn], $builderBlocks); + 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); + } 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,38 +188,8 @@ 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); @@ -303,92 +200,59 @@ private function resolveFieldConfigAndInstance(Model $field): array ]; } - /** - * 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(); - $fields = $values['data']; - $fields = ModelsField::whereIn('ulid', array_keys($fields))->get(); + $processedFields = $processedFields->merge($fields); - $processedFields = $processedFields->merge($fields); + // Recursive search + $nestedContainers = $this->extractContainerData($itemData); + if (! empty($nestedContainers)) { + $processedFields = $processedFields->merge($this->getNestedFieldsFromContainerData($nestedContainers)); + } + } } - }); + } - 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); - } + $valueColumn = $this->record->valueColumn ?? 'values'; + $oldValue = $data[$valueColumn][$field->ulid] ?? $data[$valueColumn][$field->slug] ?? 'NOT_SET'; - return $data; - } + $data = $mutationStrategy($field, $fieldConfig, $fieldInstance, $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; - } + $newValue = $data[$valueColumn][$field->ulid] ?? $data[$valueColumn][$field->slug] ?? 'NOT_SET'; - foreach ($field->children as $nestedField) { - ['config' => $nestedFieldConfig, 'instance' => $nestedFieldInstance] = $this->resolveFieldConfigAndInstance($nestedField); - $data = $mutationStrategy($nestedField, $nestedFieldConfig, $nestedFieldInstance, $data); + 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; } - /** - * 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 +276,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 +297,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/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 @@ +values(); + + // Handle relationship-based values (like Content model) + if (self::isRelationship($values)) { + $fieldValue = $values->where(function ($query) use ($field) { + $query->where('field_ulid', $field->ulid) + ->orWhere('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; + } + } + if ($result instanceof \Illuminate\Support\HtmlString) { + $result = (string) $result; + } + + 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..e70a9483 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/Repeater.php b/packages/fields/src/Fields/Repeater.php index b7ee36b8..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,11 +22,12 @@ 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; -class Repeater extends Base implements FieldContract +class Repeater extends Base implements FieldContract, HydratesValues { use HasConfigurableFields; use HasFieldTypeResolver; @@ -36,6 +38,59 @@ 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/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..fbf8f951 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 @@ -92,11 +93,16 @@ public static function make(string $name, ?Field $field = null): 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 +110,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 +131,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/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..3d22079a 100644 --- a/packages/fields/src/FieldsServiceProvider.php +++ b/packages/fields/src/FieldsServiceProvider.php @@ -3,6 +3,7 @@ namespace Backstage\Fields; use Backstage\Fields\Contracts\FieldInspector; +use Backstage\Fields\Fields\Text; use Backstage\Fields\Services\FieldInspectionService; use Backstage\Fields\Testing\TestsFields; use Filament\Support\Assets\Asset; @@ -94,6 +95,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); diff --git a/packages/filament-uploadcare-field/package-lock.json b/packages/filament-uploadcare-field/package-lock.json index 83c86b67..e83e9afa 100644 --- a/packages/filament-uploadcare-field/package-lock.json +++ b/packages/filament-uploadcare-field/package-lock.json @@ -13,6 +13,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" @@ -1722,6 +1723,20 @@ "node": ">=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..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 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 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 25eceebc..1c434b86 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, @@ -18,10 +19,13 @@ export default function uploadcareField(config) { sourceList: config.sourceList, uploaderStyle: config.uploaderStyle, isWithMetadata: config.isWithMetadata, + localeName: config.localeName || 'en', uploadedFiles: '', ctx: null, removeEventListeners: null, uniqueContextName: config.uniqueContextName, + pendingUploads: [], + pendingRemovals: [], isInitialized: false, stateHasBeenInitialized: false, isStateWatcherActive: false, @@ -29,15 +33,58 @@ export default function uploadcareField(config) { doneButtonHider: null, documentClassObserver: null, formInputObserver: null, + isUpdatingState: false, - init() { - if (this.isContextAlreadyInitialized()) return; + async init() { + + if (this.isContextAlreadyInitialized()) { + + return; + } this.markContextAsInitialized(); this.applyTheme(); + + 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) => { + 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(); + + // 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) { + const current = this.getCurrentFiles(); + if (current.length > 0) { + this.clearAllFiles(false); + } + } + }); + } }, isContextAlreadyInitialized() { @@ -48,9 +95,103 @@ 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}`); + 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}`); @@ -116,11 +257,10 @@ export default function uploadcareField(config) { initializeUploader(retryCount = 0, maxRetries = 10) { if (retryCount >= maxRetries) { - console.error('Failed to initialize Uploadcare after maximum retries'); 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)) { @@ -159,17 +299,14 @@ export default function uploadcareField(config) { }, initializeState(api) { - this.$nextTick(() => { - 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; - } - this.setupStateWatcher(); - }); + if (this.initialState && !this.stateHasBeenInitialized && !this.uploadedFiles) { + this.loadInitialState(api); + } else if (!this.initialState && !this.stateHasBeenInitialized) { + this.stateHasBeenInitialized = true; + this.uploadedFiles = (this.isMultiple || this.isWithMetadata) ? '[]' : ''; + this.isLocalUpdate = true; + this.state = this.uploadedFiles; + } }, loadInitialState(api) { @@ -189,44 +326,37 @@ 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) { - console.warn('Failed to parse double-encoded JSON:', e); - } + try { parsed = JSON.parse(parsed); } catch (e) {} } - return parsed; } catch (e) { - console.warn('Failed to parse string as JSON:', e); return value; } } 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); + + 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) { - console.warn('Failed to convert Proxy to array:', e); filesArray = [parsedState]; } - } else if (!Array.isArray(parsedState)) { + } else if (Array.isArray(parsedState)) { + filesArray = parsedState; + } else if (parsedState) { filesArray = [parsedState]; } @@ -239,10 +369,13 @@ 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); } } + if (!Array.isArray(filesArray) || filesArray.length === 0) { + return; + } + if (!Array.isArray(filesArray)) { filesArray = [filesArray]; } @@ -263,30 +396,38 @@ export default function uploadcareField(config) { addFile(parsedItem, index); return; } catch (e) { - console.warn(`Failed to parse string item ${index} as JSON:`, e); } } - 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; if (!url || !this.isValidUrl(url)) { - console.warn(`Invalid URL for file ${index}:`, url); return; } 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.startsWith('-/') ? '' : '-/') + modifiers; + } + api.addFileFromCdnUrl(fullUrl); } else { 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}`); @@ -297,7 +438,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; }, @@ -313,31 +480,126 @@ export default function uploadcareField(config) { }, setupStateWatcher() { - this.$watch('state', (newValue) => { + this.$watch('state', (value) => { if (this.isLocalUpdate) { this.isLocalUpdate = false; return; } - - if (!this.stateHasBeenInitialized) { - this.stateHasBeenInitialized = true; - 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); + } } - - if ((!newValue || newValue === '[]' || newValue === '""') && !this.uploadedFiles) { - return; + }); + }, + + 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]; + } + + // Filter out nulls/undefined to prevent crashes + filesToAdd = filesToAdd.filter(item => item !== null && item !== undefined); + + 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 => { + // 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 out nulls + + + 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; - const normalizedNewValue = this.normalizeStateValue(newValue); - const normalizedUploadedFiles = this.normalizeStateValue(this.uploadedFiles); - - if (normalizedNewValue !== normalizedUploadedFiles) { - if (newValue && newValue !== '[]' && newValue !== '""') { - this.uploadedFiles = newValue; - this.isLocalUpdate = true; + 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); + return uuid1 && uuid2 && uuid1 === uuid2; + }); + + if (!urlExists) { + try { + api.addFileFromCdnUrl(url); + } catch (e) { + console.error('[Uploadcare] Failed to add file from URL:', url, e); + } } } }); + + // 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; }, normalizeStateValue(value) { @@ -345,8 +607,22 @@ 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); + } + } + + const formatted = this.formatFilesForState(parsed); + return JSON.stringify(formatted); + } catch (e) { + console.error('[Uploadcare] normalizeStateValue error', e); return value; } }, @@ -358,12 +634,18 @@ 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); 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', { @@ -372,8 +654,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); @@ -391,28 +674,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); @@ -430,39 +696,56 @@ export default function uploadcareField(config) { }; }, - createFileUploadSuccessHandler() { + createFileUploadSuccessHandler(api) { 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; + 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(); + + for (const file of this.pendingUploads) { + currentFiles = this.updateFilesList(currentFiles, file); + } + + this.updateState(currentFiles); + this.pendingUploads = []; + const form = this.$el.closest('form'); if (form) { 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); + }, 200); }; }, - createFileUrlChangedHandler() { + createFileUrlChangedHandler(api) { let debounceTimer = null; return (e) => { - const fileDetails = e.detail; - if (!fileDetails.cdnUrlModifiers) 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); } @@ -471,89 +754,122 @@ export default function uploadcareField(config) { try { const currentFiles = this.getCurrentFiles(); const updatedFiles = this.updateFileUrl(currentFiles, fileDetails); + + 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); }; }, - createFileRemovedHandler() { + createFileRemovedHandler(api) { let debounceTimer = null; return (e) => { + if (e.target.getAttribute('ctx-name') !== this.uniqueContextName && e.target !== this.ctx && !this.ctx.contains(e.target)) { + return; + } + + const removedFile = e.detail; + 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); - - const api = this.getUploadcareApi(); - if (api) { - setTimeout(() => { - this.syncStateWithUploadcare(api); - }, 150); + let currentFiles = this.getCurrentFiles(); + for (const r of this.pendingRemovals) { + currentFiles = this.removeFile(currentFiles, r); } - } catch (error) { - console.error('Error in handleFileRemoved:', error); + this.updateState(currentFiles); + this.pendingRemovals = []; + } catch (n) { + console.error('Error in handleFileRemoved:', n); } }, 100); }; }, createFormInputChangeHandler(api) { - let debounceTimer = null; - - return (e) => { - if (debounceTimer) { - clearTimeout(debounceTimer); - } - - debounceTimer = setTimeout(() => { - this.syncStateWithUploadcare(api); - }, 200); - }; + 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 isDuplicate = currentFiles.some(file => { - const existingUrl = typeof file === 'object' ? file.cdnUrl : file; - const newUrl = typeof newFile === 'object' ? newFile.cdnUrl : newFile; - return existingUrl === newUrl; - }); - - 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]; }, 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) { + let originalFile = currentFiles[fileIndex]; + + // CRITICAL FIX: Ensure originalFile is an object before spreading + if (typeof originalFile === 'string') { + const uuid = this.extractUuidFromUrl(originalFile); + originalFile = { + cdnUrl: originalFile, + uuid: uuid, + name: '', + size: 0, + mimeType: '', + isImage: false + }; + } + + 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.cdnUrlModifiers = null; + delete updatedFile.cdnUrlModifiers; + } + } + } else { + updatedFile = fileDetails.cdnUrl; + } + if (this.isMultiple) { - currentFiles[fileIndex] = updatedFile; - return currentFiles; + const newFiles = [...currentFiles]; + newFiles[fileIndex] = updatedFile; + return newFiles; } return [updatedFile]; }, @@ -563,46 +879,73 @@ 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 []; }, 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); - const newState = JSON.stringify(finalFiles); - const currentFiles = this.getCurrentFiles(); - const currentStateNormalized = JSON.stringify(this.formatFilesForState(currentFiles)); - const newStateNormalized = JSON.stringify(this.formatFilesForState(finalFiles)); - const hasActuallyChanged = currentStateNormalized !== newStateNormalized; + if (this.isUpdatingState) return; + this.isUpdatingState = true; - if (hasActuallyChanged) { - this.uploadedFiles = newState; - this.isLocalUpdate = true; - this.state = this.uploadedFiles; + 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; + }); - if (this.isMultiple && files.length > 1) { - this.$nextTick(() => { - this.isLocalUpdate = false; - }); + + 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 (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) { + if (!files) return []; + if (!Array.isArray(files)) return []; + return files.map(file => { if (this.isWithMetadata) { return file; } - return typeof file === 'object' ? file.cdnUrl : file; + return (file && typeof file === 'object') ? file.cdnUrl : file; }); }, @@ -634,33 +977,70 @@ 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; } + 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); + return uuidMatch ? uuidMatch[1] : null; + }, + + extractModifiersFromUrl(url) { + if (!url || typeof url !== 'string') return ''; - if (uuidMatch && uuidMatch[1]) { - return uuidMatch[1]; - } + const uuid = this.extractUuidFromUrl(url); + if (!uuid) return ''; - if (typeof url === 'object' && url.uuid) { - return url.uuid; - } + 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 null; + return modifiers; }, - syncStateWithUploadcare(api) { + async syncStateWithUploadcare(api) { try { - const currentFiles = this.getCurrentFilesFromUploadcare(api); + let currentFiles = this.getCurrentFilesFromUploadcare(api); + + if (currentFiles.length > 0) { + const flattenedFiles = []; + for (const file of currentFiles) { + const url = (file && typeof file === 'object') ? file.cdnUrl : file; + if (typeof url === 'string' && url.match(/[a-f0-9-]{36}~[0-9]+/)) { + try { + const groupFiles = await this.fetchGroupFiles(url); + flattenedFiles.push(...groupFiles); + } catch (e) { + flattenedFiles.push(file); + } + } else { + flattenedFiles.push(file); + } + } + currentFiles = flattenedFiles; + } + 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; @@ -670,28 +1050,53 @@ export default function uploadcareField(config) { } }, - buildStateFromFiles(formattedFiles) { - if (this.isMultiple) { - return JSON.stringify(formattedFiles); - } - - if (formattedFiles.length > 0) { - return this.isWithMetadata ? JSON.stringify(formattedFiles[0]) : formattedFiles[0]; + async fetchGroupFiles(groupUrlOrUuid) { + 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]; } + 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 []; + + return data.files.map(file => { + const cdnUrl = `https://ucarecdn.com/${file.uuid}/`; + 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 || this.isWithMetadata) return JSON.stringify(formattedFiles); + if (formattedFiles.length > 0) return formattedFiles[0]; return ''; }, getCurrentFilesFromUploadcare(api) { try { + if (api && typeof api.value === 'function') { + const value = api.value(); + if (value) { + const files = Array.isArray(value) ? value : this.parseFormInputValue(value); + return files.filter(item => item != null); + } + } + const formInput = this.$el.querySelector('uc-form-input input'); - if (formInput) { - return this.parseFormInputValue(formInput.value); + return this.parseFormInputValue(formInput.value).filter(item => item != null); } - - 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 []; @@ -699,29 +1104,37 @@ export default function uploadcareField(config) { }, parseFormInputValue(inputValue) { - if (!inputValue || (typeof inputValue === 'string' && inputValue.trim() === '')) { - return []; - } - + 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 api = this.getUploadcareApi(); + if (api) { + try { + 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) {} + try { if (typeof api.removeAllFiles === 'function') api.removeAllFiles(); } catch (e) {} + try { if (typeof api.value === 'function') api.value(this.isMultiple ? [] : ''); } catch (e) {} + } + + if (this.uploadedFiles !== ((this.isMultiple || this.isWithMetadata) ? '[]' : '')) { + this.uploadedFiles = (this.isMultiple || this.isWithMetadata) ? '[]' : ''; + this.isLocalUpdate = true; + if (emitStateChange) this.state = this.uploadedFiles; } } }; -} \ No newline at end of file +} 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/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 @@ +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; @@ -282,20 +316,252 @@ public function getState(): mixed { $state = parent::getState(); - if ($state === '[]' || $state === '""' || $state === null || $state === '') { - return null; + // Handle double-encoded JSON or JSON strings + if (is_string($state) && json_validate($state)) { + $decoded = json_decode($state, true); + if (is_array($decoded)) { + $state = $decoded; + } + } + + if ($state === null || $state === '' || $state === []) { + return $state; } + // 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 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]; + // 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 (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', $first); + } + + 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 $items, ?Model $record = null, ?string $fieldName = null): array + { + if (empty($items)) { + return []; + } + + $resolved = []; + $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']; + } + } + } + + 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); + } + } + + $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'); + + } + } catch (\Exception $e) { + } + } + + // 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'); + } catch (\Exception $e) { + } + } + + if (! $mediaItems || $mediaItems->isEmpty()) { + $mediaItems = $mediaModel::whereIn('ulid', array_values($ulidsToResolve))->get()->keyBy('ulid'); + } + + 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 + } + } + } + + foreach ($preResolvedModels as $index => $model) { + $resolved[$index] = Factory::mapMediaToValue($model); + } + + 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); + + 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; + } + + // 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) { + // Safely access array/object keys + $cdnUrl = $item['cdnUrl'] ?? $item['ucarecdn'] ?? null; + } + + if ($cdnUrl) { + return $cdnUrl; + } + + return $item['uuid'] ?? $item['filename'] ?? null; + }, $state))); } private function transformUrls($value, string $from, string $to): mixed @@ -312,25 +578,30 @@ 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 @@ -343,6 +614,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(); @@ -363,5 +671,11 @@ protected function setUp(): void return $state; }); + + $this->afterStateUpdated(function (Uploadcare $component, $state, $old) { + if ($state !== $old && ! empty($state)) { + Event::dispatch(new MediaUploading($state)); + } + }); } } 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-redirects/database/factories/RedirectFactory.php b/packages/laravel-redirects/database/factories/RedirectFactory.php new file mode 100644 index 00000000..c71c9086 --- /dev/null +++ b/packages/laravel-redirects/database/factories/RedirectFactory.php @@ -0,0 +1,26 @@ + + */ + public function definition(): array + { + return [ + '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'); 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..8c3498c2 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", @@ -41,7 +43,9 @@ "psr-4": { "Backstage\\Media\\": "src/", "Backstage\\Media\\Database\\Factories\\": "database/factories/" - } + }, + "files": [ + ] }, "autoload-dev": { "psr-4": { @@ -59,6 +63,7 @@ "sort-packages": true, "allow-plugins": { "pestphp/pest-plugin": true, + "php-http/discovery": true, "phpstan/extension-installer": true } }, @@ -72,8 +77,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/src/helpers.php b/packages/media/src/helpers.php new file mode 100644 index 00000000..fbafde19 --- /dev/null +++ b/packages/media/src/helpers.php @@ -0,0 +1,14 @@ +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/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..70ccf9d7 --- /dev/null +++ b/packages/uploadcare-field/database/migrations/2025_12_17_000001_repair_uploadcare_media_relationships.php @@ -0,0 +1,348 @@ +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/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..c6093358 --- /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) && $this->extractUuidFromUrl($file)) { + 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 || ! filter_var($cdnUrl, FILTER_VALIDATE_URL) || ! $this->extractUuidFromUrl($cdnUrl)) { + 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..ea70a7ad --- /dev/null +++ b/packages/uploadcare-field/src/Livewire/MediaGridPicker.php @@ -0,0 +1,167 @@ +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']; + // Send Media ULIDs; Uploadcare::convertUuidsToCdnUrls() will resolve them to the correct URL/UUID. + $selected = $mediaId; + + 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[] = $selected; + } + + // 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 = $selected; + + // Dispatch event to update hidden field in modal + $this->dispatch( + 'set-hidden-field', + fieldName: 'selected_media_uuid', + value: $selected + ); + } + } + + 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..49b1ca6c --- /dev/null +++ b/packages/uploadcare-field/src/Observers/ContentFieldValueObserver.php @@ -0,0 +1,221 @@ +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); + + $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 + { + 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)) { + if ($this->isUploadcareValue($decoded)) { + $data = $decoded; + } else { + $data = $decoded; + } + } + } + + if (! is_array($data)) { + return $data; + } + + // 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 ($items as $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 $isList ? $newUlids : ($newUlids[0] ?? null); + } + + foreach ($data as $key => $value) { + $data[$key] = $this->processValueRecursively($value, $mediaData); + } + + return $data; + } + + private function isUploadcareValue(array $data): bool + { + if (empty($data)) { + return false; + } + + // If it's a list, check the first item + if (array_is_list($data)) { + $first = $data[0]; + + 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; + } + + // 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 + { + $uuid = null; + $meta = []; + + if (is_string($item)) { + 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) : ''; + if (! empty($modifiers) && $modifiers[0] === '/') { + $modifiers = substr($modifiers, 1); + } + $meta = [ + 'cdnUrl' => $item, + 'cdnUrlModifiers' => $modifiers, + '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']) && 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) && $modifiers[0] === '/') { + $modifiers = substr($modifiers, 1); + } + if (! empty($modifiers)) { + $meta['cdnUrlModifiers'] = $meta['cdnUrlModifiers'] ?? $modifiers; + $meta['cdnUrl'] = $item['cdnUrl']; // Ensure url matches + } + } + } + } + + return [$uuid, $meta]; + } + + private function resolveMediaUlid(string $uuid): ?string + { + if (strlen($uuid) === 26) { + // 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(); + + return $media?->ulid; + } + + $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(); + + if (! empty($mediaData)) { + foreach ($mediaData as $data) { + $contentFieldValue->media()->attach($data['media_ulid'], [ + 'position' => $data['position'], + 'meta' => $data['meta'], + ]); + } + } + + $contentFieldValue->updateQuietly(['value' => json_encode($modifiedValue)]); + }); + } +} diff --git a/packages/uploadcare-field/src/Uploadcare.php b/packages/uploadcare-field/src/Uploadcare.php index 24bdf113..43026dde 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,11 +17,18 @@ 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\Arr; use Illuminate\Support\Facades\Auth; -class Uploadcare extends Base implements FieldContract +class Uploadcare extends Base implements FieldContract, HydratesValues { + public function getFieldType(): ?string + { + return 'uploadcare'; + } + public static function getDefaultConfig(): array { return [ @@ -35,20 +45,273 @@ 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, $component, $record) { + + 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); + + /* + // 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) && ! 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)]; + } + + // 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() + ->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) { + $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; + } + + $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)) { + + $newState = json_decode($state, true); + } else { + + } + + if ($newState !== $state) { + $component->state($newState); + } + }), field: $field ); + $isMultiple = $field->config['multiple'] ?? self::getDefaultConfig()['multiple']; + $acceptedFileTypes = self::parseAcceptedFileTypes($field); + + $input = $input->hintActions([ + fn (Input $component) => Action::make('mediaPicker') + ->schemaComponent($component) + ->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, Input $component) { + $selected = $data['selected_media_uuid'] ?? null; + if (! $selected) { + return; + } + + $cdnUrls = self::convertUuidsToCdnUrls($selected); + if (! $cdnUrls) { + return; + } + + self::updateStateWithSelectedMedia($component, $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 +322,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 +360,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 +392,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) { @@ -143,14 +429,22 @@ public static function mutateFormDataCallback(Model $record, Field $field, array return $data; } - if (! property_exists($record, 'valueColumn') || ! isset($record->values[$field->ulid])) { - return $data; + $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; + } - $values = $record->values[$field->ulid]; + // 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][$field->ulid] = []; + if ($values === '' || $values === [] || $values === null || empty($values)) { + $data[$record->valueColumn ?? 'values'][$field->ulid] = []; return $data; } @@ -159,11 +453,34 @@ public static function mutateFormDataCallback(Model $record, Field $field, array $values = self::parseValues($values); if (self::isMediaUlidArray($values)) { - $mediaData = self::extractMediaUrls($values, $withMetadata); - $data[$record->valueColumn][$field->ulid] = $mediaData; + $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)) { + $mediaData = self::extractMediaUrls($values, true); + } + + $data[$record->valueColumn ?? 'values'][$field->ulid] = $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 ?? 'values'][$field->ulid] = $result; } return $data; @@ -175,26 +492,31 @@ 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], (string) $field->ulid); + $values = self::findFieldValues($data, $field); - if ($values === '' || $values === [] || $values === null) { - $data[$record->valueColumn][$field->ulid] = null; + 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']))); + + if ($fieldFound) { + $data[$valueColumn][$field->ulid] = []; + } return $data; } $values = self::normalizeValues($values); - if (! is_array($values)) { - return $data; - } + // Side effect: create media records for new uploads + self::processUploadedFiles($values); - $media = self::processUploadedFiles($values); - $data[$record->valueColumn][$field->ulid] = collect($media)->pluck('ulid')->toArray(); + // Save the values (Array) - Filament/PersistsContentData will handle encoding if needed + $data[$valueColumn][$field->ulid] = $values; return $data; } @@ -249,49 +571,106 @@ 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) ? 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; - } + $result = array_merge($metadata, array_filter([ + 'uuid' => $uuid, + 'cdnUrl' => $cdnUrl, + ])); - $cdnUrl = $metadata['cdnUrl']; + return $result; + } - return filter_var($cdnUrl, FILTER_VALIDATE_URL) ? $cdnUrl : null; + return $cdnUrl; }) ->filter() ->values() ->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; + + // 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) { + + // First pass: look for direct keys at this level + if (array_key_exists($ulid, $array)) { + + return $array[$ulid]; + } + if (array_key_exists($slug, $array)) { + return $array[$slug]; + } + + // Second pass: recurse foreach ($array as $k => $value) { - if ($k === $key) { - return $value; - } if (is_array($value)) { - $result = $findInNested($value, $key); - if ($result !== null) { + $result = $findInNested($value, $ulid, $slug, $depth + 1); + if ($result !== $notFound) { return $result; } } } - return null; + return $notFound; }; - return $findInNested($data, $fieldUlid); + $result = $findInNested($data, $fieldUlid, $fieldSlug); + + if ($result === $notFound) { + $result = null; + $found = false; + } else { + $found = true; + } + + return $result; } private static function normalizeValues(mixed $values): mixed @@ -307,11 +686,15 @@ private static function normalizeValues(mixed $values): mixed return $values; } - private static function processUploadedFiles(array $files): array + private static function processUploadedFiles(mixed $files): array { + if (! empty($files) && ! array_is_list($files)) { + $files = [$files]; + } + $media = []; - foreach ($files as $file) { + foreach ($files as $index => $file) { $normalizedFiles = self::normalizeFileData($file); if ($normalizedFiles === null || $normalizedFiles === false) { @@ -320,12 +703,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); } } @@ -352,44 +735,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 $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 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(); @@ -399,15 +744,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]; } @@ -434,22 +775,25 @@ 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'], + '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, - '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'] ?? uniqid()), ]); + + return $media; } private static function extractDetailedInfo(array $info): array @@ -478,4 +822,511 @@ 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 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; + } + + $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) && self::extractUuidFromString($url) !== null; + } + + 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 ?Field $field_model = null; + + public function hydrate(mixed $value, ?Model $model = null): mixed + { + + if (empty($value)) { + return null; + } + + // Normalize value first + if (is_string($value) && json_validate($value)) { + $decoded = json_decode($value, true); + if (is_array($decoded)) { + $value = $decoded; + } + } + + // 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 ?? []; + $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(); + + // 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 + 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; + } + } + + $media->setAttribute('edit', [ + 'uuid' => $uuid, + 'cdnUrl' => $value, + 'cdnUrlModifiers' => $cdnUrlModifiers, + ]); + + // 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]; + } + } + } + + return $value; + } + + // Try manual hydration if relation hydration failed (e.g. pivot missing but media exists) + $hydratedUlids = self::hydrateBackstageUlids($value); + + if ($hydratedUlids !== null) { + // 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 $value; + } + + public static function mapMediaToValue(mixed $media): array | string + { + if (! $media instanceof Model && ! is_array($media)) { + return is_string($media) ? $media : []; + } + + $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 (is_string($data)) { + $data = json_decode($data, true); + } + } + + 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, '/'); + } + } + + // 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 : []; + } + + private static function hydrateFromModel(?Model $model, mixed $value = null, bool $returnModels = false): mixed + { + if (! $model || ! method_exists($model, 'media')) { + return null; + } + + $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); + } + + $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'); + + $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) { + $contextModel = clone $model; + $contextModel->setRelation('pivot', $m->pivot); + $m->setRelation('edits', new \Illuminate\Database\Eloquent\Collection([$contextModel])); + } + + } + } else { + + } + }); + + if ($returnModels) { + return $media; + } + + return json_encode($media->map(fn ($m) => self::mapMediaToValue($m))->values()->all()); + } + + private static function resolveMediaFromMixedValue(mixed $item): ?Model + { + $mediaModel = self::getMediaModel(); + + if ($item instanceof Model) { + return $item; + } + + 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; + } + + 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; + } elseif (is_array($value)) { + $media = self::resolveMediaFromMixedValue($value); + if ($media) { + return [$media->load('edits')]; + } + } + + $mediaModel = self::getMediaModel(); + $potentialUlids = array_filter(Arr::flatten($value), function ($item) { + return is_string($item) && ! json_validate($item); + }); + + 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)) { + $media = $mediaItems->firstWhere('ulid', $item); + if ($media) { + return $media->load('edits'); + } + + return null; + } + + return $item; + }; + + $hydrated = array_map($resolve, $value); + $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 6117f704..8a312898 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,46 @@ 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', + '2025_12_17_000001_repair_uploadcare_media_relationships', + ]) + ->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/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') 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