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 {
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);
}
.site-footer a {
color: var(--color-text-primary);
}
.footer-grid {
display: grid;
grid-template-columns: 2fr repeat(3, 1fr);
@ -294,9 +298,14 @@ body {
margin-bottom: 4rem;
}
.footer-brand h3 {
font-size: 1.5rem;
margin-bottom: 1rem;
display: inline-flex;
}
.footer-brand img {
height: 32px;
margin-bottom: 1rem;
}
.footer-brand p {

View file

@ -615,6 +615,7 @@ function createElementPreview(elementType, position, properties) {
switch (elementType) {
case "MarkerElement":
case "TeleportElement":
previewElement = document.createElement("a-entity");
previewElement.setAttribute("geometry", "primitive: sphere; radius: 0.2");
previewElement.setAttribute("material", "color: #4F46E5; shader: flat");
@ -633,12 +634,17 @@ function createElementPreview(elementType, position, properties) {
// Make the text always face the camera
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
previewElement.appendChild(textEntity);
break;
case "ImageElement":
case "TeleportElement":
previewElement = document.createElement("a-curvedimage");
previewElement.setAttribute("height", properties.height || 1);
previewElement.setAttribute("radius", radius);
@ -858,7 +864,6 @@ function addPositionInputListeners() {
if (posZInput) posZInput.addEventListener("change", updatePreviewPosition);
}
// Create element properties form
function createElementPropertiesForm(elementType, position = { x: 0, y: 0, z: -5 }) {
const elementProperties = document.getElementById("elementProperties");
if (!elementProperties) {
@ -866,87 +871,80 @@ function createElementPropertiesForm(elementType, position = { x: 0, y: 0, z: -5
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
let formHTML = `
<div class="form-group">
<label for="title" class="form-label">Title</label>
<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 class="form-group">
<label for="title" class="form-label">Title</label>
<input type="text" class="form-control" id="title" placeholder="Title" required>
</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 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 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 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
if (elementType === "ImageElement" || elementType === "TeleportElement") {
if (elementType === "ImageElement") {
formHTML += `
<div class="form-group">
<label for="elementUpload" class="form-label">Image</label>
<input id="elementUpload" type="file" class="form-control" accept="image/*" required>
<div class="form-text">Select an image to display</div>
</div>
<div class="form-group">
<label for="elementSize" class="form-label">Size</label>
<div class="input-group mb-2">
<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">
</div>
<div class="input-group">
<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">
</div>
</div>
`;
<div class="form-group">
<label for="elementUpload" class="form-label">Image</label>
<input id="elementUpload" type="file" class="form-control" accept="image/*" required>
<div class="form-text">Select an image to display</div>
</div>
<div class="form-group">
<label for="elementSize" class="form-label">Size</label>
<div class="input-group mb-2">
<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">
</div>
<div class="input-group">
<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">
</div>
</div>
`;
}
// Add teleport-specific fields
if (elementType === "TeleportElement") {
formHTML += `
<div class="form-group">
<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>
<input type="hidden" id="destination" required>
<div class="dropdown-menu" id="destinationDropdownMenu">
<!-- Dropdown items will be populated by JavaScript -->
</div>
</div>
<div class="form-group">
<label class="form-label">Destination View</label>
<div class="input-group mb-2">
<span class="input-group-text">X</span>
<input type="number" class="form-control" id="destination_x" name="destination_x" value="0" step="1">
</div>
<div class="input-group mb-2">
<span class="input-group-text">Y</span>
<input type="number" class="form-control" id="destination_y" name="destination_y" value="0" step="1">
</div>
<div class="input-group">
<span class="input-group-text">Z</span>
<input type="number" class="form-control" id="destination_z" name="destination_z" value="0" step="1">
</div>
<div class="form-text">Set the initial view when teleporting to the destination</div>
</div>
`;
<div class="form-group">
<label for="destinationDropdownSearch" class="form-label">Teleport Destination</label>
<div class="dropdown-container" style="position: relative;">
<input class="form-control" autocomplete="off" id="destinationDropdownSearch" type="text" placeholder="Search for a scene..." required>
<input type="hidden" id="destination" name="destination" required>
<div id="destinationDropdownMenu" style="width: 100%;"></div>
</div>
</div>
<div class="form-group">
<label class="form-label">Destination View</label>
<div class="input-group mb-2">
<span class="input-group-text">X</span>
<input type="number" class="form-control" id="destination_x" name="destination_x" value="0" step="1">
</div>
<div class="input-group mb-2">
<span class="input-group-text">Y</span>
<input type="number" class="form-control" id="destination_y" name="destination_y" value="0" step="1">
</div>
<div class="input-group">
<span class="input-group-text">Z</span>
<input type="number" class="form-control" id="destination_z" name="destination_z" value="0" step="1">
</div>
<div class="form-text">Set the initial view when teleporting to the destination</div>
</div>
`;
}
elementProperties.innerHTML = formHTML;
@ -962,6 +960,7 @@ function createElementPropertiesForm(elementType, position = { x: 0, y: 0, z: -5
populateDestinationDropdown(null, category);
}).catch(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() {
console.log("Saving new element");
@ -1196,16 +1194,27 @@ function saveNewElement() {
// Get form data
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);
// Get position values from form
const posX = parseFloat(document.getElementById("position_x").value) || 0;
const posY = parseFloat(document.getElementById("position_y").value) || 0;
const posZ = parseFloat(document.getElementById("position_z").value) || 0;
const posXEl = document.getElementById("position_x");
const posYEl = document.getElementById("position_y");
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
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("y", posY);
formData.append("z", posZ);
@ -1214,26 +1223,48 @@ function saveNewElement() {
console.log("Saving element at position:", { x: posX, y: posY, z: posZ });
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
if (elementType === "ImageElement" || elementType === "TeleportElement") {
formData.append("width", document.getElementById("width").value);
formData.append("height", document.getElementById("height").value);
if (elementType === "TeleportElement") {
const destinationEl = document.getElementById("destination");
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
const fileInput = document.getElementById("elementUpload");
if (fileInput && fileInput.files.length > 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
const saveButton = document.getElementById("saveButton");
if (saveButton) {
@ -1352,7 +1383,7 @@ function saveModifiedElement() {
}
// 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 destinationXInput = document.getElementById("destination_x");
const destinationYInput = document.getElementById("destination_y");

View file

@ -1,10 +1,5 @@
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) {
if (!category_data || !category_data.obj || !category_data.obj.scenes) {
console.error("Invalid category data");
@ -18,21 +13,40 @@ function populateDestinationDropdown(initial, category_data) {
const destinationField = document.getElementById("destination");
if (!dropdownMenu || !dropdownSearch || !destinationField) {
console.error("Dropdown elements not found");
console.error("Dropdown elements not found", {
dropdownMenu: !!dropdownMenu,
dropdownSearch: !!dropdownSearch,
destinationField: !!destinationField
});
return;
}
console.log("Setting up destination dropdown with items:", items.length);
// Clear existing items
dropdownMenu.innerHTML = "";
// Initialize dropdown
let dropdown;
try {
dropdown = new bootstrap.Dropdown(dropdownSearch);
} catch (error) {
console.error("Failed to initialize dropdown:", error);
return;
}
// Custom dropdown implementation
let isOpen = false;
// Toggle dropdown on click
dropdownSearch.addEventListener("click", function (event) {
event.stopPropagation();
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
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
items.forEach(item => {
// Get thumbnail
@ -56,27 +63,27 @@ function populateDestinationDropdown(initial, category_data) {
item.img = resolutions[0]?.file || '';
// Create dropdown item
const element = document.createElement("button");
element.setAttribute("type", "button");
element.classList.add("dropdown-item");
const element = document.createElement("div");
element.classList.add("destination-item");
element.innerHTML = `
<div class="dropdown-item-content">
<img src="${item.img}" alt="${item.title}" class="dropdown-thumbnail">
<span class="dropdown-item-text">${item.title}</span>
</div>
`;
<div class="destination-item-content">
<img src="${item.img}" alt="${item.title}" class="destination-thumbnail">
<span class="destination-item-text">${item.title}</span>
</div>
`;
// Add click handler
element.onclick = function () {
element.addEventListener("click", function () {
selectDestinationItem(item);
dropdown.hide();
};
dropdownMenu.style.display = "none";
isOpen = false;
});
dropdownMenu.appendChild(element);
});
// Add search functionality
dropdownSearch.addEventListener("keyup", function () {
dropdownSearch.addEventListener("input", function () {
const searchValue = dropdownSearch.value.toLowerCase();
const filteredItems = items.filter(item =>
item.title.toLowerCase().includes(searchValue)
@ -88,50 +95,54 @@ function populateDestinationDropdown(initial, category_data) {
// Repopulate with filtered items
if (filteredItems.length > 0) {
filteredItems.forEach(item => {
const element = document.createElement("button");
element.setAttribute("type", "button");
element.classList.add("dropdown-item");
const element = document.createElement("div");
element.classList.add("destination-item");
element.innerHTML = `
<div class="dropdown-item-content">
<img src="${item.img}" alt="${item.title}" class="dropdown-thumbnail">
<span class="dropdown-item-text">${item.title}</span>
</div>
`;
<div class="destination-item-content">
<img src="${item.img}" alt="${item.title}" class="destination-thumbnail">
<span class="destination-item-text">${item.title}</span>
</div>
`;
element.onclick = function () {
element.addEventListener("click", function () {
selectDestinationItem(item);
dropdown.hide();
};
dropdownMenu.style.display = "none";
isOpen = false;
});
dropdownMenu.appendChild(element);
});
dropdown.show();
// Show dropdown if it's not already visible
if (!isOpen) {
dropdownMenu.style.display = "block";
isOpen = true;
}
} else {
// Show "no results" message
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";
dropdownMenu.appendChild(noResults);
dropdown.show();
}
});
// Show dropdown when clicking on search field
dropdownSearch.addEventListener("click", function (event) {
event.stopPropagation();
dropdown.show();
// Show dropdown if it's not already visible
if (!isOpen) {
dropdownMenu.style.display = "block";
isOpen = true;
}
}
});
}
/**
* Select a destination item and update the form
* @param {Object} item - The selected destination item
*/
function selectDestinationItem(item) {
console.log("Selecting destination:", item.title, item.id);
// Set the hidden input value
const destinationField = document.getElementById("destination");
if (destinationField) {
destinationField.value = item.id;
} else {
console.error("Destination field not found when selecting item");
}
// Update the search field with the selected title
@ -148,9 +159,9 @@ function selectDestinationItem(item) {
container.id = "destinationPreview";
container.className = "destination-preview mt-2";
container.innerHTML = `
<img src="${item.img}" alt="${item.title}" class="img-fluid">
<div class="preview-title">${item.title}</div>
`;
<img src="${item.img}" alt="${item.title}" class="img-fluid">
<div class="preview-title">${item.title}</div>
`;
// Add after dropdown
const parentElement = dropdownSearch.parentElement;
@ -160,9 +171,9 @@ function selectDestinationItem(item) {
} else {
// Update existing preview
previewContainer.innerHTML = `
<img src="${item.img}" alt="${item.title}" class="img-fluid">
<div class="preview-title">${item.title}</div>
`;
<img src="${item.img}" alt="${item.title}" class="img-fluid">
<div class="preview-title">${item.title}</div>
`;
}
}

View file

@ -229,6 +229,20 @@ async function loadScene(
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);
});

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.contrib.auth import get_user_model
from django.conf import settings
from django.core.files.base import ContentFile
from polymorphic.models import PolymorphicModel
from PIL import Image
from PIL import Image, ImageDraw, ImageFont
from typing import Tuple
from pathlib import Path
import uuid
import math
import io
from .tasks import create_image_resolutions, create_video_resolutions
@ -130,7 +132,7 @@ class TextElement(ImageElement):
self.generate_image_from_text()
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
image = Image.new("RGB", (1, 1), color=(255, 255, 255))
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(
"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}"
def data(self) -> dict:
data = super().data()
data["attributes"][
"onclick"
] = f'window.loadScene("{self.destination.id}", {self.destination_x}, {self.destination_y}, {self.destination_z})'
return data
return {
"id": self.id,
"tag": "a-entity",
"attributes": {
"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):

View file

@ -11,6 +11,7 @@ from .models import (
TeleportElement,
TextElement,
ImageElement,
MarkerElement,
Category,
)
@ -21,6 +22,25 @@ class ElementSerializer(serializers.ModelSerializer):
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 Meta:
model = TeleportElement
@ -28,13 +48,20 @@ class TeleportElementSerializer(serializers.ModelSerializer):
"id",
"label",
"scene",
"x",
"y",
"z",
"destination",
"destination_x",
"destination_y",
"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):
@ -62,6 +89,7 @@ class ElementPolymorphicSerializer(PolymorphicSerializer):
TeleportElement: TeleportElementSerializer,
TextElement: TextElementSerializer,
ImageElement: ImageElementSerializer,
MarkerElement: MarkerElementSerializer,
}
@ -121,7 +149,9 @@ class OriginalVideoSerializer(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)
class Meta:
@ -133,10 +163,9 @@ class SceneSerializer(serializers.ModelSerializer):
if request and instance.user_has_view_permission(request.user):
# For GET requests, include the full base_content object
ret = super().to_representation(instance)
if self.context['request'].method == 'GET':
ret['base_content'] = OriginalMediaSerializer(
instance.base_content,
context=self.context
if self.context["request"].method == "GET":
ret["base_content"] = OriginalMediaSerializer(
instance.base_content, context=self.context
).data
return ret
else:

View file

@ -1,190 +1,189 @@
{% load static %}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Quackscape - Create Immersive Virtual Tours</title>
<!-- Meta tags -->
<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.">
<meta name="keywords" content="virtual tours, VR, 360 tours, virtual reality, tour builder">
<!-- Favicon -->
<link rel="icon" href="{% static 'img/favicon.png' %}" type="image/png">
<link rel="apple-touch-icon" href="{% static 'img/apple-touch-icon.png' %}">
<!-- Fonts -->
<link href="https://googledonts.private.coffee/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
</head>
<body>
<!-- Header/Navigation -->
<header class="site-header">
<nav class="container">
<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">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Quackscape - Create Immersive Virtual Tours</title>
<!-- Meta tags -->
<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.">
<meta name="keywords"
content="virtual tours, VR, 360 tours, virtual reality, tour builder">
<!-- Favicon -->
<link rel="icon" href="{% static 'img/favicon.png' %}" type="image/png">
<link rel="apple-touch-icon" href="{% static 'img/apple-touch-icon.png' %}">
<!-- Fonts -->
<link href="https://googledonts.private.coffee/css2?family=Inter:wght@300;400;500;600;700&display=swap"
rel="stylesheet">
</head>
<body>
<!-- Header/Navigation -->
<header class="site-header">
<nav class="container">
<a href="/" class="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 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 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>
<div class="footer-links">
<h4>About us</h4>
<ul>
<li><a href="#">About</a></li>
<li><a href="#">Blog</a></li>
<li><a href="#">Contact</a></li>
</ul>
<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="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 class="hero-image">
<img src="{% static 'img/hero-illustration.svg' %}"
alt="Quackscape Virtual Tour Creator">
</div>
</div>
<div class="footer-bottom">
<p>&copy; {% now "Y" %} Quackscape. All rights reserved.</p>
<div class="social-links">
<a href="#" title="Twitter"><i class="ph-light ph-twitter-logo"></i></a>
<a href="#" title="Facebook"><i class="ph-light ph-facebook-logo"></i></a>
<a href="#" title="LinkedIn"><i class="ph-light ph-linkedin-logo"></i></a>
<a href="#" title="GitHub"><i class="ph-light ph-github-logo"></i></a>
</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>
</div>
</footer>
<!-- Scripts -->
<script src="{% static 'js/landing.bundle.js' %}"></script>
</body>
</html>
</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">
<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>