Feature/upload improvements (#617)

* various improvements.. needs cleanup

* more progress - supports deeply nested + pages

* Getting close now!

* more progress!

* some cleanup

* use data[_json] to store page-based upload

* Smarter logic to get nested form fields

* some refactoring/cleanup

* Fixed issue with removing multiple files in pages

* Refactor and support `destination: page@:/images` and `destination: self@` syntax for file fields

* Prettifying the upload field

* Handling Files API to better represent the selected files in the input field

* Better plurarl string

* Fixed harcoded height for input field

* revamped CSS!!!

* `fancy: false` turns off fancy styling

* Create folder if not exists

* Add support for @theme/theme@ destination

* Fixed create directory functionality to take into account resolved paths

* Don't allow @self on page to be uploaded to if not created

* added field languages

* css tweaks

* language integration
This commit is contained in:
Andy Miller
2016-05-26 14:49:45 -06:00
parent 429a00f439
commit 8cd0279b01
21 changed files with 617 additions and 266 deletions

View File

@@ -233,9 +233,11 @@ class Admin
*
* @return Page
*/
public function page($route = false)
public function page($route = false, $path = null)
{
if (!$path) {
$path = $this->route;
}
if ($route && !$path) {
$path = '/';
@@ -285,36 +287,6 @@ class Admin
$post = isset($_POST['data']) ? $_POST['data'] : [];
}
switch ($type) {
case 'configuration':
case 'system':
$type = 'system';
$blueprints = $this->blueprints("config/{$type}");
$config = $this->grav['config'];
$obj = new Data\Data($config->get('system'), $blueprints);
$obj->merge($post);
$file = CompiledYamlFile::instance($this->grav['locator']->findResource("config://{$type}.yaml"));
$obj->file($file);
$data[$type] = $obj;
break;
case 'settings':
case 'site':
$type = 'site';
$blueprints = $this->blueprints("config/{$type}");
$config = $this->grav['config'];
$obj = new Data\Data($config->get('site'), $blueprints);
$obj->merge($post);
$file = CompiledYamlFile::instance($this->grav['locator']->findResource("config://{$type}.yaml"));
$obj->file($file);
$data[$type] = $obj;
break;
case 'login':
$data[$type] = null;
break;
default:
/** @var UniformResourceLocator $locator */
$locator = $this->grav['locator'];
$filename = $locator->findResource("config://{$type}.yaml", true, true);
@@ -367,7 +339,6 @@ class Admin
} else {
throw new \RuntimeException("Data type '{$type}' doesn't exist!");
}
}
return $data[$type];
}
@@ -1180,4 +1151,63 @@ class Admin
{
$this->permissions = array_merge($this->permissions, $permissions);
}
public function findFormFields($type, $fields, $found_fields = [])
{
foreach ($fields as $key => $field) {
if (isset($field['type']) && $field['type'] == $type) {
$found_fields[$key] = $field;
} elseif (isset($field['fields'])) {
$result = $this->findFormFields($type, $field['fields'], $found_fields);
if (!empty($result)) {
$found_fields = array_merge($found_fields, $result);
}
}
}
return $found_fields;
}
public function getPagePathFromToken($path)
{
$path_parts = pathinfo($path);
$basename = '';
if (isset($path_parts['extension'])) {
$basename = '/'.$path_parts['basename'];
$path = $path_parts['dirname'];
}
$regex = '/(@self|self@)|((?:@page|page@):(?:.*))|((?:@theme|theme@):(?:.*))/';
preg_match($regex, $path, $matches);
if ($matches) {
if ($matches[1]) {
// self@
$page = $this->page(true);
} elseif ($matches[2]) {
// page@
$parts = explode(':', $path);
$route = $parts[1];
$page = $this->grav['page']->find($route);
} elseif ($matches[3]) {
// theme@
$parts = explode(':', $path);
$route = $parts[1];
$theme = str_replace(ROOT_DIR, '', $this->grav['locator']->findResource("theme://"));
return $theme . $route . $basename;
}
} else {
return $path . $basename;
}
if (!$page) {
throw new \RuntimeException('Page route not found: ' . $path);
}
$path = str_replace($matches[0], rtrim($page->relativePagePath(), '/'), $path);
return $path . $basename;
}
}

View File

@@ -123,8 +123,7 @@ class AdminController
$nonce = $this->grav['uri']->param('admin-nonce');
}
if (!$nonce || !Utils::verifyNonce($nonce, 'admin-form'))
{
if (!$nonce || !Utils::verifyNonce($nonce, 'admin-form')) {
if ($this->task == 'addmedia') {
$message = sprintf($this->admin->translate('PLUGIN_ADMIN.FILE_TOO_LARGE', null, true), ini_get('post_max_size'));
@@ -1265,70 +1264,56 @@ class AdminController
}
/**
* @param $field
* @return array
*/
private function cleanFilesData()
private function cleanFilesData($field)
{
/** @var Page $page */
$page = null;
$cleanFiles = [];
$type = trim("{$this->view}/{$this->admin->route}", '/');
$data = $this->admin->data($type, $this->post);
$blueprints = $data->blueprints();
if (!isset($blueprints['form']['fields'])) {
throw new \RuntimeException('Blueprints missing form fields definition');
}
$blueprint = $blueprints['form']['fields'];
$file = $_FILES['data'];
foreach ((array)$file['error'] as $index => $errors) {
$errors = !is_array($errors) ? [$errors] : $errors;
$errors = (array) Utils::getDotNotation($file['error'], $field['name']);
foreach($errors as $multiple_index => $error) {
foreach ($errors as $index => $error) {
if ($error == UPLOAD_ERR_OK) {
if (is_array($file['name'][$index])) {
$tmp_name = $file['tmp_name'][$index][$multiple_index];
$name = $file['name'][$index][$multiple_index];
$type = $file['type'][$index][$multiple_index];
$size = $file['size'][$index][$multiple_index];
} else {
$tmp_name = $file['tmp_name'][$index];
$name = $file['name'][$index];
$type = $file['type'][$index];
$size = $file['size'][$index];
$fieldname = $field['name'];
// Deal with multiple files
if (isset($field['multiple']) && $field['multiple'] == true) {
$fieldname = $fieldname . ".$index";
}
$destination = Folder::getRelativePath(rtrim($blueprint[$index]['destination'], '/'));
$tmp_name = Utils::getDotNotation($file['tmp_name'], $fieldname);
$name = Utils::getDotNotation($file['name'], $fieldname);
$type = Utils::getDotNotation($file['type'], $fieldname);
$size = Utils::getDotNotation($file['size'], $fieldname);
if (!$this->match_in_array($type, $blueprint[$index]['accept'])) {
$original_destination = null ;
$destination = Folder::getRelativePath(rtrim($field['destination'], '/'));
if (!$this->match_in_array($type, $field['accept'])) {
throw new \RuntimeException('File "' . $name . '" is not an accepted MIME type.');
}
if (Utils::startsWith($destination, '@page:')) {
$parts = explode(':', $destination);
$route = $parts[1];
$page = $this->grav['page']->find($route);
if (!$page) {
throw new \RuntimeException('Unable to upload file to destination. Page route not found.');
if (isset($field['random_name']) && $field['random_name'] === true) {
$path_parts = pathinfo($name);
$name = Utils::generateRandomString(15) . '.' . $path_parts['extension'];
}
$destination = $page->relativePagePath();
} else {
if ($destination == '@self') {
$page = $this->admin->page(true);
$destination = $page->relativePagePath();
} else {
Folder::mkdir($destination);
}
$resolved_destination = $this->admin->getPagePathFromToken($destination);
$upload_path = $resolved_destination . '/' . $name;
// Create dir if need be
if (!is_dir($resolved_destination)) {
Folder::mkdir($resolved_destination);
}
if (move_uploaded_file($tmp_name, "$destination/$name")) {
$path = $page ? $this->grav['uri']->convertUrl($page, $page->route() . '/' . $name) : $destination . '/' . $name;
if (move_uploaded_file($tmp_name, $upload_path)) {
$path = $destination . '/' . $name;
$fileData = [
'name' => $name,
'path' => $path,
@@ -1338,14 +1323,15 @@ class AdminController
'route' => $page ? $path : null
];
$cleanFiles[$index][$path] = $fileData;
$cleanFiles[$field['name']][$path] = $fileData;
} else {
throw new \RuntimeException("Unable to upload file(s) to $destination/$name");
}
} else {
if ($error != UPLOAD_ERR_NO_FILE) {
throw new \RuntimeException("Unable to upload file(s) - Error: ".$file['name'][$index].": ".$this->upload_errors[$error]);
}
throw new \RuntimeException("Unable to upload file(s) - Error: " . $field['name'] . ": " . $this->upload_errors[$error]);
}
}
}
@@ -1383,15 +1369,59 @@ class AdminController
return $obj;
}
$cleanFiles = $this->cleanFilesData();
$blueprints = $obj->blueprints();
foreach ($cleanFiles as $key => $data) {
if (!isset($blueprints['form']['fields'])) {
throw new \RuntimeException('Blueprints missing form fields definition');
}
$fields = $blueprints['form']['fields'];
$found_files = $this->findFields('file', $fields);
foreach ($found_files as $key => $data) {
if ($this->view == 'pages') {
$keys = explode('.', preg_replace('/^header./', '', $key));
$init_key = array_shift($keys);
if (count($keys) > 0) {
$new_data = isset($obj->header()->$init_key) ? $obj->header()->$init_key: [];
Utils::setDotNotation($new_data, implode('.', $keys), $data);
} else {
$new_data = $data;
}
$obj->modifyHeader($init_key, $new_data);
} else {
$obj->set($key, $data);
}
}
return $obj;
}
public function findFields($type, $fields, $found = [])
{
foreach ($fields as $key => $field) {
if (isset($field['type']) && $field['type'] == $type) {
$file_field = $this->cleanFilesData($field);
} elseif (isset($field['fields'])) {
$result = $this->findFields($type, $field['fields'], $found);
if (!empty($result)) {
$found = array_merge($found, $result);
}
} else {
$file_field = null;
}
if (isset($file_field) && (!is_array($file_field) || !empty($file_field))) {
$found = array_merge($file_field, $found);
}
}
return $found;
}
/**
* Handles creating an empty page folder (without markdown file)
*
@@ -1493,8 +1523,12 @@ class AdminController
// Find new parent page in order to build the path.
$route = !isset($data['route']) ? dirname($this->admin->route) : $data['route'];
/** @var Page $obj */
$obj = $this->admin->page(true);
// Ensure route is prefixed with a forward slash.
$route = '/' . ltrim($route, '/');
@@ -1530,6 +1564,8 @@ class AdminController
$obj = $obj->move($parent);
$this->preparePage($obj, false, $obj->language());
$obj = $this->processFiles($obj);
// Reset slug and route. For now we do not support slug twig variable on save.
$obj->slug($original_slug);
@@ -1883,11 +1919,11 @@ class AdminController
/**
* Determine if the user can edit media
*
* @param string $type
* @return bool True if the media action is allowed
*/
protected function canEditMedia()
protected function canEditMedia($type = 'media')
{
$type = 'media';
if (!$this->authorizeTask('edit media', ['admin.' . $type, 'admin.super'])) {
return false;
}
@@ -1907,6 +1943,7 @@ class AdminController
}
$filename = base64_decode($this->route);
$file = File::instance($filename);
$resultRemoveMedia = false;
$resultRemoveMediaMeta = true;
@@ -1943,15 +1980,33 @@ class AdminController
protected function taskRemoveFileFromBlueprint()
{
$uri = $this->grav['uri'];
$this->taskRemoveMedia();
$blueprint = base64_decode($uri->param('blueprint'));
$path = base64_decode($uri->param('path'));
$proute = base64_decode($uri->param('proute'));
$type = $uri->param('type');
$field = $uri->param('field');
$blueprint = $uri->param('blueprint');
$this->taskRemoveMedia();
$path = base64_decode($uri->param('path'));
if ($type == 'pages') {
$page = $this->admin->page(true, $proute);
$keys = explode('.', preg_replace('/^header./', '', $field));
$header = (array) $page->header();
$data_path = implode('.', $keys);
$data = Utils::getDotNotation($header, $data_path);
$files = $this->grav['config']->get($blueprint . '.' . $field);
if (isset($data[$path])) {
unset($data[$path]);
Utils::setDotNotation($header, $data_path, $data);
$page->header($header);
}
$page->save();
} else {
$blueprint_prefix = $type == 'config' ? '': $type . '.';
$blueprint_name = str_replace('/blueprints', '', str_replace('config/', '', $blueprint));
$blueprint_field = $blueprint_prefix . $blueprint_name . '.' . $field;
$files = $this->grav['config']->get($blueprint_field);
foreach ($files as $key => $value) {
if ($key == $path) {
@@ -1959,15 +2014,24 @@ class AdminController
}
}
$this->grav['config']->set($blueprint . '.' . $field, $files);
$this->grav['config']->set($blueprint_field, $files);
if (substr($blueprint, 0, 7) == 'plugins') {
Plugin::saveConfig(substr($blueprint, 8));
switch ($type) {
case 'config':
$data = $this->grav['config']->get($blueprint_name);
$config = $this->admin->data($blueprint, $data);
$config->save();
break;
case 'themes':
Theme::saveConfig($blueprint_name);
break;
case 'plugins':
Plugin::saveConfig($blueprint_name);
break;
}
if (substr($blueprint, 0, 6) == 'themes') {
Theme::saveConfig(substr($blueprint, 7));
}
//
//
$redirect = base64_decode($uri->param('redirect'));
$route = $this->grav['config']->get('plugins.admin.route');

View File

@@ -528,8 +528,9 @@ PLUGIN_ADMIN:
ORDERING_DISABLED_BECAUSE_PARENT_SETTING_ORDER: "Parent setting order, ordering disabled"
ORDERING_DISABLED_BECAUSE_PAGE_NOT_VISIBLE: "Page is not visible, ordering disabled"
ORDERING_DISABLED_BECAUSE_TOO_MANY_SIBLINGS: "Ordering via the admin is unsupported because there are more than 200 siblings"
CANNOT_ADD_MEDIA_FILES_PAGE_NOT_SAVED: "You cannot add media files until you save the page. Just click 'Save' on top"
DROP_FILES_HERE_TO_UPLOAD: "Drop files here to upload"
CANNOT_ADD_MEDIA_FILES_PAGE_NOT_SAVED: "NOTE: You cannot add media files until you save the page. Just click 'Save' on top"
CANNOT_ADD_FILES_PAGE_NOT_SAVED: "NOTE: Page must be saved before you can upload files to it."
DROP_FILES_HERE_TO_UPLOAD: "Drop your files here or <strong>click in this area</strong>"
INSERT: "Insert"
UNDO: "Undo"
REDO: "Redo"

View File

@@ -0,0 +1,22 @@
import $ from 'jquery';
import format from '../../utils/formatbytes';
$('body').on('change', '.form-input-file > input[type="file"]', (event) => {
let input = event.target;
let files = input.files;
let container = $(input).next();
if (files.length) {
let plural = files.length > 1 ? 's' : '';
let html = '';
html += `${files.length} file${plural} selected`;
html += '<ul>';
for (let i = 0; i < files.length; i++) {
html += `<li>${files[i].name} (${format(files[i].size, 2)})</li>`;
}
html += '</ul>';
container.html(html);
}
});

View File

@@ -4,6 +4,7 @@ import CollectionsField, { Instance as CollectionsFieldInstance } from './collec
import DateTimeField, { Instance as DateTimeFieldInstance } from './datetime';
import EditorField, { Instance as EditorFieldInstance } from './editor';
import ColorpickerField, { Instance as ColorpickerFieldInstance } from './colorpicker';
import './files';
export default {
SelectizeField: {

View File

@@ -1 +1,3 @@
@import url("//fonts.googleapis.com/css?family=Montserrat:400|Lato:300,400,700|Inconsolata:400,700");body,h5,h6,.badge,.note,.grav-mdeditor-preview,input,select,textarea,button,.selectize-input{font-family:"Lato","Helvetica","Tahoma","Geneva","Arial",sans-serif}h1,h2,h3,h4,#admin-menu li,.form-tabs>label,.label{font-family:"Montserrat","Helvetica","Tahoma","Geneva","Arial",sans-serif}code,kbd,pre,samp,body .CodeMirror{font-family:"Inconsolata","Monaco","Consolas","Lucida Console",monospace !important}
/*# sourceMappingURL=fonts.css.map */

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1 +1,3 @@
body,h5,h6,.badge,.note,.grav-mdeditor-preview,input,select,textarea,button,.selectize-input,h1,h2,h3,h4,#admin-menu li,.form-tabs>label,.label{font-family:"Helvetica Neue", "Helvetica", "Tahoma", "Geneva", "Arial", sans-serif}code,kbd,pre,samp,body .CodeMirror{font-family:"Monaco", "Consolas", "Lucida Console", monospace}
/*# sourceMappingURL=simple-fonts.css.map */

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -640,6 +640,34 @@ form {
}
.form-input-file {
border: 4px dashed $form-border;
p {
color: $form-field-text;
}
ul {
color: $primary-accent-fg;
background: $primary-accent-bg;
}
}
.file-thumbnail-remove {
background: $button-bg;
.fa {
color: $button-text;
}
&:hover {
background: $button-text;
.fa {
color: $button-bg;
}
}
}
}
.grav-editor-resizer {

View File

@@ -29,16 +29,33 @@ form {
padding-left: 1rem;
}
.thumbnail {
max-width: 200px;
vertical-align: top;
.file-thumbnail-wrapper {
display: inline-block;
position: relative;
}
.thumbnail-remove {
.file-thumbnail {
max-height: 150px;
vertical-align: top;
font-size: 1.4rem;
line-height: 1;
display: inline-block;
margin-bottom: 10px;
}
.file-thumbnail-remove {
position: absolute;
text-align: center;
display: block;
top: 3px;
right: 3px;
width: 25px;
height: 25px;
border-radius:100%;
.fa {
font-size: 20px;
line-height: 25px;
vertical-align: top;
}
}
}
@@ -113,6 +130,40 @@ form {
-webkit-font-smoothing: antialiased;
}
.form-input-file {
position: relative;
min-height: 70px;
border-radius: $form-border-radius;
ul {
margin: 1rem 0;
text-align: left;
font-size: 1rem;
border-radius: $form-border-radius;
}
p {
display: block;
height: 100%;
text-align: center;
margin: 0;
padding: 0.8rem 1rem 0;
font-size: 1.2rem;
}
input {
position: absolute;
margin: 0;
padding: 0;
width: 100%;
height: 100%;
outline: none;
opacity: 0;
}
}
.selectize-dropdown {
z-index: 100000;
}

View File

@@ -7,31 +7,44 @@
{% set uri = global.grav.uri %}
{% set files = global.files %}
{% set config = global.grav.config %}
{% set blueprint = (global.plugin ? 'plugins.' : global.theme ? 'themes.' : '') ~ uri.basename %}
<img class="thumbnail" src="{{ uri.rootUrl == '/' ? '/' : uri.rootUrl ~ '/'}}{{ path }}" alt="{{ path|replace({(files.destination ~ '/'): ''}) }}" />
<a class="thumbnail-remove" href="{{ uri.addNonce(global.base_url_relative ~
'/media/' ~ base64_encode(global.base_path ~ '/' ~ path) ~
{% set route = global.context.route() %}
{% set type = global.context.content() ? 'pages' : global.plugin ? 'plugins' : global.theme ? 'themes' : 'config' %}
{% set blueprint_name = global.blueprints.getFilename %}
{% if type == 'pages' %}
{% set blueprint_name = type ~ '/' ~ blueprint_name %}
{% endif %}
{% set blueprint = base64_encode(blueprint_name) %}
{% set real_path = global.admin.getPagePathFromToken(path) %}
{% if type == 'pages' %}
{% include 'forms/fields/hidden/hidden.html.twig' with {field: {name: 'data._json.' ~ global.field.name}, value:{(value.path):value}|raw|json_encode} %}
{% endif %}
<div class="file-thumbnail-wrapper">
<img class="file-thumbnail" src="{{ uri.rootUrl == '/' ? '/' : uri.rootUrl ~ '/'}}{{ real_path }}" alt="{{ path|replace({(files.destination ~ '/'): ''}) }}" />
<a class="file-thumbnail-remove" href="{{ uri.addNonce(global.base_url_relative ~
'/media/' ~ base64_encode(global.base_path ~ '/' ~ real_path) ~
'/task' ~ config.system.param_sep ~ 'removeFileFromBlueprint' ~
'/proute' ~ config.system.param_sep ~ base64_encode(route) ~
'/blueprint' ~ config.system.param_sep ~ blueprint ~
'/type' ~ config.system.param_sep ~ type ~
'/field' ~ config.system.param_sep ~ files.name ~
'/path' ~ config.system.param_sep ~ base64_encode(value.path) ~
'/redirect' ~ config.system.param_sep ~ base64_encode(uri.path), 'admin-form', 'admin-nonce') }}">
<i class="fa fa-close"></i>
<i class="fa fa-fw fa-close"></i>
</a>
</div>
{% endif %}
{% endmacro %}
{% block input %}
{% set page_can_upload = exists or (type == 'page' and not exists and not (field.destination starts with '@self' or field.destination starts with 'self@')) %}
{% if type is not defined or page_can_upload %}
{% if not plugin and not theme %}
The "file" input field cannot be used in Pages Blueprints. It's intended to be used for Plugins and Themes blueprints.
Use the "pagemediaselect" type instead.
{% else %}
{% for path, file in value %}
{{ _self.preview(path, file, _context) }}
{% endfor %}
<div class="form-input-wrapper {{ field.size }}">
<div class="form-input-wrapper {% if field.fancy is not same as(false) %}form-input-file{% endif %} {{ field.size|default('xlarge') }}">
<input
{# required attribute structures #}
name="{{ (scope ~ field.name)|fieldName ~ (files.multiple ? '[]' : '') }}"
@@ -40,9 +53,16 @@
{% if files.multiple %}multiple="multiple"{% endif %}
{% if files.accept %}accept="{{ files.accept|join(',') }}"{% endif %}
{% if field.disabled or isDisabledToggleable %}disabled="disabled"{% endif %}
{% if field.random_name %}random="true"{% endif %}
{{ parent() }}
{% endblock %}
/>
{% if field.fancy is not same as(false) %}
<p>{{ "PLUGIN_ADMIN.DROP_FILES_HERE_TO_UPLOAD"|tu|raw }}</p>
{% endif %}
</div>
{% else %}
<span class="note">{{ "PLUGIN_ADMIN.CANNOT_ADD_FILES_PAGE_NOT_SAVED"|tu|raw }}</span>
{% endif %}
{% endblock %}

View File

@@ -14,9 +14,9 @@
<div class="form-tab">
<div class="form-field">
<div class="form-label">
<label>
<span class="note">
{{ "PLUGIN_ADMIN.CANNOT_ADD_MEDIA_FILES_PAGE_NOT_SAVED"|tu }}
</label>
</span>
</div>
</div>
</div>

View File

@@ -1,4 +1,4 @@
{% set taxonomies = (taxonomies is null ? admin.data('site').taxonomies : taxonomies) %}
{% set taxonomies = (taxonomies is null ? admin.data('config/site').taxonomies : taxonomies) %}
{% set parentname = field.name %}
{% for name in taxonomies %}

View File

@@ -25,12 +25,10 @@
{% endif %}
{% set modular = context.modular ? 'modular_' : '' %}
{% set warn = config.plugins.admin.warnings.delete_page %}
{% set admin_lang = admin.session.admin_lang ?: 'en' %}
{% set page_lang = context.language %}
{% set type = 'page' %}
{% block stylesheets %}
{% if mode == 'edit' %}

View File

@@ -1,18 +1,14 @@
{% set form_id = form_id ? form_id : 'blueprints' %}
{% set scope = scope ?: 'data.' %}
{% set multipart = '' %}
{% for field in blueprints.fields %}
{% if field.type == 'file' %}
{% if admin.findFormFields('file', blueprints.fields) %}
{% set multipart = ' enctype="multipart/form-data"' %}
{% endif %}
{% endfor %}
<form id="{{ form_id }}" method="post" data-grav-form="{{ form_id }}" data-grav-keepalive="true"{{ multipart|raw }}>
{% for field in blueprints.fields %}
{% if field.type %}
{% set value = field.name ? data.value(field.name) : data.toArray %}
<div class="block block-{{ field.type }}">
{% include ["forms/fields/#{field.type}/#{field.type}.html.twig", 'forms/fields/text/text.html.twig'] %}
</div>