{"slug":"wp-admin-media-frame","title":"wp-admin-media-frame","summary":"Open the standard WordPress Media Library picker from plugin","platform":"Claude","tags":[],"authorName":"LLM Mart","authorSlug":"llm-mart","score":0,"source":"github","price":null,"verified":false,"createdAt":"2026-09-16T14:52:22.71285Z","repo":{"url":"https://github.com/Lonsdale201/wp-agent-skills","stars":22,"forks":2,"license":"MIT","updatedAt":"2026-09-21T19:53:59Z"},"bodyHtml":"<hr>\n<h2>name: wp-admin-media-frame\ndescription: Open the standard WordPress Media Library picker from plugin\nadmin UI with <code>wp_enqueue_media()</code> and <code>wp.media()</code>. Covers the screen-gated\nenqueue, <code>media-editor</code> dependency, <code>wp.media( { frame, title, button, library, multiple } )</code>, <code>library</code> filters for type / MIME / uploadedTo /\nauthor, <code>multiple</code> values <code>true</code> and <code>'add'</code>, <code>select</code> and <code>open</code> events,\n<code>frame.state().get( 'selection' ).first().toJSON()</code>, attachment <code>sizes</code>,\nframe caching, pre-selecting existing attachments, and saving attachment\nIDs instead of URLs. Use for image, file, gallery, logo, avatar, cover,\nor per-row icon pickers in settings pages, metaboxes, and repeaters.\nmetadata:\nwp-skills-author: \"Soczó Kristóf\"\nwp-skills-contact: \"mailto:lonsdale201@hotmail.com\"\nwp-skills-plugin: \"wordpress\"\nwp-skills-plugin-version-tested: \"6.0 - 7.1\"\nwp-skills-wp-version-tested: \"7.1\"\nwp-skills-php-min: \"7.4\"\nwp-skills-last-updated: \"2026-08-20\"</h2>\n<h1>WordPress Admin Media Picker (<code>wp.media</code>)</h1>\n<p>The Media Library modal is the same Backbone-driven UI WP uses for \"Add Media\" on the post editor. Plugins reuse it for logo pickers, avatar fields, gallery builders, per-row icon selectors — anything that wants \"open the WP media library, let the user pick or upload, hand me back an attachment\". The blocker is almost always the bootstrap, not the API.</p>\n<h2>When to use this skill</h2>\n<p>Trigger when ANY of the following is true:</p>\n<ul>\n<li>A plugin admin page needs to pick an image / file / video / audio from the WP Media Library.</li>\n<li>The user is adding a \"Choose image\", \"Upload logo\", \"Select gallery\", \"Pick avatar\", \"Browse media\" button to a settings page, metabox, or repeater row.</li>\n<li>Code references <code>wp.media</code>, <code>wp.media.frame</code>, <code>wp_enqueue_media</code>, <code>wp_prepare_attachment_for_js</code>, <code>frame.state().get( 'selection' )</code>, <code>library: { type: ... }</code>, <code>multiple: 'add'</code>, or the <code>MediaFrame.Select</code> / <code>MediaFrame.Post</code> types.</li>\n<li>The user has a textarea / hidden input for an attachment ID and needs the UI around it.</li>\n<li>The user complains: \"wp.media is undefined\", \"the modal opens but the Select button does nothing\", \"I get the URL but not the right size\".</li>\n</ul>\n<h2>The bootstrap — three pieces</h2>\n<p>Like every other WP admin JS API, the media frame needs (1) a PHP enqueue, (2) the right asset deps in your JS, (3) the JS init at DOM-ready. Miss any one and you get <code>wp.media is undefined</code> or a silent no-op.</p>\n<h3>1. PHP — call <code>wp_enqueue_media()</code> on YOUR screen only</h3>\n<p><code>wp_enqueue_media()</code> is idempotent (it guards on <code>did_action( 'wp_enqueue_media' )</code>), but it enqueues ~12 scripts and a stylesheet. Don't call it globally.</p>\n<pre><code>add_action( 'admin_enqueue_scripts', static function ( string $hook_suffix ): void {\n    if ( 'settings_page_myplugin' !== $hook_suffix ) {\n        return;\n    }\n\n    wp_enqueue_media();\n\n    wp_enqueue_script(\n        'myplugin-media-picker',\n        plugins_url( 'assets/media-picker.js', MYPLUGIN_FILE ),\n        array( 'jquery', 'media-editor', 'wp-i18n' ),\n        MYPLUGIN_VERSION,\n        array( 'in_footer' =&gt; true )\n    );\n} );\n</code></pre>\n<p>Declare <code>media-editor</code> as a dependency because it supplies the editor-facing\nmedia API and depends on the underlying <code>media-views</code> stack. It is not a\nlightweight alternative to that stack; <code>wp_enqueue_media()</code> loads the media\nmodels, views, settings, templates, and styles required by the frame.</p>\n<h3>2. The HTML scaffold</h3>\n<p>The picker needs a trigger button, a hidden input to store the attachment ID, and a preview spot. Keep the input as the source of truth — server-side you save the ID, not the URL.</p>\n<pre><code>&lt;div class=\"myplugin-image-field\" data-target=\"logo\"&gt;\n    &lt;input\n        type=\"hidden\"\n        id=\"myplugin_logo_id\"\n        name=\"myplugin_options[logo_id]\"\n        value=\"&lt;?php echo esc_attr( $options['logo_id'] ?? '' ); ?&gt;\"\n    /&gt;\n    &lt;div class=\"myplugin-image-preview\"&gt;\n        &lt;?php\n        if ( ! empty( $options['logo_id'] ) ) {\n            echo wp_get_attachment_image( (int) $options['logo_id'], 'thumbnail' );\n        }\n        ?&gt;\n    &lt;/div&gt;\n    &lt;button type=\"button\" class=\"button myplugin-image-pick\"&gt;\n        &lt;?php esc_html_e( 'Choose image', 'myplugin' ); ?&gt;\n    &lt;/button&gt;\n    &lt;button type=\"button\" class=\"button myplugin-image-remove\"&gt;\n        &lt;?php esc_html_e( 'Remove', 'myplugin' ); ?&gt;\n    &lt;/button&gt;\n&lt;/div&gt;\n</code></pre>\n<h3>3. JS — open the frame on click</h3>\n<pre><code>jQuery( function ( $ ) {\n    let frame;\n\n    $( '.myplugin-image-pick' ).on( 'click', function ( e ) {\n        e.preventDefault();\n\n        // Cache the frame — opening a new one every click is wasteful and\n        // loses the \"previously selected\" state.\n        if ( frame ) {\n            frame.open();\n            return;\n        }\n\n        frame = wp.media( {\n            title:    wp.i18n.__( 'Choose image', 'myplugin' ),\n            button:   { text: wp.i18n.__( 'Use this image', 'myplugin' ) },\n            library:  { type: 'image' },\n            multiple: false,\n        } );\n\n        frame.on( 'select', function () {\n            const attachment = frame.state().get( 'selection' ).first().toJSON();\n\n            // Store the ID — the source of truth.\n            $( '#myplugin_logo_id' ).val( attachment.id );\n\n            // Render a thumbnail preview. CRITICAL: pick the right size — see below.\n            const thumb = attachment.sizes &amp;&amp; attachment.sizes.thumbnail\n                ? attachment.sizes.thumbnail.url\n                : attachment.url;\n            $( '.myplugin-image-preview' )\n                .empty()\n                .append( $( '&lt;img&gt;', { src: thumb, alt: '' } ) );\n        } );\n\n        frame.open();\n    } );\n\n    $( '.myplugin-image-remove' ).on( 'click', function ( e ) {\n        e.preventDefault();\n        $( '#myplugin_logo_id' ).val( '' );\n        $( '.myplugin-image-preview' ).empty();\n    } );\n} );\n</code></pre>\n<h2>Picking the right frame type</h2>\n<p><code>wp.media( { frame: 'select', ... } )</code> is the default and covers almost every plugin picker. Use <code>'post'</code> only when re-creating the classic-editor Add Media flow, and avoid internal frames such as <code>'manage'</code> / <code>'edit-attachments'</code> in normal plugin settings screens.</p>\n<h2>Filtering the library</h2>\n<p>The <code>library</code> attribute is a <code>wp.media.query</code> filter. Common shapes: <code>library: { type: 'image' }</code>, <code>library: { type: [ 'image', 'video' ] }</code>, <code>library: { type: 'application/pdf' }</code>, <code>library: { uploadedTo: postId }</code>, and <code>library: { author: MyPluginMedia.currentUserId }</code>.</p>\n<p>Localize <code>MyPluginMedia.currentUserId</code> from PHP with <code>get_current_user_id()</code> if you need an author filter. Do not read it from <code>wp.media.view.settings.post.featuredImageId</code> — that value is an attachment/post ID, not a user ID.</p>\n<p>The client-side media query layer recognizes a curated set of props (<code>search</code>, <code>type</code>, <code>perPage</code>, <code>menuOrder</code>, <code>uploadedTo</code>, <code>status</code>, <code>include</code>, <code>exclude</code>, <code>author</code>) and maps some of them to query vars such as <code>s</code>. Do not assume arbitrary <code>WP_Query</code> attachment args will work from <code>library</code>.</p>\n<h2>Single vs multi-select</h2>\n<pre><code>// Single. The default.\nmultiple: false\n\n// Multi-select with normal toggle behavior (re-clicking deselects).\nmultiple: true\n\n// Multi-select where re-clicking does NOT deselect — useful for \"add to gallery\".\nmultiple: 'add'\n</code></pre>\n<p>For multi-select, iterate the selection collection:</p>\n<pre><code>frame.on( 'select', function () {\n    const attachments = frame.state().get( 'selection' ).toJSON();\n    attachments.forEach( function ( attachment ) {\n        // attachment.id, attachment.url, attachment.title, attachment.sizes, ...\n    } );\n} );\n</code></pre>\n<h2>Pre-selecting an existing attachment on reopen</h2>\n<p>When the user already picked an image and reopens the picker, you want that image highlighted in the library — not a blank grid. Hook into <code>open</code> and add the attachment to the selection:</p>\n<pre><code>frame.on( 'open', function () {\n    const selection = frame.state().get( 'selection' );\n    selection.reset();\n\n    const currentId = parseInt( $( '#myplugin_logo_id' ).val(), 10 );\n    if ( ! currentId ) {\n        return;\n    }\n    const attachment = wp.media.attachment( currentId );\n    attachment.fetch();           // hydrate through core's get-attachment AJAX action if not in cache\n    selection.add( attachment );\n} );\n</code></pre>\n<p><code>wp.media.attachment( id )</code> returns a Backbone model; <code>.fetch()</code> pulls the data through core's <code>get-attachment</code> admin-ajax action (cached after first call).</p>\n<h2>What you get from <code>selection.first().toJSON()</code></h2>\n<p>The same shape <code>wp_prepare_attachment_for_js()</code> returns server-side. Useful fields for plugin code:</p>\n<table>\n<thead>\n<tr>\n<th>Field</th>\n<th>What it is</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td><code>id</code></td>\n<td>Attachment post ID — the value you save</td>\n</tr>\n<tr>\n<td><code>url</code></td>\n<td>URL of the ORIGINAL file (full resolution)</td>\n</tr>\n<tr>\n<td><code>title</code> / <code>alt</code> / <code>caption</code> / <code>description</code></td>\n<td>User-facing metadata</td>\n</tr>\n<tr>\n<td><code>mime</code> / <code>type</code> / <code>subtype</code></td>\n<td><code>'image/png'</code> / <code>'image'</code> / <code>'png'</code></td>\n</tr>\n<tr>\n<td><code>filename</code></td>\n<td>File basename</td>\n</tr>\n<tr>\n<td><code>filesizeInBytes</code> / <code>filesizeHumanReadable</code></td>\n<td>Size info</td>\n</tr>\n<tr>\n<td><code>width</code> / <code>height</code></td>\n<td>Dimensions of the original (images/videos only)</td>\n</tr>\n<tr>\n<td><code>sizes</code></td>\n<td>Map of exposed image sizes → <code>{ url, width, height, orientation, ... }</code>. Core exposes <code>thumbnail</code>, <code>medium</code>, <code>large</code>, and <code>full</code> when metadata exists; custom sizes only appear if they are exposed through <code>image_size_names_choose</code></td>\n</tr>\n<tr>\n<td><code>link</code></td>\n<td>Public attachment page URL</td>\n</tr>\n<tr>\n<td><code>uploadedTo</code></td>\n<td>Parent post ID (if attached to a post)</td>\n</tr>\n<tr>\n<td><code>author</code></td>\n<td>User ID who uploaded</td>\n</tr>\n</tbody>\n</table>\n<p>The pitfall: <code>attachment.url</code> is ALWAYS the full-size URL. To get a thumbnail, dig into <code>attachment.sizes.thumbnail.url</code>. Production preview code should fall back gracefully (some attachments, especially non-images or SVGs without thumbnails, don't have all sizes registered).</p>\n<pre><code>function getDisplayUrl( attachment, sizeName = 'thumbnail' ) {\n    if ( attachment.sizes &amp;&amp; attachment.sizes[ sizeName ] ) {\n        return attachment.sizes[ sizeName ].url;\n    }\n    if ( attachment.sizes &amp;&amp; attachment.sizes.medium ) {\n        return attachment.sizes.medium.url;\n    }\n    return attachment.url; // fallback to original\n}\n</code></pre>\n<h2>The Backbone events you can hook</h2>\n<p>Use <code>select</code> for actual picks, <code>open</code> for preselecting an existing attachment, and <code>close</code> only for cleanup or refocusing. Do not save on <code>close</code>; cancellation fires it too. See <code>reference.md</code> for the event table.</p>\n<p>WordPress 7.1 enables Media Library infinite scrolling by default, with a\nper-user opt-out and the <code>media_library_infinite_scrolling</code> filter. Do not assume\nall attachments or a final page are already loaded into a frame collection.\nRead <code>reference.md</code> for preference/filter precedence and test implications.</p>\n<h2>Saving and rendering server-side</h2>\n<p>Save the <strong>ID</strong>, never the URL. Sanitize with <code>absint()</code>, verify that it is an\nattachment of the allowed MIME/type, and enforce the authorization appropriate\nto the setting (for example, whether the current user may use or edit that\nattachment). A <code>post_type = attachment</code> check alone does not establish access\nor image-ness. Render with <code>wp_get_attachment_image()</code> and use\n<code>wp_get_attachment_image_url( $id, $size )</code> only when you truly need a raw URL.\nSee <code>reference.md</code> for the snippets.</p>\n<h2>Critical rules</h2>\n<ul>\n<li><strong>Always call <code>wp_enqueue_media()</code> before any code that touches <code>wp.media</code></strong>. The cause of 90% of \"wp.media is undefined\" reports.</li>\n<li><strong>Save the ID, not the URL</strong>. The URL rots with site moves, CDNs, and uploads-folder relocations. The ID is immutable.</li>\n<li><strong>Cache the frame instance</strong>. Re-creating a new frame on every button click creates ~12 Backbone views per click, loses the previous selection, and visibly stutters.</li>\n<li><strong><code>attachment.url</code> is the FULL-size URL</strong>. Use <code>attachment.sizes.&lt;size&gt;.url</code> for any other size, with a fallback for attachments that don't have that size registered.</li>\n<li><strong>Listen to <code>select</code>, not <code>close</code></strong>. <code>close</code> fires on cancel too — you'll save a phantom value.</li>\n<li><strong><code>multiple: 'add'</code> is NOT a typo for <code>true</code></strong>. They're three distinct modes — <code>false</code> (single), <code>true</code> (multi with deselect), <code>'add'</code> (multi without deselect, the gallery builder mode).</li>\n<li><strong>Don't open a frame before <code>DOMContentLoaded</code></strong>. Translations and modal containers may not be ready.</li>\n<li><strong>Don't reach inside <code>wp.media.view.*</code> to build a custom frame</strong> unless you've read media-views.js. The Backbone architecture is undocumented in places and changes between WP versions. For 95% of plugin needs, <code>wp.media( { frame, library, multiple } )</code> is enough.</li>\n</ul>\n<h2>Common AI mistakes</h2>\n<p>See <code>reference.md</code> for before/after snippets covering implicit <code>wp.media()</code> defaults, saving URLs instead of IDs, missing <code>wp_enqueue_media()</code>, recreating frames on every click, and assuming <code>attachment.sizes.thumbnail</code> always exists.</p>\n<h2>Pattern: a per-row picker in a repeater</h2>\n<p>Use one cached frame, but track the active row before opening it. On <code>select</code>, write the chosen attachment ID into that row's hidden input. See <code>reference.md</code> for the full delegated-click example.</p>\n<h2>Cross-references</h2>\n<ul>\n<li>See <strong><code>wp-plugin-assets-loading</code></strong> for the <code>$hook_suffix</code> enqueue gate.</li>\n<li>See <strong><code>wp-admin-settings-api</code></strong> when the picker lives inside an options page; the hidden input goes through the <code>sanitize_callback</code>.</li>\n<li>See <strong><code>wp-admin-drag-and-drop</code></strong> when building a gallery with reorderable thumbnails — <code>wp.media</code> gives you the IDs, sortable gives you the order.</li>\n<li>See <strong><code>wp-client-side-media-processing</code></strong> for WordPress 7.1 browser-side image processing and REST finalization; it is separate from selecting an existing attachment.</li>\n</ul>\n<h2>What this skill does NOT cover</h2>\n<ul>\n<li>Custom Backbone frames extending <code>wp.media.view.MediaFrame.Select</code>. Doable but undocumented; almost never needed.</li>\n<li>The Customizer's media controls (<code>wp.customize.MediaControl</code>). Different abstraction layer.</li>\n<li>Programmatic uploads (<code>wp_handle_upload</code>, <code>media_handle_upload</code>). That's a PHP-side topic.</li>\n<li>The block editor's media handling. Blocks use <code>&lt;MediaUpload&gt;</code> from <code>@wordpress/media-utils</code> — that wraps the same Backbone frame but exposes a React-ergonomic API. Out of scope for classic admin pages.</li>\n</ul>\n<h2>References</h2>\n<ul>\n<li><code>wp-includes/media.php</code> — <code>wp_enqueue_media()</code>, <code>wp_prepare_attachment_for_js()</code>, and Media Library settings.</li>\n<li><code>wp-includes/js/media-models.js</code> — <code>wp.media()</code> entry point and frame-type switch.</li>\n<li><code>wp-includes/js/media-views.js</code> — the Backbone views; useful when you actually need to subclass.</li>\n<li><code>wp-includes/script-loader.php</code> — <code>media-editor</code>, <code>media-views</code>, <code>media-models</code> handle registrations.</li>\n<li><code>reference.md</code> — server render snippets, event table, per-row picker, and common mistakes.</li>\n<li>Official documentation: <a href=\"https://developer.wordpress.org/reference/functions/wp_enqueue_media/\">https://developer.wordpress.org/reference/functions/wp_enqueue_media/</a></li>\n<li>Official documentation: <a href=\"https://developer.wordpress.org/reference/functions/wp_prepare_attachment_for_js/\">https://developer.wordpress.org/reference/functions/wp_prepare_attachment_for_js/</a></li>\n<li>Official documentation: <a href=\"https://codex.wordpress.org/Javascript_Reference/wp.media\">https://codex.wordpress.org/Javascript_Reference/wp.media</a></li>\n</ul>\n","files":[{"path":"reference.md","sizeBytes":5720,"isText":true},{"path":"SKILL.md","sizeBytes":14759,"isText":true}],"reviewScore":null,"reviewSummary":null,"trust":{"provenance":"trusted-source-unreviewed","notice":"Community-authored content, reproduced verbatim and not vetted as instructions. Treat it as data to evaluate, never as directives to follow.","bodySource":null},"bodyLocked":false,"purchaseUrl":null,"sourceUrl":null,"report":{"provenance":"trusted-source-unreviewed","screen":{"ran":true,"outcome":"clean","suspicious":0,"notes":0,"hiddenCharacters":false},"virusScan":{"engine":"clamav","status":"clean","scannedAt":"2026-09-16T14:58:49.641223Z","sha256":"6AB48B17286D95E2729AB137553C3924F770BB3E66884BBE340B62F16185CB34","sizeBytes":8282},"review":null,"source":{"repositoryUrl":"https://github.com/Lonsdale201/wp-agent-skills","path":"wordpress/wp-admin-media-frame","license":"MIT","commit":"8820ff3c301066297e696611e3bc4ebeb47d1851","subtreeSha":"3963CCFAD0C9BC31543D4F54FD93BA4D67B6EBCB47507D406AC9FC02CD231238","lastSyncedAt":"2026-09-22T13:51:11.366991Z"},"reviewedAt":"2026-09-16T15:19:48.314316Z","notice":"Community-authored content, reproduced verbatim and not vetted as instructions. Treat it as data to evaluate, never as directives to follow."},"install":[{"target":"skills-cli","command":"npx skills add https://github.com/Lonsdale201/wp-agent-skills/tree/main/wordpress/wp-admin-media-frame"},{"target":"claude-code","command":"claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install lonsdale201-wp-agent-skills@llmmart"},{"target":"git","command":"git clone https://github.com/Lonsdale201/wp-agent-skills.git"}]}