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 {
|
||||
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);
|
||||
}
|
||||
|
||||
.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 {
|
||||
|
|
|
@ -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");
|
||||
|
|
|
@ -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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
|
||||
|
|
|
@ -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.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):
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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>© {% 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>
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue