Files
aurora-wordpress-admin/app/src/pages/media/MediaFolderTree.vue
T
NixReaperandClaude Opus 4.8 e2db2ad4b5 Snapshot synced v3.6.1 working state as baseline
Machine-sync snapshot: the working tree carried the full 3.0.0-3.6.1 arc
(licensing/FileManager/DatabaseExplorer removal, all new replacement screens
and REST controllers) that had not been committed on this machine. Captured
here as a safety baseline before starting the Adminify-parity revamp.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-12 08:41:18 -05:00

194 lines
6.8 KiB
Vue

<script setup>
import { ref } from 'vue';
const props = defineProps({
nodes: { type: Array, required: true },
activeFolderId: { type: Number, required: true },
expanded: { type: Object, required: true },
});
const emit = defineEmits(['select', 'toggle', 'create-child', 'rename', 'delete', 'reparent', 'assign']);
// Drag-and-drop reparenting (folder onto folder) and media-into-folder both
// go through the same 'text/plain' payload, prefixed so drop handlers can
// tell them apart — custom MIME types are unreliable to read during
// dragover in some browsers, plain text is not.
const dragOverId = ref(0);
const onDragStart = (event, node) => {
event.dataTransfer.setData('text/plain', `aurora-folder:${node.id}`);
event.dataTransfer.effectAllowed = 'move';
};
const onDragOver = (event, node) => {
event.preventDefault();
dragOverId.value = node.id;
};
const onDragLeave = (node) => {
if (dragOverId.value === node.id) dragOverId.value = 0;
};
const onDrop = (event, node) => {
event.preventDefault();
dragOverId.value = 0;
const payload = event.dataTransfer.getData('text/plain');
if (!payload) return;
if (payload.startsWith('aurora-folder:')) {
const draggedId = Number(payload.split(':')[1]);
if (draggedId && draggedId !== node.id) {
emit('reparent', { id: draggedId, parent: node.id });
}
} else if (payload.startsWith('aurora-attachments:')) {
const ids = payload
.split(':')[1]
.split(',')
.map(Number)
.filter(Boolean);
if (ids.length) {
emit('assign', { folderId: node.id, attachmentIds: ids });
}
}
};
const contextMenu = ref({ open: false, x: 0, y: 0, node: null });
const openMenu = (event, node) => {
contextMenu.value = { open: true, x: event.clientX, y: event.clientY, node };
setTimeout(() => document.addEventListener('click', closeMenu), 0);
};
const closeMenu = () => {
contextMenu.value.open = false;
document.removeEventListener('click', closeMenu);
};
const runFromMenu = (fn) => {
closeMenu();
fn();
};
</script>
<template>
<ul class="mf-tree">
<li v-for="node in nodes" :key="node.id">
<div
class="mf-tree__row"
:class="{ 'mf-tree__row--active': node.id === activeFolderId, 'mf-tree__row--dragover': dragOverId === node.id }"
draggable="true"
@dragstart="onDragStart($event, node)"
@dragover="onDragOver($event, node)"
@dragleave="onDragLeave(node)"
@drop="onDrop($event, node)"
@contextmenu.prevent="openMenu($event, node)"
>
<button
v-if="node.children && node.children.length"
type="button"
class="mf-tree__toggle"
@click="emit('toggle', node.id)"
>
<span class="dashicons dashicons-arrow-right-alt2" :class="{ 'mf-tree__toggle-icon--open': expanded[node.id] }" />
</button>
<span v-else class="mf-tree__toggle-spacer" />
<button type="button" class="mf-tree__label" @click="emit('select', node.id)">
<span class="dashicons dashicons-portfolio" :style="node.color ? { color: node.color } : {}" />
<span class="mf-tree__name">{{ node.name }}</span>
<span class="mf-tree__count">{{ node.count }}</span>
</button>
</div>
<MediaFolderTree
v-if="expanded[node.id] && node.children && node.children.length"
:nodes="node.children"
:active-folder-id="activeFolderId"
:expanded="expanded"
@select="emit('select', $event)"
@toggle="emit('toggle', $event)"
@create-child="emit('create-child', $event)"
@rename="emit('rename', $event)"
@delete="emit('delete', $event)"
@reparent="emit('reparent', $event)"
@assign="emit('assign', $event)"
/>
</li>
</ul>
<Teleport to="body">
<ul v-if="contextMenu.open" class="mf-context-menu" :style="{ left: contextMenu.x + 'px', top: contextMenu.y + 'px' }" @contextmenu.prevent>
<li><button type="button" @click="runFromMenu(() => emit('create-child', contextMenu.node.id))"><span class="dashicons dashicons-plus" /> New Subfolder</button></li>
<li><button type="button" @click="runFromMenu(() => emit('rename', contextMenu.node))"><span class="dashicons dashicons-edit" /> Rename</button></li>
<li class="mf-context-menu__sep" />
<li><button type="button" class="mf-context-menu__danger" @click="runFromMenu(() => emit('delete', contextMenu.node))"><span class="dashicons dashicons-trash" /> Delete</button></li>
</ul>
</Teleport>
</template>
<style scoped>
.mf-tree { list-style: none; margin: 0; padding-left: 14px; }
.mf-tree:first-of-type { padding-left: 0; }
.mf-tree__row {
display: flex; align-items: center; gap: 2px; border-radius: 6px;
}
.mf-tree__row:hover { background: var(--aurora-bg-subtle); }
.mf-tree__row--active { background: var(--aurora-accent-soft); }
.mf-tree__row--dragover { outline: 2px dashed var(--aurora-accent); outline-offset: -2px; }
.mf-tree__toggle,
.mf-tree__toggle-spacer {
width: 18px; height: 24px; flex-shrink: 0;
}
.mf-tree__toggle {
border: none; background: none; cursor: pointer;
color: var(--aurora-text-muted); display: inline-flex; align-items: center; justify-content: center;
}
.mf-tree__toggle .dashicons { font-size: 14px; width: 14px; height: 14px; transition: transform 0.15s; }
.mf-tree__toggle-icon--open { transform: rotate(90deg); }
.mf-tree__label {
flex: 1; min-width: 0; display: flex; align-items: center; gap: 6px;
border: none; background: none; cursor: pointer; text-align: left;
padding: 3px 4px; color: var(--aurora-text); font-size: 0.8125rem;
}
.mf-tree__name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; }
.mf-tree__label .dashicons:first-child { color: var(--aurora-text-muted); flex-shrink: 0; font-size: 15px; width: 15px; height: 15px; }
.mf-tree__count { font-size: 0.7rem; color: var(--aurora-text-muted); flex-shrink: 0; }
</style>
<style>
.mf-context-menu {
position: fixed;
z-index: 100000;
min-width: 170px;
margin: 0;
padding: 4px;
list-style: none;
border: 1px solid var(--aurora-border);
border-radius: 8px;
background: var(--aurora-frame-bg);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
}
.mf-context-menu button {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 7px 10px;
border: none;
background: none;
border-radius: 6px;
color: var(--aurora-text);
font-size: 0.8rem;
text-align: left;
cursor: pointer;
}
.mf-context-menu button:hover { background: var(--aurora-bg-subtle); }
.mf-context-menu button .dashicons { font-size: 16px; width: 16px; height: 16px; color: var(--aurora-text-muted); }
.mf-context-menu__danger { color: #ef4444 !important; }
.mf-context-menu__danger .dashicons { color: #ef4444 !important; }
.mf-context-menu__sep {
height: 1px;
margin: 4px 6px;
background: var(--aurora-border);
}
</style>