From 532690a329dac1256f3efd268d40a2f255ab04ce Mon Sep 17 00:00:00 2001 From: Wally Hackenslacker Date: Fri, 27 Mar 2026 20:09:35 -0400 Subject: [PATCH] special galleries: helper module, renderers, templates & urls; hide back in special views; highlight active special --- viewer/.views.py.kate-swp | Bin 0 -> 91 bytes viewer/directory.py | 90 +++++++++++++++++++---------- viewer/image.py | 64 +++++++++++++------- viewer/specials.py | 71 +++++++++++++++++++++++ viewer/templates/gallery_view.html | 22 ++++--- viewer/templates/image_view.html | 10 ++-- viewer/urls.py | 16 ++++- viewer/views.py | 43 ++++++++++++++ 8 files changed, 252 insertions(+), 64 deletions(-) create mode 100644 viewer/.views.py.kate-swp create mode 100644 viewer/specials.py diff --git a/viewer/.views.py.kate-swp b/viewer/.views.py.kate-swp new file mode 100644 index 0000000000000000000000000000000000000000..23c4271966601bd49fb7ab00fc77d301afbd62a5 GIT binary patch literal 91 zcmZQzU=Z?7EJ;-eE>A2_aLdd|RWQ;sU|?Vn>Dt7uH0c)8oAy&uo)f~u#O$N*?FkM7 R%0vUPBM`f~qHvwixd1d!6bt|W literal 0 HcmV?d00001 diff --git a/viewer/directory.py b/viewer/directory.py index ddd19d9..3788cec 100644 --- a/viewer/directory.py +++ b/viewer/directory.py @@ -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,31 +83,42 @@ def render_directory(request, path_text, full_path): ) query_state = build_query(search_text) - try: - current_entries = [ - entry for entry in full_path.iterdir() if not entry.is_symlink() - ] - except OSError: - return HttpResponseNotFound("Not found") - - current_subdirs = sorted( - [entry for entry in current_entries if entry.is_dir()], - key=lambda item: ( - item.name.lower(), - str(item.relative_to(settings.GALLERY_ROOT)).lower(), - ), - ) - - if search_text == "": - images = [ - entry - for entry in current_entries - if entry.is_file() and is_image_file(entry) - ] + # 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: - _, images = do_recursive_search(full_path, search_text) + try: + current_entries = [ + entry for entry in full_path.iterdir() if not entry.is_symlink() + ] + except OSError: + return HttpResponseNotFound("Not found") - images = sort_images(images, sort_key) + current_subdirs = sorted( + [entry for entry in current_entries if entry.is_dir()], + key=lambda item: ( + item.name.lower(), + str(item.relative_to(settings.GALLERY_ROOT)).lower(), + ), + ) + + if search_text == "": + images = [ + entry + for entry in current_entries + if entry.is_file() and is_image_file(entry) + ] + else: + _, images = do_recursive_search(full_path, search_text) + + images = sort_images(images, sort_key) image_data = [] for image in images: @@ -162,10 +175,15 @@ def render_directory(request, path_text, full_path): except Exception: dir_text = "" - back_url = gallery_url( - Path(dir_text) if dir_text != "" else None, True, query_state - ) - back_thumb = get_first_image_thumbnail_url(full_path.parent) + # 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 + ) + back_thumb = get_first_image_thumbnail_url(full_path.parent) home_url = gallery_url(None, True, query_state) home_thumb = get_first_image_thumbnail_url(settings.GALLERY_ROOT) @@ -189,11 +207,25 @@ def render_directory(request, path_text, full_path): "search_action_url": gallery_url( Path(path_text) if path_text != "" else None, True, search_action_query ), - "clear_search_url": gallery_url( + "clear_search_url": gallery_url( Path(path_text) if path_text != "" else None, True, None ), } + # 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 diff --git a/viewer/image.py b/viewer/image.py index 2ef98d1..abe40ee 100644 --- a/viewer/image.py +++ b/viewer/image.py @@ -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,19 +68,26 @@ def render_image(request, path_text, full_path): image = Path("/imgs/").joinpath(path_text) img_dir = full_path.parent - try: - entries = [ - entry - for entry in img_dir.iterdir() - if entry.is_file() and not entry.is_symlink() and is_image_file(entry) - ] - except OSError: - return HttpResponseNotFound("Not found") + 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 + for entry in img_dir.iterdir() + if entry.is_file() and not entry.is_symlink() and is_image_file(entry) + ] + except OSError: + return HttpResponseNotFound("Not found") - # Sort siblings according to requested sort mode - images_sorted = sort_images(entries, sort_key) + # 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,10 +130,15 @@ def render_image(request, path_text, full_path): except Exception: dir_text = "" - back_url = gallery_url( - Path(dir_text) if dir_text != "" else None, True, query_state - ) - back_thumb = get_first_image_thumbnail_url(full_path.parent) + # 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 + ) + back_thumb = get_first_image_thumbnail_url(full_path.parent) home_url = gallery_url(None, True, query_state) home_thumb = get_first_image_thumbnail_url(settings.GALLERY_ROOT) @@ -169,11 +182,19 @@ def render_image(request, path_text, full_path): except Exception: return None - # Breadcrumbs: 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) - breadcrumbs.append({"label": full_path.name, "path": None}) + # 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) + breadcrumbs.append({"label": full_path.name, "path": None}) next_theme = "light" if theme == "dark" else "dark" theme_query = { @@ -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 diff --git a/viewer/specials.py b/viewer/specials.py new file mode 100644 index 0000000..d6be536 --- /dev/null +++ b/viewer/specials.py @@ -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 diff --git a/viewer/templates/gallery_view.html b/viewer/templates/gallery_view.html index 7da9b18..467e47e 100644 --- a/viewer/templates/gallery_view.html +++ b/viewer/templates/gallery_view.html @@ -29,6 +29,7 @@
+ {% if not is_special %}
@@ -40,12 +41,13 @@ {% endif %}
+ {% endif %}
- Favorites - Most visited - Recently visited + Favorites + Most visited + Recently visited {% if path != '' %}
@@ -112,18 +114,20 @@
+ {% if not is_special %}
+ {% endif %}
- Favorites - Most visited - Recently visited + Favorites + Most visited + Recently visited {% if path != '' %}
@@ -186,7 +190,11 @@ {% if not forloop.first %} / {% endif %} - {{ crumb.label|truncatechars:45 }} + {% if crumb.path %} + {{ crumb.label|truncatechars:45 }} + {% else %} + {{ crumb.label|truncatechars:45 }} + {% endif %} {% endfor %} diff --git a/viewer/templates/image_view.html b/viewer/templates/image_view.html index b6ab7c9..1e78dcb 100644 --- a/viewer/templates/image_view.html +++ b/viewer/templates/image_view.html @@ -29,9 +29,9 @@
- Favorites - Most visited - Recently visited + Favorites + Most visited + Recently visited
@@ -110,9 +110,9 @@ / {% endif %} {% if crumb.path %} - {{ crumb.label }} + {{ crumb.label }} {% else %} - {{ crumb.label }} + {{ crumb.label }} {% endif %} {% endfor %} diff --git a/viewer/urls.py b/viewer/urls.py index ab6a97a..e59f7cf 100644 --- a/viewer/urls.py +++ b/viewer/urls.py @@ -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"^(?Pfavorites|most-visited|recent)/(?P.*)/$", + special_gallery_view, + name="special_gallery_view_path", + ), + re_path( + r"^(?Pfavorites|most-visited|recent)/$", + special_gallery_view, + name="special_gallery_view_root", + ), + # Generic gallery path (catch-all for filesystem paths) path("/", gallery_view, name="gallery_view_path"), ] diff --git a/viewer/views.py b/viewer/views.py index b14c9cd..8ab2072 100644 --- a/viewer/views.py +++ b/viewer/views.py @@ -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.