diff --git a/api/config/settings/common.py b/api/config/settings/common.py index c789c36af4c7e5005275fed2760760169aa78c6b..b860b1c3ea0a48001245fc1cc2d142beef278e27 100644 --- a/api/config/settings/common.py +++ b/api/config/settings/common.py @@ -467,3 +467,13 @@ MUSIC_DIRECTORY_SERVE_PATH = env( USERS_INVITATION_EXPIRATION_DAYS = env.int( "USERS_INVITATION_EXPIRATION_DAYS", default=14 ) + +VERSATILEIMAGEFIELD_RENDITION_KEY_SETS = { + "square": [ + ("original", "url"), + ("square_crop", "crop__400x400"), + ("medium_square_crop", "crop__200x200"), + ("small_square_crop", "crop__50x50"), + ] +} +VERSATILEIMAGEFIELD_SETTINGS = {"create_images_on_demand": False} diff --git a/api/funkwhale_api/common/management/commands/script.py b/api/funkwhale_api/common/management/commands/script.py index cbfc78f0f5dfc14e1ea3f69ba72a5a075004bbb2..7f8d5c15df223e31edb779e2d0eaac5111d5cb4c 100644 --- a/api/funkwhale_api/common/management/commands/script.py +++ b/api/funkwhale_api/common/management/commands/script.py @@ -19,7 +19,7 @@ class Command(BaseCommand): def handle(self, *args, **options): name = options["script_name"] if not name: - self.show_help() + return self.show_help() available_scripts = self.get_scripts() try: diff --git a/api/funkwhale_api/common/scripts/__init__.py b/api/funkwhale_api/common/scripts/__init__.py index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..863256ba81cc7c8a3419c9b2386851bd5387d3ed 100644 --- a/api/funkwhale_api/common/scripts/__init__.py +++ b/api/funkwhale_api/common/scripts/__init__.py @@ -0,0 +1,6 @@ +from . import create_image_variations +from . import django_permissions_to_user_permissions +from . import test + + +__all__ = ["create_image_variations", "django_permissions_to_user_permissions", "test"] diff --git a/api/funkwhale_api/common/scripts/create_image_variations.py b/api/funkwhale_api/common/scripts/create_image_variations.py new file mode 100644 index 0000000000000000000000000000000000000000..5e941ce1fe7c731eca94b3885d24ac7d68d02030 --- /dev/null +++ b/api/funkwhale_api/common/scripts/create_image_variations.py @@ -0,0 +1,30 @@ +""" +Compute different sizes of image used for Album covers and User avatars +""" + +from versatileimagefield.image_warmer import VersatileImageFieldWarmer + +from funkwhale_api.music.models import Album +from funkwhale_api.users.models import User + + +MODELS = [(Album, "cover", "square"), (User, "avatar", "square")] + + +def main(command, **kwargs): + for model, attribute, key_set in MODELS: + qs = model.objects.exclude(**{"{}__isnull".format(attribute): True}) + qs = qs.exclude(**{attribute: ""}) + warmer = VersatileImageFieldWarmer( + instance_or_queryset=qs, + rendition_key_set=key_set, + image_attr=attribute, + verbose=True, + ) + command.stdout.write( + "Creating images for {} / {}".format(model.__name__, attribute) + ) + num_created, failed_to_create = warmer.warm() + command.stdout.write( + " {} created, {} in error".format(num_created, len(failed_to_create)) + ) diff --git a/api/funkwhale_api/favorites/views.py b/api/funkwhale_api/favorites/views.py index 61b5bee6ce2e132d6bc85d810497b8dfdf3250a5..ae47e03f251cb5e89972e716c36563d6b3e9f5ca 100644 --- a/api/funkwhale_api/favorites/views.py +++ b/api/funkwhale_api/favorites/views.py @@ -18,7 +18,11 @@ class TrackFavoriteViewSet( ): serializer_class = serializers.UserTrackFavoriteSerializer - queryset = models.TrackFavorite.objects.all() + queryset = ( + models.TrackFavorite.objects.all() + .select_related("track__artist", "track__album__artist", "user") + .prefetch_related("track__files") + ) permission_classes = [ permissions.ConditionalAuthentication, permissions.OwnerPermission, diff --git a/api/funkwhale_api/music/models.py b/api/funkwhale_api/music/models.py index 207b22dfb6745df5124e329736c5990e9ef2b941..c8dd61313df9e94d392847c5855efe783c2e6775 100644 --- a/api/funkwhale_api/music/models.py +++ b/api/funkwhale_api/music/models.py @@ -15,7 +15,9 @@ from django.dispatch import receiver from django.urls import reverse from django.utils import timezone from taggit.managers import TaggableManager + from versatileimagefield.fields import VersatileImageField +from versatileimagefield.image_warmer import VersatileImageFieldWarmer from funkwhale_api import downloader, musicbrainz from funkwhale_api.federation import utils as federation_utils @@ -641,3 +643,13 @@ def update_request_status(sender, instance, created, **kwargs): # let's mark the request as imported since the import is over instance.import_request.status = "imported" return instance.import_request.save(update_fields=["status"]) + + +@receiver(models.signals.post_save, sender=Album) +def warm_album_covers(sender, instance, **kwargs): + if not instance.cover: + return + album_covers_warmer = VersatileImageFieldWarmer( + instance_or_queryset=instance, rendition_key_set="square", image_attr="cover" + ) + num_created, failed_to_create = album_covers_warmer.warm() diff --git a/api/funkwhale_api/music/serializers.py b/api/funkwhale_api/music/serializers.py index 14ea54d51a22283e1c82f230da51ecab862b2c14..0661eb8f4dcd8dadc27c52dacec793d3ba9bab9b 100644 --- a/api/funkwhale_api/music/serializers.py +++ b/api/funkwhale_api/music/serializers.py @@ -1,6 +1,7 @@ from django.db.models import Q from rest_framework import serializers from taggit.models import Tag +from versatileimagefield.serializers import VersatileImageFieldSerializer from funkwhale_api.activity import serializers as activity_serializers from funkwhale_api.users.serializers import UserBasicSerializer @@ -8,8 +9,12 @@ from funkwhale_api.users.serializers import UserBasicSerializer from . import models, tasks +cover_field = VersatileImageFieldSerializer(allow_null=True, sizes="square") + + class ArtistAlbumSerializer(serializers.ModelSerializer): tracks_count = serializers.SerializerMethodField() + cover = cover_field class Meta: model = models.Album @@ -87,6 +92,7 @@ class AlbumTrackSerializer(serializers.ModelSerializer): class AlbumSerializer(serializers.ModelSerializer): tracks = serializers.SerializerMethodField() artist = ArtistSimpleSerializer(read_only=True) + cover = cover_field class Meta: model = models.Album @@ -111,6 +117,7 @@ class AlbumSerializer(serializers.ModelSerializer): class TrackAlbumSerializer(serializers.ModelSerializer): artist = ArtistSimpleSerializer(read_only=True) + cover = cover_field class Meta: model = models.Album @@ -156,6 +163,8 @@ class TagSerializer(serializers.ModelSerializer): class SimpleAlbumSerializer(serializers.ModelSerializer): + cover = cover_field + class Meta: model = models.Album fields = ("id", "mbid", "title", "release_date", "cover") diff --git a/api/funkwhale_api/playlists/serializers.py b/api/funkwhale_api/playlists/serializers.py index 71b8f315ade121cbf0d4ce270b5263973eab11b0..a60a349387020a2dd110c9f101fef0bdfce0510a 100644 --- a/api/funkwhale_api/playlists/serializers.py +++ b/api/funkwhale_api/playlists/serializers.py @@ -107,7 +107,7 @@ class PlaylistSerializer(serializers.ModelSerializer): covers = [] max_covers = 5 for plt in plts: - url = plt.track.album.cover.url + url = plt.track.album.cover.crop["200x200"].url if url in covers: continue covers.append(url) diff --git a/api/funkwhale_api/users/models.py b/api/funkwhale_api/users/models.py index a56406d8bb92f80f9d060930cc902706919a2ed2..6cef3900b36cde2e0ce39da081881b606f76e164 100644 --- a/api/funkwhale_api/users/models.py +++ b/api/funkwhale_api/users/models.py @@ -11,12 +11,14 @@ import uuid from django.conf import settings from django.contrib.auth.models import AbstractUser from django.db import models +from django.dispatch import receiver from django.urls import reverse from django.utils import timezone from django.utils.encoding import python_2_unicode_compatible from django.utils.translation import ugettext_lazy as _ from versatileimagefield.fields import VersatileImageField +from versatileimagefield.image_warmer import VersatileImageFieldWarmer from funkwhale_api.common import fields, preferences from funkwhale_api.common import utils as common_utils @@ -205,3 +207,13 @@ class Invitation(models.Model): ) return super().save(**kwargs) + + +@receiver(models.signals.post_save, sender=User) +def warm_user_avatar(sender, instance, **kwargs): + if not instance.avatar: + return + user_avatar_warmer = VersatileImageFieldWarmer( + instance_or_queryset=instance, rendition_key_set="square", image_attr="avatar" + ) + num_created, failed_to_create = user_avatar_warmer.warm() diff --git a/api/funkwhale_api/users/serializers.py b/api/funkwhale_api/users/serializers.py index a13a44c81a8fdeabb6ba9b4287d7ae8a84bb1449..74b060222e5e9b6d35a926a63327722670ca8565 100644 --- a/api/funkwhale_api/users/serializers.py +++ b/api/funkwhale_api/users/serializers.py @@ -45,15 +45,7 @@ class UserActivitySerializer(activity_serializers.ModelSerializer): return "Person" -avatar_field = VersatileImageFieldSerializer( - allow_null=True, - sizes=[ - ("original", "url"), - ("square_crop", "crop__400x400"), - ("medium_square_crop", "crop__200x200"), - ("small_square_crop", "crop__50x50"), - ], -) +avatar_field = VersatileImageFieldSerializer(allow_null=True, sizes="square") class UserBasicSerializer(serializers.ModelSerializer): diff --git a/api/tests/music/test_serializers.py b/api/tests/music/test_serializers.py index 0d7400dfc7c70092a406574ea23754c531313c65..8705354f7b2090bb756690c7d9283c96e6e33da7 100644 --- a/api/tests/music/test_serializers.py +++ b/api/tests/music/test_serializers.py @@ -12,7 +12,12 @@ def test_artist_album_serializer(factories, to_api_date): "artist": album.artist.id, "creation_date": to_api_date(album.creation_date), "tracks_count": 1, - "cover": album.cover.url, + "cover": { + "original": album.cover.url, + "square_crop": album.cover.crop["400x400"].url, + "medium_square_crop": album.cover.crop["200x200"].url, + "small_square_crop": album.cover.crop["50x50"].url, + }, "release_date": to_api_date(album.release_date), } serializer = serializers.ArtistAlbumSerializer(album) @@ -83,7 +88,12 @@ def test_album_serializer(factories, to_api_date): "title": album.title, "artist": serializers.ArtistSimpleSerializer(album.artist).data, "creation_date": to_api_date(album.creation_date), - "cover": album.cover.url, + "cover": { + "original": album.cover.url, + "square_crop": album.cover.crop["400x400"].url, + "medium_square_crop": album.cover.crop["200x200"].url, + "small_square_crop": album.cover.crop["50x50"].url, + }, "release_date": to_api_date(album.release_date), "tracks": serializers.AlbumTrackSerializer([track2, track1], many=True).data, } diff --git a/api/tests/playlists/test_serializers.py b/api/tests/playlists/test_serializers.py index 42569f7a3283a365103b1aa315a6d318db376c2f..79765a24bd71b76bb121c20ba5b0026d49851013 100644 --- a/api/tests/playlists/test_serializers.py +++ b/api/tests/playlists/test_serializers.py @@ -80,11 +80,11 @@ def test_playlist_serializer_include_covers(factories, api_request): qs = playlist.__class__.objects.with_covers().with_tracks_count() expected = [ - request.build_absolute_uri(t1.album.cover.url), - request.build_absolute_uri(t2.album.cover.url), - request.build_absolute_uri(t4.album.cover.url), - request.build_absolute_uri(t5.album.cover.url), - request.build_absolute_uri(t6.album.cover.url), + request.build_absolute_uri(t1.album.cover.crop["200x200"].url), + request.build_absolute_uri(t2.album.cover.crop["200x200"].url), + request.build_absolute_uri(t4.album.cover.crop["200x200"].url), + request.build_absolute_uri(t5.album.cover.crop["200x200"].url), + request.build_absolute_uri(t6.album.cover.crop["200x200"].url), ] serializer = serializers.PlaylistSerializer(qs.get(), context={"request": request}) diff --git a/changes/changelog.d/image.enhancement b/changes/changelog.d/image.enhancement new file mode 100644 index 0000000000000000000000000000000000000000..d0ad52c9f6e44cca81a07f4827f7549e117aaa69 --- /dev/null +++ b/changes/changelog.d/image.enhancement @@ -0,0 +1,25 @@ +Use thumbnails for avatars and covers to reduce bandwidth + + +Image thumbnails [Manual action required] +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +To reduce bandwidth usage on slow or limited connexions and improve performance +in general, we now use smaller images in the front-end. For instance, if you have +an album cover with a 1000x1000 pixel size, we will create smaller +versions of this image (50x50, 200x200, 400x400) and reference those resized version +when we don't actually need the original image. + +Thumbnail will be created automatically for new objects, however, you have +to launch a manual command to deal with existing ones. + +On docker setups:: + + docker-compose run --rm api python manage.py script create_image_variations --no-input + +On non-docker setups:: + + python manage.py script create_image_variations --no-input + +This should be quite fast but may take up to a few minutes depending on the number +of albums you have in database. It is safe to interrupt the process or rerun it multiple times. diff --git a/front/src/components/Sidebar.vue b/front/src/components/Sidebar.vue index 6f744d74f3be33315a6d1dd04fa40f5f41d6116d..4a3f42c773a23c268a943ef77cbdd4bcb33eab13 100644 --- a/front/src/components/Sidebar.vue +++ b/front/src/components/Sidebar.vue @@ -133,7 +133,7 @@ <tr @click="$store.dispatch('queue/currentIndex', index)" v-for="(track, index) in tracks" :key="index" :class="[{'active': index === queue.currentIndex}]"> <td class="right aligned">{{ index + 1}}</td> <td class="center aligned"> - <img class="ui mini image" v-if="track.album.cover" :src="$store.getters['instance/absoluteUrl'](track.album.cover)"> + <img class="ui mini image" v-if="track.album.cover && track.album.cover.original" :src="$store.getters['instance/absoluteUrl'](track.album.cover.small_square_crop)"> <img class="ui mini image" v-else src="../assets/audio/default-cover.png"> </td> <td colspan="4"> diff --git a/front/src/components/audio/Player.vue b/front/src/components/audio/Player.vue index 84b076208e835ca3efdd6223cb939674e09c7d23..704121d92e2fb98f0688634bbd184bb768258fd8 100644 --- a/front/src/components/audio/Player.vue +++ b/front/src/components/audio/Player.vue @@ -14,7 +14,7 @@ <div v-if="currentTrack" class="track-area ui unstackable items"> <div class="ui inverted item"> <div class="ui tiny image"> - <img ref="cover" @load="updateBackground" v-if="currentTrack.album.cover" :src="$store.getters['instance/absoluteUrl'](currentTrack.album.cover)"> + <img ref="cover" @load="updateBackground" v-if="currentTrack.album.cover && currentTrack.album.cover.original" :src="$store.getters['instance/absoluteUrl'](currentTrack.album.cover.medium_square_crop)"> <img v-else src="../../assets/audio/default-cover.png"> </div> <div class="middle aligned content"> diff --git a/front/src/components/audio/album/Card.vue b/front/src/components/audio/album/Card.vue index 71f2ec80db5def9c8542a07331534226cb241a4a..0c5a7c8032aea8e2c501b7005ce78950874c81ab 100644 --- a/front/src/components/audio/album/Card.vue +++ b/front/src/components/audio/album/Card.vue @@ -2,7 +2,7 @@ <div class="ui card"> <div class="content"> <div class="right floated tiny ui image"> - <img v-if="album.cover" v-lazy="$store.getters['instance/absoluteUrl'](album.cover)"> + <img v-if="album.cover.original" v-lazy="$store.getters['instance/absoluteUrl'](album.cover.square_crop)"> <img v-else src="../../../assets/audio/default-cover.png"> </div> <div class="header"> diff --git a/front/src/components/audio/album/Widget.vue b/front/src/components/audio/album/Widget.vue index 37cddd50015c78d03199dcd963e3f2568e119fea..fa71808b3756b450d7d383c0e922b9410af245a3 100644 --- a/front/src/components/audio/album/Widget.vue +++ b/front/src/components/audio/album/Widget.vue @@ -3,9 +3,9 @@ <h3 class="ui header"> <slot name="title"></slot> </h3> - <i @click="fetchData(previousPage)" :disabled="!previousPage" :class="['ui', {disabled: !previousPage}, 'circular', 'large', 'angle left', 'icon']"> + <i @click="fetchData(previousPage)" :disabled="!previousPage" :class="['ui', {disabled: !previousPage}, 'circular', 'medium', 'angle left', 'icon']"> </i> - <i @click="fetchData(nextPage)" :disabled="!nextPage" :class="['ui', {disabled: !nextPage}, 'circular', 'large', 'angle right', 'icon']"> + <i @click="fetchData(nextPage)" :disabled="!nextPage" :class="['ui', {disabled: !nextPage}, 'circular', 'medium', 'angle right', 'icon']"> </i> <div class="ui hidden divider"></div> <div class="ui five cards"> @@ -13,7 +13,7 @@ <div class="ui loader"></div> </div> <div class="card" v-for="album in albums" :key="album.id"> - <div :class="['ui', 'image', 'with-overlay', {'default-cover': !album.cover}]" :style="getImageStyle(album)"> + <div :class="['ui', 'image', 'with-overlay', {'default-cover': !album.cover.original}]" :style="getImageStyle(album)"> <play-button class="play-overlay" :icon-only="true" :button-classes="['ui', 'circular', 'large', 'orange', 'icon', 'button']" :album="album.id"></play-button> </div> <div class="content"> @@ -92,8 +92,8 @@ export default { getImageStyle (album) { let url = '../../../assets/audio/default-cover.png' - if (album.cover) { - url = this.$store.getters['instance/absoluteUrl'](album.cover) + if (album.cover.original) { + url = this.$store.getters['instance/absoluteUrl'](album.cover.medium_square_crop) } else { return {} } diff --git a/front/src/components/audio/artist/Card.vue b/front/src/components/audio/artist/Card.vue index bd7cb68c4edfb2cc2f582919a17543080132d403..dd32b4735310d46de665da5aa0da6cf75af7a113 100644 --- a/front/src/components/audio/artist/Card.vue +++ b/front/src/components/audio/artist/Card.vue @@ -11,7 +11,7 @@ <tbody> <tr v-for="album in albums"> <td> - <img class="ui mini image" v-if="album.cover" :src="$store.getters['instance/absoluteUrl'](album.cover)"> + <img class="ui mini image" v-if="album.cover.original" :src="$store.getters['instance/absoluteUrl'](album.cover.small_square_crop)"> <img class="ui mini image" v-else src="../../../assets/audio/default-cover.png"> </td> <td colspan="4"> diff --git a/front/src/components/audio/track/Row.vue b/front/src/components/audio/track/Row.vue index ef3660ee2dc34e485fd604c09cfd09c281b070cc..cf79267cfddf250206b4a8e8ec520528ba7ff0ed 100644 --- a/front/src/components/audio/track/Row.vue +++ b/front/src/components/audio/track/Row.vue @@ -4,7 +4,7 @@ <play-button class="basic icon" :discrete="true" :track="track"></play-button> </td> <td> - <img class="ui mini image" v-if="track.album.cover" v-lazy="$store.getters['instance/absoluteUrl'](track.album.cover)"> + <img class="ui mini image" v-if="track.album.cover.original" v-lazy="$store.getters['instance/absoluteUrl'](track.album.cover.small_square_crop)"> <img class="ui mini image" v-else src="../../../assets/audio/default-cover.png"> </td> <td colspan="6"> diff --git a/front/src/components/audio/track/Widget.vue b/front/src/components/audio/track/Widget.vue index 7c727b4027aa48823ba9ad9ddb838ae6a78089cf..ca3ae2424b10da96de75216ced1d5ab1654ebd6c 100644 --- a/front/src/components/audio/track/Widget.vue +++ b/front/src/components/audio/track/Widget.vue @@ -3,9 +3,11 @@ <h3 class="ui header"> <slot name="title"></slot> </h3> - <i @click="fetchData(previousPage)" :disabled="!previousPage" :class="['ui', {disabled: !previousPage}, 'circular', 'large', 'angle up', 'icon']"> + <i @click="fetchData(previousPage)" :disabled="!previousPage" :class="['ui', {disabled: !previousPage}, 'circular', 'medium', 'angle up', 'icon']"> </i> - <i @click="fetchData(nextPage)" :disabled="!nextPage" :class="['ui', {disabled: !nextPage}, 'circular', 'large', 'angle down', 'icon']"> + <i @click="fetchData(nextPage)" :disabled="!nextPage" :class="['ui', {disabled: !nextPage}, 'circular', 'medium', 'angle down', 'icon']"> + </i> + <i @click="fetchData(url)" :class="['ui', 'circular', 'medium', 'refresh', 'icon']"> </i> <div class="ui divided unstackable items"> <div v-if="isLoading" class="ui inverted active dimmer"> @@ -13,7 +15,7 @@ </div> <div class="item" v-for="object in objects" :key="object.id"> <div class="ui tiny image"> - <img v-if="object.track.album.cover" v-lazy="$store.getters['instance/absoluteUrl'](object.track.album.cover)"> + <img v-if="object.track.album.cover.original" v-lazy="$store.getters['instance/absoluteUrl'](object.track.album.cover.medium_square_crop)"> <img v-else src="../../../assets/audio/default-cover.png"> <play-button class="play-overlay" :icon-only="true" :button-classes="['ui', 'circular', 'tiny', 'orange', 'icon', 'button']" :track="object.track"></play-button> </div> @@ -121,4 +123,7 @@ export default { left: 2.5em; } } +.refresh.icon { + float: right; +} </style> diff --git a/front/src/components/library/Album.vue b/front/src/components/library/Album.vue index 698f07ae28abcf4a611327d41fedfa21e3295d91..312640baa184916650fb911988b462b9eb268bb5 100644 --- a/front/src/components/library/Album.vue +++ b/front/src/components/library/Album.vue @@ -4,7 +4,7 @@ <div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div> </div> <template v-if="album"> - <div :class="['ui', 'head', {'with-background': album.cover}, 'vertical', 'center', 'aligned', 'stripe', 'segment']" :style="headerStyle" v-title="album.title"> + <div :class="['ui', 'head', {'with-background': album.cover.original}, 'vertical', 'center', 'aligned', 'stripe', 'segment']" :style="headerStyle" v-title="album.title"> <div class="segment-content"> <h2 class="ui center aligned icon header"> <i class="circular inverted sound yellow icon"></i> @@ -98,10 +98,10 @@ export default { return 'https://musicbrainz.org/release/' + this.album.mbid }, headerStyle () { - if (!this.album.cover) { + if (!this.album.cover.original) { return '' } - return 'background-image: url(' + this.$store.getters['instance/absoluteUrl'](this.album.cover) + ')' + return 'background-image: url(' + this.$store.getters['instance/absoluteUrl'](this.album.cover.original) + ')' } }, watch: { diff --git a/front/src/components/library/Artist.vue b/front/src/components/library/Artist.vue index 6e3ad638d01a0dcdb6685c04ac036ba09d79d61b..0f0abe1e91756b12bbf1b19d075ce8b347155e21 100644 --- a/front/src/components/library/Artist.vue +++ b/front/src/components/library/Artist.vue @@ -158,10 +158,10 @@ export default { })[0] }, headerStyle () { - if (!this.cover) { + if (!this.cover.original) { return '' } - return 'background-image: url(' + this.$store.getters['instance/absoluteUrl'](this.cover) + ')' + return 'background-image: url(' + this.$store.getters['instance/absoluteUrl'](this.cover.original) + ')' } }, watch: { diff --git a/front/src/components/playlists/Widget.vue b/front/src/components/playlists/Widget.vue index 72c544205fa5e4261eeff2032c2f9a766205053c..65904a12447fc0a7d9e0250bbc987f1f286b72e8 100644 --- a/front/src/components/playlists/Widget.vue +++ b/front/src/components/playlists/Widget.vue @@ -3,9 +3,11 @@ <h3 class="ui header"> <slot name="title"></slot> </h3> - <i @click="fetchData(previousPage)" :disabled="!previousPage" :class="['ui', {disabled: !previousPage}, 'circular', 'large', 'angle up', 'icon']"> + <i @click="fetchData(previousPage)" :disabled="!previousPage" :class="['ui', {disabled: !previousPage}, 'circular', 'medium', 'angle up', 'icon']"> </i> - <i @click="fetchData(nextPage)" :disabled="!nextPage" :class="['ui', {disabled: !nextPage}, 'circular', 'large', 'angle down', 'icon']"> + <i @click="fetchData(nextPage)" :disabled="!nextPage" :class="['ui', {disabled: !nextPage}, 'circular', 'medium', 'angle down', 'icon']"> + </i> + <i @click="fetchData(url)" :class="['ui', 'circular', 'medium', 'refresh', 'icon']"> </i> <div v-if="isLoading" class="ui inverted active dimmer"> <div class="ui loader"></div> @@ -75,3 +77,8 @@ export default { } } </script> +<style scoped> +.refresh.icon { + float: right; +} +</style>