Add public shareable link for galleries
This commit is contained in:
parent
b76a350c28
commit
a875c2707b
7 changed files with 126 additions and 14 deletions
24
photo21/static/copy-button.js
Normal file
24
photo21/static/copy-button.js
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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 %}
|
||||||
|
|
|
||||||
|
|
@ -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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -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"]
|
||||||
|
|
|
||||||
|
|
@ -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 %}
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -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", args=[gallery_slug])
|
||||||
|
else:
|
||||||
url = reverse_lazy("photologue:pl-gallery-archive")
|
url = reverse_lazy("photologue:pl-gallery-archive")
|
||||||
url = reverse_lazy("photologue:pl-gallery", args=[gallery_slug])
|
|
||||||
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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue