From 2f0c426dc151ed7690c79eb933d7d1939e5393ac Mon Sep 17 00:00:00 2001 From: krek0 Date: Thu, 24 Sep 2026 10:46:52 +0200 Subject: [PATCH 1/4] Vary photo display ratios to break up uniform gallery rows --- photologue/static/gallery_justified.js | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/photologue/static/gallery_justified.js b/photologue/static/gallery_justified.js index cec2070..12ce8f9 100644 --- a/photologue/static/gallery_justified.js +++ b/photologue/static/gallery_justified.js @@ -3,6 +3,25 @@ (function () { const SPACING = 3; const PADDING = 3; + // Photos that all share the same aspect ratio give rows of identical height, + // which reads as a contact sheet. Some photos are therefore displayed with a + // slightly different ratio than their own: `object-fit: cover` trims the + // edges, and the justified rows become irregular on their own. + const CROP_STEPS = [0.8, 1, 1, 1.25]; // display ratio multipliers, drawn per photo + + // id -> multiplier, drawn once per page load and kept on resize (and when a + // photo is deleted) so the gallery does not reshuffle while it is looked at. + const cropPlan = new Map(); + + function displayRatio(item, index) { + const w = parseInt(item.dataset.width) || 1; + const h = parseInt(item.dataset.height) || 1; + const id = item.dataset.slideName || 'i' + index; + if (!cropPlan.has(id)) { + cropPlan.set(id, CROP_STEPS[Math.floor(Math.random() * CROP_STEPS.length)]); + } + return (w / h) * cropPlan.get(id); + } function applyLayout() { const container = document.getElementById('lightgallery'); @@ -13,11 +32,7 @@ const containerWidth = container.offsetWidth - PADDING * 2; const TARGET_HEIGHT = Math.min(220, Math.floor(containerWidth * 0.4)); - const ratios = items.map(a => { - const w = parseInt(a.dataset.width) || 1; - const h = parseInt(a.dataset.height) || 1; - return w / h; - }); + const ratios = items.map(displayRatio); // Group items into rows const rows = []; From f7415fb5f6929078f69d07fbd2e7554e1bab561e Mon Sep 17 00:00:00 2001 From: krek0 Date: Thu, 24 Sep 2026 12:52:50 +0200 Subject: [PATCH 2/4] Sort gallery media by capture date instead of file name --- photo21/settings.py | 2 +- photologue/admin.py | 12 ++- photologue/forms.py | 3 + .../commands/backfill_capture_dates.py | 77 ++++++++++++++++ ...o_date_source_video_date_taken_and_more.py | 33 +++++++ photologue/models.py | 73 +++++++++++---- photologue/static/upload.js | 2 + .../templates/photologue/gallery_detail.html | 2 +- .../templates/photologue/photo_detail.html | 5 +- photologue/utils.py | 91 +++++++++++++++++++ photologue/views.py | 47 ++++++++-- 11 files changed, 314 insertions(+), 33 deletions(-) create mode 100644 photologue/management/commands/backfill_capture_dates.py create mode 100644 photologue/migrations/0011_photo_date_source_video_date_source_video_date_taken_and_more.py diff --git a/photo21/settings.py b/photo21/settings.py index 24a6874..baca374 100644 --- a/photo21/settings.py +++ b/photo21/settings.py @@ -209,7 +209,7 @@ AUTH_PASSWORD_VALIDATORS = [ # Internationalization # https://docs.djangoproject.com/en/2.2/topics/i18n/ -TIME_ZONE = "UTC" +TIME_ZONE = "Europe/Paris" USE_TZ = True diff --git a/photologue/admin.py b/photologue/admin.py index c516bf3..6a5456d 100644 --- a/photologue/admin.py +++ b/photologue/admin.py @@ -45,14 +45,20 @@ class MediaAdmin(admin.ModelAdmin): def get_queryset(self, request): return super().get_queryset(request).prefetch_related("owner", "galleries") + def get_date_source(self, obj): + return obj.get_date_source_display() or "-" + get_owner.admin_order_field = "owner" get_owner.short_description = _("owner") get_galleries.short_description = _("Gallery") + get_date_source.admin_order_field = "date_source" + get_date_source.short_description = _("date source") class PhotoAdmin(MediaAdmin): - list_display = ("title", "date_taken", "date_added", "is_public", "view_count", "admin_thumbnail", "get_owner", "get_galleries") - readonly_fields = ("date_taken",) + list_display = ("title", "date_taken", "get_date_source", "date_added", "is_public", "view_count", "admin_thumbnail", "get_owner", "get_galleries") + # The capture date is editable so that a camera set to the wrong time can be + # corrected; an edited date is then marked as manual and never overwritten. model = Photo @@ -64,7 +70,7 @@ class TagAdmin(admin.ModelAdmin): class VideoAdmin(MediaAdmin): - list_display = ("title", "date_added", "is_public", "get_owner", "get_galleries") + list_display = ("title", "date_taken", "get_date_source", "date_added", "is_public", "get_owner", "get_galleries") model = Video diff --git a/photologue/forms.py b/photologue/forms.py index 10a757d..6f48485 100644 --- a/photologue/forms.py +++ b/photologue/forms.py @@ -43,6 +43,9 @@ class MultipleFileField(forms.FileField): class UploadForm(forms.Form): file_field = MultipleFileField(label="") + # File.lastModified, in milliseconds, sent by upload.js. An upload is the last + # chance to catch it: the date of the file itself does not survive HTTP. + file_modified = forms.IntegerField(required=False, widget=forms.HiddenInput) gallery = forms.ModelChoiceField( Gallery.objects.all(), label=_("Gallery"), diff --git a/photologue/management/commands/backfill_capture_dates.py b/photologue/management/commands/backfill_capture_dates.py new file mode 100644 index 0000000..ae6f703 --- /dev/null +++ b/photologue/management/commands/backfill_capture_dates.py @@ -0,0 +1,77 @@ +# This file is part of photo21 +# Copyright (C) 2022 Amicale des élèves de l'ENS Paris-Saclay +# SPDX-License-Identifier: GPL-3.0-or-later + +from django.core.management.base import BaseCommand + +from photologue.models import DATE_FROM_EXIF, DATE_FROM_VIDEO, DATE_MANUAL, Photo, Video +from photologue.utils import capture_date_from_exif, capture_date_from_video + + +class Command(BaseCommand): + help = ( + "Read the capture date of existing media from their metadata. Media whose " + "date was set by hand are never touched. By default only media without a " + "date, and media imported before dates were tracked, are read." + ) + + def add_arguments(self, parser): + parser.add_argument( + "--force", + action="store_true", + help="Re-read every media, even those with a known date source.", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Report what would change without writing anything.", + ) + + def handle(self, *args, **options): + for model, reader in ((Photo, self.photo_date), (Video, self.video_date)): + queryset = model.objects.exclude(date_source=DATE_MANUAL) + if not options["force"]: + # An empty source means the media predates this field, and its date + # may have been stored in the wrong time zone. + queryset = queryset.filter(date_taken__isnull=True) | queryset.filter(date_source="") + self.process(model, queryset.distinct(), reader, options) + + def process(self, model, queryset, reader, options): + name = model._meta.verbose_name_plural + found = unchanged = missing = 0 + for media in queryset.iterator(): + taken, source = reader(media) + if taken is None: + missing += 1 + self.stdout.write(f" no date in {media.title}") + continue + if taken == media.date_taken and source == media.date_source: + unchanged += 1 + continue + found += 1 + self.stdout.write(f" {media.title}: {media.date_taken} -> {taken} ({source})") + if not options["dry_run"]: + media.set_capture_date(taken, source) + media.save(update_fields=["date_taken", "date_source"]) + done = "would be updated" if options["dry_run"] else "updated" + self.stdout.write( + self.style.SUCCESS(f"{name}: {found} {done}, {unchanged} already correct, {missing} without metadata") + ) + + @staticmethod + def photo_date(photo): + if not photo.image: + return None, None + try: + exif_date = photo.exif(photo.image.file).get("EXIF DateTimeOriginal", None) + except Exception: + return None, None + if exif_date is None: + return None, None + return capture_date_from_exif(exif_date.values), DATE_FROM_EXIF + + @staticmethod + def video_date(video): + if not video.file: + return None, None + return capture_date_from_video(video.file.path), DATE_FROM_VIDEO diff --git a/photologue/migrations/0011_photo_date_source_video_date_source_video_date_taken_and_more.py b/photologue/migrations/0011_photo_date_source_video_date_source_video_date_taken_and_more.py new file mode 100644 index 0000000..7724cbb --- /dev/null +++ b/photologue/migrations/0011_photo_date_source_video_date_source_video_date_taken_and_more.py @@ -0,0 +1,33 @@ +# Generated by Django 6.0.4 on 2026-09-24 09:12 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('photologue', '0010_video'), + ] + + operations = [ + migrations.AddField( + model_name='photo', + name='date_source', + field=models.CharField(blank=True, choices=[('exif', 'EXIF metadata'), ('video', 'video metadata'), ('file', 'file date at upload'), ('manual', 'set by hand')], editable=False, help_text='Where the capture date comes from.', max_length=10, verbose_name='date source'), + ), + migrations.AddField( + model_name='video', + name='date_source', + field=models.CharField(blank=True, choices=[('exif', 'EXIF metadata'), ('video', 'video metadata'), ('file', 'file date at upload'), ('manual', 'set by hand')], editable=False, help_text='Where the capture date comes from.', max_length=10, verbose_name='date source'), + ), + migrations.AddField( + model_name='video', + name='date_taken', + field=models.DateTimeField(blank=True, help_text='Date the media was captured; read from its metadata.', null=True, verbose_name='date taken'), + ), + migrations.AlterField( + model_name='photo', + name='date_taken', + field=models.DateTimeField(blank=True, help_text='Date the media was captured; read from its metadata.', null=True, verbose_name='date taken'), + ), + ] diff --git a/photologue/models.py b/photologue/models.py index 782f9dc..240f72b 100644 --- a/photologue/models.py +++ b/photologue/models.py @@ -4,7 +4,6 @@ import logging import os import unicodedata -from datetime import datetime from functools import partial from importlib import import_module from inspect import isclass @@ -39,6 +38,19 @@ LATEST_LIMIT = getattr(settings, "PHOTOLOGUE_GALLERY_LATEST_LIMIT", None) # max_length setting for the ImageModel ImageField IMAGE_FIELD_MAX_LENGTH = getattr(settings, "PHOTOLOGUE_IMAGE_FIELD_MAX_LENGTH", 100) +# Where a capture date comes from, from the most to the least trustworthy. An +# empty value means the date was never determined. +DATE_FROM_EXIF = "exif" +DATE_FROM_VIDEO = "video" +DATE_FROM_FILE = "file" +DATE_MANUAL = "manual" +DATE_SOURCE_CHOICES = ( + (DATE_FROM_EXIF, _("EXIF metadata")), + (DATE_FROM_VIDEO, _("video metadata")), + (DATE_FROM_FILE, _("file date at upload")), + (DATE_MANUAL, _("set by hand")), +) + # Modify image file buffer size. ImageFile.MAXBLOCK = getattr(settings, "PHOTOLOGUE_MAXBLOCK", 256 * 2**10) @@ -265,6 +277,20 @@ class MediaModel(models.Model): ) thumbnail_width = models.PositiveIntegerField(editable=False, null=True) thumbnail_height = models.PositiveIntegerField(editable=False, null=True) + date_taken = models.DateTimeField( + _("date taken"), + null=True, + blank=True, + help_text=_("Date the media was captured; read from its metadata."), + ) + date_source = models.CharField( + _("date source"), + max_length=10, + blank=True, + editable=False, + choices=DATE_SOURCE_CHOICES, + help_text=_("Where the capture date comes from."), + ) is_video = False @@ -288,9 +314,32 @@ class MediaModel(models.Model): def get_download_url(self): raise NotImplementedError + @property + def date_is_approximate(self): + """True when the date was deduced rather than read from the capture itself.""" + return self.date_source == DATE_FROM_FILE + + def set_capture_date(self, value, source): + """Record a capture date read from the file, and where it comes from.""" + self.date_taken = value + self.date_source = source + self._date_read_from_file = True + def save(self, *args, **kwargs): if not self.slug: self.slug = slugify(self.title) + if self.pk and not getattr(self, "_date_read_from_file", False): + # A date changed by anything but our own readers was set by a human, + # and must survive a later re-read of the metadata. + stored = ( + type(self) + .objects.filter(pk=self.pk) + .values_list("date_taken", flat=True) + .first() + ) + if stored != self.date_taken: + self.date_source = DATE_MANUAL + self._date_read_from_file = False super().save(*args, **kwargs) @@ -302,12 +351,6 @@ class Photo(MediaModel): width_field="thumbnail_width", height_field="thumbnail_height", ) - date_taken = models.DateTimeField( - _("date taken"), - null=True, - blank=True, - help_text=_("Date image was taken; is obtained from the image EXIF data."), - ) view_count = models.PositiveIntegerField(_("view count"), default=0, editable=False) crop_from = models.CharField( _("crop from"), @@ -577,21 +620,15 @@ class Photo(MediaModel): if (self.date_taken is None or image_has_changed) and self.image: # Attempt to get the date the photo was taken from the EXIF data. try: + from .utils import capture_date_from_exif + exif_date = self.exif(self.image.file).get( "EXIF DateTimeOriginal", None ) if exif_date is not None: - d, t = exif_date.values.split() - year, month, day = d.split(":") - hour, minute, second = t.split(":") - self.date_taken = datetime( - int(year), - int(month), - int(day), - int(hour), - int(minute), - int(second), - ) + taken = capture_date_from_exif(exif_date.values) + if taken is not None: + self.set_capture_date(taken, DATE_FROM_EXIF) except Exception: logger.error("Failed to read EXIF DateTimeOriginal", exc_info=True) # If crop_from property has been changed on existing image, diff --git a/photologue/static/upload.js b/photologue/static/upload.js index 584ec42..f741085 100644 --- a/photologue/static/upload.js +++ b/photologue/static/upload.js @@ -99,6 +99,8 @@ async function uploadFile(file, galleryID, csrfvalue, ownerID) { const sendform = new FormData(); sendform.append('csrfmiddlewaretoken', csrfvalue); sendform.append('file_field', file); + // The capture date is often missing from the metadata but present here. + if (file.lastModified) sendform.append('file_modified', file.lastModified); sendform.append('gallery', galleryID); if (ownerID) sendform.append('owner', ownerID); const start = performance.now(); diff --git a/photologue/templates/photologue/gallery_detail.html b/photologue/templates/photologue/gallery_detail.html index 2ebf304..086b708 100755 --- a/photologue/templates/photologue/gallery_detail.html +++ b/photologue/templates/photologue/gallery_detail.html @@ -114,7 +114,7 @@ SPDX-License-Identifier: GPL-3.0-or-later data-height="{{ item.thumbnail_height|default:1 }}"> {{ item.title }}{% if not item.is_video and item.date_taken %} - {{ item.date_taken|date }} {{ item.date_taken|time }}{% endif %} - {{ item.owner.first_name|default:item.owner.username }}{% if not item.is_video and item.license %} - {{ item.license }}{% endif %}{% if not item.is_public %} - !PRIVATE!{% endif %} + alt="{{ item.title }}{% if item.date_taken %} - {{ item.date_taken|date }} {{ item.date_taken|time }}{% endif %} - {{ item.owner.first_name|default:item.owner.username }}{% if not item.is_video and item.license %} - {{ item.license }}{% endif %}{% if not item.is_public %} - !PRIVATE!{% endif %}"> {% endfor %} diff --git a/photologue/templates/photologue/photo_detail.html b/photologue/templates/photologue/photo_detail.html index 0c40697..df4d246 100644 --- a/photologue/templates/photologue/photo_detail.html +++ b/photologue/templates/photologue/photo_detail.html @@ -12,7 +12,10 @@ SPDX-License-Identifier: GPL-3.0-or-later

{{ object.title }}

-

{% trans "Published" %} {{ object.date_added }}

+

+ {% if object.date_taken %}{% trans "Taken" %} {{ object.date_taken }}{% if object.date_is_approximate %} ({% trans "approximate date" %}){% endif %} · {% endif %} + {% trans "Published" %} {{ object.date_added }} +

{% if object.caption %}

{{ object.caption }}

{% endif %} diff --git a/photologue/utils.py b/photologue/utils.py index d3ff68b..a697eb1 100644 --- a/photologue/utils.py +++ b/photologue/utils.py @@ -5,7 +5,12 @@ import logging import os +from datetime import datetime, timezone as dt_timezone + +from django.conf import settings from django.core.files.base import ContentFile +from django.utils import timezone +from django.utils.dateparse import parse_datetime from io import BytesIO from PIL import Image import av @@ -61,3 +66,89 @@ def generate_video_thumbnail(video): video.thumbnail.save(thumb_name, ContentFile(buffer.getvalue()), save=True) except Exception: logger.error("Failed to save video thumbnail", exc_info=True) + + +def capture_date_from_exif(exif_value): + """Turn an EXIF "YYYY:MM:DD HH:MM:SS" value into an aware datetime. + + EXIF dates carry no time zone: they are local time as read by the camera, so + they are interpreted in the site time zone. + """ + try: + day, hour = str(exif_value).split() + year, month, dayofmonth = (int(part) for part in day.split(":")) + hours, minutes, seconds = (int(part) for part in hour.split(":")) + naive = datetime(year, month, dayofmonth, hours, minutes, seconds) + except (AttributeError, TypeError, ValueError): + logger.error("Unreadable EXIF date %r", exif_value) + return None + return timezone.make_aware(naive) if settings.USE_TZ else naive + + +def capture_date_from_video(path): + """Read the capture date from a video container, or None.""" + try: + container = av.open(path) + raw = container.metadata.get("creation_time") + container.close() + except Exception: + logger.error("Failed to read video metadata from %s", path, exc_info=True) + return None + if not raw: + return None + parsed = parse_datetime(raw) + if parsed is None: + return None + if settings.USE_TZ and timezone.is_naive(parsed): + # Containers usually store UTC; a naive value is the camera's local time. + parsed = timezone.make_aware(parsed) + return parsed + + +def capture_date_from_upload(last_modified_ms): + """Turn a browser File.lastModified value (ms since epoch) into a datetime.""" + try: + stamp = int(last_modified_ms) / 1000 + except (TypeError, ValueError): + return None + if not stamp: + return None + return datetime.fromtimestamp(stamp, tz=dt_timezone.utc) + + +def order_media_by_capture(items): + """Sort media in capture order, and fill in the dates that are missing. + + A file name is a reliable chronological clue inside one photographer's batch + (most cameras number their files in order) and meaningless between two of + them. So media without a capture date borrow the date of the closest dated + file of the same owner, which puts them back in the middle of the sequence + instead of exiling them at the end of the gallery. Only media whose owner has + no dated file at all end up at the end, in file name order. + + Every item is annotated with `capture_date` and `capture_date_is_guessed`. + """ + by_owner = {} + for item in items: + item.capture_date = item.date_taken + item.capture_date_is_guessed = False + by_owner.setdefault(item.owner_id, []).append(item) + + dated, undated = [], [] + for batch in by_owner.values(): + batch.sort(key=lambda item: (item.title, item.pk)) + # Forwards, then backwards for the files that come before the first date. + for sequence in (batch, batch[::-1]): + known = None + for item in sequence: + if item.capture_date is not None: + known = item.capture_date + elif known is not None: + item.capture_date = known + item.capture_date_is_guessed = True + for item in batch: + (dated if item.capture_date is not None else undated).append(item) + + dated.sort(key=lambda item: (item.capture_date, item.owner_id, item.title, item.pk)) + undated.sort(key=lambda item: (item.owner_id, item.title, item.pk)) + return dated + undated diff --git a/photologue/views.py b/photologue/views.py index 93116d2..21b4643 100644 --- a/photologue/views.py +++ b/photologue/views.py @@ -26,8 +26,15 @@ from django.contrib.auth import get_user_model from django.db.models import F, Q from .forms import UploadForm -from .models import Gallery, Photo, Tag, Video -from .utils import generate_video_thumbnail, is_photo, is_video +from .models import DATE_FROM_FILE, DATE_FROM_VIDEO, Gallery, Photo, Tag, Video +from .utils import ( + capture_date_from_upload, + capture_date_from_video, + generate_video_thumbnail, + is_photo, + is_video, + order_media_by_capture, +) MEDIA_MODELS = {"photo": Photo, "video": Video} @@ -229,9 +236,9 @@ class GalleryDetailView(DetailView): context["photos"] = context["photos"].filter(owner__id=self.kwargs["owner"]) context["videos"] = context["videos"].filter(owner__id=self.kwargs["owner"]) - # Combine photos and videos into a single sorted list for the template - context["media_items"] = sorted( - [*context["photos"], *context["videos"]], key=lambda x: x.title + # Combine photos and videos into a single list, in capture order + context["media_items"] = order_media_by_capture( + [*context["photos"], *context["videos"]] ) # Increment the photo view count @@ -304,7 +311,25 @@ class GalleryUpload(PermissionRequiredMixin, FormView): kwargs["is_staff"] = self.request.user.is_staff return kwargs - def _upload_media(self, model, file_field, file_obj, gallery, gallery_dir, owner, post_save=None): + @staticmethod + def _complete_capture_date(obj, modified_ms): + """ + Fill in the capture date that the file itself did not carry: photos get + theirs from EXIF when saved, videos from their container, and anything + still undated falls back on the file date sent by the browser. + """ + taken = source = None + if obj.date_taken is None and obj.is_video: + taken, source = capture_date_from_video(obj.file_path), DATE_FROM_VIDEO + if taken is None and obj.date_taken is None and modified_ms: + taken, source = capture_date_from_upload(modified_ms), DATE_FROM_FILE + if taken is None: + return + obj.set_capture_date(taken, source) + obj.save(update_fields=["date_taken", "date_source"]) + + def _upload_media(self, model, file_field, file_obj, gallery, gallery_dir, owner, + post_save=None, modified_ms=None): """ Create a media object, save it to the DB, schedule file save on commit. Returns True if uploaded, False if already exists. @@ -317,11 +342,12 @@ class GalleryUpload(PermissionRequiredMixin, FormView): with transaction.atomic(): obj.save() obj.galleries.set([gallery]) - def _save(o=obj, fp=file_path, f=file_obj, ff=file_field, ps=post_save): + def _save(o=obj, fp=file_path, f=file_obj, ff=file_field, ps=post_save, ms=modified_ms): f.seek(0) getattr(o, ff).save(fp, f) if ps: ps(o) + self._complete_capture_date(o, ms) transaction.on_commit(_save) return True @@ -359,11 +385,14 @@ class GalleryUpload(PermissionRequiredMixin, FormView): uploaded_photo_name = [] already_exists = 0 files = form.cleaned_data["file_field"] + modified_ms = form.cleaned_data.get("file_modified") for photo_file in files: if is_photo(photo_file): - uploaded = self._upload_media(Photo, "image", photo_file, gallery, gallery_dir, owner) + uploaded = self._upload_media(Photo, "image", photo_file, gallery, gallery_dir, owner, + modified_ms=modified_ms) elif is_video(photo_file): - uploaded = self._upload_media(Video, "file", photo_file, gallery, gallery_dir, owner, post_save=generate_video_thumbnail) + uploaded = self._upload_media(Video, "file", photo_file, gallery, gallery_dir, owner, + post_save=generate_video_thumbnail, modified_ms=modified_ms) else: messages.error(self.request, f"{photo_file.name} is not a recognized image or video") jsondata["code"] = 400 From e8e27cf48814116c8fc19c3d5ea98066e0ecd527 Mon Sep 17 00:00:00 2001 From: krek0 Date: Thu, 24 Sep 2026 13:41:55 +0200 Subject: [PATCH 3/4] Change photo with a two-finger horizontal trackpad swipe --- photologue/static/gallery_detail.js | 37 ++++++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/photologue/static/gallery_detail.js b/photologue/static/gallery_detail.js index ed345d6..56cbb94 100644 --- a/photologue/static/gallery_detail.js +++ b/photologue/static/gallery_detail.js @@ -5,7 +5,7 @@ // Init gallery -lightGallery(document.getElementById('lightgallery'), { +const lgInstance = lightGallery(document.getElementById('lightgallery'), { selector: 'a.photo-item', plugins: [lgAdmin, lgHash, lgThumbnail, lgZoom, lgVideo], autoplayFirstVideo: true, @@ -26,6 +26,41 @@ lightGallery(document.getElementById('lightgallery'), { //Add download warning code const lgContainer = document.getElementById('lightgallery'); +// Change photo with a two-finger horizontal swipe on a trackpad. Without this the +// browser reads the gesture as "go back" and leaves the gallery. +const SWIPE_DISTANCE = 80; // horizontal pixels before switching photo +const SWIPE_COOLDOWN = 400; // ms, so the inertia of one swipe moves one photo +const SWIPE_GAP = 200; // ms of quiet that starts a new gesture +let swipeOuter = null; +let swipeAmount = 0; +let swipeLastWheel = 0; +let swipeLastMove = 0; + +function onTrackpadSwipe(e) { + // Leave vertical scrolling, and zoomed in photos, alone. + if (Math.abs(e.deltaX) <= Math.abs(e.deltaY)) return; + e.preventDefault(); + if (swipeOuter.classList.contains('lg-zoomed')) return; + + const now = performance.now(); + if (now - swipeLastWheel > SWIPE_GAP) swipeAmount = 0; + swipeLastWheel = now; + if (now - swipeLastMove < SWIPE_COOLDOWN) return; + + swipeAmount += e.deltaX; + if (Math.abs(swipeAmount) < SWIPE_DISTANCE) return; + if (swipeAmount > 0) lgInstance.goToNextSlide(); + else lgInstance.goToPrevSlide(); + swipeAmount = 0; + swipeLastMove = now; +} + +lgContainer.addEventListener('lgAfterOpen', () => { + swipeOuter = document.querySelector('.lg-outer'); + swipeAmount = 0; + swipeOuter.addEventListener('wheel', onTrackpadSwipe, { passive: false }); +}); + // On attend que la galerie soit ouverte pour cibler le bouton lgContainer.addEventListener('lgAfterOpen', () => { const downloadBtn = document.querySelector('.lg-download'); From caa83ad318337302172a1653e98c8008322257a3 Mon Sep 17 00:00:00 2001 From: krek0 Date: Thu, 24 Sep 2026 13:50:18 +0200 Subject: [PATCH 4/4] Load gallery thumbnails lazily instead of all at once --- photologue/static/gallery_justified.js | 2 -- photologue/templates/photologue/gallery_detail.html | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/photologue/static/gallery_justified.js b/photologue/static/gallery_justified.js index 12ce8f9..c4958c7 100644 --- a/photologue/static/gallery_justified.js +++ b/photologue/static/gallery_justified.js @@ -80,8 +80,6 @@ container.style.height = (top - SPACING + PADDING) + 'px'; items.forEach(item => { item.style.visibility = 'visible'; - const img = item.querySelector('img[data-lazy]'); - if (img) img.src = img.dataset.lazy; }); } diff --git a/photologue/templates/photologue/gallery_detail.html b/photologue/templates/photologue/gallery_detail.html index 086b708..c9a85d6 100755 --- a/photologue/templates/photologue/gallery_detail.html +++ b/photologue/templates/photologue/gallery_detail.html @@ -112,7 +112,7 @@ SPDX-License-Identifier: GPL-3.0-or-later data-uncensor-url="{% url 'photologue:pl-media-uncensor' item.model_name item.id %}" data-width="{{ item.thumbnail_width|default:1 }}" data-height="{{ item.thumbnail_height|default:1 }}"> - {{ item.title }}{% if item.date_taken %} - {{ item.date_taken|date }} {{ item.date_taken|time }}{% endif %} - {{ item.owner.first_name|default:item.owner.username }}{% if not item.is_video and item.license %} - {{ item.license }}{% endif %}{% if not item.is_public %} - !PRIVATE!{% endif %}