image view: implement gallery-based layout, metadata dropdown, and thumbnails; add styles for image shadow; update tests
This commit is contained in:
@@ -10,3 +10,8 @@
|
|||||||
width: 100% !important;
|
width: 100% !important;
|
||||||
flex: 0 0 auto !important;
|
flex: 0 0 auto !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Ensure image area still visible without JS */
|
||||||
|
.image-wrapper {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|||||||
@@ -175,6 +175,32 @@ body {
|
|||||||
padding-bottom: 8px;
|
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 {
|
.thumb-card {
|
||||||
width: 128px;
|
width: 128px;
|
||||||
display: block;
|
display: block;
|
||||||
|
|||||||
@@ -1,74 +1,244 @@
|
|||||||
{% load static %}
|
{% load static %}
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta name="viewport" content="width=device-width, height=device-height" />
|
<meta charset="utf-8">
|
||||||
<title>
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
File: {{image_path.name}}
|
<title>File: {{ image_path.name }}</title>
|
||||||
</title>
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
|
||||||
<link href="{% static 'css/old_styles.css' %}" rel="stylesheet">
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.0.0/css/all.min.css" crossorigin="anonymous" referrerpolicy="no-referrer">
|
||||||
|
<link href="{% static 'css/styles.css' %}" rel="stylesheet">
|
||||||
|
<noscript>
|
||||||
|
<link href="{% static 'css/noscript.css' %}" rel="stylesheet">
|
||||||
|
</noscript>
|
||||||
</head>
|
</head>
|
||||||
|
<body class="theme-{{ theme }}">
|
||||||
<body class="background">
|
<div class="app-shell d-flex">
|
||||||
<!-- Navigation. -->
|
<aside class="sidebar d-none d-lg-flex flex-column">
|
||||||
<table class="fc mauto">
|
<div class="d-flex align-items-center user-row">
|
||||||
<tr>
|
<span><i class="fa-solid fa-circle-user fa-lg"></i></span>
|
||||||
<!-- Home button. -->
|
<span class="user-name text-truncate">{{ request.user.username }}</span>
|
||||||
<td>
|
<a class="btn btn-sm btn-plain ms-auto" href="{{ theme_toggle_url }}" aria-label="Toggle theme">
|
||||||
<a href="/gallery/">
|
{% if theme == 'dark' %}
|
||||||
<img src="{% static 'imgs/gohome.png' %}" class="small-nav-icon">
|
<i class="fa-solid fa-sun"></i>
|
||||||
|
{% else %}
|
||||||
|
<i class="fa-solid fa-moon"></i>
|
||||||
|
{% endif %}
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</div>
|
||||||
|
|
||||||
<!-- Back button. -->
|
<hr>
|
||||||
<td>
|
|
||||||
<a href="..">
|
<a href="#" class="sidebar-link">Favorites</a>
|
||||||
<img src="{% static 'imgs/back.png' %}" class="small-nav-icon">
|
<a href="#" class="sidebar-link">Most visited</a>
|
||||||
|
<a href="#" class="sidebar-link">Recently visited</a>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<div class="sidebar-scroll flex-grow-1">
|
||||||
|
{% if prev_url %}
|
||||||
|
<a href="{{ prev_url }}" class="subdir-item">
|
||||||
|
{% if prev_thumb %}
|
||||||
|
<img src="{{ prev_thumb }}" class="subdir-thumb" alt="prev thumb">
|
||||||
|
{% else %}
|
||||||
|
<span class="subdir-fallback"><i class="fa-solid fa-image"></i></span>
|
||||||
|
{% endif %}
|
||||||
|
<span>Previous</span>
|
||||||
</a>
|
</a>
|
||||||
</td>
|
{% else %}
|
||||||
|
<div class="subdir-item text-muted">
|
||||||
|
<span class="subdir-fallback"><i class="fa-solid fa-image"></i></span>
|
||||||
|
<span>Previous</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<!-- Logout button. -->
|
{% if next_url %}
|
||||||
<td>
|
<a href="{{ next_url }}" class="subdir-item">
|
||||||
<form method="post" action="{% url 'logout' %}" class="ml-4">
|
{% if next_thumb %}
|
||||||
|
<img src="{{ next_thumb }}" class="subdir-thumb" alt="next thumb">
|
||||||
|
{% else %}
|
||||||
|
<span class="subdir-fallback"><i class="fa-solid fa-image"></i></span>
|
||||||
|
{% endif %}
|
||||||
|
<span>Next</span>
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<div class="subdir-item text-muted">
|
||||||
|
<span class="subdir-fallback"><i class="fa-solid fa-image"></i></span>
|
||||||
|
<span>Next</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<a href="{{ back_url }}" class="subdir-item">
|
||||||
|
{% if back_thumb %}
|
||||||
|
<img src="{{ back_thumb }}" class="subdir-thumb" alt="back thumb">
|
||||||
|
{% else %}
|
||||||
|
<span class="subdir-fallback"><i class="fa-solid fa-image"></i></span>
|
||||||
|
{% endif %}
|
||||||
|
<span>Back</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a href="{{ home_url }}" class="subdir-item">
|
||||||
|
{% if home_thumb %}
|
||||||
|
<img src="{{ home_thumb }}" class="subdir-thumb" alt="home thumb">
|
||||||
|
{% else %}
|
||||||
|
<span class="subdir-fallback"><i class="fa-solid fa-image"></i></span>
|
||||||
|
{% endif %}
|
||||||
|
<span>Home</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<form method="post" action="{% url 'logout' %}">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<button type="submit" class="clear-btn">
|
<button type="submit" class="btn btn-sm btn-plain w-100">
|
||||||
<img src="{% static 'imgs/boot.png' %}" class="small-nav-icon">
|
<i class="fa-solid fa-arrow-up-from-bracket"></i>
|
||||||
|
<span class="ms-1">Logout</span>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</td>
|
</aside>
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<!-- Image view. -->
|
<main class="main-area d-flex flex-column flex-grow-1">
|
||||||
<table class="fc mauto h90">
|
<div class="top-bar d-flex align-items-center gap-2">
|
||||||
<tr>
|
<button class="btn btn-sm btn-plain sidebar-toggle d-lg-none" type="button" data-bs-toggle="offcanvas" data-bs-target="#sidebarOffcanvas" aria-controls="sidebarOffcanvas" aria-label="Open menu">
|
||||||
<!-- Previous image. -->
|
<i class="fa-solid fa-bars"></i>
|
||||||
<td>
|
</button>
|
||||||
{% if prev %}
|
|
||||||
<a href="../{{prev}}">
|
<div class="breadcrumb-wrap flex-grow-1">
|
||||||
<img src="{% static 'imgs/back.png' %}" class="navigation-icon">
|
{% for crumb in breadcrumbs %}
|
||||||
</a>
|
{% if not forloop.first %}
|
||||||
|
<span class="crumb-sep">/</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
{% if crumb.path %}
|
||||||
|
<a href="{{ crumb.path }}" class="crumb-link">{{ crumb.label }}</a>
|
||||||
|
{% else %}
|
||||||
|
<span class="crumb-link">{{ crumb.label }}</span>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Current image. -->
|
<div class="dropdown ms-auto">
|
||||||
<td>
|
<button class="btn btn-sm btn-plain" type="button" data-bs-toggle="dropdown" aria-expanded="false" title="Info" aria-label="Image info">
|
||||||
<a href="{{image_path}}" target="_blank">
|
<i class="fa-solid fa-circle-info"></i>
|
||||||
<div class="image-container">
|
</button>
|
||||||
<img src="{{image_path}}" class="image">
|
<div class="dropdown-menu dropdown-menu-end info-menu p-2">
|
||||||
|
<div style="max-height:220px; overflow:auto;">
|
||||||
|
<div class="small text-muted">{{ image_meta.filename }}</div>
|
||||||
|
{% if image_meta.width and image_meta.height %}
|
||||||
|
<div>{{ image_meta.width }} x {{ image_meta.height }} px</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if image_meta.filesize %}
|
||||||
|
<div>{{ image_meta.filesize }}</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if image_meta.created %}
|
||||||
|
<div>Creation date {{ image_meta.created }}</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if image_meta.modified %}
|
||||||
|
<div>Modification date {{ image_meta.modified }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="gallery-scroll flex-grow-1 d-flex">
|
||||||
|
<div class="image-content w-100 d-flex justify-content-center align-items-start">
|
||||||
|
<a href="{{ image_path }}" target="_blank">
|
||||||
|
<div class="image-wrapper">
|
||||||
|
<img src="{{ image_path }}" alt="{{ image_path.name }}" class="image-full">
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Next image. -->
|
<div class="offcanvas offcanvas-start" tabindex="-1" id="sidebarOffcanvas" aria-labelledby="sidebarOffcanvasLabel">
|
||||||
<td>
|
<div class="offcanvas-header pb-2">
|
||||||
{% if next %}
|
<h2 class="h6 mb-0" id="sidebarOffcanvasLabel">Gallery</h2>
|
||||||
<a href="../{{next}}">
|
<div class="d-flex align-items-center gap-2">
|
||||||
<img src="{% static 'imgs/forward.png' %}" class="navigation-icon">
|
<a class="btn btn-sm btn-plain" href="{{ theme_toggle_url }}" aria-label="Toggle theme">
|
||||||
|
{% if theme == 'dark' %}
|
||||||
|
<i class="fa-solid fa-sun"></i>
|
||||||
|
{% else %}
|
||||||
|
<i class="fa-solid fa-moon"></i>
|
||||||
|
{% endif %}
|
||||||
|
</a>
|
||||||
|
<button type="button" class="btn btn-sm btn-plain sidebar-close" data-bs-dismiss="offcanvas" aria-label="Close menu">
|
||||||
|
<i class="fa-solid fa-xmark"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="offcanvas-body pt-0 d-flex flex-column">
|
||||||
|
<aside class="sidebar d-flex flex-column">
|
||||||
|
<div class="d-flex align-items-center user-row">
|
||||||
|
<span><i class="fa-solid fa-circle-user fa-lg"></i></span>
|
||||||
|
<span class="user-name text-truncate">{{ request.user.username }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<a href="#" class="sidebar-link">Favorites</a>
|
||||||
|
<a href="#" class="sidebar-link">Most visited</a>
|
||||||
|
<a href="#" class="sidebar-link">Recently visited</a>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<div class="sidebar-scroll">
|
||||||
|
{% if prev_url %}
|
||||||
|
<a href="{{ prev_url }}" class="subdir-item">
|
||||||
|
{% if prev_thumb %}
|
||||||
|
<img src="{{ prev_thumb }}" class="subdir-thumb" alt="prev thumb">
|
||||||
|
{% else %}
|
||||||
|
<span class="subdir-fallback"><i class="fa-solid fa-image"></i></span>
|
||||||
|
{% endif %}
|
||||||
|
<span>Previous</span>
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
|
||||||
</tr>
|
{% if next_url %}
|
||||||
</table>
|
<a href="{{ next_url }}" class="subdir-item">
|
||||||
|
{% if next_thumb %}
|
||||||
|
<img src="{{ next_thumb }}" class="subdir-thumb" alt="next thumb">
|
||||||
|
{% else %}
|
||||||
|
<span class="subdir-fallback"><i class="fa-solid fa-image"></i></span>
|
||||||
|
{% endif %}
|
||||||
|
<span>Next</span>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<a href="{{ back_url }}" class="subdir-item">
|
||||||
|
{% if back_thumb %}
|
||||||
|
<img src="{{ back_thumb }}" class="subdir-thumb" alt="back thumb">
|
||||||
|
{% else %}
|
||||||
|
<span class="subdir-fallback"><i class="fa-solid fa-image"></i></span>
|
||||||
|
{% endif %}
|
||||||
|
<span>Back</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a href="{{ home_url }}" class="subdir-item">
|
||||||
|
{% if home_thumb %}
|
||||||
|
<img src="{{ home_thumb }}" class="subdir-thumb" alt="home thumb">
|
||||||
|
{% else %}
|
||||||
|
<span class="subdir-fallback"><i class="fa-solid fa-image"></i></span>
|
||||||
|
{% endif %}
|
||||||
|
<span>Home</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<form method="post" action="{% url 'logout' %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button type="submit" class="btn btn-sm btn-plain w-100">
|
||||||
|
<i class="fa-solid fa-arrow-up-from-bracket"></i>
|
||||||
|
<span class="ms-1">Logout</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -299,6 +299,68 @@ class GalleryViewTests(GalleryBaseTests):
|
|||||||
self.assertEqual(response.context["image_path"].name, "beta.jpg")
|
self.assertEqual(response.context["image_path"].name, "beta.jpg")
|
||||||
self.assertEqual(response.context["prev"], "alpha.jpg")
|
self.assertEqual(response.context["prev"], "alpha.jpg")
|
||||||
self.assertEqual(response.context["next"], "gamma.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):
|
def test_sort_modes_recent_and_tnecer_use_mtime(self):
|
||||||
recent = self.client.get("/gallery/?sort=recent")
|
recent = self.client.get("/gallery/?sort=recent")
|
||||||
@@ -365,9 +427,11 @@ class GalleryTemplateTests(GalleryBaseTests):
|
|||||||
response = self.client.get("/gallery/?theme=dark")
|
response = self.client.get("/gallery/?theme=dark")
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
body = response.content.decode("utf-8")
|
# Styles are served via static files; assert the rules exist in the stylesheet
|
||||||
self.assertIn(".search-input::placeholder", body)
|
css_path = Path(__file__).resolve().parent / "static" / "css" / "styles.css"
|
||||||
self.assertIn("color: var(--muted);", body)
|
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):
|
def test_sort_dropdown_active_option_underlined(self):
|
||||||
response = self.client.get("/gallery/?sort=recent")
|
response = self.client.get("/gallery/?sort=recent")
|
||||||
@@ -381,5 +445,7 @@ class GalleryTemplateTests(GalleryBaseTests):
|
|||||||
response = self.client.get("/gallery/")
|
response = self.client.get("/gallery/")
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
body = response.content.decode("utf-8")
|
# The requested drop shadows should be defined in the stylesheet
|
||||||
self.assertIn("box-shadow: 0 5px 20px", body)
|
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)
|
||||||
|
|||||||
145
viewer/views.py
145
viewer/views.py
@@ -11,6 +11,8 @@ from django.shortcuts import render, redirect
|
|||||||
|
|
||||||
# Project imports.
|
# Project imports.
|
||||||
from .utils import make_thumbnail, is_image_file
|
from .utils import make_thumbnail, is_image_file
|
||||||
|
from PIL import Image
|
||||||
|
import datetime
|
||||||
|
|
||||||
###########################################################################################
|
###########################################################################################
|
||||||
# Constants. #
|
# Constants. #
|
||||||
@@ -371,31 +373,156 @@ def _render_image(request, path_text, full_path):
|
|||||||
Renders the view corresponding to an image file.
|
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)
|
image = Path("/imgs/").joinpath(path_text)
|
||||||
img_dir = full_path.parent
|
img_dir = full_path.parent
|
||||||
|
|
||||||
try:
|
try:
|
||||||
siblings = [
|
entries = [
|
||||||
entry.name
|
entry
|
||||||
for entry in img_dir.iterdir()
|
for entry in img_dir.iterdir()
|
||||||
if entry.is_file() and not entry.is_symlink() and is_image_file(entry)
|
if entry.is_file() and not entry.is_symlink() and is_image_file(entry)
|
||||||
]
|
]
|
||||||
except OSError:
|
except OSError:
|
||||||
return HttpResponseNotFound("Not found")
|
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")
|
return HttpResponseNotFound("Not found")
|
||||||
|
|
||||||
index = images.index(image.name)
|
prev_path = images_sorted[index - 1] if index > 0 else None
|
||||||
previous = index - 1 if index > 0 else None
|
next_path = images_sorted[index + 1] if index < len(images_sorted) - 1 else None
|
||||||
following = index + 1 if index < len(images) - 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 = {
|
context = {
|
||||||
"image_path": image,
|
"image_path": image,
|
||||||
"prev": images[previous] if previous is not None else None,
|
# keep legacy prev/next names for tests
|
||||||
"next": images[following] if following is not None else None,
|
"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)
|
return render(request, "image_view.html", context)
|
||||||
|
|||||||
Reference in New Issue
Block a user