feat: Introduce file upload functionality
This commit overhauls the user area with the addition of comprehensive file upload capabilities for images and videos. Notably, it integrates front-end enhancements for drag-and-drop uploads in the user area and introduces a secure back-end handling process. The back-end adjustments include a new media upload view and adjustments to image and video models to support large file handling through settings for maximum image pixel limits. Additionally, the refactor standardizes CSRF token retrieval across JavaScript modules, improving security and code maintainability. - Front-end additions include detailed user feedback during the file upload process, such as progress bars and success/error indicators. - Back-end improvements ensure large image files are processed efficiently, mitigating potential memory issues by configuring a maximum image pixel threshold. - Consolidating the CSRF token retrieval into the `api.js` module centralizes security mechanisms, reducing redundancy and enhancing the codebase's clarity. Overall, these changes enrich the platform's media management capabilities, bolster security practices, and improve user experience through intuitive interface updates and robust back-end processing.
This commit is contained in:
parent
06fb7ac04e
commit
7d78c5d4a0
11 changed files with 308 additions and 26 deletions
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
|
|
|
@ -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 };
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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');
|
||||
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);
|
||||
}
|
||||
|
|
|
@ -216,6 +216,8 @@ QUACKSCAPE_CONTENT_RESOLUTIONS = [
|
|||
(65536, 32768),
|
||||
]
|
||||
|
||||
MAX_IMAGE_PIXELS = 34359738368 # 262144x131072 - should be enough, no?
|
||||
|
||||
# ffmpeg settings
|
||||
|
||||
FFMPEG_OPTIONS = {
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)):
|
||||
|
|
12
quackscape/users/templates/users/media_upload.html
Normal file
12
quackscape/users/templates/users/media_upload.html
Normal file
|
@ -0,0 +1,12 @@
|
|||
{% extends "users/base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<h4>Upload new images or videos</h4>
|
||||
<div class="container mt-5">
|
||||
<div class="drop-zone">
|
||||
<span class="drop-zone__prompt">Drop files here or click to upload</span>
|
||||
<input type="file" name="myFiles" class="drop-zone__input" multiple>
|
||||
</div>
|
||||
<div class="mt-3" id="uploadStatus"></div>
|
||||
</div>
|
||||
{% endblock %}
|
|
@ -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/<uuid:category>/', CategoryView.as_view(), name='category'),
|
||||
path('category/<uuid:category>/upload/', FileUploadView.as_view(), name='media-upload'),
|
||||
path('login/', LoginView.as_view(), name='login'),
|
||||
path('logout/', LogoutView.as_view(), name='logout'),
|
||||
]
|
|
@ -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)
|
||||
|
|
Loading…
Reference in a new issue