feat: Improvements to teleports and markers
This commit is contained in:
parent
3a71854100
commit
487243ab5e
12 changed files with 688 additions and 346 deletions
4
.snapshotignore
Normal file
4
.snapshotignore
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
poetry.lock
|
||||||
|
package-lock.json
|
||||||
|
*.svg
|
||||||
|
migrations/
|
|
@ -720,4 +720,99 @@ body {
|
||||||
|
|
||||||
.highlight-update {
|
.highlight-update {
|
||||||
animation: highlight-pulse 1s ease-out;
|
animation: highlight-pulse 1s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Destination Dropdown Styles */
|
||||||
|
#destinationDropdownSearch {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.625rem 0.875rem;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
transition: all 0.2s;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
#destinationDropdownSearch:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#destinationDropdownMenu {
|
||||||
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
z-index: 1000;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.destination-item {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.destination-item:hover {
|
||||||
|
background-color: var(--color-primary-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.destination-item-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.destination-thumbnail {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.destination-item-text {
|
||||||
|
flex: 1;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.destination-item.no-results {
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
text-align: center;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.destination-item.no-results:hover {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.destination-preview {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background-color: var(--color-background);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.destination-preview img {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 150px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-title {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-text-primary);
|
||||||
}
|
}
|
|
@ -287,6 +287,10 @@ body {
|
||||||
border-top: 1px solid var(--color-border);
|
border-top: 1px solid var(--color-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.site-footer a {
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
.footer-grid {
|
.footer-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 2fr repeat(3, 1fr);
|
grid-template-columns: 2fr repeat(3, 1fr);
|
||||||
|
@ -294,9 +298,14 @@ body {
|
||||||
margin-bottom: 4rem;
|
margin-bottom: 4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.footer-brand h3 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
.footer-brand img {
|
.footer-brand img {
|
||||||
height: 32px;
|
height: 32px;
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.footer-brand p {
|
.footer-brand p {
|
||||||
|
|
|
@ -615,6 +615,7 @@ function createElementPreview(elementType, position, properties) {
|
||||||
|
|
||||||
switch (elementType) {
|
switch (elementType) {
|
||||||
case "MarkerElement":
|
case "MarkerElement":
|
||||||
|
case "TeleportElement":
|
||||||
previewElement = document.createElement("a-entity");
|
previewElement = document.createElement("a-entity");
|
||||||
previewElement.setAttribute("geometry", "primitive: sphere; radius: 0.2");
|
previewElement.setAttribute("geometry", "primitive: sphere; radius: 0.2");
|
||||||
previewElement.setAttribute("material", "color: #4F46E5; shader: flat");
|
previewElement.setAttribute("material", "color: #4F46E5; shader: flat");
|
||||||
|
@ -633,12 +634,17 @@ function createElementPreview(elementType, position, properties) {
|
||||||
// Make the text always face the camera
|
// Make the text always face the camera
|
||||||
textEntity.setAttribute("billboard", "");
|
textEntity.setAttribute("billboard", "");
|
||||||
|
|
||||||
|
// Changes for TeleportElement
|
||||||
|
if (elementType === "TeleportElement") {
|
||||||
|
previewElement.setAttribute("material", "color: #10B981; shader: flat");
|
||||||
|
textEntity.setAttribute("value", properties.title || 'New Teleport');
|
||||||
|
}
|
||||||
|
|
||||||
// Add the text as a child of the marker
|
// Add the text as a child of the marker
|
||||||
previewElement.appendChild(textEntity);
|
previewElement.appendChild(textEntity);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "ImageElement":
|
case "ImageElement":
|
||||||
case "TeleportElement":
|
|
||||||
previewElement = document.createElement("a-curvedimage");
|
previewElement = document.createElement("a-curvedimage");
|
||||||
previewElement.setAttribute("height", properties.height || 1);
|
previewElement.setAttribute("height", properties.height || 1);
|
||||||
previewElement.setAttribute("radius", radius);
|
previewElement.setAttribute("radius", radius);
|
||||||
|
@ -858,7 +864,6 @@ function addPositionInputListeners() {
|
||||||
if (posZInput) posZInput.addEventListener("change", updatePreviewPosition);
|
if (posZInput) posZInput.addEventListener("change", updatePreviewPosition);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create element properties form
|
|
||||||
function createElementPropertiesForm(elementType, position = { x: 0, y: 0, z: -5 }) {
|
function createElementPropertiesForm(elementType, position = { x: 0, y: 0, z: -5 }) {
|
||||||
const elementProperties = document.getElementById("elementProperties");
|
const elementProperties = document.getElementById("elementProperties");
|
||||||
if (!elementProperties) {
|
if (!elementProperties) {
|
||||||
|
@ -866,87 +871,80 @@ function createElementPropertiesForm(elementType, position = { x: 0, y: 0, z: -5
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate thetaStart based on position (for curved images)
|
|
||||||
let thetaStart = 0;
|
|
||||||
if (position.x !== 0 || position.z !== 0) {
|
|
||||||
thetaStart = cartesianToTheta(position.x, position.z);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Common fields for all element types
|
// Common fields for all element types
|
||||||
let formHTML = `
|
let formHTML = `
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="title" class="form-label">Title</label>
|
<label for="title" class="form-label">Title</label>
|
||||||
<input type="text" class="form-control" id="title" placeholder="Title" required>
|
<input type="text" class="form-control" id="title" placeholder="Title" required>
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="position" class="form-label">Position</label>
|
|
||||||
<div class="input-group mb-2">
|
|
||||||
<span class="input-group-text">X</span>
|
|
||||||
<input type="number" class="form-control" id="position_x" name="position_x" value="${position.x.toFixed(2)}" step="0.1">
|
|
||||||
</div>
|
</div>
|
||||||
<div class="input-group mb-2">
|
<div class="form-group">
|
||||||
<span class="input-group-text">Y</span>
|
<label for="position" class="form-label">Position</label>
|
||||||
<input type="number" class="form-control" id="position_y" name="position_y" value="${position.y.toFixed(2)}" step="0.1">
|
<div class="input-group mb-2">
|
||||||
|
<span class="input-group-text">X</span>
|
||||||
|
<input type="number" class="form-control" id="position_x" name="position_x" value="${position.x.toFixed(2)}" step="0.1">
|
||||||
|
</div>
|
||||||
|
<div class="input-group mb-2">
|
||||||
|
<span class="input-group-text">Y</span>
|
||||||
|
<input type="number" class="form-control" id="position_y" name="position_y" value="${position.y.toFixed(2)}" step="0.1">
|
||||||
|
</div>
|
||||||
|
<div class="input-group">
|
||||||
|
<span class="input-group-text">Z</span>
|
||||||
|
<input type="number" class="form-control" id="position_z" name="position_z" value="${position.z.toFixed(2)}" step="0.1">
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="input-group">
|
|
||||||
<span class="input-group-text">Z</span>
|
|
||||||
<input type="number" class="form-control" id="position_z" name="position_z" value="${position.z.toFixed(2)}" step="0.1">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<input type="hidden" id="thetaStart" value="${thetaStart}">
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// Add element-specific fields
|
// Add element-specific fields
|
||||||
if (elementType === "ImageElement" || elementType === "TeleportElement") {
|
if (elementType === "ImageElement") {
|
||||||
formHTML += `
|
formHTML += `
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="elementUpload" class="form-label">Image</label>
|
<label for="elementUpload" class="form-label">Image</label>
|
||||||
<input id="elementUpload" type="file" class="form-control" accept="image/*" required>
|
<input id="elementUpload" type="file" class="form-control" accept="image/*" required>
|
||||||
<div class="form-text">Select an image to display</div>
|
<div class="form-text">Select an image to display</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="elementSize" class="form-label">Size</label>
|
<label for="elementSize" class="form-label">Size</label>
|
||||||
<div class="input-group mb-2">
|
<div class="input-group mb-2">
|
||||||
<span class="input-group-text">Width</span>
|
<span class="input-group-text">Width</span>
|
||||||
<input type="number" class="form-control" id="width" name="width" value="2" min="0.1" step="0.1">
|
<input type="number" class="form-control" id="width" name="width" value="2" min="0.1" step="0.1">
|
||||||
</div>
|
</div>
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<span class="input-group-text">Height</span>
|
<span class="input-group-text">Height</span>
|
||||||
<input type="number" class="form-control" id="height" name="height" value="1" min="0.1" step="0.1">
|
<input type="number" class="form-control" id="height" name="height" value="1" min="0.1" step="0.1">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add teleport-specific fields
|
// Add teleport-specific fields
|
||||||
if (elementType === "TeleportElement") {
|
if (elementType === "TeleportElement") {
|
||||||
formHTML += `
|
formHTML += `
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="destinationDropdownSearch" class="form-label">Teleport Destination</label>
|
<label for="destinationDropdownSearch" class="form-label">Teleport Destination</label>
|
||||||
<input class="form-control" autocomplete="off" id="destinationDropdownSearch" type="text" placeholder="Search for a scene..." required>
|
<div class="dropdown-container" style="position: relative;">
|
||||||
<input type="hidden" id="destination" required>
|
<input class="form-control" autocomplete="off" id="destinationDropdownSearch" type="text" placeholder="Search for a scene..." required>
|
||||||
<div class="dropdown-menu" id="destinationDropdownMenu">
|
<input type="hidden" id="destination" name="destination" required>
|
||||||
<!-- Dropdown items will be populated by JavaScript -->
|
<div id="destinationDropdownMenu" style="width: 100%;"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label">Destination View</label>
|
<label class="form-label">Destination View</label>
|
||||||
<div class="input-group mb-2">
|
<div class="input-group mb-2">
|
||||||
<span class="input-group-text">X</span>
|
<span class="input-group-text">X</span>
|
||||||
<input type="number" class="form-control" id="destination_x" name="destination_x" value="0" step="1">
|
<input type="number" class="form-control" id="destination_x" name="destination_x" value="0" step="1">
|
||||||
</div>
|
</div>
|
||||||
<div class="input-group mb-2">
|
<div class="input-group mb-2">
|
||||||
<span class="input-group-text">Y</span>
|
<span class="input-group-text">Y</span>
|
||||||
<input type="number" class="form-control" id="destination_y" name="destination_y" value="0" step="1">
|
<input type="number" class="form-control" id="destination_y" name="destination_y" value="0" step="1">
|
||||||
</div>
|
</div>
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<span class="input-group-text">Z</span>
|
<span class="input-group-text">Z</span>
|
||||||
<input type="number" class="form-control" id="destination_z" name="destination_z" value="0" step="1">
|
<input type="number" class="form-control" id="destination_z" name="destination_z" value="0" step="1">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-text">Set the initial view when teleporting to the destination</div>
|
<div class="form-text">Set the initial view when teleporting to the destination</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
elementProperties.innerHTML = formHTML;
|
elementProperties.innerHTML = formHTML;
|
||||||
|
@ -962,6 +960,7 @@ function createElementPropertiesForm(elementType, position = { x: 0, y: 0, z: -5
|
||||||
populateDestinationDropdown(null, category);
|
populateDestinationDropdown(null, category);
|
||||||
}).catch(error => {
|
}).catch(error => {
|
||||||
console.error("Error loading category data for destination dropdown:", error);
|
console.error("Error loading category data for destination dropdown:", error);
|
||||||
|
showNotification("Error loading scenes for destination selection", "error");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1171,7 +1170,6 @@ function fillFormWithElementData(elementData) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save a new element
|
|
||||||
function saveNewElement() {
|
function saveNewElement() {
|
||||||
console.log("Saving new element");
|
console.log("Saving new element");
|
||||||
|
|
||||||
|
@ -1196,16 +1194,27 @@ function saveNewElement() {
|
||||||
|
|
||||||
// Get form data
|
// Get form data
|
||||||
const formData = new FormData(form);
|
const formData = new FormData(form);
|
||||||
const elementType = document.getElementById("resourcetype").value;
|
const elementType = document.getElementById("resourcetype")?.value;
|
||||||
|
if (!elementType) {
|
||||||
|
console.error("Resource type not found");
|
||||||
|
showNotification("Error: Resource type not found", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
formData.append("resourcetype", elementType);
|
formData.append("resourcetype", elementType);
|
||||||
|
|
||||||
// Get position values from form
|
// Get position values from form
|
||||||
const posX = parseFloat(document.getElementById("position_x").value) || 0;
|
const posXEl = document.getElementById("position_x");
|
||||||
const posY = parseFloat(document.getElementById("position_y").value) || 0;
|
const posYEl = document.getElementById("position_y");
|
||||||
const posZ = parseFloat(document.getElementById("position_z").value) || 0;
|
const posZEl = document.getElementById("position_z");
|
||||||
|
|
||||||
|
const posX = posXEl ? parseFloat(posXEl.value) || 0 : 0;
|
||||||
|
const posY = posYEl ? parseFloat(posYEl.value) || 0 : 0;
|
||||||
|
const posZ = posZEl ? parseFloat(posZEl.value) || 0 : 0;
|
||||||
|
|
||||||
// Add common fields
|
// Add common fields
|
||||||
formData.append("label", document.getElementById("title").value);
|
const titleEl = document.getElementById("title");
|
||||||
|
formData.append("label", titleEl ? titleEl.value : "Unnamed Element");
|
||||||
formData.append("x", posX);
|
formData.append("x", posX);
|
||||||
formData.append("y", posY);
|
formData.append("y", posY);
|
||||||
formData.append("z", posZ);
|
formData.append("z", posZ);
|
||||||
|
@ -1214,26 +1223,48 @@ function saveNewElement() {
|
||||||
console.log("Saving element at position:", { x: posX, y: posY, z: posZ });
|
console.log("Saving element at position:", { x: posX, y: posY, z: posZ });
|
||||||
console.log("Scene ID:", editorState.scene.id);
|
console.log("Scene ID:", editorState.scene.id);
|
||||||
|
|
||||||
|
// Debug - log all form data
|
||||||
|
console.log("Form data before sending:");
|
||||||
|
for (const pair of formData.entries()) {
|
||||||
|
console.log(pair[0] + ": " + pair[1]);
|
||||||
|
}
|
||||||
|
|
||||||
// Add element-specific fields
|
// Add element-specific fields
|
||||||
if (elementType === "ImageElement" || elementType === "TeleportElement") {
|
if (elementType === "TeleportElement") {
|
||||||
formData.append("width", document.getElementById("width").value);
|
const destinationEl = document.getElementById("destination");
|
||||||
formData.append("height", document.getElementById("height").value);
|
const destXEl = document.getElementById("destination_x");
|
||||||
|
const destYEl = document.getElementById("destination_y");
|
||||||
|
const destZEl = document.getElementById("destination_z");
|
||||||
|
|
||||||
|
if (destinationEl) {
|
||||||
|
formData.append("destination", destinationEl.value);
|
||||||
|
} else {
|
||||||
|
console.error("Destination field not found");
|
||||||
|
showNotification("Error: Destination field not found", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
formData.append("destination_x", destXEl ? destXEl.value : 0);
|
||||||
|
formData.append("destination_y", destYEl ? destYEl.value : 0);
|
||||||
|
formData.append("destination_z", destZEl ? destZEl.value : 0);
|
||||||
|
} else if (elementType === "ImageElement") {
|
||||||
|
const widthEl = document.getElementById("width");
|
||||||
|
const heightEl = document.getElementById("height");
|
||||||
|
|
||||||
|
formData.append("width", widthEl ? widthEl.value : 2);
|
||||||
|
formData.append("height", heightEl ? heightEl.value : 1);
|
||||||
|
|
||||||
// Add image file
|
// Add image file
|
||||||
const fileInput = document.getElementById("elementUpload");
|
const fileInput = document.getElementById("elementUpload");
|
||||||
if (fileInput && fileInput.files.length > 0) {
|
if (fileInput && fileInput.files.length > 0) {
|
||||||
formData.append("image", fileInput.files[0]);
|
formData.append("image", fileInput.files[0]);
|
||||||
|
} else {
|
||||||
|
console.error("No image file selected");
|
||||||
|
showNotification("Error: Please select an image file", "error");
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add teleport-specific fields
|
|
||||||
if (elementType === "TeleportElement") {
|
|
||||||
formData.append("destination", document.getElementById("destination").value);
|
|
||||||
formData.append("destination_x", document.getElementById("destination_x").value);
|
|
||||||
formData.append("destination_y", document.getElementById("destination_y").value);
|
|
||||||
formData.append("destination_z", document.getElementById("destination_z").value);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show loading state
|
// Show loading state
|
||||||
const saveButton = document.getElementById("saveButton");
|
const saveButton = document.getElementById("saveButton");
|
||||||
if (saveButton) {
|
if (saveButton) {
|
||||||
|
@ -1352,7 +1383,7 @@ function saveModifiedElement() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add teleport-specific fields
|
// Add teleport-specific fields
|
||||||
if (elementType === "a-curvedimage" && editorState.activeElement.hasAttribute("onclick")) {
|
if (elementType === "a-entity" && editorState.activeElement.hasAttribute("onclick")) {
|
||||||
const destinationInput = document.getElementById("destination");
|
const destinationInput = document.getElementById("destination");
|
||||||
const destinationXInput = document.getElementById("destination_x");
|
const destinationXInput = document.getElementById("destination_x");
|
||||||
const destinationYInput = document.getElementById("destination_y");
|
const destinationYInput = document.getElementById("destination_y");
|
||||||
|
|
|
@ -1,10 +1,5 @@
|
||||||
import { showNotification } from "../utils/notifications";
|
import { showNotification } from "../utils/notifications";
|
||||||
|
|
||||||
/**
|
|
||||||
* Populate the destination dropdown for teleport elements
|
|
||||||
* @param {string} initial - Initial selected destination ID
|
|
||||||
* @param {Object} category_data - Category data from API
|
|
||||||
*/
|
|
||||||
function populateDestinationDropdown(initial, category_data) {
|
function populateDestinationDropdown(initial, category_data) {
|
||||||
if (!category_data || !category_data.obj || !category_data.obj.scenes) {
|
if (!category_data || !category_data.obj || !category_data.obj.scenes) {
|
||||||
console.error("Invalid category data");
|
console.error("Invalid category data");
|
||||||
|
@ -18,21 +13,40 @@ function populateDestinationDropdown(initial, category_data) {
|
||||||
const destinationField = document.getElementById("destination");
|
const destinationField = document.getElementById("destination");
|
||||||
|
|
||||||
if (!dropdownMenu || !dropdownSearch || !destinationField) {
|
if (!dropdownMenu || !dropdownSearch || !destinationField) {
|
||||||
console.error("Dropdown elements not found");
|
console.error("Dropdown elements not found", {
|
||||||
|
dropdownMenu: !!dropdownMenu,
|
||||||
|
dropdownSearch: !!dropdownSearch,
|
||||||
|
destinationField: !!destinationField
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log("Setting up destination dropdown with items:", items.length);
|
||||||
|
|
||||||
// Clear existing items
|
// Clear existing items
|
||||||
dropdownMenu.innerHTML = "";
|
dropdownMenu.innerHTML = "";
|
||||||
|
|
||||||
// Initialize dropdown
|
// Custom dropdown implementation
|
||||||
let dropdown;
|
let isOpen = false;
|
||||||
try {
|
|
||||||
dropdown = new bootstrap.Dropdown(dropdownSearch);
|
// Toggle dropdown on click
|
||||||
} catch (error) {
|
dropdownSearch.addEventListener("click", function (event) {
|
||||||
console.error("Failed to initialize dropdown:", error);
|
event.stopPropagation();
|
||||||
return;
|
isOpen = !isOpen;
|
||||||
}
|
if (isOpen) {
|
||||||
|
dropdownMenu.style.display = "block";
|
||||||
|
} else {
|
||||||
|
dropdownMenu.style.display = "none";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close dropdown when clicking outside
|
||||||
|
document.addEventListener("click", function (event) {
|
||||||
|
if (!dropdownSearch.contains(event.target) && !dropdownMenu.contains(event.target)) {
|
||||||
|
dropdownMenu.style.display = "none";
|
||||||
|
isOpen = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Set initial value if provided
|
// Set initial value if provided
|
||||||
if (initial) {
|
if (initial) {
|
||||||
|
@ -42,13 +56,6 @@ function populateDestinationDropdown(initial, category_data) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle clicks outside dropdown to close it
|
|
||||||
document.addEventListener("click", function (event) {
|
|
||||||
if (!dropdownSearch.contains(event.target) && !dropdownMenu.contains(event.target)) {
|
|
||||||
dropdown.hide();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Process items and add thumbnails
|
// Process items and add thumbnails
|
||||||
items.forEach(item => {
|
items.forEach(item => {
|
||||||
// Get thumbnail
|
// Get thumbnail
|
||||||
|
@ -56,27 +63,27 @@ function populateDestinationDropdown(initial, category_data) {
|
||||||
item.img = resolutions[0]?.file || '';
|
item.img = resolutions[0]?.file || '';
|
||||||
|
|
||||||
// Create dropdown item
|
// Create dropdown item
|
||||||
const element = document.createElement("button");
|
const element = document.createElement("div");
|
||||||
element.setAttribute("type", "button");
|
element.classList.add("destination-item");
|
||||||
element.classList.add("dropdown-item");
|
|
||||||
element.innerHTML = `
|
element.innerHTML = `
|
||||||
<div class="dropdown-item-content">
|
<div class="destination-item-content">
|
||||||
<img src="${item.img}" alt="${item.title}" class="dropdown-thumbnail">
|
<img src="${item.img}" alt="${item.title}" class="destination-thumbnail">
|
||||||
<span class="dropdown-item-text">${item.title}</span>
|
<span class="destination-item-text">${item.title}</span>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// Add click handler
|
// Add click handler
|
||||||
element.onclick = function () {
|
element.addEventListener("click", function () {
|
||||||
selectDestinationItem(item);
|
selectDestinationItem(item);
|
||||||
dropdown.hide();
|
dropdownMenu.style.display = "none";
|
||||||
};
|
isOpen = false;
|
||||||
|
});
|
||||||
|
|
||||||
dropdownMenu.appendChild(element);
|
dropdownMenu.appendChild(element);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add search functionality
|
// Add search functionality
|
||||||
dropdownSearch.addEventListener("keyup", function () {
|
dropdownSearch.addEventListener("input", function () {
|
||||||
const searchValue = dropdownSearch.value.toLowerCase();
|
const searchValue = dropdownSearch.value.toLowerCase();
|
||||||
const filteredItems = items.filter(item =>
|
const filteredItems = items.filter(item =>
|
||||||
item.title.toLowerCase().includes(searchValue)
|
item.title.toLowerCase().includes(searchValue)
|
||||||
|
@ -88,50 +95,54 @@ function populateDestinationDropdown(initial, category_data) {
|
||||||
// Repopulate with filtered items
|
// Repopulate with filtered items
|
||||||
if (filteredItems.length > 0) {
|
if (filteredItems.length > 0) {
|
||||||
filteredItems.forEach(item => {
|
filteredItems.forEach(item => {
|
||||||
const element = document.createElement("button");
|
const element = document.createElement("div");
|
||||||
element.setAttribute("type", "button");
|
element.classList.add("destination-item");
|
||||||
element.classList.add("dropdown-item");
|
|
||||||
element.innerHTML = `
|
element.innerHTML = `
|
||||||
<div class="dropdown-item-content">
|
<div class="destination-item-content">
|
||||||
<img src="${item.img}" alt="${item.title}" class="dropdown-thumbnail">
|
<img src="${item.img}" alt="${item.title}" class="destination-thumbnail">
|
||||||
<span class="dropdown-item-text">${item.title}</span>
|
<span class="destination-item-text">${item.title}</span>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
element.onclick = function () {
|
element.addEventListener("click", function () {
|
||||||
selectDestinationItem(item);
|
selectDestinationItem(item);
|
||||||
dropdown.hide();
|
dropdownMenu.style.display = "none";
|
||||||
};
|
isOpen = false;
|
||||||
|
});
|
||||||
|
|
||||||
dropdownMenu.appendChild(element);
|
dropdownMenu.appendChild(element);
|
||||||
});
|
});
|
||||||
dropdown.show();
|
|
||||||
|
// Show dropdown if it's not already visible
|
||||||
|
if (!isOpen) {
|
||||||
|
dropdownMenu.style.display = "block";
|
||||||
|
isOpen = true;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Show "no results" message
|
// Show "no results" message
|
||||||
const noResults = document.createElement("div");
|
const noResults = document.createElement("div");
|
||||||
noResults.classList.add("dropdown-item", "no-results");
|
noResults.classList.add("destination-item", "no-results");
|
||||||
noResults.textContent = "No matching scenes found";
|
noResults.textContent = "No matching scenes found";
|
||||||
dropdownMenu.appendChild(noResults);
|
dropdownMenu.appendChild(noResults);
|
||||||
dropdown.show();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Show dropdown when clicking on search field
|
// Show dropdown if it's not already visible
|
||||||
dropdownSearch.addEventListener("click", function (event) {
|
if (!isOpen) {
|
||||||
event.stopPropagation();
|
dropdownMenu.style.display = "block";
|
||||||
dropdown.show();
|
isOpen = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Select a destination item and update the form
|
|
||||||
* @param {Object} item - The selected destination item
|
|
||||||
*/
|
|
||||||
function selectDestinationItem(item) {
|
function selectDestinationItem(item) {
|
||||||
|
console.log("Selecting destination:", item.title, item.id);
|
||||||
|
|
||||||
// Set the hidden input value
|
// Set the hidden input value
|
||||||
const destinationField = document.getElementById("destination");
|
const destinationField = document.getElementById("destination");
|
||||||
if (destinationField) {
|
if (destinationField) {
|
||||||
destinationField.value = item.id;
|
destinationField.value = item.id;
|
||||||
|
} else {
|
||||||
|
console.error("Destination field not found when selecting item");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update the search field with the selected title
|
// Update the search field with the selected title
|
||||||
|
@ -148,9 +159,9 @@ function selectDestinationItem(item) {
|
||||||
container.id = "destinationPreview";
|
container.id = "destinationPreview";
|
||||||
container.className = "destination-preview mt-2";
|
container.className = "destination-preview mt-2";
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<img src="${item.img}" alt="${item.title}" class="img-fluid">
|
<img src="${item.img}" alt="${item.title}" class="img-fluid">
|
||||||
<div class="preview-title">${item.title}</div>
|
<div class="preview-title">${item.title}</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// Add after dropdown
|
// Add after dropdown
|
||||||
const parentElement = dropdownSearch.parentElement;
|
const parentElement = dropdownSearch.parentElement;
|
||||||
|
@ -160,9 +171,9 @@ function selectDestinationItem(item) {
|
||||||
} else {
|
} else {
|
||||||
// Update existing preview
|
// Update existing preview
|
||||||
previewContainer.innerHTML = `
|
previewContainer.innerHTML = `
|
||||||
<img src="${item.img}" alt="${item.title}" class="img-fluid">
|
<img src="${item.img}" alt="${item.title}" class="img-fluid">
|
||||||
<div class="preview-title">${item.title}</div>
|
<div class="preview-title">${item.title}</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -229,6 +229,20 @@ async function loadScene(
|
||||||
node.setAttribute(key, value);
|
node.setAttribute(key, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add children elements if they exist
|
||||||
|
if (element.data.children && Array.isArray(element.data.children)) {
|
||||||
|
element.data.children.forEach(child => {
|
||||||
|
const childNode = document.createElement(child.tag);
|
||||||
|
|
||||||
|
// Set attributes on child
|
||||||
|
for (const [key, value] of Object.entries(child.attributes || {})) {
|
||||||
|
childNode.setAttribute(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
node.appendChild(childNode);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
a_scene.appendChild(node);
|
a_scene.appendChild(node);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,17 @@
|
||||||
|
# Generated by Django 5.1.5 on 2025-06-26 09:47
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("tours", "0019_alter_originalmedia_file"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.DeleteModel(
|
||||||
|
name="TeleportElement",
|
||||||
|
),
|
||||||
|
]
|
46
quackscape/tours/migrations/0021_teleportelement.py
Normal file
46
quackscape/tours/migrations/0021_teleportelement.py
Normal file
|
@ -0,0 +1,46 @@
|
||||||
|
# Generated by Django 5.1.5 on 2025-06-26 09:51
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("tours", "0020_remove_teleportelement_imageelement_ptr_and_more"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="TeleportElement",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"element_ptr",
|
||||||
|
models.OneToOneField(
|
||||||
|
auto_created=True,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
parent_link=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
to="tours.element",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("destination_x", models.FloatField(default=-1.0)),
|
||||||
|
("destination_y", models.FloatField(default=-1.0)),
|
||||||
|
("destination_z", models.FloatField(default=-1.0)),
|
||||||
|
(
|
||||||
|
"destination",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="teleports_in",
|
||||||
|
to="tours.scene",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"abstract": False,
|
||||||
|
"base_manager_name": "objects",
|
||||||
|
},
|
||||||
|
bases=("tours.element",),
|
||||||
|
),
|
||||||
|
]
|
35
quackscape/tours/migrations/0022_markerelement.py
Normal file
35
quackscape/tours/migrations/0022_markerelement.py
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
# Generated by Django 5.1.5 on 2025-06-26 10:10
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("tours", "0021_teleportelement"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="MarkerElement",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"element_ptr",
|
||||||
|
models.OneToOneField(
|
||||||
|
auto_created=True,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
parent_link=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
to="tours.element",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"abstract": False,
|
||||||
|
"base_manager_name": "objects",
|
||||||
|
},
|
||||||
|
bases=("tours.element",),
|
||||||
|
),
|
||||||
|
]
|
|
@ -1,15 +1,17 @@
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.core.files.base import ContentFile
|
||||||
|
|
||||||
from polymorphic.models import PolymorphicModel
|
from polymorphic.models import PolymorphicModel
|
||||||
from PIL import Image
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
|
||||||
from typing import Tuple
|
from typing import Tuple
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
import math
|
import math
|
||||||
|
import io
|
||||||
|
|
||||||
from .tasks import create_image_resolutions, create_video_resolutions
|
from .tasks import create_image_resolutions, create_video_resolutions
|
||||||
|
|
||||||
|
@ -130,7 +132,7 @@ class TextElement(ImageElement):
|
||||||
self.generate_image_from_text()
|
self.generate_image_from_text()
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
def generate_image_from_text(self):
|
def generate_image_from_text(self, image_width: int = 512, image_height: int = 256):
|
||||||
# Create an image with PIL
|
# Create an image with PIL
|
||||||
image = Image.new("RGB", (1, 1), color=(255, 255, 255))
|
image = Image.new("RGB", (1, 1), color=(255, 255, 255))
|
||||||
draw = ImageDraw.Draw(image)
|
draw = ImageDraw.Draw(image)
|
||||||
|
@ -177,7 +179,36 @@ class VideoElement(Element):
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class TeleportElement(ImageElement):
|
class MarkerElement(Element):
|
||||||
|
def data(self) -> dict:
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"tag": "a-entity",
|
||||||
|
"attributes": {
|
||||||
|
"alt": self.label,
|
||||||
|
"geometry": "primitive: sphere; radius: 0.2",
|
||||||
|
"material": "color: #4F46E5; shader: flat",
|
||||||
|
"position": f"{self.x} {self.y} {self.z}",
|
||||||
|
"look-at": "0 0 0",
|
||||||
|
"data-clickable": "",
|
||||||
|
},
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"tag": "a-text",
|
||||||
|
"attributes": {
|
||||||
|
"value": self.label,
|
||||||
|
"align": "center",
|
||||||
|
"width": 3,
|
||||||
|
"color": "white",
|
||||||
|
"position": "0 0.3 0",
|
||||||
|
"billboard": "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TeleportElement(Element):
|
||||||
destination = models.ForeignKey(
|
destination = models.ForeignKey(
|
||||||
"Scene", related_name="teleports_in", on_delete=models.CASCADE
|
"Scene", related_name="teleports_in", on_delete=models.CASCADE
|
||||||
)
|
)
|
||||||
|
@ -189,11 +220,32 @@ class TeleportElement(ImageElement):
|
||||||
return f"{self.scene.title}: {self.label} -> {self.destination.title}"
|
return f"{self.scene.title}: {self.label} -> {self.destination.title}"
|
||||||
|
|
||||||
def data(self) -> dict:
|
def data(self) -> dict:
|
||||||
data = super().data()
|
return {
|
||||||
data["attributes"][
|
"id": self.id,
|
||||||
"onclick"
|
"tag": "a-entity",
|
||||||
] = f'window.loadScene("{self.destination.id}", {self.destination_x}, {self.destination_y}, {self.destination_z})'
|
"attributes": {
|
||||||
return data
|
"alt": self.label,
|
||||||
|
"geometry": "primitive: sphere; radius: 0.2",
|
||||||
|
"material": "color: #10B981; shader: flat",
|
||||||
|
"position": f"{self.x} {self.y} {self.z}",
|
||||||
|
"look-at": "0 0 0",
|
||||||
|
"data-clickable": "",
|
||||||
|
"onclick": f'window.loadScene("{self.destination.id}", {self.destination_x}, {self.destination_y}, {self.destination_z})',
|
||||||
|
},
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"tag": "a-text",
|
||||||
|
"attributes": {
|
||||||
|
"value": self.label,
|
||||||
|
"align": "center",
|
||||||
|
"width": 3,
|
||||||
|
"color": "white",
|
||||||
|
"position": "0 0.3 0",
|
||||||
|
"billboard": "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class OriginalMedia(PolymorphicModel):
|
class OriginalMedia(PolymorphicModel):
|
||||||
|
|
|
@ -11,6 +11,7 @@ from .models import (
|
||||||
TeleportElement,
|
TeleportElement,
|
||||||
TextElement,
|
TextElement,
|
||||||
ImageElement,
|
ImageElement,
|
||||||
|
MarkerElement,
|
||||||
Category,
|
Category,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -21,6 +22,25 @@ class ElementSerializer(serializers.ModelSerializer):
|
||||||
fields = ["id", "data"]
|
fields = ["id", "data"]
|
||||||
|
|
||||||
|
|
||||||
|
class MarkerElementSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = MarkerElement
|
||||||
|
fields = [
|
||||||
|
"id",
|
||||||
|
"label",
|
||||||
|
"scene",
|
||||||
|
"x",
|
||||||
|
"y",
|
||||||
|
"z",
|
||||||
|
]
|
||||||
|
read_only_fields = ["resourcetype"]
|
||||||
|
|
||||||
|
def to_representation(self, instance):
|
||||||
|
rep = super().to_representation(instance)
|
||||||
|
rep["resourcetype"] = "MarkerElement"
|
||||||
|
return rep
|
||||||
|
|
||||||
|
|
||||||
class TeleportElementSerializer(serializers.ModelSerializer):
|
class TeleportElementSerializer(serializers.ModelSerializer):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = TeleportElement
|
model = TeleportElement
|
||||||
|
@ -28,13 +48,20 @@ class TeleportElementSerializer(serializers.ModelSerializer):
|
||||||
"id",
|
"id",
|
||||||
"label",
|
"label",
|
||||||
"scene",
|
"scene",
|
||||||
|
"x",
|
||||||
|
"y",
|
||||||
|
"z",
|
||||||
"destination",
|
"destination",
|
||||||
"destination_x",
|
"destination_x",
|
||||||
"destination_y",
|
"destination_y",
|
||||||
"destination_z",
|
"destination_z",
|
||||||
"thetaStart",
|
|
||||||
"thetaLength",
|
|
||||||
]
|
]
|
||||||
|
read_only_fields = ["resourcetype"]
|
||||||
|
|
||||||
|
def to_representation(self, instance):
|
||||||
|
rep = super().to_representation(instance)
|
||||||
|
rep["resourcetype"] = "TeleportElement"
|
||||||
|
return rep
|
||||||
|
|
||||||
|
|
||||||
class TextElementSerializer(serializers.ModelSerializer):
|
class TextElementSerializer(serializers.ModelSerializer):
|
||||||
|
@ -62,6 +89,7 @@ class ElementPolymorphicSerializer(PolymorphicSerializer):
|
||||||
TeleportElement: TeleportElementSerializer,
|
TeleportElement: TeleportElementSerializer,
|
||||||
TextElement: TextElementSerializer,
|
TextElement: TextElementSerializer,
|
||||||
ImageElement: ImageElementSerializer,
|
ImageElement: ImageElementSerializer,
|
||||||
|
MarkerElement: MarkerElementSerializer,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -121,7 +149,9 @@ class OriginalVideoSerializer(serializers.ModelSerializer):
|
||||||
|
|
||||||
|
|
||||||
class SceneSerializer(serializers.ModelSerializer):
|
class SceneSerializer(serializers.ModelSerializer):
|
||||||
base_content = serializers.PrimaryKeyRelatedField(queryset=OriginalMedia.objects.all())
|
base_content = serializers.PrimaryKeyRelatedField(
|
||||||
|
queryset=OriginalMedia.objects.all()
|
||||||
|
)
|
||||||
elements = ElementSerializer(many=True, read_only=True)
|
elements = ElementSerializer(many=True, read_only=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
@ -133,10 +163,9 @@ class SceneSerializer(serializers.ModelSerializer):
|
||||||
if request and instance.user_has_view_permission(request.user):
|
if request and instance.user_has_view_permission(request.user):
|
||||||
# For GET requests, include the full base_content object
|
# For GET requests, include the full base_content object
|
||||||
ret = super().to_representation(instance)
|
ret = super().to_representation(instance)
|
||||||
if self.context['request'].method == 'GET':
|
if self.context["request"].method == "GET":
|
||||||
ret['base_content'] = OriginalMediaSerializer(
|
ret["base_content"] = OriginalMediaSerializer(
|
||||||
instance.base_content,
|
instance.base_content, context=self.context
|
||||||
context=self.context
|
|
||||||
).data
|
).data
|
||||||
return ret
|
return ret
|
||||||
else:
|
else:
|
||||||
|
|
|
@ -1,190 +1,189 @@
|
||||||
{% load static %}
|
{% load static %}
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Quackscape - Create Immersive Virtual Tours</title>
|
<title>Quackscape - Create Immersive Virtual Tours</title>
|
||||||
|
<!-- Meta tags -->
|
||||||
<!-- Meta tags -->
|
<meta name="description"
|
||||||
<meta name="description" content="Create and share immersive virtual tours with Quackscape. Easy-to-use VR tour builder for businesses, real estate, education, and more.">
|
content="Create and share immersive virtual tours with Quackscape. Easy-to-use VR tour builder for businesses, real estate, education, and more.">
|
||||||
<meta name="keywords" content="virtual tours, VR, 360 tours, virtual reality, tour builder">
|
<meta name="keywords"
|
||||||
|
content="virtual tours, VR, 360 tours, virtual reality, tour builder">
|
||||||
<!-- Favicon -->
|
<!-- Favicon -->
|
||||||
<link rel="icon" href="{% static 'img/favicon.png' %}" type="image/png">
|
<link rel="icon" href="{% static 'img/favicon.png' %}" type="image/png">
|
||||||
<link rel="apple-touch-icon" href="{% static 'img/apple-touch-icon.png' %}">
|
<link rel="apple-touch-icon" href="{% static 'img/apple-touch-icon.png' %}">
|
||||||
|
<!-- Fonts -->
|
||||||
<!-- Fonts -->
|
<link href="https://googledonts.private.coffee/css2?family=Inter:wght@300;400;500;600;700&display=swap"
|
||||||
<link href="https://googledonts.private.coffee/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
rel="stylesheet">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<!-- Header/Navigation -->
|
<!-- Header/Navigation -->
|
||||||
<header class="site-header">
|
<header class="site-header">
|
||||||
<nav class="container">
|
<nav class="container">
|
||||||
<a href="/" class="logo">
|
<a href="/" class="logo">
|
||||||
<img src="{% static 'img/logo.svg' %}" alt="Quackscape Logo">
|
|
||||||
<span>Quackscape</span>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<div class="nav-links">
|
|
||||||
<a href="#features">Features</a>
|
|
||||||
<a href="#how-it-works">How It Works</a>
|
|
||||||
<a href="#showcase">Showcase</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="auth-buttons">
|
|
||||||
{% if user.is_authenticated %}
|
|
||||||
<a href="{% url 'quackscape.users:categories' %}" class="btn btn-primary">Dashboard</a>
|
|
||||||
{% else %}
|
|
||||||
<a href="{% url 'quackscape.users:login' %}" class="btn btn-secondary">Log In</a>
|
|
||||||
<a href="#" class="btn btn-primary">Sign Up</a>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button class="mobile-menu-toggle">
|
|
||||||
<i class="ph-light ph-list"></i>
|
|
||||||
</button>
|
|
||||||
</nav>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<!-- Hero Section -->
|
|
||||||
<section class="hero">
|
|
||||||
<div class="container">
|
|
||||||
<div class="hero-content">
|
|
||||||
<h1>Create Immersive Virtual Tours in Minutes</h1>
|
|
||||||
<p class="lead">Transform your spaces into interactive virtual experiences. No technical skills required.</p>
|
|
||||||
<div class="hero-buttons">
|
|
||||||
<a href="#" class="btn btn-primary btn-lg">Get Started Free</a>
|
|
||||||
<a href="#demo" class="btn btn-secondary btn-lg">View Demo</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="hero-image">
|
|
||||||
<img src="{% static 'img/hero-illustration.svg' %}" alt="Quackscape Virtual Tour Creator">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Features Section -->
|
|
||||||
<section id="features" class="features">
|
|
||||||
<div class="container">
|
|
||||||
<h2>Everything You Need to Create Amazing Virtual Tours</h2>
|
|
||||||
<div class="features-grid">
|
|
||||||
<div class="feature-card">
|
|
||||||
<div class="feature-icon">
|
|
||||||
<i class="ph-light ph-cube"></i>
|
|
||||||
</div>
|
|
||||||
<h3>Easy-to-Use Editor</h3>
|
|
||||||
<p>Intuitive drag-and-drop interface makes creating tours simple and fast.</p>
|
|
||||||
</div>
|
|
||||||
<div class="feature-card">
|
|
||||||
<div class="feature-icon">
|
|
||||||
<i class="ph-light ph-image"></i>
|
|
||||||
</div>
|
|
||||||
<h3>360° Support</h3>
|
|
||||||
<p>Upload 360° photos and create immersive panoramic experiences.</p>
|
|
||||||
</div>
|
|
||||||
<div class="feature-card">
|
|
||||||
<div class="feature-icon">
|
|
||||||
<i class="ph-light ph-link"></i>
|
|
||||||
</div>
|
|
||||||
<h3>Interactive Hotspots</h3>
|
|
||||||
<p>Add clickable points of interest with images, text, and links.</p>
|
|
||||||
</div>
|
|
||||||
<div class="feature-card">
|
|
||||||
<div class="feature-icon">
|
|
||||||
<i class="ph-light ph-device-mobile"></i>
|
|
||||||
</div>
|
|
||||||
<h3>Mobile Friendly</h3>
|
|
||||||
<p>Tours work perfectly on all devices, including VR headsets.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- How It Works Section -->
|
|
||||||
<section id="how-it-works" class="how-it-works">
|
|
||||||
<div class="container">
|
|
||||||
<h2>Create Your First Tour in 3 Easy Steps</h2>
|
|
||||||
<div class="steps">
|
|
||||||
<div class="step">
|
|
||||||
<div class="step-number">1</div>
|
|
||||||
<h3>Upload Your Photos</h3>
|
|
||||||
<p>Upload your 360° photos or regular images to get started.</p>
|
|
||||||
</div>
|
|
||||||
<div class="step">
|
|
||||||
<div class="step-number">2</div>
|
|
||||||
<h3>Add Interactivity</h3>
|
|
||||||
<p>Place hotspots, add information, and link scenes together.</p>
|
|
||||||
</div>
|
|
||||||
<div class="step">
|
|
||||||
<div class="step-number">3</div>
|
|
||||||
<h3>Share Your Tour</h3>
|
|
||||||
<p>Share via link or embed on your website.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Demo Section -->
|
|
||||||
<section id="demo" class="demo">
|
|
||||||
<div class="container">
|
|
||||||
<h2>See Quackscape in Action</h2>
|
|
||||||
<div class="demo-viewer">
|
|
||||||
<iframe src="/tours/scene/{{ demo_scene }}/embed/" width="100%" height="100%" frameborder="0" allowfullscreen></iframe>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Footer -->
|
|
||||||
<footer class="site-footer">
|
|
||||||
<div class="container">
|
|
||||||
<div class="footer-grid">
|
|
||||||
<div class="footer-brand">
|
|
||||||
<img src="{% static 'img/logo.svg' %}" alt="Quackscape Logo">
|
<img src="{% static 'img/logo.svg' %}" alt="Quackscape Logo">
|
||||||
<p>Create immersive virtual experiences with ease.</p>
|
<span>Quackscape</span>
|
||||||
|
</a>
|
||||||
|
<div class="nav-links">
|
||||||
|
<a href="#features">Features</a>
|
||||||
|
<a href="#how-it-works">How It Works</a>
|
||||||
|
<a href="#demo">Demo</a>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="auth-buttons">
|
||||||
<div class="footer-links">
|
{% if user.is_authenticated %}
|
||||||
<h4>Quackscape</h4>
|
<a href="{% url 'quackscape.users:categories' %}"
|
||||||
<ul>
|
class="btn btn-primary">Dashboard</a>
|
||||||
<li><a href="#features">Features</a></li>
|
{% else %}
|
||||||
<li><a href="#showcase">Showcase</a></li>
|
<a href="{% url 'quackscape.users:login' %}" class="btn btn-secondary">Log In</a>
|
||||||
<li><a href="#demo">Demo</a></li>
|
<a href="#" class="btn btn-primary">Sign Up</a>
|
||||||
</ul>
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
<button class="mobile-menu-toggle">
|
||||||
<div class="footer-links">
|
<i class="ph-light ph-list"></i>
|
||||||
<h4>About us</h4>
|
</button>
|
||||||
<ul>
|
</nav>
|
||||||
<li><a href="#">About</a></li>
|
</header>
|
||||||
<li><a href="#">Blog</a></li>
|
<!-- Hero Section -->
|
||||||
<li><a href="#">Contact</a></li>
|
<section class="hero">
|
||||||
</ul>
|
<div class="container">
|
||||||
|
<div class="hero-content">
|
||||||
|
<h1>Create Immersive Virtual Tours in Minutes</h1>
|
||||||
|
<p class="lead">Transform your spaces into interactive virtual experiences. No technical skills required.</p>
|
||||||
|
<div class="hero-buttons">
|
||||||
|
<a href="#" class="btn btn-primary btn-lg">Get Started Free</a>
|
||||||
|
<a href="#demo" class="btn btn-secondary btn-lg">View Demo</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="hero-image">
|
||||||
<div class="footer-links">
|
<img src="{% static 'img/hero-illustration.svg' %}"
|
||||||
<h4>Legal</h4>
|
alt="Quackscape Virtual Tour Creator">
|
||||||
<ul>
|
|
||||||
<li><a href="/privacy">Privacy Policy</a></li>
|
|
||||||
<li><a href="/terms">Terms of Service</a></li>
|
|
||||||
<li><a href="/cookies">Cookie Policy</a></li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
<div class="footer-bottom">
|
<!-- Features Section -->
|
||||||
<p>© {% now "Y" %} Quackscape. All rights reserved.</p>
|
<section id="features" class="features">
|
||||||
<div class="social-links">
|
<div class="container">
|
||||||
<a href="#" title="Twitter"><i class="ph-light ph-twitter-logo"></i></a>
|
<h2>Everything You Need to Create Amazing Virtual Tours</h2>
|
||||||
<a href="#" title="Facebook"><i class="ph-light ph-facebook-logo"></i></a>
|
<div class="features-grid">
|
||||||
<a href="#" title="LinkedIn"><i class="ph-light ph-linkedin-logo"></i></a>
|
<div class="feature-card">
|
||||||
<a href="#" title="GitHub"><i class="ph-light ph-github-logo"></i></a>
|
<div class="feature-icon">
|
||||||
|
<i class="ph-light ph-cube"></i>
|
||||||
|
</div>
|
||||||
|
<h3>Easy-to-Use Editor</h3>
|
||||||
|
<p>Intuitive drag-and-drop interface makes creating tours simple and fast.</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature-card">
|
||||||
|
<div class="feature-icon">
|
||||||
|
<i class="ph-light ph-image"></i>
|
||||||
|
</div>
|
||||||
|
<h3>360° Support</h3>
|
||||||
|
<p>Upload 360° photos and create immersive panoramic experiences.</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature-card">
|
||||||
|
<div class="feature-icon">
|
||||||
|
<i class="ph-light ph-link"></i>
|
||||||
|
</div>
|
||||||
|
<h3>Interactive Hotspots</h3>
|
||||||
|
<p>Add clickable points of interest with images, text, and links.</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature-card">
|
||||||
|
<div class="feature-icon">
|
||||||
|
<i class="ph-light ph-device-mobile"></i>
|
||||||
|
</div>
|
||||||
|
<h3>Mobile Friendly</h3>
|
||||||
|
<p>Tours work perfectly on all devices, including VR headsets.</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
</footer>
|
<!-- How It Works Section -->
|
||||||
|
<section id="how-it-works" class="how-it-works">
|
||||||
<!-- Scripts -->
|
<div class="container">
|
||||||
<script src="{% static 'js/landing.bundle.js' %}"></script>
|
<h2>Create Your First Tour in 3 Easy Steps</h2>
|
||||||
</body>
|
<div class="steps">
|
||||||
</html>
|
<div class="step">
|
||||||
|
<div class="step-number">1</div>
|
||||||
|
<h3>Upload Your Photos</h3>
|
||||||
|
<p>Upload your 360° photos or regular images to get started.</p>
|
||||||
|
</div>
|
||||||
|
<div class="step">
|
||||||
|
<div class="step-number">2</div>
|
||||||
|
<h3>Add Interactivity</h3>
|
||||||
|
<p>Place hotspots, add information, and link scenes together.</p>
|
||||||
|
</div>
|
||||||
|
<div class="step">
|
||||||
|
<div class="step-number">3</div>
|
||||||
|
<h3>Share Your Tour</h3>
|
||||||
|
<p>Share via link or embed on your website.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<!-- Demo Section -->
|
||||||
|
<section id="demo" class="demo">
|
||||||
|
<div class="container">
|
||||||
|
<h2>See Quackscape in Action</h2>
|
||||||
|
<div class="demo-viewer">
|
||||||
|
<iframe src="/tours/scene/{{ demo_scene }}/embed/"
|
||||||
|
width="100%"
|
||||||
|
height="100%"
|
||||||
|
frameborder="0"
|
||||||
|
allowfullscreen></iframe>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<!-- Footer -->
|
||||||
|
<footer class="site-footer">
|
||||||
|
<div class="container">
|
||||||
|
<div class="footer-grid">
|
||||||
|
<div class="footer-brand">
|
||||||
|
<img src="{% static 'img/logo.svg' %}" alt="Quackscape Logo">
|
||||||
|
<h3>Quackscape</h3>
|
||||||
|
<p>Create immersive virtual experiences with ease.</p>
|
||||||
|
</div>
|
||||||
|
<div class="footer-links">
|
||||||
|
<h4>Quackscape</h4>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<a href="#features">Features</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="#showcase">Showcase</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="#demo">Demo</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="footer-links">
|
||||||
|
<h4>Legal</h4>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<a href="/privacy">Privacy Policy</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/terms">Terms of Service</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/cookies">Cookie Policy</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="footer-bottom">
|
||||||
|
<p>
|
||||||
|
Brought to you by <a href="https://private.coffee" target="_blank">Private.coffee</a>
|
||||||
|
</p>
|
||||||
|
<div class="social-links">
|
||||||
|
<a href="https://cuddly.space/@privatecoffee" title="Mastodon"><i class="ph-light ph-mastodon-logo"></i></a>
|
||||||
|
<a href="https://git.private.coffee/PrivateCoffee/quackscape"
|
||||||
|
title="Private.coffee Git"><i class="ph-light ph-git-branch"></i></a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
<!-- Scripts -->
|
||||||
|
<script src="{% static 'js/landing.bundle.js' %}"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue