diff --git a/photo21/static/copy-button.js b/photo21/static/copy-button.js
new file mode 100644
index 0000000..91f7ec8
--- /dev/null
+++ b/photo21/static/copy-button.js
@@ -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);
+ });
+});
diff --git a/photo21/templates/base.html b/photo21/templates/base.html
index 66ea520..9a1c499 100644
--- a/photo21/templates/base.html
+++ b/photo21/templates/base.html
@@ -36,6 +36,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
{% if request.user.is_authenticated %}
diff --git a/photologue/migrations/0004_alter_photo_options_gallery_public_token.py b/photologue/migrations/0004_alter_photo_options_gallery_public_token.py
new file mode 100644
index 0000000..5cae560
--- /dev/null
+++ b/photologue/migrations/0004_alter_photo_options_gallery_public_token.py
@@ -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'),
+ ),
+ ]
diff --git a/photologue/models.py b/photologue/models.py
index d08603f..3ca67b5 100644
--- a/photologue/models.py
+++ b/photologue/models.py
@@ -174,6 +174,13 @@ class Gallery(models.Model):
verbose_name=_("photos"),
blank=True,
)
+ public_token = models.UUIDField(
+ _("public token"),
+ null=True,
+ blank=True,
+ unique=True,
+ default=None,
+ )
class Meta:
ordering = ["-date_start"]
diff --git a/photologue/templates/photologue/gallery_detail.html b/photologue/templates/photologue/gallery_detail.html
index 06c6c39..c317ff2 100644
--- a/photologue/templates/photologue/gallery_detail.html
+++ b/photologue/templates/photologue/gallery_detail.html
@@ -25,6 +25,8 @@ SPDX-License-Identifier: GPL-3.0-or-later
+
+{% if guest_mode %}{% endif %}
{% endblock %}
{% block content %}
@@ -41,6 +43,18 @@ SPDX-License-Identifier: GPL-3.0-or-later
{% if gallery.date_start %}{{ gallery.date_start }}{% if gallery.date_end and gallery.date_end != gallery.date_start %} {% trans "to" %} {{ gallery.date_end }}{% endif %}
{% endif %}
{% if request.user.is_staff and gallery.photo_private_count %}{{ gallery.photo_private_count }} photos censurées
{% endif %}
+{% if not guest_mode and request.user.is_staff %}
+ {% if public_url %}
+
+ {% trans "Public link:" %}
+
+
+
+
+ {% else %}
+
+ {% endif %}
+{% endif %}
{% if 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 %}
{{ gallery.description|safe|linebreaksbr }}
{% endif %}
+ {% if not guest_mode %}
+ {% endif %}
+ {% if not guest_mode %}
+ {% endif %}
{% endblock %}
diff --git a/photologue/urls.py b/photologue/urls.py
index 68f7d76..003109c 100644
--- a/photologue/urls.py
+++ b/photologue/urls.py
@@ -8,6 +8,8 @@ from .views import (
GalleryArchiveIndexView,
GalleryDetailView,
GalleryDownload,
+ GalleryPublicView,
+ GalleryTokenView,
GalleryUpload,
GalleryYearArchiveView,
PhotoDeleteView,
@@ -40,4 +42,6 @@ urlpatterns = [
path("photo/
/delete/", PhotoDeleteView.as_view(), name="pl-photo-delete"),
path("photo//report/", PhotoReportView.as_view(), name="pl-photo-report"),
path("upload/", GalleryUpload.as_view(), name="pl-gallery-upload"),
+ path("share//", GalleryPublicView.as_view(), name="pl-gallery-public"),
+ path("gallery//token/", GalleryTokenView.as_view(), name="pl-gallery-token"),
]
diff --git a/photologue/views.py b/photologue/views.py
index 0ad9e9f..b0081f5 100644
--- a/photologue/views.py
+++ b/photologue/views.py
@@ -3,6 +3,7 @@
# SPDX-License-Identifier: GPL-3.0-or-later
import os
+import uuid
import zipfile
from io import BytesIO
from pathlib import Path
@@ -14,9 +15,10 @@ from django.core.mail import mail_admins
from django.db import IntegrityError
from django.http import HttpResponse
from django.http import JsonResponse
-from django.shortcuts import redirect
-from django.urls import reverse_lazy
+from django.shortcuts import get_object_or_404, redirect
+from django.urls import reverse, reverse_lazy
from django.utils.text import slugify
+from django.views import View
from django.views.generic.dates import ArchiveIndexView, YearArchiveView
from django.views.generic.detail import DetailView
from django.views.generic.edit import DeleteView, FormView
@@ -87,34 +89,29 @@ class PhotoDeleteView(PermissionRequiredMixin, DeleteView):
return reverse_lazy("photologue:pl-gallery", args=[slug])
-class PhotoReportView(LoginRequiredMixin, DetailView):
+class PhotoReportView(DetailView):
model = Photo
template_name = "photologue/photo_confirm_report.html"
def post(self, request, *args, **kwargs):
- """
- Make photo private on POST.
- """
- # Mark photo as private
photo = self.get_object()
photo.is_public = False
photo.save()
- # Get gallery
galleries = photo.galleries.all()
gallery_slug = galleries[0].slug if galleries else ""
- if not gallery_slug:
+ if gallery_slug:
+ url = reverse_lazy("photologue:pl-gallery", args=[gallery_slug])
+ else:
url = reverse_lazy("photologue:pl-gallery-archive")
- url = reverse_lazy("photologue:pl-gallery", args=[gallery_slug])
url = request.build_absolute_uri(url)
- # Send mail to managers
+ reporter = request.user.username if request.user.is_authenticated else "anonymous"
mail_admins(
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)
@@ -165,7 +162,9 @@ class GalleryDetailView(LoginRequiredMixin, DetailView):
if "owner" in self.kwargs:
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)
@@ -207,6 +206,42 @@ class GalleryDownload(LoginRequiredMixin, DetailView):
# 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):
"""
Form to upload new photos in a gallery