feat(media-folder-pro): add virtual folder system for WordPress media library

Custom WordPress plugin that replaces the default flat media library with
a structured folder view. Features: hierarchical folders via custom taxonomy,
sidebar folder tree, drag & drop, modal integration with Elementor/builders,
bulk assign, upload auto-assign, toast notifications.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-28 14:08:49 +01:00
parent 4ad3303b18
commit 5014b9108f
19 changed files with 3692 additions and 0 deletions

View File

@@ -0,0 +1,533 @@
/* Media Folder Pro — Admin Styles */
/* Sidebar container */
#mfp-folder-root {
position: fixed;
top: 32px; /* WP admin bar */
left: 160px; /* WP admin menu */
bottom: 0;
width: 260px;
background: #f0f0f1;
border-right: 1px solid #c3c4c7;
overflow-y: auto;
z-index: 99;
font-size: 13px;
padding: 0;
display: flex;
flex-direction: column;
}
/* Collapsed admin menu */
.folded #mfp-folder-root {
left: 36px;
}
/* Push main content */
body.upload-php #wpbody {
margin-left: 260px;
}
/* Toolbar */
.mfp-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
border-bottom: 1px solid #c3c4c7;
background: #fff;
flex-shrink: 0;
}
.mfp-toolbar__title {
font-weight: 600;
font-size: 13px;
color: #1d2327;
}
.mfp-toolbar__btn {
background: none;
border: 1px solid #c3c4c7;
border-radius: 3px;
cursor: pointer;
color: #2271b1;
font-size: 16px;
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s;
}
.mfp-toolbar__btn:hover {
background: #2271b1;
color: #fff;
border-color: #2271b1;
}
/* All Media link */
.mfp-all-media {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
color: #1d2327;
cursor: pointer;
border-bottom: 1px solid #e0e0e0;
background: #fff;
flex-shrink: 0;
transition: background 0.15s;
}
.mfp-all-media:hover {
background: #f0f6fc;
}
.mfp-all-media.is-active {
background: #2271b1;
color: #fff;
}
.mfp-all-media__icon {
font-size: 16px;
width: 20px;
text-align: center;
}
/* Uncategorized link */
.mfp-uncategorized {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
color: #787c82;
cursor: pointer;
border-bottom: 1px solid #c3c4c7;
background: #fff;
flex-shrink: 0;
transition: background 0.15s;
font-style: italic;
}
.mfp-uncategorized:hover {
background: #f0f6fc;
color: #1d2327;
}
.mfp-uncategorized.is-active {
background: #2271b1;
color: #fff;
font-style: normal;
}
.mfp-uncategorized__icon {
font-size: 16px;
width: 20px;
text-align: center;
}
/* Tree container */
.mfp-tree {
flex: 1;
overflow-y: auto;
padding: 4px 0;
}
.mfp-tree ul {
list-style: none;
margin: 0;
padding: 0;
}
.mfp-tree ul ul {
padding-left: 16px;
}
/* Folder item */
.mfp-folder {
position: relative;
}
.mfp-folder__row {
display: flex;
align-items: center;
gap: 4px;
padding: 5px 12px 5px 8px;
cursor: pointer;
border-radius: 0;
transition: background 0.15s;
user-select: none;
}
.mfp-folder__row:hover {
background: #f0f6fc;
}
.mfp-folder__row.is-active {
background: #2271b1;
color: #fff;
}
.mfp-folder__toggle {
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
color: #787c82;
flex-shrink: 0;
transition: transform 0.2s;
}
.mfp-folder__row.is-active .mfp-folder__toggle {
color: rgba(255, 255, 255, 0.7);
}
.mfp-folder__toggle.is-expanded {
transform: rotate(90deg);
}
.mfp-folder__toggle.is-leaf {
visibility: hidden;
}
.mfp-folder__icon {
font-size: 15px;
width: 20px;
text-align: center;
flex-shrink: 0;
}
.mfp-folder__name {
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-size: 13px;
line-height: 1.4;
}
.mfp-folder__count {
font-size: 11px;
color: #787c82;
flex-shrink: 0;
}
.mfp-folder__row.is-active .mfp-folder__count {
color: rgba(255, 255, 255, 0.7);
}
/* Inline rename input */
.mfp-folder__rename-input {
flex: 1;
font-size: 13px;
padding: 1px 4px;
border: 1px solid #2271b1;
border-radius: 2px;
outline: none;
background: #fff;
color: #1d2327;
}
/* Children container */
.mfp-folder__children {
display: none;
}
.mfp-folder__children.is-open {
display: block;
}
/* Context menu */
.mfp-context-menu {
position: fixed;
background: #fff;
border: 1px solid #c3c4c7;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
z-index: 1000;
min-width: 160px;
padding: 4px 0;
}
.mfp-context-menu__item {
display: block;
width: 100%;
padding: 6px 12px;
text-align: left;
background: none;
border: none;
cursor: pointer;
font-size: 13px;
color: #1d2327;
transition: background 0.1s;
}
.mfp-context-menu__item:hover {
background: #2271b1;
color: #fff;
}
.mfp-context-menu__item--danger {
color: #d63638;
}
.mfp-context-menu__item--danger:hover {
background: #d63638;
color: #fff;
}
.mfp-context-menu__separator {
height: 1px;
background: #e0e0e0;
margin: 4px 0;
}
/* Empty state */
.mfp-empty {
padding: 30px 16px;
text-align: center;
color: #787c82;
}
.mfp-empty__icon {
font-size: 36px;
margin-bottom: 8px;
}
.mfp-empty__text {
font-size: 13px;
margin-bottom: 12px;
}
.mfp-empty__cta {
display: inline-block;
background: #2271b1;
color: #fff;
border: none;
border-radius: 3px;
padding: 6px 14px;
font-size: 13px;
cursor: pointer;
transition: background 0.15s;
}
.mfp-empty__cta:hover {
background: #135e96;
}
/* Loading spinner */
.mfp-loading {
padding: 30px 12px;
text-align: center;
}
.mfp-spinner {
display: inline-block;
width: 24px;
height: 24px;
border: 3px solid #c3c4c7;
border-top-color: #2271b1;
border-radius: 50%;
animation: mfp-spin 0.6s linear infinite;
}
@keyframes mfp-spin {
to { transform: rotate(360deg); }
}
/* Modal folder filter dropdown */
.mfp-modal-filter-wrap {
float: left;
margin-right: 8px;
margin-top: 10px;
}
.mfp-modal-filter {
min-width: 220px;
max-width: 400px;
width: auto;
height: 32px;
font-size: 13px;
border: 1px solid #c3c4c7;
border-radius: 3px;
background: #fff;
color: #1d2327;
padding: 0 24px 0 8px;
cursor: pointer;
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%23787c82'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 8px center;
text-overflow: ellipsis;
}
.mfp-modal-filter:focus {
border-color: #2271b1;
box-shadow: 0 0 0 1px #2271b1;
outline: none;
}
/* Folder select on Upload tab */
.mfp-upload-folder-wrap {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 12px 16px;
background: #f0f6fc;
border-bottom: 1px solid #c3c4c7;
}
.mfp-upload-folder-label {
font-size: 13px;
color: #1d2327;
font-weight: 500;
white-space: nowrap;
}
/* Drag & drop — media on folder (blue) */
.mfp-drop-target {
background: #f0f6fc !important;
outline: 2px dashed #2271b1;
outline-offset: -2px;
}
.mfp-dragging .mfp-folder__row {
transition: background 0.1s;
}
.mfp-dragging .mfp-folder__row:hover {
background: #e8f0fe;
}
body.upload-php .attachments .attachment[draggable="true"] {
cursor: grab;
}
body.upload-php .attachments .attachment[draggable="true"]:active {
cursor: grabbing;
}
/* Drag & drop — folder on folder (yellow/amber) */
.mfp-folder-drop-target {
background: #fef3cd !important;
outline: 2px dashed #dba617;
outline-offset: -2px;
}
.mfp-dragging-folder .mfp-folder__row {
transition: background 0.1s;
}
.mfp-dragging-folder .mfp-folder__row:hover {
background: #fef9e7;
}
/* Tree drop zone for root-level drop */
.mfp-tree-drop-target {
background: #fef9e7;
outline: 2px dashed #dba617;
outline-offset: -4px;
}
/* Bulk assign button */
.mfp-bulk-btn {
margin-left: 8px !important;
margin-top: 10px !important;
}
/* Bulk assign dropdown */
.mfp-bulk-dropdown {
position: fixed;
background: #fff;
border: 1px solid #c3c4c7;
border-radius: 4px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 1001;
min-width: 200px;
max-width: 280px;
max-height: 300px;
overflow-y: auto;
padding: 4px 0;
}
.mfp-bulk-dropdown__item {
display: block;
width: 100%;
padding: 6px 12px;
text-align: left;
background: none;
border: none;
cursor: pointer;
font-size: 13px;
color: #1d2327;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
transition: background 0.1s;
}
.mfp-bulk-dropdown__item:hover {
background: #2271b1;
color: #fff;
}
.mfp-bulk-dropdown__item--remove {
color: #787c82;
font-style: italic;
}
.mfp-bulk-dropdown__item--remove:hover {
background: #787c82;
color: #fff;
}
/* Toast notifications */
.mfp-toast {
position: fixed;
top: 50px;
right: 20px;
z-index: 100001;
padding: 10px 18px;
border-radius: 4px;
font-size: 13px;
color: #fff;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
transform: translateX(120%);
transition: transform 0.3s ease, opacity 0.3s ease;
opacity: 0;
max-width: 320px;
pointer-events: none;
}
.mfp-toast.is-visible {
transform: translateX(0);
opacity: 1;
}
.mfp-toast--success {
background: #00a32a;
}
.mfp-toast--error {
background: #d63638;
}
.mfp-toast--info {
background: #2271b1;
}
/* Responsive: hide on small screens */
@media screen and (max-width: 782px) {
#mfp-folder-root {
display: none;
}
body.upload-php #wpbody {
margin-left: 0;
}
}

View File

@@ -0,0 +1,509 @@
/**
* Media Folder Pro — Folder Tree UI
* Vanilla JS, no jQuery dependency.
*/
( function () {
'use strict';
const { ajaxUrl, nonce, i18n } = window.mfpData || {};
if ( ! ajaxUrl ) return;
let folders = [];
let activeFolder = null;
let contextMenu = null;
let root = null;
// ─── AJAX helper ──────────────────────────────────────────
function ajax( action, data = {} ) {
const body = new URLSearchParams();
body.append( 'action', action );
body.append( 'nonce', nonce );
for ( const [ k, v ] of Object.entries( data ) ) {
body.append( k, v );
}
return fetch( ajaxUrl, {
method: 'POST',
credentials: 'same-origin',
body,
} ).then( ( r ) => r.json() );
}
// ─── Toast notifications ──────────────────────────────────
function showToast( message, type ) {
type = type || 'success';
const toast = document.createElement( 'div' );
toast.className = 'mfp-toast mfp-toast--' + type;
toast.textContent = message;
document.body.appendChild( toast );
// Trigger animation
requestAnimationFrame( function () {
toast.classList.add( 'is-visible' );
} );
setTimeout( function () {
toast.classList.remove( 'is-visible' );
toast.addEventListener( 'transitionend', function () {
toast.remove();
} );
// Fallback removal
setTimeout( function () { toast.remove(); }, 500 );
}, 3000 );
}
// Expose globally
window.mfpToast = showToast;
// ─── DOM helpers ──────────────────────────────────────────
function el( tag, attrs, children ) {
attrs = attrs || {};
children = children || [];
const node = document.createElement( tag );
for ( const [ k, v ] of Object.entries( attrs ) ) {
if ( k === 'className' ) {
node.className = v;
} else if ( k === 'textContent' ) {
node.textContent = v;
} else if ( k.startsWith( 'on' ) ) {
node.addEventListener( k.slice( 2 ).toLowerCase(), v );
} else {
node.setAttribute( k, v );
}
}
for ( const child of children ) {
if ( typeof child === 'string' ) {
node.appendChild( document.createTextNode( child ) );
} else if ( child ) {
node.appendChild( child );
}
}
return node;
}
// ─── Context menu ─────────────────────────────────────────
function closeContextMenu() {
if ( contextMenu ) {
contextMenu.remove();
contextMenu = null;
}
}
function showContextMenu( e, folder ) {
e.preventDefault();
e.stopPropagation();
closeContextMenu();
contextMenu = el( 'div', { className: 'mfp-context-menu' }, [
el( 'button', {
className: 'mfp-context-menu__item',
textContent: i18n.newSubfolder,
onClick: function () { closeContextMenu(); createFolder( folder.id ); },
} ),
el( 'button', {
className: 'mfp-context-menu__item',
textContent: i18n.rename,
onClick: function () { closeContextMenu(); renameFolder( folder ); },
} ),
el( 'div', { className: 'mfp-context-menu__separator' } ),
el( 'button', {
className: 'mfp-context-menu__item mfp-context-menu__item--danger',
textContent: i18n.delete,
onClick: function () { closeContextMenu(); deleteFolder( folder ); },
} ),
] );
contextMenu.style.left = e.clientX + 'px';
contextMenu.style.top = e.clientY + 'px';
document.body.appendChild( contextMenu );
const rect = contextMenu.getBoundingClientRect();
if ( rect.right > window.innerWidth ) {
contextMenu.style.left = ( e.clientX - rect.width ) + 'px';
}
if ( rect.bottom > window.innerHeight ) {
contextMenu.style.top = ( e.clientY - rect.height ) + 'px';
}
}
// ─── Render ───────────────────────────────────────────────
function renderTree( items, container ) {
const ul = el( 'ul' );
for ( const folder of items ) {
ul.appendChild( renderFolder( folder ) );
}
container.appendChild( ul );
}
function renderFolder( folder ) {
const hasChildren = folder.children && folder.children.length > 0;
const toggle = el( 'span', {
className: 'mfp-folder__toggle' + ( hasChildren ? '' : ' is-leaf' ),
textContent: '\u25B6',
onClick: function ( e ) {
e.stopPropagation();
if ( hasChildren ) toggleExpand( li );
},
} );
const icon = el( 'span', {
className: 'mfp-folder__icon',
textContent: '\uD83D\uDCC1',
} );
const name = el( 'span', {
className: 'mfp-folder__name',
textContent: folder.name,
} );
const count = el( 'span', {
className: 'mfp-folder__count',
textContent: folder.count > 0 ? String( folder.count ) : '',
} );
const row = el( 'div', {
className: 'mfp-folder__row',
'data-id': String( folder.id ),
draggable: 'true',
onClick: function () { selectFolder( folder ); },
onContextmenu: function ( e ) { showContextMenu( e, folder ); },
}, [ toggle, icon, name, count ] );
// ─── Folder drag (reorder hierarchy) ──────────────────
row.addEventListener( 'dragstart', function ( e ) {
e.stopPropagation();
e.dataTransfer.setData( 'application/mfp-folder', String( folder.id ) );
e.dataTransfer.effectAllowed = 'move';
document.body.classList.add( 'mfp-dragging-folder' );
} );
row.addEventListener( 'dragend', function () {
document.body.classList.remove( 'mfp-dragging-folder' );
root.querySelectorAll( '.mfp-folder-drop-target' ).forEach( function ( el ) {
el.classList.remove( 'mfp-folder-drop-target' );
} );
} );
// Drop target for folder-on-folder
row.addEventListener( 'dragover', function ( e ) {
e.preventDefault();
e.stopPropagation();
// Folder drag = yellow, media drag = blue (handled in media-filter.js)
if ( e.dataTransfer.types.includes( 'application/mfp-folder' ) ) {
row.classList.add( 'mfp-folder-drop-target' );
e.dataTransfer.dropEffect = 'move';
} else {
row.classList.add( 'mfp-drop-target' );
e.dataTransfer.dropEffect = 'move';
}
} );
row.addEventListener( 'dragleave', function () {
row.classList.remove( 'mfp-folder-drop-target' );
row.classList.remove( 'mfp-drop-target' );
} );
row.addEventListener( 'drop', function ( e ) {
e.preventDefault();
e.stopPropagation();
row.classList.remove( 'mfp-folder-drop-target' );
row.classList.remove( 'mfp-drop-target' );
const draggedFolderId = e.dataTransfer.getData( 'application/mfp-folder' );
if ( draggedFolderId ) {
// Folder → folder move
if ( String( draggedFolderId ) === String( folder.id ) ) return;
moveFolder( parseInt( draggedFolderId, 10 ), folder.id );
return;
}
const attachmentId = e.dataTransfer.getData( 'text/plain' );
if ( attachmentId ) {
// Media → folder assign (delegated to media-filter.js via event)
document.dispatchEvent( new CustomEvent( 'mfp-media-dropped', {
detail: { attachmentIds: [ attachmentId ], folderId: folder.id },
} ) );
}
} );
const childrenContainer = el( 'div', {
className: 'mfp-folder__children',
} );
if ( hasChildren ) {
renderTree( folder.children, childrenContainer );
}
const li = el( 'li', { className: 'mfp-folder' }, [ row, childrenContainer ] );
return li;
}
function toggleExpand( li ) {
const children = li.querySelector( '.mfp-folder__children' );
const toggle = li.querySelector( '.mfp-folder__toggle' );
if ( ! children ) return;
const isOpen = children.classList.contains( 'is-open' );
children.classList.toggle( 'is-open', ! isOpen );
toggle.classList.toggle( 'is-expanded', ! isOpen );
}
function selectFolder( folder ) {
activeFolder = folder.id;
clearActiveStates();
const row = root.querySelector( '.mfp-folder__row[data-id="' + folder.id + '"]' );
if ( row ) row.classList.add( 'is-active' );
document.dispatchEvent( new CustomEvent( 'mfp-folder-selected', {
detail: { folderId: folder.id, folderName: folder.name },
} ) );
}
function selectAllMedia() {
activeFolder = null;
clearActiveStates();
var allBtn = root.querySelector( '.mfp-all-media' );
if ( allBtn ) allBtn.classList.add( 'is-active' );
document.dispatchEvent( new CustomEvent( 'mfp-folder-selected', {
detail: { folderId: null, folderName: null },
} ) );
}
function selectUncategorized() {
activeFolder = -1;
clearActiveStates();
var uncatBtn = root.querySelector( '.mfp-uncategorized' );
if ( uncatBtn ) uncatBtn.classList.add( 'is-active' );
document.dispatchEvent( new CustomEvent( 'mfp-folder-selected', {
detail: { folderId: -1, folderName: i18n.uncategorized },
} ) );
}
function clearActiveStates() {
root.querySelectorAll( '.mfp-folder__row.is-active' ).forEach( function ( el ) {
el.classList.remove( 'is-active' );
} );
root.querySelector( '.mfp-all-media' )?.classList.remove( 'is-active' );
root.querySelector( '.mfp-uncategorized' )?.classList.remove( 'is-active' );
}
// ─── CRUD ─────────────────────────────────────────────────
function createFolder( parentId ) {
parentId = parentId || 0;
const name = prompt( i18n.folderName );
if ( ! name || ! name.trim() ) return;
ajax( 'mfp_create_folder', { name: name.trim(), parent_id: parentId } )
.then( function ( res ) {
if ( res.success ) {
refreshTree();
showToast( i18n.folderCreated || 'Folder utworzony' );
} else {
showToast( res.data?.message || i18n.error, 'error' );
}
} )
.catch( function () { showToast( i18n.error, 'error' ); } );
}
function renameFolder( folder ) {
const row = root.querySelector( '.mfp-folder__row[data-id="' + folder.id + '"]' );
if ( ! row ) return;
const nameEl = row.querySelector( '.mfp-folder__name' );
const oldName = nameEl.textContent;
const input = el( 'input', {
className: 'mfp-folder__rename-input',
type: 'text',
} );
input.value = oldName;
nameEl.style.display = 'none';
row.insertBefore( input, nameEl.nextSibling );
input.focus();
input.select();
function finish() {
const newName = input.value.trim();
input.remove();
nameEl.style.display = '';
if ( ! newName || newName === oldName ) return;
ajax( 'mfp_rename_folder', { folder_id: folder.id, name: newName } )
.then( function ( res ) {
if ( res.success ) {
nameEl.textContent = res.data.folder.name;
showToast( i18n.folderRenamed || 'Nazwa zmieniona' );
} else {
showToast( res.data?.message || i18n.error, 'error' );
}
} )
.catch( function () { showToast( i18n.error, 'error' ); } );
}
input.addEventListener( 'blur', finish );
input.addEventListener( 'keydown', function ( e ) {
if ( e.key === 'Enter' ) {
e.preventDefault();
input.blur();
}
if ( e.key === 'Escape' ) {
input.value = oldName;
input.blur();
}
} );
}
function deleteFolder( folder ) {
if ( ! confirm( i18n.confirmDelete ) ) return;
ajax( 'mfp_delete_folder', { folder_id: folder.id } )
.then( function ( res ) {
if ( res.success ) {
if ( activeFolder === folder.id ) selectAllMedia();
refreshTree();
showToast( i18n.folderDeleted || 'Folder usunięty' );
} else {
showToast( res.data?.message || i18n.error, 'error' );
}
} )
.catch( function () { showToast( i18n.error, 'error' ); } );
}
function moveFolder( folderId, newParentId ) {
ajax( 'mfp_move_folder', { folder_id: folderId, new_parent_id: newParentId } )
.then( function ( res ) {
if ( res.success ) {
refreshTree();
showToast( i18n.folderMoved || 'Folder przeniesiony' );
} else {
showToast( res.data?.message || i18n.error, 'error' );
}
} )
.catch( function () { showToast( i18n.error, 'error' ); } );
}
// ─── Refresh ──────────────────────────────────────────────
window.mfpRefreshTree = function () { refreshTree(); };
function refreshTree() {
ajax( 'mfp_get_folders' ).then( function ( res ) {
if ( ! res.success ) return;
folders = res.data.folders;
const tree = root.querySelector( '.mfp-tree' );
tree.innerHTML = '';
if ( folders.length === 0 ) {
tree.appendChild( el( 'div', { className: 'mfp-empty' }, [
el( 'div', { className: 'mfp-empty__icon', textContent: '\uD83D\uDCC2' } ),
el( 'div', { className: 'mfp-empty__text', textContent: i18n.emptyState || 'Brak folderów' } ),
el( 'button', {
className: 'mfp-empty__cta',
textContent: i18n.createFirst || 'Utwórz pierwszy folder',
onClick: function () { createFolder( 0 ); },
} ),
] ) );
} else {
renderTree( folders, tree );
}
} );
}
// ─── Init ─────────────────────────────────────────────────
function init() {
root = document.getElementById( 'mfp-folder-root' );
if ( ! root ) return;
// Toolbar
const toolbar = el( 'div', { className: 'mfp-toolbar' }, [
el( 'span', { className: 'mfp-toolbar__title', textContent: 'Foldery' } ),
el( 'button', {
className: 'mfp-toolbar__btn',
textContent: '+',
title: i18n.newFolder,
onClick: function () { createFolder( 0 ); },
} ),
] );
// All Media
const allMedia = el( 'div', {
className: 'mfp-all-media is-active',
onClick: selectAllMedia,
}, [
el( 'span', { className: 'mfp-all-media__icon', textContent: '\uD83D\uDDBC\uFE0F' } ),
el( 'span', { textContent: i18n.allMedia } ),
] );
// Uncategorized
const uncategorized = el( 'div', {
className: 'mfp-uncategorized',
onClick: selectUncategorized,
}, [
el( 'span', { className: 'mfp-uncategorized__icon', textContent: '\uD83D\uDCC4' } ),
el( 'span', { textContent: i18n.uncategorized || 'Bez folderu' } ),
] );
// Tree
const tree = el( 'div', { className: 'mfp-tree' }, [
el( 'div', { className: 'mfp-loading' }, [
el( 'div', { className: 'mfp-spinner' } ),
] ),
] );
root.appendChild( toolbar );
root.appendChild( allMedia );
root.appendChild( uncategorized );
root.appendChild( tree );
// Drop on root area = move folder to top level
tree.addEventListener( 'dragover', function ( e ) {
if ( e.target.closest( '.mfp-folder__row' ) ) return;
if ( e.dataTransfer.types.includes( 'application/mfp-folder' ) ) {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
tree.classList.add( 'mfp-tree-drop-target' );
}
} );
tree.addEventListener( 'dragleave', function ( e ) {
if ( ! tree.contains( e.relatedTarget ) || e.relatedTarget === tree ) {
tree.classList.remove( 'mfp-tree-drop-target' );
}
} );
tree.addEventListener( 'drop', function ( e ) {
if ( e.target.closest( '.mfp-folder__row' ) ) return;
tree.classList.remove( 'mfp-tree-drop-target' );
const draggedFolderId = e.dataTransfer.getData( 'application/mfp-folder' );
if ( draggedFolderId ) {
e.preventDefault();
moveFolder( parseInt( draggedFolderId, 10 ), 0 );
}
} );
// Close context menu on click outside
document.addEventListener( 'click', closeContextMenu );
// Listen for folder changes from modal
document.addEventListener( 'mfp-folder-changed', function () {
refreshTree();
} );
// Load folders
refreshTree();
}
if ( document.readyState === 'loading' ) {
document.addEventListener( 'DOMContentLoaded', init );
} else {
init();
}
} )();

View File

@@ -0,0 +1,293 @@
/**
* Media Folder Pro — Media Grid Filter + Drag & Drop + Bulk Assign
* Integrates with WP media grid via wp.media backbone props.
*/
( function () {
'use strict';
const { ajaxUrl, nonce, i18n } = window.mfpData || {};
if ( ! ajaxUrl ) return;
let currentFolderId = null;
function toast( msg, type ) {
if ( window.mfpToast ) window.mfpToast( msg, type );
}
// ─── AJAX helper ──────────────────────────────────────────
function ajax( action, data ) {
data = data || {};
const body = new URLSearchParams();
body.append( 'action', action );
body.append( 'nonce', nonce );
for ( const [ k, v ] of Object.entries( data ) ) {
if ( Array.isArray( v ) ) {
v.forEach( function ( item ) { body.append( k + '[]', item ); } );
} else {
body.append( k, v );
}
}
return fetch( ajaxUrl, {
method: 'POST',
credentials: 'same-origin',
body: body,
} ).then( function ( r ) { return r.json(); } );
}
// ─── Media Grid Filter ───────────────────────────────────
function getMediaCollection() {
if ( ! window.wp || ! wp.media || ! wp.media.frame ) {
return null;
}
var library = wp.media.frame.state &&
wp.media.frame.state() &&
wp.media.frame.state().get( 'library' );
return library || null;
}
function filterByFolder( folderId ) {
currentFolderId = folderId;
var library = getMediaCollection();
if ( library ) {
if ( folderId ) {
library.props.set( { media_folder: folderId } );
} else {
library.props.set( { media_folder: 0 } );
}
}
}
// Listen for folder selection from tree
document.addEventListener( 'mfp-folder-selected', function ( e ) {
filterByFolder( e.detail.folderId );
} );
// Listen for media dropped on folder (from folder-tree.js)
document.addEventListener( 'mfp-media-dropped', function ( e ) {
assignMedia( e.detail.attachmentIds, e.detail.folderId );
} );
// ─── Drag & Drop (media) ─────────────────────────────────
function enableDraggable( attachment ) {
if ( attachment.getAttribute( 'data-mfp-draggable' ) ) return;
attachment.setAttribute( 'data-mfp-draggable', '1' );
attachment.setAttribute( 'draggable', 'true' );
attachment.addEventListener( 'dragstart', function ( e ) {
var id = attachment.getAttribute( 'data-id' );
if ( ! id ) {
e.preventDefault();
return;
}
e.dataTransfer.setData( 'text/plain', id );
e.dataTransfer.effectAllowed = 'move';
document.body.classList.add( 'mfp-dragging' );
} );
attachment.addEventListener( 'dragend', function () {
document.body.classList.remove( 'mfp-dragging' );
} );
}
function scanAttachments() {
document.querySelectorAll( '.attachments .attachment' ).forEach( enableDraggable );
}
function observeAttachments() {
var container = document.querySelector( '.attachments' );
if ( ! container ) return;
scanAttachments();
var observer = new MutationObserver( function () {
scanAttachments();
} );
observer.observe( container, { childList: true, subtree: true } );
}
// Drop handlers on folder rows — delegated from tree root
function initDropTargets() {
var root = document.getElementById( 'mfp-folder-root' );
if ( ! root ) return;
// "All Media" drop = unassign
var allMedia = root.querySelector( '.mfp-all-media' );
if ( allMedia ) {
allMedia.addEventListener( 'dragover', function ( e ) {
if ( e.dataTransfer.types.includes( 'application/mfp-folder' ) ) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
allMedia.classList.add( 'mfp-drop-target' );
} );
allMedia.addEventListener( 'dragleave', function () {
allMedia.classList.remove( 'mfp-drop-target' );
} );
allMedia.addEventListener( 'drop', function ( e ) {
e.preventDefault();
allMedia.classList.remove( 'mfp-drop-target' );
var attachmentId = e.dataTransfer.getData( 'text/plain' );
if ( attachmentId ) assignMedia( [ attachmentId ], 0 );
} );
}
}
function assignMedia( attachmentIds, folderId ) {
ajax( 'mfp_assign_media', {
attachment_ids: attachmentIds,
folder_id: folderId,
} ).then( function ( res ) {
if ( res.success ) {
if ( window.mfpRefreshTree ) window.mfpRefreshTree();
var count = res.data.count || attachmentIds.length;
toast(
( i18n.assignSuccess || 'Media przypisane' ) +
( count > 1 ? ' (' + count + ')' : '' )
);
// Refresh grid if viewing a different folder
if ( currentFolderId && String( currentFolderId ) !== String( folderId ) ) {
filterByFolder( currentFolderId );
}
} else {
toast( ( res.data && res.data.message ) || i18n.assignError || 'Error', 'error' );
}
} ).catch( function () {
toast( i18n.error || 'Error', 'error' );
} );
}
// ─── Bulk Assign ─────────────────────────────────────────
function initBulkAssign() {
// Inject bulk button into WP media toolbar
var observer = new MutationObserver( function () {
var toolbar = document.querySelector( '.media-toolbar-secondary' );
if ( toolbar && ! toolbar.querySelector( '.mfp-bulk-btn' ) ) {
createBulkButton( toolbar );
}
} );
observer.observe( document.body, { childList: true, subtree: true } );
// Also check immediately
var toolbar = document.querySelector( '.media-toolbar-secondary' );
if ( toolbar && ! toolbar.querySelector( '.mfp-bulk-btn' ) ) {
createBulkButton( toolbar );
}
}
function createBulkButton( toolbar ) {
var btn = document.createElement( 'button' );
btn.className = 'button mfp-bulk-btn';
btn.textContent = i18n.moveToFolder || 'Przenieś do folderu';
btn.type = 'button';
btn.addEventListener( 'click', function () {
showBulkDropdown( btn );
} );
toolbar.appendChild( btn );
}
function showBulkDropdown( anchor ) {
// Remove existing dropdown
var existing = document.querySelector( '.mfp-bulk-dropdown' );
if ( existing ) { existing.remove(); return; }
// Get selected attachments
var selected = document.querySelectorAll( '.attachment.selected, .attachment.details' );
if ( selected.length === 0 ) {
toast( i18n.noSelection || 'Zaznacz media do przeniesienia', 'info' );
return;
}
var ids = [];
selected.forEach( function ( el ) {
var id = el.getAttribute( 'data-id' );
if ( id ) ids.push( id );
} );
if ( ids.length === 0 ) return;
// Load folders and build dropdown
ajax( 'mfp_get_folders' ).then( function ( res ) {
if ( ! res.success ) return;
var dropdown = document.createElement( 'div' );
dropdown.className = 'mfp-bulk-dropdown';
// "Remove from folder" option
var removeOpt = document.createElement( 'button' );
removeOpt.className = 'mfp-bulk-dropdown__item mfp-bulk-dropdown__item--remove';
removeOpt.textContent = i18n.removeFromFolder || 'Usuń z folderu';
removeOpt.addEventListener( 'click', function () {
dropdown.remove();
assignMedia( ids, 0 );
} );
dropdown.appendChild( removeOpt );
var sep = document.createElement( 'div' );
sep.className = 'mfp-context-menu__separator';
dropdown.appendChild( sep );
// Folder options (flat)
flattenAndAddOptions( dropdown, res.data.folders, 0, ids );
// Position
var rect = anchor.getBoundingClientRect();
dropdown.style.position = 'fixed';
dropdown.style.left = rect.left + 'px';
dropdown.style.top = ( rect.bottom + 4 ) + 'px';
document.body.appendChild( dropdown );
// Close on outside click
function closeDropdown( e ) {
if ( ! dropdown.contains( e.target ) && e.target !== anchor ) {
dropdown.remove();
document.removeEventListener( 'click', closeDropdown );
}
}
setTimeout( function () {
document.addEventListener( 'click', closeDropdown );
}, 0 );
} );
}
function flattenAndAddOptions( container, folders, depth, ids ) {
for ( var i = 0; i < folders.length; i++ ) {
var folder = folders[ i ];
var prefix = depth > 0 ? '\u2003'.repeat( depth ) + '\u2014 ' : '';
var opt = document.createElement( 'button' );
opt.className = 'mfp-bulk-dropdown__item';
opt.textContent = prefix + folder.name;
opt.setAttribute( 'data-folder-id', folder.id );
( function ( fId ) {
opt.addEventListener( 'click', function () {
container.remove();
assignMedia( ids, fId );
} );
} )( folder.id );
container.appendChild( opt );
if ( folder.children && folder.children.length > 0 ) {
flattenAndAddOptions( container, folder.children, depth + 1, ids );
}
}
}
// ─── Init ─────────────────────────────────────────────────
function init() {
observeAttachments();
initDropTargets();
initBulkAssign();
}
if ( document.readyState === 'loading' ) {
document.addEventListener( 'DOMContentLoaded', init );
} else {
init();
}
} )();

View File

@@ -0,0 +1,229 @@
/**
* Media Folder Pro — Modal Integration
* Adds folder dropdown filter to wp.media modal and auto-assigns uploads.
*/
( function () {
'use strict';
if ( ! window.wp || ! wp.media || ! wp.media.view ) return;
const { ajaxUrl, nonce, i18n } = window.mfpData || {};
if ( ! ajaxUrl ) return;
let activeFolderId = 0;
let foldersCache = null;
// ─── AJAX helper ──────────────────────────────────────────
function ajax( action, data ) {
data = data || {};
const body = new URLSearchParams();
body.append( 'action', action );
body.append( 'nonce', nonce );
for ( const [ k, v ] of Object.entries( data ) ) {
body.append( k, v );
}
return fetch( ajaxUrl, {
method: 'POST',
credentials: 'same-origin',
body: body,
} ).then( function ( r ) { return r.json(); } );
}
// ─── Load folders ─────────────────────────────────────────
function loadFolders() {
if ( foldersCache ) {
return Promise.resolve( foldersCache );
}
return ajax( 'mfp_get_folders' ).then( function ( res ) {
if ( res.success ) {
foldersCache = res.data.folders;
return foldersCache;
}
return [];
} );
}
function flattenFolders( folders, depth ) {
depth = depth || 0;
var result = [];
for ( var i = 0; i < folders.length; i++ ) {
var folder = folders[ i ];
var prefix = depth > 0 ? '\u2003'.repeat( depth ) + '\u2014 ' : '';
result.push( {
value: folder.id,
label: prefix + folder.name,
count: folder.count,
} );
if ( folder.children && folder.children.length > 0 ) {
result = result.concat( flattenFolders( folder.children, depth + 1 ) );
}
}
return result;
}
// ─── Build a folder <select> element ──────────────────────
function buildFolderSelect( onChange ) {
var select = document.createElement( 'select' );
select.className = 'mfp-modal-filter attachment-filters';
var defaultOpt = document.createElement( 'option' );
defaultOpt.value = '0';
defaultOpt.textContent = i18n.allMedia;
select.appendChild( defaultOpt );
loadFolders().then( function ( folders ) {
var flat = flattenFolders( folders );
for ( var j = 0; j < flat.length; j++ ) {
var opt = document.createElement( 'option' );
opt.value = String( flat[ j ].value );
opt.textContent = flat[ j ].label + ( flat[ j ].count > 0 ? ' (' + flat[ j ].count + ')' : '' );
select.appendChild( opt );
}
if ( activeFolderId ) {
select.value = String( activeFolderId );
}
} );
select.addEventListener( 'change', function () {
var folderId = parseInt( select.value, 10 );
activeFolderId = folderId || 0;
if ( onChange ) onChange( folderId );
// Sync all folder selects in the modal
syncAllSelects( folderId );
} );
return select;
}
// Keep all folder selects in sync (library toolbar + upload tab)
var allSelects = [];
function syncAllSelects( folderId ) {
for ( var i = 0; i < allSelects.length; i++ ) {
allSelects[ i ].value = String( folderId || 0 );
}
}
// ─── Override AttachmentsBrowser toolbar ───────────────────
var OriginalBrowser = wp.media.view.AttachmentsBrowser;
wp.media.view.AttachmentsBrowser = OriginalBrowser.extend( {
createToolbar: function () {
OriginalBrowser.prototype.createToolbar.call( this );
var toolbar = this.toolbar;
var collection = this.collection;
var select = buildFolderSelect( function ( folderId ) {
if ( folderId > 0 ) {
collection.props.set( { media_folder: folderId } );
} else {
collection.props.set( { media_folder: 0 } );
}
} );
allSelects.push( select );
var FilterView = wp.media.View.extend( {
tagName: 'div',
className: 'mfp-modal-filter-wrap',
render: function () {
this.$el.append( select );
return this;
},
} );
toolbar.set( 'mfpFolderFilter', new FilterView( {
controller: this.controller,
priority: -75,
} ) );
},
} );
// ─── Add folder select to Upload tab ──────────────────────
var OriginalUploaderInline = wp.media.view.UploaderInline;
wp.media.view.UploaderInline = OriginalUploaderInline.extend( {
ready: function () {
OriginalUploaderInline.prototype.ready.apply( this, arguments );
// Don't add twice
if ( this.$el.find( '.mfp-upload-folder-wrap' ).length ) return;
var wrap = document.createElement( 'div' );
wrap.className = 'mfp-upload-folder-wrap';
var label = document.createElement( 'label' );
label.className = 'mfp-upload-folder-label';
label.textContent = ( i18n.moveToFolder || 'Wyślij do folderu' ) + ': ';
var select = buildFolderSelect( function () {
// Just updates activeFolderId via buildFolderSelect's change handler
} );
allSelects.push( select );
wrap.appendChild( label );
wrap.appendChild( select );
// Insert before the upload area
this.$el.prepend( wrap );
},
} );
// ─── Inject folder ID into plupload params ──────────────
if ( wp.Uploader ) {
var origUploaderInit = wp.Uploader.prototype.init;
wp.Uploader.prototype.init = function () {
origUploaderInit.apply( this, arguments );
var uploader = this.uploader;
if ( ! uploader ) return;
uploader.bind( 'BeforeUpload', function () {
if ( activeFolderId > 0 ) {
uploader.settings.multipart_params.mfp_folder_id = activeFolderId;
} else {
delete uploader.settings.multipart_params.mfp_folder_id;
}
} );
uploader.bind( 'FileUploaded', function () {
document.dispatchEvent( new CustomEvent( 'mfp-folder-changed' ) );
// Re-query the library so new file appears.
// Don't use reset() — it clears the grid and shows "no items".
// Instead, toggle the prop to force a fresh AJAX query.
setTimeout( function () {
if ( ! wp.media.frame ) return;
try {
var state = wp.media.frame.state();
var lib = state && state.get( 'library' );
if ( lib && lib.props ) {
var folder = lib.props.get( 'media_folder' ) || 0;
lib.props.unset( 'media_folder', { silent: true } );
lib.props.set( { media_folder: folder } );
}
} catch ( e ) {}
}, 800 );
} );
};
}
// ─── Sync on modal open/close ─────────────────────────────
var origModalOpen = wp.media.view.Modal.prototype.open;
wp.media.view.Modal.prototype.open = function () {
var modal = this;
origModalOpen.apply( this, arguments );
if ( ! modal._mfpCloseHooked ) {
modal._mfpCloseHooked = true;
var origClose = modal.close;
modal.close = function () {
foldersCache = null;
activeFolderId = 0;
allSelects = [];
document.dispatchEvent( new CustomEvent( 'mfp-folder-changed' ) );
return origClose.apply( this, arguments );
};
}
};
} )();

View File

@@ -0,0 +1,237 @@
<?php
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
class MFP_Ajax_Handler {
private MFP_Taxonomy $taxonomy;
public function __construct( MFP_Taxonomy $taxonomy ) {
$this->taxonomy = $taxonomy;
}
public function register_hooks(): void {
$actions = [
'mfp_create_folder',
'mfp_rename_folder',
'mfp_delete_folder',
'mfp_move_folder',
'mfp_get_folders',
'mfp_assign_media',
'mfp_upload_to_folder',
];
foreach ( $actions as $action ) {
add_action( "wp_ajax_{$action}", [ $this, $action ] );
}
}
private function verify_request(): void {
check_ajax_referer( 'mfp_nonce', 'nonce' );
if ( ! current_user_can( 'upload_files' ) ) {
wp_send_json_error( [ 'message' => __( 'Brak uprawnień.', 'media-folder-pro' ) ], 403 );
}
}
public function mfp_create_folder(): void {
$this->verify_request();
$name = sanitize_text_field( wp_unslash( $_POST['name'] ?? '' ) );
$parent_id = (int) ( $_POST['parent_id'] ?? 0 );
if ( empty( $name ) ) {
wp_send_json_error( [ 'message' => __( 'Nazwa folderu jest wymagana.', 'media-folder-pro' ) ] );
}
$result = wp_insert_term( $name, MFP_Taxonomy::TAXONOMY, [
'parent' => $parent_id,
] );
if ( is_wp_error( $result ) ) {
wp_send_json_error( [ 'message' => $result->get_error_message() ] );
}
$term = get_term( $result['term_id'], MFP_Taxonomy::TAXONOMY );
wp_send_json_success( [
'folder' => [
'id' => $term->term_id,
'name' => $term->name,
'slug' => $term->slug,
'parent' => $term->parent,
'count' => 0,
'children' => [],
],
] );
}
public function mfp_rename_folder(): void {
$this->verify_request();
$folder_id = (int) ( $_POST['folder_id'] ?? 0 );
$name = sanitize_text_field( wp_unslash( $_POST['name'] ?? '' ) );
if ( ! $folder_id || empty( $name ) ) {
wp_send_json_error( [ 'message' => __( 'ID folderu i nowa nazwa są wymagane.', 'media-folder-pro' ) ] );
}
$result = wp_update_term( $folder_id, MFP_Taxonomy::TAXONOMY, [
'name' => $name,
] );
if ( is_wp_error( $result ) ) {
wp_send_json_error( [ 'message' => $result->get_error_message() ] );
}
$term = get_term( $result['term_id'], MFP_Taxonomy::TAXONOMY );
wp_send_json_success( [
'folder' => [
'id' => $term->term_id,
'name' => $term->name,
'slug' => $term->slug,
],
] );
}
public function mfp_delete_folder(): void {
$this->verify_request();
$folder_id = (int) ( $_POST['folder_id'] ?? 0 );
if ( ! $folder_id ) {
wp_send_json_error( [ 'message' => __( 'ID folderu jest wymagane.', 'media-folder-pro' ) ] );
}
if ( $this->taxonomy->folder_has_children( $folder_id ) ) {
wp_send_json_error( [
'message' => __( 'Folder nie jest pusty. Usuń najpierw zawartość i podfoldery.', 'media-folder-pro' ),
'code' => 'not_empty',
] );
}
$result = wp_delete_term( $folder_id, MFP_Taxonomy::TAXONOMY );
if ( is_wp_error( $result ) ) {
wp_send_json_error( [ 'message' => $result->get_error_message() ] );
}
wp_send_json_success();
}
public function mfp_move_folder(): void {
$this->verify_request();
$folder_id = (int) ( $_POST['folder_id'] ?? 0 );
$new_parent_id = (int) ( $_POST['new_parent_id'] ?? 0 );
if ( ! $folder_id ) {
wp_send_json_error( [ 'message' => __( 'ID folderu jest wymagane.', 'media-folder-pro' ) ] );
}
if ( $this->taxonomy->would_create_cycle( $folder_id, $new_parent_id ) ) {
wp_send_json_error( [
'message' => __( 'Nie można przenieść folderu do samego siebie lub swojego podfolderu.', 'media-folder-pro' ),
] );
}
$result = wp_update_term( $folder_id, MFP_Taxonomy::TAXONOMY, [
'parent' => $new_parent_id,
] );
if ( is_wp_error( $result ) ) {
wp_send_json_error( [ 'message' => $result->get_error_message() ] );
}
$term = get_term( $result['term_id'], MFP_Taxonomy::TAXONOMY );
wp_send_json_success( [
'folder' => [
'id' => $term->term_id,
'name' => $term->name,
'parent' => $term->parent,
],
] );
}
public function mfp_assign_media(): void {
$this->verify_request();
$attachment_ids = array_map( 'intval', (array) ( $_POST['attachment_ids'] ?? [] ) );
$folder_id = (int) ( $_POST['folder_id'] ?? 0 );
if ( empty( $attachment_ids ) ) {
wp_send_json_error( [ 'message' => __( 'Brak wybranych mediów.', 'media-folder-pro' ) ] );
}
$count = 0;
foreach ( $attachment_ids as $id ) {
if ( $id <= 0 ) {
continue;
}
if ( get_post_type( $id ) !== 'attachment' ) {
continue;
}
$terms = $folder_id > 0 ? [ $folder_id ] : [];
$result = wp_set_object_terms( $id, $terms, MFP_Taxonomy::TAXONOMY );
if ( ! is_wp_error( $result ) ) {
$count++;
}
}
// Return updated folder counts.
$all_terms = get_terms( [
'taxonomy' => MFP_Taxonomy::TAXONOMY,
'hide_empty' => false,
'fields' => 'all',
] );
$folder_counts = [];
if ( ! is_wp_error( $all_terms ) ) {
foreach ( $all_terms as $term ) {
$folder_counts[ $term->term_id ] = (int) $term->count;
}
}
wp_send_json_success( [
'count' => $count,
'folder_counts' => $folder_counts,
] );
}
public function mfp_upload_to_folder(): void {
$this->verify_request();
$attachment_id = (int) ( $_POST['attachment_id'] ?? 0 );
$folder_id = (int) ( $_POST['folder_id'] ?? 0 );
if ( ! $attachment_id || ! $folder_id ) {
wp_send_json_error( [ 'message' => __( 'ID załącznika i folderu są wymagane.', 'media-folder-pro' ) ] );
}
if ( get_post_type( $attachment_id ) !== 'attachment' ) {
wp_send_json_error( [ 'message' => __( 'Nieprawidłowy załącznik.', 'media-folder-pro' ) ] );
}
$result = wp_set_object_terms( $attachment_id, [ $folder_id ], MFP_Taxonomy::TAXONOMY );
if ( is_wp_error( $result ) ) {
wp_send_json_error( [ 'message' => $result->get_error_message() ] );
}
wp_send_json_success();
}
public function mfp_get_folders(): void {
$this->verify_request();
wp_send_json_success( [
'folders' => $this->taxonomy->get_folder_tree(),
] );
}
}

View File

@@ -0,0 +1,64 @@
<?php
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
class MFP_Media_Query {
public function register(): void {
add_filter( 'ajax_query_attachments_args', [ $this, 'filter_by_folder' ] );
add_action( 'add_attachment', [ $this, 'assign_folder_on_upload' ] );
}
/**
* Assign folder to attachment during upload if mfp_folder_id is in POST data.
* This runs server-side during async-upload.php, so no race condition.
*/
public function assign_folder_on_upload( int $attachment_id ): void {
if ( empty( $_POST['mfp_folder_id'] ) ) {
return;
}
$folder_id = (int) $_POST['mfp_folder_id'];
if ( $folder_id <= 0 ) {
return;
}
wp_set_object_terms( $attachment_id, [ $folder_id ], MFP_Taxonomy::TAXONOMY );
}
/**
* Inject tax_query into media grid AJAX queries when media_folder param is present.
*
* @param array $query WP_Query args for attachment query.
* @return array Modified query args.
*/
public function filter_by_folder( array $query ): array {
if ( empty( $_REQUEST['query']['media_folder'] ) ) {
return $query;
}
$folder_id = (int) $_REQUEST['query']['media_folder'];
if ( ! isset( $query['tax_query'] ) ) {
$query['tax_query'] = [];
}
if ( $folder_id === -1 ) {
// Uncategorized: media without any folder.
$query['tax_query'][] = [
'taxonomy' => MFP_Taxonomy::TAXONOMY,
'operator' => 'NOT EXISTS',
];
} elseif ( $folder_id > 0 ) {
$query['tax_query'][] = [
'taxonomy' => MFP_Taxonomy::TAXONOMY,
'terms' => [ $folder_id ],
'field' => 'term_id',
];
}
return $query;
}
}

View File

@@ -0,0 +1,122 @@
<?php
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
class MFP_Taxonomy {
public const TAXONOMY = 'media_folder';
public function register(): void {
register_taxonomy( self::TAXONOMY, 'attachment', [
'labels' => [
'name' => __( 'Foldery mediów', 'media-folder-pro' ),
'singular_name' => __( 'Folder mediów', 'media-folder-pro' ),
'add_new_item' => __( 'Dodaj nowy folder', 'media-folder-pro' ),
'edit_item' => __( 'Edytuj folder', 'media-folder-pro' ),
],
'hierarchical' => true,
'public' => false,
'show_ui' => false,
'show_in_rest' => true,
'show_admin_column' => false,
'query_var' => false,
'rewrite' => false,
] );
}
/**
* @return array<int, array{id: int, name: string, slug: string, parent: int, count: int, children: array}>
*/
public function get_folder_tree(): array {
$terms = get_terms( [
'taxonomy' => self::TAXONOMY,
'hide_empty' => false,
'orderby' => 'name',
'order' => 'ASC',
] );
if ( is_wp_error( $terms ) || empty( $terms ) ) {
return [];
}
$flat = [];
foreach ( $terms as $term ) {
$flat[ $term->term_id ] = [
'id' => $term->term_id,
'name' => $term->name,
'slug' => $term->slug,
'parent' => $term->parent,
'count' => (int) $term->count,
'children' => [],
];
}
$tree = [];
foreach ( $flat as $id => &$node ) {
if ( $node['parent'] === 0 ) {
$tree[] = &$flat[ $id ];
} elseif ( isset( $flat[ $node['parent'] ] ) ) {
$flat[ $node['parent'] ]['children'][] = &$flat[ $id ];
} else {
$tree[] = &$flat[ $id ];
}
}
unset( $node );
return $tree;
}
public function folder_has_children( int $folder_id ): bool {
$children = get_terms( [
'taxonomy' => self::TAXONOMY,
'parent' => $folder_id,
'hide_empty' => false,
'number' => 1,
'fields' => 'ids',
] );
if ( ! empty( $children ) ) {
return true;
}
$attachments = get_posts( [
'post_type' => 'attachment',
'post_status' => 'inherit',
'tax_query' => [
[
'taxonomy' => self::TAXONOMY,
'terms' => $folder_id,
],
],
'posts_per_page' => 1,
'fields' => 'ids',
] );
return ! empty( $attachments );
}
public function would_create_cycle( int $folder_id, int $new_parent_id ): bool {
if ( $new_parent_id === 0 ) {
return false;
}
if ( $folder_id === $new_parent_id ) {
return true;
}
$current = $new_parent_id;
while ( $current !== 0 ) {
$term = get_term( $current, self::TAXONOMY );
if ( is_wp_error( $term ) || ! $term ) {
break;
}
if ( $term->parent === $folder_id ) {
return true;
}
$current = $term->parent;
}
return false;
}
}

View File

@@ -0,0 +1,218 @@
<?php
/**
* Plugin Name: Media Folder Pro
* Plugin URI: https://www.project-pro.pl
* Description: Strukturyzowana biblioteka mediów z wirtualnymi folderami (podkatalogami).
* Version: 0.1.0
* Author: Project Pro
* Author URI: https://www.project-pro.pl
* License: GPL-2.0-or-later
* Text Domain: media-folder-pro
* Requires at least: 6.0
* Requires PHP: 8.0
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
define( 'MFP_VERSION', '0.1.0' );
define( 'MFP_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
define( 'MFP_PLUGIN_URL', plugin_dir_url( __FILE__ ) );
require_once MFP_PLUGIN_DIR . 'includes/class-taxonomy.php';
require_once MFP_PLUGIN_DIR . 'includes/class-ajax-handler.php';
require_once MFP_PLUGIN_DIR . 'includes/class-media-query.php';
final class Media_Folder_Pro {
private static ?self $instance = null;
private MFP_Taxonomy $taxonomy;
private MFP_Ajax_Handler $ajax;
private MFP_Media_Query $media_query;
public static function instance(): self {
if ( null === self::$instance ) {
self::$instance = new self();
}
return self::$instance;
}
private function __construct() {
$this->taxonomy = new MFP_Taxonomy();
$this->ajax = new MFP_Ajax_Handler( $this->taxonomy );
$this->media_query = new MFP_Media_Query();
add_action( 'init', [ $this->taxonomy, 'register' ] );
add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_admin_assets' ] );
add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_modal_assets' ], 20 );
add_action( 'wp_enqueue_media', [ $this, 'enqueue_modal_on_media_load' ] );
add_action( 'admin_footer-upload.php', [ $this, 'render_folder_tree_container' ] );
$this->ajax->register_hooks();
$this->media_query->register();
}
/**
* Shared localization data for all JS scripts.
*/
private function get_mfp_data(): array {
return [
'ajaxUrl' => admin_url( 'admin-ajax.php' ),
'nonce' => wp_create_nonce( 'mfp_nonce' ),
'i18n' => [
'allMedia' => __( 'Wszystkie media', 'media-folder-pro' ),
'newFolder' => __( 'Nowy folder', 'media-folder-pro' ),
'folderName' => __( 'Nazwa folderu:', 'media-folder-pro' ),
'rename' => __( 'Zmień nazwę', 'media-folder-pro' ),
'delete' => __( 'Usuń', 'media-folder-pro' ),
'newSubfolder' => __( 'Nowy podfolder', 'media-folder-pro' ),
'confirmDelete' => __( 'Czy na pewno usunąć ten folder?', 'media-folder-pro' ),
'folderNotEmpty' => __( 'Folder nie jest pusty. Usuń najpierw zawartość.', 'media-folder-pro' ),
'error' => __( 'Wystąpił błąd. Spróbuj ponownie.', 'media-folder-pro' ),
'assignSuccess' => __( 'Media przypisane do folderu.', 'media-folder-pro' ),
'assignError' => __( 'Nie udało się przypisać mediów.', 'media-folder-pro' ),
'dropHere' => __( 'Upuść tutaj', 'media-folder-pro' ),
'uncategorized' => __( 'Bez folderu', 'media-folder-pro' ),
'moveToFolder' => __( 'Przenieś do folderu', 'media-folder-pro' ),
'removeFromFolder' => __( 'Usuń z folderu', 'media-folder-pro' ),
'noSelection' => __( 'Zaznacz media do przeniesienia', 'media-folder-pro' ),
'folderCreated' => __( 'Folder utworzony', 'media-folder-pro' ),
'folderRenamed' => __( 'Nazwa zmieniona', 'media-folder-pro' ),
'folderDeleted' => __( 'Folder usunięty', 'media-folder-pro' ),
'folderMoved' => __( 'Folder przeniesiony', 'media-folder-pro' ),
'emptyState' => __( 'Brak folderów', 'media-folder-pro' ),
'createFirst' => __( 'Utwórz pierwszy folder', 'media-folder-pro' ),
],
];
}
/**
* Ensure mfpData is localized (registers inline script if tree JS not loaded).
*/
private function ensure_mfp_data(): void {
if ( wp_script_is( 'media-folder-pro-tree', 'enqueued' ) ) {
return;
}
wp_register_script( 'media-folder-pro-tree', false );
wp_enqueue_script( 'media-folder-pro-tree' );
wp_localize_script( 'media-folder-pro-tree', 'mfpData', $this->get_mfp_data() );
}
/**
* Full assets for Media Library pages (upload.php, media-new.php).
*/
public function enqueue_admin_assets( string $hook ): void {
if ( ! in_array( $hook, [ 'upload.php', 'media-new.php' ], true ) ) {
return;
}
wp_enqueue_style(
'media-folder-pro-admin',
MFP_PLUGIN_URL . 'assets/css/admin.css',
[],
MFP_VERSION
);
wp_enqueue_script(
'media-folder-pro-tree',
MFP_PLUGIN_URL . 'assets/js/folder-tree.js',
[],
MFP_VERSION,
true
);
wp_enqueue_script(
'media-folder-pro-filter',
MFP_PLUGIN_URL . 'assets/js/media-filter.js',
[ 'media-views', 'media-folder-pro-tree' ],
MFP_VERSION,
true
);
wp_enqueue_script(
'media-folder-pro-modal',
MFP_PLUGIN_URL . 'assets/js/modal-integration.js',
[ 'media-views', 'media-folder-pro-tree' ],
MFP_VERSION,
true
);
wp_localize_script( 'media-folder-pro-tree', 'mfpData', $this->get_mfp_data() );
}
/**
* Modal integration for standard admin pages (post editor, etc.).
*/
public function enqueue_modal_assets( string $hook ): void {
if ( $hook === 'upload.php' || $hook === 'media-new.php' ) {
return;
}
if ( ! did_action( 'wp_enqueue_media' ) && ! wp_script_is( 'media-views', 'enqueued' ) ) {
return;
}
if ( wp_script_is( 'media-folder-pro-modal', 'enqueued' ) ) {
return;
}
wp_enqueue_style(
'media-folder-pro-admin',
MFP_PLUGIN_URL . 'assets/css/admin.css',
[],
MFP_VERSION
);
$this->ensure_mfp_data();
wp_enqueue_script(
'media-folder-pro-modal',
MFP_PLUGIN_URL . 'assets/js/modal-integration.js',
[ 'media-views', 'media-folder-pro-tree' ],
MFP_VERSION,
true
);
}
/**
* Hook into wp_enqueue_media — fires whenever any plugin/theme calls wp_enqueue_media().
* Covers: Elementor, WPBakery, Divi, ACF, and any builder using wp.media.
*/
public function enqueue_modal_on_media_load(): void {
if ( wp_script_is( 'media-folder-pro-modal', 'enqueued' ) ) {
return;
}
// Skip pages handled by enqueue_admin_assets — wp_enqueue_media() fires
// BEFORE admin_enqueue_scripts on upload.php, which would poison the
// tree script handle with a false (empty) registration.
$screen = get_current_screen();
if ( $screen && in_array( $screen->base, [ 'upload', 'media' ], true ) ) {
return;
}
wp_enqueue_style(
'media-folder-pro-admin',
MFP_PLUGIN_URL . 'assets/css/admin.css',
[],
MFP_VERSION
);
$this->ensure_mfp_data();
wp_enqueue_script(
'media-folder-pro-modal',
MFP_PLUGIN_URL . 'assets/js/modal-integration.js',
[ 'media-views', 'media-folder-pro-tree' ],
MFP_VERSION,
true
);
}
public function render_folder_tree_container(): void {
echo '<div id="mfp-folder-root"></div>';
}
}
add_action( 'plugins_loaded', [ 'Media_Folder_Pro', 'instance' ] );