diff --git a/assets/css/userarea.css b/assets/css/userarea.css index b94a06d..68b79d5 100644 --- a/assets/css/userarea.css +++ b/assets/css/userarea.css @@ -1,3 +1,5 @@ +/* Base "admin" layout */ + body { background-color: #2d2d30; color: #ccc; @@ -22,7 +24,6 @@ body { background-color: #1e1e1e; color: #9cdcfe; padding: 20px; - height: calc(100vh - 50px); overflow-y: auto; } @@ -62,6 +63,8 @@ body { border-color: #3c3c3c; } +/* Tabs */ + .nav-tabs { border-bottom: 1px solid #444; } @@ -88,6 +91,8 @@ body { border-radius: 0.25rem; } +/* Tables */ + .dataTables_wrapper { font-family: 'Courier New', monospace; color: #9CDCFE; @@ -162,4 +167,63 @@ table.dataTable thead .sorting_desc_disabled:after { table.dataTable { width: 100% !important; -} \ No newline at end of file +} + +/* Uploads */ + +.drop-zone { + max-width: 480px; + height: 150px; + padding: 25px; + display: flex; + align-items: center; + justify-content: center; + text-align: center; + border: 2px dashed #009578; + border-radius: 10px; + font-family: sans-serif; + margin: 0 auto; + cursor: pointer; +} + +.drop-zone--over { + background-color: #f0f0f0; +} + +.drop-zone__input { + display: none; +} + +.drop-zone__prompt { + color: #cccccc; + font-size: 20px; +} + +.file-upload-wrapper { + margin-top: 20px; +} + +.file-details { + display: flex; + align-items: center; + justify-content: space-between; +} + +.file-name { + flex-grow: 1; + padding-right: 10px; +} + +.thumbnail { + width: 128px; + height: 64px; + object-fit: cover; +} + +.progress { + width: 100%; +} + +.error-message { + color: red; +} diff --git a/assets/js/api.js b/assets/js/api.js index 29abb52..707222e 100644 --- a/assets/js/api.js +++ b/assets/js/api.js @@ -38,7 +38,6 @@ function getSceneElement(scene_uuid, uuid) { } function getCategory(category) { - return api .then( (client) => @@ -51,4 +50,20 @@ function getCategory(category) { ); } -export { getScene, getSceneElement, getCategory }; +// Function to get the CSRF token cookie. Not exactly "API", but fits here best. +function getCookie(name) { + let cookieValue = null; + if (document.cookie && document.cookie !== "") { + const cookies = document.cookie.split(";"); + for (let i = 0; i < cookies.length; i++) { + const cookie = cookies[i].trim(); + if (cookie.substring(0, name.length + 1) === name + "=") { + cookieValue = decodeURIComponent(cookie.substring(name.length + 1)); + break; + } + } + } + return cookieValue; +} + +export { getScene, getSceneElement, getCategory, getCookie }; diff --git a/assets/js/editor.js b/assets/js/editor.js index 72dae51..7abf96b 100644 --- a/assets/js/editor.js +++ b/assets/js/editor.js @@ -1,26 +1,10 @@ -import { getScene, getSceneElement, getCategory } from "./api"; +import { getScene, getSceneElement, getCategory, getCookie } from "./api"; import { populateDestinationDropdown } from "./editor/teleport"; import "../css/editor.css"; let clickTimestamp = 0; -// Function to get the CSRF token cookie -function getCookie(name) { - let cookieValue = null; - if (document.cookie && document.cookie !== "") { - const cookies = document.cookie.split(";"); - for (let i = 0; i < cookies.length; i++) { - const cookie = cookies[i].trim(); - if (cookie.substring(0, name.length + 1) === name + "=") { - cookieValue = decodeURIComponent(cookie.substring(name.length + 1)); - break; - } - } - } - return cookieValue; -} - // Find parent quackscape-scene for ID function findParentScene(element) { var parent = element.parentElement; diff --git a/assets/js/userarea.js b/assets/js/userarea.js index 2a8ba53..576f179 100644 --- a/assets/js/userarea.js +++ b/assets/js/userarea.js @@ -1,9 +1,130 @@ import '../scss/frontend.scss'; import '../css/userarea.css'; +import { getCookie } from './api'; + import { Tab } from 'bootstrap'; import DataTable from 'datatables.net-dt'; let mediaTable = new DataTable('#mediaTable'); let scenesTable = new DataTable('#scenesTable'); -let permissionsTable = new DataTable('#permissionsTable'); \ No newline at end of file +let permissionsTable = new DataTable('#permissionsTable'); + +/* Uploads */ + +document.querySelectorAll('.drop-zone__input').forEach(inputElement => { + const dropZoneElement = inputElement.closest('.drop-zone'); + + dropZoneElement.addEventListener('click', e => { + inputElement.click(); + }); + + dropZoneElement.addEventListener('dragover', e => { + e.preventDefault(); + dropZoneElement.classList.add('drop-zone--over'); + }); + + ['dragleave', 'dragend', 'drop'].forEach(type => { + dropZoneElement.addEventListener(type, e => { + dropZoneElement.classList.remove('drop-zone--over'); + }); + }); + + dropZoneElement.addEventListener('drop', e => { + e.preventDefault(); + if (e.dataTransfer.files.length) { + inputElement.files = e.dataTransfer.files; + handleFiles(inputElement.files); + } + }); + + inputElement.addEventListener('change', e => { + if (inputElement.files.length) { + handleFiles(inputElement.files); + } + }); +}); + +function handleFiles(files) { + const uploadStatus = document.getElementById('uploadStatus'); + + Array.from(files).forEach(file => { + const fileRow = document.createElement('div'); + fileRow.classList.add('row', 'align-items-center', 'mb-2', 'file-upload-wrapper'); + + const thumbnailCol = document.createElement('div'); + thumbnailCol.classList.add('col-2'); + const thumbnail = document.createElement('img'); + thumbnail.classList.add('img-fluid', 'thumbnail'); + thumbnail.src = ''; // Placeholder until upload completes + thumbnailCol.appendChild(thumbnail); + + const fileNameCol = document.createElement('div'); + fileNameCol.classList.add('col-7'); + const fileName = document.createElement('span'); + fileName.classList.add('file-name'); + fileName.textContent = file.name; + fileNameCol.appendChild(fileName); + + const progressCol = document.createElement('div'); + progressCol.classList.add('col-3'); + const progressBar = document.createElement('div'); + progressBar.classList.add('progress'); + const progressBarInner = document.createElement('div'); + progressBarInner.classList.add('progress-bar'); + progressBarInner.setAttribute('role', 'progressbar'); + progressBarInner.setAttribute('aria-valuemin', '0'); + progressBarInner.setAttribute('aria-valuemax', '100'); + progressBarInner.style.width = '0%'; + progressBar.appendChild(progressBarInner); + progressCol.appendChild(progressBar); + + fileRow.appendChild(thumbnailCol); + fileRow.appendChild(fileNameCol); + fileRow.appendChild(progressCol); + + uploadStatus.prepend(fileRow); + + uploadFile(file, progressBarInner, thumbnail); + }); +} + +function uploadFile(file, progressBar, thumbnail) { + const xhr = new XMLHttpRequest(); + const formData = new FormData(); + + formData.append('file', file); + formData.append('csrfmiddlewaretoken', getCookie('csrftoken')); + + if (file.type.startsWith('image')) { + formData.append('media_type', 'image'); + } else if (file.type.startsWith('video')) { + formData.append('media_type', 'video'); + } else { + formData.append('media_type', 'other'); + } + + formData.append('title', file.name); + + xhr.upload.addEventListener('progress', (e) => { + if (e.lengthComputable) { + const percentComplete = (e.loaded / e.total) * 100; + progressBar.style.width = `${percentComplete}%`; + progressBar.textContent = `${Math.round(percentComplete)}%`; + } + }); + + xhr.open('POST', document.location.href, true); + + xhr.onload = () => { + if (xhr.status === 200) { + const response = JSON.parse(xhr.responseText); + const thumbnailUrl = response.thumbnailUrl; + thumbnail.src = thumbnailUrl; + } else { + progressBar.classList.add('bg-danger'); + progressBar.textContent = "Error!"; + } + }; + xhr.send(formData); +} diff --git a/quackscape/settings.py b/quackscape/settings.py index 2875966..76e600b 100644 --- a/quackscape/settings.py +++ b/quackscape/settings.py @@ -216,6 +216,8 @@ QUACKSCAPE_CONTENT_RESOLUTIONS = [ (65536, 32768), ] +MAX_IMAGE_PIXELS = 34359738368 # 262144x131072 - should be enough, no? + # ffmpeg settings FFMPEG_OPTIONS = { diff --git a/quackscape/tours/models.py b/quackscape/tours/models.py index 28d5fd4..615722c 100644 --- a/quackscape/tours/models.py +++ b/quackscape/tours/models.py @@ -1,6 +1,7 @@ from django.db import models from django.contrib.auth import get_user_model from django.urls import reverse_lazy +from django.conf import settings from polymorphic.models import PolymorphicModel from PIL import Image @@ -49,8 +50,12 @@ class Category(models.Model): class CategoryPermission(models.Model): - category = models.ForeignKey(Category, related_name="permissions", on_delete=models.CASCADE) - user = models.ForeignKey(get_user_model(), related_name="category_permissions", on_delete=models.CASCADE) + category = models.ForeignKey( + Category, related_name="permissions", on_delete=models.CASCADE + ) + user = models.ForeignKey( + get_user_model(), related_name="category_permissions", on_delete=models.CASCADE + ) # TODO: Permission levels @@ -230,7 +235,9 @@ class OriginalImage(OriginalMedia): def media_type(self) -> str: return "image" - def save(self): + def save(self, *args, **kwargs): + Image.MAX_IMAGE_PIXELS = settings.MAX_IMAGE_PIXELS + if not self.width: with Image.open(self.file) as img: self.width, self.height = img.size @@ -244,6 +251,13 @@ class OriginalVideo(OriginalMedia): def media_type(self) -> str: return "video" + def save(self, *args, **kwargs): + super().save(*args, **kwargs) + + # TODO: Get and save video resolution + + create_video_resolutions(self.id) + class MediaResolution(models.Model): id = models.UUIDField(default=uuid.uuid4, primary_key=True) diff --git a/quackscape/tours/serializers.py b/quackscape/tours/serializers.py index 81ae034..db5a262 100644 --- a/quackscape/tours/serializers.py +++ b/quackscape/tours/serializers.py @@ -4,6 +4,8 @@ from rest_polymorphic.serializers import PolymorphicSerializer from .models import ( Scene, OriginalMedia, + OriginalImage, + OriginalVideo, MediaResolution, Element, TeleportElement, @@ -84,6 +86,32 @@ class OriginalMediaSerializer(serializers.ModelSerializer): return None +class OriginalImageSerializer(serializers.ModelSerializer): + class Meta: + model = OriginalImage + fields = ["id", "title", "file"] + + def to_representation(self, instance): + request = self.context.get("request") + if request and instance.user_has_view_permission(request.user): + return super().to_representation(instance) + else: + return None + + +class OriginalVideoSerializer(serializers.ModelSerializer): + class Meta: + model = OriginalVideo + fields = ["id", "title", "width", "height", "file"] + + def to_representation(self, instance): + request = self.context.get("request") + if request and instance.user_has_view_permission(request.user): + return super().to_representation(instance) + else: + return None + + class SceneSerializer(serializers.ModelSerializer): base_content = OriginalMediaSerializer() elements = ElementSerializer(many=True, read_only=True) diff --git a/quackscape/tours/tasks.py b/quackscape/tours/tasks.py index e4b1e44..7e45d33 100644 --- a/quackscape/tours/tasks.py +++ b/quackscape/tours/tasks.py @@ -15,6 +15,8 @@ import uuid @shared_task def create_image_resolutions(image: "OriginalImage"): + Image.MAX_IMAGE_PIXELS = settings.MAX_IMAGE_PIXELS + OriginalImage = apps.get_model("tours", "OriginalImage") if isinstance(image, (str, uuid.UUID)): diff --git a/quackscape/users/templates/users/media_upload.html b/quackscape/users/templates/users/media_upload.html new file mode 100644 index 0000000..e82c46a --- /dev/null +++ b/quackscape/users/templates/users/media_upload.html @@ -0,0 +1,12 @@ +{% extends "users/base.html" %} + +{% block content %} +

Upload new images or videos

+
+
+ Drop files here or click to upload + +
+
+
+{% endblock %} \ No newline at end of file diff --git a/quackscape/users/urls.py b/quackscape/users/urls.py index 138dafe..ef3c78e 100644 --- a/quackscape/users/urls.py +++ b/quackscape/users/urls.py @@ -1,4 +1,4 @@ -from .views import UserAreaMainView, CategoriesView, CategoryView +from .views import UserAreaMainView, CategoriesView, CategoryView, FileUploadView from django.urls import path from django.contrib.auth.views import LogoutView, LoginView @@ -7,6 +7,7 @@ urlpatterns = [ path('', UserAreaMainView.as_view(), name='user-area-main'), path('categories/', CategoriesView.as_view(), name='categories'), path('category//', CategoryView.as_view(), name='category'), + path('category//upload/', FileUploadView.as_view(), name='media-upload'), path('login/', LoginView.as_view(), name='login'), path('logout/', LogoutView.as_view(), name='logout'), ] \ No newline at end of file diff --git a/quackscape/users/views.py b/quackscape/users/views.py index 87dbaeb..6dffba1 100644 --- a/quackscape/users/views.py +++ b/quackscape/users/views.py @@ -1,7 +1,17 @@ from django.views.generic import TemplateView, ListView, DetailView from django.http import Http404 +from rest_framework.parsers import MultiPartParser, FormParser +from rest_framework.response import Response +from rest_framework.generics import GenericAPIView +from rest_framework import status + from quackscape.tours.models import Category +from quackscape.tours.serializers import ( + OriginalMediaSerializer, + OriginalImageSerializer, + OriginalVideoSerializer, +) class TitleMixin: @@ -54,3 +64,32 @@ class MediaUploadView(TitleMixin, TemplateView): class CategoryCreateView(TitleMixin, TemplateView): template_name = "users/category_create.html" title = "Create Category" + + +class FileUploadView(GenericAPIView): + parser_classes = (MultiPartParser, FormParser) + + def get(self, request, *args, **kwargs): + return MediaUploadView.as_view()(request) + + def post(self, request, *args, **kwargs): + media_type = request.data.get("media_type") + if media_type == "image": + serializer_class = OriginalImageSerializer + elif media_type == "video": + serializer_class = OriginalVideoSerializer + else: + return Response( + {"error": "Invalid media type"}, status=status.HTTP_400_BAD_REQUEST + ) + + serializer = serializer_class(data=request.data) + + if serializer.is_valid(): + instance.category = request.kwargs["category"] + instance = serializer.save() + instance.refresh_from_db() + return Response(OriginalMediaSerializer(instance).data, status=status.HTTP_201_CREATED) + + else: + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)