Media Gallery
The Tagixo Primix integration ships an optional Media Gallery subsystem that gives editors a central place to upload, crop, and pick images. It exposes a Primix resource for managing media, a form field and table column for picker UIs, and a modal component reused inside the visual builder canvas.
1. Enabling the Media Gallery
Register the feature on the plugin in your AdminPanelProvider:
use Ccast\TagixoPrimix\TagixoPrimixPlugin;
public function panel(Panel $panel): Panel
{
return $panel
->plugin(
TagixoPrimixPlugin::make()
->withMediaGallery()
);
}
Alternatively, pass configuration as an array:
TagixoPrimixPlugin::make()
->withMediaGallery([
'disk' => 'public',
'directory' => 'media',
'max_file_size' => 10240, // KB
'allowed_types' => ['image/jpeg', 'image/png', 'image/webp', 'image/gif'],
'generate_crops' => true,
'visibility' => 'public', // 'public' | 'private'
]),
All keys are optional; defaults are documented in the Upload configuration section below.
2. What the Media Gallery Adds
Enabling the feature registers:
MediaResource— a full Primix resource at/admin/media. It renders two views:- Grid view — thumbnail cards with hover actions (edit, delete, copy URL).
- Table view — tabular listing with sortable columns (name, disk, size, created at).
- Upload action — a header action that opens a multi-file uploader supporting drag-and-drop. Files are validated against
allowed_typesandmax_file_sizebefore being stored on the configured disk. - Edit modal — lets editors rename the file, update alt text, and set focal-point crops.
- Delete action — soft-checks for usages before hard-deleting from disk and the
tgx_mediatable. - Crops tab — per-media crop definitions stored as JSON in
tgx_media.crops. Predefined crop keys (e.g.thumbnail,og) are configurable; editors can also add ad-hoc named crops.
3. MediaGalleryPickerField
Use this field whenever a Primix form needs a media picker:
use Ccast\TagixoPrimix\Forms\Components\MediaGalleryPickerField;
MediaGalleryPickerField::make('hero_image')
->label('Hero image')
->required()
->allowedTypes(['image/jpeg', 'image/png', 'image/webp'])
->cropKey('thumbnail')
->returnShape('array') // 'array' (default) | 'url' | 'id'
->previewHeight(160), // px, default 120
Method reference
| Method | Type | Default | Description |
|---|---|---|---|
allowedTypes(array) |
array<string> |
all configured types | MIME types shown in the picker |
cropKey(string) |
string|null |
null |
If set, the stored value uses the named crop URL instead of the original |
returnShape(string) |
'array'|'url'|'id' |
'array' |
Shape of the stored value |
previewHeight(int) |
int |
120 |
Height in pixels of the thumbnail preview inside the form |
disk(string) |
string|null |
plugin default | Override the storage disk |
nullable() |
— | — | Allows clearing the selection |
multiple() |
— | — | Enables multi-select; stored value becomes an array of items |
Return value shape
When returnShape is 'array' (the default), the stored value is:
{
"id": 42,
"url": "https://example.com/storage/media/photo.jpg",
"crop_url": "https://example.com/storage/media/crops/photo_thumbnail.jpg",
"alt": "A hero photo",
"width": 1920,
"height": 1080,
"mime_type": "image/jpeg"
}
When returnShape is 'url', only the resolved URL string is stored. When returnShape is 'id', only the integer media ID is stored.
Model cast
For 'array' return shape, cast the column in your model:
protected $casts = [
'hero_image' => 'array',
];
LiVue event flow
The picker opens a LiVue-powered modal (<tgx-media-gallery-picker-modal>). Selection fires an update:modelValue emit on the LiVue component, which Primix bridges to Livewire via an @update:modelValue listener that calls $set('data.hero_image', $event). No Alpine.js is involved; the entire round-trip stays within the LiVue → Livewire channel.
4. MediaGalleryPickerColumn
Display a thumbnail in Primix tables:
use Ccast\TagixoPrimix\Tables\Columns\MediaGalleryPickerColumn;
MediaGalleryPickerColumn::make('hero_image')
->label('Hero')
->height(48) // px, default 40
->width(80) // px, default auto
->circular(false) // default false
->tooltip(true), // show alt text on hover, default false
Method reference
| Method | Type | Default | Description |
|---|---|---|---|
height(int) |
int |
40 |
Thumbnail height in pixels |
width(int|null) |
int|null |
null (auto) |
Thumbnail width in pixels |
circular(bool) |
bool |
false |
Renders as a circle (avatar-style) |
tooltip(bool) |
bool |
false |
Shows the alt value on hover |
fallbackIcon(string) |
string |
heroicon-o-photo |
Icon shown when the cell is empty |
cropKey(string) |
string|null |
null |
Prefer a named crop URL if present in the stored array |
How it reads stored state
The column resolves the display URL by inspecting the stored value:
- If the value is an array with a
crop_urlkey andcropKeyis configured, it usescrop_url. - If the value is an array with a
urlkey, it usesurl. - If the value is a plain string, it treats it as a direct URL.
- If the value is an integer, it performs a lazy
Media::find()lookup.
Caveat for private disks
When the media disk visibility is private, stored URLs in the database are bare paths (no domain). The column calls Media::getUrlAttribute() at render time to generate a signed URL. This adds one query per row unless you eager-load the media relationship or use returnShape('url') with a pre-signed value. See Signed URL generation below.
5. Builder Media Modal
Inside the visual builder canvas, image props use the same media picker infrastructure via the <tgx-media-picker-field> Vue component. This component is the canonical single implementation — it is not a copy.
Component registration: The component is auto-registered by the Tagixo JS bundle and available inside the iframe canvas without any additional import.
Data source: The modal fetches the media library via a REST endpoint (GET /tagixo/api/media) rather than through the Livewire/Primix stack. This keeps the canvas iframe decoupled from the parent Livewire page.
Vue store integration: The selected media object is written directly into the builder's Pinia store (useBuilderStore().setActivePropValue(key, value)). The REST fetch result is cached in a separate useMediaStore() to avoid redundant round-trips while the editor has the modal open.
Prop serialisation: Builder media values follow the same 'array' shape as the Primix field. The PHP renderer (PageRenderer) resolves crop_url when a cropKey is set on the prop definition, falling back to url.
6. Upload Configuration
All keys live under config/tagixo.php in the media_gallery namespace. Set overrides via .env.
| Config key | Env variable | Default | Description |
|---|---|---|---|
media_gallery.disk |
TAGIXO_MEDIA_DISK |
'public' |
Laravel filesystem disk for uploads |
media_gallery.directory |
TAGIXO_MEDIA_DIR |
'media' |
Sub-directory on the disk |
media_gallery.max_file_size |
TAGIXO_MEDIA_MAX_SIZE |
10240 |
Maximum upload size in KB (10 MB) |
media_gallery.allowed_types |
— | ['image/jpeg','image/png','image/webp','image/gif','image/svg+xml'] |
Accepted MIME types (array, not env-configurable) |
media_gallery.visibility |
TAGIXO_MEDIA_VISIBILITY |
'public' |
'public' or 'private' |
media_gallery.signed_url_ttl |
TAGIXO_MEDIA_URL_TTL |
86400 |
Signed URL TTL in seconds (24 h) |
media_gallery.generate_crops |
TAGIXO_MEDIA_CROPS |
true |
Whether to run crop generation on upload |
media_gallery.crop_definitions |
— | ['thumbnail'=>[300,300],'og'=>[1200,630]] |
Named crop sizes [width, height] |
media_gallery.image_driver |
TAGIXO_MEDIA_DRIVER |
'gd' |
Intervention Image driver ('gd' or 'imagick') |
7. Signed URL Generation
When visibility is set to private, media files are stored without public access. URL resolution goes through Media::getUrlAttribute():
// Simplified internal logic
public function getUrlAttribute(): string
{
if ($this->visibility === 'private') {
$ttl = config('tagixo.media_gallery.signed_url_ttl', 86400);
return Storage::disk($this->disk)
->temporaryUrl($this->path, now()->addSeconds($ttl));
}
return Storage::disk($this->disk)->url($this->path);
}
TTL: Signed URLs expire after 24 hours by default (TAGIXO_MEDIA_URL_TTL=86400).
HTML caching warning: If you cache rendered page HTML (e.g. via a full-page cache), signed URLs embedded in src attributes will expire before the cache entry does. Either:
- Use
publicvisibility for images served in HTML, and reserveprivatefor download-only assets. - Set the HTML cache TTL shorter than the signed URL TTL.
- Avoid caching pages that contain private media; add a
Cache-Control: no-storeheader for those routes.
Crop URLs follow the same visibility path — each crop entry in tgx_media.crops stores the bare path, not the URL; the accessor is called per-crop at serialisation time.
8. LiVue-Specific Notes
update:modelValue vs. Livewire wire events
The <tgx-media-gallery-picker-modal> component emits update:modelValue following the standard Vue 3 v-model contract. Primix wraps this in a thin Livewire bridge; do not use wire:model directly on the component — the bridge handles the sync. In custom LiVue pages outside Primix, listen for update:modelValue in your Vue component tree:
<tgx-media-picker-field
:model-value="heroImage"
@update:model-value="heroImage = $event"
/>
No Alpine.js dependency
The media picker does not use Alpine.js. Do not add x-data or @click.away hooks around it expecting Alpine to intercept events — LiVue's reactivity system handles all state.
No PHP round-trip on selection
Selecting a media item in the picker does not fire a Livewire request. The media list is fetched once from the REST endpoint and cached in the Vue store for the duration of the page session. A PHP round-trip only occurs when Primix persists the form on save.
Debugging guidance
- Picker opens but shows no media: Check that the REST endpoint
GET /tagixo/api/mediareturns a 200 with valid JSON. Confirm the authenticated user has thebrowse-mediapermission. - Selection does not persist: Open Vue DevTools and verify the Pinia
useBuilderStoreor the Livewire component state updates onupdate:modelValue. If not, check that the field'swire:keyis unique within its repeater context. - Crop thumbnails missing: Run
php artisan tagixo:regenerate-cropsto reprocess existing uploads.
9. Permission Gating
The Media Gallery is gated behind the manage-media permission, which is checked in MediaResource::canAccess().
Seeder
The plugin ships a seeder that registers the permission:
php artisan db:seed --class="Ccast\\TagixoPrimix\\Database\\Seeders\\MediaPermissionsSeeder"
This creates two granular permissions that you can assign independently:
| Permission | Description |
|---|---|
browse-media |
View the media library and use the picker |
upload-media |
Upload new files (implies browse-media) |
manage-media is a convenience aggregate that grants both.
Browse-vs-upload separation pattern
To allow editors to pick from existing media but not upload new files, assign only browse-media:
// In your role seeder or panel AuthServiceProvider
$editorRole->givePermissionTo('browse-media');
$adminRole->givePermissionTo('manage-media');
The MediaResource upload action checks for upload-media before rendering; editors with only browse-media see a read-only gallery. The picker field and column are unaffected by upload permissions — they only require browse-media.