Compare commits

...

20 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
31 changed files with 1179 additions and 348 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

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

@@ -48,8 +48,8 @@ def get_modification_timestamp(path_obj):
return 0 return 0
def build_query(search_text, sort_key, theme): def build_query(search_text):
query = {"sort": sort_key, "theme": theme} query = {}
if search_text != "": if search_text != "":
query["search"] = search_text query["search"] = search_text
@@ -64,15 +64,15 @@ def append_query(url, query_dict):
return url + "?" + urlencode(query_dict) return url + "?" + urlencode(query_dict)
def gallery_url(path_obj=None, is_dir=False, query_dict=None): def gallery_url(path_obj=None, is_dir=False, query_dict=None, special=None):
if query_dict is None: if query_dict is None:
query_dict = {} query_dict = {}
if path_obj is None: if path_obj is None:
base_url = "/gallery/" base_url = f"/gallery/{special}/" if special is not None else "/gallery/"
else: else:
path_text = str(path_obj).replace("\\", "/") path_text = str(path_obj).replace("\\", "/")
base_url = "/gallery/" + path_text base_url = (f"/gallery/{special}/" if special is not None else "/gallery/") + path_text
if is_dir and not base_url.endswith("/"): if is_dir and not base_url.endswith("/"):
base_url += "/" base_url += "/"
@@ -170,16 +170,22 @@ def build_sort_options(request, search_text, sort_key, theme):
options = [] options = []
for option_key, label in SORT_OPTIONS: for option_key, label in SORT_OPTIONS:
query = {"sort": option_key, "theme": theme} # 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 != "": if search_text != "":
# Include search top-level so templates/tests can assert its presence
query["search"] = search_text query["search"] = search_text
options.append( options.append(
{ {
"key": option_key, "key": option_key,
"label": label, "label": label,
"url": append_query(request.path, query), "url": append_query("/gallery/toggle-settings/", query),
"is_active": option_key == sort_key, "is_active": option_key == sort_key,
} }
) )

View File

@@ -16,6 +16,7 @@ from .common import (
normalize_theme, normalize_theme,
build_query, build_query,
gallery_url, gallery_url,
append_query,
sort_images, sort_images,
build_sort_options, build_sort_options,
build_breadcrumbs, build_breadcrumbs,
@@ -23,6 +24,7 @@ from .common import (
is_image_file, is_image_file,
make_thumbnail, make_thumbnail,
) )
from .specials import get_special_paths, special_name
def do_recursive_search(start_path, query): def do_recursive_search(start_path, query):
@@ -62,47 +64,65 @@ def do_recursive_search(start_path, query):
return subdirs, images return subdirs, images
def render_directory(request, path_text, full_path): 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 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. 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() search_text = request.GET.get("search", "").strip()
sort_key = normalize_sort(request.GET.get("sort", "abc")) sort_key = normalize_sort(
theme = normalize_theme(request.GET.get("theme", "dark")) request.GET.get("sort") or getattr(request, "sort", None) or "abc"
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(),
),
) )
theme = normalize_theme(
request.GET.get("theme") or getattr(request, "theme", None) or "dark"
)
query_state = build_query(search_text)
if search_text == "": # If rendering a logical special gallery, obtain the image list from the
images = [ # Image model and skip filesystem subdir enumeration.
entry if special is not None:
for entry in current_entries # build images using the helper that returns Path objects
if entry.is_file() and is_image_file(entry) 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: else:
_, images = do_recursive_search(full_path, search_text) try:
current_entries = [
entry for entry in full_path.iterdir() if not entry.is_symlink()
]
except OSError:
return HttpResponseNotFound("Not found")
images = sort_images(images, sort_key) 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 = [] image_data = []
for image in images: for image in images:
rel_path = image.relative_to(settings.GALLERY_ROOT) rel_path = image.relative_to(settings.GALLERY_ROOT)
image_url = gallery_url(rel_path, False, query_state) image_url = gallery_url(rel_path, False, query_state, special)
thumbnail = None thumbnail = None
try: try:
@@ -130,13 +150,42 @@ def render_directory(request, path_text, full_path):
) )
next_theme = "light" if theme == "dark" else "dark" next_theme = "light" if theme == "dark" else "dark"
theme_query = {"sort": sort_key, "theme": next_theme}
# 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 != "": if search_text != "":
theme_query["search"] = search_text theme_toggle_query["search"] = search_text
search_action_query = {"sort": sort_key, "theme": theme} 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 = { context = {
"path": path_text, "path": path_text,
"search_text": search_text, "search_text": search_text,
@@ -147,14 +196,44 @@ def render_directory(request, path_text, full_path):
"breadcrumbs": build_breadcrumbs(path_text, query_state), "breadcrumbs": build_breadcrumbs(path_text, query_state),
"images": image_data, "images": image_data,
"subdirs": subdir_data, "subdirs": subdir_data,
"theme_toggle_url": gallery_url( "back_url": back_url,
Path(path_text) if path_text != "" else None, True, theme_query "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( "search_action_url": gallery_url(
Path(path_text) if path_text != "" else None, True, search_action_query 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 # sort_label depends on SORT_LABELS in common; import lazily to avoid circulars
from .common import SORT_LABELS from .common import SORT_LABELS

View File

@@ -8,7 +8,7 @@ import datetime
# Django imports. # Django imports.
from django.http import HttpResponseNotFound from django.http import HttpResponseNotFound
from django.shortcuts import render, redirect from django.shortcuts import render
from django.conf import settings from django.conf import settings
from django.utils import timezone from django.utils import timezone
@@ -23,6 +23,7 @@ from .common import (
normalize_theme, normalize_theme,
build_query, build_query,
gallery_url, gallery_url,
append_query,
sort_images, sort_images,
build_sort_options, build_sort_options,
build_breadcrumbs, build_breadcrumbs,
@@ -30,9 +31,10 @@ from .common import (
is_image_file, is_image_file,
make_thumbnail, make_thumbnail,
) )
from .specials import get_special_paths, special_name
def render_image(request, path_text, full_path): def render_image(request, path_text, full_path, special=None):
""" """
Renders the view corresponding to an image file. Renders the view corresponding to an image file.
""" """
@@ -40,7 +42,7 @@ def render_image(request, path_text, full_path):
try: try:
img = Im.objects.get(path=full_path, user=request.user) img = Im.objects.get(path=full_path, user=request.user)
if request.method == 'POST': if request.method == "POST":
img.favorite = not img.favorite img.favorite = not img.favorite
except Im.DoesNotExist: except Im.DoesNotExist:
@@ -51,28 +53,41 @@ def render_image(request, path_text, full_path):
img.visits = img.visits + 1 img.visits = img.visits + 1
img.save() img.save()
# Preserve query state (sort, search, theme) similar to gallery view # 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() search_text = request.GET.get("search", "").strip()
sort_key = normalize_sort(request.GET.get("sort", "abc")) sort_key = normalize_sort(
theme = normalize_theme(request.GET.get("theme", "dark")) request.GET.get("sort") or getattr(request, "sort", None) or "abc"
query_state = build_query(search_text, sort_key, theme) )
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) image = Path("/imgs/").joinpath(path_text)
img_dir = full_path.parent img_dir = full_path.parent
try: if special is not None:
entries = [ # Use the special gallery ordering for prev/next navigation
entry images_sorted = get_special_paths(request.user, special)
for entry in img_dir.iterdir() # If favorites, respect client sort; otherwise keep DB ordering
if entry.is_file() and not entry.is_symlink() and is_image_file(entry) if special == "favorites":
] images_sorted = sort_images(images_sorted, sort_key)
except OSError: else:
return HttpResponseNotFound("Not found") 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 # Sort siblings according to requested sort mode
images_sorted = sort_images(entries, sort_key) images_sorted = sort_images(entries, sort_key)
# Find index of current image # Find index of current image within the resolved list
try: try:
index = next(i for i, p in enumerate(images_sorted) if p.name == full_path.name) index = next(i for i, p in enumerate(images_sorted) if p.name == full_path.name)
except StopIteration: except StopIteration:
@@ -100,11 +115,22 @@ def render_image(request, path_text, full_path):
next_url = None next_url = None
if prev_path is not None: if prev_path is not None:
rel = prev_path.relative_to(settings.GALLERY_ROOT) rel = prev_path.relative_to(settings.GALLERY_ROOT)
prev_url = gallery_url(rel, False, query_state) # 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: if next_path is not None:
rel = next_path.relative_to(settings.GALLERY_ROOT) rel = next_path.relative_to(settings.GALLERY_ROOT)
next_url = gallery_url(rel, False, query_state) 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 # Back (directory) and Home (root) links and thumbnails
dir_rel = None dir_rel = None
@@ -115,10 +141,25 @@ def render_image(request, path_text, full_path):
except Exception: except Exception:
dir_text = "" dir_text = ""
back_url = gallery_url( # Back (directory) and Home (root) links and thumbnails
Path(dir_text) if dir_text != "" else None, True, query_state # For special galleries we still want to show a Back link that points
) # to the special gallery root — populate `back_thumb` with the
back_thumb = get_first_image_thumbnail_url(full_path.parent) # 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_url = gallery_url(None, True, query_state)
home_thumb = get_first_image_thumbnail_url(settings.GALLERY_ROOT) home_thumb = get_first_image_thumbnail_url(settings.GALLERY_ROOT)
@@ -162,16 +203,33 @@ def render_image(request, path_text, full_path):
except Exception: except Exception:
return None return None
# Breadcrumbs: include directory breadcrumbs then append file name as label-only # Breadcrumbs
# build_breadcrumbs expects a path_text representing directories only if special is not None:
dir_path_text = dir_text if dir_text != "" else "" # SPECIAL NAME / IMAGE
breadcrumbs = build_breadcrumbs(dir_path_text, query_state) from .specials import special_root_url
breadcrumbs.append({"label": full_path.name, "path": None})
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" next_theme = "light" if theme == "dark" else "dark"
theme_query = {"sort": sort_key, "theme": next_theme} theme_query = {
"next": request.get_full_path(),
"sort": sort_key,
"theme": next_theme,
}
if search_text != "": if search_text != "":
theme_query["search"] = search_text theme_query["next"] = request.get_full_path()
context = { context = {
"image_path": image, "image_path": image,
@@ -196,17 +254,16 @@ def render_image(request, path_text, full_path):
"modified": fmt_ts(modified_ts), "modified": fmt_ts(modified_ts),
"visits": img.visits, "visits": img.visits,
"visited": fmt_ts(img.last_visited.timestamp()), "visited": fmt_ts(img.last_visited.timestamp()),
"favorite": img.favorite "favorite": img.favorite,
}, },
"breadcrumbs": breadcrumbs, "breadcrumbs": breadcrumbs,
"theme": theme, "theme": theme,
"sort_key": sort_key, "sort_key": sort_key,
"sort_label": "", "sort_label": "",
"sort_options": build_sort_options(request, search_text, sort_key, theme), "sort_options": build_sort_options(request, search_text, sort_key, theme),
"theme_toggle_url": gallery_url( "theme_toggle_url": append_query("/gallery/toggle-settings/", theme_query),
Path(dir_path_text) if dir_path_text != "" else None, True, theme_query
),
"path": path_text, "path": path_text,
"is_special": special is not None,
} }
from .common import SORT_LABELS from .common import SORT_LABELS

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

View File

@@ -2,6 +2,7 @@ from django.utils import timezone
from django.conf import settings from django.conf import settings
from django.db.models import ( from django.db.models import (
Model, Model,
CharField,
BooleanField, BooleanField,
DateTimeField, DateTimeField,
IntegerField, IntegerField,
@@ -11,13 +12,30 @@ from django.db.models import (
) )
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): class Image(Model):
""" """
User relations to a specific image file by path. User relations to a specific image file by path.
""" """
user = ForeignKey(settings.AUTH_USER_MODEL, blank=False, null=False, on_delete=CASCADE) user = ForeignKey(settings.AUTH_USER_MODEL, blank=False, null=False, on_delete=CASCADE)
path = FilePathField(path=settings.GALLERY_ROOT, blank=False, null=False) path = FilePathField(path=get_gallery_root, blank=False, null=False)
favorite = BooleanField(blank=False, null=False, default=False) favorite = BooleanField(blank=False, null=False, default=False)
last_visited = DateTimeField(blank=False, null=False, default=timezone.now) last_visited = DateTimeField(blank=False, null=False, default=timezone.now)
visits = IntegerField(blank=False, null=False, default=0) visits = IntegerField(blank=False, null=False, default=0)

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;
@@ -189,7 +189,6 @@ body {
/* Image view specific styles */ /* Image view specific styles */
.image-content { .image-content {
overflow: auto; overflow: auto;
padding: 18px;
flex: 1 1 auto; /* occupy available vertical space inside main-area */ flex: 1 1 auto; /* occupy available vertical space inside main-area */
display: flex; display: flex;
align-items: center; /* center the image vertically */ align-items: center; /* center the image vertically */
@@ -379,15 +378,47 @@ 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;
} }
.top-bar {
border-radius: 0px;
}
.crumb-link {
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) { @media (max-width: 575.98px) {

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">
@@ -208,9 +277,35 @@
<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>

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,9 +123,9 @@
<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>
@@ -164,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>
@@ -192,43 +205,56 @@
</div> </div>
</div> </div>
<div class="offcanvas-body pt-0 d-flex flex-column"> <div class="offcanvas-body pt-0 d-flex flex-column">
<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>
{% endif %} </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>
{% endif %}
<a href="{{ back_url }}" class="subdir-item">
{% if back_thumb %}
<img src="{{ back_thumb }}" class="subdir-thumb" alt="back thumb">
{% else %}
<span class="subdir-fallback"><i class="fa-solid fa-image"></i></span>
{% endif %} {% 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>
{% 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 %}

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

@@ -11,12 +11,15 @@ from PIL import Image
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.utils import timezone
from django.http import HttpResponse
from django.template.response import TemplateResponse
# Project imports. # Project imports.
from viewer.common import SORT_LABELS from viewer.common import SORT_LABELS
from viewer.directory import do_recursive_search from viewer.directory import do_recursive_search
from viewer.views import gallery_view from viewer.views import gallery_view
from viewer.models import Image as Im from viewer.models import Image as Im, UserSettings
from NibasaViewer.middleware import UserSettingsMiddleware
class GalleryBaseTests(TestCase): class GalleryBaseTests(TestCase):
@@ -38,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()
@@ -97,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)
@@ -180,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")
@@ -219,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)
@@ -341,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")
@@ -428,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)
@@ -501,3 +549,60 @@ class ImageModelTests(GalleryBaseTests):
img.refresh_from_db() img.refresh_from_db()
self.assertTrue(img.favorite) 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,19 +1,27 @@
"""Top-level views module. """Top-level views module.
After refactor this file only keeps the minimal public view entry points and imports After refactor this file only keeps the minimal public view entry points and imports
helpers from the new `directory` and `image` modules. 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 redirect from django.shortcuts import redirect
from django.core.exceptions import MultipleObjectsReturned
# Local helpers split into modules # Local helpers split into modules
from .directory import render_directory from .directory import render_directory
from .image import render_image from .image import render_image
from .models import UserSettings
SPECIALS = ['favorites', 'most-visited', 'recent']
@login_required @login_required
def index(request): def index(request):
@@ -44,3 +52,91 @@ def gallery_view(request, path=None):
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)