Compare commits
16 Commits
caf8130c93
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 24c1c96f19 | |||
| 5a2bea3040 | |||
| 27da3a33d3 | |||
|
|
ab81a0a97d | ||
|
|
b8e7a876a7 | ||
|
|
377951efbe | ||
|
|
73b538b698 | ||
|
|
8719be3227 | ||
|
|
13ab55d1d3 | ||
|
|
5ec793b47d | ||
|
|
f658106316 | ||
|
|
7cc7f04b80 | ||
|
|
5a6632491a | ||
| 77d83c58d1 | |||
| 480ec37ef6 | |||
| 3e209519e5 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -362,3 +362,5 @@ Icon
|
|||||||
Network Trash Folder
|
Network Trash Folder
|
||||||
Temporary Items
|
Temporary Items
|
||||||
.apdisk
|
.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
|
## 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`
|
- Activate venv (bash): `source .venv/bin/activate`
|
||||||
- Install deps: `pip install -r requirements.txt`
|
- Install deps: `pip install -r requirements.txt`
|
||||||
- Apply migrations: `python manage.py migrate`
|
- Apply migrations: `.venv/bin/python manage.py migrate`
|
||||||
- Run server: `python manage.py runserver`
|
- 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:
|
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:
|
Use these commands as the standard validation set:
|
||||||
|
|
||||||
- Django system checks: `python manage.py check`
|
- Django system checks: `.venv/bin/python manage.py check`
|
||||||
- Migration consistency check: `python manage.py makemigrations --check --dry-run`
|
- Migration consistency check: `.venv/bin/python manage.py makemigrations --check --dry-run`
|
||||||
- Python syntax sanity: `python -m compileall NibasaViewer viewer`
|
- Python syntax sanity: `.venv/bin/python -m compileall NibasaViewer viewer`
|
||||||
- Run Django tests (all): `python manage.py test`
|
- Run Django tests (all): `.venv/bin/python manage.py test`
|
||||||
|
|
||||||
Single-test execution patterns (important for fast iteration):
|
Single-test execution patterns (important for fast iteration):
|
||||||
|
|
||||||
- Single test module: `python manage.py test viewer.test`
|
- Single test module: `.venv/bin/python manage.py test viewer.test`
|
||||||
- Single test class: `python manage.py test viewer.test.GalleryViewTests`
|
- Single test class: `.venv/bin/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 method: `.venv/bin/python manage.py test viewer.test.GalleryViewTests.test_search_action_url_uses_nested_path`
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
|
|
||||||
- If there are currently no tests, add focused tests near the changed behavior before large refactors.
|
- 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
|
## 5) Operational Commands
|
||||||
|
|
||||||
- Pre-generate thumbnails: `python manage.py makethumbnails`
|
- Pre-generate thumbnails: `.venv/bin/python manage.py makethumbnails`
|
||||||
- Create auth user (shell):
|
- Create auth user (shell):
|
||||||
- `python manage.py shell`
|
- `.venv/bin/python manage.py shell`
|
||||||
- `from django.contrib.auth.models import User`
|
- `from django.contrib.auth.models import User`
|
||||||
- `User.objects.create_user('<USERNAME>', '<EMAIL>', '<PASSWORD>')`
|
- `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
|
## 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.
|
- `_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 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 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)
|
## 7) Code Style Guidelines (Repository-Specific)
|
||||||
|
|
||||||
@@ -169,8 +173,8 @@ When implementing a change, follow this minimum loop:
|
|||||||
|
|
||||||
Suggested pre-handoff command bundle:
|
Suggested pre-handoff command bundle:
|
||||||
|
|
||||||
- `python manage.py check`
|
- `.venv/bin/python manage.py check`
|
||||||
- `python manage.py test` (or a targeted test label)
|
- `.venv/bin/python manage.py test` (or a targeted test label)
|
||||||
- `python -m compileall NibasaViewer viewer`
|
- `.venv/bin/python -m compileall NibasaViewer viewer`
|
||||||
|
|
||||||
This repository values stability, compatibility, and straightforward Django patterns over novelty.
|
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
|
- `utils.py` - Thumbnail generation helpers
|
||||||
- `templates/` - Pure HTML templates (no JavaScript)
|
- `templates/` - Pure HTML templates (no JavaScript)
|
||||||
- `static/` - CSS styling and navigation icons
|
- `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
|
### 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:
|
**`gallery_view()` function** (viewer/views.py:65-189) handles both:
|
||||||
|
|
||||||
1. **Directory browsing** when path points to folder:
|
1. **Directory browsing** when path points to folder:
|
||||||
- Lists subdirectories and images
|
- Lists subdirectories and images
|
||||||
- Supports search with recursive directory scanning (viewer/views.py:32-52)
|
- Supports search with recursive directory scanning (`viewer/directory.py:do_recursive_search`)
|
||||||
- Paginates images at 28 per page (7 columns × 4 rows)
|
- Does not use server-side pagination; the gallery returns full image lists and templates render them in a grid
|
||||||
- Converts flat image lists to table rows for HTML rendering
|
- Converts image lists to structured context used by templates
|
||||||
|
|
||||||
2. **Image viewing** when path points to file:
|
2. **Image viewing** when path points to file:
|
||||||
- Displays single image with prev/next navigation
|
- Displays single image with prev/next navigation
|
||||||
- Finds sibling images in same directory
|
- Finds sibling images in same directory
|
||||||
- Links to full-resolution image
|
- 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
|
### Thumbnail Generation
|
||||||
|
|
||||||
**Lazy generation approach** (viewer/utils.py):
|
**Lazy generation approach** (viewer/utils.py):
|
||||||
- Thumbnails created on-demand when viewing gallery
|
- 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
|
- Uses Pillow for image processing
|
||||||
- Fast extension-based image detection (no MIME-type I/O)
|
- 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.common.CommonMiddleware",
|
||||||
"django.middleware.csrf.CsrfViewMiddleware",
|
"django.middleware.csrf.CsrfViewMiddleware",
|
||||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||||
|
"NibasaViewer.middleware.UserSettingsMiddleware",
|
||||||
"django.contrib.messages.middleware.MessageMiddleware",
|
"django.contrib.messages.middleware.MessageMiddleware",
|
||||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
"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
|
from django.contrib.auth.models import User
|
||||||
user = User.objects.create_user('<USERNAME>', '<EMAIL>', '<PASSWORD>')
|
user = User.objects.create_user('<USERNAME>', '<EMAIL>', '<PASSWORD>')
|
||||||
user.save()
|
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;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.crumb-link {
|
.crumb-link, .crumb-link-last {
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
@@ -189,7 +189,6 @@ body {
|
|||||||
/* Image view specific styles */
|
/* Image view specific styles */
|
||||||
.image-content {
|
.image-content {
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
padding: 18px;
|
|
||||||
flex: 1 1 auto; /* occupy available vertical space inside main-area */
|
flex: 1 1 auto; /* occupy available vertical space inside main-area */
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center; /* center the image vertically */
|
align-items: center; /* center the image vertically */
|
||||||
@@ -240,7 +239,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.info-menu {
|
.info-menu {
|
||||||
min-width: 220px;
|
min-width: 275px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -381,13 +380,30 @@ body.theme-dark .small.text-muted {
|
|||||||
|
|
||||||
@media (max-width: 991.98px) {
|
@media (max-width: 991.98px) {
|
||||||
.app-shell {
|
.app-shell {
|
||||||
padding: 12px;
|
padding: 0px;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.offcanvas {
|
.offcanvas {
|
||||||
width: 100vw !important;
|
width: 100vw !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.top-bar {
|
||||||
|
border-radius: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crumb-link {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crumb-sep {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-scroll {
|
||||||
|
padding-left: 16px;
|
||||||
|
padding-right: 16px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 575.98px) {
|
@media (max-width: 575.98px) {
|
||||||
|
|||||||
@@ -33,6 +33,11 @@
|
|||||||
<div class="input-group input-group-sm">
|
<div class="input-group input-group-sm">
|
||||||
<input type="search" name="search" class="form-control search-input" placeholder="Search..." value="{{ search_text }}">
|
<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>
|
<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>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
@@ -42,6 +47,28 @@
|
|||||||
<a href="#" class="sidebar-link">Most visited</a>
|
<a href="#" class="sidebar-link">Most visited</a>
|
||||||
<a href="#" class="sidebar-link">Recently 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>
|
<hr>
|
||||||
|
|
||||||
<div class="sidebar-scroll flex-grow-1">
|
<div class="sidebar-scroll flex-grow-1">
|
||||||
@@ -98,6 +125,28 @@
|
|||||||
<a href="#" class="sidebar-link">Most visited</a>
|
<a href="#" class="sidebar-link">Most visited</a>
|
||||||
<a href="#" class="sidebar-link">Recently 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>
|
<hr>
|
||||||
|
|
||||||
<div class="sidebar-scroll" style="max-height: 200px;">
|
<div class="sidebar-scroll" style="max-height: 200px;">
|
||||||
@@ -137,7 +186,7 @@
|
|||||||
{% if not forloop.first %}
|
{% if not forloop.first %}
|
||||||
<span class="crumb-sep">/</span>
|
<span class="crumb-sep">/</span>
|
||||||
{% endif %}
|
{% 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 %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -212,6 +261,28 @@
|
|||||||
<a href="#" class="sidebar-link">Most visited</a>
|
<a href="#" class="sidebar-link">Most visited</a>
|
||||||
<a href="#" class="sidebar-link">Recently 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>
|
<hr>
|
||||||
|
|
||||||
<div class="sidebar-scroll flex-grow-1">
|
<div class="sidebar-scroll flex-grow-1">
|
||||||
|
|||||||
@@ -117,12 +117,23 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</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">
|
<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">
|
<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>
|
<i class="fa-solid fa-circle-info"></i>
|
||||||
</button>
|
</button>
|
||||||
<div class="dropdown-menu dropdown-menu-end info-menu p-2">
|
<div class="dropdown-menu dropdown-menu-end info-menu p-2">
|
||||||
<div style="max-height:220px; overflow:auto;">
|
<div style="max-height:300px; overflow:auto;">
|
||||||
<div class="small text-muted">{{ image_meta.filename|truncatechars:40 }}</div>
|
<div class="small text-muted">{{ image_meta.filename|truncatechars:40 }}</div>
|
||||||
{% if image_meta.width and image_meta.height %}
|
{% if image_meta.width and image_meta.height %}
|
||||||
<div>{{ image_meta.width }} x {{ image_meta.height }} px</div>
|
<div>{{ image_meta.width }} x {{ image_meta.height }} px</div>
|
||||||
@@ -138,6 +149,14 @@
|
|||||||
<div class="small text-muted">Modification date</div>
|
<div class="small text-muted">Modification date</div>
|
||||||
<div>{{ image_meta.modified }}</div>
|
<div>{{ image_meta.modified }}</div>
|
||||||
{% endif %}
|
{% 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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -145,11 +164,11 @@
|
|||||||
|
|
||||||
<section class="gallery-scroll flex-grow-1 d-flex">
|
<section class="gallery-scroll flex-grow-1 d-flex">
|
||||||
<div class="image-content w-100">
|
<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">
|
<img src="{{ image_path }}" alt="{{ image_path.name }}" class="image-full">
|
||||||
</div>
|
</a>
|
||||||
</a>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
179
viewer/test.py
179
viewer/test.py
@@ -10,9 +10,16 @@ from PIL import Image
|
|||||||
# Django imports.
|
# Django imports.
|
||||||
from django.test import Client, RequestFactory, TestCase, override_settings
|
from django.test import Client, RequestFactory, TestCase, override_settings
|
||||||
from django.contrib.auth.models import User
|
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.
|
# 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):
|
class GalleryBaseTests(TestCase):
|
||||||
@@ -34,6 +41,10 @@ class GalleryBaseTests(TestCase):
|
|||||||
self.client = Client()
|
self.client = Client()
|
||||||
self.client.force_login(self.user)
|
self.client.force_login(self.user)
|
||||||
self.factory = RequestFactory()
|
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()
|
self._build_fixture_tree()
|
||||||
|
|
||||||
@@ -93,6 +104,12 @@ class GalleryViewTests(GalleryBaseTests):
|
|||||||
request = self.factory.get("/gallery/../")
|
request = self.factory.get("/gallery/../")
|
||||||
request.user = self.user
|
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, "../")
|
response = gallery_view(request, "../")
|
||||||
self.assertEqual(response.status_code, 404)
|
self.assertEqual(response.status_code, 404)
|
||||||
|
|
||||||
@@ -176,26 +193,29 @@ class GalleryViewTests(GalleryBaseTests):
|
|||||||
breadcrumbs = response.context["breadcrumbs"]
|
breadcrumbs = response.context["breadcrumbs"]
|
||||||
self.assertTrue(breadcrumbs[0]["path"].find("/gallery/?") == 0)
|
self.assertTrue(breadcrumbs[0]["path"].find("/gallery/?") == 0)
|
||||||
self.assertIn("search=match", breadcrumbs[0]["path"])
|
self.assertIn("search=match", breadcrumbs[0]["path"])
|
||||||
self.assertIn("sort=recent", breadcrumbs[0]["path"])
|
# build_query now only preserves search; sort/theme are not included
|
||||||
self.assertIn("theme=light", breadcrumbs[0]["path"])
|
self.assertNotIn("sort=", breadcrumbs[0]["path"])
|
||||||
|
self.assertNotIn("theme=", breadcrumbs[0]["path"])
|
||||||
|
|
||||||
def test_subdir_links_preserve_query(self):
|
def test_subdir_links_preserve_query(self):
|
||||||
response = self.client.get("/gallery/?search=match&sort=recent&theme=light")
|
response = self.client.get("/gallery/?search=match&sort=recent&theme=light")
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
subdir_paths = [subdir["path"] for subdir in response.context["subdirs"]]
|
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("search=match") != -1 for path in subdir_paths))
|
||||||
self.assertTrue(any(path.find("sort=recent") != -1 for path in subdir_paths))
|
self.assertFalse(any(path.find("sort=") != -1 for path in subdir_paths))
|
||||||
self.assertTrue(any(path.find("theme=light") != -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):
|
def test_image_links_preserve_query(self):
|
||||||
response = self.client.get("/gallery/?search=match&sort=recent&theme=light")
|
response = self.client.get("/gallery/?search=match&sort=recent&theme=light")
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
image_paths = [image["path"] for image in response.context["images"]]
|
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("search=match") != -1 for path in image_paths))
|
||||||
self.assertTrue(any(path.find("sort=recent") != -1 for path in image_paths))
|
self.assertFalse(any(path.find("sort=") != -1 for path in image_paths))
|
||||||
self.assertTrue(any(path.find("theme=light") != -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):
|
def test_theme_toggle_url_preserves_query(self):
|
||||||
response = self.client.get("/gallery/?search=match&sort=recent&theme=dark")
|
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("sort=recent", url)
|
||||||
self.assertIn("theme=light", 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):
|
def test_search_action_url_uses_nested_path(self):
|
||||||
response = self.client.get("/gallery/sub_a/?sort=recent&theme=light")
|
response = self.client.get("/gallery/sub_a/?sort=recent&theme=light")
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
@@ -280,7 +320,7 @@ class GalleryViewTests(GalleryBaseTests):
|
|||||||
self.assertNotIn("symlink_sub", names)
|
self.assertNotIn("symlink_sub", names)
|
||||||
|
|
||||||
def test_thumbnail_failure_does_not_break_render(self):
|
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/")
|
response = self.client.get("/gallery/")
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 200)
|
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
|
norm = lambda u: u.replace("/./", "/") if u is not None else u
|
||||||
self.assertEqual(norm(back_url), norm(home_url))
|
self.assertEqual(norm(back_url), norm(home_url))
|
||||||
self.assertIn("/gallery/", norm(back_url))
|
self.assertIn("/gallery/", norm(back_url))
|
||||||
self.assertIn("sort=abc", back_url)
|
# build_query no longer injects sort/theme into gallery URLs
|
||||||
self.assertIn("theme=dark", back_url)
|
self.assertNotIn("sort=", back_url)
|
||||||
|
self.assertNotIn("theme=", back_url)
|
||||||
|
|
||||||
# Back and Home thumbnails should be available and point to /thumbs/
|
# Back and Home thumbnails should be available and point to /thumbs/
|
||||||
back_thumb = response.context.get("back_thumb")
|
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("created"))
|
||||||
self.assertIsNotNone(image_meta.get("modified"))
|
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):
|
def test_sort_modes_recent_and_tnecer_use_mtime(self):
|
||||||
recent = self.client.get("/gallery/?sort=recent")
|
recent = self.client.get("/gallery/?sort=recent")
|
||||||
@@ -383,7 +432,7 @@ class GalleryViewTests(GalleryBaseTests):
|
|||||||
def fake_creation(path_obj):
|
def fake_creation(path_obj):
|
||||||
return creation_values.get(path_obj.name, 0)
|
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")
|
old = self.client.get("/gallery/?sort=old")
|
||||||
new = self.client.get("/gallery/?sort=new")
|
new = self.client.get("/gallery/?sort=new")
|
||||||
|
|
||||||
@@ -416,6 +465,17 @@ class GalleryTemplateTests(GalleryBaseTests):
|
|||||||
self.assertIn("Most visited", body)
|
self.assertIn("Most visited", body)
|
||||||
self.assertIn("Recently 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):
|
def test_template_shows_fallback_icon_for_empty_subdir(self):
|
||||||
response = self.client.get("/gallery/")
|
response = self.client.get("/gallery/")
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
@@ -449,3 +509,100 @@ class GalleryTemplateTests(GalleryBaseTests):
|
|||||||
css_path = Path(__file__).resolve().parent / "static" / "css" / "styles.css"
|
css_path = Path(__file__).resolve().parent / "static" / "css" / "styles.css"
|
||||||
css = css_path.read_text(encoding="utf-8")
|
css = css_path.read_text(encoding="utf-8")
|
||||||
self.assertIn("box-shadow: 0 5px 20px", css)
|
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
|
from django.urls import path
|
||||||
|
|
||||||
# Module imports
|
# Module imports
|
||||||
from .views import (
|
from .views import gallery_view, toggle_settings
|
||||||
gallery_view
|
|
||||||
)
|
|
||||||
|
|
||||||
###########################################################################################
|
###########################################################################################
|
||||||
# URL Patterns. #
|
# URL Patterns. #
|
||||||
@@ -13,6 +11,7 @@ from .views import (
|
|||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
# Views.
|
# Views.
|
||||||
path('', gallery_view, name = 'gallery_view_root'),
|
path("", gallery_view, name="gallery_view_root"),
|
||||||
path('<path:path>/', gallery_view, name = 'gallery_view_path'),
|
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.
|
"""Top-level views module.
|
||||||
from pathlib import Path
|
|
||||||
from urllib.parse import urlencode
|
After refactor this file only keeps the minimal public view entry points and imports
|
||||||
from functools import cmp_to_key
|
helpers from the new `directory` and `image` modules. Also provides the
|
||||||
|
`toggle_settings` view used by template buttons to persist theme/sort.
|
||||||
|
"""
|
||||||
|
|
||||||
# Django imports.
|
# Django imports.
|
||||||
|
from urllib.parse import urlparse
|
||||||
from django.http import HttpResponseNotFound
|
from django.http import HttpResponseNotFound
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth.decorators import login_required
|
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.
|
# Local helpers split into modules
|
||||||
from .utils import make_thumbnail, is_image_file
|
from .directory import render_directory
|
||||||
from PIL import Image
|
from .image import render_image
|
||||||
import datetime
|
|
||||||
|
|
||||||
###########################################################################################
|
from .models import UserSettings
|
||||||
# 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. #
|
|
||||||
###########################################################################################
|
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
@@ -277,257 +25,6 @@ def index(request):
|
|||||||
return redirect("gallery_view_root")
|
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
|
@login_required
|
||||||
def gallery_view(request, path=None):
|
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 ""
|
path_text = path if path is not None else ""
|
||||||
|
|
||||||
if candidate.is_dir():
|
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