Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
4566d3a
feat: media optimizations (#7)
Baspa Dec 12, 2025
14b147b
fix: nested arrays may not be passed to `whereIn`
Baspa Dec 12, 2025
b792a21
fix: styling
Baspa Dec 12, 2025
bec28e6
fix: repair `uc` relationships, improve retreiving uuids
Baspa Dec 17, 2025
d27fa5f
fix: styling
Baspa Dec 19, 2025
c8d7fcd
fix: saving and reading Uploadccare metadata
Baspa Dec 22, 2025
a6ce53f
fix: styling
Baspa Dec 22, 2025
ae1ce77
fix: force casting to `string` to prevent `array` error
Baspa Dec 29, 2025
f6a7184
feat: temporarily disable translation strings
Baspa Dec 29, 2025
3b8e272
fix: hydrate differently for front- and backend
Baspa Dec 31, 2025
5252639
wip
Baspa Dec 31, 2025
4b2e17d
wip
Baspa Dec 31, 2025
a162f01
fix: refreshing and persisting form states (create & create another)
Baspa Jan 2, 2026
fcfdabc
fix: applying modifiers
Baspa Jan 2, 2026
a8c9e9c
fix: styling
Baspa Jan 2, 2026
d76c734
fix: hydrated values and relation fields
Baspa Jan 2, 2026
dee3782
Merge branch '2.x' of github.com:backstagephp/cms into 2.x
Baspa Jan 2, 2026
2527e68
fix: styling
Baspa Jan 2, 2026
dfd41ba
wip
Baspa Jan 5, 2026
c378c05
fix: loading crops
Baspa Jan 5, 2026
6311f71
wip
Baspa Jan 5, 2026
18a8d26
Merge branch '2.x' of github.com:backstagephp/cms into 2.x
Baspa Jan 5, 2026
c4d81b4
fix: styling
Baspa Jan 5, 2026
ecd94f7
remove logging
Baspa Jan 5, 2026
18228f8
Merge branch '2.x' of github.com:backstagephp/cms into 2.x
Baspa Jan 5, 2026
92c7d8d
fix: accidental added code block
Baspa Jan 6, 2026
dab6013
fix: styling
Baspa Jan 6, 2026
5bd8d3a
Remove matrix in github workflows
Casmo Jan 30, 2026
8ee8272
fix: add safety checks for uploadcare
Baspa Feb 13, 2026
8c0a5bc
feat: context-aware hydration contracts for field values
mhortulanus Feb 25, 2026
8bb17ef
chore: upgrade Laravel Pint to 1.29.0 and apply code style fixes
Baspa Mar 13, 2026
3a187c1
fix: styling
markvaneijk Mar 31, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/run-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ jobs:
strategy:
fail-fast: true
matrix:
os: [ubuntu-latest, windows-latest]
os: [ubuntu-latest]
php: [8.3]
laravel: [12.*]
include:
Expand Down
51 changes: 51 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
<?php

use Backstage\Media\Models\Media;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;

return new class extends Migration
{
public function up(): void
{
$targetFieldIds = DB::table('fields')
->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
{
//
}
};
Loading