Compare commits

...

2 commits

9 changed files with 176 additions and 79 deletions

View file

@ -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

File diff suppressed because one or more lines are too long

View 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;
}

View file

@ -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>

View file

@ -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):

View file

@ -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 %}

View file

@ -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"),
] ]

View file

@ -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

View file

@ -1,3 +1,3 @@
Django==3.0.2 Django==3.1.1
psycopg2-binary>=2.5.4 # required by Django for PostgreSQL support: https://docs.djangoproject.com/en/2.2/ref/databases/#postgresql-notes psycopg2-binary>=2.5.4 # required by Django for PostgreSQL support: https://docs.djangoproject.com/en/2.2/ref/databases/#postgresql-notes
django-crispy-forms==1.8.1 django-crispy-forms==1.9.2