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

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

+
+ @endforelse +
+ + @if($this->mediaItems->hasPages() || $this->mediaItems->total() > $perPage) +
+
+ + + + {{ __('Page') }} {{ $this->mediaItems->currentPage() }} {{ __('of') }} {{ $this->mediaItems->lastPage() }} + + + +
+ +
+ {{ __('Per page') }}: + +
+
+ @endif +
\ No newline at end of file diff --git a/src/Forms/Components/MediaGridPicker.php b/src/Forms/Components/MediaGridPicker.php new file mode 100644 index 0000000..453c945 --- /dev/null +++ b/src/Forms/Components/MediaGridPicker.php @@ -0,0 +1,66 @@ +fieldName = $fieldName; + + return $this; + } + + public function getFieldName(): string + { + return $this->fieldName; + } + + public function perPage(int $perPage): static + { + $this->perPage = $perPage; + + return $this; + } + + public function getPerPage(): int + { + return $this->perPage; + } + + public function multiple(bool $multiple = true): static + { + $this->multiple = $multiple; + + return $this; + } + + public function getMultiple(): bool + { + return $this->multiple; + } + + public function acceptedFileTypes(?array $acceptedFileTypes): static + { + $this->acceptedFileTypes = $acceptedFileTypes; + + return $this; + } + + public function getAcceptedFileTypes(): ?array + { + return $this->acceptedFileTypes; + } +} diff --git a/src/Listeners/CreateMediaFromUploadcare.php b/src/Listeners/CreateMediaFromUploadcare.php new file mode 100644 index 0000000..e7ce9ed --- /dev/null +++ b/src/Listeners/CreateMediaFromUploadcare.php @@ -0,0 +1,172 @@ +createMediaFromUploadcare($event->file); + } + + private function createMediaFromUploadcare(mixed $file): ?Media + { + try { + $normalizedFile = $this->normalizeUploadcareFile($file); + if (! $normalizedFile) { + return null; + } + + $fileInfo = $this->extractUploadcareFileInfo($normalizedFile); + if (! $fileInfo) { + return null; + } + + $disk = 'uploadcare'; + $searchCriteria = $this->buildUploadcareSearchCriteria($fileInfo, $disk); + $values = $this->buildUploadcareValues($fileInfo, $disk); + + $searchCriteria = $this->addTenantToSearchCriteria($searchCriteria); + $values = $this->addTenantToMediaData($values); + + return Media::updateOrCreate($searchCriteria, $values); + } catch (Exception $e) { + return null; + } + } + + private function normalizeUploadcareFile(mixed $file): ?array + { + if (is_string($file)) { + if (filter_var($file, FILTER_VALIDATE_URL) && $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/src/Livewire/MediaGridPicker.php b/src/Livewire/MediaGridPicker.php new file mode 100644 index 0000000..2543257 --- /dev/null +++ b/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/src/Observers/ContentFieldValueObserver.php b/src/Observers/ContentFieldValueObserver.php new file mode 100644 index 0000000..49b1ca6 --- /dev/null +++ b/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/src/Uploadcare.php b/src/Uploadcare.php index 24bdf11..ebd0a88 100755 --- a/src/Uploadcare.php +++ b/src/Uploadcare.php @@ -3,22 +3,37 @@ namespace Backstage\UploadcareField; use Backstage\Fields\Contracts\FieldContract; +use Backstage\Fields\Contracts\HydratesValues; +use Backstage\Fields\Contracts\HydratesValuesForFilament; +use Backstage\Fields\Contracts\HydratesValuesForFrontend; use Backstage\Fields\Fields\Base; use Backstage\Fields\Models\Field; +use Backstage\Models\ContentFieldValue; use Backstage\Uploadcare\Enums\Style; use Backstage\Uploadcare\Forms\Components\Uploadcare as Input; +use Backstage\UploadcareField\Forms\Components\MediaGridPicker; +use Filament\Actions\Action; use Filament\Facades\Filament; +use Filament\Forms\Components\Hidden; use Filament\Forms\Components\Select; use Filament\Forms\Components\TextInput; use Filament\Forms\Components\Toggle; 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\Collection; use Illuminate\Support\Facades\Auth; -class Uploadcare extends Base implements FieldContract +class Uploadcare extends Base implements FieldContract, HydratesValues, HydratesValuesForFilament, HydratesValuesForFrontend { + public function getFieldType(): ?string + { + return 'uploadcare'; + } + public static function getDefaultConfig(): array { return [ @@ -35,20 +50,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 = 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 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), + 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 +327,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 +365,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 +397,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 +434,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 +458,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(ContentFieldValue::class)) { + try { + $cfv = ContentFieldValue::where('content_ulid', $record->ulid) + ->where('field_ulid', $field->ulid) + ->first(); + + if ($cfv) { + $models = self::hydrateFromModel($cfv, $values, true); + if ($models && $models instanceof 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 +497,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 +576,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 +691,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,13 +708,19 @@ private static function processUploadedFiles(array $files): array if (self::isArrayOfArrays($normalizedFiles)) { foreach ($normalizedFiles as $singleFile) { - if ($singleFile !== null && ! self::shouldSkipFile($singleFile)) { - $media[] = self::createOrUpdateMediaRecord($singleFile); + if ($singleFile !== null) { + $mediaRecord = self::createOrUpdateMediaRecord($singleFile); + if ($mediaRecord !== null) { + $media[] = $mediaRecord; + } } } } else { - if (is_array($normalizedFiles) && ! self::shouldSkipFile($normalizedFiles)) { - $media[] = self::createOrUpdateMediaRecord($normalizedFiles); + if (is_array($normalizedFiles)) { + $mediaRecord = self::createOrUpdateMediaRecord($normalizedFiles); + if ($mediaRecord !== null) { + $media[] = $mediaRecord; + } } } } @@ -352,44 +746,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 +755,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]; } @@ -421,7 +773,7 @@ private static function extractCdnUrl(array $file): ?string return $url && filter_var($url, FILTER_VALIDATE_URL) ? $url : null; } - private static function createOrUpdateMediaRecord(array $file): Model + private static function createOrUpdateMediaRecord(array $file): ?Model { $mediaModel = self::getMediaModel(); @@ -432,24 +784,39 @@ private static function createOrUpdateMediaRecord(array $file): Model $info = $file['fileInfo'] ?? $file; $detailedInfo = self::extractDetailedInfo($info); + // Extract UUID - this is required for Uploadcare files + $uuid = $info['uuid'] ?? ($info['fileInfo']['uuid'] ?? null); + + // Skip if no UUID is present - we can't create a valid media record without it + if (empty($uuid)) { + \Log::warning('Uploadcare: Skipping media record creation - UUID missing from file data', [ + 'file_data' => $file, + ]); + + return null; + } + $tenantUlid = Filament::getTenant()->ulid ?? null; - return $mediaModel::updateOrCreate([ + $media = $mediaModel::updateOrCreate([ 'site_ulid' => $tenantUlid, 'disk' => 'uploadcare', - 'filename' => $info['uuid'], + 'filename' => $uuid, ], [ - '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($uuid), ]); + + return $media; } private static function extractDetailedInfo(array $info): array @@ -478,4 +845,521 @@ 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 hydrateForFrontend(mixed $value, ?Model $model = null): mixed + { + return $this->hydrate($value, $model); + } + + public function hydrateForFilament(mixed $value, ?Model $model = null): mixed + { + return $this->hydrate($value, $model); + } + + public function hydrate(mixed $value, ?Model $model = null): mixed + { + + 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/src/UploadcareFieldServiceProvider.php b/src/UploadcareFieldServiceProvider.php index 6117f70..a2d64cf 100644 --- a/src/UploadcareFieldServiceProvider.php +++ b/src/UploadcareFieldServiceProvider.php @@ -2,6 +2,16 @@ namespace Backstage\UploadcareField; +use Backstage\Fields\Fields; +use Backstage\Media\Events\MediaUploading; +use Backstage\Media\Models\Media; +use Backstage\Models\ContentFieldValue; +use Backstage\UploadcareField\Listeners\CreateMediaFromUploadcare; +use Backstage\UploadcareField\Livewire\MediaGridPicker; +use Backstage\UploadcareField\Observers\ContentFieldValueObserver; +use Filament\Support\Assets\Css; +use Filament\Support\Facades\FilamentAsset; +use Illuminate\Support\Facades\Event; use Spatie\LaravelPackageTools\Package; use Spatie\LaravelPackageTools\PackageServiceProvider; @@ -13,6 +23,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'); + + Event::listen( + MediaUploading::class, + CreateMediaFromUploadcare::class, + ); + + ContentFieldValue::observe(ContentFieldValueObserver::class); + + Fields::registerField(Uploadcare::class); + } + + public function bootingPackage(): void + { + $this->loadViewsFrom(__DIR__.'/../resources/views', 'backstage-uploadcare-field'); + + // Register Media src resolver + 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', MediaGridPicker::class); } } diff --git a/tailwind.config.js b/tailwind.config.js new file mode 100644 index 0000000..8db9ef8 --- /dev/null +++ b/tailwind.config.js @@ -0,0 +1,11 @@ +export default { + darkMode: 'selector', + content: [ + './src/**/*.php', + './resources/views/**/*.blade.php', + ], + theme: { + extend: {}, + }, + plugins: [], +} diff --git a/tests/MediaGridPickerTest.php b/tests/MediaGridPickerTest.php new file mode 100644 index 0000000..3f17050 --- /dev/null +++ b/tests/MediaGridPickerTest.php @@ -0,0 +1,80 @@ + 'Backstage\\Models\\Media']); + } + + public function test_form_component_can_initialize_with_default_values(): void + { + $picker = new MediaGridPicker('test_field'); + + $this->assertEquals(12, $picker->getPerPage()); + } + + public function test_form_component_can_change_per_page(): void + { + $picker = new MediaGridPicker('test_field'); + + $picker->perPage(24); + + $this->assertEquals(24, $picker->getPerPage()); + } + + public function test_livewire_component_can_initialize(): void + { + $component = Livewire::test(LivewireMediaGridPicker::class, [ + 'fieldName' => 'test_field', + 'perPage' => 12, + ]); + + $component->assertSet('fieldName', 'test_field') + ->assertSet('perPage', 12); + } + + public function test_livewire_component_can_update_per_page(): void + { + $component = Livewire::test(LivewireMediaGridPicker::class, [ + 'fieldName' => 'test_field', + 'perPage' => 12, + ]); + + $component->call('updatePerPage', 24) + ->assertSet('perPage', 24); + } + + public function test_livewire_component_dispatches_media_selected_event(): void + { + $component = Livewire::test(LivewireMediaGridPicker::class, [ + 'fieldName' => 'test_field', + 'perPage' => 12, + ]); + + $media = [ + 'id' => 'test-id', + 'filename' => 'test.jpg', + 'cdn_url' => 'https://ucarecdn.com/test-uuid/', + ]; + + $component->call('selectMedia', $media) + ->assertDispatched('media-selected', [ + 'fieldName' => 'test_field', + 'media' => $media, + ]); + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php index d5098fc..b4007f8 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -13,7 +13,7 @@ protected function setUp(): void parent::setUp(); Factory::guessFactoryNamesUsing( - fn (string $modelName) => 'Backstage\\UploadcareField\\Database\\Factories\\' . class_basename($modelName) . 'Factory' + fn (string $modelName) => 'Backstage\\UploadcareField\\Database\\Factories\\'.class_basename($modelName).'Factory' ); }