Add public shareable link for galleries

This commit is contained in:
krek0 2026-04-20 22:29:17 +02:00
parent b76a350c28
commit a875c2707b
7 changed files with 126 additions and 14 deletions

View file

@ -0,0 +1,24 @@
document.querySelectorAll('[data-clipboard-text]').forEach(function(btn) {
btn.addEventListener('click', function() {
var text = btn.dataset.clipboardText;
if (navigator.clipboard) {
navigator.clipboard.writeText(text);
} else {
var ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
}
var original = btn.textContent;
btn.textContent = '✓ Copied!';
btn.disabled = true;
setTimeout(function() {
btn.textContent = original;
btn.disabled = false;
}, 2000);
});
});

View file

@ -36,6 +36,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
</button> </button>
<div class="collapse navbar-collapse" id="navbarSupportedContent"> <div class="collapse navbar-collapse" id="navbarSupportedContent">
<ul class="navbar-nav me-auto mb-2 mb-lg-0"> <ul class="navbar-nav me-auto mb-2 mb-lg-0">
{% if not request.guest_mode %}
<li class="nav-item"> <li class="nav-item">
{% url 'photologue:pl-gallery-archive' as url %} {% url 'photologue:pl-gallery-archive' as url %}
<a class="nav-link {% if request.path_info == url %}active{% endif %}" href="{{ url }}">{% trans 'Galleries' %}</a> <a class="nav-link {% if request.path_info == url %}active{% endif %}" href="{{ url }}">{% trans 'Galleries' %}</a>
@ -51,6 +52,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
<a class="nav-link" href="{% url 'admin:index' %}">{% trans 'Manage' %}</a> <a class="nav-link" href="{% url 'admin:index' %}">{% trans 'Manage' %}</a>
</li> </li>
{% endif %} {% endif %}
{% endif %}
</ul> </ul>
<ul class="navbar-nav"> <ul class="navbar-nav">
{% if request.user.is_authenticated %} {% if request.user.is_authenticated %}

View file

@ -0,0 +1,22 @@
# Generated by Django 5.2.13 on 2026-04-20 19:54
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('photologue', '0003_remove_gallery_is_public'),
]
operations = [
migrations.AlterModelOptions(
name='photo',
options={'get_latest_by': 'date_added', 'ordering': ['title'], 'verbose_name': 'photo', 'verbose_name_plural': 'photos'},
),
migrations.AddField(
model_name='gallery',
name='public_token',
field=models.UUIDField(blank=True, default=None, null=True, unique=True, verbose_name='public token'),
),
]

View file

@ -174,6 +174,13 @@ class Gallery(models.Model):
verbose_name=_("photos"), verbose_name=_("photos"),
blank=True, blank=True,
) )
public_token = models.UUIDField(
_("public token"),
null=True,
blank=True,
unique=True,
default=None,
)
class Meta: class Meta:
ordering = ["-date_start"] ordering = ["-date_start"]

View file

@ -25,6 +25,8 @@ SPDX-License-Identifier: GPL-3.0-or-later
<script src="{% static 'lightgallery/plugins/zoom/lg-zoom.min.js' %}"></script> <script src="{% static 'lightgallery/plugins/zoom/lg-zoom.min.js' %}"></script>
<script src="{% static 'gallery_detail.js' %}"></script> <script src="{% static 'gallery_detail.js' %}"></script>
<script src="{% static 'sweetalert.js' %}"></script> <script src="{% static 'sweetalert.js' %}"></script>
<script src="{% static 'copy-button.js' %}"></script>
{% if guest_mode %}<style>#lg-download, #lg-admin, #lg-delete { display: none !important; }</style>{% endif %}
{% endblock %} {% endblock %}
{% block content %} {% block content %}
@ -41,6 +43,18 @@ SPDX-License-Identifier: GPL-3.0-or-later
</h1> </h1>
{% if gallery.date_start %}<p class="text-muted small">{{ gallery.date_start }}{% if gallery.date_end and gallery.date_end != gallery.date_start %} {% trans "to" %} {{ gallery.date_end }}{% endif %}</p>{% endif %} {% if gallery.date_start %}<p class="text-muted small">{{ gallery.date_start }}{% if gallery.date_end and gallery.date_end != gallery.date_start %} {% trans "to" %} {{ gallery.date_end }}{% endif %}</p>{% endif %}
{% if request.user.is_staff and gallery.photo_private_count %}<p class="text-danger small">{{ gallery.photo_private_count }} photos censurées</p>{% endif %} {% if request.user.is_staff and gallery.photo_private_count %}<p class="text-danger small">{{ gallery.photo_private_count }} photos censurées</p>{% endif %}
{% if not guest_mode and request.user.is_staff %}
{% if public_url %}
<div class="alert alert-secondary py-1 d-flex align-items-center gap-2 small" role="alert">
<span>{% trans "Public link:" %}</span>
<input type="text" class="form-control form-control-sm" id="public-link-input" value="{{ public_url }}" readonly style="max-width:400px">
<button class="btn btn-outline-secondary btn-sm" data-clipboard-text="{{ public_url }}">{% trans "Copy" %}</button>
<form method="post" action="{% url 'photologue:pl-gallery-token' gallery.slug %}" class="mb-0">{% csrf_token %}<input type="hidden" name="action" value="revoke"><button type="submit" class="btn btn-outline-danger btn-sm">{% trans "Revoke" %}</button></form>
</div>
{% else %}
<form method="post" action="{% url 'photologue:pl-gallery-token' gallery.slug %}" class="mb-2">{% csrf_token %}<input type="hidden" name="action" value="generate"><button type="submit" class="btn btn-outline-primary btn-sm">{% trans "Generate public link" %}</button></form>
{% endif %}
{% endif %}
{% if gallery.tags.all %} {% if gallery.tags.all %}
<p class="text-muted"> <p class="text-muted">
Tags : {% for tag in gallery.tags.all %} Tags : {% for tag in gallery.tags.all %}
@ -51,6 +65,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
{% if gallery.description %}<p>{{ gallery.description|safe|linebreaksbr }}</p>{% endif %} {% if gallery.description %}<p>{{ gallery.description|safe|linebreaksbr }}</p>{% endif %}
<div class="card"> <div class="card">
{% if not guest_mode %}
<div class="card-header pb-0 border-bottom-0"> <div class="card-header pb-0 border-bottom-0">
<ul class="nav nav-tabs"> <ul class="nav nav-tabs">
<li class="nav-item"> <li class="nav-item">
@ -69,6 +84,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
{% endfor %} {% endfor %}
</ul> </ul>
</div> </div>
{% endif %}
<div class="card-body row" id="lightgallery"> <div class="card-body row" id="lightgallery">
{% for photo in photos %} {% for photo in photos %}
<a class="col-6 col-md-3 mb-2 text-center" href="{{ photo.get_absolute_url }}" data-src="{{ photo.get_display_url}}" data-download-url="{{ photo.image.url }}" data-slide-name="{{ photo.id }}"> <a class="col-6 col-md-3 mb-2 text-center" href="{{ photo.get_absolute_url }}" data-src="{{ photo.get_display_url}}" data-download-url="{{ photo.image.url }}" data-slide-name="{{ photo.id }}">
@ -76,6 +92,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
</a> </a>
{% endfor %} {% endfor %}
</div> </div>
{% if not guest_mode %}
<div class="card-footer"> <div class="card-footer">
<a href="{% url 'photologue:pl-gallery-download' gallery.slug %}" class="btn btn-secondary btn-sm"> <a href="{% url 'photologue:pl-gallery-download' gallery.slug %}" class="btn btn-secondary btn-sm">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-download" viewBox="0 0 16 16"> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-download" viewBox="0 0 16 16">
@ -85,5 +102,6 @@ SPDX-License-Identifier: GPL-3.0-or-later
{% trans 'Download all gallery' %} {% trans 'Download all gallery' %}
</a> </a>
</div> </div>
{% endif %}
</div> </div>
{% endblock %} {% endblock %}

View file

@ -8,6 +8,8 @@ from .views import (
GalleryArchiveIndexView, GalleryArchiveIndexView,
GalleryDetailView, GalleryDetailView,
GalleryDownload, GalleryDownload,
GalleryPublicView,
GalleryTokenView,
GalleryUpload, GalleryUpload,
GalleryYearArchiveView, GalleryYearArchiveView,
PhotoDeleteView, PhotoDeleteView,
@ -40,4 +42,6 @@ urlpatterns = [
path("photo/<int:pk>/delete/", PhotoDeleteView.as_view(), name="pl-photo-delete"), path("photo/<int:pk>/delete/", PhotoDeleteView.as_view(), name="pl-photo-delete"),
path("photo/<int:pk>/report/", PhotoReportView.as_view(), name="pl-photo-report"), path("photo/<int:pk>/report/", PhotoReportView.as_view(), name="pl-photo-report"),
path("upload/", GalleryUpload.as_view(), name="pl-gallery-upload"), path("upload/", GalleryUpload.as_view(), name="pl-gallery-upload"),
path("share/<uuid:token>/", GalleryPublicView.as_view(), name="pl-gallery-public"),
path("gallery/<slug:slug>/token/", GalleryTokenView.as_view(), name="pl-gallery-token"),
] ]

View file

@ -3,6 +3,7 @@
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
import os import os
import uuid
import zipfile import zipfile
from io import BytesIO from io import BytesIO
from pathlib import Path from pathlib import Path
@ -14,9 +15,10 @@ from django.core.mail import mail_admins
from django.db import IntegrityError from django.db import IntegrityError
from django.http import HttpResponse from django.http import HttpResponse
from django.http import JsonResponse from django.http import JsonResponse
from django.shortcuts import redirect from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse_lazy from django.urls import reverse, reverse_lazy
from django.utils.text import slugify from django.utils.text import slugify
from django.views import View
from django.views.generic.dates import ArchiveIndexView, YearArchiveView from django.views.generic.dates import ArchiveIndexView, YearArchiveView
from django.views.generic.detail import DetailView from django.views.generic.detail import DetailView
from django.views.generic.edit import DeleteView, FormView from django.views.generic.edit import DeleteView, FormView
@ -87,34 +89,29 @@ class PhotoDeleteView(PermissionRequiredMixin, DeleteView):
return reverse_lazy("photologue:pl-gallery", args=[slug]) return reverse_lazy("photologue:pl-gallery", args=[slug])
class PhotoReportView(LoginRequiredMixin, DetailView): class PhotoReportView(DetailView):
model = Photo model = Photo
template_name = "photologue/photo_confirm_report.html" template_name = "photologue/photo_confirm_report.html"
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
"""
Make photo private on POST.
"""
# Mark photo as private
photo = self.get_object() photo = self.get_object()
photo.is_public = False photo.is_public = False
photo.save() photo.save()
# Get gallery
galleries = photo.galleries.all() galleries = photo.galleries.all()
gallery_slug = galleries[0].slug if galleries else "" gallery_slug = galleries[0].slug if galleries else ""
if not gallery_slug: if gallery_slug:
url = reverse_lazy("photologue:pl-gallery-archive")
url = reverse_lazy("photologue:pl-gallery", args=[gallery_slug]) url = reverse_lazy("photologue:pl-gallery", args=[gallery_slug])
else:
url = reverse_lazy("photologue:pl-gallery-archive")
url = request.build_absolute_uri(url) url = request.build_absolute_uri(url)
# Send mail to managers reporter = request.user.username if request.user.is_authenticated else "anonymous"
mail_admins( mail_admins(
subject=f"Abuse report for photo id {photo.pk}", subject=f"Abuse report for photo id {photo.pk}",
message=f"{self.request.user.username} reported an abuse for `{photo.title}`: {url}#lg=1&slide={photo.pk}", message=f"{reporter} reported an abuse for `{photo.title}`: {url}#lg=1&slide={photo.pk}",
) )
# Redirect to gallery
return redirect(url) return redirect(url)
@ -165,7 +162,9 @@ class GalleryDetailView(LoginRequiredMixin, DetailView):
if "owner" in self.kwargs: if "owner" in self.kwargs:
context["photos"] = context["photos"].filter(owner__id=self.kwargs["owner"]) context["photos"] = context["photos"].filter(owner__id=self.kwargs["owner"])
# Increment the photo view count if self.object.public_token:
public_path = reverse("photologue:pl-gallery-public", args=[self.object.public_token])
context["public_url"] = self.request.build_absolute_uri(public_path)
context["photos"].update(view_count=F("view_count") + 1) context["photos"].update(view_count=F("view_count") + 1)
@ -207,6 +206,42 @@ class GalleryDownload(LoginRequiredMixin, DetailView):
# return response # return response
class GalleryPublicView(DetailView):
model = Gallery
template_name = "photologue/gallery_detail.html"
def get_object(self):
return get_object_or_404(Gallery, public_token=self.kwargs["token"])
def get(self, request, *args, **kwargs):
request.guest_mode = True
return super().get(request, *args, **kwargs)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["photos"] = self.object.photos.filter(is_public=True).select_related("owner")
context["owners"] = []
context["guest_mode"] = True
context["photos"].update(view_count=F("view_count") + 1)
return context
class GalleryTokenView(LoginRequiredMixin, View):
def post(self, request, slug):
if not request.user.is_staff:
from django.core.exceptions import PermissionDenied
raise PermissionDenied
gallery = get_object_or_404(Gallery, slug=slug)
action = request.POST.get("action")
if action == "generate":
gallery.public_token = uuid.uuid4()
gallery.save()
elif action == "revoke":
gallery.public_token = None
gallery.save()
return redirect(reverse("photologue:pl-gallery", args=[slug]))
class GalleryUpload(PermissionRequiredMixin, FormView): class GalleryUpload(PermissionRequiredMixin, FormView):
""" """
Form to upload new photos in a gallery Form to upload new photos in a gallery