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.
This commit is contained in:
parent
06a00262a0
commit
013d02a15c
22 changed files with 780 additions and 77 deletions
69
assets/css/editor.css
Normal file
69
assets/css/editor.css
Normal file
|
@ -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%;
|
||||||
|
}
|
165
assets/css/userarea.css
Normal file
165
assets/css/userarea.css
Normal file
|
@ -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;
|
||||||
|
}
|
|
@ -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 };
|
||||||
|
|
|
@ -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;
|
let clickTimestamp = 0;
|
||||||
var editModal = null;
|
|
||||||
|
|
||||||
// Find parent quackscape-scene for ID
|
// Find parent quackscape-scene for ID
|
||||||
function findParentScene(element) {
|
function findParentScene(element) {
|
||||||
|
@ -16,10 +16,6 @@ function findParentScene(element) {
|
||||||
return parent;
|
return parent;
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener("DOMContentLoaded", function () {
|
|
||||||
editModal = new Modal("#editModal");
|
|
||||||
});
|
|
||||||
|
|
||||||
// Distinguishing clicks from drags based on duration.
|
// Distinguishing clicks from drags based on duration.
|
||||||
// TODO: Find a better way to distinguish these.
|
// TODO: Find a better way to distinguish these.
|
||||||
function addEventListeners(element) {
|
function addEventListeners(element) {
|
||||||
|
@ -44,11 +40,11 @@ function addEventListeners(element) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Open a modal for creating a new Element
|
// Open a modal for creating a new Element
|
||||||
function startCreateModal(event) {
|
function startCreateElement(event) {
|
||||||
var modalLabel = document.getElementById("editModalLabel");
|
var propertiesTitle = document.getElementById("propertiesTitle");
|
||||||
modalLabel.textContent = "Create Element";
|
modalLabel.textContent = "Create Element";
|
||||||
|
|
||||||
var modalContent = document.getElementById("editModalContent");
|
var propertiesContent = document.getElementById("propertiesContent");
|
||||||
|
|
||||||
var thetaStart = cartesianToTheta(
|
var thetaStart = cartesianToTheta(
|
||||||
event.detail.intersection.point.x,
|
event.detail.intersection.point.x,
|
||||||
|
@ -65,21 +61,20 @@ function startCreateModal(event) {
|
||||||
<hr/>
|
<hr/>
|
||||||
|
|
||||||
<form id="newElementForm">
|
<form id="newElementForm">
|
||||||
<select class="form-select" aria-label="Default select example">
|
<label for="resourcetype" class="form-label">Element Type</label>
|
||||||
|
<select class="form-control" aria-label="Element Type" id="resourcetype">
|
||||||
<option selected>Select Element Type</option>
|
<option selected>Select Element Type</option>
|
||||||
<option value="1">Marker</option>
|
<option value="MarkerElement">Marker</option>
|
||||||
<option value="2">Image</option>
|
<option value="ImageElement">Image</option>
|
||||||
<option value="3">Teleport</option>
|
<option value="ImageElement">Teleport</option>
|
||||||
</select>
|
</select>
|
||||||
</form>
|
</form>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
editModal.show();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function startModifyModal(event) {
|
function startModifyElement(event) {
|
||||||
var modalLabel = document.getElementById("editModalLabel");
|
var propertiesTitle = document.getElementById("propertiesTitle");
|
||||||
modalLabel.textContent = "Modify Element";
|
propertiesTitle.textContent = "Modify Element";
|
||||||
|
|
||||||
// Get element from API
|
// Get element from API
|
||||||
var scene = findParentScene(event.target);
|
var scene = findParentScene(event.target);
|
||||||
|
@ -90,21 +85,93 @@ function startModifyModal(event) {
|
||||||
);
|
);
|
||||||
|
|
||||||
element_data_request.then((element_data) => {
|
element_data_request.then((element_data) => {
|
||||||
console.log(element_data);
|
var propertiesContent = document.getElementById("propertiesContent");
|
||||||
|
|
||||||
var modalContent = document.getElementById("editModalContent");
|
propertiesContent.innerHTML = `<b>Modifying element:</b><br/>
|
||||||
|
|
||||||
modalContent.innerHTML = `<b>Modifying element:</b><br/>
|
|
||||||
Element Type: ${event.srcElement.tagName}<br/>
|
Element Type: ${event.srcElement.tagName}<br/>
|
||||||
Element ID: ${event.target.getAttribute("id")}<br/>
|
Element ID: ${event.target.getAttribute("id")}<br/>
|
||||||
Element data: ${JSON.stringify(element_data.obj)}<br/>
|
Element data: ${JSON.stringify(element_data.obj)}<br/>
|
||||||
<hr/>
|
<hr/>
|
||||||
|
|
||||||
<form id="modifyElementForm">
|
<form id="modifyElementForm">
|
||||||
|
<input type="hidden" id="id" value="${event.target.getAttribute("id")}">
|
||||||
|
<div class="dropdown mb-2">
|
||||||
|
<label for="destinationDropdownSearch" class="form-label">Teleport Destination</label>
|
||||||
|
<input class="form-control" autocomplete="off" id="destinationDropdownSearch" type="text" placeholder="Search...">
|
||||||
|
<input type="hidden" id="destination">
|
||||||
|
<div class="dropdown-menu" id="destinationDropdownMenu">
|
||||||
|
<!-- Dropdown items will be populated here by JavaScript -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-2">
|
||||||
|
<label for="destination_x" class="form-label">Destination X/Y/Z Rotation</label>
|
||||||
|
<div class="row g-2">
|
||||||
|
<div class="col">
|
||||||
|
<input type="number" class="form-control" id="destination_x" name="input1" min="0" max="360" placeholder="X">
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<input type="number" class="form-control" id="destination_y" name="input2" min="0" max="360" placeholder="Y">
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<input type="number" class="form-control" id="destination_z" name="input3" min="0" max="360" placeholder="Z">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-2">
|
||||||
|
<label for="elementUpload" class="form-label">Marker image</label>
|
||||||
|
<input id="elementUpload" type="file" class="form-control">
|
||||||
|
</div>
|
||||||
|
|
||||||
</form>
|
</form>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
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) {
|
function handleClick(event) {
|
||||||
console.log(event);
|
// If clicked on the sky, start creating a new element
|
||||||
|
|
||||||
if (event.target.tagName == "A-SKY") {
|
if (event.target.tagName == "A-SKY") {
|
||||||
startCreateModal(event);
|
startCreateElement(event);
|
||||||
} else {
|
} else {
|
||||||
startModifyModal(event);
|
// Else we are modifying an existing element
|
||||||
|
startModifyElement(event);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
86
assets/js/editor/teleport.js
Normal file
86
assets/js/editor/teleport.js
Normal file
|
@ -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 = `<img src="${item.img}" alt="${item.title}"><span class="dropdown-item-text">${item.title}</span>`;
|
||||||
|
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 = `<img src="${item.img}" alt="${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 };
|
|
@ -12,11 +12,12 @@ class QuackscapeScene extends HTMLElement {
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
// When one is created, automatically load the scene
|
// When one is created, automatically load the scene
|
||||||
this.scene = this.getAttribute("scene");
|
this.scene = this.getAttribute("scene");
|
||||||
|
this.embedded = this.getAttribute("embedded") != undefined;
|
||||||
this.x = this.getAttribute("x") | 0;
|
this.x = this.getAttribute("x") | 0;
|
||||||
this.y = this.getAttribute("y") | 0;
|
this.y = this.getAttribute("y") | 0;
|
||||||
this.z = this.getAttribute("z") | 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
|
// Function to load a scene into a destination object
|
||||||
// x and y signify the initial looking direction, -1 for the scene's default
|
// 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
|
// Get WebGL maximum texture size
|
||||||
var canvas = document.createElement("canvas");
|
var canvas = document.createElement("canvas");
|
||||||
var gl =
|
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");
|
var a_scene = document.createElement("a-scene");
|
||||||
a_scene.setAttribute("cursor", "rayOrigin: mouse");
|
a_scene.setAttribute("cursor", "rayOrigin: mouse");
|
||||||
|
|
||||||
|
if (embedded) {
|
||||||
|
a_scene.setAttribute("embedded", "embedded");
|
||||||
|
}
|
||||||
|
|
||||||
// Create a-camera element
|
// Create a-camera element
|
||||||
var rig = document.createElement("a-entity");
|
var rig = document.createElement("a-entity");
|
||||||
rig.setAttribute("id", "rig");
|
rig.setAttribute("id", "rig");
|
||||||
|
|
7
assets/js/userarea.js
Normal file
7
assets/js/userarea.js
Normal file
|
@ -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');
|
24
package-lock.json
generated
24
package-lock.json
generated
|
@ -12,6 +12,8 @@
|
||||||
"@popperjs/core": "^2.11.8",
|
"@popperjs/core": "^2.11.8",
|
||||||
"aframe": "^1.5.0",
|
"aframe": "^1.5.0",
|
||||||
"bootstrap": "^5.3.3",
|
"bootstrap": "^5.3.3",
|
||||||
|
"datatables.net-dt": "^2.0.2",
|
||||||
|
"jquery": "^3.7.1",
|
||||||
"swagger-client": "^3.26.0"
|
"swagger-client": "^3.26.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
@ -3316,6 +3318,23 @@
|
||||||
"resolved": "https://registry.npmjs.org/custom-event-polyfill/-/custom-event-polyfill-1.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/custom-event-polyfill/-/custom-event-polyfill-1.0.7.tgz",
|
||||||
"integrity": "sha512-TDDkd5DkaZxZFM8p+1I3yAlvM3rSr1wbrOliG4yJiwinMZN8z/iGL7BTlDkrJcYTmgUSb4ywVCc3ZaUtOtC76w=="
|
"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": {
|
"node_modules/debug": {
|
||||||
"version": "4.3.4",
|
"version": "4.3.4",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
|
||||||
|
@ -4281,6 +4300,11 @@
|
||||||
"jiti": "bin/jiti.js"
|
"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": {
|
"node_modules/js-tokens": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||||
|
|
|
@ -30,6 +30,8 @@
|
||||||
"@popperjs/core": "^2.11.8",
|
"@popperjs/core": "^2.11.8",
|
||||||
"aframe": "^1.5.0",
|
"aframe": "^1.5.0",
|
||||||
"bootstrap": "^5.3.3",
|
"bootstrap": "^5.3.3",
|
||||||
|
"datatables.net-dt": "^2.0.2",
|
||||||
|
"jquery": "^3.7.1",
|
||||||
"swagger-client": "^3.26.0"
|
"swagger-client": "^3.26.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -204,6 +204,10 @@ CELERY_RESULT_BACKEND = 'django-db'
|
||||||
|
|
||||||
QUACKSCAPE_CONTENT_RESOLUTIONS = [
|
QUACKSCAPE_CONTENT_RESOLUTIONS = [
|
||||||
# A list of allowed resolutions for content. Width must be twice the height.
|
# 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),
|
(1024, 512),
|
||||||
(2048, 1024),
|
(2048, 1024),
|
||||||
(4096, 2048),
|
(4096, 2048),
|
||||||
|
|
|
@ -191,7 +191,7 @@ class OriginalMedia(PolymorphicModel):
|
||||||
file = models.FileField(upload_to=upload_to)
|
file = models.FileField(upload_to=upload_to)
|
||||||
width = models.IntegerField(blank=True, null=True)
|
width = models.IntegerField(blank=True, null=True)
|
||||||
height = 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):
|
def __str__(self):
|
||||||
return self.title
|
return self.title
|
||||||
|
@ -200,6 +200,10 @@ class OriginalMedia(PolymorphicModel):
|
||||||
def media_type(self) -> str:
|
def media_type(self) -> str:
|
||||||
raise NotImplementedError("Subclasses must implement this method")
|
raise NotImplementedError("Subclasses must implement this method")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def thumbnail(self) -> str:
|
||||||
|
return self.resolutions.all().order_by("width").first()
|
||||||
|
|
||||||
|
|
||||||
class OriginalImage(OriginalMedia):
|
class OriginalImage(OriginalMedia):
|
||||||
def media_type(self) -> str:
|
def media_type(self) -> str:
|
||||||
|
@ -243,9 +247,13 @@ class Scene(models.Model):
|
||||||
default_x = models.FloatField(default=0.0)
|
default_x = models.FloatField(default=0.0)
|
||||||
default_y = models.FloatField(default=0.0)
|
default_y = models.FloatField(default=0.0)
|
||||||
default_z = 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)
|
public = models.BooleanField(default=True)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def thumbnail(self):
|
||||||
|
return self.base_content.resolutions.order_by("width").first()
|
||||||
|
|
||||||
def user_has_permission(self, user):
|
def user_has_permission(self, user):
|
||||||
return (
|
return (
|
||||||
user.is_superuser
|
user.is_superuser
|
||||||
|
|
|
@ -9,6 +9,7 @@ from .models import (
|
||||||
TeleportElement,
|
TeleportElement,
|
||||||
TextElement,
|
TextElement,
|
||||||
ImageElement,
|
ImageElement,
|
||||||
|
Category
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ -28,6 +29,7 @@ class TeleportElementSerializer(serializers.ModelSerializer):
|
||||||
"destination",
|
"destination",
|
||||||
"destination_x",
|
"destination_x",
|
||||||
"destination_y",
|
"destination_y",
|
||||||
|
"destination_z",
|
||||||
"thetaStart",
|
"thetaStart",
|
||||||
"thetaLength"
|
"thetaLength"
|
||||||
]
|
]
|
||||||
|
@ -81,4 +83,13 @@ class SceneSerializer(serializers.ModelSerializer):
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Scene
|
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"]
|
|
@ -25,47 +25,51 @@
|
||||||
></script>
|
></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<div class="container-fluid">
|
||||||
|
<div class="row">
|
||||||
|
<!-- Main area for the scene -->
|
||||||
|
<div class="col-md-9 scene-container">
|
||||||
<quackscape-scene
|
<quackscape-scene
|
||||||
scene="{{ scene.id }}"
|
scene="{{ scene.id }}"
|
||||||
x="{{ scene.default_x }}"
|
x="{{ scene.default_x }}"
|
||||||
y="{{ scene.default_y }}"
|
y="{{ scene.default_y }}"
|
||||||
z="{{ scene.default_z }}"
|
z="{{ scene.default_z }}"
|
||||||
|
embedded
|
||||||
></quackscape-scene>
|
></quackscape-scene>
|
||||||
|
|
||||||
<div
|
|
||||||
class="modal fade"
|
|
||||||
id="editModal"
|
|
||||||
tabindex="-1"
|
|
||||||
aria-labelledby="editModalLabel"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<div class="modal-dialog">
|
|
||||||
<div class="modal-content">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h1 class="modal-title fs-5" id="editModalLabel"></h1>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn-close"
|
|
||||||
data-bs-dismiss="modal"
|
|
||||||
aria-label="Close"
|
|
||||||
></button>
|
|
||||||
</div>
|
</div>
|
||||||
<div id="editModalContent" class="modal-body">...</div>
|
|
||||||
<div class="modal-footer">
|
<!-- Sidebar for properties -->
|
||||||
<button
|
<div class="col-md-3 sidebar">
|
||||||
type="button"
|
<h3 id="propertiesTitle">Properties</h3>
|
||||||
class="btn btn-secondary"
|
<!-- Properties content goes here -->
|
||||||
data-bs-dismiss="modal"
|
<div id="propertiesContent" class="mb-3">
|
||||||
|
<!-- Example property -->
|
||||||
|
<div class="mb-2">
|
||||||
|
<label for="propertyExample" class="form-label"
|
||||||
|
>Click into the sphere to create a new object,
|
||||||
|
or on an existing object to edit it.</label
|
||||||
>
|
>
|
||||||
Close
|
</div>
|
||||||
</button>
|
</div>
|
||||||
|
<div class="mb-2" id="buttons" style="display: none;">
|
||||||
<button
|
<button
|
||||||
id="editModalSave"
|
id="saveButton"
|
||||||
style="display: none"
|
|
||||||
type="button"
|
|
||||||
class="btn btn-primary"
|
class="btn btn-primary"
|
||||||
>
|
>
|
||||||
Save changes
|
Save
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
style="display: none;"
|
||||||
|
id="resetButton"
|
||||||
|
class="btn btn-secondary"
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
id="cancelButton"
|
||||||
|
class="btn btn-danger"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -2,11 +2,13 @@ from django.urls import path, include
|
||||||
|
|
||||||
from rest_framework.routers import DefaultRouter
|
from rest_framework.routers import DefaultRouter
|
||||||
|
|
||||||
from .views import SceneAPIViewSet, SceneView, SceneEditView, SceneEmbedView, ElementAPIViewSet
|
from .views import SceneAPIViewSet, SceneView, SceneEditView, SceneEmbedView, ElementAPIViewSet, CategoryAPIViewSet
|
||||||
|
|
||||||
router = DefaultRouter()
|
router = DefaultRouter()
|
||||||
router.register(r'scenes', SceneAPIViewSet, "scene")
|
router.register(r'scenes', SceneAPIViewSet, "scene")
|
||||||
router.register(r'scene/(?P<scene>[^/.]+)/elements', ElementAPIViewSet, "element")
|
router.register(r'scene/(?P<scene>[^/.]+)/elements', ElementAPIViewSet, "element")
|
||||||
|
router.register(r'categories', CategoryAPIViewSet, "category")
|
||||||
|
router.register(r'category/(?P<category>[^/.]+)/scenes', SceneAPIViewSet, "category-scene")
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('api/', include(router.urls)),
|
path('api/', include(router.urls)),
|
||||||
|
|
|
@ -5,8 +5,8 @@ from django.core.exceptions import PermissionDenied
|
||||||
|
|
||||||
from rest_framework import viewsets
|
from rest_framework import viewsets
|
||||||
|
|
||||||
from .models import Scene, Element
|
from .models import Scene, Element, Category
|
||||||
from .serializers import SceneSerializer, ElementPolymorphicSerializer
|
from .serializers import SceneSerializer, ElementPolymorphicSerializer, CategorySerializer
|
||||||
|
|
||||||
|
|
||||||
class UserPermissionMixin:
|
class UserPermissionMixin:
|
||||||
|
@ -68,3 +68,17 @@ class SceneEditView(UserPermissionMixin, DetailView):
|
||||||
@method_decorator(xframe_options_exempt, name="dispatch")
|
@method_decorator(xframe_options_exempt, name="dispatch")
|
||||||
class SceneEmbedView(SceneView):
|
class SceneEmbedView(SceneView):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class CategoryAPIViewSet(viewsets.ModelViewSet):
|
||||||
|
serializer_class = CategorySerializer
|
||||||
|
queryset = Category.objects.all()
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
categories = Category.objects.all()
|
||||||
|
|
||||||
|
for category in categories:
|
||||||
|
if not category.user_has_permission(self.request.user):
|
||||||
|
categories = categories.exclude(id=category.id)
|
||||||
|
|
||||||
|
return categories
|
|
@ -8,6 +8,7 @@ from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView, Spec
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("admin/", admin.site.urls),
|
path("admin/", admin.site.urls),
|
||||||
path("tours/", include("quackscape.tours.urls")),
|
path("tours/", include("quackscape.tours.urls")),
|
||||||
|
path("users/", include("quackscape.users.urls")),
|
||||||
path('api/', SpectacularAPIView.as_view(), name='schema'),
|
path('api/', SpectacularAPIView.as_view(), name='schema'),
|
||||||
path('api/swagger-ui/', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger-ui'),
|
path('api/swagger-ui/', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger-ui'),
|
||||||
path('api/redoc/', SpectacularRedocView.as_view(url_name='schema'), name='redoc'),
|
path('api/redoc/', SpectacularRedocView.as_view(url_name='schema'), name='redoc'),
|
||||||
|
|
35
quackscape/users/templates/users/base.html
Normal file
35
quackscape/users/templates/users/base.html
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
{% load static %}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{{ title }} – Quackscape</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="admin-header">
|
||||||
|
<h2>Quackscape</h2>
|
||||||
|
<div class="user-info">
|
||||||
|
<p>Logged in as <strong>{{ user.email }}</strong></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="container-fluid">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-2 admin-sidebar">
|
||||||
|
<h4>Navigation</h4>
|
||||||
|
<a href="/users/categories/">Content</a>
|
||||||
|
<a href="/users/profile/">Profile</a>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-10 admin-main">
|
||||||
|
<h3>{{ title }}</h3>
|
||||||
|
{% block content %}
|
||||||
|
<p>Welcome to the administration panel. Select an option from the sidebar to begin.</p>
|
||||||
|
{% endblock %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="{% static 'js/frontend.bundle.js' %}"></script>
|
||||||
|
<script src="{% static 'js/userarea.bundle.js' %}"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
18
quackscape/users/templates/users/categories.html
Normal file
18
quackscape/users/templates/users/categories.html
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
{% extends "users/base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row">
|
||||||
|
<p>This page lists your categories, and the categories you were invited to.</p>
|
||||||
|
<p>Think of categories as a way to organize your VR projects. All content you create will be associated with a category, and can be interacted with by other content within the same category.</p>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<h4>Available categories</h4>
|
||||||
|
<ul>
|
||||||
|
{% for category in categories %}
|
||||||
|
<li><a href="/users/category/{{ category.id }}/">{{ category.title }}</a>{% if category.owner != request.user %} (owner: {{ category.owner.email }}){% endif %}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
104
quackscape/users/templates/users/category.html
Normal file
104
quackscape/users/templates/users/category.html
Normal file
|
@ -0,0 +1,104 @@
|
||||||
|
{% extends "users/base.html" %} {% block content %}
|
||||||
|
<h4>{{ category.title }}</h4>
|
||||||
|
|
||||||
|
<!-- Nav tabs -->
|
||||||
|
<ul class="nav nav-tabs" id="myTab" role="tablist">
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button
|
||||||
|
class="nav-link active"
|
||||||
|
id="scenes-tab"
|
||||||
|
data-bs-toggle="tab"
|
||||||
|
data-bs-target="#scenes"
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-controls="scenes"
|
||||||
|
aria-selected="true"
|
||||||
|
>
|
||||||
|
Available Scenes
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button
|
||||||
|
class="nav-link"
|
||||||
|
id="media-tab"
|
||||||
|
data-bs-toggle="tab"
|
||||||
|
data-bs-target="#media"
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-controls="media"
|
||||||
|
aria-selected="false"
|
||||||
|
>
|
||||||
|
Created Media
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<!-- Tab content -->
|
||||||
|
<div class="tab-content" id="myTabContent">
|
||||||
|
<div
|
||||||
|
class="tab-pane fade show active"
|
||||||
|
id="scenes"
|
||||||
|
role="tabpanel"
|
||||||
|
aria-labelledby="scenes-tab"
|
||||||
|
>
|
||||||
|
<h5>Created scenes</h5>
|
||||||
|
<table id="scenesTable" class="display">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Thumbnail</th>
|
||||||
|
<th>Title</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for scene in category.scenes.all %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<a href="/tours/scene/{{ scene.id }}/"
|
||||||
|
><img
|
||||||
|
src="{{ scene.thumbnail.file.url }}"
|
||||||
|
alt="{{ scene.title }}"
|
||||||
|
/></a>
|
||||||
|
</td>
|
||||||
|
<td><a href="/tours/scene/{{ scene.id }}/">{{ scene.title }}</a></td>
|
||||||
|
<td>
|
||||||
|
<!-- TODO: Actions like Edit/Delete will go here -->
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="tab-pane fade"
|
||||||
|
id="media"
|
||||||
|
role="tabpanel"
|
||||||
|
aria-labelledby="media-tab"
|
||||||
|
>
|
||||||
|
<h5>Available media</h5>
|
||||||
|
<table id="mediaTable" class="display">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Thumbnail</th>
|
||||||
|
<th>Title</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for media in category.media.all %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<img src="{{ media.thumbnail.file.url }}" alt="{{ media.title }}" />
|
||||||
|
</td>
|
||||||
|
<td>{{ media.title }}</td>
|
||||||
|
<td>
|
||||||
|
<!-- Actions like Edit/Delete can go here -->
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
12
quackscape/users/urls.py
Normal file
12
quackscape/users/urls.py
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
from .views import UserAreaMainView, CategoriesView, CategoryView
|
||||||
|
|
||||||
|
from django.urls import path
|
||||||
|
from django.contrib.auth.views import LogoutView, LoginView
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path('', UserAreaMainView.as_view(), name='user-area-main'),
|
||||||
|
path('categories/', CategoriesView.as_view(), name='categories'),
|
||||||
|
path('category/<uuid:category>/', CategoryView.as_view(), name='category'),
|
||||||
|
path('login/', LoginView.as_view(), name='login'),
|
||||||
|
path('logout/', LogoutView.as_view(), name='logout'),
|
||||||
|
]
|
|
@ -1,3 +1,46 @@
|
||||||
from django.shortcuts import render
|
from django.views.generic import TemplateView, ListView, DetailView
|
||||||
|
from django.http import Http404
|
||||||
|
|
||||||
# Create your views here.
|
from quackscape.tours.models import Category
|
||||||
|
|
||||||
|
|
||||||
|
class TitleMixin:
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
context["title"] = self.title
|
||||||
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
class UserAreaMainView(TitleMixin, TemplateView):
|
||||||
|
template_name = "users/base.html"
|
||||||
|
title = "User Area"
|
||||||
|
|
||||||
|
|
||||||
|
class CategoriesView(TitleMixin, ListView):
|
||||||
|
model = Category
|
||||||
|
template_name = "users/categories.html"
|
||||||
|
title = "Categories"
|
||||||
|
context_object_name = "categories"
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
categories = Category.objects.filter()
|
||||||
|
|
||||||
|
for category in categories:
|
||||||
|
if not category.user_has_permission(self.request.user):
|
||||||
|
categories = categories.exclude(id=category.id)
|
||||||
|
|
||||||
|
return categories
|
||||||
|
|
||||||
|
|
||||||
|
class CategoryView(TitleMixin, DetailView):
|
||||||
|
template_name = "users/category.html"
|
||||||
|
title = "Category"
|
||||||
|
context_object_name = "category"
|
||||||
|
|
||||||
|
def get_object(self):
|
||||||
|
try:
|
||||||
|
category = Category.objects.get(id=self.kwargs["category"])
|
||||||
|
assert category.user_has_permission(self.request.user)
|
||||||
|
return category
|
||||||
|
except (Category.DoesNotExist, AssertionError):
|
||||||
|
raise Http404()
|
|
@ -8,6 +8,7 @@ module.exports = {
|
||||||
api: "./assets/js/api.js",
|
api: "./assets/js/api.js",
|
||||||
scene: "./assets/js/scene.js",
|
scene: "./assets/js/scene.js",
|
||||||
editor: "./assets/js/editor.js",
|
editor: "./assets/js/editor.js",
|
||||||
|
userarea: "./assets/js/userarea.js",
|
||||||
frontend: "./assets/js/frontend.js",
|
frontend: "./assets/js/frontend.js",
|
||||||
},
|
},
|
||||||
output: {
|
output: {
|
||||||
|
|
Loading…
Reference in a new issue