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 @@ > - +
+
+ +
+ +
-