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}`;
+ 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}`;
+ 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 @@
>
-
+