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;}
|
||||
|
||||
|
||||
/* WoW class-coloured buttons */
|
||||
.btn-druid, .btn-class-1 {
|
||||
/* WoW class-coloured alerts */
|
||||
.alert-druid, .alert-class-1 {
|
||||
color: #fff;
|
||||
background-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;
|
||||
background-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;
|
||||
background-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;
|
||||
background-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;
|
||||
background-color: #ffffff;
|
||||
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;
|
||||
background-color: #fff569;
|
||||
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;
|
||||
background-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;
|
||||
background-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;
|
||||
background-color: #c79c6e;
|
||||
border-color: #c79c6e;
|
||||
}
|
||||
.btn-warrior:hover, .btn-class-9:hover {
|
||||
color: #fff;
|
||||
background-color: #b3895c;
|
||||
border-color: #b3895c;
|
||||
|
||||
|
||||
.response-character-alert {
|
||||
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 {
|
||||
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.. -->
|
||||
<script src="{% static 'js/jquery.slim.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 %}
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
@ -65,10 +65,16 @@ class RaidResponseForm(ModelForm):
|
|||
self.helper.form_tag = False
|
||||
|
||||
def clean_role(self):
|
||||
role = self.cleaned_data["role"]
|
||||
if role is None:
|
||||
return self.cleaned_data["character"].role
|
||||
return role
|
||||
return self.cleaned_data["role"] or self.cleaned_data["character"].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):
|
||||
|
|
|
@ -52,14 +52,13 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{% regroup raid.responses.all by get_status_display as status_responses_list %}
|
||||
{% for status, status_responses in status_responses_list %}
|
||||
{% regroup status_responses by get_group_display as group_responses_list %}
|
||||
{% for status, status_responses in raid_responses.items %}
|
||||
{% regroup status_responses by group as group_responses_list %}
|
||||
{% for group, group_responses in group_responses_list %}
|
||||
<div class="card mb-2">
|
||||
<h6 class="card-header d-flex response-status-{{ status | slugify }}-bg">
|
||||
<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 }}-bg">
|
||||
<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 type="button" class="badge badge-dark" data-toggle="modal" data-target="#exportModal">Export</span>
|
||||
</h6>
|
||||
|
@ -75,19 +74,22 @@
|
|||
<div class="d-flex flex-column mr-2 mb-4">
|
||||
{% regroup class_responses by character.user.rank as 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">
|
||||
<hr class="flex-grow-1 my-0">
|
||||
<div class="mx-2 my-0 font-weight-light text-muted small">{{ rank }}</div>
|
||||
<hr class="flex-grow-1 my-0">
|
||||
</div>
|
||||
-->
|
||||
{% 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 %}
|
||||
<abbr title="{{ response.character.user.main }}">{{ response.character.name }}</abbr>
|
||||
{% else %}
|
||||
{{ response.character.name }}
|
||||
{% endif %}
|
||||
</a>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
@ -96,6 +98,14 @@
|
|||
{% endfor %}
|
||||
</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 %}
|
||||
|
||||
|
@ -129,11 +139,81 @@
|
|||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
// Export button
|
||||
const charactersTextinput = document.querySelector("#characters-textinput");
|
||||
$("#exportModal").on("show.bs.modal", function (event) {
|
||||
charactersTextinput.textContent = $(event.relatedTarget).closest(".card").find(".response-character-button").map(function () {
|
||||
return this.text.trim();
|
||||
charactersTextinput.textContent = $(event.relatedTarget).closest(".card").find(".response-character-alert").map(function () {
|
||||
return this.innerText.trim();
|
||||
}).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>
|
||||
{% endblock scripts %}
|
|
@ -9,4 +9,5 @@ urlpatterns = [
|
|||
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>/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.mixins import PermissionRequiredMixin
|
||||
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.utils import timezone
|
||||
from django.views.generic import DeleteView, CreateView, MonthArchiveView
|
||||
from django.views.generic.detail import SingleObjectMixin
|
||||
from django.views.generic.edit import BaseUpdateView
|
||||
|
||||
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
|
||||
|
||||
User = get_user_model()
|
||||
|
@ -95,6 +97,17 @@ class RaidDetailView(SingleObjectMixin, MultiModelFormView):
|
|||
"comments__user__main"
|
||||
).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):
|
||||
if not self.request.user.is_authenticated:
|
||||
return {}
|
||||
|
@ -140,6 +153,22 @@ class RaidDetailView(SingleObjectMixin, MultiModelFormView):
|
|||
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):
|
||||
permission_required = "raids.add_raid"
|
||||
model = Raid
|
||||
|
|
Loading…
Reference in a new issue