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 }}
+
+
+
+
-
-
-
-
-
-
-
-
-
+
+ |
+
-
-
-
-
+
+
+
+
+
+
+
+
+ |
-
-
-
+
+
+
+
+
+
+
+ {% for crumb in breadcrumbs %}
+ {% if not forloop.first %}
+ /
+ {% endif %}
+ {% if crumb.path %}
+ {{ crumb.label }}
+ {% else %}
+ {{ crumb.label }}
+ {% endif %}
+ {% endfor %}
+
+
+
+
+
+
+
+
+
+
+
+
+ |
-
-
+
+
+
-
-
-
-
-
- {% 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)