feat: Improvements to teleports and markers

This commit is contained in:
Kumi 2025-06-26 15:42:38 +02:00
parent 3a71854100
commit 487243ab5e
Signed by: kumi
GPG key ID: ECBCC9082395383F
12 changed files with 688 additions and 346 deletions

4
.snapshotignore Normal file
View file

@ -0,0 +1,4 @@
poetry.lock
package-lock.json
*.svg
migrations/

View file

@ -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);
} }

View file

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

View file

@ -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");

View file

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

View file

@ -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);
}); });

View file

@ -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",
),
]

View 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",),
),
]

View 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",),
),
]

View file

@ -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):

View file

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

View file

@ -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>&copy; {% 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>