Compare commits
18 Commits
a7008f9c28
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 24c1c96f19 | |||
| 5a2bea3040 | |||
| 27da3a33d3 | |||
|
|
ab81a0a97d | ||
|
|
b8e7a876a7 | ||
|
|
377951efbe | ||
|
|
73b538b698 | ||
|
|
8719be3227 | ||
|
|
13ab55d1d3 | ||
|
|
5ec793b47d | ||
|
|
f658106316 | ||
|
|
7cc7f04b80 | ||
|
|
5a6632491a | ||
| 77d83c58d1 | |||
| 480ec37ef6 | |||
| 3e209519e5 | |||
| caf8130c93 | |||
| d42de13d4e |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -362,3 +362,5 @@ Icon
|
||||
Network Trash Folder
|
||||
Temporary Items
|
||||
.apdisk
|
||||
|
||||
.kateproject*
|
||||
|
||||
40
AGENTS.md
40
AGENTS.md
@@ -22,13 +22,13 @@ This document is intentionally explicit so tasks can be completed with minimal r
|
||||
|
||||
## 3) Environment and Setup Commands
|
||||
|
||||
- Create venv: `python -m venv .venv`
|
||||
- Create venv: `.venv/bin/python -m venv .venv`
|
||||
- Activate venv (bash): `source .venv/bin/activate`
|
||||
- Install deps: `pip install -r requirements.txt`
|
||||
- Apply migrations: `python manage.py migrate`
|
||||
- Run server: `python manage.py runserver`
|
||||
- Apply migrations: `.venv/bin/python manage.py migrate`
|
||||
- Run server: `.venv/bin/python manage.py runserver`
|
||||
|
||||
Local configuration is loaded from `.env` via `python-dotenv`.
|
||||
Local configuration is loaded from `.env` via `.venv/bin/python-dotenv`.
|
||||
|
||||
Use this minimum `.env` content for gallery paths:
|
||||
|
||||
@@ -49,30 +49,30 @@ There is no dedicated linter or formatter config in-repo (no `ruff`, `flake8`, `
|
||||
|
||||
Use these commands as the standard validation set:
|
||||
|
||||
- Django system checks: `python manage.py check`
|
||||
- Migration consistency check: `python manage.py makemigrations --check --dry-run`
|
||||
- Python syntax sanity: `python -m compileall NibasaViewer viewer`
|
||||
- Run Django tests (all): `python manage.py test`
|
||||
- Django system checks: `.venv/bin/python manage.py check`
|
||||
- Migration consistency check: `.venv/bin/python manage.py makemigrations --check --dry-run`
|
||||
- Python syntax sanity: `.venv/bin/python -m compileall NibasaViewer viewer`
|
||||
- Run Django tests (all): `.venv/bin/python manage.py test`
|
||||
|
||||
Single-test execution patterns (important for fast iteration):
|
||||
|
||||
- Single test module: `python manage.py test viewer.test`
|
||||
- Single test class: `python manage.py test viewer.test.GalleryViewTests`
|
||||
- Single test method: `python manage.py test viewer.test.GalleryViewTests.test_search_action_url_uses_nested_path`
|
||||
- Single test module: `.venv/bin/python manage.py test viewer.test`
|
||||
- Single test class: `.venv/bin/python manage.py test viewer.test.GalleryViewTests`
|
||||
- Single test method: `.venv/bin/python manage.py test viewer.test.GalleryViewTests.test_search_action_url_uses_nested_path`
|
||||
|
||||
Notes:
|
||||
|
||||
- If there are currently no tests, add focused tests near the changed behavior before large refactors.
|
||||
- Prefer `python manage.py test ...` style labels over introducing a second test runner.
|
||||
- Prefer `.venv/bin/python manage.py test ...` style labels over introducing a second test runner.
|
||||
|
||||
## 5) Operational Commands
|
||||
|
||||
- Pre-generate thumbnails: `python manage.py makethumbnails`
|
||||
- Pre-generate thumbnails: `.venv/bin/python manage.py makethumbnails`
|
||||
- Create auth user (shell):
|
||||
- `python manage.py shell`
|
||||
- `.venv/bin/python manage.py shell`
|
||||
- `from django.contrib.auth.models import User`
|
||||
- `User.objects.create_user('<USERNAME>', '<EMAIL>', '<PASSWORD>')`
|
||||
- Collect static files (deploy-time): `python manage.py collectstatic`
|
||||
- Collect static files (deploy-time): `.venv/bin/python manage.py collectstatic`
|
||||
|
||||
## 6) Architecture and Change Boundaries
|
||||
|
||||
@@ -90,6 +90,10 @@ Notes:
|
||||
- `_render_image` in `viewer/views.py` now exposes additional context keys used by the template and tests: `prev_url`, `next_url`, `prev_thumb`, `next_thumb`, `back_url`, `back_thumb`, `home_url`, `home_thumb`, and `image_meta` (dictionary with `filename`, `width`, `height`, `filesize`, `created`, `modified`). Agents modifying image-view behavior should update tests in `viewer/test.py` as well.
|
||||
- The top-bar sort button for the image view was replaced by an Info button (`fa-circle-info`) which shows a dropdown containing image metadata. The dropdown is vertically scrollable for long content.
|
||||
- The displayed main image now uses the same drop shadow and rounded corners as thumbnails (styles added to `viewer/static/css/styles.css`).
|
||||
- The gallery view and image view were split into focused modules: `viewer/directory.py` handles directory browsing and search, `viewer/image.py` handles single-image rendering and metadata, `viewer/common.py` contains shared helpers (URL/query builders, sort helpers, breadcrumbs), and `viewer/utils.py` contains thumbnail helpers (`THUMB_SIZE`, `is_image_file`, `make_thumbnail`). `viewer/views.py` is now a thin delegating layer that routes requests to those modules.
|
||||
- The image view (`viewer/image.py`) exposes richer context keys used by the template and tests: `prev_url`, `next_url`, `prev_thumb`, `next_thumb`, `back_url`, `back_thumb`, `home_url`, `home_thumb`, and `image_meta` (dictionary with `filename`, `width`, `height`, `filesize`, `created`, `modified`). Agents modifying image-view behavior should update tests in `viewer/test.py` as well.
|
||||
- The top-bar sort button for the gallery view remains; the image view uses an Info button (`fa-circle-info`) which shows a dropdown containing image metadata. The dropdown is vertically scrollable for long content.
|
||||
- Thumbnails are 128×128 (`THUMB_SIZE`) and generated lazily on-demand in `viewer/utils.py`; `IMAGE_EXTENSIONS` provides fast extension-based filtering. A management command `.venv/bin/python manage.py makethumbnails` exists for batch pre-generation.
|
||||
|
||||
## 7) Code Style Guidelines (Repository-Specific)
|
||||
|
||||
@@ -169,8 +173,8 @@ When implementing a change, follow this minimum loop:
|
||||
|
||||
Suggested pre-handoff command bundle:
|
||||
|
||||
- `python manage.py check`
|
||||
- `python manage.py test` (or a targeted test label)
|
||||
- `python -m compileall NibasaViewer viewer`
|
||||
- `.venv/bin/python manage.py check`
|
||||
- `.venv/bin/python manage.py test` (or a targeted test label)
|
||||
- `.venv/bin/python -m compileall NibasaViewer viewer`
|
||||
|
||||
This repository values stability, compatibility, and straightforward Django patterns over novelty.
|
||||
|
||||
25
CLAUDE.md
25
CLAUDE.md
@@ -49,6 +49,13 @@ The project has one Django app: **`viewer`**. All gallery functionality is conso
|
||||
- `utils.py` - Thumbnail generation helpers
|
||||
- `templates/` - Pure HTML templates (no JavaScript)
|
||||
- `static/` - CSS styling and navigation icons
|
||||
- `views.py` - Thin delegating entry points that route to `viewer/directory.py` and `viewer/image.py` (see `gallery_view` in `viewer/views.py`).
|
||||
- `directory.py` - Directory browsing, search, and list rendering helpers.
|
||||
- `image.py` - Single-image rendering and metadata helpers.
|
||||
- `common.py` - Shared helpers: URL/query builders, sorting, breadcrumbs, thumbnail utilities.
|
||||
- `utils.py` - Thumbnail generation helpers.
|
||||
- `templates/` - HTML templates (minimal JS via Bootstrap where needed).
|
||||
- `static/` - CSS styling and navigation icons
|
||||
|
||||
### Gallery Data Flow
|
||||
|
||||
@@ -76,21 +83,23 @@ Uses Django's built-in `django.contrib.auth` system:
|
||||
**`gallery_view()` function** (viewer/views.py:65-189) handles both:
|
||||
|
||||
1. **Directory browsing** when path points to folder:
|
||||
- Lists subdirectories and images
|
||||
- Supports search with recursive directory scanning (viewer/views.py:32-52)
|
||||
- Paginates images at 28 per page (7 columns × 4 rows)
|
||||
- Converts flat image lists to table rows for HTML rendering
|
||||
- Lists subdirectories and images
|
||||
- Supports search with recursive directory scanning (`viewer/directory.py:do_recursive_search`)
|
||||
- Does not use server-side pagination; the gallery returns full image lists and templates render them in a grid
|
||||
- Converts image lists to structured context used by templates
|
||||
|
||||
2. **Image viewing** when path points to file:
|
||||
- Displays single image with prev/next navigation
|
||||
- Finds sibling images in same directory
|
||||
- Links to full-resolution image
|
||||
- Displays single image with prev/next navigation
|
||||
- Finds sibling images in same directory
|
||||
- Links to full-resolution image
|
||||
|
||||
The image view logic now lives in `viewer/image.py` and exposes additional context keys used by templates and tests (see `viewer/test.py`): `prev_url`, `next_url`, `prev_thumb`, `next_thumb`, `back_url`, `back_thumb`, `home_url`, `home_thumb`, and `image_meta` (filename/width/height/filesize/created/modified).
|
||||
|
||||
### Thumbnail Generation
|
||||
|
||||
**Lazy generation approach** (viewer/utils.py):
|
||||
- Thumbnails created on-demand when viewing gallery
|
||||
- 128×128 pixel size (hardcoded)
|
||||
- 128×128 pixel size (`THUMB_SIZE`) and cached under `THUMBNAILS_ROOT`
|
||||
- Uses Pillow for image processing
|
||||
- Fast extension-based image detection (no MIME-type I/O)
|
||||
|
||||
|
||||
65
NibasaViewer/middleware.py
Normal file
65
NibasaViewer/middleware.py
Normal file
@@ -0,0 +1,65 @@
|
||||
from django.template.response import TemplateResponse
|
||||
from django.core.exceptions import MultipleObjectsReturned
|
||||
|
||||
from viewer.models import UserSettings
|
||||
|
||||
|
||||
class UserSettingsMiddleware:
|
||||
"""Middleware that injects `theme` and `sort` into template response context.
|
||||
|
||||
On each request it looks for a UserSettings instance for the authenticated
|
||||
user. The found values (or model defaults) are attached to the request and
|
||||
then added to any TemplateResponse's context under the `theme` and `sort`
|
||||
keys.
|
||||
"""
|
||||
|
||||
def __init__(self, get_response):
|
||||
self.get_response = get_response
|
||||
|
||||
def __call__(self, request):
|
||||
# Populate request attributes with user settings or defaults.
|
||||
request.theme = None
|
||||
request.sort = None
|
||||
|
||||
user = getattr(request, "user", None)
|
||||
if user and getattr(user, "is_authenticated", False):
|
||||
# Use filter().first() to avoid exceptions if no settings exist.
|
||||
try:
|
||||
settings_obj = UserSettings.objects.get(user=user)
|
||||
except UserSettings.DoesNotExist:
|
||||
settings_obj = UserSettings(user=user)
|
||||
settings_obj.save()
|
||||
except MultipleObjectsReturned:
|
||||
settings_obj = UserSettings.objects.filter(user=user).first()
|
||||
finally:
|
||||
request.theme = settings_obj.theme
|
||||
request.sort = settings_obj.sort
|
||||
|
||||
# Fall back to the model defaults when not set from DB.
|
||||
if request.theme is None:
|
||||
request.theme = UserSettings._meta.get_field("theme").get_default()
|
||||
if request.sort is None:
|
||||
request.sort = UserSettings._meta.get_field("sort").get_default()
|
||||
|
||||
response = self.get_response(request)
|
||||
return response
|
||||
|
||||
def process_template_response(self, request, response):
|
||||
"""Add `theme` and `sort` to TemplateResponse.context_data.
|
||||
|
||||
This method is only called for responses that implement
|
||||
`render()` (TemplateResponse-like). We update or create
|
||||
`context_data` so templates can access `theme` and `sort`.
|
||||
"""
|
||||
if not isinstance(response, TemplateResponse):
|
||||
return response
|
||||
|
||||
ctx = getattr(response, "context_data", None)
|
||||
if ctx is None:
|
||||
response.context_data = {"theme": request.theme, "sort": request.sort}
|
||||
else:
|
||||
# Do not overwrite existing keys if templates or other processors set them
|
||||
ctx.setdefault("theme", request.theme)
|
||||
ctx.setdefault("sort", request.sort)
|
||||
|
||||
return response
|
||||
@@ -105,6 +105,7 @@ MIDDLEWARE = [
|
||||
"django.middleware.common.CommonMiddleware",
|
||||
"django.middleware.csrf.CsrfViewMiddleware",
|
||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||
"NibasaViewer.middleware.UserSettingsMiddleware",
|
||||
"django.contrib.messages.middleware.MessageMiddleware",
|
||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||
]
|
||||
|
||||
66
README.md
66
README.md
@@ -9,3 +9,69 @@ To login a user should be manually created by running the following commands in
|
||||
from django.contrib.auth.models import User
|
||||
user = User.objects.create_user('<USERNAME>', '<EMAIL>', '<PASSWORD>')
|
||||
user.save()
|
||||
|
||||
## Running the project
|
||||
|
||||
This project uses Django. The instructions below show how to run the site in a local/testing (development) mode and a simple release (production) mode. All commands assume you're in the repository root.
|
||||
|
||||
- Create and activate a virtualenv (recommended):
|
||||
|
||||
.venv/bin/python -m venv .venv
|
||||
source .venv/bin/activate
|
||||
|
||||
- Install dependencies:
|
||||
|
||||
pip install -r requirements.txt
|
||||
|
||||
- Configure environment variables in a `.env` file (the app reads `GALLERY_ROOT` and `THUMBNAILS_ROOT`). Example `.env` contents:
|
||||
|
||||
GALLERY_ROOT=/path/to/images
|
||||
THUMBNAILS_ROOT=/path/to/thumb-cache
|
||||
|
||||
Development / testing mode
|
||||
|
||||
- Apply migrations and run the development server:
|
||||
|
||||
.venv/bin/python manage.py migrate
|
||||
.venv/bin/python manage.py runserver
|
||||
|
||||
This runs Django's built-in development server (DEBUG mode). It's suitable for local testing and development only.
|
||||
|
||||
Release / production mode (simple guide)
|
||||
|
||||
- Collect static files and run with a WSGI server like `gunicorn` (example):
|
||||
|
||||
.venv/bin/python manage.py collectstatic --noinput
|
||||
GALLERY_ROOT=/path/to/images THUMBNAILS_ROOT=/path/to/thumb-cache \
|
||||
gunicorn NibasaViewer.wsgi:application --bind 0.0.0.0:8000 --workers 3
|
||||
|
||||
Set `DEBUG=False` and provide a proper `ALLOWED_HOSTS` value in your environment or settings when running in production. Use a reverse proxy (nginx) for serving static files and handling HTTPS in front of your WSGI server.
|
||||
|
||||
Running tests
|
||||
|
||||
- Run the full Django test suite:
|
||||
|
||||
.venv/bin/python manage.py test
|
||||
|
||||
- Useful focused test commands (fast iteration):
|
||||
|
||||
.venv/bin/python manage.py test viewer.test
|
||||
.venv/bin/python manage.py test viewer.test.GalleryViewTests
|
||||
.venv/bin/python manage.py test viewer.test.GalleryViewTests.test_search_action_url_uses_nested_path
|
||||
|
||||
- Sanity checks recommended before pushing changes:
|
||||
|
||||
.venv/bin/python manage.py check
|
||||
.venv/bin/python manage.py makemigrations --check --dry-run
|
||||
.venv/bin/python -m compileall NibasaViewer viewer
|
||||
|
||||
Other useful management commands
|
||||
|
||||
- Pre-generate thumbnails (batch):
|
||||
|
||||
.venv/bin/python manage.py makethumbnails
|
||||
|
||||
Notes
|
||||
|
||||
- Keep `.env` local and out of version control; use `.env.example` as a template if you want to commit a sample file.
|
||||
- For production deployments follow standard Django deployment guides (use a dedicated WSGI server, reverse proxy, secure secret management, and proper file permissions).
|
||||
|
||||
227
viewer/common.py
Normal file
227
viewer/common.py
Normal file
@@ -0,0 +1,227 @@
|
||||
# Standard library imports.
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlencode
|
||||
from functools import cmp_to_key
|
||||
|
||||
# Django imports.
|
||||
from django.conf import settings
|
||||
|
||||
# Project imports.
|
||||
from .utils import make_thumbnail, is_image_file
|
||||
|
||||
###########################################################################################
|
||||
# Constants. #
|
||||
###########################################################################################
|
||||
|
||||
SORT_OPTIONS = [
|
||||
("abc", "Alphabetical A-Z"),
|
||||
("cba", "Alphabetical Z-A"),
|
||||
("old", "Creation date old to new"),
|
||||
("new", "Creation date new to old"),
|
||||
("recent", "Modification date most recent"),
|
||||
("tnecer", "Modification date least recent"),
|
||||
]
|
||||
|
||||
SORT_LABELS = dict(SORT_OPTIONS)
|
||||
|
||||
|
||||
def normalize_sort(sort_value):
|
||||
return sort_value if sort_value in SORT_LABELS else "abc"
|
||||
|
||||
|
||||
def normalize_theme(theme_value):
|
||||
return theme_value if theme_value in ("dark", "light") else "dark"
|
||||
|
||||
|
||||
def get_creation_timestamp(path_obj):
|
||||
try:
|
||||
stat_data = path_obj.stat()
|
||||
return getattr(stat_data, "st_birthtime", stat_data.st_ctime)
|
||||
except OSError:
|
||||
return 0
|
||||
|
||||
|
||||
def get_modification_timestamp(path_obj):
|
||||
try:
|
||||
return path_obj.stat().st_mtime
|
||||
except OSError:
|
||||
return 0
|
||||
|
||||
|
||||
def build_query(search_text):
|
||||
query = {}
|
||||
|
||||
if search_text != "":
|
||||
query["search"] = search_text
|
||||
|
||||
return query
|
||||
|
||||
|
||||
def append_query(url, query_dict):
|
||||
if len(query_dict) == 0:
|
||||
return url
|
||||
|
||||
return url + "?" + urlencode(query_dict)
|
||||
|
||||
|
||||
def gallery_url(path_obj=None, is_dir=False, query_dict=None):
|
||||
if query_dict is None:
|
||||
query_dict = {}
|
||||
|
||||
if path_obj is None:
|
||||
base_url = "/gallery/"
|
||||
else:
|
||||
path_text = str(path_obj).replace("\\", "/")
|
||||
base_url = "/gallery/" + path_text
|
||||
|
||||
if is_dir and not base_url.endswith("/"):
|
||||
base_url += "/"
|
||||
|
||||
return append_query(base_url, query_dict)
|
||||
|
||||
|
||||
def sort_images(images, sort_key):
|
||||
def compare(img_a, img_b):
|
||||
name_a = img_a.name.lower()
|
||||
name_b = img_b.name.lower()
|
||||
rel_a = str(img_a.relative_to(settings.GALLERY_ROOT)).lower()
|
||||
rel_b = str(img_b.relative_to(settings.GALLERY_ROOT)).lower()
|
||||
|
||||
if sort_key == "abc":
|
||||
if name_a < name_b:
|
||||
return -1
|
||||
if name_a > name_b:
|
||||
return 1
|
||||
|
||||
elif sort_key == "cba":
|
||||
if name_a > name_b:
|
||||
return -1
|
||||
if name_a < name_b:
|
||||
return 1
|
||||
|
||||
elif sort_key == "old":
|
||||
created_a = get_creation_timestamp(img_a)
|
||||
created_b = get_creation_timestamp(img_b)
|
||||
|
||||
if created_a < created_b:
|
||||
return -1
|
||||
if created_a > created_b:
|
||||
return 1
|
||||
|
||||
elif sort_key == "new":
|
||||
created_a = get_creation_timestamp(img_a)
|
||||
created_b = get_creation_timestamp(img_b)
|
||||
|
||||
if created_a > created_b:
|
||||
return -1
|
||||
if created_a < created_b:
|
||||
return 1
|
||||
|
||||
elif sort_key == "recent":
|
||||
modified_a = get_modification_timestamp(img_a)
|
||||
modified_b = get_modification_timestamp(img_b)
|
||||
|
||||
if modified_a > modified_b:
|
||||
return -1
|
||||
if modified_a < modified_b:
|
||||
return 1
|
||||
|
||||
elif sort_key == "tnecer":
|
||||
modified_a = get_modification_timestamp(img_a)
|
||||
modified_b = get_modification_timestamp(img_b)
|
||||
|
||||
if modified_a < modified_b:
|
||||
return -1
|
||||
if modified_a > modified_b:
|
||||
return 1
|
||||
|
||||
if name_a < name_b:
|
||||
return -1
|
||||
if name_a > name_b:
|
||||
return 1
|
||||
if rel_a < rel_b:
|
||||
return -1
|
||||
if rel_a > rel_b:
|
||||
return 1
|
||||
return 0
|
||||
|
||||
return sorted(images, key=cmp_to_key(compare))
|
||||
|
||||
|
||||
def build_breadcrumbs(path_text, query_dict):
|
||||
breadcrumbs = [{"label": "Gallery", "path": gallery_url(None, True, query_dict)}]
|
||||
|
||||
if path_text == "":
|
||||
return breadcrumbs
|
||||
|
||||
segments = Path(path_text).parts
|
||||
current = Path("")
|
||||
|
||||
for segment in segments:
|
||||
current = current.joinpath(segment)
|
||||
breadcrumbs.append(
|
||||
{"label": segment, "path": gallery_url(current, True, query_dict)}
|
||||
)
|
||||
|
||||
return breadcrumbs
|
||||
|
||||
|
||||
def build_sort_options(request, search_text, sort_key, theme):
|
||||
options = []
|
||||
|
||||
for option_key, label in SORT_OPTIONS:
|
||||
# Build a URL that points to the settings toggle endpoint which will
|
||||
# persist the chosen sort (and optionally theme) in the user's
|
||||
# UserSettings and then redirect back to the current view. Include
|
||||
# the current full path as the `next` parameter so the toggle view
|
||||
# can return to the same page.
|
||||
query = {"next": request.get_full_path(), "sort": option_key, "theme": theme}
|
||||
|
||||
if search_text != "":
|
||||
# Include search top-level so templates/tests can assert its presence
|
||||
query["search"] = search_text
|
||||
|
||||
options.append(
|
||||
{
|
||||
"key": option_key,
|
||||
"label": label,
|
||||
"url": append_query("/gallery/toggle-settings/", query),
|
||||
"is_active": option_key == sort_key,
|
||||
}
|
||||
)
|
||||
|
||||
return options
|
||||
|
||||
|
||||
def get_first_image_thumbnail_url(subdir):
|
||||
try:
|
||||
images = sorted(
|
||||
[
|
||||
entry
|
||||
for entry in subdir.iterdir()
|
||||
if entry.is_file() and not entry.is_symlink() and is_image_file(entry)
|
||||
],
|
||||
key=lambda item: (
|
||||
item.name.lower(),
|
||||
str(item.relative_to(settings.GALLERY_ROOT)).lower(),
|
||||
),
|
||||
)
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
if len(images) == 0:
|
||||
return None
|
||||
|
||||
first_image = images[0]
|
||||
|
||||
try:
|
||||
make_thumbnail(first_image)
|
||||
rel_path = first_image.relative_to(settings.GALLERY_ROOT)
|
||||
thumb_path = settings.THUMBNAILS_ROOT.joinpath(rel_path)
|
||||
|
||||
if thumb_path.exists():
|
||||
return "/thumbs/" + str(rel_path).replace("\\", "/")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return None
|
||||
202
viewer/directory.py
Normal file
202
viewer/directory.py
Normal file
@@ -0,0 +1,202 @@
|
||||
"""
|
||||
Directory-related rendering helpers for the gallery view.
|
||||
"""
|
||||
|
||||
# Standard library imports.
|
||||
from pathlib import Path
|
||||
|
||||
# Django imports.
|
||||
from django.http import HttpResponseNotFound
|
||||
from django.shortcuts import render
|
||||
from django.conf import settings
|
||||
|
||||
# Project imports.
|
||||
from .common import (
|
||||
normalize_sort,
|
||||
normalize_theme,
|
||||
build_query,
|
||||
gallery_url,
|
||||
append_query,
|
||||
sort_images,
|
||||
build_sort_options,
|
||||
build_breadcrumbs,
|
||||
get_first_image_thumbnail_url,
|
||||
is_image_file,
|
||||
make_thumbnail,
|
||||
)
|
||||
|
||||
|
||||
def do_recursive_search(start_path, query):
|
||||
"""
|
||||
Gets all images and sub-directories inside the start_path whose name matches the given query,
|
||||
and then joins the results recursively by iterating in each sub-directory.
|
||||
"""
|
||||
|
||||
try:
|
||||
entries = [entry for entry in start_path.iterdir() if not entry.is_symlink()]
|
||||
except OSError:
|
||||
return [], []
|
||||
|
||||
all_subdirs = sorted(
|
||||
[entry for entry in entries if entry.is_dir()],
|
||||
key=lambda item: (item.name.lower(), str(item).lower()),
|
||||
)
|
||||
|
||||
subdirs = [entry for entry in all_subdirs if query.lower() in entry.name.lower()]
|
||||
|
||||
images = sorted(
|
||||
[
|
||||
entry
|
||||
for entry in entries
|
||||
if entry.is_file()
|
||||
and is_image_file(entry)
|
||||
and query.lower() in entry.name.lower()
|
||||
],
|
||||
key=lambda item: (item.name.lower(), str(item).lower()),
|
||||
)
|
||||
|
||||
for subdir in all_subdirs:
|
||||
rec_subdirs, rec_images = do_recursive_search(subdir, query.lower())
|
||||
subdirs.extend(rec_subdirs)
|
||||
images.extend(rec_images)
|
||||
|
||||
return subdirs, images
|
||||
|
||||
|
||||
def render_directory(request, path_text, full_path):
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
|
||||
# Search remains a GET parameter. For sort and theme prefer explicit
|
||||
# GET parameters when present (so query-preserving links behave as
|
||||
# callers expect), otherwise fall back to middleware-provided settings.
|
||||
search_text = request.GET.get("search", "").strip()
|
||||
sort_key = normalize_sort(
|
||||
request.GET.get("sort") or getattr(request, "sort", None) or "abc"
|
||||
)
|
||||
theme = normalize_theme(
|
||||
request.GET.get("theme") or getattr(request, "theme", None) or "dark"
|
||||
)
|
||||
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)
|
||||
]
|
||||
else:
|
||||
_, images = do_recursive_search(full_path, search_text)
|
||||
|
||||
images = sort_images(images, sort_key)
|
||||
|
||||
image_data = []
|
||||
for image in images:
|
||||
rel_path = image.relative_to(settings.GALLERY_ROOT)
|
||||
image_url = gallery_url(rel_path, False, query_state)
|
||||
|
||||
thumbnail = None
|
||||
try:
|
||||
# use shared make_thumbnail so tests can patch viewer.common.make_thumbnail
|
||||
make_thumbnail(image)
|
||||
thumb_path = settings.THUMBNAILS_ROOT.joinpath(rel_path)
|
||||
if thumb_path.exists():
|
||||
thumbnail = "/thumbs/" + str(rel_path).replace("\\", "/")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
image_data.append(
|
||||
{"path": image_url, "name": image.name, "thumbnail": thumbnail}
|
||||
)
|
||||
|
||||
subdir_data = []
|
||||
for subdir in current_subdirs:
|
||||
rel_path = subdir.relative_to(settings.GALLERY_ROOT)
|
||||
subdir_data.append(
|
||||
{
|
||||
"path": gallery_url(rel_path, True, query_state),
|
||||
"name": subdir.name,
|
||||
"thumbnail": get_first_image_thumbnail_url(subdir),
|
||||
}
|
||||
)
|
||||
|
||||
next_theme = "light" if theme == "dark" else "dark"
|
||||
|
||||
# The theme toggle button now calls the persistent settings endpoint.
|
||||
# Include `next` (for redirect back) and also surface `sort` and `search`
|
||||
# as top-level query parameters so templates/tests can inspect them easily.
|
||||
theme_toggle_query = {
|
||||
"next": request.get_full_path(),
|
||||
"theme": next_theme,
|
||||
"sort": sort_key,
|
||||
}
|
||||
|
||||
if search_text != "":
|
||||
theme_toggle_query["search"] = search_text
|
||||
|
||||
search_action_query = {"sort": sort_key, "theme": theme}
|
||||
|
||||
# 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)
|
||||
|
||||
context = {
|
||||
"path": path_text,
|
||||
"search_text": search_text,
|
||||
"theme": theme,
|
||||
"sort_key": sort_key,
|
||||
"sort_label": "",
|
||||
"sort_options": build_sort_options(request, search_text, sort_key, theme),
|
||||
"breadcrumbs": build_breadcrumbs(path_text, query_state),
|
||||
"images": image_data,
|
||||
"subdirs": subdir_data,
|
||||
"back_url": back_url,
|
||||
"back_thumb": back_thumb,
|
||||
"home_url": home_url,
|
||||
"home_thumb": home_thumb,
|
||||
"theme_toggle_url": append_query(
|
||||
"/gallery/toggle-settings/", theme_toggle_query
|
||||
),
|
||||
"search_action_url": gallery_url(
|
||||
Path(path_text) if path_text != "" else None, True, search_action_query
|
||||
),
|
||||
"clear_search_url": gallery_url(
|
||||
Path(path_text) if path_text != "" else None, True, None
|
||||
),
|
||||
}
|
||||
|
||||
# sort_label depends on SORT_LABELS in common; import lazily to avoid circulars
|
||||
from .common import SORT_LABELS
|
||||
|
||||
context["sort_label"] = SORT_LABELS.get(sort_key, "")
|
||||
|
||||
return render(request, "gallery_view.html", context)
|
||||
225
viewer/image.py
Normal file
225
viewer/image.py
Normal file
@@ -0,0 +1,225 @@
|
||||
"""
|
||||
Image-related rendering helpers for the gallery image view.
|
||||
"""
|
||||
|
||||
# Standard library imports.
|
||||
from pathlib import Path
|
||||
import datetime
|
||||
|
||||
# Django imports.
|
||||
from django.http import HttpResponseNotFound
|
||||
from django.shortcuts import render, redirect
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
|
||||
# Third-party
|
||||
from PIL import Image
|
||||
|
||||
# Project imports.
|
||||
from .models import Image as Im
|
||||
|
||||
from .common import (
|
||||
normalize_sort,
|
||||
normalize_theme,
|
||||
build_query,
|
||||
gallery_url,
|
||||
append_query,
|
||||
sort_images,
|
||||
build_sort_options,
|
||||
build_breadcrumbs,
|
||||
get_first_image_thumbnail_url,
|
||||
is_image_file,
|
||||
make_thumbnail,
|
||||
)
|
||||
|
||||
|
||||
def render_image(request, path_text, full_path):
|
||||
"""
|
||||
Renders the view corresponding to an image file.
|
||||
"""
|
||||
|
||||
try:
|
||||
img = Im.objects.get(path=full_path, user=request.user)
|
||||
|
||||
if request.method == "POST":
|
||||
img.favorite = not img.favorite
|
||||
|
||||
except Im.DoesNotExist:
|
||||
img = Im(path=full_path, user=request.user)
|
||||
|
||||
finally:
|
||||
img.last_visited = timezone.now()
|
||||
img.visits = img.visits + 1
|
||||
img.save()
|
||||
|
||||
# Search remains a GET parameter. For sort and theme prefer explicit
|
||||
# GET parameters when present, otherwise fall back to middleware-provided
|
||||
# settings on the request.
|
||||
search_text = request.GET.get("search", "").strip()
|
||||
sort_key = normalize_sort(
|
||||
request.GET.get("sort") or getattr(request, "sort", None) or "abc"
|
||||
)
|
||||
theme = normalize_theme(
|
||||
request.GET.get("theme") or getattr(request, "theme", None) or "dark"
|
||||
)
|
||||
query_state = build_query(search_text)
|
||||
|
||||
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")
|
||||
|
||||
# Sort siblings according to requested sort mode
|
||||
images_sorted = sort_images(entries, sort_key)
|
||||
|
||||
# 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")
|
||||
|
||||
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 = {
|
||||
"next": request.get_full_path(),
|
||||
"sort": sort_key,
|
||||
"theme": next_theme,
|
||||
}
|
||||
if search_text != "":
|
||||
theme_query["next"] = request.get_full_path()
|
||||
|
||||
context = {
|
||||
"image_path": image,
|
||||
# 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),
|
||||
"visits": img.visits,
|
||||
"visited": fmt_ts(img.last_visited.timestamp()),
|
||||
"favorite": img.favorite,
|
||||
},
|
||||
"breadcrumbs": breadcrumbs,
|
||||
"theme": theme,
|
||||
"sort_key": sort_key,
|
||||
"sort_label": "",
|
||||
"sort_options": build_sort_options(request, search_text, sort_key, theme),
|
||||
"theme_toggle_url": append_query("/gallery/toggle-settings/", theme_query),
|
||||
"path": path_text,
|
||||
}
|
||||
|
||||
from .common import SORT_LABELS
|
||||
|
||||
context["sort_label"] = SORT_LABELS.get(sort_key, "")
|
||||
|
||||
return render(request, "image_view.html", context)
|
||||
52
viewer/migrations/0001_initial.py
Normal file
52
viewer/migrations/0001_initial.py
Normal file
@@ -0,0 +1,52 @@
|
||||
# Generated by Django 6.0.3 on 2026-03-24 18:56
|
||||
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
import pathlib
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="Image",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"path",
|
||||
models.FilePathField(
|
||||
path=pathlib.PurePosixPath("/home/miky/Imágenes")
|
||||
),
|
||||
),
|
||||
("favorite", models.BooleanField(default=False)),
|
||||
(
|
||||
"last_visited",
|
||||
models.DateTimeField(default=django.utils.timezone.now),
|
||||
),
|
||||
("visits", models.IntegerField(default=0)),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
||||
19
viewer/migrations/0002_alter_image_path.py
Normal file
19
viewer/migrations/0002_alter_image_path.py
Normal file
@@ -0,0 +1,19 @@
|
||||
# Generated by Django 6.0.3 on 2026-03-24 19:38
|
||||
|
||||
import viewer.models
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("viewer", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="image",
|
||||
name="path",
|
||||
field=models.FilePathField(path=viewer.models.get_gallery_root),
|
||||
),
|
||||
]
|
||||
39
viewer/migrations/0003_usersettings.py
Normal file
39
viewer/migrations/0003_usersettings.py
Normal file
@@ -0,0 +1,39 @@
|
||||
# Generated by Django 6.0.3 on 2026-03-25 08:19
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("viewer", "0002_alter_image_path"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="UserSettings",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("theme", models.CharField(default="dark", max_length=5)),
|
||||
("sort", models.CharField(default="abc", max_length=6)),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
||||
45
viewer/models.py
Normal file
45
viewer/models.py
Normal file
@@ -0,0 +1,45 @@
|
||||
from django.utils import timezone
|
||||
from django.conf import settings
|
||||
from django.db.models import (
|
||||
Model,
|
||||
CharField,
|
||||
BooleanField,
|
||||
DateTimeField,
|
||||
IntegerField,
|
||||
FilePathField,
|
||||
ForeignKey,
|
||||
CASCADE
|
||||
)
|
||||
|
||||
|
||||
def get_gallery_root():
|
||||
return settings.GALLERY_ROOT
|
||||
|
||||
|
||||
class UserSettings(Model):
|
||||
"""
|
||||
User relations to a specific image file by path.
|
||||
"""
|
||||
|
||||
user = ForeignKey(settings.AUTH_USER_MODEL, blank=False, null=False, on_delete=CASCADE)
|
||||
theme = CharField(max_length=5, blank=False, null=False, default='dark')
|
||||
sort = CharField(max_length=6, blank=False, null=False, default='abc')
|
||||
|
||||
class meta:
|
||||
ordering = ["pk"]
|
||||
|
||||
|
||||
class Image(Model):
|
||||
"""
|
||||
User relations to a specific image file by path.
|
||||
"""
|
||||
|
||||
user = ForeignKey(settings.AUTH_USER_MODEL, blank=False, null=False, on_delete=CASCADE)
|
||||
path = FilePathField(path=get_gallery_root, blank=False, null=False)
|
||||
favorite = BooleanField(blank=False, null=False, default=False)
|
||||
last_visited = DateTimeField(blank=False, null=False, default=timezone.now)
|
||||
visits = IntegerField(blank=False, null=False, default=0)
|
||||
|
||||
class meta:
|
||||
ordering = ["pk"]
|
||||
get_latest_by = "-last_visited"
|
||||
@@ -142,7 +142,7 @@ body {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.crumb-link {
|
||||
.crumb-link, .crumb-link-last {
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
font-size: 15px;
|
||||
@@ -176,17 +176,19 @@ body {
|
||||
|
||||
.gallery-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(128px, 128px));
|
||||
justify-content: space-between;
|
||||
column-gap: 24px;
|
||||
row-gap: 24px;
|
||||
/* Create as many fixed 128px columns as will fit and center them.
|
||||
`auto-fit` ensures the grid creates as many columns as possible
|
||||
while keeping each column exactly 128px. Use `justify-content:
|
||||
center` so the columns are centered on wider containers. */
|
||||
grid-template-columns: repeat(auto-fit, 128px);
|
||||
justify-content: center;
|
||||
gap: 24px; /* shorthand for row/column gaps */
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
/* Image view specific styles */
|
||||
.image-content {
|
||||
overflow: auto;
|
||||
padding: 18px;
|
||||
flex: 1 1 auto; /* occupy available vertical space inside main-area */
|
||||
display: flex;
|
||||
align-items: center; /* center the image vertically */
|
||||
@@ -237,7 +239,7 @@ body {
|
||||
}
|
||||
|
||||
.info-menu {
|
||||
min-width: 220px;
|
||||
min-width: 275px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
@@ -378,30 +380,39 @@ body.theme-dark .small.text-muted {
|
||||
|
||||
@media (max-width: 991.98px) {
|
||||
.app-shell {
|
||||
padding: 12px;
|
||||
padding: 0px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.offcanvas {
|
||||
width: 100vw !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 575.98px) {
|
||||
.gallery-grid {
|
||||
grid-template-columns: 1fr;
|
||||
justify-content: stretch;
|
||||
}
|
||||
|
||||
.thumb-card {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
.top-bar {
|
||||
border-radius: 0px;
|
||||
}
|
||||
|
||||
.crumb-link {
|
||||
max-width: 130px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.crumb-sep {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.gallery-scroll {
|
||||
padding-left: 16px;
|
||||
padding-right: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 575.98px) {
|
||||
/* On narrow viewports keep the fixed 128px thumbnails but allow the
|
||||
grid to place as many side-by-side as fit. Reduce breadcrumb max
|
||||
width to preserve layout. */
|
||||
.crumb-link {
|
||||
max-width: 130px;
|
||||
}
|
||||
|
||||
.crumb-link-full {
|
||||
overflow: hidden;
|
||||
|
||||
@@ -33,6 +33,11 @@
|
||||
<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>
|
||||
{% if search_text %}
|
||||
<a class="btn btn-sm btn-plain ms-auto" href="{{ clear_search_url }}" aria-label="Clear search">
|
||||
<i class="fa-solid fa-xmark"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -42,6 +47,28 @@
|
||||
<a href="#" class="sidebar-link">Most visited</a>
|
||||
<a href="#" class="sidebar-link">Recently visited</a>
|
||||
|
||||
{% if path != '' %}
|
||||
<hr>
|
||||
|
||||
<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>
|
||||
{% endif %}
|
||||
|
||||
<hr>
|
||||
|
||||
<div class="sidebar-scroll flex-grow-1">
|
||||
@@ -98,6 +125,28 @@
|
||||
<a href="#" class="sidebar-link">Most visited</a>
|
||||
<a href="#" class="sidebar-link">Recently visited</a>
|
||||
|
||||
{% if path != '' %}
|
||||
<hr>
|
||||
|
||||
<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>
|
||||
{% endif %}
|
||||
|
||||
<hr>
|
||||
|
||||
<div class="sidebar-scroll" style="max-height: 200px;">
|
||||
@@ -137,7 +186,7 @@
|
||||
{% if not forloop.first %}
|
||||
<span class="crumb-sep">/</span>
|
||||
{% endif %}
|
||||
<a href="{{ crumb.path }}" class="crumb-link">{{ crumb.label|truncatechars:45 }}</a>
|
||||
<a href="{{ crumb.path }}" class="{% if not forloop.last %}crumb-link{% else %}crumb-link-last{% endif %}">{{ crumb.label|truncatechars:45 }}</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
@@ -183,8 +232,9 @@
|
||||
|
||||
<div class="offcanvas offcanvas-start" tabindex="-1" id="sidebarOffcanvas" aria-labelledby="sidebarOffcanvasLabel">
|
||||
<div class="offcanvas-header pb-2">
|
||||
<h2 class="h6 mb-0" id="sidebarOffcanvasLabel">Gallery</h2>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<div class="d-flex align-items-center user-row w-100 gap-2">
|
||||
<span><i class="fa-solid fa-circle-user fa-lg"></i></span>
|
||||
<span class="user-name w-100 text-truncate">{{ request.user.username }}</span>
|
||||
<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>
|
||||
@@ -198,54 +248,67 @@
|
||||
</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>
|
||||
<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>
|
||||
|
||||
<hr>
|
||||
<hr>
|
||||
|
||||
<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>
|
||||
<a href="#" class="sidebar-link">Favorites</a>
|
||||
<a href="#" class="sidebar-link">Most visited</a>
|
||||
<a href="#" class="sidebar-link">Recently visited</a>
|
||||
|
||||
<hr>
|
||||
{% if path != '' %}
|
||||
<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="{{ 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>
|
||||
|
||||
<hr>
|
||||
<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>
|
||||
{% endif %}
|
||||
|
||||
<div class="sidebar-scroll flex-grow-1">
|
||||
{% for subdir in subdirs %}
|
||||
<a href="{{ subdir.path }}" class="subdir-item">
|
||||
{% if subdir.thumbnail %}
|
||||
<img src="{{ subdir.thumbnail }}" alt="{{ subdir.name }}" class="subdir-thumb">
|
||||
{% else %}
|
||||
<span class="subdir-fallback"><i class="fa-solid fa-image"></i></span>
|
||||
{% endif %}
|
||||
<span>{{ subdir.name|truncatechars:30 }}</span>
|
||||
</a>
|
||||
{% empty %}
|
||||
<small class="text-secondary">No subdirectories</small>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<hr>
|
||||
|
||||
<hr>
|
||||
<div class="sidebar-scroll flex-grow-1">
|
||||
{% for subdir in subdirs %}
|
||||
<a href="{{ subdir.path }}" class="subdir-item">
|
||||
{% if subdir.thumbnail %}
|
||||
<img src="{{ subdir.thumbnail }}" alt="{{ subdir.name }}" class="subdir-thumb">
|
||||
{% else %}
|
||||
<span class="subdir-fallback"><i class="fa-solid fa-image"></i></span>
|
||||
{% endif %}
|
||||
<span>{{ subdir.name|truncatechars:30 }}</span>
|
||||
</a>
|
||||
{% empty %}
|
||||
<small class="text-secondary">No subdirectories</small>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -117,13 +117,24 @@
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<form method="post" action="{% url 'gallery_view_path' path=path %}">
|
||||
{% csrf_token %}
|
||||
<button class="btn btn-sm btn-plain" type="submit" title="Favorite" aria-label="Fav image" name="favorite">
|
||||
{% if image_meta.favorite %}
|
||||
<i class="fa-solid fa-star"></i>
|
||||
{% else %}
|
||||
<i class="fa-regular fa-star"></i>
|
||||
{% endif %}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="dropdown ms-auto">
|
||||
<button class="btn btn-sm btn-plain" type="button" data-bs-toggle="dropdown" aria-expanded="false" title="Info" aria-label="Image info">
|
||||
<i class="fa-solid fa-circle-info"></i>
|
||||
</button>
|
||||
<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>
|
||||
<div style="max-height:300px; overflow:auto;">
|
||||
<div class="small text-muted">{{ image_meta.filename|truncatechars:40 }}</div>
|
||||
{% if image_meta.width and image_meta.height %}
|
||||
<div>{{ image_meta.width }} x {{ image_meta.height }} px</div>
|
||||
{% endif %}
|
||||
@@ -138,6 +149,14 @@
|
||||
<div class="small text-muted">Modification date</div>
|
||||
<div>{{ image_meta.modified }}</div>
|
||||
{% endif %}
|
||||
{% if image_meta.visits %}
|
||||
<div class="small text-muted">Visits:</div>
|
||||
<div>{{ image_meta.visits }}</div>
|
||||
{% endif %}
|
||||
{% if image_meta.visited %}
|
||||
<div class="small text-muted">Last visit:</div>
|
||||
<div>{{ image_meta.visited }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -145,11 +164,11 @@
|
||||
|
||||
<section class="gallery-scroll flex-grow-1 d-flex">
|
||||
<div class="image-content w-100">
|
||||
<a href="{{ image_path }}" target="_blank">
|
||||
<div class="image-wrapper">
|
||||
<div class="image-wrapper">
|
||||
<a href="{{ image_path }}" target="_blank">
|
||||
<img src="{{ image_path }}" alt="{{ image_path.name }}" class="image-full">
|
||||
</div>
|
||||
</a>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
@@ -157,8 +176,9 @@
|
||||
|
||||
<div class="offcanvas offcanvas-start" tabindex="-1" id="sidebarOffcanvas" aria-labelledby="sidebarOffcanvasLabel">
|
||||
<div class="offcanvas-header pb-2">
|
||||
<h2 class="h6 mb-0" id="sidebarOffcanvasLabel">Gallery</h2>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<div class="d-flex align-items-center user-row w-100 gap-2">
|
||||
<span><i class="fa-solid fa-circle-user fa-lg"></i></span>
|
||||
<span class="user-name w-100 text-truncate">{{ request.user.username }}</span>
|
||||
<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>
|
||||
@@ -172,72 +192,63 @@
|
||||
</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>
|
||||
<a href="#" class="sidebar-link">Favorites</a>
|
||||
<a href="#" class="sidebar-link">Most visited</a>
|
||||
<a href="#" class="sidebar-link">Recently visited</a>
|
||||
|
||||
<hr>
|
||||
<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>
|
||||
{% endif %}
|
||||
|
||||
{% if next_url %}
|
||||
<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">
|
||||
<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>Back</span>
|
||||
<span>Previous</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
<a href="{{ home_url }}" class="subdir-item">
|
||||
{% if home_thumb %}
|
||||
<img src="{{ home_thumb }}" class="subdir-thumb" alt="home thumb">
|
||||
{% if next_url %}
|
||||
<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>Home</span>
|
||||
<span>Next</span>
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<hr>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
179
viewer/test.py
179
viewer/test.py
@@ -10,9 +10,16 @@ from PIL import Image
|
||||
# Django imports.
|
||||
from django.test import Client, RequestFactory, TestCase, override_settings
|
||||
from django.contrib.auth.models import User
|
||||
from django.utils import timezone
|
||||
from django.http import HttpResponse
|
||||
from django.template.response import TemplateResponse
|
||||
|
||||
# Project imports.
|
||||
from viewer.views import SORT_LABELS, do_recursive_search, gallery_view
|
||||
from viewer.common import SORT_LABELS
|
||||
from viewer.directory import do_recursive_search
|
||||
from viewer.views import gallery_view
|
||||
from viewer.models import Image as Im, UserSettings
|
||||
from NibasaViewer.middleware import UserSettingsMiddleware
|
||||
|
||||
|
||||
class GalleryBaseTests(TestCase):
|
||||
@@ -34,6 +41,10 @@ class GalleryBaseTests(TestCase):
|
||||
self.client = Client()
|
||||
self.client.force_login(self.user)
|
||||
self.factory = RequestFactory()
|
||||
# Ensure UserSettings exists for the test user so middleware-backed
|
||||
# attributes (`theme`, `sort`) are available during view rendering
|
||||
# even when tests call views directly via RequestFactory.
|
||||
UserSettings.objects.get_or_create(user=self.user)
|
||||
|
||||
self._build_fixture_tree()
|
||||
|
||||
@@ -93,6 +104,12 @@ class GalleryViewTests(GalleryBaseTests):
|
||||
request = self.factory.get("/gallery/../")
|
||||
request.user = self.user
|
||||
|
||||
# When calling the view directly we still need to allow any
|
||||
# TemplateResponse processing to see the settings. Ensure the
|
||||
# request has the attributes the middleware would provide.
|
||||
request.theme = UserSettings._meta.get_field("theme").get_default()
|
||||
request.sort = UserSettings._meta.get_field("sort").get_default()
|
||||
|
||||
response = gallery_view(request, "../")
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
@@ -176,26 +193,29 @@ class GalleryViewTests(GalleryBaseTests):
|
||||
breadcrumbs = response.context["breadcrumbs"]
|
||||
self.assertTrue(breadcrumbs[0]["path"].find("/gallery/?") == 0)
|
||||
self.assertIn("search=match", breadcrumbs[0]["path"])
|
||||
self.assertIn("sort=recent", breadcrumbs[0]["path"])
|
||||
self.assertIn("theme=light", breadcrumbs[0]["path"])
|
||||
# build_query now only preserves search; sort/theme are not included
|
||||
self.assertNotIn("sort=", breadcrumbs[0]["path"])
|
||||
self.assertNotIn("theme=", breadcrumbs[0]["path"])
|
||||
|
||||
def test_subdir_links_preserve_query(self):
|
||||
response = self.client.get("/gallery/?search=match&sort=recent&theme=light")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
subdir_paths = [subdir["path"] for subdir in response.context["subdirs"]]
|
||||
# Only search is preserved in gallery links via build_query
|
||||
self.assertTrue(any(path.find("search=match") != -1 for path in subdir_paths))
|
||||
self.assertTrue(any(path.find("sort=recent") != -1 for path in subdir_paths))
|
||||
self.assertTrue(any(path.find("theme=light") != -1 for path in subdir_paths))
|
||||
self.assertFalse(any(path.find("sort=") != -1 for path in subdir_paths))
|
||||
self.assertFalse(any(path.find("theme=") != -1 for path in subdir_paths))
|
||||
|
||||
def test_image_links_preserve_query(self):
|
||||
response = self.client.get("/gallery/?search=match&sort=recent&theme=light")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
image_paths = [image["path"] for image in response.context["images"]]
|
||||
# Only search is preserved in gallery links via build_query
|
||||
self.assertTrue(any(path.find("search=match") != -1 for path in image_paths))
|
||||
self.assertTrue(any(path.find("sort=recent") != -1 for path in image_paths))
|
||||
self.assertTrue(any(path.find("theme=light") != -1 for path in image_paths))
|
||||
self.assertFalse(any(path.find("sort=") != -1 for path in image_paths))
|
||||
self.assertFalse(any(path.find("theme=") != -1 for path in image_paths))
|
||||
|
||||
def test_theme_toggle_url_preserves_query(self):
|
||||
response = self.client.get("/gallery/?search=match&sort=recent&theme=dark")
|
||||
@@ -215,6 +235,26 @@ class GalleryViewTests(GalleryBaseTests):
|
||||
self.assertIn("sort=recent", url)
|
||||
self.assertIn("theme=light", url)
|
||||
|
||||
def test_clear_search_url_present_and_clears_query(self):
|
||||
# When a search is active the context should provide a URL that
|
||||
# clears the search (no query parameters) for the current path.
|
||||
response = self.client.get("/gallery/?search=match&sort=recent&theme=light")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
clear_url = response.context.get("clear_search_url")
|
||||
# For the root path the clear URL should be the gallery root without query
|
||||
self.assertEqual(clear_url, "/gallery/")
|
||||
|
||||
def test_clear_search_url_uses_nested_path(self):
|
||||
# Ensure clear_search_url respects nested paths (clears search but preserves path)
|
||||
response = self.client.get(
|
||||
"/gallery/sub_a/?search=match&sort=recent&theme=light"
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
clear_url = response.context.get("clear_search_url")
|
||||
self.assertTrue(clear_url.find("/gallery/sub_a/") == 0)
|
||||
|
||||
def test_search_action_url_uses_nested_path(self):
|
||||
response = self.client.get("/gallery/sub_a/?sort=recent&theme=light")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
@@ -280,7 +320,7 @@ class GalleryViewTests(GalleryBaseTests):
|
||||
self.assertNotIn("symlink_sub", names)
|
||||
|
||||
def test_thumbnail_failure_does_not_break_render(self):
|
||||
with patch("viewer.views.make_thumbnail", side_effect=Exception("fail")):
|
||||
with patch("viewer.common.make_thumbnail", side_effect=Exception("fail")):
|
||||
response = self.client.get("/gallery/")
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
@@ -337,8 +377,9 @@ class GalleryViewTests(GalleryBaseTests):
|
||||
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)
|
||||
# build_query no longer injects sort/theme into gallery URLs
|
||||
self.assertNotIn("sort=", back_url)
|
||||
self.assertNotIn("theme=", back_url)
|
||||
|
||||
# Back and Home thumbnails should be available and point to /thumbs/
|
||||
back_thumb = response.context.get("back_thumb")
|
||||
@@ -361,6 +402,14 @@ class GalleryViewTests(GalleryBaseTests):
|
||||
)
|
||||
self.assertIsNotNone(image_meta.get("created"))
|
||||
self.assertIsNotNone(image_meta.get("modified"))
|
||||
# New image metadata fields: visits (int), visited (formatted ts), favorite (bool)
|
||||
self.assertIn("visits", image_meta)
|
||||
self.assertIsInstance(image_meta.get("visits"), int)
|
||||
self.assertGreaterEqual(image_meta.get("visits"), 1)
|
||||
self.assertIn("visited", image_meta)
|
||||
self.assertIsNotNone(image_meta.get("visited"))
|
||||
self.assertIn("favorite", image_meta)
|
||||
self.assertIsInstance(image_meta.get("favorite"), bool)
|
||||
|
||||
def test_sort_modes_recent_and_tnecer_use_mtime(self):
|
||||
recent = self.client.get("/gallery/?sort=recent")
|
||||
@@ -383,7 +432,7 @@ class GalleryViewTests(GalleryBaseTests):
|
||||
def fake_creation(path_obj):
|
||||
return creation_values.get(path_obj.name, 0)
|
||||
|
||||
with patch("viewer.views.get_creation_timestamp", side_effect=fake_creation):
|
||||
with patch("viewer.common.get_creation_timestamp", side_effect=fake_creation):
|
||||
old = self.client.get("/gallery/?sort=old")
|
||||
new = self.client.get("/gallery/?sort=new")
|
||||
|
||||
@@ -416,6 +465,17 @@ class GalleryTemplateTests(GalleryBaseTests):
|
||||
self.assertIn("Most visited", body)
|
||||
self.assertIn("Recently visited", body)
|
||||
|
||||
def test_clear_search_button_shown_when_searching(self):
|
||||
# The template should render a clear-search button when a search is active
|
||||
resp = self.client.get("/gallery/?search=match")
|
||||
body = resp.content.decode("utf-8")
|
||||
self.assertIn('aria-label="Clear search"', body)
|
||||
|
||||
# And it should not be present when there's no search
|
||||
resp2 = self.client.get("/gallery/")
|
||||
body2 = resp2.content.decode("utf-8")
|
||||
self.assertNotIn('aria-label="Clear search"', body2)
|
||||
|
||||
def test_template_shows_fallback_icon_for_empty_subdir(self):
|
||||
response = self.client.get("/gallery/")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
@@ -449,3 +509,100 @@ class GalleryTemplateTests(GalleryBaseTests):
|
||||
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)
|
||||
|
||||
|
||||
class ImageModelTests(GalleryBaseTests):
|
||||
def test_image_model_defaults_and_path_storage(self):
|
||||
# Create a model entry and verify defaults
|
||||
path_str = str(self.gallery_root / "alpha.jpg")
|
||||
img = Im.objects.create(user=self.user, path=path_str)
|
||||
|
||||
self.assertEqual(img.visits, 0)
|
||||
self.assertFalse(img.favorite)
|
||||
self.assertIsNotNone(img.last_visited)
|
||||
# FilePathField should store the provided path string
|
||||
self.assertEqual(img.path, path_str)
|
||||
|
||||
def test_get_request_increments_visits_and_updates_last_visited(self):
|
||||
path_url = "/gallery/alpha.jpg/"
|
||||
before = timezone.now()
|
||||
response = self.client.get(path_url, follow=True)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
img = Im.objects.get(user=self.user, path=str(self.gallery_root / "alpha.jpg"))
|
||||
self.assertGreaterEqual(img.visits, 1)
|
||||
self.assertIsNotNone(img.last_visited)
|
||||
# last_visited should be updated to a recent timestamp
|
||||
self.assertGreaterEqual(img.last_visited, before)
|
||||
|
||||
def test_post_toggles_favorite_flag(self):
|
||||
path_url = "/gallery/alpha.jpg/"
|
||||
# Ensure initial state
|
||||
img, _ = Im.objects.get_or_create(
|
||||
user=self.user, path=str(self.gallery_root / "alpha.jpg")
|
||||
)
|
||||
img.favorite = False
|
||||
img.save()
|
||||
|
||||
response = self.client.post(path_url, follow=True)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
img.refresh_from_db()
|
||||
self.assertTrue(img.favorite)
|
||||
|
||||
|
||||
class UserSettingsModelTests(TestCase):
|
||||
def test_usersettings_defaults(self):
|
||||
user = User.objects.create_user("usertest", "u@example.com", "pw")
|
||||
us = UserSettings.objects.create(user=user)
|
||||
|
||||
self.assertEqual(us.theme, "dark")
|
||||
self.assertEqual(us.sort, "abc")
|
||||
|
||||
|
||||
class UserSettingsMiddlewareTests(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user("mwuser", "mw@example.com", "pw")
|
||||
self.factory = RequestFactory()
|
||||
|
||||
def test_middleware_creates_settings_and_sets_request(self):
|
||||
request = self.factory.get("/")
|
||||
request.user = self.user
|
||||
|
||||
def get_response(req):
|
||||
return HttpResponse("ok")
|
||||
|
||||
mw = UserSettingsMiddleware(get_response)
|
||||
response = mw(request)
|
||||
|
||||
# UserSettings should have been created and request attrs populated
|
||||
self.assertTrue(UserSettings.objects.filter(user=self.user).exists())
|
||||
self.assertEqual(
|
||||
request.theme, UserSettings._meta.get_field("theme").get_default()
|
||||
)
|
||||
self.assertEqual(
|
||||
request.sort, UserSettings._meta.get_field("sort").get_default()
|
||||
)
|
||||
|
||||
def test_process_template_response_injects_and_preserves(self):
|
||||
# Create a UserSettings with non-defaults
|
||||
UserSettings.objects.create(user=self.user, theme="light", sort="cba")
|
||||
|
||||
request = self.factory.get("/")
|
||||
request.user = self.user
|
||||
|
||||
def get_response(req):
|
||||
# Provide a TemplateResponse that already sets `theme` to ensure
|
||||
# the middleware does not overwrite existing keys.
|
||||
return TemplateResponse(
|
||||
req, "viewer/gallery_view.html", {"theme": "override-theme"}
|
||||
)
|
||||
|
||||
mw = UserSettingsMiddleware(get_response)
|
||||
response = mw(request)
|
||||
|
||||
# process_template_response should set missing keys but preserve existing ones
|
||||
resp = mw.process_template_response(request, response)
|
||||
self.assertIsInstance(resp, TemplateResponse)
|
||||
self.assertEqual(resp.context_data.get("theme"), "override-theme")
|
||||
self.assertEqual(resp.context_data.get("sort"), "cba")
|
||||
|
||||
@@ -2,9 +2,7 @@
|
||||
from django.urls import path
|
||||
|
||||
# Module imports
|
||||
from .views import (
|
||||
gallery_view
|
||||
)
|
||||
from .views import gallery_view, toggle_settings
|
||||
|
||||
###########################################################################################
|
||||
# URL Patterns. #
|
||||
@@ -13,6 +11,7 @@ from .views import (
|
||||
|
||||
urlpatterns = [
|
||||
# Views.
|
||||
path('', gallery_view, name = 'gallery_view_root'),
|
||||
path('<path:path>/', gallery_view, name = 'gallery_view_path'),
|
||||
path("", gallery_view, name="gallery_view_root"),
|
||||
path("toggle-settings/", toggle_settings, name="toggle_settings"),
|
||||
path("<path:path>/", gallery_view, name="gallery_view_path"),
|
||||
]
|
||||
|
||||
581
viewer/views.py
581
viewer/views.py
@@ -1,275 +1,23 @@
|
||||
# Standard library imports.
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlencode
|
||||
from functools import cmp_to_key
|
||||
"""Top-level views module.
|
||||
|
||||
After refactor this file only keeps the minimal public view entry points and imports
|
||||
helpers from the new `directory` and `image` modules. Also provides the
|
||||
`toggle_settings` view used by template buttons to persist theme/sort.
|
||||
"""
|
||||
|
||||
# Django imports.
|
||||
from urllib.parse import urlparse
|
||||
from django.http import HttpResponseNotFound
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.shortcuts import render, redirect
|
||||
from django.shortcuts import redirect
|
||||
from django.core.exceptions import MultipleObjectsReturned
|
||||
|
||||
# Project imports.
|
||||
from .utils import make_thumbnail, is_image_file
|
||||
from PIL import Image
|
||||
import datetime
|
||||
# Local helpers split into modules
|
||||
from .directory import render_directory
|
||||
from .image import render_image
|
||||
|
||||
###########################################################################################
|
||||
# Constants. #
|
||||
###########################################################################################
|
||||
|
||||
SORT_OPTIONS = [
|
||||
("abc", "Alphabetical A-Z"),
|
||||
("cba", "Alphabetical Z-A"),
|
||||
("old", "Creation date old to new"),
|
||||
("new", "Creation date new to old"),
|
||||
("recent", "Modification date most recent"),
|
||||
("tnecer", "Modification date least recent"),
|
||||
]
|
||||
|
||||
SORT_LABELS = dict(SORT_OPTIONS)
|
||||
|
||||
###########################################################################################
|
||||
# Helper functions. #
|
||||
###########################################################################################
|
||||
|
||||
|
||||
def normalize_sort(sort_value):
|
||||
return sort_value if sort_value in SORT_LABELS else "abc"
|
||||
|
||||
|
||||
def normalize_theme(theme_value):
|
||||
return theme_value if theme_value in ("dark", "light") else "dark"
|
||||
|
||||
|
||||
def get_creation_timestamp(path_obj):
|
||||
try:
|
||||
stat_data = path_obj.stat()
|
||||
return getattr(stat_data, "st_birthtime", stat_data.st_ctime)
|
||||
except OSError:
|
||||
return 0
|
||||
|
||||
|
||||
def get_modification_timestamp(path_obj):
|
||||
try:
|
||||
return path_obj.stat().st_mtime
|
||||
except OSError:
|
||||
return 0
|
||||
|
||||
|
||||
def build_query(search_text, sort_key, theme):
|
||||
query = {"sort": sort_key, "theme": theme}
|
||||
|
||||
if search_text != "":
|
||||
query["search"] = search_text
|
||||
|
||||
return query
|
||||
|
||||
|
||||
def append_query(url, query_dict):
|
||||
if len(query_dict) == 0:
|
||||
return url
|
||||
|
||||
return url + "?" + urlencode(query_dict)
|
||||
|
||||
|
||||
def gallery_url(path_obj=None, is_dir=False, query_dict=None):
|
||||
if query_dict is None:
|
||||
query_dict = {}
|
||||
|
||||
if path_obj is None:
|
||||
base_url = "/gallery/"
|
||||
else:
|
||||
path_text = str(path_obj).replace("\\", "/")
|
||||
base_url = "/gallery/" + path_text
|
||||
|
||||
if is_dir and not base_url.endswith("/"):
|
||||
base_url += "/"
|
||||
|
||||
return append_query(base_url, query_dict)
|
||||
|
||||
|
||||
def do_recursive_search(start_path, query):
|
||||
"""
|
||||
Gets all images and sub-directories inside the start_path whose name matches the given query,
|
||||
and then joins the results recursively by iterating in each sub-directory.
|
||||
"""
|
||||
|
||||
try:
|
||||
entries = [entry for entry in start_path.iterdir() if not entry.is_symlink()]
|
||||
except OSError:
|
||||
return [], []
|
||||
|
||||
all_subdirs = sorted(
|
||||
[entry for entry in entries if entry.is_dir()],
|
||||
key=lambda item: (item.name.lower(), str(item).lower()),
|
||||
)
|
||||
|
||||
subdirs = [entry for entry in all_subdirs if query.lower() in entry.name.lower()]
|
||||
|
||||
images = sorted(
|
||||
[
|
||||
entry
|
||||
for entry in entries
|
||||
if entry.is_file()
|
||||
and is_image_file(entry)
|
||||
and query.lower() in entry.name.lower()
|
||||
],
|
||||
key=lambda item: (item.name.lower(), str(item).lower()),
|
||||
)
|
||||
|
||||
for subdir in all_subdirs:
|
||||
rec_subdirs, rec_images = do_recursive_search(subdir, query.lower())
|
||||
subdirs.extend(rec_subdirs)
|
||||
images.extend(rec_images)
|
||||
|
||||
return subdirs, images
|
||||
|
||||
|
||||
def sort_images(images, sort_key):
|
||||
def compare(img_a, img_b):
|
||||
name_a = img_a.name.lower()
|
||||
name_b = img_b.name.lower()
|
||||
rel_a = str(img_a.relative_to(settings.GALLERY_ROOT)).lower()
|
||||
rel_b = str(img_b.relative_to(settings.GALLERY_ROOT)).lower()
|
||||
|
||||
if sort_key == "abc":
|
||||
if name_a < name_b:
|
||||
return -1
|
||||
if name_a > name_b:
|
||||
return 1
|
||||
|
||||
elif sort_key == "cba":
|
||||
if name_a > name_b:
|
||||
return -1
|
||||
if name_a < name_b:
|
||||
return 1
|
||||
|
||||
elif sort_key == "old":
|
||||
created_a = get_creation_timestamp(img_a)
|
||||
created_b = get_creation_timestamp(img_b)
|
||||
|
||||
if created_a < created_b:
|
||||
return -1
|
||||
if created_a > created_b:
|
||||
return 1
|
||||
|
||||
elif sort_key == "new":
|
||||
created_a = get_creation_timestamp(img_a)
|
||||
created_b = get_creation_timestamp(img_b)
|
||||
|
||||
if created_a > created_b:
|
||||
return -1
|
||||
if created_a < created_b:
|
||||
return 1
|
||||
|
||||
elif sort_key == "recent":
|
||||
modified_a = get_modification_timestamp(img_a)
|
||||
modified_b = get_modification_timestamp(img_b)
|
||||
|
||||
if modified_a > modified_b:
|
||||
return -1
|
||||
if modified_a < modified_b:
|
||||
return 1
|
||||
|
||||
elif sort_key == "tnecer":
|
||||
modified_a = get_modification_timestamp(img_a)
|
||||
modified_b = get_modification_timestamp(img_b)
|
||||
|
||||
if modified_a < modified_b:
|
||||
return -1
|
||||
if modified_a > modified_b:
|
||||
return 1
|
||||
|
||||
if name_a < name_b:
|
||||
return -1
|
||||
if name_a > name_b:
|
||||
return 1
|
||||
if rel_a < rel_b:
|
||||
return -1
|
||||
if rel_a > rel_b:
|
||||
return 1
|
||||
return 0
|
||||
|
||||
return sorted(images, key=cmp_to_key(compare))
|
||||
|
||||
|
||||
def build_breadcrumbs(path_text, query_dict):
|
||||
breadcrumbs = [{"label": "Gallery", "path": gallery_url(None, True, query_dict)}]
|
||||
|
||||
if path_text == "":
|
||||
return breadcrumbs
|
||||
|
||||
segments = Path(path_text).parts
|
||||
current = Path("")
|
||||
|
||||
for segment in segments:
|
||||
current = current.joinpath(segment)
|
||||
breadcrumbs.append(
|
||||
{"label": segment, "path": gallery_url(current, True, query_dict)}
|
||||
)
|
||||
|
||||
return breadcrumbs
|
||||
|
||||
|
||||
def build_sort_options(request, search_text, sort_key, theme):
|
||||
options = []
|
||||
|
||||
for option_key, label in SORT_OPTIONS:
|
||||
query = {"sort": option_key, "theme": theme}
|
||||
|
||||
if search_text != "":
|
||||
query["search"] = search_text
|
||||
|
||||
options.append(
|
||||
{
|
||||
"key": option_key,
|
||||
"label": label,
|
||||
"url": append_query(request.path, query),
|
||||
"is_active": option_key == sort_key,
|
||||
}
|
||||
)
|
||||
|
||||
return options
|
||||
|
||||
|
||||
def get_first_image_thumbnail_url(subdir):
|
||||
try:
|
||||
images = sorted(
|
||||
[
|
||||
entry
|
||||
for entry in subdir.iterdir()
|
||||
if entry.is_file() and not entry.is_symlink() and is_image_file(entry)
|
||||
],
|
||||
key=lambda item: (
|
||||
item.name.lower(),
|
||||
str(item.relative_to(settings.GALLERY_ROOT)).lower(),
|
||||
),
|
||||
)
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
if len(images) == 0:
|
||||
return None
|
||||
|
||||
first_image = images[0]
|
||||
|
||||
try:
|
||||
make_thumbnail(first_image)
|
||||
rel_path = first_image.relative_to(settings.GALLERY_ROOT)
|
||||
thumb_path = settings.THUMBNAILS_ROOT.joinpath(rel_path)
|
||||
|
||||
if thumb_path.exists():
|
||||
return "/thumbs/" + str(rel_path).replace("\\", "/")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
|
||||
###########################################################################################
|
||||
# View functions. #
|
||||
###########################################################################################
|
||||
from .models import UserSettings
|
||||
|
||||
|
||||
@login_required
|
||||
@@ -277,257 +25,6 @@ def index(request):
|
||||
return redirect("gallery_view_root")
|
||||
|
||||
|
||||
def _render_directory(request, path_text, full_path):
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
|
||||
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)
|
||||
|
||||
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)
|
||||
]
|
||||
else:
|
||||
_, images = do_recursive_search(full_path, search_text)
|
||||
|
||||
images = sort_images(images, sort_key)
|
||||
|
||||
image_data = []
|
||||
for image in images:
|
||||
rel_path = image.relative_to(settings.GALLERY_ROOT)
|
||||
image_url = gallery_url(rel_path, False, query_state)
|
||||
|
||||
thumbnail = None
|
||||
try:
|
||||
make_thumbnail(image)
|
||||
thumb_path = settings.THUMBNAILS_ROOT.joinpath(rel_path)
|
||||
if thumb_path.exists():
|
||||
thumbnail = "/thumbs/" + str(rel_path).replace("\\", "/")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
image_data.append(
|
||||
{"path": image_url, "name": image.name, "thumbnail": thumbnail}
|
||||
)
|
||||
|
||||
subdir_data = []
|
||||
for subdir in current_subdirs:
|
||||
rel_path = subdir.relative_to(settings.GALLERY_ROOT)
|
||||
subdir_data.append(
|
||||
{
|
||||
"path": gallery_url(rel_path, True, query_state),
|
||||
"name": subdir.name,
|
||||
"thumbnail": get_first_image_thumbnail_url(subdir),
|
||||
}
|
||||
)
|
||||
|
||||
next_theme = "light" if theme == "dark" else "dark"
|
||||
theme_query = {"sort": sort_key, "theme": next_theme}
|
||||
|
||||
if search_text != "":
|
||||
theme_query["search"] = search_text
|
||||
|
||||
search_action_query = {"sort": sort_key, "theme": theme}
|
||||
|
||||
context = {
|
||||
"path": path_text,
|
||||
"search_text": search_text,
|
||||
"theme": theme,
|
||||
"sort_key": sort_key,
|
||||
"sort_label": SORT_LABELS[sort_key],
|
||||
"sort_options": build_sort_options(request, search_text, sort_key, theme),
|
||||
"breadcrumbs": build_breadcrumbs(path_text, query_state),
|
||||
"images": image_data,
|
||||
"subdirs": subdir_data,
|
||||
"theme_toggle_url": append_query(request.path, theme_query),
|
||||
"search_action_url": append_query(request.path, search_action_query),
|
||||
}
|
||||
|
||||
return render(request, "gallery_view.html", context)
|
||||
|
||||
|
||||
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:
|
||||
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)
|
||||
|
||||
# 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")
|
||||
|
||||
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,
|
||||
# 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)
|
||||
|
||||
|
||||
@login_required
|
||||
def gallery_view(request, path=None):
|
||||
"""
|
||||
@@ -549,6 +46,54 @@ def gallery_view(request, path=None):
|
||||
path_text = path if path is not None else ""
|
||||
|
||||
if candidate.is_dir():
|
||||
return _render_directory(request, path_text, candidate)
|
||||
return render_directory(request, path_text, candidate)
|
||||
|
||||
return _render_image(request, path_text, candidate)
|
||||
return render_image(request, path_text, candidate)
|
||||
|
||||
|
||||
@login_required
|
||||
def toggle_settings(request):
|
||||
"""Persist theme and/or sort for the current user and redirect back.
|
||||
|
||||
Expected query params:
|
||||
- next: the URL to redirect back to (optional)
|
||||
- theme: optional, 'light' or 'dark'
|
||||
- sort: optional, one of allowed sort keys
|
||||
|
||||
The view will obtain or create the UserSettings row for the user and set
|
||||
any provided values. If multiple UserSettings rows exist (shouldn't
|
||||
normally happen) the first is used.
|
||||
"""
|
||||
|
||||
next_url = request.GET.get("next") or "/gallery/"
|
||||
|
||||
# Only allow in-site redirects for safety
|
||||
parsed = urlparse(next_url)
|
||||
if parsed.netloc and parsed.netloc != "":
|
||||
next_url = "/gallery/"
|
||||
|
||||
user = getattr(request, "user", None)
|
||||
if not user or not getattr(user, "is_authenticated", False):
|
||||
return redirect(next_url)
|
||||
|
||||
# Obtain or create the settings row
|
||||
try:
|
||||
settings_obj = UserSettings.objects.get(user=user)
|
||||
except UserSettings.DoesNotExist:
|
||||
settings_obj = UserSettings(user=user)
|
||||
except MultipleObjectsReturned:
|
||||
settings_obj = UserSettings.objects.filter(user=user).first()
|
||||
|
||||
# Apply provided values
|
||||
theme = request.GET.get("theme")
|
||||
sort = request.GET.get("sort")
|
||||
|
||||
if theme in ("light", "dark"):
|
||||
settings_obj.theme = theme
|
||||
|
||||
if sort:
|
||||
settings_obj.sort = sort
|
||||
|
||||
settings_obj.save()
|
||||
|
||||
return redirect(next_url)
|
||||
|
||||
Reference in New Issue
Block a user