diff --git a/app/Http/Controllers/DashboardController.php b/app/Http/Controllers/DashboardController.php index 0ab8be7..bb231d1 100644 --- a/app/Http/Controllers/DashboardController.php +++ b/app/Http/Controllers/DashboardController.php @@ -28,14 +28,14 @@ class DashboardController extends Controller { return view('dashboard.pages.home'); } - // View Model Data + // Page to View Model Data public function getView($model) { $model_class = Dashboard::getModel($model, 'view'); if ($model_class != null) { return view('dashboard.pages.view', [ - 'heading' => $model_class::getDashboardHeading($model), + 'heading' => $model_class->getDashboardHeading(), 'column_headings' => $model_class::getDashboardColumnData('headings'), 'model' => $model, 'rows' => $model_class::getDashboardData(), @@ -46,7 +46,7 @@ class DashboardController extends Controller { } } - // Edit List of Model Rows + // Page to Edit List of Model Rows public function getEditList($model) { $model_class = Dashboard::getModel($model, 'edit'); @@ -55,7 +55,7 @@ class DashboardController extends Controller { $data = $model_class::getDashboardData(true); return view('dashboard.pages.edit-list', [ - 'heading' => $model_class::getDashboardHeading($model), + 'heading' => $model_class->getDashboardHeading(), 'model' => $model, 'rows' => $data['rows'], 'paramdisplay' => $data['paramdisplay'], @@ -75,7 +75,7 @@ class DashboardController extends Controller { } } - // Create and Edit Model Item + // Page to Create and Edit Model Item public function getEditItem($model, $id = 'new') { $model_class = Dashboard::getModel($model, 'edit'); @@ -103,7 +103,7 @@ class DashboardController extends Controller { } return view('dashboard.pages.edit-item', [ - 'heading' => $model_class::getDashboardHeading($model), + 'heading' => $model_class->getDashboardHeading(), 'model' => $model, 'id' => $id, 'item' => $item, @@ -296,15 +296,14 @@ class DashboardController extends Controller { if (is_null($item)) { return 'record-access-fail'; - } else if (!$item->userCheck()) { - return 'permission-fail'; } else if ($request->hasFile('file')) { - $directory = base_path() . '/public/uploads/' . $request['model'] . '/img/'; - File::makeDirectory($directory, 0755, true, true); - $image = Image::make($request->file('file')); - $image->save($directory . $request['id'] . '-' . $request['name'] . '.jpg'); - $item->touch(); - return 'success'; + $save_result = $item->saveImage($request['name'], $request->file('file')); + + if ($save_result == 'success') { + $item->touch(); + } + + return $save_result; } else { return 'file-upload-fail'; } @@ -319,8 +318,7 @@ class DashboardController extends Controller { $this->validate($request, [ 'id' => 'required', 'model' => 'required', - 'name' => 'required', - 'ext' => 'required' + 'name' => 'required' ]); $model_class = Dashboard::getModel($request['model'], 'edit'); @@ -330,14 +328,14 @@ class DashboardController extends Controller { if (is_null($item)) { return 'record-access-fail'; - } else if (!$item->userCheck()) { - return 'permission-fail'; } else if ($request->hasFile('file')) { - $directory = base_path() . '/public/uploads/' . $request['model'] . '/files/'; - File::makeDirectory($directory, 0755, true, true); - $request->file('file')->move($directory, $request['id'] . '-' . $request['name'] . '.' . $request['ext']); - $item->touch(); - return 'success'; + $save_result = $item->saveFile($request['name'], $request->file('file')); + + if ($save_result == 'success') { + $item->touch(); + } + + return $save_result; } else { return 'file-upload-fail'; } @@ -365,26 +363,18 @@ class DashboardController extends Controller { return 'permission-fail'; } - // delete the row - $item->delete(); - // delete associated files if they exist foreach ($model_class::$dashboard_columns as $column) { if ($column['type'] == 'image') { - $image = base_path() . '/public/uploads/' . $request['model'] . '/img/' . $request['id'] . '-' . $column['name'] . '.jpg'; - - if (file_exists($image) && !unlink($image)) { - return 'image-delete-fail'; - } + $item->deleteImage($column['name'], false); } else if ($column['type'] == 'file') { - $file = base_path() . '/public/uploads/' . $request['model'] . '/files/' . $request['id'] . '-' . $column['name'] . '.' . $column['ext']; - - if (file_exists($file) && !unlink($file)) { - return 'file-delete-fail'; - } + $item->deleteFile($column['name'], false); } } + // delete the row + $item->delete(); + // update the order of the remaining rows if $dashboard_reorder is true if ($model_class::$dashboard_reorder) { foreach ($model_class::getDashboardData() as $index => $item) { @@ -412,20 +402,13 @@ class DashboardController extends Controller { $model_class = Dashboard::getModel($request['model'], 'edit'); if ($model_class != null) { - $image = base_path() . '/public/uploads/' . $request['model'] . '/img/' . $request['id'] . '-' . $request['name'] . '.jpg'; $item = $model_class::find($request['id']); if (is_null($item)) { return 'record-access-fail'; - } else if (!$item->userCheck()) { - return 'permission-fail'; - } else if (!file_exists($image)) { - return 'image-not-exists-fail'; - } else if (!unlink($image)) { - return 'image-delete-fail'; } - return 'success'; + return $item->deleteImage($request['name'], true); } else { return 'model-access-fail'; } @@ -437,27 +420,19 @@ class DashboardController extends Controller { $this->validate($request, [ 'id' => 'required', 'model' => 'required', - 'name' => 'required', - 'ext' => 'required' + 'name' => 'required' ]); $model_class = Dashboard::getModel($request['model'], 'edit'); if ($model_class != null) { - $file = base_path() . '/public/uploads/' . $request['model'] . '/files/' . $request['id'] . '-' . $request['name'] . '.' . $request['ext']; $item = $model_class::find($request['id']); if (is_null($item)) { return 'record-access-fail'; - } else if (!$item->userCheck()) { - return 'permission-fail'; - } else if (!file_exists($file)) { - return 'file-not-exists-fail'; - } else if (!unlink($file)) { - return 'file-delete-fail'; } - return 'success'; + return $item->deleteFile($request['name'], true); } else { return 'model-access-fail'; } diff --git a/app/Models/Blog.php b/app/Models/Blog.php index d305210..39e5033 100644 --- a/app/Models/Blog.php +++ b/app/Models/Blog.php @@ -49,8 +49,8 @@ class Blog extends DashboardModel $blog_entry['tags'] = $tags; // Add the header image if one exists - $header_image_path = '/uploads/blog/img/' . $blog_entry->id . '-header-image.jpg'; - $blog_entry['headerimage'] = file_exists(base_path() . '/public' . $header_image_path) ? $header_image_path . '?version=' . $blog_entry->timestamp() : ''; + $header_image_path = $blog_entry->getUploadsPath('image') . $blog_entry->id . '-header-image.jpg'; + $blog_entry['headerimage'] = file_exists(public_path($header_image_path)) ? $header_image_path . '?version=' . $blog_entry->timestamp() : ''; // Add the processed blog entry to the array array_push($blog_entries, $blog_entry); diff --git a/app/Models/DashboardModel.php b/app/Models/DashboardModel.php index e64a249..70cac68 100644 --- a/app/Models/DashboardModel.php +++ b/app/Models/DashboardModel.php @@ -4,6 +4,8 @@ namespace App\Models; use Illuminate\Database\Eloquent\Model; use Auth; +use File; +use Image; use App\Traits\Timestamp; class DashboardModel extends Model @@ -120,13 +122,316 @@ class DashboardModel extends Model * * @return string */ - public static function getDashboardHeading($model) + public function getDashboardHeading() { - return static::$dashboard_heading == null ? ucfirst($model) : static::$dashboard_heading; + return static::$dashboard_heading == null ? ucfirst($this->getTable()) : static::$dashboard_heading; } /** - * Returns an array of column 'headings' or 'names' + * Return the upload path for a given type + * + * @return boolean + */ + public function getUploadsPath($type) + { + if ($type == 'image') { + return '/uploads/' . $this->getTable() . '/img/'; + } else if ($type == 'file') { + return '/uploads/' . $this->getTable() . '/files/'; + } + } + + /** + * Save an image + * + * @return boolean + */ + public function saveImage($name, $file) + { + // Fail if the user doesn't have permission + if (!$this->userCheck()) { + return 'permission-fail'; + } + + $max_width = 0; + $max_height = 0; + $main_ext = 'jpg'; + + // Retrieve the column + $column = static::getColumn($name); + + // Return an error if no column is found + if ($column == null) { + return 'no-such-column-fail'; + } + + // Update the extension if it's been configured + if (array_key_exists('ext', $column)) { + $main_ext = $column['ext']; + } + + // Create the directory if it doesn't exist + $directory = public_path($this->getUploadsPath('image')); + File::makeDirectory($directory, 0755, true, true); + + // Set the base file path (including the file name but not the extension) + $base_filename = $directory . $this->id . '-' . $name . '.'; + + if ($main_ext == 'svg') { + // Save the image provided it's an SVG + if (gettype($file) == 'string') { + if (!preg_match('/\.svg$/i', $file)) { + return 'incorrect-format-fail'; + } + + copy($file, $base_filename . $main_ext); + } else { + if ($file->extension() != 'svg') { + return 'incorrect-format-fail'; + } + + $file->move($directory, $base_filename . $main_ext); + } + } else { + // Update the maximum width if it's been configured + if (array_key_exists('max_width', $column)) { + $max_width = $column['max_width']; + } + // Update the maximum height if it's been configured + if (array_key_exists('max_height', $column)) { + $max_height = $column['max_height']; + } + + $image = Image::make($file); + + if ($max_width > 0 || $max_height > 0) { + $width = $image->width(); + $height = $image->height(); + $new_width = null; + $new_height = null; + + if ($max_width > 0 && $max_height > 0) { + if ($width > $max_width || $height > $max_height) { + $new_width = $max_width; + $new_height = ($new_width / $width) * $height; + + if ($new_height > $max_height) { + $new_width = ($max_height / $height) * $width; + } + } + } else if ($max_width > 0) { + if ($width > $max_width) { + $new_width = $max_width; + } + } else if ($height > $max_height) { + $new_height = $max_height; + } + + if (!is_null($new_width) || !is_null($new_height)) { + $image->resize($new_width, $new_height, function($constraint) { + $constraint->aspectRatio(); + }); + } + } + + $image->save($base_filename . $main_ext); + $image->save($base_filename . 'webp'); + } + + return 'success'; + } + + /* + * Delete an image + * + * @return string + */ + public function deleteImage($name, $not_exist_fail) + { + // Fail if the user doesn't have permission + if (!$this->userCheck()) { + return 'permission-fail'; + } + + // Set up our variables + $main_ext = 'jpg'; + $extensions = []; + + // Retrieve the column + $column = static::getColumn($name); + + // Return an error if no column is found + if ($column == null) { + return 'no-such-column-fail'; + } + + // Update the extension if it's been configured + if (array_key_exists('ext', $column)) { + $main_ext = $column['ext']; + } + + // Build the set of extensions to delete + array_push($extensions, $main_ext); + + // If the image extension isn't svg also delete the webp + if ($main_ext != 'svg') { + array_push($extensions, 'webp'); + } + + // Delete each image + foreach ($extensions as $ext) { + // Get the full path of the image + $image = public_path($this->getUploadsPath('image') . $this->id . '-' . $name . '.' . $ext); + + // Try to delete the image + if (file_exists($image)) { + if (!unlink($image)) { + return 'image-delete-fail'; + } + } else if ($not_exist_fail) { + return 'image-not-exists-fail'; + } + } + + // Success + return 'success'; + } + + /** + * Save a file + * + * @return boolean + */ + public function saveFile($name, $file) + { + // Fail if the user doesn't have permission + if (!$this->userCheck()) { + return 'permission-fail'; + } + + // Retrieve the column + $column = static::getColumn($name); + + // Return an error if no column is found + if ($column == null) { + return 'no-such-column-fail'; + } + + // Fail if an ext hasn't been declared + if (!array_key_exists('ext', $column)) { + return 'no-configured-extension-fail'; + } + + // Store the extension + $ext = $column['ext']; + + // Create the directory if it doesn't exist + $directory = public_path($this->getUploadsPath('file')); + File::makeDirectory($directory, 0755, true, true); + + // Save the file provided it's the correct extension + if (gettype($file) == 'string') { + if (!preg_match("/\.$ext/i", $file)) { + return 'incorrect-format-fail'; + } + + copy($file, $base_filename . $main_ext); + } else { + if ($file->extension() != $ext) { + return 'incorrect-format-fail'; + } + + $file->move($directory, $this->id . '-' . $name . '.' . $ext); + } + + // Success + return 'success'; + } + + /* + * Delete a file + * + * @return string + */ + public function deleteFile($name, $not_exist_fail) + { + // Fail if the user doesn't have permission + if (!$this->userCheck()) { + return 'permission-fail'; + } + + // Retrieve the column + $column = static::getColumn($name); + + // Return an error if no column is found + if ($column == null) { + return 'no-such-column-fail'; + } + + // Fail if an ext hasn't been declared + if (!array_key_exists('ext', $column)) { + return 'no-configured-extension-fail'; + } + + // Store the extension + $ext = $column['ext']; + + // Get the full path of the file + $file = public_path($this->getUploadsPath('file') . $this->id . '-' . $name . '.' . $ext); + + // Try to delete the file + if (file_exists($file)) { + if (!unlink($file)) { + return 'file-delete-fail'; + } + } else if ($not_exist_fail) { + return 'file-not-exists-fail'; + } + + // Success + return 'success'; + } + + /** + * Determine whether a user column exists and whether it matches the current user if it does + * + * @return boolean + */ + public function userCheck() + { + $user_check = true; + + foreach (static::$dashboard_columns as $column) { + if (array_key_exists('type', $column) && $column['type'] == 'user') { + if ($this->{$column['name']} != Auth::id()) { + $user_check = false; + } + + break; + } + } + + return $user_check; + } + + /** + * Get the file extension for an image + * + * @return string + */ + public static function getColumn($name) + { + foreach (static::$dashboard_columns as $column) { + if ($column['name'] == $name) { + return $column; + } + } + + return null; + } + + /** + * Return an array of column 'headings' or 'names' * * @return array */ @@ -152,7 +457,7 @@ class DashboardModel extends Model } /** - * Performs a search against the columns in $dashboard_display + * Perform a search against the columns in $dashboard_display * * @return array */ @@ -192,7 +497,7 @@ class DashboardModel extends Model } /** - * Returns data for the dashboard + * Return data for the dashboard * * @return array */ @@ -250,7 +555,7 @@ class DashboardModel extends Model } /** - * Retrieves the current query string containing valid query parameters + * Retrieve the current query string containing valid query parameters * * @return string */ @@ -275,25 +580,4 @@ class DashboardModel extends Model return $string; } - - /** - * Determines whether a user column exists and whether it matches the current user if it does - * - * @return boolean - */ - public function userCheck() { - $user_check = true; - - foreach (static::$dashboard_columns as $column) { - if (array_key_exists('type', $column) && $column['type'] == 'user') { - if ($this->{$column['name']} != Auth::id()) { - $user_check = false; - } - - break; - } - } - - return $user_check; - } } diff --git a/gulpfile.js b/gulpfile.js index 15dde46..858ffdc 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -265,7 +265,7 @@ gulp.task("watch", () => { }); gulp.watch([ "app/**/*.php", "routes/**/*.php", "resources/views/**/*.blade.php" ], gulp.series(browserSyncReload)); - gulp.watch([ "resources/js/**/app.js", "resources/js/mixins/**/*.js", "resources/components/**/*.vue" ], gulp.series("js-public", browserSyncReload)); + gulp.watch([ "resources/js/**/app.js", "resources/js/mixins/**/*.js", "resources/js/imports/**/*.js", "resources/components/**/*.vue" ], gulp.series("js-public", browserSyncReload)); gulp.watch("resources/js/**/dashboard.js", gulp.series("js-dashboard", browserSyncReload)); gulp.watch("resources/sass/**/*.scss", gulp.parallel("sass-public", "sass-dashboard", "sass-error")); }); diff --git a/readme.md b/readme.md index 53e5fe2..0f6f66b 100644 --- a/readme.md +++ b/readme.md @@ -218,7 +218,9 @@ Models with their `$dashboard_type` set to `edit` also use: * `options` (required by `select`) Takes an array of options that are either strings or arrays containing the keys `title` (for what will display with the option) and `value` (for what will be recorded) * `name`: (required by `file` and `image`) Used along with the record id to determine the filename * `delete`: (optional for `file` and `image`) Enables a delete button for the upload when set to true -* `ext`: (required by `file`) Configures the file extension of the upload +* `ext`: (required by `file` and optional for `image`) Configures the file extension of the upload (`image` defaults to `jpg`) +* `max_width`: (optional for `image`) Configures the maximum width of an image upload (defaults to `0` which sets no maximum width) +* `max_height`: (optional for `image`) Configures the maximum height of an image upload (defaults to `0` which sets no maximum height) An example of the `$dashboard_columns` array in a model with its `$dashboard_type` set to `view`: @@ -238,7 +240,7 @@ An example of the `$dashboard_columns` array in a model with its `$dashboard_typ [ 'name' => 'created_at', 'title' => 'Date', 'type' => 'display' ], [ 'name' => 'title', 'required' => true, 'unique' => true, 'type' => 'string' ], [ 'name' => 'body', 'required' => true, 'type' => 'mkd' ], - [ 'name' => 'header-image', 'title' => 'Header Image', 'type' => 'image', 'delete' => true ], + [ 'name' => 'header-image', 'title' => 'Header Image', 'type' => 'image', 'delete' => true, 'ext' => 'jpg' ], [ 'name' => 'tags', 'type' => 'list', 'model' => 'BlogTags', 'columns' => [ 'name' ], 'foreign' => 'blog_id', 'sort' => 'order' ] ]; ``` diff --git a/resources/components/pages/blog.vue b/resources/components/pages/blog.vue index d1326e7..d681175 100644 --- a/resources/components/pages/blog.vue +++ b/resources/components/pages/blog.vue @@ -12,7 +12,7 @@
+ :style="{ backgroundImage: 'url(' + imageType(entry.headerimage) + ')' }">
diff --git a/resources/js/app.js b/resources/js/app.js index 7212dff..7c5db16 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -15,6 +15,15 @@ Vue.use(Vuex); // CSRF prevention header Vue.http.headers.common["X-CSRF-TOKEN"] = env.csrfToken; +// Import local javascript +import SupportsWebP from "imports/supports-webp.js"; + +// Import global mixins +import ImageType from "mixins/image-type.js"; + +// Register global mixins +Vue.mixin(ImageType); + // Import global components import NavSection from "sections/nav.vue"; import FooterSection from "sections/footer.vue"; @@ -61,7 +70,8 @@ const store = new Vuex.Store({ appLang: env.appLang, appDefaultLang: env.appDefaultLang, firstLoad: true, - lastPath: "" + lastPath: "", + supportsWebP: null }, getters: { @@ -83,6 +93,10 @@ const store = new Vuex.Store({ getLastPath: state => { return state.lastPath; + }, + + getSupportsWebP: state => { + return state.supportsWebP; } }, @@ -98,6 +112,10 @@ const store = new Vuex.Store({ setLastPath(state, value) { state.lastPath = value; + }, + + setSupportsWebP(state, value) { + state.supportsWebP = value; } }, @@ -106,6 +124,9 @@ const store = new Vuex.Store({ } }); +// Detect webp support +SupportsWebP.detect(store); + // Sync vue-router-sync with vuex store sync(store, router); diff --git a/resources/js/dashboard.js b/resources/js/dashboard.js index 7b00d3c..bf85ea9 100644 --- a/resources/js/dashboard.js +++ b/resources/js/dashboard.js @@ -4,6 +4,19 @@ const fadeTime = 250; // declare a reverse function for jquery jQuery.fn.reverse = [].reverse; +// extends an error message with additional text +function getErrorText(message, response) { + let errorText = message; + + switch (response) { + case "incorrect-format-fail": + errorText += ": Incorrect file format"; + break; + } + + return errorText; +} + // show or hide the loading modal function loadingModal(action) { const $loadingModal = $("#loading-modal"); @@ -429,7 +442,6 @@ function editItemInit() { file.append("name", $(fileUpload).attr("name")); file.append("id", row_id); file.append("model", model); - file.append("ext", $(fileUpload).data("ext")); $.ajax({ type: "POST", @@ -444,7 +456,7 @@ function editItemInit() { } else { loadingModal("hide"); - showAlert("Failed to upload file", function() { + showAlert(getErrorText("Failed to upload file", response), function() { submitting = false; }); } @@ -491,7 +503,7 @@ function editItemInit() { } else { loadingModal("hide"); - showAlert("Failed to upload image", function() { + showAlert(getErrorText("Failed to upload image", response), function() { submitting = false; }); } diff --git a/resources/js/imports/supports-webp.js b/resources/js/imports/supports-webp.js new file mode 100644 index 0000000..1b22858 --- /dev/null +++ b/resources/js/imports/supports-webp.js @@ -0,0 +1,48 @@ +export default { + detect: (store) => { + const webpTestImages = { + lossy: "UklGRiIAAABXRUJQVlA4IBYAAAAwAQCdASoBAAEADsD+JaQAA3AAAAAA", + lossless: "UklGRhoAAABXRUJQVlA4TA0AAAAvAAAAEAcQERGIiP4HAA==", + alpha: "UklGRkoAAABXRUJQVlA4WAoAAAAQAAAAAAAAAAAAQUxQSAwAAAARBxAR/Q9ERP8DAABWUDggGAAAABQBAJ0BKgEAAQAAAP4AAA3AAP7mtQAAAA==" + }; + + const results = { + lossy: null, + lossless: null, + alpha: null + }; + + const getResultsValues = () => { + return Object.keys(results).map((feature) => { + return results[feature]; + }); + }; + + const callback = (feature, result) => { + results[feature] = result; + + if (getResultsValues().indexOf(null) === -1) { + store.commit("setSupportsWebP", getResultsValues().indexOf(false) === -1); + console.log(store.getters.getSupportsWebP); + } + }; + + const checkFeature = (feature) => { + const img = new Image(); + + img.onload = function() { + callback(feature, img.width > 0 && img.height > 0); + }; + + img.onerror = function() { + callback(feature, false); + }; + + img.src = "data:image/webp;base64," + webpTestImages[feature]; + }; + + Object.keys(webpTestImages).forEach((feature) => { + checkFeature(feature); + }); + } +}; diff --git a/resources/js/mixins/image-type.js b/resources/js/mixins/image-type.js new file mode 100644 index 0000000..9ab63a5 --- /dev/null +++ b/resources/js/mixins/image-type.js @@ -0,0 +1,13 @@ +export default { + methods: { + imageType(image) { + if (this.$store.getters.getSupportsWebP === true) { + return image.replace(/\.(png|jpg)/, ".webp"); + } else if (this.$store.getters.getSupportsWebP === false) { + return image; + } else { + return ""; + } + } + } +}; diff --git a/resources/views/dashboard/pages/edit-item.blade.php b/resources/views/dashboard/pages/edit-item.blade.php index 5a5ba12..79224ee 100644 --- a/resources/views/dashboard/pages/edit-item.blade.php +++ b/resources/views/dashboard/pages/edit-item.blade.php @@ -21,6 +21,7 @@
@set('value', $item !== null ? $item[$column['name']] : '') @set('type', $id == 'new' && array_key_exists('type-new', $column) ? $column['type-new'] : $column['type']) + @set('ext', array_key_exists('ext', $column) ? $column['ext'] : 'jpg') @if($type == 'hidden') @@ -28,7 +29,17 @@ @elseif($type != 'display' || $id != 'new')
- +
@@ -100,7 +111,7 @@
@elseif($type == 'image') - @set('current_image', "/uploads/$model/img/$id-" . $column['name'] . '.jpg') + @set('current_image', "/uploads/$model/img/$id-" . $column['name'] . '.' . $ext) @if(file_exists(base_path() . '/public' . $current_image)) @@ -116,14 +127,14 @@ @endif @elseif($type == 'file') @set('current_file', "/uploads/$model/files/$id-" . $column['name'] . '.' . $column['ext']) - + @if(file_exists(base_path() . '/public' . $current_file))
View {{ strtoupper($column['ext']) }} @if(array_key_exists('delete', $column) && $column['delete']) - + Delete {{ strtoupper($column['ext']) }} @endif diff --git a/resources/views/templates/public.blade.php b/resources/views/templates/public.blade.php index e7318f1..b984ece 100644 --- a/resources/views/templates/public.blade.php +++ b/resources/views/templates/public.blade.php @@ -6,11 +6,12 @@