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

@ -721,3 +721,98 @@ 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,12 +871,6 @@ 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">
@ -893,11 +892,10 @@ function createElementPropertiesForm(elementType, position = { x: 0, y: 0, z: -5
<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>
@ -923,10 +921,10 @@ function createElementPropertiesForm(elementType, position = { x: 0, y: 0, z: -5
formHTML += `
<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" required>
<div class="dropdown-menu" id="destinationDropdownMenu">
<!-- Dropdown items will be populated by JavaScript -->
<input type="hidden" id="destination" name="destination" required>
<div id="destinationDropdownMenu" style="width: 100%;"></div>
</div>
</div>
@ -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 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 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

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,23 +1,23 @@
{% load static %}
<!DOCTYPE html>
<html lang="en">
<head>
<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">
<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>
<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">
@ -25,28 +25,25 @@
<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>
<a href="#demo">Demo</a>
</div>
<div class="auth-buttons">
{% if user.is_authenticated %}
<a href="{% url 'quackscape.users:categories' %}" class="btn btn-primary">Dashboard</a>
<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">
@ -59,11 +56,11 @@
</div>
</div>
<div class="hero-image">
<img src="{% static 'img/hero-illustration.svg' %}" alt="Quackscape Virtual Tour Creator">
<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">
@ -100,7 +97,6 @@
</div>
</div>
</section>
<!-- How It Works Section -->
<section id="how-it-works" class="how-it-works">
<div class="container">
@ -124,67 +120,70 @@
</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>
<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>
<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>About us</h4>
<ul>
<li><a href="#">About</a></li>
<li><a href="#">Blog</a></li>
<li><a href="#">Contact</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>
<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>&copy; {% now "Y" %} Quackscape. All rights reserved.</p>
<p>
Brought to you by <a href="https://private.coffee" target="_blank">Private.coffee</a>
</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>
<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>
</body>
</html>