From a97fb6078ba7b4af4152899a62338982224ed890 Mon Sep 17 00:00:00 2001 From: Wally Hackenslacker Date: Mon, 23 Mar 2026 20:25:40 -0400 Subject: [PATCH] image view: implement gallery-based layout, metadata dropdown, and thumbnails; add styles for image shadow; update tests --- viewer/static/css/noscript.css | 5 + viewer/static/css/styles.css | 26 +++ viewer/templates/image_view.html | 290 ++++++++++++++++++++++++------- viewer/test.py | 76 +++++++- viewer/views.py | 145 +++++++++++++++- 5 files changed, 468 insertions(+), 74 deletions(-) diff --git a/viewer/static/css/noscript.css b/viewer/static/css/noscript.css index ac6abe7..dce7464 100644 --- a/viewer/static/css/noscript.css +++ b/viewer/static/css/noscript.css @@ -10,3 +10,8 @@ width: 100% !important; flex: 0 0 auto !important; } + +/* Ensure image area still visible without JS */ +.image-wrapper { + max-width: 100%; +} diff --git a/viewer/static/css/styles.css b/viewer/static/css/styles.css index d080ca8..dadeaa5 100644 --- a/viewer/static/css/styles.css +++ b/viewer/static/css/styles.css @@ -175,6 +175,32 @@ body { padding-bottom: 8px; } +/* Image view specific styles */ +.image-content { + overflow: auto; + padding: 18px; +} + +.image-wrapper { + display: inline-block; + max-width: calc(100% - 40px); + border-radius: 14px; + overflow: hidden; + box-shadow: 0 5px 20px rgba(0, 0, 0, 0.35); +} + +.image-full { + display: block; + max-width: 100%; + height: auto; + border-radius: 14px; +} + +.info-menu { + min-width: 220px; + border-radius: 8px; +} + .thumb-card { width: 128px; display: block; diff --git a/viewer/templates/image_view.html b/viewer/templates/image_view.html index 11880dc..17ebdc0 100644 --- a/viewer/templates/image_view.html +++ b/viewer/templates/image_view.html @@ -1,74 +1,244 @@ {% load static %} - + - - - File: {{image_path.name}} - - + + + File: {{ image_path.name }} + + + + - - - - - - - + - - - - - -
- - + + - - +
+ +
Favorites + Most visited + Recently visited + +
+ +
-
+ + {% if home_thumb %} + home thumb + {% else %} + + {% endif %} + Home + + + +
+ + + {% csrf_token %} + +
+ + +
+
+ + + + + +
+ + +
+ + +
+ + + - - - - - - - - - - - - -
- {% if prev %} - - - - {% endif %} - - -
- -
-
-
- {% if next %} - - - - {% endif %} -
+ diff --git a/viewer/test.py b/viewer/test.py index 1aeca17..1991db7 100644 --- a/viewer/test.py +++ b/viewer/test.py @@ -299,6 +299,68 @@ class GalleryViewTests(GalleryBaseTests): self.assertEqual(response.context["image_path"].name, "beta.jpg") self.assertEqual(response.context["prev"], "alpha.jpg") self.assertEqual(response.context["next"], "gamma.jpg") + # New context keys for image view should be present + self.assertIn("prev_url", response.context) + self.assertIn("next_url", response.context) + self.assertIsNone(response.context.get("prev_url")) if response.context.get( + "prev" + ) is None else self.assertTrue( + "/gallery/alpha.jpg" in response.context.get("prev_url") + ) + self.assertIsNone(response.context.get("next_url")) if response.context.get( + "next" + ) is None else self.assertTrue( + "/gallery/gamma.jpg" in response.context.get("next_url") + ) + + # Thumbnails (if available) should be provided as /thumbs/... or be None + prev_thumb = response.context.get("prev_thumb") + next_thumb = response.context.get("next_thumb") + if prev_thumb is not None: + self.assertTrue(prev_thumb.startswith("/thumbs/")) + if next_thumb is not None: + self.assertTrue(next_thumb.startswith("/thumbs/")) + + # Breadcrumbs should include filename as last label and it must be non-clickable (path None) + breadcrumbs = response.context.get("breadcrumbs") + self.assertIsNotNone(breadcrumbs) + self.assertEqual(breadcrumbs[-1]["label"], "beta.jpg") + self.assertIsNone(breadcrumbs[-1]["path"]) + + # Back and Home URLs should exist and preserve query state + back_url = response.context.get("back_url") + home_url = response.context.get("home_url") + self.assertIsNotNone(back_url) + self.assertIsNotNone(home_url) + # For images in root both should be the gallery root with query params + # normalize any './' segments to compare reliably + norm = lambda u: u.replace("/./", "/") if u is not None else u + self.assertEqual(norm(back_url), norm(home_url)) + self.assertIn("/gallery/", norm(back_url)) + self.assertIn("sort=abc", back_url) + self.assertIn("theme=dark", back_url) + + # Back and Home thumbnails should be available and point to /thumbs/ + back_thumb = response.context.get("back_thumb") + home_thumb = response.context.get("home_thumb") + self.assertIsNotNone(back_thumb) + self.assertIsNotNone(home_thumb) + self.assertTrue(back_thumb.startswith("/thumbs/")) + self.assertTrue(home_thumb.startswith("/thumbs/")) + self.assertEqual(back_thumb, home_thumb) + + # Image metadata should be present and sensible + image_meta = response.context.get("image_meta") + self.assertIsNotNone(image_meta) + self.assertEqual(image_meta.get("filename"), "beta.jpg") + self.assertEqual(image_meta.get("width"), 300) + self.assertEqual(image_meta.get("height"), 200) + self.assertIsNotNone(image_meta.get("filesize")) + self.assertTrue( + "KB" in image_meta.get("filesize") or "MB" in image_meta.get("filesize") + ) + self.assertIsNotNone(image_meta.get("created")) + self.assertIsNotNone(image_meta.get("modified")) def test_sort_modes_recent_and_tnecer_use_mtime(self): recent = self.client.get("/gallery/?sort=recent") @@ -365,9 +427,11 @@ class GalleryTemplateTests(GalleryBaseTests): response = self.client.get("/gallery/?theme=dark") self.assertEqual(response.status_code, 200) - body = response.content.decode("utf-8") - self.assertIn(".search-input::placeholder", body) - self.assertIn("color: var(--muted);", body) + # Styles are served via static files; assert the rules exist in the stylesheet + css_path = Path(__file__).resolve().parent / "static" / "css" / "styles.css" + css = css_path.read_text(encoding="utf-8") + self.assertIn(".search-input::placeholder", css) + self.assertIn("color: var(--muted);", css) def test_sort_dropdown_active_option_underlined(self): response = self.client.get("/gallery/?sort=recent") @@ -381,5 +445,7 @@ class GalleryTemplateTests(GalleryBaseTests): response = self.client.get("/gallery/") self.assertEqual(response.status_code, 200) - body = response.content.decode("utf-8") - self.assertIn("box-shadow: 0 5px 20px", body) + # The requested drop shadows should be defined in the stylesheet + css_path = Path(__file__).resolve().parent / "static" / "css" / "styles.css" + css = css_path.read_text(encoding="utf-8") + self.assertIn("box-shadow: 0 5px 20px", css) diff --git a/viewer/views.py b/viewer/views.py index 26b69a3..ddef4bf 100644 --- a/viewer/views.py +++ b/viewer/views.py @@ -11,6 +11,8 @@ from django.shortcuts import render, redirect # Project imports. from .utils import make_thumbnail, is_image_file +from PIL import Image +import datetime ########################################################################################### # Constants. # @@ -371,31 +373,156 @@ def _render_image(request, path_text, full_path): Renders the view corresponding to an image file. """ + # Preserve query state (sort, search, theme) similar to gallery view + search_text = request.GET.get("search", "").strip() + sort_key = normalize_sort(request.GET.get("sort", "abc")) + theme = normalize_theme(request.GET.get("theme", "dark")) + query_state = build_query(search_text, sort_key, theme) + image = Path("/imgs/").joinpath(path_text) img_dir = full_path.parent try: - siblings = [ - entry.name + 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") - images = sorted(siblings, key=lambda item: item.lower()) + # Sort siblings according to requested sort mode + images_sorted = sort_images(entries, sort_key) - if image.name not in images: + # Find index of current image + try: + index = next(i for i, p in enumerate(images_sorted) if p.name == full_path.name) + except StopIteration: return HttpResponseNotFound("Not found") - index = images.index(image.name) - previous = index - 1 if index > 0 else None - following = index + 1 if index < len(images) - 1 else None + prev_path = images_sorted[index - 1] if index > 0 else None + next_path = images_sorted[index + 1] if index < len(images_sorted) - 1 else None + + # Helper to produce thumb url for a Path or None + def thumb_for(path_obj): + if path_obj is None: + return None + try: + make_thumbnail(path_obj) + rel = path_obj.relative_to(settings.GALLERY_ROOT) + thumb_path = settings.THUMBNAILS_ROOT.joinpath(rel) + if thumb_path.exists(): + return "/thumbs/" + str(rel).replace("\\", "/") + except Exception: + pass + return None + + # Build URLs (preserving query state) + prev_url = None + next_url = None + if prev_path is not None: + rel = prev_path.relative_to(settings.GALLERY_ROOT) + prev_url = gallery_url(rel, False, query_state) + + if next_path is not None: + rel = next_path.relative_to(settings.GALLERY_ROOT) + next_url = gallery_url(rel, False, query_state) + + # Back (directory) and Home (root) links and thumbnails + dir_rel = None + try: + # derive directory path text relative to gallery root + dir_rel = full_path.parent.relative_to(settings.GALLERY_ROOT) + dir_text = str(dir_rel).replace("\\", "/") + 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) + home_url = gallery_url(None, True, query_state) + home_thumb = get_first_image_thumbnail_url(settings.GALLERY_ROOT) + + # Prev/next thumbnails + prev_thumb = thumb_for(prev_path) + next_thumb = thumb_for(next_path) + + # Image metadata + width = height = None + filesize = None + created_ts = None + modified_ts = None + try: + img_file = full_path + with Image.open(img_file) as im: + width, height = im.size + except Exception: + pass + + try: + stat = full_path.stat() + filesize = stat.st_size + created_ts = getattr(stat, "st_birthtime", stat.st_ctime) + modified_ts = stat.st_mtime + except Exception: + pass + + def human_size(bytes_val): + if bytes_val is None: + return None + kb = 1024.0 + if bytes_val < kb * 1024: + return f"{bytes_val / kb:.2f} KB" + return f"{bytes_val / (kb * kb):.2f} MB" + + def fmt_ts(ts): + if ts is None: + return None + try: + return datetime.datetime.fromtimestamp(ts).strftime("%c") + 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}) + + next_theme = "light" if theme == "dark" else "dark" + theme_query = {"sort": sort_key, "theme": next_theme} + if search_text != "": + theme_query["search"] = search_text context = { "image_path": image, - "prev": images[previous] if previous is not None else None, - "next": images[following] if following is not None else None, + # keep legacy prev/next names for tests + "prev": prev_path.name if prev_path is not None else None, + "next": next_path.name if next_path is not None else None, + # new richer values + "prev_url": prev_url, + "next_url": next_url, + "prev_thumb": prev_thumb, + "next_thumb": next_thumb, + "back_url": back_url, + "back_thumb": back_thumb, + "home_url": home_url, + "home_thumb": home_thumb, + "image_meta": { + "filename": full_path.name, + "width": width, + "height": height, + "filesize": human_size(filesize), + "created": fmt_ts(created_ts), + "modified": fmt_ts(modified_ts), + }, + "breadcrumbs": breadcrumbs, + "theme": theme, + "sort_key": sort_key, + "sort_label": SORT_LABELS[sort_key], + "sort_options": build_sort_options(request, search_text, sort_key, theme), + "theme_toggle_url": append_query(request.path, theme_query), } return render(request, "image_view.html", context)