CWA 2 is a prerelease (2.0.0-alpha), heading for a beta. It runs production sites; configuration can still change between releases, always with upgrade notes. View versions and support
Component Helpers

Images & Media

Display uploaded files with useCwaComponent and withFile(), manage uploads in admin tabs with useCwaResourceUpload, and use Imagine filter variants.

When a PHP component uses #[Silverback\Uploadable], use useCwaComponent with the withFile() plugin. It exposes the uploaded file's URL, load state, and Imagine filter variants under a files map keyed by the field's fileProp (default 'file'). Map entries are reactive, so in templates you use them without .value (files.file.contentUrl).

Display Component

<!-- app/app/cwa/components/Image/Image.vue -->
<template>
    <div class="w-full relative my-5">
        <div
            v-if="files.file?.displayMedia"
            class="relative flex overflow-hidden max-w-full w-fit"
        >
            <img
                ref="file"
                :src="files.file.contentUrl"
                :srcset="files.file.srcset"
                :sizes="sizes"
                :width="files.file.displayMedia.width"
                :height="files.file.displayMedia.height"
                class="max-w-full h-auto"
                :style="{ 'aspect-ratio': `${files.file.displayMedia.width} / ${files.file.displayMedia.height}` }"
                @load="files.file.handleLoad"
            >
            <div
                class="absolute inset-0 bg-gray-200 pointer-events-none transition-opacity"
                :class="{ 'opacity-0': files.file.loaded }"
            />
        </div>
        <div
            v-else
            class="w-40 h-40 bg-gray-200 flex items-center justify-center text-gray-500 font-bold"
        >
            No Image
        </div>
    </div>
</template>

<script setup lang="ts">
import { computed, useTemplateRef } from 'vue'
import type { IriProp } from '#cwa/composables/cwa-resource'
import { useCwaComponent, withFile } from '#imports'

const props = defineProps<IriProp>()

const imageRef = useTemplateRef<unknown>('file')
const { exposeMeta, files } = useCwaComponent(props, [
    withFile({ imagineFilterName: 'general', imageRef }) ])

// Shown at the general filter's width, or the viewport's when narrower
const sizes = computed(() => {    const width = files.file?.displayMedia?.width    return width ? `(max-width: ${width}px) 100vw, ${width}px` : undefined}) 
defineExpose(exposeMeta)
</script>

Two stages are at work here. displayMedia tells you the media exists in the resource data, so the outer branch swaps the "No Image" box for the real <img>. loaded then tells you the browser has painted it, fading the grey placeholder out over the top. The wrapper is w-fit, so the placeholder covers the image and no more, and the aspect-ratio style reserves the image's space before it loads.

imageRef makes loaded reliable for an image the browser finished loading before the page hydrated, such as one in its cache: its load event fired before Vue attached @load, so without imageRef the placeholder would stay over it. Pass it whenever you rely on loaded, and omit it for non-image fields. The template's Image component passes it. Why imageRef exists explains it, and why the ref is typed <unknown>.

Responsive Images

files.file.srcset lists every sized variant of the field: the original and each Imagine filter, smallest first. The browser picks one from it using the sizes you give the <img>. The example above follows the template's Image component:

  • withFile({ imagineFilterName: 'general' }) makes the general filter the src, the fallback for browsers without srcset support.
  • sizes says the image is shown at that filter's width, or the viewport's width when the viewport is narrower. Write your own if your layout differs.
  • It's a plain <img>, not <NuxtImg>, because <NuxtImg> reads sizes as its own format and builds its own srcset.

srcset is undefined for an SVG, or when the field has fewer than two variants with a known width, and Vue then leaves the attribute out. The full rules are under useCwaFileField › srcset.

Add a Filter for High-Density Screens

The original upload is in the srcset too. On a high-density screen, the browser wants about twice the display width. If no filter is that wide, it downloads the original, which can be many times larger. The template adds a large filter at twice general's size for this:

# api/config/packages/liip_imagine.yaml
liip_imagine:
  filter_sets:
    general:
      format: webp
      quality: 80
      filters:
        thumbnail:
          size: [ 1200, 1200 ]
          mode: inset
    large:      format: webp      quality: 80      filters:        thumbnail:          size: [ 2400, 2400 ]          mode: inset

Then list it on the field, so the API generates it for each upload:

#[Silverback\UploadableField(adapter: 'gcloud', urlGenerator: 'public', imagineFilters: ['thumbnail', 'general', 'large'])]

A filter never upscales, so an image smaller than 2400 px gets a large variant the same width as the original. srcset then keeps whichever of the two files is smaller. See Uploadable for imagineFilters. The template's filters all save WebP, whatever the upload's format: see WebP and Other Output Formats.

IMAGE: The browser's network panel on a high-density screen, showing the image request for the large variant rather than the original upload

The files Map

Each entry — files[fileProp] — carries these values for that field:

ValueTypeDescription
contentUrlstring | undefinedPublic URL of the (optionally filtered) file
displayMediaMediaFile | undefinedThe media object to display — has contentUrl, mimeType, width, height; undefined until available
srcsetstring | undefinedEvery sized variant, smallest first, for the <img>'s srcset. See Responsive images
loadedbooleanBecomes true when handleLoad() is called
handleLoad() => voidCall on the <img> element's @load event

Because entries are reactive, use them directly in templates (files.file.contentUrl) — no .value. Gate rendering on files.file?.displayMedia so the skeleton shows until the media is resolved and the browser has loaded the image.

Using a Specific Imagine Filter

Pass imagineFilterName to surface a specific image variant as displayMedia/contentUrl:

const { files } = useCwaComponent(props, [
    withFile({ imagineFilterName: 'thumbnail' })
])
// files.file.contentUrl → the 'thumbnail' variant

Without imagineFilterName, the field resolves to the original uploaded file.

Multiple File Fields

A component can hold more than one uploadable field (e.g. a poster and a thumbnail). Add one withFile() per field, each with its own fileProp — useCwaComponent accumulates them into the same files map:

const { files } = useCwaComponent(props, [
    withFile({ fileProp: 'poster' }),
    withFile({ fileProp: 'thumbnail', imagineFilterName: 'small' }),
])
// files.poster.contentUrl · files.thumbnail.contentUrl

Each fileProp must match a distinct #[UploadableField] property on the PHP entity. For the standalone, non-plugin form, see useCwaFileField.

Admin Upload Tab

useCwaResourceUpload returns a bind object — destructure it, spread it onto CwaUiFormFile with v-bind, and only supply the label (and optional accept) per field:

<!-- app/app/cwa/components/Image/admin/Image.vue -->
<template>
    <div class="p-4">
        <CwaUiFormFile v-bind="bind" label="Upload Image" accept="image/*" />
    </div>
</template>

<script setup lang="ts">
import type { IriProp } from '#cwa/composables/cwa-resource'
import { useCwaResourceManagerTab, useCwaResourceUpload } from '#imports'

defineProps<IriProp>()

const { exposeMeta, iri } = useCwaResourceManagerTab({ name: 'Image', order: 1 })
const { bind } = useCwaResourceUpload(iri, 'file')  // 'file' = the PHP property name

defineExpose(exposeMeta)
</script>

useCwaResourceUpload(iri, propertyName) handles file selection, multipart upload to {iri}/upload, and deletion via PATCH. bind is a ComputedRef, and templates only auto-unwrap top-level refs, so v-bind="upload.bind" on a non-destructured result would not unwrap. For multiple file fields, call it once per property and rename as you destructure (const { bind: posterBind } = …). See useCwaResourceUpload for the full reference.

Large JPEG, PNG and WebP images are resized in the browser before they are sent: by default, anything over 2560 px on its longest edge or over 20 megapixels. The resized file has no EXIF metadata, so phone photos lose their GPS location. To keep originals for one field, pass { imageDownscale: { enabled: false } } as the fourth argument. See Large images are resized before upload.

Video, PDF, and Other Files

The same pattern works for any file type — withFile() is not image-specific. For non-image files there's no @load event to wait for, so render as soon as files.file?.contentUrl is available:

<template>
    <a v-if="files.file?.contentUrl" :href="files.file.contentUrl" download>
        Download PDF
    </a>
</template>

<script setup lang="ts">
import type { IriProp } from '#cwa/composables/cwa-resource'
import { useCwaComponent, withFile } from '#imports'

const props = defineProps<IriProp>()
const { exposeMeta, files } = useCwaComponent(props, [withFile()])
defineExpose(exposeMeta)
</script>

For video:

<video v-if="files.file?.contentUrl" :src="files.file.contentUrl" controls class="w-full" />