Compare commits

..

27 Commits

Author SHA1 Message Date
314ba461c0 Removed spurious kate-swp file from the repo. 2026-03-27 22:01:17 -04:00
f8a4f71e74 Update login template. 2026-03-27 21:54:09 -04:00
7cc9d1ed17 Add special-gallery back thumbnail and hide sort button for specials 2026-03-27 21:10:38 -04:00
21a3ab40c5 special-gallery: scope image prev/next/back to special galleries; add tests 2026-03-27 21:04:18 -04:00
0e48a5d9bd Removed unused imports. 2026-03-27 20:36:59 -04:00
701845dceb tests: add most-visited/recent and edge-case tests for special galleries 2026-03-27 20:33:33 -04:00
97aa42661a tests: add view-level tests for special galleries (favorites directory and image view) 2026-03-27 20:32:54 -04:00
5f1035a252 special galleries: add tests; make special breadcrumb clickable; highlight active special; hide back only 2026-03-27 20:31:43 -04:00
532690a329 special galleries: helper module, renderers, templates & urls; hide back in special views; highlight active special 2026-03-27 20:09:35 -04:00
24c1c96f19 Added home and back links to gallery view. 2026-03-25 12:35:00 -04:00
5a2bea3040 Assorted CSS tweaks for mobile. 2026-03-25 12:21:49 -04:00
27da3a33d3 Made top bar fit screen borders on mobile. 2026-03-25 12:15:01 -04:00
Miguel Astor
ab81a0a97d test: add clear-search URL and template tests
Add tests for clear_search_url context and template clear-search button when a search is active.
2026-03-25 06:08:00 -04:00
Miguel Astor
b8e7a876a7 tests: accept build_query only preserving search (drop sort/theme from gallery link assertions) 2026-03-25 05:54:41 -04:00
Miguel Astor
377951efbe Fixed issue with sort and theme keys still being applied as GET parameters. 2026-03-25 04:53:36 -04:00
Miguel Astor
73b538b698 feat(viewer): persist theme/sort via toggle view; use middleware-provided theme/sort in views and templates 2026-03-25 04:43:35 -04:00
Miguel Astor
8719be3227 tests: add UserSettings model and middleware tests; ensure tests create UserSettings defaults 2026-03-25 04:25:38 -04:00
Miguel Astor
13ab55d1d3 Changed Image model path to method. 2026-03-24 15:39:09 -04:00
Miguel Astor
5ec793b47d Added kate droppings to gitignore. 2026-03-24 15:34:15 -04:00
Miguel Astor
f658106316 Fixed issue with image links covering all image wrapper area. 2026-03-24 15:34:03 -04:00
Miguel Astor
7cc7f04b80 test: update image view tests and add Image model tests 2026-03-24 15:12:45 -04:00
Miguel Astor
5a6632491a Added model for special galleries. 2026-03-24 15:06:48 -04:00
77d83c58d1 docs: update README with run/test instructions 2026-03-23 23:55:26 -04:00
480ec37ef6 Updated AGENTS and CLAUDE files. 2026-03-23 23:53:14 -04:00
3e209519e5 refactor: split directory and image rendering into viewer.directory, viewer.image; add viewer.common for shared helpers 2026-03-23 23:48:23 -04:00
caf8130c93 gallery: allow multiple fixed 128px thumbnails per row on small screens 2026-03-23 23:20:57 -04:00
d42de13d4e Fixed layout issues with the offcanvas sidebar in mobile. 2026-03-23 23:15:49 -04:00
35 changed files with 2005 additions and 874 deletions

3
.gitignore vendored
View File

@@ -362,3 +362,6 @@ Icon
Network Trash Folder Network Trash Folder
Temporary Items Temporary Items
.apdisk .apdisk
.kateproject*
.*.kate-swp

View File

@@ -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.

View File

@@ -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)

View 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

View File

@@ -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",
] ]

View File

@@ -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
View 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, special=None):
if query_dict is None:
query_dict = {}
if path_obj is None:
base_url = f"/gallery/{special}/" if special is not None else "/gallery/"
else:
path_text = str(path_obj).replace("\\", "/")
base_url = (f"/gallery/{special}/" if special is not None else "/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

242
viewer/directory.py Normal file
View File

@@ -0,0 +1,242 @@
"""
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,
)
from .specials import get_special_paths, special_name
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, special=None):
"""
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)
# If rendering a logical special gallery, obtain the image list from the
# Image model and skip filesystem subdir enumeration.
if special is not None:
# build images using the helper that returns Path objects
images = get_special_paths(request.user, special)
# Respect the client's sort_key only if the special is favorites
# otherwise the ordering comes from the DB query (most-visited/recent).
if special == "favorites":
images = sort_images(images, sort_key)
current_subdirs = []
else:
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, special)
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 = ""
# For special galleries hide the Back control but still present Home
if special is not None:
back_url = None
back_thumb = None
else:
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
),
}
# When rendering a special gallery adjust breadcrumbs and hide search
# and back controls. Use `is_special` and `special_name` in the template.
if special is not None:
context["is_special"] = True
context["special_name"] = special_name(special)
# expose which special is active so templates can highlight it
context["active_special"] = special
# Override breadcrumbs for special galleries to be a single label
# and make it link to the special gallery root so templates can show
# it as an active, clickable breadcrumb.
from .specials import special_root_url
context["breadcrumbs"] = [
{
"label": context["special_name"],
"path": special_root_url(special, query_state),
}
]
# Hide the search box (templates use `is_special` to decide)
context["search_text"] = ""
context["search_action_url"] = ""
context["clear_search_url"] = ""
# 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)

273
viewer/image.py Normal file
View File

@@ -0,0 +1,273 @@
"""
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
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,
)
from .specials import get_special_paths, special_name
def render_image(request, path_text, full_path, special=None):
"""
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
if special is not None:
# Use the special gallery ordering for prev/next navigation
images_sorted = get_special_paths(request.user, special)
# If favorites, respect client sort; otherwise keep DB ordering
if special == "favorites":
images_sorted = sort_images(images_sorted, sort_key)
else:
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 within the resolved list
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)
# When viewing from a special gallery, route prev/next through the
# special gallery URL so subsequent navigation preserves the special
# gallery context. Otherwise use the normal gallery URL for the file.
if special is not None:
rel_text = str(rel).replace("\\", "/")
prev_url = append_query(f"/gallery/{special}/{rel_text}/", query_state)
else:
prev_url = gallery_url(rel, False, query_state)
if next_path is not None:
rel = next_path.relative_to(settings.GALLERY_ROOT)
if special is not None:
rel_text = str(rel).replace("\\", "/")
next_url = append_query(f"/gallery/{special}/{rel_text}/", query_state)
else:
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 (directory) and Home (root) links and thumbnails
# For special galleries we still want to show a Back link that points
# to the special gallery root — populate `back_thumb` with the
# thumbnail of the first image in that special gallery so the UI can
# display a representative image. For non-special views keep the
# existing behavior (thumbnail for parent directory).
if special is not None:
back_url = None
# images_sorted is already the special list in the requested order
try:
first_image = images_sorted[0] if len(images_sorted) > 0 else None
back_thumb = thumb_for(first_image)
except Exception:
back_thumb = None
else:
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
if special is not None:
# SPECIAL NAME / IMAGE
from .specials import special_root_url
breadcrumbs = [
{
"label": special_name(special),
"path": special_root_url(special, query_state),
},
{"label": full_path.name, "path": None},
]
else:
# 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,
"is_special": special is not None,
}
from .common import SORT_LABELS
context["sort_label"] = SORT_LABELS.get(sort_key, "")
return render(request, "image_view.html", context)

View 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,
),
),
],
),
]

View 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),
),
]

View 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
View 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"

71
viewer/specials.py Normal file
View File

@@ -0,0 +1,71 @@
"""
Helpers for assembling "special" galleries (favorites, most-visited, recent).
These functions return filesystem Path objects suitable for use by the
existing directory and image renderers.
"""
from pathlib import Path
from django.conf import settings
from .models import Image as ImModel
SPECIALS = {
"favorites": "Favorites",
"most-visited": "Most Visited",
"recent": "Recent",
}
def special_name(key):
return SPECIALS.get(key, key)
def special_root_url(key, query_dict=None):
# Build a URL for the special gallery root (e.g. /gallery/favorites/)
base = f"/gallery/{key}/"
if not query_dict:
return base
from urllib.parse import urlencode
return base + "?" + urlencode(query_dict)
def get_special_paths(user, key, limit=100):
"""Return a list of pathlib.Path objects for the requested special gallery.
Only include files that actually exist on disk. The returned list is
ordered according to the special gallery semantics.
"""
qs = ImModel.objects.filter(user=user)
if key == "favorites":
qs = qs.filter(favorite=True).order_by("-last_visited")
elif key == "most-visited":
qs = qs.order_by("-visits")
elif key == "recent":
qs = qs.order_by("-last_visited")
else:
return []
if key in ("most-visited", "recent"):
qs = qs[:limit]
paths = []
for row in qs:
try:
p = Path(row.path)
# ensure the stored path is inside GALLERY_ROOT for safety
try:
p.relative_to(settings.GALLERY_ROOT)
except Exception:
# skip paths outside the gallery root
continue
if p.exists() and p.is_file():
paths.append(p)
except Exception:
continue
return paths

View File

@@ -1,128 +0,0 @@
/****************************************************************************
* Specific elements. *
****************************************************************************/
html {
height:100%;
}
body {
height: 100%;
margin: 0px;
}
#id_username {
width: 100%;
}
#id_password {
width: 100%;
}
a:link {
color: #ffff00;
}
a:visited {
color: #009CD9;
}
/****************************************************************************
* Containers. *
****************************************************************************/
.centered-container {
width: 100%;
text-align: center;
margin-bottom: 0.5em;
}
.background {
background-color: darkslategray;
color: lightgray;
}
.image-container {
max-width: 900px;
max-height: 600px;
}
.fc {
width: fit-content;
}
.h90 {
height: 90%;
}
.full-width {
width: 100%;
}
.fixed-width {
width: 300px;
}
/****************************************************************************
* Content. *
****************************************************************************/
.image {
max-width: 100%;
max-height: 600px;
width: auto;
}
.mauto {
margin: auto;
}
.mauto-top {
margin: 200px auto;
}
.navigation-icon {
width: 100px;
}
.small-nav-icon {
width: 3em;
}
.mb-2 {
margin-bottom: 2em;
}
.mr-2 {
margin-right: 2em;
}
.ml-4 {
margin-left: 4em;
}
.ml-2 {
margin-left: 2em;
}
.clear-btn {
border: none;
background-color: #00000000;
}
.search-box {
height: 2.5em;
}
.float-right {
float: right;
}
/****************************************************************************
* Grid. *
****************************************************************************/
.column {
text-align: center;
border: black 1px solid;
}

View File

@@ -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;
@@ -176,17 +176,19 @@ body {
.gallery-grid { .gallery-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(128px, 128px)); /* Create as many fixed 128px columns as will fit and center them.
justify-content: space-between; `auto-fit` ensures the grid creates as many columns as possible
column-gap: 24px; while keeping each column exactly 128px. Use `justify-content:
row-gap: 24px; center` so the columns are centered on wider containers. */
grid-template-columns: repeat(auto-fit, 128px);
justify-content: center;
gap: 24px; /* shorthand for row/column gaps */
padding-bottom: 8px; padding-bottom: 8px;
} }
/* 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 */
@@ -237,7 +239,7 @@ body {
} }
.info-menu { .info-menu {
min-width: 220px; min-width: 275px;
border-radius: 8px; border-radius: 8px;
} }
@@ -376,32 +378,56 @@ body.theme-dark .small.text-muted {
background: transparent; background: transparent;
} }
@media (max-width: 991.98px) { .login-form {
background: var(--panel-strong);
border-radius: 0 1rem 1rem 0;
}
.card {
background: var(--bg);
}
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;
} }
}
@media (max-width: 575.98px) { .top-bar {
.gallery-grid { border-radius: 0px;
grid-template-columns: 1fr;
justify-content: stretch;
}
.thumb-card {
margin-left: auto;
margin-right: auto;
} }
.crumb-link { .crumb-link {
max-width: 130px; display: none;
} }
.crumb-sep {
display: none;
}
.gallery-scroll {
padding-left: 16px;
padding-right: 16px;
}
}
@media (max-width: 767px) {
.login-form {
background: var(--panel-strong);
border-radius: 1rem 1rem 1rem 1rem;
}
}
@media (max-width: 575.98px) {
/* On narrow viewports keep the fixed 128px thumbnails but allow the
grid to place as many side-by-side as fit. Reduce breadcrumb max
width to preserve layout. */
.crumb-link {
max-width: 130px;
}
.crumb-link-full { .crumb-link-full {
overflow: hidden; overflow: hidden;

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 604 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

BIN
viewer/static/imgs/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

View File

@@ -29,18 +29,51 @@
<hr> <hr>
{% if not is_special %}
<form action="{{ search_action_url }}" method="get"> <form action="{{ search_action_url }}" method="get">
<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>
{% endif %}
<hr> <hr>
<a href="#" class="sidebar-link">Favorites</a> <a href="/gallery/favorites/" class="sidebar-link {% if active_special == 'favorites' %}active-sort{% endif %}">Favorites</a>
<a href="#" class="sidebar-link">Most visited</a> <a href="/gallery/most-visited/" class="sidebar-link {% if active_special == 'most-visited' %}active-sort{% endif %}">Most visited</a>
<a href="#" class="sidebar-link">Recently visited</a> <a href="/gallery/recent/" class="sidebar-link {% if active_special == 'recent' %}active-sort{% endif %}">Recently visited</a>
{% if path != '' or is_special %}
<hr>
{% endif %}
{% if path != '' %}
<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>
{% endif %}
{% if path != '' or is_special %}
<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>
@@ -85,18 +118,46 @@
<hr> <hr>
{% if not is_special %}
<form action="{{ search_action_url }}" method="get"> <form action="{{ search_action_url }}" method="get">
<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>
</div> </div>
</form> </form>
{% endif %}
<hr> <hr>
<a href="#" class="sidebar-link">Favorites</a> <a href="/gallery/favorites/" class="sidebar-link {% if active_special == 'favorites' %}active-sort{% endif %}">Favorites</a>
<a href="#" class="sidebar-link">Most visited</a> <a href="/gallery/most-visited/" class="sidebar-link {% if active_special == 'most-visited' %}active-sort{% endif %}">Most visited</a>
<a href="#" class="sidebar-link">Recently visited</a> <a href="/gallery/recent/" class="sidebar-link {% if active_special == 'recent' %}active-sort{% endif %}">Recently visited</a>
{% if path != '' or is_special %}
<hr>
{% endif %}
{% if path != '' %}
<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>
{% endif %}
{% if path != '' or is_special %}
<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>
@@ -137,10 +198,15 @@
{% 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> {% if crumb.path %}
<a href="{{ crumb.path }}" class="{% if not forloop.last %}crumb-link{% else %}crumb-link-last{% endif %}">{{ crumb.label|truncatechars:45 }}</a>
{% else %}
<span class="{% if not forloop.last %}crumb-link{% else %}crumb-link-last{% endif %}">{{ crumb.label|truncatechars:45 }}</span>
{% endif %}
{% endfor %} {% endfor %}
</div> </div>
{% if not is_special %}
<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="{{ sort_label }}" aria-label="Sort options"> <button class="btn btn-sm btn-plain" type="button" data-bs-toggle="dropdown" aria-expanded="false" title="{{ sort_label }}" aria-label="Sort options">
<i class="fa-solid fa-arrow-down-short-wide"></i> <i class="fa-solid fa-arrow-down-short-wide"></i>
@@ -153,8 +219,10 @@
{% endfor %} {% endfor %}
</ul> </ul>
</div> </div>
{% endif %}
</div> </div>
{% if not is_special %}
<noscript> <noscript>
<div class="pt-2"> <div class="pt-2">
{% for option in sort_options %} {% for option in sort_options %}
@@ -162,6 +230,7 @@
{% endfor %} {% endfor %}
</div> </div>
</noscript> </noscript>
{% endif %}
<section class="gallery-scroll flex-grow-1"> <section class="gallery-scroll flex-grow-1">
<div class="gallery-grid"> <div class="gallery-grid">
@@ -183,8 +252,9 @@
<div class="offcanvas offcanvas-start" tabindex="-1" id="sidebarOffcanvas" aria-labelledby="sidebarOffcanvasLabel"> <div class="offcanvas offcanvas-start" tabindex="-1" id="sidebarOffcanvas" aria-labelledby="sidebarOffcanvasLabel">
<div class="offcanvas-header pb-2"> <div class="offcanvas-header pb-2">
<h2 class="h6 mb-0" id="sidebarOffcanvasLabel">Gallery</h2> <div class="d-flex align-items-center user-row w-100 gap-2">
<div class="d-flex align-items-center gap-2"> <span><i class="fa-solid fa-circle-user fa-lg"></i></span>
<span class="user-name w-100 text-truncate">{{ request.user.username }}</span>
<a class="btn btn-sm btn-plain" href="{{ theme_toggle_url }}" aria-label="Toggle theme"> <a class="btn btn-sm btn-plain" href="{{ theme_toggle_url }}" aria-label="Toggle theme">
{% if theme == 'dark' %} {% if theme == 'dark' %}
<i class="fa-solid fa-sun"></i> <i class="fa-solid fa-sun"></i>
@@ -198,54 +268,71 @@
</div> </div>
</div> </div>
<div class="offcanvas-body pt-0 d-flex flex-column"> <div class="offcanvas-body pt-0 d-flex flex-column">
<aside class="sidebar d-flex flex-column"> <form action="{{ search_action_url }}" method="get">
<div class="d-flex align-items-center user-row"> <div class="input-group input-group-sm">
<span><i class="fa-solid fa-circle-user fa-lg"></i></span> <input type="search" name="search" class="form-control search-input" placeholder="Search..." value="{{ search_text }}">
<span class="user-name text-truncate">{{ request.user.username }}</span> <span class="input-group-text search-addon"><i class="fa-solid fa-magnifying-glass"></i></span>
</div> </div>
</form>
<hr> <hr>
<form action="{{ search_action_url }}" method="get"> <a href="/gallery/favorites/" class="sidebar-link {% if active_special == 'favorites' %}active-sort{% endif %}">Favorites</a>
<div class="input-group input-group-sm"> <a href="/gallery/most-visited/" class="sidebar-link {% if active_special == 'most-visited' %}active-sort{% endif %}">Most visited</a>
<input type="search" name="search" class="form-control search-input" placeholder="Search..." value="{{ search_text }}"> <a href="/gallery/recent/" class="sidebar-link {% if active_special == 'recent' %}active-sort{% endif %}">Recently visited</a>
<span class="input-group-text search-addon"><i class="fa-solid fa-magnifying-glass"></i></span>
</div>
</form>
<hr> {% if path != '' or is_special %}
<hr>
{% endif %}
<a href="#" class="sidebar-link">Favorites</a> {% if path != '' %}
<a href="#" class="sidebar-link">Most visited</a> <a href="{{ back_url }}" class="subdir-item">
<a href="#" class="sidebar-link">Recently visited</a> {% 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>
{% endif %}
<hr> {% if path != '' or is_special %}
<a href="{{ home_url }}" class="subdir-item">
{% if home_thumb %}
<img src="{{ home_thumb }}" class="subdir-thumb" alt="home thumb">
{% else %}
<span class="subdir-fallback"><i class="fa-solid fa-image"></i></span>
{% endif %}
<span>Home</span>
</a>
{% endif %}
<div class="sidebar-scroll flex-grow-1"> <hr>
{% for subdir in subdirs %}
<a href="{{ subdir.path }}" class="subdir-item">
{% if subdir.thumbnail %}
<img src="{{ subdir.thumbnail }}" alt="{{ subdir.name }}" class="subdir-thumb">
{% else %}
<span class="subdir-fallback"><i class="fa-solid fa-image"></i></span>
{% endif %}
<span>{{ subdir.name|truncatechars:30 }}</span>
</a>
{% empty %}
<small class="text-secondary">No subdirectories</small>
{% endfor %}
</div>
<hr> <div class="sidebar-scroll flex-grow-1">
{% for subdir in subdirs %}
<a href="{{ subdir.path }}" class="subdir-item">
{% if subdir.thumbnail %}
<img src="{{ subdir.thumbnail }}" alt="{{ subdir.name }}" class="subdir-thumb">
{% else %}
<span class="subdir-fallback"><i class="fa-solid fa-image"></i></span>
{% endif %}
<span>{{ subdir.name|truncatechars:30 }}</span>
</a>
{% empty %}
<small class="text-secondary">No subdirectories</small>
{% endfor %}
</div>
<form method="post" action="{% url 'logout' %}"> <hr>
{% csrf_token %}
<button type="submit" class="btn btn-sm btn-plain w-100"> <form method="post" action="{% url 'logout' %}">
<i class="fa-solid fa-arrow-up-from-bracket"></i> {% csrf_token %}
<span class="ms-1">Logout</span> <button type="submit" class="btn btn-sm btn-plain w-100">
</button> <i class="fa-solid fa-arrow-up-from-bracket"></i>
</form> <span class="ms-1">Logout</span>
</aside> </button>
</form>
</div> </div>
</div> </div>

View File

@@ -29,53 +29,66 @@
<hr> <hr>
<a href="#" class="sidebar-link">Favorites</a> <a href="/gallery/favorites/" class="sidebar-link {% if is_special and breadcrumbs.0.label == 'Favorites' %}active-sort{% endif %}">Favorites</a>
<a href="#" class="sidebar-link">Most visited</a> <a href="/gallery/most-visited/" class="sidebar-link {% if is_special and breadcrumbs.0.label == 'Most Visited' %}active-sort{% endif %}">Most visited</a>
<a href="#" class="sidebar-link">Recently visited</a> <a href="/gallery/recent/" class="sidebar-link {% if is_special and breadcrumbs.0.label == 'Recent' %}active-sort{% endif %}">Recently visited</a>
<hr> <hr>
<div class="sidebar-scroll flex-grow-1"> <div class="sidebar-scroll flex-grow-1">
{% if prev_url %} {% if not is_special or is_special and 'favorites' in breadcrumbs.0.path %}
<a href="{{ prev_url }}" class="subdir-item"> {% if prev_url %}
{% if prev_thumb %} <a href="{{ prev_url }}" class="subdir-item">
<img src="{{ prev_thumb }}" class="subdir-thumb" alt="prev thumb"> {% if prev_thumb %}
{% else %} <img src="{{ prev_thumb }}" class="subdir-thumb" alt="prev thumb">
<span class="subdir-fallback"><i class="fa-solid fa-image"></i></span> {% else %}
{% endif %} <span class="subdir-fallback"><i class="fa-solid fa-image"></i></span>
<span>Previous</span> {% endif %}
</a> <span>Previous</span>
{% else %} </a>
<div class="subdir-item text-muted">
<span class="subdir-fallback"><i class="fa-solid fa-image"></i></span>
<span>Previous</span>
</div>
{% endif %}
{% if next_url %}
<a href="{{ next_url }}" class="subdir-item">
{% if next_thumb %}
<img src="{{ next_thumb }}" class="subdir-thumb" alt="next thumb">
{% else %}
<span class="subdir-fallback"><i class="fa-solid fa-image"></i></span>
{% endif %}
<span>Next</span>
</a>
{% else %}
<div class="subdir-item text-muted">
<span class="subdir-fallback"><i class="fa-solid fa-image"></i></span>
<span>Next</span>
</div>
{% endif %}
<a href="{{ back_url }}" class="subdir-item">
{% if back_thumb %}
<img src="{{ back_thumb }}" class="subdir-thumb" alt="back thumb">
{% else %} {% else %}
<span class="subdir-fallback"><i class="fa-solid fa-image"></i></span> <div class="subdir-item text-muted">
<span class="subdir-fallback"><i class="fa-solid fa-image"></i></span>
<span>Previous</span>
</div>
{% endif %} {% endif %}
<span>Back</span>
</a> {% if next_url %}
<a href="{{ next_url }}" class="subdir-item">
{% if next_thumb %}
<img src="{{ next_thumb }}" class="subdir-thumb" alt="next thumb">
{% else %}
<span class="subdir-fallback"><i class="fa-solid fa-image"></i></span>
{% endif %}
<span>Next</span>
</a>
{% else %}
<div class="subdir-item text-muted">
<span class="subdir-fallback"><i class="fa-solid fa-image"></i></span>
<span>Next</span>
</div>
{% endif %}
{% endif %}
{% if is_special %}
<a href="{{ breadcrumbs.0.path }}" 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>
{% else %}
<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>
{% endif %}
<a href="{{ home_url }}" class="subdir-item"> <a href="{{ home_url }}" class="subdir-item">
{% if home_thumb %} {% if home_thumb %}
@@ -110,20 +123,31 @@
<span class="crumb-sep">/</span> <span class="crumb-sep">/</span>
{% endif %} {% endif %}
{% if crumb.path %} {% if crumb.path %}
<a href="{{ crumb.path }}" class="crumb-link">{{ crumb.label }}</a> <a href="{{ crumb.path }}" class="{% if not forloop.last %}crumb-link{% else %}crumb-link-last{% endif %}">{{ crumb.label }}</a>
{% else %} {% else %}
<span class="crumb-link-full">{{ crumb.label }}</span> <span class="{% if not forloop.last %}crumb-link{% else %}crumb-link-last{% endif %}">{{ crumb.label }}</span>
{% endif %} {% endif %}
{% 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 }}</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>
{% endif %} {% endif %}
@@ -138,6 +162,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 +177,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>
@@ -157,8 +189,9 @@
<div class="offcanvas offcanvas-start" tabindex="-1" id="sidebarOffcanvas" aria-labelledby="sidebarOffcanvasLabel"> <div class="offcanvas offcanvas-start" tabindex="-1" id="sidebarOffcanvas" aria-labelledby="sidebarOffcanvasLabel">
<div class="offcanvas-header pb-2"> <div class="offcanvas-header pb-2">
<h2 class="h6 mb-0" id="sidebarOffcanvasLabel">Gallery</h2> <div class="d-flex align-items-center user-row w-100 gap-2">
<div class="d-flex align-items-center gap-2"> <span><i class="fa-solid fa-circle-user fa-lg"></i></span>
<span class="user-name w-100 text-truncate">{{ request.user.username }}</span>
<a class="btn btn-sm btn-plain" href="{{ theme_toggle_url }}" aria-label="Toggle theme"> <a class="btn btn-sm btn-plain" href="{{ theme_toggle_url }}" aria-label="Toggle theme">
{% if theme == 'dark' %} {% if theme == 'dark' %}
<i class="fa-solid fa-sun"></i> <i class="fa-solid fa-sun"></i>
@@ -172,21 +205,14 @@
</div> </div>
</div> </div>
<div class="offcanvas-body pt-0 d-flex flex-column"> <div class="offcanvas-body pt-0 d-flex flex-column">
<aside class="sidebar d-flex flex-column"> <a href="/gallery/favorites/" class="sidebar-link {% if is_special and breadcrumbs.0.label == 'Favorites' %}active-sort{% endif %}">Favorites</a>
<div class="d-flex align-items-center user-row"> <a href="/gallery/most-visited/" class="sidebar-link {% if is_special and breadcrumbs.0.label == 'Most Visited' %}active-sort{% endif %}">Most visited</a>
<span><i class="fa-solid fa-circle-user fa-lg"></i></span> <a href="/gallery/recent/" class="sidebar-link {% if is_special and breadcrumbs.0.label == 'Recent' %}active-sort{% endif %}">Recently visited</a>
<span class="user-name text-truncate">{{ request.user.username }}</span>
</div>
<hr> <hr>
<a href="#" class="sidebar-link">Favorites</a> <div class="sidebar-scroll flex-grow-1">
<a href="#" class="sidebar-link">Most visited</a> {% if not is_special or is_special and 'favorites' in breadcrumbs.0.path %}
<a href="#" class="sidebar-link">Recently visited</a>
<hr>
<div class="sidebar-scroll">
{% if prev_url %} {% if prev_url %}
<a href="{{ prev_url }}" class="subdir-item"> <a href="{{ prev_url }}" class="subdir-item">
{% if prev_thumb %} {% if prev_thumb %}
@@ -208,7 +234,18 @@
<span>Next</span> <span>Next</span>
</a> </a>
{% endif %} {% endif %}
{% endif %}
{% if is_special %}
<a href="{{ breadcrumbs.0.path }}" 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>
{% else %}
<a href="{{ back_url }}" class="subdir-item"> <a href="{{ back_url }}" class="subdir-item">
{% if back_thumb %} {% if back_thumb %}
<img src="{{ back_thumb }}" class="subdir-thumb" alt="back thumb"> <img src="{{ back_thumb }}" class="subdir-thumb" alt="back thumb">
@@ -217,27 +254,27 @@
{% endif %} {% endif %}
<span>Back</span> <span>Back</span>
</a> </a>
{% endif %}
<a href="{{ home_url }}" class="subdir-item"> <a href="{{ home_url }}" class="subdir-item">
{% if home_thumb %} {% if home_thumb %}
<img src="{{ home_thumb }}" class="subdir-thumb" alt="home thumb"> <img src="{{ home_thumb }}" class="subdir-thumb" alt="home thumb">
{% else %} {% else %}
<span class="subdir-fallback"><i class="fa-solid fa-image"></i></span> <span class="subdir-fallback"><i class="fa-solid fa-image"></i></span>
{% endif %} {% endif %}
<span>Home</span> <span>Home</span>
</a> </a>
</div> </div>
<hr> <hr>
<form method="post" action="{% url 'logout' %}"> <form method="post" action="{% url 'logout' %}">
{% csrf_token %} {% csrf_token %}
<button type="submit" class="btn btn-sm btn-plain w-100"> <button type="submit" class="btn btn-sm btn-plain w-100">
<i class="fa-solid fa-arrow-up-from-bracket"></i> <i class="fa-solid fa-arrow-up-from-bracket"></i>
<span class="ms-1">Logout</span> <span class="ms-1">Logout</span>
</button> </button>
</form> </form>
</aside>
</div> </div>
</div> </div>

View File

@@ -1,43 +1,74 @@
{% load static %} {% load static form_tags %}
<!DOCTYPE html> <!DOCTYPE html>
<html> <html lang="en">
<head> <head>
<meta name="viewport" content="width=device-width, height=device-height" /> <meta charset="utf-8">
<title> <meta name="viewport" content="width=device-width, initial-scale=1">
NibasaViewer login <title>NibasaViewer — Login</title>
</title> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
<link href="{% static 'css/old_styles.css' %}" rel="stylesheet"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.0.0/css/all.min.css" crossorigin="anonymous" referrerpolicy="no-referrer">
<link href="{% static 'css/styles.css' %}" rel="stylesheet">
</head> </head>
<body class="background"> <body class="theme-{{ theme|default:'dark' }}">
<div class="fixed-width mauto-top">
<form method="post" action="{% url 'login' %}" class="full-width"> <section class="vh-100">
{% csrf_token %} <div class="container py-5 h-100">
<table class="full-width"> <div class="row d-flex justify-content-center align-items-center h-100">
<tr> <div class="col col-xl-10">
<td> <div class="card shadow" style="border-radius: 1rem;">
{{ form.username.label_tag }} <div class="row g-0">
</td> <div class="col-md-6 col-lg-5 d-none d-md-block">
<td> <img src="{% static 'imgs/login.jpg' %}"
{{ form.username }} alt="login image" class="img-fluid" style="height:100%; object-fit:cover; border-radius: 1rem 0 0 1rem;" />
</td> </div>
</tr> <div class="col-md-6 col-lg-7 d-flex align-items-center">
<tr> <div class="card-body p-4 p-lg-5 text-black h-100 login-form">
<td>
{{ form.password.label_tag }} <form method="post" action="{% url 'login' %}" class="w-100">
</td> {% csrf_token %}
<td> {% if form.non_field_errors %}
{{ form.password }} <div class="alert alert-danger">{{ form.non_field_errors }}</div>
</td> {% endif %}
</tr>
<tr> <div class="d-flex align-items-center mb-3 pb-1">
<td colspan="2"> <img src="{% static 'imgs/logo.png' %}" alt="Logo" style="width:44px; height:44px; object-fit:contain;" class="me-3">
<input type="submit" value="login" class="float-right"> <span class="h1 fw-bold mb-0 text-light">Nibasa Viewer</span>
</div>
<h5 class="fw-normal mb-1 mt-4 pb-3" style="letter-spacing: 1px; color:var(--muted);">Sign in</h5>
<div class="mb-4">
{% render_field form.username class="form-control form-control-lg" placeholder="User name" autocomplete="username" %}
{% if form.username.errors %}
<div class="text-danger small mt-1">{{ form.username.errors|striptags }}</div>
{% endif %}
</div>
<div class="mb-4">
{% render_field form.password class="form-control form-control-lg" placeholder="Password" autocomplete="current-password" %}
{% if form.password.errors %}
<div class="text-danger small mt-1">{{ form.password.errors|striptags }}</div>
{% endif %}
</div>
<div class="pt-1 mb-4">
<button class="btn btn-dark btn-lg btn-block w-100" type="submit">LOGIN</button>
</div>
{% if next %}
<input type="hidden" name="next" value="{{ next }}"> <input type="hidden" name="next" value="{{ next }}">
</td> {% endif %}
</tr> </form>
</table>
</form> </div>
</div> </div>
</div>
</div>
</div>
</div>
</div>
</section>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous"></script>
</body> </body>
</html> </html>

View File

View File

@@ -0,0 +1,33 @@
from django import template
from django.utils.safestring import mark_safe
register = template.Library()
@register.simple_tag
def render_field(field, **attrs):
"""Render a bound field with temporary widget attributes.
Usage in template:
{% render_field form.username class="form-control" placeholder="Email" %}
This updates the widget attrs just for the duration of rendering and
restores the original attrs afterwards to avoid side effects.
"""
try:
widget = field.field.widget
except Exception:
return ""
# preserve original attrs and update temporarily
original_attrs = widget.attrs.copy()
# Convert all attr values to strings (template passes them as strings)
for k, v in attrs.items():
widget.attrs[str(k)] = str(v)
rendered = field.as_widget()
# restore original attrs
widget.attrs = original_attrs
return mark_safe(rendered)

View File

@@ -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")

173
viewer/test_specials.py Normal file
View File

@@ -0,0 +1,173 @@
from pathlib import Path
import tempfile
from django.test import TestCase, override_settings
from django.contrib.auth.models import User
from django.utils import timezone
from viewer.models import Image as Im
class SpecialGalleriesTests(TestCase):
def setUp(self):
# prepare a temporary gallery root and files
self.tmp_gallery = tempfile.TemporaryDirectory()
self.gallery_root = Path(self.tmp_gallery.name)
self.settings_override = override_settings(GALLERY_ROOT=self.gallery_root)
self.settings_override.enable()
self.user = User.objects.create_user("specuser", "s@example.com", "pw")
# create files under gallery root
now = timezone.now()
a = self.gallery_root / "a.jpg"
b = self.gallery_root / "b.jpg"
c = self.gallery_root / "c.jpg"
for p in (a, b, c):
p.write_bytes(b"x")
Im.objects.create(
user=self.user, path=str(a), favorite=True, visits=5, last_visited=now
)
Im.objects.create(
user=self.user, path=str(b), favorite=False, visits=10, last_visited=now
)
Im.objects.create(
user=self.user, path=str(c), favorite=True, visits=2, last_visited=now
)
# client for view tests
from django.test import Client
self.client = Client()
self.client.force_login(self.user)
def tearDown(self):
self.settings_override.disable()
self.tmp_gallery.cleanup()
def test_get_special_paths_filters_and_orders(self):
from viewer.specials import get_special_paths
favs = get_special_paths(self.user, "favorites")
# favorites should include only those marked favorite (a and c)
fav_names = sorted([p.name for p in favs])
self.assertEqual(fav_names, ["a.jpg", "c.jpg"])
most = get_special_paths(self.user, "most-visited")
# most-visited should be ordered descending by visits, expect b.jpg first
self.assertGreaterEqual(len(most), 1)
self.assertEqual(most[0].name, "b.jpg")
def test_favorites_directory_view_and_links(self):
# Directory listing for favorites should show only favorite images
resp = self.client.get("/gallery/favorites/")
self.assertEqual(resp.status_code, 200)
ctx = resp.context
self.assertTrue(ctx.get("is_special"))
names = [img["name"] for img in ctx.get("images")]
self.assertEqual(sorted(names), ["a.jpg", "c.jpg"])
# Back should be hidden (None) and home present
self.assertIsNone(ctx.get("back_url"))
self.assertIsNotNone(ctx.get("home_url"))
self.assertTrue(str(ctx.get("home_url")).startswith("/gallery/"))
# Breadcrumb should point to the special root
breadcrumbs = ctx.get("breadcrumbs")
self.assertEqual(breadcrumbs[0]["label"], "Favorites")
self.assertTrue(breadcrumbs[0]["path"].startswith("/gallery/favorites/"))
def test_favorites_image_view_prev_next_and_breadcrumbs(self):
# Image view under special gallery should scope prev/next to favorites only
resp = self.client.get("/gallery/favorites/a.jpg/")
self.assertEqual(resp.status_code, 200)
ctx = resp.context
self.assertTrue(ctx.get("is_special"))
# Breadcrumbs: first is special root (clickable), last is filename non-clickable
crumbs = ctx.get("breadcrumbs")
self.assertEqual(crumbs[0]["label"], "Favorites")
self.assertIsNotNone(crumbs[0]["path"])
self.assertEqual(crumbs[-1]["label"], "a.jpg")
self.assertIsNone(crumbs[-1]["path"])
# Prev should be None for first in alphabetical order among favorites
self.assertIsNone(ctx.get("prev"))
# Next should be c.jpg (since favorites are a.jpg and c.jpg sorted)
self.assertIn("c.jpg", ctx.get("next"))
def test_favorites_image_view_prev_next_urls_and_back_link(self):
# Image view under favorites should produce prev/next URLs scoped to favorites
resp = self.client.get("/gallery/favorites/a.jpg/")
self.assertEqual(resp.status_code, 200)
ctx = resp.context
self.assertTrue(ctx.get("is_special"))
crumbs = ctx.get("breadcrumbs")
# back link for template should point to the special root
self.assertTrue(crumbs[0]["path"].startswith("/gallery/favorites/"))
# prev_url should be None for first favorite; next_url should be inside favorites
self.assertIsNone(ctx.get("prev_url"))
next_url = ctx.get("next_url")
self.assertIsNotNone(next_url)
self.assertTrue(next_url.startswith("/gallery/favorites/"))
self.assertIn("c.jpg", next_url)
def test_most_visited_image_view_prev_next_urls(self):
# most-visited image view should scope prev/next URLs to the most-visited gallery
resp = self.client.get("/gallery/most-visited/b.jpg/")
self.assertEqual(resp.status_code, 200)
ctx = resp.context
self.assertTrue(ctx.get("is_special"))
crumbs = ctx.get("breadcrumbs")
self.assertTrue(crumbs[0]["path"].startswith("/gallery/most-visited/"))
# b.jpg is the top most-visited image in fixtures => prev_url None, next_url points to a.jpg
self.assertIsNone(ctx.get("prev_url"))
next_url = ctx.get("next_url")
self.assertIsNotNone(next_url)
self.assertTrue(next_url.startswith("/gallery/most-visited/"))
self.assertIn("a.jpg", next_url)
def test_most_visited_and_recent_directory_views(self):
# most-visited should list images ordered by visits desc
resp = self.client.get("/gallery/most-visited/")
self.assertEqual(resp.status_code, 200)
names = [img["name"] for img in resp.context.get("images")]
# b.jpg had visits=10, should be first
self.assertEqual(names[0], "b.jpg")
# recent: modify last_visited so that c is newest
from viewer.models import Image as ImModel
now = timezone.now()
# update c to be most recent
ImModel.objects.filter(path=str(self.gallery_root / "c.jpg")).update(
last_visited=now
)
resp2 = self.client.get("/gallery/recent/")
self.assertEqual(resp2.status_code, 200)
recent_names = [img["name"] for img in resp2.context.get("images")]
# c.jpg should be first in recent
self.assertEqual(recent_names[0], "c.jpg")
def test_missing_and_outside_paths_are_skipped(self):
# Create an Image row pointing to a missing file inside gallery
from viewer.models import Image as ImModel
missing_path = self.gallery_root / "missing.jpg"
ImModel.objects.create(
user=self.user, path=str(missing_path), favorite=True, visits=1
)
# Create a file outside gallery root and an Image row pointing to it
outside_tmp = tempfile.NamedTemporaryFile(delete=False)
outside_tmp.write(b"x")
outside_tmp.flush()
outside_tmp.close()
ImModel.objects.create(
user=self.user, path=str(Path(outside_tmp.name)), favorite=True, visits=1
)
# favorites listing should still only include existing gallery files (a.jpg and c.jpg)
resp = self.client.get("/gallery/favorites/")
self.assertEqual(resp.status_code, 200)
names = sorted([img["name"] for img in resp.context.get("images")])
self.assertEqual(names, ["a.jpg", "c.jpg"])

View File

@@ -1,10 +1,8 @@
# Django imports # Django imports
from django.urls import path from django.urls import path, re_path
# Module imports # Module imports
from .views import ( from .views import gallery_view, toggle_settings, special_gallery_view
gallery_view
)
########################################################################################### ###########################################################################################
# URL Patterns. # # URL Patterns. #
@@ -13,6 +11,19 @@ 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"),
# Special gallery routes (explicit list to avoid clashing with regular paths)
re_path(
r"^(?P<gallery>favorites|most-visited|recent)/(?P<path>.*)/$",
special_gallery_view,
name="special_gallery_view_path",
),
re_path(
r"^(?P<gallery>favorites|most-visited|recent)/$",
special_gallery_view,
name="special_gallery_view_root",
),
# Generic gallery path (catch-all for filesystem paths)
path("<path:path>/", gallery_view, name="gallery_view_path"),
] ]

View File

@@ -1,275 +1,26 @@
# 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) SPECIALS = ['favorites', 'most-visited', 'recent']
###########################################################################################
# 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 +28,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 +49,94 @@ 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 special_gallery_view(request, gallery, path=None):
"""
Shows a list of subdirectories and image files inside the given special gallery.
Available special galleries are: (1) favorites; (2) most visited; (3) recently visited.
"""
root = settings.GALLERY_ROOT.resolve()
if gallery not in SPECIALS:
return HttpResponseNotFound("Not found")
try:
candidate = root.joinpath(path).resolve() if path is not None else root
candidate.relative_to(root)
except Exception:
return HttpResponseNotFound("Not found")
path_text = path if path is not None else ""
if path is not None:
# Special galleries only have a logical root directory.
# When there is a valid sub-directory path inside the gallery root
# in the request then redirect to the base special gallery.
if candidate.is_dir():
return redirect('special_gallery_view_path', gallery, None, permanent=True)
else:
# When there is no path to render, then go to the corresponding special gallery root.
return render_directory(request, path_text, candidate, gallery)
# Fail if the requested image doesn' exist.
if not candidate.exists():
return HttpResponseNotFound("Not found")
# Images are rendered normally, with a control to ensure the back, next and previous buttons
# use the special gallery.
return render_image(request, path_text, candidate, gallery)
@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)