completely ajaxify the picture page

This commit is contained in:
thomas girod 2024-09-03 20:15:37 +02:00
parent d545becf24
commit bc40b92744
12 changed files with 605 additions and 480 deletions

View File

@ -991,8 +991,8 @@ class SithFile(models.Model):
return user.is_board_member return user.is_board_member
if user.is_com_admin: if user.is_com_admin:
return True return True
if self.is_in_sas and user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID): if self.is_in_sas:
return True return user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID)
return user.id == self.owner_id return user.id == self.owner_id
def can_be_viewed_by(self, user): def can_be_viewed_by(self, user):

View File

@ -107,3 +107,35 @@ function update_query_string(key, value, action = History.REPLACE, url = null) {
return url; return url;
} }
/**
* Given a paginated endpoint, fetch all the items of this endpoint,
* performing multiple API calls if necessary.
* @param {string} url The paginated endpoint to fetch
* @return {Promise<Object[]>}
*/
async function fetch_paginated(url) {
const max_per_page = 199;
const paginated_url = new URL(url, document.location.origin);
paginated_url.searchParams.set("page_size", max_per_page.toString());
paginated_url.searchParams.set("page", "1");
let first_page = (await ( await fetch(paginated_url)).json());
let results = first_page.results;
const nb_pictures = first_page.count
const nb_pages = Math.ceil(nb_pictures / max_per_page);
if (nb_pages > 1) {
let promises = [];
for (let i = 2; i <= nb_pages; i++) {
paginated_url.searchParams.set("page", i.toString());
promises.push(
fetch(paginated_url).then(res => res.json().then(json => json.results))
);
}
results.push(...await Promise.all(promises))
}
return results;
}

View File

@ -65,17 +65,29 @@
{{ super() }} {{ super() }}
<script> <script>
/**
* @typedef UserProfile
* @property {number} id
* @property {string} first_name
* @property {string} last_name
* @property {string} nick_name
* @property {string} display_name
* @property {string} profile_url
* @property {string} profile_pict
*/
/** /**
* @typedef Picture * @typedef Picture
* @property {number} id * @property {number} id
* @property {string} name * @property {string} name
* @property {number} size * @property {number} size
* @property {string} date * @property {string} date
* @property {Object} author * @property {UserProfile} owner
* @property {string} full_size_url * @property {string} full_size_url
* @property {string} compressed_url * @property {string} compressed_url
* @property {string} thumb_url * @property {string} thumb_url
* @property {string} album * @property {string} album
* @property {boolean} is_moderated
* @property {boolean} asked_for_removal
*/ */
document.addEventListener("alpine:init", () => { document.addEventListener("alpine:init", () => {
@ -86,7 +98,7 @@
albums: {}, albums: {},
async init() { async init() {
this.pictures = await this.get_pictures(); this.pictures = await fetch_paginated("{{ url("api:pictures") }}" + "?users_identified={{ object.id }}");
this.albums = this.pictures.reduce((acc, picture) => { this.albums = this.pictures.reduce((acc, picture) => {
if (!acc[picture.album]){ if (!acc[picture.album]){
acc[picture.album] = []; acc[picture.album] = [];
@ -97,34 +109,6 @@
this.loading = false; this.loading = false;
}, },
/**
* @return {Promise<Picture[]>}
*/
async get_pictures() {
{# The API forbids to get more than 199 items at once
from paginated routes.
In order to download all the user pictures, it may be needed
to performs multiple requests #}
const max_per_page = 199;
const url = "{{ url("api:pictures") }}"
+ "?users_identified={{ object.id }}"
+ `&page_size=${max_per_page}`;
let first_page = (await ( await fetch(url)).json());
let promises = [first_page.results];
const nb_pictures = first_page.count
const nb_pages = Math.ceil(nb_pictures / max_per_page);
for (let i = 2; i <= nb_pages; i++) {
promises.push(
fetch(url + `&page=${i}`).then(res => res.json().then(json => json.results))
);
}
return (await Promise.all(promises)).flat()
},
async download_zip(){ async download_zip(){
this.is_downloading = true; this.is_downloading = true;
const bar = this.$refs.progress; const bar = this.$refs.progress;

File diff suppressed because it is too large Load Diff

View File

@ -7,7 +7,7 @@
msgid "" msgid ""
msgstr "" msgstr ""
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-09-17 11:54+0200\n" "POT-Creation-Date: 2024-09-03 15:22+0200\n"
"PO-Revision-Date: 2024-09-17 11:54+0200\n" "PO-Revision-Date: 2024-09-17 11:54+0200\n"
"Last-Translator: Sli <antoine@bartuccio.fr>\n" "Last-Translator: Sli <antoine@bartuccio.fr>\n"
"Language-Team: AE info <ae.info@utbm.fr>\n" "Language-Team: AE info <ae.info@utbm.fr>\n"
@ -19,3 +19,6 @@ msgstr ""
#: core/static/user/js/family_graph.js:230 #: core/static/user/js/family_graph.js:230
msgid "family_tree.%(extension)s" msgid "family_tree.%(extension)s"
msgstr "arbre_genealogique.%(extension)s" msgstr "arbre_genealogique.%(extension)s"
#: sas/static/sas/js/picture.js:52
msgid "Couldn't delete picture"
msgstr "Echec de la suppression de la photo"

View File

@ -9,14 +9,10 @@ from ninja_extra.permissions import IsAuthenticated
from ninja_extra.schemas import PaginatedResponseSchema from ninja_extra.schemas import PaginatedResponseSchema
from pydantic import NonNegativeInt from pydantic import NonNegativeInt
from core.api_permissions import CanView from core.api_permissions import CanView, IsOwner
from core.models import Notification, User from core.models import Notification, User
from sas.models import PeoplePictureRelation, Picture from sas.models import PeoplePictureRelation, Picture
from sas.schemas import ( from sas.schemas import IdentifiedUserSchema, PictureFilterSchema, PictureSchema
IdentifiedUserSchema,
PictureFilterSchema,
PictureSchema,
)
@api_controller("/sas/picture") @api_controller("/sas/picture")
@ -51,6 +47,7 @@ class PicturesController(ControllerBase):
filters.filter(Picture.objects.viewable_by(user)) filters.filter(Picture.objects.viewable_by(user))
.distinct() .distinct()
.order_by("-parent__date", "date") .order_by("-parent__date", "date")
.select_related("owner")
.annotate(album=F("parent__name")) .annotate(album=F("parent__name"))
) )
@ -88,6 +85,18 @@ class PicturesController(ControllerBase):
}, },
) )
@route.delete("/{picture_id}", permissions=[IsOwner])
def delete_picture(self, picture_id: int):
self.get_object_or_exception(Picture, pk=picture_id).delete()
@route.patch("/{picture_id}/moderate", permissions=[IsOwner])
def moderate_picture(self, picture_id: int):
picture = self.get_object_or_exception(Picture, pk=picture_id)
picture.is_moderated = True
picture.moderator = self.context.request.user
picture.asked_for_removal = False
picture.save()
@api_controller("/sas/relation", tags="User identification on SAS pictures") @api_controller("/sas/relation", tags="User identification on SAS pictures")
class UsersIdentifiedController(ControllerBase): class UsersIdentifiedController(ControllerBase):

View File

@ -17,8 +17,9 @@ class PictureFilterSchema(FilterSchema):
class PictureSchema(ModelSchema): class PictureSchema(ModelSchema):
class Meta: class Meta:
model = Picture model = Picture
fields = ["id", "name", "date", "size", "is_moderated"] fields = ["id", "name", "date", "size", "is_moderated", "asked_for_removal"]
owner: UserProfileSchema
full_size_url: str full_size_url: str
compressed_url: str compressed_url: str
thumb_url: str thumb_url: str

View File

@ -72,44 +72,30 @@
aspect-ratio: 16/9; aspect-ratio: 16/9;
background: #333333; background: #333333;
> a { position: relative;
img {
width: 100%;
height: 100%;
object-fit: cover;
opacity: 70%;
}
.overlay {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: white;
font-size: 40px;
}
> div {
display: flex; display: flex;
position: relative; position: relative;
width: 100%; width: 100%;
height: 100%; height: 100%;
> div {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
font-size: 30px;
color: white;
background-repeat: no-repeat;
background-position: center center;
background-size: cover;
&::before {
position: absolute;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background-color: rgba(0, 0, 0, .3);
}
}
} }
} }
> #prev > a > div::before {
content: '';
}
> #next > a > div::before {
content: '';
}
} }
> .tags { > .tags {
@ -304,20 +290,3 @@
} }
} }
} }
.moderation {
box-sizing: border-box;
width: 100%;
border: 2px solid coral;
border-radius: 2px;
padding: 10px;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
> div:last-child {
display: flex;
gap: 20px;
}
}

View File

@ -0,0 +1,167 @@
/**
* @typedef PictureIdentification
* @property {number} id The actual id of the identification
* @property {UserProfile} user The identified user
*/
document.addEventListener("alpine:init", () => {
Alpine.data("picture_viewer", () => ({
/**
* All the pictures that can be displayed on this picture viewer
* @type Picture[]
* */
pictures: [],
/**
* The users identified on the currently displayed picture
* @type PictureIdentification[]
*/
identifications: [],
/**
* The currently displayed picture
* @type Picture
* */
current_picture: undefined,
/**
* The picture which will be displayed next if the user press the "next" button
* @type ?Picture
* */
next_picture: null,
/**
* The picture which will be dispalyed next if the user press the "previous" button
* @type ?Picture
* */
previous_picture: null,
/**
* The select2 component used to identify users
*/
selector: undefined,
/**
* true if the page is in a loading state, else false
*/
loading: true,
/**
* Error message when a moderation operation fails
* @type string
*/
moderation_error: "",
async init() {
this.pictures = await fetch_paginated(picture_endpoint);
this.selector = sithSelect2({
element: $(this.$refs.search),
data_source: remote_data_source("/api/user/search", {
excluded: () => [...this.identifications.map((i) => i.user.id)],
result_converter: (obj) => Object({ ...obj, text: obj.display_name }),
}),
picture_getter: (user) => user.profile_pict,
});
this.current_picture = this.pictures.find(
(i) => i.id === first_picture_id,
);
this.$watch("current_picture", () => this.update_picture());
await this.update_picture();
},
/**
* Update the page.
* Called when the `current_picture` property changes.
*
* The url is modified without reloading the page,
* and the previous picture, the next picture and
* the list of identified users are updated.
*/
async update_picture() {
this.loading = true;
window.history.pushState(
{},
"",
`/sas/picture/${this.current_picture.id}/`,
);
this.moderation_error = "";
const index = this.pictures.indexOf(this.current_picture);
this.previous_picture = this.pictures[index - 1] || null;
this.next_picture = this.pictures[index + 1] || null;
this.identifications = await (
await fetch(`/api/sas/picture/${this.current_picture.id}/identified`)
).json();
this.loading = false;
},
async moderate_picture() {
const res = await fetch(
`/api/sas/picture/${this.current_picture.id}/moderate`,
{
method: "PATCH",
},
);
if (!res.ok) {
this.moderation_error =
gettext("Couldn't moderate picture") + " : " + res.statusText;
return;
}
this.current_picture.is_moderated = true;
this.current_picture.asked_for_removal = false;
},
async delete_picture() {
const res = await fetch(`/api/sas/picture/${this.current_picture}/`, {
method: "DELETE",
});
if (!res.ok) {
this.moderation_error =
gettext("Couldn't delete picture") + " : " + res.statusText;
return;
}
this.pictures.splice(this.pictures.indexOf(this.current_picture), 1);
if (this.pictures.length === 0) {
// The deleted picture was the only one in the list.
// As the album is now empty, go back to the parent page
document.location.href = album_url;
}
this.current_picture = this.next_picture || this.previous_picture;
},
/**
* Send the identification request and update the list of identified users.
*/
async submit_identification() {
this.loading = true;
const url = `/api/sas/picture/${this.current_picture.id}/identified`;
await fetch(url, {
method: "PUT",
body: JSON.stringify(this.selector.val().map((i) => parseInt(i))),
});
// refresh the identified users list
this.identifications = await (await fetch(url)).json();
this.selector.empty().trigger("change");
this.loading = false;
},
/**
* Check if an identification can be removed by the currently logged user
* @param {PictureIdentification} identification
* @return {boolean}
*/
can_be_removed(identification) {
return user_is_sas_admin || identification.user.id === user_id;
},
/**
* Untag a user from the current picture
* @param {PictureIdentification} identification
*/
async remove_identification(identification) {
this.loading = true;
const res = await fetch(`/api/sas/relation/${identification.id}`, {
method: "DELETE",
});
if (res.ok) {
this.identifications = this.identifications.filter(
(i) => i.id !== identification.id,
);
}
this.loading = false;
},
}));
});

View File

@ -1,52 +0,0 @@
document.addEventListener("alpine:init", () => {
Alpine.data("user_identification", () => ({
identifications: [],
selector: undefined,
async init() {
this.loading = true;
this.identifications = await (
await fetch(`/api/sas/picture/${picture_id}/identified`)
).json();
this.selector = sithSelect2({
element: $(this.$refs.search),
data_source: remote_data_source("/api/user/search", {
excluded: () => [...this.identifications.map((i) => i.user.id)],
result_converter: (obj) => Object({ ...obj, text: obj.display_name }),
}),
picture_getter: (user) => user.profile_pict,
});
this.loading = false;
},
async submit_identification() {
this.loading = true;
const url = `/api/sas/picture/${picture_id}/identified`;
await fetch(url, {
method: "PUT",
body: JSON.stringify(this.selector.val().map((i) => parseInt(i))),
});
// refresh the identified users list
this.identifications = await (await fetch(url)).json();
this.selector.empty().trigger("change");
this.loading = false;
},
can_be_removed(item) {
return user_is_sas_admin || item.user.id === user_id;
},
async remove(item) {
this.loading = true;
const res = await fetch(`/api/sas/relation/${item.id}`, {
method: "DELETE",
});
if (res.ok) {
this.identifications = this.identifications.filter(
(i) => i.id !== item.id,
);
}
this.loading = false;
},
}));
});

View File

@ -5,7 +5,7 @@
{%- endblock -%} {%- endblock -%}
{%- block additional_js -%} {%- block additional_js -%}
<script src="{{ static("sas/js/relation.js") }}"></script> <script defer src="{{ static("sas/js/picture.js") }}"></script>
{%- endblock -%} {%- endblock -%}
{% block title %} {% block title %}
@ -15,152 +15,157 @@
{% from "sas/macros.jinja" import print_path %} {% from "sas/macros.jinja" import print_path %}
{% block content %} {% block content %}
<code> <main x-data="picture_viewer">
<a href="{{ url('sas:main') }}">SAS</a> / {{ print_path(picture.parent) }} {{ picture.get_display_name() }} <code>
</code> <a href="{{ url('sas:main') }}">SAS</a> / {{ print_path(album) }} <span x-text="current_picture.name"></span>
</code>
<br> <br>
<div class="title"> <div class="title">
<h3>{{ picture.get_display_name() }}</h3> <h3 x-text="current_picture.name"></h3>
<h4>{{ picture.parent.children.filter(id__lte=picture.id).count() }} <h4 x-text="`${pictures.indexOf(current_picture)} / ${pictures.length}`"></h4>
/ {{ picture.parent.children.count() }}</h4>
</div>
<br>
{% if not picture.is_moderated %}
{% set next = picture.get_next() %}
{% if not next %}
{% set next = url('sas:moderation') %}
{% else %}
{% set next = next.get_absolute_url() + "#pict" %}
{% endif %}
<div class="moderation">
<div>
{% if picture.asked_for_removal %}
<span class="important">{% trans %}Asked for removal{% endtrans %}</span>
{% else %}
&nbsp;
{% endif %}
</div>
<div>
<a href="{{ url('core:file_moderate', file_id=picture.id) }}?next={{ next }}">
{% trans %}Moderate{% endtrans %}
</a>
<a href="{{ url('core:file_delete', file_id=picture.id) }}?next={{ next }}">
{% trans %}Delete{% endtrans %}
</a>
</div>
</div> </div>
{% endif %} <br>
<div class="container" id="pict"> <template x-if="!current_picture.is_moderated">
<div class="main"> <div class="alert alert-red">
<div class="alert-main">
<div class="photo"> <template x-if="current_picture.asked_for_removal">
<img src="{{ picture.get_download_compressed_url() }}" alt="{{ picture.get_display_name() }}"/> <span class="important">{% trans %}Asked for removal{% endtrans %}</span>
</div> </template>
<p>
<div class="general"> {% trans trimmed %}
<div class="infos"> This picture can be viewed only by root users and by SAS admins.
<h5>{% trans %}Infos{% endtrans %}</h5> It will be hidden to other users until it has been moderated.
{% endtrans %}
</p>
</div>
<div>
<div> <div>
<div> <button class="btn btn-blue" @click="moderate_picture()">
<span>{% trans %}Date: {% endtrans %}</span> {% trans %}Moderate{% endtrans %}
<span>{{ picture.date|date(DATETIME_FORMAT) }}</span> </button>
</div> <button class="btn btn-red" @click.prevent="delete_picture()">
<div> {% trans %}Delete{% endtrans %}
<span>{% trans %}Owner: {% endtrans %}</span> </button>
<a href="{{ picture.owner.get_absolute_url() }}">{{ picture.owner.get_short_name() }}</a> </div>
</div> <p x-show="!!moderation_error" x-text="moderation_error"></p>
</div>
</div>
</template>
{% if picture.moderator %} <div class="container" id="pict">
<div class="main">
<div class="photo" :aria-busy="loading">
<img :src="current_picture.compressed_url" :alt="current_picture.name"/>
</div>
<div class="general">
<div class="infos">
<h5>{% trans %}Infos{% endtrans %}</h5>
<div>
<div> <div>
<span>{% trans %}Moderator: {% endtrans %}</span> <span>{% trans %}Date: {% endtrans %}</span>
<a href="{{ picture.moderator.get_absolute_url() }}">{{ picture.moderator.get_short_name() }}</a> <span
x-text="Intl.DateTimeFormat(
'{{ LANGUAGE_CODE }}', {dateStyle: 'long'}
).format(new Date(current_picture.date))"
>
</span>
</div> </div>
{% endif %} <div>
<span>{% trans %}Owner: {% endtrans %}</span>
<a :href="current_picture.owner.profile_url" x-text="current_picture.owner.display_name"></a>
</div>
</div>
</div> </div>
</div>
<div class="tools"> <div class="tools">
<h5>{% trans %}Tools{% endtrans %}</h5> <h5>{% trans %}Tools{% endtrans %}</h5>
<div>
<div> <div>
<a class="text" href="{{ picture.get_download_url() }}"> <div>
{% trans %}HD version{% endtrans %} <a class="text" :href="current_picture.full_size_url">
</a> {% trans %}HD version{% endtrans %}
<br> </a>
<a class="text danger" href="?ask_removal">{% trans %}Ask for removal{% endtrans %}</a> <br>
</div> <a class="text danger" href="?ask_removal">{% trans %}Ask for removal{% endtrans %}</a>
<div class="buttons"> </div>
<a class="button" href="{{ url('sas:picture_edit', picture_id=picture.id) }}">✏️</a> <div class="buttons">
<a class="button" href="?rotate_left">↺</a> <a class="button" :href="`/sas/picture/${current_picture.id}/edit/`">✏️</a>
<a class="button" href="?rotate_right">↻</a> <a class="button" href="?rotate_left">↺</a>
<a class="button" href="?rotate_right">↻</a>
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div>
<div class="subsection"> <div class="subsection">
<div class="navigation" x-data> <div class="navigation">
<div id="prev"> <div id="prev" class="clickable">
{% if previous_pict %} <template x-if="previous_picture">
<a <div
href="{{ url( 'sas:picture', picture_id=previous_pict.id) }}#pict" @keyup.left.window="current_picture = previous_picture"
@keyup.left.window="$el.click()" @click="current_picture = previous_picture"
> >
<div style="background-image: url('{{ previous_pict.get_download_thumb_url() }}');"></div> <img :src="previous_picture.thumb_url" alt="{% trans %}Previous picture{% endtrans %}"/>
</a> <div class="overlay">←</div>
{% endif %} </div>
</template>
</div>
<div id="next" class="clickable">
<template x-if="next_picture">
<div
@keyup.right.window="current_picture = next_picture"
@click="current_picture = next_picture"
>
<img :src="next_picture.thumb_url" alt="{% trans %}Previous picture{% endtrans %}"/>
<div class="overlay">→</div>
</div>
</template>
</div>
</div> </div>
<div id="next">
{% if next_pict %} <div class="tags">
<a <h5>{% trans %}People{% endtrans %}</h5>
href="{{ url( 'sas:picture', picture_id=next_pict.id) }}#pict" {% if user.was_subscribed %}
@keyup.right.window="$el.click()" <form @submit.prevent="submit_identification" x-show="!!selector">
> <select x-ref="search" multiple="multiple"></select>
<div style="background-image: url('{{ next_pict.get_download_thumb_url() }}');"></div> <input type="submit" value="{% trans %}Go{% endtrans %}"/>
</a> </form>
{% endif %} {% endif %}
<ul>
<template x-for="identification in identifications" :key="identification.id">
<li>
<a class="user" :href="identification.user.profile_url">
<img class="profile-pic" :src="identification.user.profile_pict" alt="image de profil"/>
<span x-text="identification.user.display_name"></span>
</a>
<template x-if="can_be_removed(identification)">
<a class="delete clickable" @click="remove_identification(identification)">❌</a>
</template>
</li>
</template>
<template x-if="loading">
{# shadow element that exists only to put the loading wheel below
the list of identified people #}
<li class="loader" aria-busy="true"></li>
</template>
</ul>
</div> </div>
</div> </div>
<div class="tags" x-data="user_identification">
<h5>{% trans %}People{% endtrans %}</h5>
{% if user.was_subscribed %}
<form @submit.prevent="submit_identification" x-show="!!selector">
<select x-ref="search" multiple="multiple"></select>
<input type="submit" value="{% trans %}Go{% endtrans %}"/>
</form>
{% endif %}
<ul>
<template x-for="identification in identifications" :key="identification.id">
<li>
<a class="user" :href="identification.user.profile_url">
<img class="profile-pic" :src="identification.user.profile_pict" alt="image de profil"/>
<span x-text="identification.user.display_name"></span>
</a>
<template x-if="can_be_removed(identification)">
<a class="delete clickable" @click="remove(identification)">❌</a>
</template>
</li>
</template>
<template x-if="loading">
<li class="loader" aria-busy="true"></li>
</template>
</ul>
</div>
</div> </div>
</div> </main>
{% endblock %} {% endblock %}
{% block script %} {% block script %}
{{ super() }} {{ super() }}
<script> <script>
const picture_id = {{ picture.id }}; const picture_endpoint = "{{ url("api:pictures") + "?album_id=" + album.id|string }}";
const album_url = "{{ album.get_absolute_url() }}";
const first_picture_id = {{ picture.id }}; {# id of the first picture to show after page load #}
const user_id = {{ user.id }}; const user_id = {{ user.id }};
const user_is_sas_admin = {{ (user.is_root or user.is_in_group(pk = settings.SITH_GROUP_SAS_ADMIN_ID))|tojson }} const user_is_sas_admin = {{ (user.is_root or user.is_in_group(pk = settings.SITH_GROUP_SAS_ADMIN_ID))|tojson }}
</script> </script>

View File

@ -146,17 +146,9 @@ class PictureView(CanViewMixin, DetailView):
return super().get(request, *args, **kwargs) return super().get(request, *args, **kwargs)
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs) return super().get_context_data(**kwargs) | {
pictures_qs = Picture.objects.filter( "album": Album.objects.get(children=self.object)
parent_id=self.object.parent_id }
).viewable_by(self.request.user)
kwargs["next_pict"] = (
pictures_qs.filter(id__gt=self.object.id).order_by("id").first()
)
kwargs["previous_pict"] = (
pictures_qs.filter(id__lt=self.object.id).order_by("-id").first()
)
return kwargs
def send_album(request, album_id): def send_album(request, album_id):