special galleries: helper module, renderers, templates & urls; hide back in special views; highlight active special

This commit is contained in:
2026-03-27 20:09:35 -04:00
parent 24c1c96f19
commit 532690a329
8 changed files with 252 additions and 64 deletions

BIN
viewer/.views.py.kate-swp Normal file

Binary file not shown.

View File

@@ -24,6 +24,8 @@ from .common import (
is_image_file,
make_thumbnail,
)
from .specials import get_special_paths, special_name
from .models import Image as ImModel
def do_recursive_search(start_path, query):
@@ -63,7 +65,7 @@ def do_recursive_search(start_path, query):
return subdirs, images
def render_directory(request, path_text, full_path):
def render_directory(request, path_text, full_path, special=None):
"""
Renders the gallery view related to directories, be it the contents of an actual directory
in the file system, or logical gallery directories like search result pages.
@@ -81,6 +83,17 @@ def render_directory(request, path_text, full_path):
)
query_state = build_query(search_text)
# If rendering a logical special gallery, obtain the image list from the
# Image model and skip filesystem subdir enumeration.
if special is not None:
# build images using the helper that returns Path objects
images = get_special_paths(request.user, special)
# Respect the client's sort_key only if the special is favorites
# otherwise the ordering comes from the DB query (most-visited/recent).
if special == "favorites":
images = sort_images(images, sort_key)
current_subdirs = []
else:
try:
current_entries = [
entry for entry in full_path.iterdir() if not entry.is_symlink()
@@ -162,6 +175,11 @@ def render_directory(request, path_text, full_path):
except Exception:
dir_text = ""
# For special galleries hide the Back control but still present Home
if special is not None:
back_url = None
back_thumb = None
else:
back_url = gallery_url(
Path(dir_text) if dir_text != "" else None, True, query_state
)
@@ -194,6 +212,20 @@ def render_directory(request, path_text, full_path):
),
}
# When rendering a special gallery adjust breadcrumbs and hide search
# and back controls. Use `is_special` and `special_name` in the template.
if special is not None:
context["is_special"] = True
context["special_name"] = special_name(special)
# expose which special is active so templates can highlight it
context["active_special"] = special
# Override breadcrumbs for special galleries to be a single label
context["breadcrumbs"] = [{"label": context["special_name"], "path": None}]
# Hide the search box (templates use `is_special` to decide)
context["search_text"] = ""
context["search_action_url"] = ""
context["clear_search_url"] = ""
# sort_label depends on SORT_LABELS in common; import lazily to avoid circulars
from .common import SORT_LABELS

View File

@@ -31,9 +31,10 @@ from .common import (
is_image_file,
make_thumbnail,
)
from .specials import get_special_paths, special_name
def render_image(request, path_text, full_path):
def render_image(request, path_text, full_path, special=None):
"""
Renders the view corresponding to an image file.
"""
@@ -67,6 +68,13 @@ def render_image(request, path_text, full_path):
image = Path("/imgs/").joinpath(path_text)
img_dir = full_path.parent
if special is not None:
# Use the special gallery ordering for prev/next navigation
images_sorted = get_special_paths(request.user, special)
# If favorites, respect client sort; otherwise keep DB ordering
if special == "favorites":
images_sorted = sort_images(images_sorted, sort_key)
else:
try:
entries = [
entry
@@ -79,7 +87,7 @@ def render_image(request, path_text, full_path):
# Sort siblings according to requested sort mode
images_sorted = sort_images(entries, sort_key)
# Find index of current image
# Find index of current image within the resolved list
try:
index = next(i for i, p in enumerate(images_sorted) if p.name == full_path.name)
except StopIteration:
@@ -122,6 +130,11 @@ def render_image(request, path_text, full_path):
except Exception:
dir_text = ""
# For special galleries hide the Back control but still present Home
if special is not None:
back_url = None
back_thumb = None
else:
back_url = gallery_url(
Path(dir_text) if dir_text != "" else None, True, query_state
)
@@ -169,7 +182,15 @@ def render_image(request, path_text, full_path):
except Exception:
return None
# Breadcrumbs: include directory breadcrumbs then append file name as label-only
# Breadcrumbs
if special is not None:
# SPECIAL NAME / IMAGE
breadcrumbs = [
{"label": special_name(special), "path": None},
{"label": full_path.name, "path": None},
]
else:
# include directory breadcrumbs then append file name as label-only
# build_breadcrumbs expects a path_text representing directories only
dir_path_text = dir_text if dir_text != "" else ""
breadcrumbs = build_breadcrumbs(dir_path_text, query_state)
@@ -216,6 +237,7 @@ def render_image(request, path_text, full_path):
"sort_options": build_sort_options(request, search_text, sort_key, theme),
"theme_toggle_url": append_query("/gallery/toggle-settings/", theme_query),
"path": path_text,
"is_special": special is not None,
}
from .common import SORT_LABELS

71
viewer/specials.py Normal file
View File

@@ -0,0 +1,71 @@
"""
Helpers for assembling "special" galleries (favorites, most-visited, recent).
These functions return filesystem Path objects suitable for use by the
existing directory and image renderers.
"""
from pathlib import Path
from django.conf import settings
from .models import Image as ImModel
SPECIALS = {
"favorites": "Favorites",
"most-visited": "Most Visited",
"recent": "Recent",
}
def special_name(key):
return SPECIALS.get(key, key)
def special_root_url(key, query_dict=None):
# Build a URL for the special gallery root (e.g. /gallery/favorites/)
base = f"/gallery/{key}/"
if not query_dict:
return base
from urllib.parse import urlencode
return base + "?" + urlencode(query_dict)
def get_special_paths(user, key, limit=100):
"""Return a list of pathlib.Path objects for the requested special gallery.
Only include files that actually exist on disk. The returned list is
ordered according to the special gallery semantics.
"""
qs = ImModel.objects.filter(user=user)
if key == "favorites":
qs = qs.filter(favorite=True).order_by("-last_visited")
elif key == "most-visited":
qs = qs.order_by("-visits")
elif key == "recent":
qs = qs.order_by("-last_visited")
else:
return []
if key in ("most-visited", "recent"):
qs = qs[:limit]
paths = []
for row in qs:
try:
p = Path(row.path)
# ensure the stored path is inside GALLERY_ROOT for safety
try:
p.relative_to(settings.GALLERY_ROOT)
except Exception:
# skip paths outside the gallery root
continue
if p.exists() and p.is_file():
paths.append(p)
except Exception:
continue
return paths

View File

@@ -29,6 +29,7 @@
<hr>
{% if not is_special %}
<form action="{{ search_action_url }}" method="get">
<div class="input-group input-group-sm">
<input type="search" name="search" class="form-control search-input" placeholder="Search..." value="{{ search_text }}">
@@ -40,12 +41,13 @@
{% endif %}
</div>
</form>
{% endif %}
<hr>
<a href="#" class="sidebar-link">Favorites</a>
<a href="#" class="sidebar-link">Most visited</a>
<a href="#" class="sidebar-link">Recently visited</a>
<a href="/gallery/favorites/" class="sidebar-link {% if active_special == 'favorites' %}active-sort{% endif %}">Favorites</a>
<a href="/gallery/most-visited/" class="sidebar-link {% if active_special == 'most-visited' %}active-sort{% endif %}">Most visited</a>
<a href="/gallery/recent/" class="sidebar-link {% if active_special == 'recent' %}active-sort{% endif %}">Recently visited</a>
{% if path != '' %}
<hr>
@@ -112,18 +114,20 @@
<hr>
{% if not is_special %}
<form action="{{ search_action_url }}" method="get">
<div class="input-group input-group-sm">
<input type="search" name="search" class="form-control search-input" placeholder="Search..." value="{{ search_text }}">
<span class="input-group-text search-addon"><i class="fa-solid fa-magnifying-glass"></i></span>
</div>
</form>
{% endif %}
<hr>
<a href="#" class="sidebar-link">Favorites</a>
<a href="#" class="sidebar-link">Most visited</a>
<a href="#" class="sidebar-link">Recently visited</a>
<a href="/gallery/favorites/" class="sidebar-link">Favorites</a>
<a href="/gallery/most-visited/" class="sidebar-link">Most visited</a>
<a href="/gallery/recent/" class="sidebar-link">Recently visited</a>
{% if path != '' %}
<hr>
@@ -186,7 +190,11 @@
{% if not forloop.first %}
<span class="crumb-sep">/</span>
{% endif %}
{% if crumb.path %}
<a href="{{ crumb.path }}" class="{% if not forloop.last %}crumb-link{% else %}crumb-link-last{% endif %}">{{ crumb.label|truncatechars:45 }}</a>
{% else %}
<span class="{% if not forloop.last %}crumb-link{% else %}crumb-link-last{% endif %}">{{ crumb.label|truncatechars:45 }}</span>
{% endif %}
{% endfor %}
</div>

View File

@@ -29,9 +29,9 @@
<hr>
<a href="#" class="sidebar-link">Favorites</a>
<a href="#" class="sidebar-link">Most visited</a>
<a href="#" class="sidebar-link">Recently visited</a>
<a href="/gallery/favorites/" class="sidebar-link {% if is_special and breadcrumbs.0.label == 'Favorites' %}active-sort{% endif %}">Favorites</a>
<a href="/gallery/most-visited/" class="sidebar-link {% if is_special and breadcrumbs.0.label == 'Most Visited' %}active-sort{% endif %}">Most visited</a>
<a href="/gallery/recent/" class="sidebar-link {% if is_special and breadcrumbs.0.label == 'Recent' %}active-sort{% endif %}">Recently visited</a>
<hr>
@@ -110,9 +110,9 @@
<span class="crumb-sep">/</span>
{% endif %}
{% if crumb.path %}
<a href="{{ crumb.path }}" class="crumb-link">{{ crumb.label }}</a>
<a href="{{ crumb.path }}" class="{% if not forloop.last %}crumb-link{% else %}crumb-link-last{% endif %}">{{ crumb.label }}</a>
{% else %}
<span class="crumb-link-full">{{ crumb.label }}</span>
<span class="{% if not forloop.last %}crumb-link{% else %}crumb-link-last{% endif %}">{{ crumb.label }}</span>
{% endif %}
{% endfor %}
</div>

View File

@@ -1,8 +1,8 @@
# Django imports
from django.urls import path
from django.urls import path, re_path
# Module imports
from .views import gallery_view, toggle_settings
from .views import gallery_view, toggle_settings, special_gallery_view
###########################################################################################
# URL Patterns. #
@@ -13,5 +13,17 @@ urlpatterns = [
# Views.
path("", gallery_view, name="gallery_view_root"),
path("toggle-settings/", toggle_settings, name="toggle_settings"),
# Special gallery routes (explicit list to avoid clashing with regular paths)
re_path(
r"^(?P<gallery>favorites|most-visited|recent)/(?P<path>.*)/$",
special_gallery_view,
name="special_gallery_view_path",
),
re_path(
r"^(?P<gallery>favorites|most-visited|recent)/$",
special_gallery_view,
name="special_gallery_view_root",
),
# Generic gallery path (catch-all for filesystem paths)
path("<path:path>/", gallery_view, name="gallery_view_path"),
]

View File

@@ -20,6 +20,9 @@ from .image import render_image
from .models import UserSettings
SPECIALS = ['favorites', 'most-visited', 'recent']
@login_required
def index(request):
return redirect("gallery_view_root")
@@ -51,6 +54,46 @@ def gallery_view(request, path=None):
return render_image(request, path_text, candidate)
@login_required
def special_gallery_view(request, gallery, path=None):
"""
Shows a list of subdirectories and image files inside the given special gallery.
Available special galleries are: (1) favorites; (2) most visited; (3) recently visited.
"""
root = settings.GALLERY_ROOT.resolve()
if gallery not in SPECIALS:
return HttpResponseNotFound("Not found")
try:
candidate = root.joinpath(path).resolve() if path is not None else root
candidate.relative_to(root)
except Exception:
return HttpResponseNotFound("Not found")
path_text = path if path is not None else ""
if path is not None:
# Special galleries only have a logical root directory.
# When there is a valid sub-directory path inside the gallery root
# in the request then redirect to the base special gallery.
if candidate.is_dir():
return redirect('special_gallery_view_path', gallery, None, permanent=True)
else:
# When there is no path to render, then go to the corresponding special gallery root.
return render_directory(request, path_text, candidate, gallery)
# Fail if the requested image doesn' exist.
if not candidate.exists():
return HttpResponseNotFound("Not found")
# Images are rendered normally, with a control to ensure the back, next and previous buttons
# use the special gallery.
return render_image(request, path_text, candidate, gallery)
@login_required
def toggle_settings(request):
"""Persist theme and/or sort for the current user and redirect back.