From 13437cce0a9c5830225117d34b15279c15b48d8f Mon Sep 17 00:00:00 2001 From: Brook Gagnon Date: Mon, 25 Nov 2024 04:00:46 +0000 Subject: [PATCH 1/2] add settings to media items details page, with ability to rotate images --- controllers/media.php | 43 +++++++++++++++ html/media/details.html | 18 ++++++- js/media/details.js | 49 ++++++++++++++++- js/sidebar.js | 2 +- models/media_model.php | 38 ++++++++++++++ routes.json | 10 ++++ ui/fields/imageRotate.js | 111 +++++++++++++++++++++++++++++++++++++++ updates/20241122.php | 20 +++++++ 8 files changed, 287 insertions(+), 4 deletions(-) create mode 100644 ui/fields/imageRotate.js create mode 100644 updates/20241122.php diff --git a/controllers/media.php b/controllers/media.php index 2375acd..35d2cb5 100644 --- a/controllers/media.php +++ b/controllers/media.php @@ -385,6 +385,49 @@ public function save() } } + /** + * Save additional media properties. + * + * @param id + * + * @route POST /v2/media/properties/(:id:) + */ + public function save_properties() + { + $this->user->require_authenticated(); + + $id = $this->data('id'); + $properties = $this->data('properties'); + + $media = $this->models->media('get_by_id', ['id' => $id]); + + // trigger permission failure if can't edit + if (!$this->user_can_edit($media)) { + $this->user->require_permission('manage_media'); + } + + $this->models->media('properties', ['id' => $id, 'properties' => $properties]); + + return [true, 'Properties saved.']; + } + + /** + * Get additional media properties. + * + * @param id + * + * @route GET /v2/media/properties/(:id:) + */ + public function get_properties() + { + $this->user->require_authenticated(); + + $id = $this->data('id'); + $properties = $this->models->media('properties', ['id' => $id]); + + return [true, 'Properties.', $properties]; + } + /** * Checks that user can manage versions for media item. Does not return true * or false, but can throw a permissions error using 'require_permissions'. diff --git a/html/media/details.html b/html/media/details.html index b8b3367..3477a02 100644 --- a/html/media/details.html +++ b/html/media/details.html @@ -1,6 +1,5 @@ -

Media Details

@@ -91,7 +90,6 @@

Media Details

-
@@ -101,4 +99,20 @@

Where is this media used?

+ +

Media Settings

+ + + + +
\ No newline at end of file diff --git a/js/media/details.js b/js/media/details.js index b269e81..d0b827c 100644 --- a/js/media/details.js +++ b/js/media/details.js @@ -18,7 +18,10 @@ */ // media details page -OB.Media.detailsPage = function (id) { +OB.MediaDetails = {}; +OB.MediaDetails.currentId = null; +OB.MediaDetails.page = function (id) { + OB.MediaDetails.currentId = id; OB.API.post("media", "get", { id: id, where_used: true }, function (response) { if (response.status == false) return; @@ -27,6 +30,26 @@ OB.Media.detailsPage = function (id) { var item = response.data; var used = response.data.where_used.used; + if (item?.type == "image") { + OB.API.post("media", "get_properties", { id: id }, function (propertiesResponse) { + const properties = propertiesResponse.data; + + const mediaDetailsSettings = document.querySelector('.media_details_settings[data-type="image"]'); + mediaDetailsSettings.removeAttribute("hidden"); + const imageRotate = document.createElement("ob-field-image-rotate"); + // set class + imageRotate.className = "media_details_settings_imagerotate"; + imageRotate.dataset.edit = "true"; + imageRotate.dataset.id = item.id; + console.log(properties); + imageRotate.value = properties?.rotate ?? 0; + mediaDetailsSettings.appendChild(imageRotate); + document.querySelector(".media_details_settings_save").removeAttribute("hidden"); + }); + } else { + $('.media_details_settings[data-type="none"]').show(); + } + // handle buttons // we can download if we're the owner, or we have the download_media permission @@ -223,3 +246,27 @@ OB.Media.detailsPage = function (id) { $("#media_details_used").show(); }); }; + +OB.MediaDetails.saveSettings = async function () { + $("#media_details_settings_saved").hide(); + + const rotateField = document.querySelector(".media_details_settings_imagerotate"); + + if (rotateField) { + const data = {}; + data.properties = { + rotate: rotateField.value, + }; + + OB.API.post( + "media", + "save_properties", + { id: OB.MediaDetails.currentId, properties: data.properties }, + function (response) { + if (response.status == true) { + $("#media_details_settings_saved").show(); + } + }, + ); + } +}; diff --git a/js/sidebar.js b/js/sidebar.js index 53908b4..141ce60 100644 --- a/js/sidebar.js +++ b/js/sidebar.js @@ -699,7 +699,7 @@ OB.Sidebar.contextMenuDownload = function (id) { }; OB.Sidebar.contextMenuDetailsPage = function (id) { - OB.Media.detailsPage(id); + OB.MediaDetails.page(id); }; OB.Sidebar.contextMenuVersionPage = function (id, title) { diff --git a/models/media_model.php b/models/media_model.php index 18ce33e..8714076 100644 --- a/models/media_model.php +++ b/models/media_model.php @@ -1306,6 +1306,44 @@ public function validate($args = []) return [true,$item['local_id']]; } + /** + * Get or set a media property. + * + * @param item + * + * @return id + */ + public function properties($args = []) + { + OBFHelpers::require_args($args, ['id']); + $media_id = $args['id']; + $properties = $args['properties'] ?? null; + + + $this->db->where('id', $media_id); + $media = $this->db->get_one('media'); + + if (!$media) { + return false; + } + + if ($properties) { + $this->db->where('id', $media_id); + return $this->db->update('media', ['properties' => json_encode($properties)]); + } else { + $properties = $media['properties']; + + if ($properties) { + $properties = json_decode($properties, true); + } else { + $properties = []; + } + + return $properties; + } + } + + /** * Insert or update a media item. * diff --git a/routes.json b/routes.json index 6ea783f..259ea65 100644 --- a/routes.json +++ b/routes.json @@ -75,6 +75,11 @@ "media", "search" ], + [ + "/api/v2/media/properties/(:id:)", + "media", + "get_properties" + ], [ "/api/v2/media/versions/(:media_id:)", "media", @@ -357,6 +362,11 @@ "media", "save" ], + [ + "/api/v2/media/properties/(:id:)", + "media", + "save_properties" + ], [ "/api/v2/media/versions", "media", diff --git a/ui/fields/imageRotate.js b/ui/fields/imageRotate.js new file mode 100644 index 0000000..c997264 --- /dev/null +++ b/ui/fields/imageRotate.js @@ -0,0 +1,111 @@ +import { OBField } from "../base/field.js"; +import { html, render } from "../vendor.js"; + +class OBFieldImageRotate extends OBField { + _value = 0; + + async renderEdit() { + render( + html` +
+
+
${this._value}°
+
+
`, + this.root, + ); + + this.root.querySelector(".controls-left button").addEventListener("click", this.rotateLeft.bind(this)); + this.root.querySelector(".controls-right button").addEventListener("click", this.rotateRight.bind(this)); + } + + scss() { + return ` + ob-element-thumbnail { + max-width: 300px; + aspect-ratio: 1 / 1; + display: block; + border: 1px solid var(--field-background); + border-radius: 5px; + } + + ob-element-thumbnail[data-rotate="90"] { + transform: rotate(90deg); + } + + ob-element-thumbnail[data-rotate="180"] { + transform: rotate(180deg); + } + + ob-element-thumbnail[data-rotate="270"] { + transform: rotate(270deg); + } + + .controls { + display: flex; + justify-content: space-between; + margin-top: 10px; + } + + .controls-left, .controls-right { + flex: 1 1 0; + display: flex; + } + + .controls-right { + justify-content: flex-end; + } + + :host { + display: inline-block; + + input { + color: #2e3436; + font-size: 13px; + border-radius: 2px; + border: 0; + padding: 5px; + width: 250px; + vertical-align: middle; + } + } + `; + } + + get value() { + return this._value; + } + + set value(value) { + this._value = value; + + const thumbnail = this.root.querySelector("ob-element-thumbnail"); + if (thumbnail) { + thumbnail.dataset.rotate = value; + } + + const label = this.root.querySelector(".controls-label"); + if (label) { + label.innerText = `${value}°`; + } + } + + rotateLeft() { + let rotate = (parseInt(this._value) - 90) % 360; + if (rotate < 0) { + rotate += 360; + } + this.value = rotate; + } + + rotateRight() { + const rotate = (parseInt(this._value) + 90) % 360; + this.value = rotate; + } +} + +customElements.define("ob-field-image-rotate", OBFieldImageRotate); diff --git a/updates/20241122.php b/updates/20241122.php new file mode 100644 index 0000000..4ea359e --- /dev/null +++ b/updates/20241122.php @@ -0,0 +1,20 @@ +db->query(' + ALTER TABLE `media` ADD COLUMN `properties` TEXT NULL DEFAULT NULL AFTER `dynamic_select`; + '); + + return true; + } +} From 217437f12780f7f00d9d9047f0720453b4fa6806 Mon Sep 17 00:00:00 2001 From: Brook Gagnon Date: Mon, 25 Nov 2024 18:48:44 +0000 Subject: [PATCH 2/2] add rotate support to thumbnail generator. update imageRotate field to support an initial offset. --- VERSION | 2 +- classes/core/obfhelpers.php | 4 +++- js/media/details.js | 2 +- models/media_model.php | 48 ++++++++++++++++++++++++++++++++++++- ui/fields/imageRotate.js | 24 +++++++++++++++++-- 5 files changed, 74 insertions(+), 6 deletions(-) diff --git a/VERSION b/VERSION index c9c162c..201d38c 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -5.3.3-20241113 +5.3.3-20241125 diff --git a/classes/core/obfhelpers.php b/classes/core/obfhelpers.php index 208d177..267f2dd 100644 --- a/classes/core/obfhelpers.php +++ b/classes/core/obfhelpers.php @@ -164,7 +164,7 @@ public static function image_format($filename) * @param width Target width. * @param height Target height. */ - public static function image_resize($src, $dst, $width, $height) + public static function image_resize($src, $dst, $width, $height, $rotate = 0) { if (!file_exists($src)) { trigger_error('The source file does not exist', E_USER_WARNING); @@ -208,6 +208,8 @@ public static function image_resize($src, $dst, $width, $height) $im->setImageBackgroundColor('white'); // Set white background if necessary $im->setImageAlphaChannel(Imagick::ALPHACHANNEL_REMOVE); // Remove alpha channel $im->mergeImageLayers(Imagick::LAYERMETHOD_FLATTEN); // Flatten image + $im->rotateImage('white', $rotate); + $im->thumbnailImage($width, $height, true); // Adjust width and height as necessary (maintain aspect ratio) // Set the image format and apply lossy compression diff --git a/js/media/details.js b/js/media/details.js index d0b827c..11a20e4 100644 --- a/js/media/details.js +++ b/js/media/details.js @@ -42,7 +42,7 @@ OB.MediaDetails.page = function (id) { imageRotate.dataset.edit = "true"; imageRotate.dataset.id = item.id; console.log(properties); - imageRotate.value = properties?.rotate ?? 0; + imageRotate.value = imageRotate.offset = properties?.rotate ?? 0; mediaDetailsSettings.appendChild(imageRotate); document.querySelector(".media_details_settings_save").removeAttribute("hidden"); }); diff --git a/models/media_model.php b/models/media_model.php index 8714076..8f34f94 100644 --- a/models/media_model.php +++ b/models/media_model.php @@ -243,6 +243,7 @@ public function get_init_what($args = []) $this->db->what('media.status', 'status'); $this->db->what('media.dynamic_select', 'dynamic_select'); $this->db->what('users.display_name', 'owner_name'); + $this->db->what('media.properties', 'properties'); foreach ($args['metadata_fields'] as $metadata_field) { // add the field to the select portion of the query @@ -334,6 +335,13 @@ public function thumbnail_file($args = []) return false; } + $media_properties = json_decode($media['properties'], true); + if ($media_properties && $media_properties['rotate']) { + $rotate = $media_properties['rotate']; + } else { + $rotate = null; + } + // first search for a cached version of our resized thumbnail $cache_directory = OB_CACHE . '/thumbnails/media/' . $media['file_location'][0] . '/' . $media['file_location'][1]; $thumbnail_files = glob($cache_directory . '/' . $media['id'] . '.*'); @@ -365,7 +373,7 @@ public function thumbnail_file($args = []) $output_file = $output_dir . '/' . $media['id'] . '.webp'; // resize our image to a webp thumbnail - OBFHelpers::image_resize($input_file, $output_file, 600, 600); + OBFHelpers::image_resize($input_file, $output_file, 600, 600, $rotate); // return our file if it exists now if (file_exists($output_file)) { @@ -376,6 +384,43 @@ public function thumbnail_file($args = []) return false; } + /** + * Clear thumbnail cache. + * + * @param media ID or media row array. + */ + public function thumbnail_clear($args = []) + { + OBFHelpers::require_args($args, ['media']); + + if (!is_array($args['media'])) { + $this->db->where('id', $args['media']); + $media = $this->db->get_one('media'); + + if (!$media) { + return false; + } + } else { + $media = $args['media']; + } + + OBFHelpers::require_args($media, ['type', 'is_archived', 'is_approved', 'file_location']); + if (strlen($media['file_location']) != 2) { + trigger_error('Invalid media file location.', E_USER_WARNING); + return false; + } + + // first search for a cached version of our resized thumbnail + $cache_directory = OB_CACHE . '/thumbnails/media/' . $media['file_location'][0] . '/' . $media['file_location'][1]; + $thumbnail_files = glob($cache_directory . '/' . $media['id'] . '.*'); + + foreach ($thumbnail_files as $thumbnail_file) { + unlink($thumbnail_file); + } + + return true; + } + /** * Get permissions linked to a media item. * @@ -1328,6 +1373,7 @@ public function properties($args = []) } if ($properties) { + $this->thumbnail_clear(['media' => $media_id]); $this->db->where('id', $media_id); return $this->db->update('media', ['properties' => json_encode($properties)]); } else { diff --git a/ui/fields/imageRotate.js b/ui/fields/imageRotate.js index c997264..cdc6610 100644 --- a/ui/fields/imageRotate.js +++ b/ui/fields/imageRotate.js @@ -3,6 +3,7 @@ import { html, render } from "../vendor.js"; class OBFieldImageRotate extends OBField { _value = 0; + _offset = 0; // how much the thumbnail is already rotated async renderEdit() { render( @@ -21,6 +22,8 @@ class OBFieldImageRotate extends OBField { this.root.querySelector(".controls-left button").addEventListener("click", this.rotateLeft.bind(this)); this.root.querySelector(".controls-right button").addEventListener("click", this.rotateRight.bind(this)); + + this.updateImage(); } scss() { @@ -76,21 +79,38 @@ class OBFieldImageRotate extends OBField { `; } + get offset() { + return this._offset; + } + + set offset(offset) { + this._offset = offset; + this.updateImage(); + } + get value() { return this._value; } set value(value) { this._value = value; + this.updateImage(); + } + + updateImage() { + let rotate = (parseInt(this._value) - parseInt(this._offset)) % 360; + if (rotate < 0) { + rotate += 360; + } const thumbnail = this.root.querySelector("ob-element-thumbnail"); if (thumbnail) { - thumbnail.dataset.rotate = value; + thumbnail.dataset.rotate = rotate; } const label = this.root.querySelector(".controls-label"); if (label) { - label.innerText = `${value}°`; + label.innerText = `${this._value}°`; } }