Skip to content

Commit

Permalink
Enable file uploading by drag&drop
Browse files Browse the repository at this point in the history
  • Loading branch information
adamziel committed Dec 27, 2024
1 parent 74e671a commit 7a4a0dc
Show file tree
Hide file tree
Showing 4 changed files with 260 additions and 41 deletions.
111 changes: 111 additions & 0 deletions packages/playground/data-liberation-static-files-editor/plugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,14 @@ static public function initialize() {
return current_user_can('edit_posts');
},
));

register_rest_route('static-files-editor/v1', '/create-files', array(
'methods' => 'POST',
'callback' => array(self::class, 'create_files_endpoint'),
'permission_callback' => function() {
return current_user_can('edit_posts');
},
));
});

// @TODO: the_content and rest_prepare_local_file filters run twice for REST API requests.
Expand Down Expand Up @@ -613,6 +621,109 @@ static public function move_file_endpoint($request) {
return array('success' => true);
}

static public function create_files_endpoint($request) {
$path = $request->get_param('path');
$nodes_json = $request->get_param('nodes');

if(!$path) {
$path = '/';
}

if (!$nodes_json) {
return new WP_Error('invalid_tree', 'Invalid file tree structure');
}

$nodes = json_decode($nodes_json, true);
if (!$nodes) {
return new WP_Error('invalid_json', 'Invalid JSON structure');
}

$created_files = [];

try {
$fs = self::get_fs();
foreach ($nodes as $node) {
$result = self::process_node($node, $path, $fs, $request);
if (is_wp_error($result)) {
return $result;
}
$created_files = array_merge($created_files, $result);
}

return array(
'created_files' => $created_files
);
} catch (Exception $e) {
return new WP_Error('creation_failed', $e->getMessage());
}
}

static private function process_node($node, $parent_path, $fs, $request) {
if (!isset($node['name']) || !isset($node['type'])) {
return new WP_Error('invalid_node', 'Invalid node structure');
}

$path = rtrim($parent_path, '/') . '/' . ltrim($node['name'], '/');
$created_files = [];

if ($node['type'] === 'folder') {
if (!$fs->mkdir($path)) {
return new WP_Error('mkdir_failed', "Failed to create directory: $path");
}

if (!empty($node['children'])) {
foreach ($node['children'] as $child) {
$result = self::process_node($child, $path, $fs, $request);
if (is_wp_error($result)) {
return $result;
}
$created_files = array_merge($created_files, $result);
}
}
} else {
$content = '';
if (isset($node['content']) && is_string($node['content']) && strpos($node['content'], '@file:') === 0) {
$file_key = substr($node['content'], 6);
$uploaded_file = $request->get_file_params()[$file_key] ?? null;
if ($uploaded_file && $uploaded_file['error'] === UPLOAD_ERR_OK) {
$content = file_get_contents($uploaded_file['tmp_name']);
}
}

if (!$fs->put_contents($path, $content)) {
return new WP_Error('write_failed', "Failed to write file: $path");
}

/*
// @TODO: Should we create posts here?
// * We'll reindex the data later anyway and create those posts on demand.
// * ^ yes, but this means we don't have these posts in the database right after the upload.
// * But if we do create them, how do we know which files need a post, and which ones are
// images, videos, etc?
$post_data = array(
'post_title' => basename($path),
'post_type' => WP_LOCAL_FILE_POST_TYPE,
'post_status' => 'publish',
'meta_input' => array(
'local_file_path' => $path
)
);
$post_id = wp_insert_post($post_data);
if (is_wp_error($post_id)) {
return $post_id;
}
*/

$created_files[] = array(
'path' => $path,
// 'post_id' => $post_id
);
}

return $created_files;
}

}

WP_Static_Files_Editor_Plugin::initialize();
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import React, {
import React from 'react';
import {
useEffect,
useRef,
useState,
Expand All @@ -19,23 +20,32 @@ import '@wordpress/components/build-style/style.css';
import css from './style.module.css';
import classNames from 'classnames';
import { folder, file } from '../icons';
import { FileTree } from './types';

export type FileNode = {
name: string;
type: 'file' | 'folder';
children?: FileNode[];
content?: File;
};

export type CreatedNode = {
type: 'file' | 'folder';
path: string;
};
export type CreatedNode =
| {
type: 'file' | 'folder';
path: string;
content?: string | ArrayBuffer | File;
}
| {
type: 'tree';
path: string;
content: FileNode[];
};

export type FilePickerControlProps = {
files: FileNode[];
initialPath?: string;
onSelect?: (path: string, node: FileNode) => void;
onNodeCreated?: (node: CreatedNode) => void;
onNodesCreated?: (tree: FileTree) => void;
onNodeDeleted?: (path: string) => void;
onNodeMoved?: ({
fromPath,
Expand All @@ -61,6 +71,7 @@ type DragState = {
path: string;
hoverPath: string | null;
hoverType: 'file' | 'folder' | null;
isExternal?: boolean;
};

type FilePickerContextType = {
Expand Down Expand Up @@ -93,8 +104,8 @@ export const FilePickerTree: React.FC<FilePickerControlProps> = ({
files,
initialPath,
onSelect = () => {},
onNodeCreated = (...args) => {
console.log('onNodeCreated', args);
onNodesCreated = (tree: FileTree) => {
console.log('onNodesCreated', tree);
},
onNodeDeleted = (path: string) => {
console.log('onNodeDeleted', path);
Expand Down Expand Up @@ -206,13 +217,15 @@ export const FilePickerTree: React.FC<FilePickerControlProps> = ({
toPath,
});
} else {
const fullPath = `${editedNode.parentPath}/${name}`.replace(
/\/+/g,
'/'
);
onNodeCreated({
type: editedNode.type,
path: fullPath,
onNodesCreated({
path: editedNode.parentPath,
nodes: [
{
name: name,
type: editedNode.type,
content: null,
},
],
});
}
};
Expand All @@ -226,6 +239,7 @@ export const FilePickerTree: React.FC<FilePickerControlProps> = ({
path,
hoverPath: null,
hoverType: null,
isExternal: false,
});
};

Expand All @@ -239,6 +253,19 @@ export const FilePickerTree: React.FC<FilePickerControlProps> = ({
type: 'file' | 'folder'
) => {
e.preventDefault();

// Handle external files being dragged in
if (e.dataTransfer.types.includes('Files')) {
e.dataTransfer.dropEffect = 'copy';
setDragState({
path: '',
hoverPath: path,
hoverType: type,
isExternal: true,
});
return;
}

if (dragState && dragState.path !== path) {
// Prevent dropping a folder into its own descendant
if (dragState.path && isDescendantPath(dragState.path, path)) {
Expand All @@ -255,12 +282,71 @@ export const FilePickerTree: React.FC<FilePickerControlProps> = ({
}
};

const handleDrop = (
const handleDrop = async (
e: React.DragEvent,
targetPath: string,
targetType: 'file' | 'folder'
) => {
e.preventDefault();
// Handle file/directory upload
if (e.dataTransfer.items.length > 0) {
const targetFolder =
targetType === 'folder'
? targetPath
: targetPath.split('/').slice(0, -1).join('/');
const items = Array.from(e.dataTransfer.items);

const buildTree = async (
entry: FileSystemEntry,
parentPath: string = ''
): Promise<FileNode> => {
if (entry.isFile) {
const fileEntry = entry as FileSystemFileEntry;
const file = await new Promise<File>((resolve) =>
fileEntry.file(resolve)
);
return {
name: entry.name,
type: 'file',
content: file,
};
} else {
const dirEntry = entry as FileSystemDirectoryEntry;
const reader = dirEntry.createReader();
const entries = await new Promise<FileSystemEntry[]>(
(resolve) => {
reader.readEntries((entries) => resolve(entries));
}
);

const children = await Promise.all(
entries.map((entry) => buildTree(entry))
);

return {
name: entry.name,
type: 'folder',
children,
};
}
};

const rootNodes = await Promise.all(
items
.map((item) => item.webkitGetAsEntry())
.filter((entry): entry is FileSystemEntry => entry !== null)
.map((entry) => buildTree(entry))
);

onNodesCreated({
path: targetFolder,
nodes: rootNodes,
});

setDragState(null);
return;
}

if (dragState) {
// Prevent dropping a folder into its own descendant
if (
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export type FileNode = {
name: string;
type: 'file' | 'folder';
children?: FileNode[];
content?: File;
};

export type FileTree = {
path: string;
nodes: FileNode[];
};
Loading

0 comments on commit 7a4a0dc

Please sign in to comment.