Allow drag 'n drop of raid responses.
This commit is contained in:
parent
a013d157ff
commit
fb6fcc3307
|
@ -20,104 +20,69 @@
|
||||||
.bg-warrior, .bg-class-9 {background-color: #c79c6e; color: #fff;}
|
.bg-warrior, .bg-class-9 {background-color: #c79c6e; color: #fff;}
|
||||||
|
|
||||||
|
|
||||||
/* WoW class-coloured buttons */
|
/* WoW class-coloured alerts */
|
||||||
.btn-druid, .btn-class-1 {
|
.alert-druid, .alert-class-1 {
|
||||||
color: #fff;
|
color: #fff;
|
||||||
background-color: #ff7d0a;
|
background-color: #ff7d0a;
|
||||||
border-color: #ff7d0a;
|
border-color: #ff7d0a;
|
||||||
}
|
}
|
||||||
.btn-druid:hover, .btn-class-1:hover {
|
|
||||||
color: #fff;
|
|
||||||
background-color: #eb7c0a;
|
|
||||||
border-color: #eb7c0a;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-hunter, .btn-class-2 {
|
.alert-hunter, .alert-class-2 {
|
||||||
color: #212529;
|
color: #212529;
|
||||||
background-color: #abd473;
|
background-color: #abd473;
|
||||||
border-color: #abd473;
|
border-color: #abd473;
|
||||||
}
|
}
|
||||||
.btn-hunter:hover, .btn-class-2:hover {
|
|
||||||
color: #212529;
|
|
||||||
background-color: #98c062;
|
|
||||||
border-color: #98c062;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-mage, .btn-class-3 {
|
.alert-mage, .alert-class-3 {
|
||||||
color: #fff;
|
color: #fff;
|
||||||
background-color: #69ccf0;
|
background-color: #69ccf0;
|
||||||
border-color: #69ccf0;
|
border-color: #69ccf0;
|
||||||
}
|
}
|
||||||
.btn-mage:hover, .btn-class-3:hover {
|
|
||||||
color: #fff;
|
|
||||||
background-color: #67b8dc;
|
|
||||||
border-color: #67b8dc;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-paladin, .btn-class-4 {
|
.alert-paladin, .alert-class-4 {
|
||||||
color: #fff;
|
color: #fff;
|
||||||
background-color: #f58cba;
|
background-color: #f58cba;
|
||||||
border-color: #f58cba;
|
border-color: #f58cba;
|
||||||
}
|
}
|
||||||
.btn-paladin:hover, .btn-class-4:hover {
|
|
||||||
color: #fff;
|
|
||||||
background-color: #e17aa7;
|
|
||||||
border-color: #e17aa7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-priest, .btn-class-5 {
|
.alert-priest, .alert-class-5 {
|
||||||
color: #212529;
|
color: #212529;
|
||||||
background-color: #ffffff;
|
background-color: #ffffff;
|
||||||
border-color: #ced4da;
|
border-color: #ced4da;
|
||||||
}
|
}
|
||||||
.btn-priest:hover, .btn-class-5:hover {
|
|
||||||
color: #212529;
|
|
||||||
background-color: #ebebeb;
|
|
||||||
border-color: #ced2d6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-rogue, .btn-class-6 {
|
.alert-rogue, .alert-class-6 {
|
||||||
color: #212529;
|
color: #212529;
|
||||||
background-color: #fff569;
|
background-color: #fff569;
|
||||||
border-color: #f5eb63;
|
border-color: #f5eb63;
|
||||||
}
|
}
|
||||||
.btn-rogue:hover, .btn-class-6:hover {
|
|
||||||
color: #212529;
|
|
||||||
background-color: #ebe168;
|
|
||||||
border-color: #e1d761;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-shaman, .btn-class-7 {
|
.alert-shaman, .alert-class-7 {
|
||||||
color: #fff;
|
color: #fff;
|
||||||
background-color: #0070de;
|
background-color: #0070de;
|
||||||
border-color: #0070de;
|
border-color: #0070de;
|
||||||
}
|
}
|
||||||
.btn-shaman:hover, .btn-class-7:hover {
|
|
||||||
color: #fff;
|
|
||||||
background-color: #0065ca;
|
|
||||||
border-color: #0065ca;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-warlock, .btn-class-8 {
|
.alert-warlock, .alert-class-8 {
|
||||||
color: #fff;
|
color: #fff;
|
||||||
background-color: #9482c9;
|
background-color: #9482c9;
|
||||||
border-color: #9482c9;
|
border-color: #9482c9;
|
||||||
}
|
}
|
||||||
.btn-warlock:hover, .btn-class-8:hover {
|
|
||||||
color: #fff;
|
|
||||||
background-color: #816fb5;
|
|
||||||
border-color: #816fb5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-warrior, .btn-class-9 {
|
.alert-warrior, .alert-class-9 {
|
||||||
color: #fff;
|
color: #fff;
|
||||||
background-color: #c79c6e;
|
background-color: #c79c6e;
|
||||||
border-color: #c79c6e;
|
border-color: #c79c6e;
|
||||||
}
|
}
|
||||||
.btn-warrior:hover, .btn-class-9:hover {
|
|
||||||
color: #fff;
|
|
||||||
background-color: #b3895c;
|
.response-character-alert {
|
||||||
border-color: #b3895c;
|
width: 16ch;
|
||||||
|
padding: .375rem .75rem; /* from bootstrap's .btn */
|
||||||
|
text-align: center;
|
||||||
|
touch-action: none; /* required by interact.js */
|
||||||
|
user-select: none; /* required by interact.js */
|
||||||
|
-moz-user-select: none; /* required by interact.js */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -210,8 +175,3 @@ a.response-status-confirmed-bg:hover, a.response-status-5-bg:hover {
|
||||||
.o-50 {
|
.o-50 {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.response-character-button {
|
|
||||||
width: 16ch;
|
|
||||||
}
|
|
||||||
|
|
3
drakul/base/static/js/interact.min.js
vendored
Normal file
3
drakul/base/static/js/interact.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
16
drakul/base/static/js/main.js
Normal file
16
drakul/base/static/js/main.js
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
function getCookie(name) {
|
||||||
|
// From https://docs.djangoproject.com/en/3.1/ref/csrf/
|
||||||
|
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();
|
||||||
|
// Does this cookie string begin with the name we want?
|
||||||
|
if (cookie.substring(0, name.length + 1) === (name + '=')) {
|
||||||
|
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cookieValue;
|
||||||
|
}
|
|
@ -61,6 +61,8 @@
|
||||||
<!-- We're only really using JavaScript to control the navbar expansion for mobile devices.. -->
|
<!-- We're only really using JavaScript to control the navbar expansion for mobile devices.. -->
|
||||||
<script src="{% static 'js/jquery.slim.min.js' %}"></script>
|
<script src="{% static 'js/jquery.slim.min.js' %}"></script>
|
||||||
<script src="{% static 'js/bootstrap.bundle.min.js' %}"></script>
|
<script src="{% static 'js/bootstrap.bundle.min.js' %}"></script>
|
||||||
|
<script src="{% static 'js/interact.min.js' %}"></script>
|
||||||
|
<script src="{% static 'js/main.js' %}"></script>
|
||||||
{% block scripts %}{% endblock scripts %}
|
{% block scripts %}{% endblock scripts %}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
@ -65,10 +65,16 @@ class RaidResponseForm(ModelForm):
|
||||||
self.helper.form_tag = False
|
self.helper.form_tag = False
|
||||||
|
|
||||||
def clean_role(self):
|
def clean_role(self):
|
||||||
role = self.cleaned_data["role"]
|
return self.cleaned_data["role"] or self.cleaned_data["character"].role
|
||||||
if role is None:
|
|
||||||
return self.cleaned_data["character"].role
|
|
||||||
return role
|
class RaidResponseChangeForm(ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = RaidResponse
|
||||||
|
fields = ["role", "status", "group"]
|
||||||
|
|
||||||
|
def clean_role(self):
|
||||||
|
return self.cleaned_data["role"] or self.instance.character.role
|
||||||
|
|
||||||
|
|
||||||
class RaidForm(ModelForm):
|
class RaidForm(ModelForm):
|
||||||
|
|
|
@ -52,14 +52,13 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% regroup raid.responses.all by get_status_display as status_responses_list %}
|
{% for status, status_responses in raid_responses.items %}
|
||||||
{% for status, status_responses in status_responses_list %}
|
{% regroup status_responses by group as group_responses_list %}
|
||||||
{% regroup status_responses by get_group_display as group_responses_list %}
|
|
||||||
{% for group, group_responses in group_responses_list %}
|
{% for group, group_responses in group_responses_list %}
|
||||||
<div class="card mb-2">
|
<div class="card mb-2 response-container" data-response-container-status="{{ status }}" data-response-container-group="{{ group }}">
|
||||||
<h6 class="card-header d-flex response-status-{{ status | slugify }}-bg">
|
<h6 class="card-header d-flex response-status-{{ status }}-bg">
|
||||||
<span class="mr-auto">
|
<span class="mr-auto">
|
||||||
{{ status }}{% if group_responses_list|length > 1 %}: {{ group }}{% endif %} ({{ group_responses | length }})
|
{{ status.label }}{% if group_responses_list|length > 1 %}: {{ group_responses.0.get_group_display }}{% endif %} ({{ group_responses | length }})
|
||||||
</span>
|
</span>
|
||||||
<span type="button" class="badge badge-dark" data-toggle="modal" data-target="#exportModal">Export</span>
|
<span type="button" class="badge badge-dark" data-toggle="modal" data-target="#exportModal">Export</span>
|
||||||
</h6>
|
</h6>
|
||||||
|
@ -75,19 +74,22 @@
|
||||||
<div class="d-flex flex-column mr-2 mb-4">
|
<div class="d-flex flex-column mr-2 mb-4">
|
||||||
{% regroup class_responses by character.user.rank as rank_responses_list %}
|
{% regroup class_responses by character.user.rank as rank_responses_list %}
|
||||||
{% for rank, rank_responses in rank_responses_list %}
|
{% for rank, rank_responses in rank_responses_list %}
|
||||||
|
<!-- TODO: Uncomment to show user ranks in raid signup
|
||||||
<div class="d-flex justify-content-center align-items-center">
|
<div class="d-flex justify-content-center align-items-center">
|
||||||
<hr class="flex-grow-1 my-0">
|
<hr class="flex-grow-1 my-0">
|
||||||
<div class="mx-2 my-0 font-weight-light text-muted small">{{ rank }}</div>
|
<div class="mx-2 my-0 font-weight-light text-muted small">{{ rank }}</div>
|
||||||
<hr class="flex-grow-1 my-0">
|
<hr class="flex-grow-1 my-0">
|
||||||
</div>
|
</div>
|
||||||
|
-->
|
||||||
{% for response in rank_responses %}
|
{% for response in rank_responses %}
|
||||||
<a class="btn response-character-button mb-1 btn-class-{{ response.character.klass }}" role="button" href="#">
|
<div class="alert response-character-alert mb-1 alert-class-{{ response.character.klass }}" role="alert" data-response-id="{{ response.id }}"
|
||||||
|
{% if response.role is not None %} data-response-role="{{ response.role }}"{% endif %}>
|
||||||
{% if response.character != response.character.user.main %}
|
{% if response.character != response.character.user.main %}
|
||||||
<abbr title="{{ response.character.user.main }}">{{ response.character.name }}</abbr>
|
<abbr title="{{ response.character.user.main }}">{{ response.character.name }}</abbr>
|
||||||
{% else %}
|
{% else %}
|
||||||
{{ response.character.name }}
|
{{ response.character.name }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</a>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
@ -96,6 +98,14 @@
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% empty %}
|
||||||
|
{% if perms.raids.change_raid %}
|
||||||
|
<div class="card mb-2 response-container" style="opacity: 0.4" data-response-container-status="{{ status }}" data-response-container-group="1">
|
||||||
|
<h6 class="card-header d-flex response-status-{{ status }}-bg">
|
||||||
|
<span class="mr-auto">{{ status.label }}</span>
|
||||||
|
</h6>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
||||||
|
@ -129,11 +139,81 @@
|
||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
<script>
|
<script>
|
||||||
|
// Export button
|
||||||
const charactersTextinput = document.querySelector("#characters-textinput");
|
const charactersTextinput = document.querySelector("#characters-textinput");
|
||||||
$("#exportModal").on("show.bs.modal", function (event) {
|
$("#exportModal").on("show.bs.modal", function (event) {
|
||||||
charactersTextinput.textContent = $(event.relatedTarget).closest(".card").find(".response-character-button").map(function () {
|
charactersTextinput.textContent = $(event.relatedTarget).closest(".card").find(".response-character-alert").map(function () {
|
||||||
return this.text.trim();
|
return this.innerText.trim();
|
||||||
}).get().join("\n");
|
}).get().join("\n");
|
||||||
})
|
});
|
||||||
|
|
||||||
|
{% if perms.raids.change_raid %}
|
||||||
|
// Raid response dragging
|
||||||
|
const csrftoken = getCookie("csrftoken");
|
||||||
|
let original;
|
||||||
|
const position = {};
|
||||||
|
interact(".response-character-alert").draggable({
|
||||||
|
manualStart: true,
|
||||||
|
listeners: {
|
||||||
|
start(event) {
|
||||||
|
original.style.opacity = "0.5";
|
||||||
|
|
||||||
|
let boundingClientRect = original.getBoundingClientRect();
|
||||||
|
event.target.style.position = "fixed";
|
||||||
|
event.target.style.top = boundingClientRect.top + "px";
|
||||||
|
event.target.style.left = boundingClientRect.left + "px";
|
||||||
|
position.x = 0;
|
||||||
|
position.y = 0;
|
||||||
|
position.pageYOffset = window.pageYOffset;
|
||||||
|
},
|
||||||
|
move(event) {
|
||||||
|
position.x += event.dx;
|
||||||
|
position.y += event.dy;
|
||||||
|
event.target.style.transform = `translate(${position.x}px, ${position.y + (position.pageYOffset - window.pageYOffset)}px)`;
|
||||||
|
},
|
||||||
|
end(event) {
|
||||||
|
// remove the clone and restore opacity on the original element
|
||||||
|
event.target.remove();
|
||||||
|
original.style.opacity = null;
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}).on("move", function (event) {
|
||||||
|
// Move clone of draggable elements instead of original (https://interactjs.io/docs/faq#clone-target-draggable)
|
||||||
|
// return if the pointer was moved without being held down or an interaction is already in progress
|
||||||
|
if (!event.interaction.pointerIsDown || event.interaction.interacting()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
original = event.currentTarget;
|
||||||
|
let clone = original.cloneNode({deep: true});
|
||||||
|
document.body.appendChild(clone);
|
||||||
|
event.interaction.start({name: "drag"}, event.interactable, clone);
|
||||||
|
});
|
||||||
|
|
||||||
|
interact(".response-container").dropzone({
|
||||||
|
accept: ".response-character-alert",
|
||||||
|
ondrop(event) {
|
||||||
|
// ignore if dropped into its original dropzone
|
||||||
|
if (event.target.closest(".response-container") === original.closest(".response-container")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let form = new FormData();
|
||||||
|
if (event.relatedTarget.dataset.responseRole) {
|
||||||
|
form.append("role", event.relatedTarget.dataset.responseRole);
|
||||||
|
}
|
||||||
|
form.append("status", event.target.dataset.responseContainerStatus);
|
||||||
|
form.append("group", event.target.dataset.responseContainerGroup);
|
||||||
|
fetch(`/raids/responses/${event.relatedTarget.dataset.responseId}/change/`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"X-CSRFToken": csrftoken,
|
||||||
|
},
|
||||||
|
mode: "same-origin",
|
||||||
|
body: form,
|
||||||
|
}).then(function (response) {
|
||||||
|
location.reload();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
{% endif %}
|
||||||
</script>
|
</script>
|
||||||
{% endblock scripts %}
|
{% endblock scripts %}
|
|
@ -9,4 +9,5 @@ urlpatterns = [
|
||||||
path("raids/<int:pk>/", views.RaidDetailView.as_view(), name="raid_detail"),
|
path("raids/<int:pk>/", views.RaidDetailView.as_view(), name="raid_detail"),
|
||||||
path("raids/<int:pk>/change/", views.RaidChangeView.as_view(), name="raid_change"),
|
path("raids/<int:pk>/change/", views.RaidChangeView.as_view(), name="raid_change"),
|
||||||
path("raids/<int:pk>/delete/", views.RaidDeleteView.as_view(), name="raid_delete"),
|
path("raids/<int:pk>/delete/", views.RaidDeleteView.as_view(), name="raid_delete"),
|
||||||
|
path("raids/responses/<int:pk>/change/", views.RaidResponseChangeView.as_view(), name="raid_response_change"),
|
||||||
]
|
]
|
||||||
|
|
|
@ -5,14 +5,16 @@ from datetime import timedelta
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.contrib.auth.mixins import PermissionRequiredMixin
|
from django.contrib.auth.mixins import PermissionRequiredMixin
|
||||||
from django.db.models import Q, Max
|
from django.db.models import Q, Max
|
||||||
from django.http import Http404
|
from django.http import Http404, HttpResponse
|
||||||
from django.urls import reverse, reverse_lazy
|
from django.urls import reverse, reverse_lazy
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.views.generic import DeleteView, CreateView, MonthArchiveView
|
from django.views.generic import DeleteView, CreateView, MonthArchiveView
|
||||||
from django.views.generic.detail import SingleObjectMixin
|
from django.views.generic.detail import SingleObjectMixin
|
||||||
|
from django.views.generic.edit import BaseUpdateView
|
||||||
|
|
||||||
from drakul.base.views import MultiModelFormView
|
from drakul.base.views import MultiModelFormView
|
||||||
from .forms import RaidResponseForm, RaidCommentForm, RaidForm, RaidResponseFormSetHelper, RaidResponseFormSet
|
from .forms import RaidResponseForm, RaidCommentForm, RaidForm, RaidResponseFormSetHelper, RaidResponseFormSet, \
|
||||||
|
RaidResponseChangeForm
|
||||||
from .models import Raid, RaidResponse, InstanceReset
|
from .models import Raid, RaidResponse, InstanceReset
|
||||||
|
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
|
@ -95,6 +97,17 @@ class RaidDetailView(SingleObjectMixin, MultiModelFormView):
|
||||||
"comments__user__main"
|
"comments__user__main"
|
||||||
).all()
|
).all()
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
"""
|
||||||
|
Include empty RaidResponse categories (statuses) returned for a given raid, to allow drag 'n dropping into it.
|
||||||
|
"""
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
raid_responses = {status: [] for status in reversed(RaidResponse.Status)}
|
||||||
|
for status, responses in itertools.groupby(context["raid"].responses.all(), key=lambda r: r.status):
|
||||||
|
raid_responses[status] = list(responses)
|
||||||
|
context["raid_responses"] = raid_responses
|
||||||
|
return context
|
||||||
|
|
||||||
def get_form_classes(self):
|
def get_form_classes(self):
|
||||||
if not self.request.user.is_authenticated:
|
if not self.request.user.is_authenticated:
|
||||||
return {}
|
return {}
|
||||||
|
@ -140,6 +153,22 @@ class RaidDetailView(SingleObjectMixin, MultiModelFormView):
|
||||||
return super().post(request, *args, **kwargs)
|
return super().post(request, *args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class RaidResponseChangeView(PermissionRequiredMixin, BaseUpdateView):
|
||||||
|
"""
|
||||||
|
For RaidResponse drag 'n dropping.
|
||||||
|
"""
|
||||||
|
permission_required = "raids.change_raid"
|
||||||
|
model = RaidResponse
|
||||||
|
form_class = RaidResponseChangeForm
|
||||||
|
|
||||||
|
def form_valid(self, form):
|
||||||
|
form.save()
|
||||||
|
return HttpResponse(status=204)
|
||||||
|
|
||||||
|
def form_invalid(self, form):
|
||||||
|
return HttpResponse(form, status=400)
|
||||||
|
|
||||||
|
|
||||||
class RaidCreateView(PermissionRequiredMixin, CreateView):
|
class RaidCreateView(PermissionRequiredMixin, CreateView):
|
||||||
permission_required = "raids.add_raid"
|
permission_required = "raids.add_raid"
|
||||||
model = Raid
|
model = Raid
|
||||||
|
|
Loading…
Reference in a new issue