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>
510 lines
16 KiB
JavaScript
510 lines
16 KiB
JavaScript
/**
|
|
* 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();
|
|
}
|
|
} )();
|