From 013d02a15c5d12ad76b5049acb23a74f98e056f0 Mon Sep 17 00:00:00 2001 From: Kumi Date: Thu, 14 Mar 2024 17:28:14 +0100 Subject: [PATCH] Enhance editor UI and user content management Introduced significant updates to the user interface for editing VR scenes, adding new CSS styles for a coherent and modern look. Implemented data tables for robust content management in the user area, now users can easily navigate through scenes and media with DataTables integration. Expanded the API with category retrieval capabilities, enabling dynamic content categorization. The editor now seamlessly integrates into the UI with a sidebar for properties editing, improving usability. The teleportation element creation and modification logic has been significantly refined, including a search-enabled dropdown for destination selection, making it more user-friendly. Added thumbnail display for scenes and media in the user area, enhancing content overview. This update also introduced user area templates and routes, providing a foundational structure for user content management functionality, including categories and individual category views. Refactored JavaScript imports to align with the new editor CSS and adjusted scene loading to support embedded scenes, improving the flexibility and usability of scene components. --- assets/css/editor.css | 69 ++++++++ assets/css/userarea.css | 165 ++++++++++++++++++ assets/js/api.js | 16 +- assets/js/editor.js | 127 ++++++++++---- assets/js/editor/teleport.js | 86 +++++++++ assets/js/scene.js | 16 +- assets/js/userarea.js | 7 + package-lock.json | 24 +++ package.json | 2 + quackscape/settings.py | 4 + quackscape/tours/models.py | 12 +- quackscape/tours/serializers.py | 13 +- .../tours/templates/tours/scene_edit.html | 76 ++++---- quackscape/tours/urls.py | 4 +- quackscape/tours/views.py | 18 +- quackscape/urls.py | 1 + quackscape/users/templates/users/base.html | 35 ++++ .../users/templates/users/categories.html | 18 ++ .../users/templates/users/category.html | 104 +++++++++++ quackscape/users/urls.py | 12 ++ quackscape/users/views.py | 47 ++++- webpack.config.js | 1 + 22 files changed, 780 insertions(+), 77 deletions(-) create mode 100644 assets/css/editor.css create mode 100644 assets/css/userarea.css create mode 100644 assets/js/editor/teleport.js create mode 100644 assets/js/userarea.js create mode 100644 quackscape/users/templates/users/base.html create mode 100644 quackscape/users/templates/users/categories.html create mode 100644 quackscape/users/templates/users/category.html create mode 100644 quackscape/users/urls.py diff --git a/assets/css/editor.css b/assets/css/editor.css new file mode 100644 index 0000000..7c124cb --- /dev/null +++ b/assets/css/editor.css @@ -0,0 +1,69 @@ +body { + background-color: #2d2d30; + color: #ccc; + font-family: "Courier New", monospace; +} + +.scene-container { + height: 100vh; + background-color: #252526; + border: 5px solid #3c3c3c; +} + +.sidebar { + background-color: #1e1e1e; + color: #9cdcfe; + padding: 20px; + border-left: 1px solid #3c3c3c; + height: 100vh; /* Full height */ + overflow-y: auto; /* Enable vertical scrolling if necessary */ +} + +.sidebar h3 { + color: #dcdcaa; +} + +.form-control { + background-color: #333333; + color: #9cdcfe; + border: 1px solid #3c3c3c; +} + +.form-label { + color: #dcdcaa; +} + +.btn-primary { + background-color: #007acc; + border-color: #007acc; +} + +.btn-secondary { + background-color: #3c3c3c; + border-color: #3c3c3c; +} + +.dropdown-item { + display: flex; + align-items: center; + white-space: nowrap; + overflow: hidden; + max-width: 100%; +} + +.dropdown-item img { + width: 128px; + height: 64px; + margin-right: 10px; + flex-shrink: 0; +} + +.dropdown-item-text { + display: block; + text-overflow: ellipsis; + overflow: hidden; +} + +.dropdown-menu { + max-width: 100%; +} \ No newline at end of file diff --git a/assets/css/userarea.css b/assets/css/userarea.css new file mode 100644 index 0000000..b94a06d --- /dev/null +++ b/assets/css/userarea.css @@ -0,0 +1,165 @@ +body { + background-color: #2d2d30; + color: #ccc; + font-family: "Courier New", monospace; +} + +.admin-header { + background-color: #333333; + padding: 10px 20px; + color: #ffffff; + display: flex; + justify-content: space-between; + align-items: center; +} + +.user-info { + color: #9cdcfe; + text-align: right; +} + +.admin-sidebar { + background-color: #1e1e1e; + color: #9cdcfe; + padding: 20px; + height: calc(100vh - 50px); + overflow-y: auto; +} + +.admin-main { + background-color: #252526; + color: #ccc; + padding: 20px; + min-height: calc(100vh - 50px); +} + +.admin-sidebar a { + color: #9cdcfe; + text-decoration: none; + padding: 10px; + display: block; +} + +.admin-sidebar a:hover { + background-color: #333333; +} + +.form-control, +.btn-primary, +.btn-secondary { + background-color: #333333; + color: #9cdcfe; + border: 1px solid #3c3c3c; +} + +.btn-primary { + background-color: #007acc; + border-color: #007acc; +} + +.btn-secondary { + background-color: #3c3c3c; + border-color: #3c3c3c; +} + +.nav-tabs { + border-bottom: 1px solid #444; +} +.nav-tabs .nav-link { + border: 1px solid transparent; + border-radius: 0.25rem; + background-color: #333; + color: #9cdcfe; + margin-right: 2px; +} +.nav-tabs .nav-link.active { + color: #fff; + background-color: #007acc; + border-color: #444 #444 #fff; +} +.nav-tabs .nav-link:hover { + border-color: #555 #555 #444; +} +.tab-content { + background-color: #252526; + color: #ccc; + border: 1px solid #444; + padding: 20px; + border-radius: 0.25rem; +} + +.dataTables_wrapper { + font-family: 'Courier New', monospace; + color: #9CDCFE; + font-size: 0.9rem; + margin: 20px 0; +} + +.dataTables_wrapper .dataTables_filter input, +.dataTables_wrapper .dataTables_length select { + color: #000; + padding: 0.5rem; + border: 1px solid #3C3C3C; + background-color: #fff; + border-radius: 0.25rem; + margin-left: 10px; +} + +.dataTables_wrapper .dataTables_paginate .paginate_button { + padding: 0.5rem 1rem; + background-color: #333; + border: 1px solid #3C3C3C; + border-radius: 0.25rem; + margin-left: 5px; + color: #9CDCFE; + cursor: pointer; +} + +.dataTables_wrapper .dataTables_paginate .paginate_button:hover { + background-color: #007ACC; + color: #fff; +} + +.dataTables_wrapper .dataTables_paginate .paginate_button.current, +.dataTables_wrapper .dataTables_paginate .paginate_button.current:hover { + background-color: #007ACC; + color: #fff; +} + +table.dataTable thead th, +table.dataTable thead td { + padding: 15px 10px; + border-bottom: 1px solid #444; + color: #DCDCAA; +} + +table.dataTable tbody tr { + background-color: #252526; +} + +table.dataTable tbody tr:hover { + background-color: #333333; +} + +table.dataTable tbody td { + padding: 15px 10px; + border-color: #444; +} + +table.dataTable thead .sorting:before, +table.dataTable thead .sorting_asc:before, +table.dataTable thead .sorting_desc:before, +table.dataTable thead .sorting_asc_disabled:before, +table.dataTable thead .sorting_desc_disabled:before, +table.dataTable thead .sorting:after, +table.dataTable thead .sorting_asc:after, +table.dataTable thead .sorting_desc:after, +table.dataTable thead .sorting_asc_disabled:after, +table.dataTable thead .sorting_desc_disabled:after { + margin-top: 0.5em; + font-size: 0.8em; +} + +table.dataTable { + width: 100% !important; +} \ No newline at end of file diff --git a/assets/js/api.js b/assets/js/api.js index 73d1ace..29abb52 100644 --- a/assets/js/api.js +++ b/assets/js/api.js @@ -37,4 +37,18 @@ function getSceneElement(scene_uuid, uuid) { ); } -export { getScene, getSceneElement }; +function getCategory(category) { + + return api + .then( + (client) => + client.apis.tours.tours_api_categories_retrieve({ id: category }), + (reason) => console.error("Failed to load OpenAPI spec: " + reason) + ) + .then( + (result) => result, + (reason) => console.error("Failed to execute API call: " + reason) + ); +} + +export { getScene, getSceneElement, getCategory }; diff --git a/assets/js/editor.js b/assets/js/editor.js index b125c1d..aaccd38 100644 --- a/assets/js/editor.js +++ b/assets/js/editor.js @@ -1,9 +1,9 @@ -import { getScene, getSceneElement } from "./api"; +import { getScene, getSceneElement, getCategory } from "./api"; +import { populateDestinationDropdown } from "./editor/teleport"; -import { Modal } from "bootstrap"; +import "../css/editor.css"; let clickTimestamp = 0; -var editModal = null; // Find parent quackscape-scene for ID function findParentScene(element) { @@ -16,10 +16,6 @@ function findParentScene(element) { return parent; } -document.addEventListener("DOMContentLoaded", function () { - editModal = new Modal("#editModal"); -}); - // Distinguishing clicks from drags based on duration. // TODO: Find a better way to distinguish these. function addEventListeners(element) { @@ -44,11 +40,11 @@ function addEventListeners(element) { } // Open a modal for creating a new Element -function startCreateModal(event) { - var modalLabel = document.getElementById("editModalLabel"); +function startCreateElement(event) { + var propertiesTitle = document.getElementById("propertiesTitle"); modalLabel.textContent = "Create Element"; - var modalContent = document.getElementById("editModalContent"); + var propertiesContent = document.getElementById("propertiesContent"); var thetaStart = cartesianToTheta( event.detail.intersection.point.x, @@ -65,21 +61,20 @@ function startCreateModal(event) {
- + +
`; - - editModal.show(); } -function startModifyModal(event) { - var modalLabel = document.getElementById("editModalLabel"); - modalLabel.textContent = "Modify Element"; +function startModifyElement(event) { + var propertiesTitle = document.getElementById("propertiesTitle"); + propertiesTitle.textContent = "Modify Element"; // Get element from API var scene = findParentScene(event.target); @@ -90,21 +85,93 @@ function startModifyModal(event) { ); element_data_request.then((element_data) => { - console.log(element_data); + var propertiesContent = document.getElementById("propertiesContent"); - var modalContent = document.getElementById("editModalContent"); - - modalContent.innerHTML = `Modifying element:
+ propertiesContent.innerHTML = `Modifying element:
Element Type: ${event.srcElement.tagName}
Element ID: ${event.target.getAttribute("id")}
Element data: ${JSON.stringify(element_data.obj)}

+ + + +
+ +
+
+ +
+
+ +
+
+ +
+
+
+ + +
+ + +
+
`; - editModal.show(); + var scene_data_request = getScene(scene.getAttribute("id")); + scene_data_request.then((scene_data) => { + var category_data_request = getCategory(scene_data.obj.category); + + category_data_request.then((category_data) => { + populateDestinationDropdown( + element_data.obj.destination, + category_data + ); + }); + }); + + document + .getElementById("elementUpload") + .addEventListener("change", function () { + if (this.files && this.files[0]) { + var reader = new FileReader(); + + reader.onload = function (e) { + // Set the image to the file contents + if (event.target.getAttribute("data-original-src") == undefined) { + event.target.setAttribute("data-original-src", event.target.getAttribute("src")); + } + event.target.setAttribute("src", e.target.result); + }; + reader.readAsDataURL(this.files[0]); + } + }); + + if (element_data.obj.destination_x != -1) { + document.getElementById("destination_x").value = + element_data.obj.destination_x; + } + if (element_data.obj.destination_y != -1) { + document.getElementById("destination_y").value = + element_data.obj.destination_y; + } + if (element_data.obj.destination_z != -1) { + document.getElementById("destination_z").value = + element_data.obj.destination_z; + } + + document.getElementById("resetButton").style = "display: inline;"; + document.getElementById("buttons").style = "display: block;"; }); } @@ -138,12 +205,12 @@ function latLonToXYZ(lat, lon, radius = 5) { } function handleClick(event) { - console.log(event); - + // If clicked on the sky, start creating a new element if (event.target.tagName == "A-SKY") { - startCreateModal(event); + startCreateElement(event); } else { - startModifyModal(event); + // Else we are modifying an existing element + startModifyElement(event); } } diff --git a/assets/js/editor/teleport.js b/assets/js/editor/teleport.js new file mode 100644 index 0000000..e8872a6 --- /dev/null +++ b/assets/js/editor/teleport.js @@ -0,0 +1,86 @@ +import { Dropdown } from "bootstrap"; + +function selectDestinationItem(item) { + // Set the input value to the selected scene's UUID + const destinationField = document.getElementById("destination"); + destinationField.value = item.id; + + const dropdownSearch = document.getElementById("destinationDropdownSearch"); + dropdownSearch.value = item.title; +} + +function populateDestinationDropdown(initial, category_data) { + const items = category_data.obj.scenes; + + const dropdownMenu = document.getElementById("destinationDropdownMenu"); + const dropdownSearch = document.getElementById("destinationDropdownSearch"); + const dropdown = new Dropdown(dropdownSearch); + + if (initial != null) { + // Get object from items by id + const initial_item = items.find((item) => item.id === initial); + selectDestinationItem(initial_item); + } + + document.addEventListener("click", function (event) { + // Check if the click is outside the dropdownSearch input and dropdownMenu + if ( + !dropdownSearch.contains(event.target) && + !dropdownMenu.contains(event.target) + ) { + dropdown.hide(); + } + }); + + items.forEach((item) => { + // First, order the resolutions by width + var resolutions = item.base_content.resolutions.sort( + (a, b) => a.width - b.width + ); + + // Then take the first object as thumbnail + item.img = resolutions[0].file; + + const element = document.createElement("button"); + element.classList.add("dropdown-item"); + element.innerHTML = `${item.title}${item.title}`; + element.onclick = function () { + selectDestinationItem(item); + dropdown.hide(); + }; + dropdownMenu.appendChild(element); + + dropdownSearch.addEventListener("keyup", function () { + const searchValue = dropdownSearch.value.toLowerCase(); + const filteredItems = items.filter((item) => + item.title.toLowerCase().includes(searchValue) + ); + + // Clear existing items + dropdownMenu.innerHTML = ""; + + // Repopulate dropdown with filtered items + filteredItems.forEach((item) => { + const element = document.createElement("button"); + element.classList.add("dropdown-item"); + element.innerHTML = `${item.title} ${item.title}`; + element.onclick = function () { + selectDestinationItem(item); + dropdown.hide(); + }; + dropdownMenu.appendChild(element); + }); + if (filteredItems.length) { + dropdown.show(); + } else { + dropdown.hide(); + } + }); + }); + + dropdownSearch.addEventListener("click", function (event) { + dropdown.show(); + }); +} + +export { populateDestinationDropdown }; diff --git a/assets/js/scene.js b/assets/js/scene.js index c07faa1..c305984 100644 --- a/assets/js/scene.js +++ b/assets/js/scene.js @@ -12,11 +12,12 @@ class QuackscapeScene extends HTMLElement { connectedCallback() { // When one is created, automatically load the scene this.scene = this.getAttribute("scene"); + this.embedded = this.getAttribute("embedded") != undefined; this.x = this.getAttribute("x") | 0; this.y = this.getAttribute("y") | 0; this.z = this.getAttribute("z") | 0; - loadScene(this.scene, this.x, this.y, this.z, this); + loadScene(this.scene, this.x, this.y, this.z, this, this.embedded); } } @@ -29,7 +30,14 @@ customElements.define("quackscape-scene", QuackscapeScene); // Function to load a scene into a destination object // x and y signify the initial looking direction, -1 for the scene's default -async function loadScene(scene_id, x = -1, y = -1, z = -1, destination = null) { +async function loadScene( + scene_id, + x = -1, + y = -1, + z = -1, + destination = null, + embedded = false +) { // Get WebGL maximum texture size var canvas = document.createElement("canvas"); var gl = @@ -78,6 +86,10 @@ async function loadScene(scene_id, x = -1, y = -1, z = -1, destination = null) { var a_scene = document.createElement("a-scene"); a_scene.setAttribute("cursor", "rayOrigin: mouse"); + if (embedded) { + a_scene.setAttribute("embedded", "embedded"); + } + // Create a-camera element var rig = document.createElement("a-entity"); rig.setAttribute("id", "rig"); diff --git a/assets/js/userarea.js b/assets/js/userarea.js new file mode 100644 index 0000000..05d9c57 --- /dev/null +++ b/assets/js/userarea.js @@ -0,0 +1,7 @@ +import '../css/userarea.css'; + +import { Tab } from 'bootstrap'; +import DataTable from 'datatables.net-dt'; + +let mediaTable = new DataTable('#mediaTable'); +let scenesTable = new DataTable('#scenesTable'); \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 7535dae..70c4504 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,8 @@ "@popperjs/core": "^2.11.8", "aframe": "^1.5.0", "bootstrap": "^5.3.3", + "datatables.net-dt": "^2.0.2", + "jquery": "^3.7.1", "swagger-client": "^3.26.0" }, "devDependencies": { @@ -3316,6 +3318,23 @@ "resolved": "https://registry.npmjs.org/custom-event-polyfill/-/custom-event-polyfill-1.0.7.tgz", "integrity": "sha512-TDDkd5DkaZxZFM8p+1I3yAlvM3rSr1wbrOliG4yJiwinMZN8z/iGL7BTlDkrJcYTmgUSb4ywVCc3ZaUtOtC76w==" }, + "node_modules/datatables.net": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/datatables.net/-/datatables.net-2.0.2.tgz", + "integrity": "sha512-uQM+s1oXhpTajUw5DCxarpfAtQJMh0MKFCLZMCc+UWLWPg8ipe6L2zgDMbRC8n9UCwGVpHOwUYlQu2JU1/PhSg==", + "dependencies": { + "jquery": ">=1.7" + } + }, + "node_modules/datatables.net-dt": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/datatables.net-dt/-/datatables.net-dt-2.0.2.tgz", + "integrity": "sha512-/Zpy7ZWGgCbCJB9qJZvaB+KFrdo+8JjjOdxL59/QiG3jquz2ZCsE9u814kbaGKTM1ARkvZVUxDB9IlH5qmHaqw==", + "dependencies": { + "datatables.net": "2.0.2", + "jquery": ">=1.7" + } + }, "node_modules/debug": { "version": "4.3.4", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", @@ -4281,6 +4300,11 @@ "jiti": "bin/jiti.js" } }, + "node_modules/jquery": { + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/jquery/-/jquery-3.7.1.tgz", + "integrity": "sha512-m4avr8yL8kmFN8psrbFFFmB/If14iN5o9nw/NgnnM+kybDJpRsAynV2BsfpTYrTRysYUdADVD7CkUUizgkpLfg==" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", diff --git a/package.json b/package.json index bad1747..485f761 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,8 @@ "@popperjs/core": "^2.11.8", "aframe": "^1.5.0", "bootstrap": "^5.3.3", + "datatables.net-dt": "^2.0.2", + "jquery": "^3.7.1", "swagger-client": "^3.26.0" } } diff --git a/quackscape/settings.py b/quackscape/settings.py index 1ee4b7b..ebd37d0 100644 --- a/quackscape/settings.py +++ b/quackscape/settings.py @@ -204,6 +204,10 @@ CELERY_RESULT_BACKEND = 'django-db' QUACKSCAPE_CONTENT_RESOLUTIONS = [ # A list of allowed resolutions for content. Width must be twice the height. + # The apparently absurdly small ones are thumbnails. + (128, 64), + (256, 128), + (512, 256), (1024, 512), (2048, 1024), (4096, 2048), diff --git a/quackscape/tours/models.py b/quackscape/tours/models.py index 8412f11..8ceb5b5 100644 --- a/quackscape/tours/models.py +++ b/quackscape/tours/models.py @@ -191,7 +191,7 @@ class OriginalMedia(PolymorphicModel): file = models.FileField(upload_to=upload_to) width = models.IntegerField(blank=True, null=True) height = models.IntegerField(blank=True, null=True) - category = models.ForeignKey(Category, on_delete=models.SET_NULL, null=True) + category = models.ForeignKey(Category, related_name="media", on_delete=models.SET_NULL, null=True) def __str__(self): return self.title @@ -200,6 +200,10 @@ class OriginalMedia(PolymorphicModel): def media_type(self) -> str: raise NotImplementedError("Subclasses must implement this method") + @property + def thumbnail(self) -> str: + return self.resolutions.all().order_by("width").first() + class OriginalImage(OriginalMedia): def media_type(self) -> str: @@ -243,9 +247,13 @@ class Scene(models.Model): default_x = models.FloatField(default=0.0) default_y = models.FloatField(default=0.0) default_z = models.FloatField(default=0.0) - category = models.ForeignKey(Category, on_delete=models.SET_NULL, null=True) + category = models.ForeignKey(Category, related_name="scenes", on_delete=models.SET_NULL, null=True) public = models.BooleanField(default=True) + @property + def thumbnail(self): + return self.base_content.resolutions.order_by("width").first() + def user_has_permission(self, user): return ( user.is_superuser diff --git a/quackscape/tours/serializers.py b/quackscape/tours/serializers.py index 96779ff..e6dae2a 100644 --- a/quackscape/tours/serializers.py +++ b/quackscape/tours/serializers.py @@ -9,6 +9,7 @@ from .models import ( TeleportElement, TextElement, ImageElement, + Category ) @@ -28,6 +29,7 @@ class TeleportElementSerializer(serializers.ModelSerializer): "destination", "destination_x", "destination_y", + "destination_z", "thetaStart", "thetaLength" ] @@ -81,4 +83,13 @@ class SceneSerializer(serializers.ModelSerializer): class Meta: model = Scene - fields = ["id", "title", "description", "base_content", "elements"] + fields = ["id", "title", "description", "base_content", "elements", "category"] + + +class CategorySerializer(serializers.ModelSerializer): + media = OriginalMediaSerializer(many=True, read_only=True) + scenes = SceneSerializer(many=True, read_only=True) + + class Meta: + model = Category + fields = ["id", "title", "media", "scenes"] \ No newline at end of file diff --git a/quackscape/tours/templates/tours/scene_edit.html b/quackscape/tours/templates/tours/scene_edit.html index 01e61bd..dff8f50 100644 --- a/quackscape/tours/templates/tours/scene_edit.html +++ b/quackscape/tours/templates/tours/scene_edit.html @@ -25,47 +25,51 @@ > - +
+
+ +
+ +
-