diff --git a/photo21/static/layout.css b/photo21/static/layout.css index 8e5568e..3bc9f47 100644 --- a/photo21/static/layout.css +++ b/photo21/static/layout.css @@ -68,6 +68,20 @@ SPDX-License-Identifier: GPL-3.0-or-later outline-offset: -5px; } +/* Play button overlay for video thumbnails in the grid */ +.photo-item[data-video]::after { + content: ''; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 48px; + height: 48px; + border-radius: 50%; + background: rgba(0, 0, 0, 0.55) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath fill='white' d='M6.79 5.093A.5.5 0 0 0 6 5.5v5a.5.5 0 0 0 .79.407l3.5-2.5a.5.5 0 0 0 0-.814l-3.5-2.5z'/%3E%3C/svg%3E") center/32px no-repeat; + pointer-events: none; +} + /* Language selector */ .lang-select { border: none; diff --git a/photo21/views.py b/photo21/views.py index 226c552..65f0b11 100644 --- a/photo21/views.py +++ b/photo21/views.py @@ -9,7 +9,7 @@ from django.contrib.auth import get_user_model from django.contrib.auth.mixins import LoginRequiredMixin from django.http import FileResponse, Http404 from django.views.generic import ListView, View -from photologue.models import Gallery, Photo +from photologue.models import Gallery, Photo, Video class MediaAccess(View): @@ -29,6 +29,12 @@ class MediaAccess(View): image__startswith=original_dir + '/', galleries__is_public=True, ).exists() + # Video files and their thumbnails + if not allowed: + allowed = ( + Video.objects.filter(file=path, galleries__is_public=True).exists() + or Video.objects.filter(thumbnail=path, galleries__is_public=True).exists() + ) except Exception: return redirect_to_login(request.get_full_path()) if not allowed: diff --git a/photologue/admin.py b/photologue/admin.py index 6d28b36..c516bf3 100644 --- a/photologue/admin.py +++ b/photologue/admin.py @@ -5,7 +5,7 @@ from django.contrib import admin from django.utils.translation import gettext_lazy as _ -from .models import Gallery, Photo, Tag +from .models import Gallery, Photo, Tag, Video class GalleryAdmin(admin.ModelAdmin): @@ -29,40 +29,31 @@ class GalleryAdmin(admin.ModelAdmin): -class PhotoAdmin(admin.ModelAdmin): - list_display = ( - "title", - "date_taken", - "date_added", - "is_public", - "view_count", - "admin_thumbnail", - "get_owner", - "get_galleries" - ) - list_filter = ["date_added", "is_public", "owner","galleries"] +class MediaAdmin(admin.ModelAdmin): + """Shared admin base for Photo and Video.""" + list_filter = ["date_added", "is_public", "owner", "galleries"] search_fields = ["title", "slug", "caption"] list_per_page = 25 prepopulated_fields = {"slug": ("title",)} - readonly_fields = ("date_taken",) - model = Photo def get_owner(self, obj): return obj.owner.username - - - def get_queryset(self, request): - # Précharge les objets 'galleries' en une seule requête supplémentaire - return super().get_queryset(request).prefetch_related("owner",'galleries') - + def get_galleries(self, obj): - return ", ".join([g.title for g in obj.galleries.all()])## get all linked galeries - - get_galleries.short_description = _("Gallery") - get_galleries.admin_order_field = 'galleries__title' + return ", ".join([g.title for g in obj.galleries.all()]) + + def get_queryset(self, request): + return super().get_queryset(request).prefetch_related("owner", "galleries") get_owner.admin_order_field = "owner" get_owner.short_description = _("owner") + get_galleries.short_description = _("Gallery") + + +class PhotoAdmin(MediaAdmin): + list_display = ("title", "date_taken", "date_added", "is_public", "view_count", "admin_thumbnail", "get_owner", "get_galleries") + readonly_fields = ("date_taken",) + model = Photo class TagAdmin(admin.ModelAdmin): @@ -72,6 +63,12 @@ class TagAdmin(admin.ModelAdmin): model = Tag +class VideoAdmin(MediaAdmin): + list_display = ("title", "date_added", "is_public", "get_owner", "get_galleries") + model = Video + + admin.site.register(Gallery, GalleryAdmin) admin.site.register(Photo, PhotoAdmin) +admin.site.register(Video, VideoAdmin) admin.site.register(Tag, TagAdmin) diff --git a/photologue/forms.py b/photologue/forms.py index 7ac6841..a2d114b 100644 --- a/photologue/forms.py +++ b/photologue/forms.py @@ -19,20 +19,12 @@ class MultipleFileInput(forms.ClearableFileInput): class MultipleFileField(forms.FileField): - allowed_extensions = [ - "jpg", - "jpeg", - "png", - "gif", - "tiff", - ] # Specify allowed extensions here - def __init__(self, *args, **kwargs): kwargs.setdefault( "widget", MultipleFileInput( attrs={ - "accept": "image/*", + "accept": "image/*,video/*", "class": "mb-3", } ), @@ -41,25 +33,9 @@ class MultipleFileField(forms.FileField): def clean(self, data, initial=None): single_file_clean = super().clean - if isinstance(data, (list, tuple)): - result = [self.validate_file(d, single_file_clean, initial) for d in data] - else: - result = self.validate_file(data, single_file_clean, initial) - return result - - def validate_file(self, file, single_file_clean, initial): - # Perform the default clean - cleaned_file = single_file_clean(file, initial) - - # Check the file extension - extension = file.name.split(".")[-1].lower() - if extension not in self.allowed_extensions: - raise forms.ValidationError( - f"{file.name} has an invalid file extension. " - f"Allowed extensions are: {', '.join(self.allowed_extensions)}" - ) - return cleaned_file + return [single_file_clean(d, initial) for d in data] + return single_file_clean(data, initial) class UploadForm(forms.Form): diff --git a/photologue/migrations/0010_video.py b/photologue/migrations/0010_video.py new file mode 100644 index 0000000..fe2a6ff --- /dev/null +++ b/photologue/migrations/0010_video.py @@ -0,0 +1,83 @@ +import django.db.models.deletion +import django.utils.timezone +import photologue.models +from django.conf import settings +from django.db import migrations, models + + + +class Migration(migrations.Migration): + + dependencies = [ + ('photologue', '0009_alter_photo_options_gallery_public_token'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + # Rename Photo dimensions fields to shared names + migrations.RenameField( + model_name='photo', + old_name='image_width', + new_name='thumbnail_width', + ), + migrations.RenameField( + model_name='photo', + old_name='image_height', + new_name='thumbnail_height', + ), + # State-only: update width_field/height_field references, no DB change needed + migrations.SeparateDatabaseAndState( + state_operations=[ + migrations.AlterField( + model_name='photo', + name='image', + field=models.ImageField( + height_field='thumbnail_height', + max_length=100, + upload_to=photologue.models.get_storage_path, + verbose_name='image', + width_field='thumbnail_width', + ), + ), + ], + database_operations=[], + ), + # Restore Photo permissions dropped by 0009 + migrations.AlterModelOptions( + name='photo', + options={ + 'get_latest_by': 'date_added', + 'ordering': ['title'], + 'permissions': [('can_resolve_censorship', 'Can resolve censorship (restore private photos)')], + 'verbose_name': 'photo', + 'verbose_name_plural': 'photos', + }, + ), + migrations.CreateModel( + name='Video', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=250, verbose_name='title')), + ('slug', models.SlugField(help_text='A "slug" is a unique URL-friendly title for an object.', max_length=250, unique=True, verbose_name='slug')), + ('caption', models.TextField(blank=True, verbose_name='caption')), + ('date_added', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date added')), + ('is_public', models.BooleanField(default=True, help_text='Public photographs will be displayed in the default views.', verbose_name='is public')), + ('thumbnail_width', models.PositiveIntegerField(editable=False, null=True)), + ('thumbnail_height', models.PositiveIntegerField(editable=False, null=True)), + ('file', models.FileField(max_length=200, upload_to=photologue.models.get_video_storage_path, verbose_name='video file')), + ('thumbnail', models.ImageField(blank=True, height_field='thumbnail_height', max_length=200, upload_to=photologue.models.get_video_storage_path, verbose_name='thumbnail', width_field='thumbnail_width')), + ('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='owner')), + ], + options={ + 'verbose_name': 'video', + 'verbose_name_plural': 'videos', + 'ordering': ['title'], + 'get_latest_by': 'date_added', + }, + ), + migrations.AddField( + model_name='gallery', + name='videos', + field=models.ManyToManyField(blank=True, related_name='galleries', to='photologue.Video', verbose_name='videos'), + ), + ] diff --git a/photologue/models.py b/photologue/models.py index 7bd506c..4a83efd 100644 --- a/photologue/models.py +++ b/photologue/models.py @@ -63,6 +63,15 @@ else: return os.path.join("photos", fn) +def get_video_storage_path(instance, filename): + fn = ( + unicodedata.normalize("NFKD", force_str(filename)) + .encode("ascii", "ignore") + .decode("ascii") + ) + return os.path.join("videos", fn) + + # Exif Orientation values # Value 0thRow 0thColumn # 1 top left @@ -174,6 +183,12 @@ class Gallery(models.Model): verbose_name=_("photos"), blank=True, ) + videos = models.ManyToManyField( + "photologue.Video", + related_name="galleries", + verbose_name=_("videos"), + blank=True, + ) is_public = models.BooleanField( _("is public"), default=False, @@ -216,20 +231,77 @@ class Gallery(models.Model): """Return a count of private photos in this gallery.""" return self.photos.filter(is_public=False).count() + def media_count(self, public=True): + """Return a count of all photos and videos in this gallery.""" + return self.photo_count(public) + ( + self.videos.filter(is_public=True).count() if public else self.videos.count() + ) + photo_count.short_description = _("count") photo_private_count.short_description = _("private count") -class ImageModel(models.Model): +class MediaModel(models.Model): + """Abstract base model with fields shared between Photo and Video.""" + + title = models.CharField(_("title"), max_length=250) + slug = models.SlugField( + _("slug"), + unique=True, + max_length=250, + help_text=_('A "slug" is a unique URL-friendly title for an object.'), + ) + caption = models.TextField(_("caption"), blank=True) + date_added = models.DateTimeField(_("date added"), default=now) + owner = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + verbose_name=_("owner"), + ) + is_public = models.BooleanField( + _("is public"), + default=True, + help_text=_("Public photographs will be displayed in the default views."), + ) + thumbnail_width = models.PositiveIntegerField(editable=False, null=True) + thumbnail_height = models.PositiveIntegerField(editable=False, null=True) + + is_video = False + + class Meta: + abstract = True + + def __str__(self): + return self.title + + @property + def model_name(self): + return self._meta.model_name + + @property + def file_path(self): + raise NotImplementedError + + def get_admin_url(self): + return reverse(f"admin:photologue_{self._meta.model_name}_change", args=[self.pk]) + + def get_download_url(self): + raise NotImplementedError + + def save(self, *args, **kwargs): + if not self.slug: + self.slug = slugify(self.title) + super().save(*args, **kwargs) + + +class Photo(MediaModel): image = models.ImageField( _("image"), max_length=IMAGE_FIELD_MAX_LENGTH, upload_to=get_storage_path, - width_field="image_width", - height_field="image_height", + width_field="thumbnail_width", + height_field="thumbnail_height", ) - image_width = models.PositiveIntegerField(editable=False, null=True) - image_height = models.PositiveIntegerField(editable=False, null=True) date_taken = models.DateTimeField( _("date taken"), null=True, @@ -244,9 +316,24 @@ class ImageModel(models.Model): default="center", choices=CROP_ANCHOR_CHOICES, ) + license = models.CharField( + max_length=255, + blank=True, + verbose_name=_("license"), + ) + + is_video = False # class attribute used in templates to distinguish media types class Meta: - abstract = True + # We do not have a reliable date for ordering, so let's use + # the title which is incremented by most cameras + ordering = ["title"] + get_latest_by = "date_added" + verbose_name = _("photo") + verbose_name_plural = _("photos") + permissions = [ + ("can_resolve_censorship", "Can resolve censorship (restore private photos)"), + ] def exif(self, file=None): try: @@ -505,6 +592,11 @@ class ImageModel(models.Model): ) except Exception: logger.error("Failed to read EXIF DateTimeOriginal", exc_info=True) + # If crop_from property has been changed on existing image, + # force image recreation + current = Photo.objects.get(pk=self.pk) if self.pk else None + if current and (current.crop_from != self.crop_from): + recreate = True super().save(*args, **kwargs) self.pre_cache(recreate) @@ -524,61 +616,16 @@ class ImageModel(models.Model): super().delete() self.image.storage.delete(self.image.name) - -class Photo(ImageModel): - title = models.CharField(_("title"), max_length=250) - slug = models.SlugField( - _("slug"), - unique=True, - max_length=250, - help_text=_('A "slug" is a unique URL-friendly title for an object.'), - ) - caption = models.TextField(_("caption"), blank=True) - date_added = models.DateTimeField(_("date added"), default=now) - owner = models.ForeignKey( - settings.AUTH_USER_MODEL, - on_delete=models.CASCADE, - verbose_name=_("owner"), - ) - license = models.CharField( - max_length=255, - blank=True, - verbose_name=_("license"), - ) - is_public = models.BooleanField( - _("is public"), - default=True, - help_text=_("Public photographs will be displayed in the default views."), - ) - - class Meta: - # We do not have a reliable date for ordering, so let's use - # the title which is incremented by most cameras - ordering = ["title"] - get_latest_by = "date_added" - verbose_name = _("photo") - verbose_name_plural = _("photos") - permissions = [ - ("can_resolve_censorship", "Can resolve censorship (restore private photos)"), - ] - - def __str__(self): - return self.title - - def save(self, *args, **kwargs): - # If crop_from property has been changed on existing image, - # update kwargs to force image recreation in parent class - current = Photo.objects.get(pk=self.pk) if self.pk else None - if current and (current.crop_from != self.crop_from): - kwargs.update(recreate=True) - - if self.slug is None: - self.slug = slugify(self.title) - super().save(*args, **kwargs) - def get_absolute_url(self): return reverse("photologue:pl-photo", args=[self.pk]) + def get_download_url(self): + return self.image.url + + @property + def file_path(self): + return self.image.path + def public_galleries(self): """Return the public galleries to which this photo belongs.""" return self.galleries @@ -699,11 +746,10 @@ class PhotoSize(models.Model): return self.name def clear_cache(self): - for cls in ImageModel.__subclasses__(): - for obj in cls.objects.all(): - obj.remove_size(self) - if self.pre_cache: - obj.create_size(self) + for obj in Photo.objects.all(): + obj.remove_size(self) + if self.pre_cache: + obj.create_size(self) PhotoSizeCache().reset() def clean(self): @@ -782,6 +828,67 @@ def init_size_method_map(): } +class Video(MediaModel): + file = models.FileField( + _("video file"), + max_length=200, + upload_to=get_video_storage_path, + ) + thumbnail = models.ImageField( + _("thumbnail"), + max_length=200, + upload_to=get_video_storage_path, + blank=True, + width_field="thumbnail_width", + height_field="thumbnail_height", + ) + + is_video = True # class attribute used in templates to distinguish media types + + class Meta: + ordering = ["title"] + get_latest_by = "date_added" + verbose_name = _("video") + verbose_name_plural = _("videos") + + def get_thumbnail_url(self): + missing = not self.thumbnail or not os.path.exists(self.thumbnail.path) + if missing and self.file: + from .utils import generate_video_thumbnail + generate_video_thumbnail(self) + return self.thumbnail.url if self.thumbnail else '' + + def get_display_url(self): + return self.file.url + + def get_download_url(self): + return self.file.url + + @property + def file_path(self): + return self.file.path + + def get_mime_type(self): + import mimetypes + mime, _ = mimetypes.guess_type(self.file.name) + return mime or 'video/mp4' + + def delete(self): + assert ( + self._get_pk_val() is not None + ), "%s object can't be deleted because its %s attribute is set to None." % ( + self._meta.object_name, + self._meta.pk.attname, + ) + # Files associated to a FileField have to be manually deleted: + # https://docs.djangoproject.com/en/dev/releases/1.3/#deleting-a-model-doesn-t-delete-associated-files + super().delete() + if self.thumbnail: + self.thumbnail.storage.delete(self.thumbnail.name) + if self.file: + self.file.storage.delete(self.file.name) + + class Tag(models.Model): name = models.CharField( max_length=250, diff --git a/photologue/static/gallery_detail.js b/photologue/static/gallery_detail.js index 03c187a..ed345d6 100644 --- a/photologue/static/gallery_detail.js +++ b/photologue/static/gallery_detail.js @@ -6,7 +6,11 @@ // Init gallery lightGallery(document.getElementById('lightgallery'), { - plugins: [lgAdmin, lgHash, lgThumbnail, lgZoom], + selector: 'a.photo-item', + plugins: [lgAdmin, lgHash, lgThumbnail, lgZoom, lgVideo], + autoplayFirstVideo: true, + autoplayVideoOnSlide: true, + gotoNextSlideOnVideoEnd: false, download: true, customSlideName: true, licenseKey: '94ED9732-30284A12-B88B0137-4FF9CEE6', @@ -66,7 +70,7 @@ lgContainer.addEventListener('lgAfterOpen', () => { lgContainer.addEventListener('lgAfterOpen', () => { // On cible le conteneur de la galerie qui vient de s'afficher const lgOuter = document.querySelector('.lg-outer'); - + lgOuter.addEventListener('contextmenu', (e) => { e.preventDefault(); }, false); diff --git a/photologue/static/lightgallery/css/lg-video.css b/photologue/static/lightgallery/css/lg-video.css new file mode 100644 index 0000000..a39b5e9 --- /dev/null +++ b/photologue/static/lightgallery/css/lg-video.css @@ -0,0 +1,144 @@ +.lg-outer .lg-video-cont { + text-align: center; + display: inline-block; + vertical-align: middle; + position: relative; +} + +.lg-outer .lg-video-cont .lg-object { + width: 100% !important; + height: 100% !important; +} + +.lg-outer .lg-has-iframe .lg-video-cont { + -webkit-overflow-scrolling: touch; + overflow: auto; +} + +.lg-outer .lg-video-object { + position: absolute; + left: 0; + right: 0; + width: 100%; + height: 100%; + top: 0; + bottom: 0; + z-index: 3; +} + +.lg-outer .lg-video-poster { + z-index: 1; +} + +.lg-outer .lg-has-video .lg-video-object { + opacity: 0; + will-change: opacity; + -webkit-transition: opacity 0.3s ease-in; + -o-transition: opacity 0.3s ease-in; + transition: opacity 0.3s ease-in; +} + +.lg-outer .lg-has-video.lg-video-loaded .lg-video-poster, +.lg-outer .lg-has-video.lg-video-loaded .lg-video-play-button { + opacity: 0 !important; +} + +.lg-outer .lg-has-video.lg-video-loaded .lg-video-object { + opacity: 1; +} + +@keyframes lg-play-stroke { + 0% { + stroke-dasharray: 1, 200; + stroke-dashoffset: 0; + } + 50% { + stroke-dasharray: 89, 200; + stroke-dashoffset: -35px; + } + 100% { + stroke-dasharray: 89, 200; + stroke-dashoffset: -124px; + } +} + +@keyframes lg-play-rotate { + 100% { + -webkit-transform: rotate(360deg); + transform: rotate(360deg); + } +} + +.lg-video-play-button { + width: 18%; + max-width: 140px; + position: absolute; + top: 50%; + left: 50%; + z-index: 2; + cursor: pointer; + transform: translate(-50%, -50%) scale(1); + will-change: opacity, transform; + -webkit-transition: -webkit-transform 0.25s cubic-bezier(0.17, 0.88, 0.32, 1.28), opacity 0.1s; + -moz-transition: -moz-transform 0.25s cubic-bezier(0.17, 0.88, 0.32, 1.28), opacity 0.1s; + -o-transition: -o-transform 0.25s cubic-bezier(0.17, 0.88, 0.32, 1.28), opacity 0.1s; + transition: transform 0.25s cubic-bezier(0.17, 0.88, 0.32, 1.28), opacity 0.1s; +} + +.lg-video-play-button:hover .lg-video-play-icon-bg, +.lg-video-play-button:hover .lg-video-play-icon { + opacity: 1; +} + +.lg-video-play-icon-bg { + fill: none; + stroke-width: 3%; + stroke: #fcfcfc; + opacity: 0.6; + will-change: opacity; + -webkit-transition: opacity 0.12s ease-in; + -o-transition: opacity 0.12s ease-in; + transition: opacity 0.12s ease-in; +} + +.lg-video-play-icon-circle { + position: absolute; + top: 0; + left: 0; + bottom: 0; + right: 0; + fill: none; + stroke-width: 3%; + stroke: rgba(30, 30, 30, 0.9); + stroke-opacity: 1; + stroke-linecap: round; + stroke-dasharray: 200; + stroke-dashoffset: 200; +} + +.lg-video-play-icon { + position: absolute; + width: 25%; + max-width: 120px; + left: 50%; + top: 50%; + transform: translate3d(-50%, -50%, 0); + opacity: 0.6; + will-change: opacity; + -webkit-transition: opacity 0.12s ease-in; + -o-transition: opacity 0.12s ease-in; + transition: opacity 0.12s ease-in; +} + +.lg-video-play-icon .lg-video-play-icon-inner { + fill: #fcfcfc; +} + +.lg-video-loading .lg-video-play-icon-circle { + animation: lg-play-rotate 2s linear 0.25s infinite, lg-play-stroke 1.5s ease-in-out 0.25s infinite; +} + +.lg-video-loaded .lg-video-play-button { + opacity: 0; + transform: translate(-50%, -50%) scale(0.7); +} diff --git a/photologue/static/lightgallery/plugins/admin/lg-admin.js b/photologue/static/lightgallery/plugins/admin/lg-admin.js index d20f6db..1ff3354 100644 --- a/photologue/static/lightgallery/plugins/admin/lg-admin.js +++ b/photologue/static/lightgallery/plugins/admin/lg-admin.js @@ -15,6 +15,9 @@ class lgAdmin { this.canResolveCensorship = document.querySelector('[name=can_resolve_censorship]').value === "true"; this.csrfToken = document.querySelector('[name=csrfmiddlewaretoken]').value; this.photoId = 0; + this.deleteUrl = ''; + this.adminUrl = ''; + this.isVideo = false; return this; } @@ -49,14 +52,19 @@ class lgAdmin { // Event called when showing a new slide onAfterSlide(event) { this.photoId = this.core.galleryItems[event.detail.index].slideName; - document.getElementById("lg-admin").href = `/admin/photologue/photo/${this.photoId}/change/`; const el = document.querySelector(`[data-slide-name='${this.photoId}']`); + this.deleteUrl = el ? el.dataset.deleteUrl : ''; + this.adminUrl = el ? el.dataset.adminUrl : ''; + this.reportUrl = el ? el.dataset.reportUrl : ''; + this.uncensorUrl = el ? el.dataset.uncensorUrl : ''; + this.isVideo = el ? el.dataset.video !== undefined : false; + document.getElementById("lg-admin").href = this.adminUrl; const ownerId = el ? el.dataset.ownerId : null; const canDelete = this.isStaff || (ownerId && ownerId === this.userId); document.getElementById("lg-delete").style.display = canDelete ? 'block' : 'none'; - document.getElementById("lg-report").style.display = canDelete ? 'none' : 'block'; + document.getElementById("lg-report").style.display = (!canDelete && this.reportUrl) ? 'block' : 'none'; const isCensored = el ? el.dataset.isPublic === 'false' : false; - document.getElementById("lg-restore").style.display = (this.canResolveCensorship && isCensored) ? 'block' : 'none'; + document.getElementById("lg-restore").style.display = (this.canResolveCensorship && isCensored && this.uncensorUrl) ? 'block' : 'none'; } // Event called when user clicks the restore (uncensor) button @@ -66,11 +74,12 @@ class lgAdmin { const photoId = this.photoId; let data = new FormData(); data.append('csrfmiddlewaretoken', this.csrfToken); - fetch(`/photo/${photoId}/uncensor/`, { + fetch(this.uncensorUrl, { method: "POST", body: data, credentials: 'same-origin', - }).then(() => { + }).then(response => { + if (!response.ok) { console.error('Uncensor failed', response.status, this.uncensorUrl); return; } const el = document.querySelector(`[data-slide-name='${photoId}']`); if (el) { el.dataset.isPublic = 'true'; @@ -132,7 +141,7 @@ class lgAdmin { const currentIndex = this.core.index; let data = new FormData(); data.append('csrfmiddlewaretoken', this.csrfToken); - fetch(`/photo/${photoId}/delete/`, { + fetch(this.deleteUrl, { method: "POST", redirect: "manual", // do not load gallery again body: data, @@ -150,7 +159,7 @@ class lgAdmin { const currentIndex = this.core.index; let data = new FormData(); data.append('csrfmiddlewaretoken', this.csrfToken); - fetch(`/photo/${photoId}/report/`, { + fetch(this.reportUrl, { method: "POST", redirect: "manual", // do not load gallery again body: data, diff --git a/photologue/static/lightgallery/plugins/video/lg-video.min.js b/photologue/static/lightgallery/plugins/video/lg-video.min.js new file mode 100644 index 0000000..a871495 --- /dev/null +++ b/photologue/static/lightgallery/plugins/video/lg-video.min.js @@ -0,0 +1,8 @@ +/** + * lightgallery | 2.2.1 | September 4th 2021 + * http://www.lightgalleryjs.com/ + * Copyright (c) 2020 Sachin Neravath; + * @license GPLv3 + */ + +!function(e,o){"object"==typeof exports&&"undefined"!=typeof module?module.exports=o():"function"==typeof define&&define.amd?define(o):(e="undefined"!=typeof globalThis?globalThis:e||self).lgVideo=o()}(this,(function(){"use strict";var e=function(){return(e=Object.assign||function(e){for(var o,i=1,t=arguments.length;i"}else if(l.vimeo){c="lg-vimeo"+i,f=n(this.settings.vimeoPlayerParams);s='"}else if(l.wistia){var u="lg-wistia"+i;f=n(this.settings.wistiaPlayerParams);s='"}else if(l.html5){for(var h="",g=0;g';if(t.tracks){var y=function(e){var o="",i=t.tracks[e];Object.keys(i||{}).forEach((function(e){o+=e+'="'+i[e]+'" '})),h+=""};for(g=0;g\n "+h+"\n Your browser does not support HTML5 video.\n "}return s},r.prototype.appendVideos=function(e,o){var i,t=this.getVideoHtml(o.src,o.addClass,o.index,o.html5Video);e.find(".lg-video-cont").append(t);var s=e.find(".lg-video-object").first();if(o.html5Video&&s.on("mousedown.lg.video",(function(e){e.stopPropagation()})),this.settings.videojs&&(null===(i=this.core.galleryItems[o.index].__slideVideoInfo)||void 0===i?void 0:i.html5))try{return videojs(s.get(),this.settings.videojsOptions)}catch(e){console.error("lightGallery:- Make sure you have included videojs")}},r.prototype.gotoNextSlideOnVideoEnd=function(e,o){var i=this,t=this.core.getSlideItem(o).find(".lg-video-object").first(),s=this.core.galleryItems[o].__slideVideoInfo||{};if(this.settings.gotoNextSlideOnVideoEnd)if(s.html5)t.on("ended",(function(){i.core.goToNextSlide()}));else if(s.vimeo)try{new Vimeo.Player(t.get()).on("ended",(function(){i.core.goToNextSlide()}))}catch(e){console.error("lightGallery:- Make sure you have included //github.com/vimeo/player.js")}else if(s.wistia)try{window._wq=window._wq||[],window._wq.push({id:t.attr("id"),onReady:function(e){e.bind("end",(function(){i.core.goToNextSlide()}))}})}catch(e){console.error("lightGallery:- Make sure you have included //fast.wistia.com/assets/external/E-v1.js")}},r.prototype.controlVideo=function(e,o){var i=this.core.getSlideItem(e).find(".lg-video-object").first(),t=this.core.galleryItems[e].__slideVideoInfo||{};if(i.get())if(t.youtube)try{i.get().contentWindow.postMessage('{"event":"command","func":"'+o+'Video","args":""}',"*")}catch(e){console.error("lightGallery:- "+e)}else if(t.vimeo)try{new Vimeo.Player(i.get())[o]()}catch(e){console.error("lightGallery:- Make sure you have included //github.com/vimeo/player.js")}else if(t.html5)if(this.settings.videojs)try{videojs(i.get())[o]()}catch(e){console.error("lightGallery:- Make sure you have included videojs")}else i.get()[o]();else if(t.wistia)try{window._wq=window._wq||[],window._wq.push({id:i.attr("id"),onReady:function(e){e[o]()}})}catch(e){console.error("lightGallery:- Make sure you have included //fast.wistia.com/assets/external/E-v1.js")}},r.prototype.loadVideoOnPosterClick=function(e){var o=this;if(!e.hasClass("lg-video-loaded"))if(e.hasClass("lg-has-video"))this.playVideo(this.core.index);else{e.addClass("lg-has-video");var i=void 0,t=this.core.galleryItems[this.core.index].src,s=this.core.galleryItems[this.core.index].video;s&&(i="string"==typeof s?JSON.parse(s):s);var l=this.appendVideos(e,{src:t,addClass:"",index:this.core.index,html5Video:i});this.gotoNextSlideOnVideoEnd(t,this.core.index);var n=e.find(".lg-object").first().get();e.find(".lg-video-cont").first().append(n),e.addClass("lg-video-loading"),l&&l.ready((function(){l.on("loadedmetadata",(function(){o.onVideoLoadAfterPosterClick(e,o.core.index)}))})),e.find(".lg-video-object").first().on("load.lg error.lg loadeddata.lg",(function(){setTimeout((function(){o.onVideoLoadAfterPosterClick(e,o.core.index)}),50)}))}},r.prototype.onVideoLoadAfterPosterClick=function(e,o){e.addClass("lg-video-loaded"),this.playVideo(o)},r.prototype.destroy=function(){this.core.LGel.off(".lg.video"),this.core.LGel.off(".video")},r}()})); diff --git a/photologue/templates/photologue/gallery_detail.html b/photologue/templates/photologue/gallery_detail.html index d98f173..5b1036b 100755 --- a/photologue/templates/photologue/gallery_detail.html +++ b/photologue/templates/photologue/gallery_detail.html @@ -12,6 +12,7 @@ SPDX-License-Identifier: GPL-3.0-or-later + {% endblock %} {% block extrajs %} @@ -25,6 +26,7 @@ SPDX-License-Identifier: GPL-3.0-or-later + @@ -90,12 +92,33 @@ SPDX-License-Identifier: GPL-3.0-or-later
- {% for photo in photos %} - - {{ photo.title }}{% if photo.date_taken %} - {{ photo.date_taken|date }} {{ photo.date_taken|time }}{% endif %}{% if photo.owner.get_full_name %} - {{ photo.owner.get_full_name }}{% else %} - {{ photo.owner.username }}{% endif %}{% if photo.license %} - {{ photo.license }}{% endif %}{% if not photo.is_public %} - !PRIVATE!{% endif %} + {% for item in media_items %} + + {{ item.title }}{% if not item.is_video and item.date_taken %} - {{ item.date_taken|date }} {{ item.date_taken|time }}{% endif %}{% if item.owner.get_full_name %} - {{ item.owner.get_full_name }}{% else %} - {{ item.owner.username }}{% endif %}{% if not item.is_video and item.license %} - {{ item.license }}{% endif %}{% if not item.is_public %} - !PRIVATE!{% endif %} {% endfor %}
+