Compare commits
9 Commits
24c1c96f19
...
1.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 314ba461c0 | |||
| f8a4f71e74 | |||
| 7cc9d1ed17 | |||
| 21a3ab40c5 | |||
| 0e48a5d9bd | |||
| 701845dceb | |||
| 97aa42661a | |||
| 5f1035a252 | |||
| 532690a329 |
1
.gitignore
vendored
@@ -364,3 +364,4 @@ Temporary Items
|
||||
.apdisk
|
||||
|
||||
.kateproject*
|
||||
.*.kate-swp
|
||||
|
||||
@@ -64,15 +64,15 @@ def append_query(url, 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:
|
||||
query_dict = {}
|
||||
|
||||
if path_obj is None:
|
||||
base_url = "/gallery/"
|
||||
base_url = f"/gallery/{special}/" if special is not None else "/gallery/"
|
||||
else:
|
||||
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("/"):
|
||||
base_url += "/"
|
||||
|
||||
@@ -24,6 +24,7 @@ from .common import (
|
||||
is_image_file,
|
||||
make_thumbnail,
|
||||
)
|
||||
from .specials import get_special_paths, special_name
|
||||
|
||||
|
||||
def do_recursive_search(start_path, query):
|
||||
@@ -63,7 +64,7 @@ def do_recursive_search(start_path, query):
|
||||
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
|
||||
in the file system, or logical gallery directories like search result pages.
|
||||
@@ -81,36 +82,47 @@ def render_directory(request, path_text, full_path):
|
||||
)
|
||||
query_state = build_query(search_text)
|
||||
|
||||
try:
|
||||
current_entries = [
|
||||
entry for entry in full_path.iterdir() if not entry.is_symlink()
|
||||
]
|
||||
except OSError:
|
||||
return HttpResponseNotFound("Not found")
|
||||
|
||||
current_subdirs = sorted(
|
||||
[entry for entry in current_entries if entry.is_dir()],
|
||||
key=lambda item: (
|
||||
item.name.lower(),
|
||||
str(item.relative_to(settings.GALLERY_ROOT)).lower(),
|
||||
),
|
||||
)
|
||||
|
||||
if search_text == "":
|
||||
images = [
|
||||
entry
|
||||
for entry in current_entries
|
||||
if entry.is_file() and is_image_file(entry)
|
||||
]
|
||||
# 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:
|
||||
_, 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 = []
|
||||
for image in images:
|
||||
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
|
||||
try:
|
||||
@@ -162,10 +174,15 @@ def render_directory(request, path_text, full_path):
|
||||
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)
|
||||
# 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)
|
||||
|
||||
@@ -189,11 +206,34 @@ def render_directory(request, path_text, full_path):
|
||||
"search_action_url": gallery_url(
|
||||
Path(path_text) if path_text != "" else None, True, search_action_query
|
||||
),
|
||||
"clear_search_url": gallery_url(
|
||||
"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
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import datetime
|
||||
|
||||
# Django imports.
|
||||
from django.http import HttpResponseNotFound
|
||||
from django.shortcuts import render, redirect
|
||||
from django.shortcuts import render
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
|
||||
@@ -31,9 +31,10 @@ from .common import (
|
||||
is_image_file,
|
||||
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.
|
||||
"""
|
||||
@@ -67,19 +68,26 @@ def render_image(request, path_text, full_path):
|
||||
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")
|
||||
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)
|
||||
# Sort siblings according to requested sort mode
|
||||
images_sorted = sort_images(entries, sort_key)
|
||||
|
||||
# Find index of current image
|
||||
# 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:
|
||||
@@ -107,11 +115,22 @@ def render_image(request, path_text, full_path):
|
||||
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)
|
||||
# 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)
|
||||
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
|
||||
dir_rel = None
|
||||
@@ -122,10 +141,25 @@ def render_image(request, path_text, full_path):
|
||||
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)
|
||||
# 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)
|
||||
|
||||
@@ -169,11 +203,24 @@ def render_image(request, path_text, full_path):
|
||||
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})
|
||||
# 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 = {
|
||||
@@ -216,6 +263,7 @@ def render_image(request, path_text, full_path):
|
||||
"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
|
||||
|
||||
71
viewer/specials.py
Normal 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
|
||||
@@ -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;
|
||||
}
|
||||
@@ -378,7 +378,16 @@ body.theme-dark .small.text-muted {
|
||||
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 {
|
||||
padding: 0px;
|
||||
gap: 12px;
|
||||
@@ -405,6 +414,12 @@ body.theme-dark .small.text-muted {
|
||||
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
|
||||
|
||||
|
Before Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 22 KiB |
BIN
viewer/static/imgs/login.jpg
Normal file
|
After Width: | Height: | Size: 604 KiB |
BIN
viewer/static/imgs/login.png
Normal file
|
After Width: | Height: | Size: 2.6 MiB |
BIN
viewer/static/imgs/logo.png
Normal file
|
After Width: | Height: | Size: 159 KiB |
|
Before Width: | Height: | Size: 10 KiB |
@@ -29,6 +29,7 @@
|
||||
|
||||
<hr>
|
||||
|
||||
{% if not is_special %}
|
||||
<form action="{{ search_action_url }}" method="get">
|
||||
<div class="input-group input-group-sm">
|
||||
<input type="search" name="search" class="form-control search-input" placeholder="Search..." value="{{ search_text }}">
|
||||
@@ -40,16 +41,19 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
<hr>
|
||||
|
||||
<a href="#" class="sidebar-link">Favorites</a>
|
||||
<a href="#" class="sidebar-link">Most visited</a>
|
||||
<a href="#" class="sidebar-link">Recently visited</a>
|
||||
<a href="/gallery/favorites/" class="sidebar-link {% if active_special == 'favorites' %}active-sort{% endif %}">Favorites</a>
|
||||
<a href="/gallery/most-visited/" class="sidebar-link {% if active_special == 'most-visited' %}active-sort{% endif %}">Most 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 != '' %}
|
||||
<hr>
|
||||
|
||||
<a href="{{ back_url }}" class="subdir-item">
|
||||
{% if back_thumb %}
|
||||
<img src="{{ back_thumb }}" class="subdir-thumb" alt="back thumb">
|
||||
@@ -58,7 +62,9 @@
|
||||
{% 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">
|
||||
@@ -112,22 +118,26 @@
|
||||
|
||||
<hr>
|
||||
|
||||
{% if not is_special %}
|
||||
<form action="{{ search_action_url }}" method="get">
|
||||
<div class="input-group input-group-sm">
|
||||
<input type="search" name="search" class="form-control search-input" placeholder="Search..." value="{{ search_text }}">
|
||||
<span class="input-group-text search-addon"><i class="fa-solid fa-magnifying-glass"></i></span>
|
||||
</div>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
<hr>
|
||||
|
||||
<a href="#" class="sidebar-link">Favorites</a>
|
||||
<a href="#" class="sidebar-link">Most visited</a>
|
||||
<a href="#" class="sidebar-link">Recently visited</a>
|
||||
<a href="/gallery/favorites/" class="sidebar-link {% if active_special == 'favorites' %}active-sort{% endif %}">Favorites</a>
|
||||
<a href="/gallery/most-visited/" class="sidebar-link {% if active_special == 'most-visited' %}active-sort{% endif %}">Most 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 != '' %}
|
||||
<hr>
|
||||
|
||||
<a href="{{ back_url }}" class="subdir-item">
|
||||
{% if back_thumb %}
|
||||
<img src="{{ back_thumb }}" class="subdir-thumb" alt="back thumb">
|
||||
@@ -136,7 +146,9 @@
|
||||
{% 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">
|
||||
@@ -186,10 +198,15 @@
|
||||
{% if not forloop.first %}
|
||||
<span class="crumb-sep">/</span>
|
||||
{% endif %}
|
||||
<a href="{{ crumb.path }}" class="{% if not forloop.last %}crumb-link{% else %}crumb-link-last{% endif %}">{{ 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 %}
|
||||
</div>
|
||||
|
||||
{% if not is_special %}
|
||||
<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">
|
||||
<i class="fa-solid fa-arrow-down-short-wide"></i>
|
||||
@@ -202,8 +219,10 @@
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if not is_special %}
|
||||
<noscript>
|
||||
<div class="pt-2">
|
||||
{% for option in sort_options %}
|
||||
@@ -211,6 +230,7 @@
|
||||
{% endfor %}
|
||||
</div>
|
||||
</noscript>
|
||||
{% endif %}
|
||||
|
||||
<section class="gallery-scroll flex-grow-1">
|
||||
<div class="gallery-grid">
|
||||
@@ -257,13 +277,15 @@
|
||||
|
||||
<hr>
|
||||
|
||||
<a href="#" class="sidebar-link">Favorites</a>
|
||||
<a href="#" class="sidebar-link">Most visited</a>
|
||||
<a href="#" class="sidebar-link">Recently visited</a>
|
||||
<a href="/gallery/favorites/" class="sidebar-link {% if active_special == 'favorites' %}active-sort{% endif %}">Favorites</a>
|
||||
<a href="/gallery/most-visited/" class="sidebar-link {% if active_special == 'most-visited' %}active-sort{% endif %}">Most 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 != '' %}
|
||||
<hr>
|
||||
|
||||
<a href="{{ back_url }}" class="subdir-item">
|
||||
{% if back_thumb %}
|
||||
<img src="{{ back_thumb }}" class="subdir-thumb" alt="back thumb">
|
||||
@@ -272,7 +294,9 @@
|
||||
{% 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">
|
||||
|
||||
@@ -29,53 +29,66 @@
|
||||
|
||||
<hr>
|
||||
|
||||
<a href="#" class="sidebar-link">Favorites</a>
|
||||
<a href="#" class="sidebar-link">Most visited</a>
|
||||
<a href="#" class="sidebar-link">Recently visited</a>
|
||||
<a href="/gallery/favorites/" class="sidebar-link {% if is_special and breadcrumbs.0.label == 'Favorites' %}active-sort{% endif %}">Favorites</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="/gallery/recent/" class="sidebar-link {% if is_special and breadcrumbs.0.label == 'Recent' %}active-sort{% endif %}">Recently visited</a>
|
||||
|
||||
<hr>
|
||||
|
||||
<div class="sidebar-scroll flex-grow-1">
|
||||
{% if prev_url %}
|
||||
<a href="{{ prev_url }}" class="subdir-item">
|
||||
{% if prev_thumb %}
|
||||
<img src="{{ prev_thumb }}" class="subdir-thumb" alt="prev thumb">
|
||||
{% else %}
|
||||
<span class="subdir-fallback"><i class="fa-solid fa-image"></i></span>
|
||||
{% endif %}
|
||||
<span>Previous</span>
|
||||
</a>
|
||||
{% else %}
|
||||
<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">
|
||||
{% if not is_special or is_special and 'favorites' in breadcrumbs.0.path %}
|
||||
{% if prev_url %}
|
||||
<a href="{{ prev_url }}" class="subdir-item">
|
||||
{% if prev_thumb %}
|
||||
<img src="{{ prev_thumb }}" class="subdir-thumb" alt="prev thumb">
|
||||
{% else %}
|
||||
<span class="subdir-fallback"><i class="fa-solid fa-image"></i></span>
|
||||
{% endif %}
|
||||
<span>Previous</span>
|
||||
</a>
|
||||
{% 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 %}
|
||||
<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">
|
||||
{% if home_thumb %}
|
||||
@@ -110,9 +123,9 @@
|
||||
<span class="crumb-sep">/</span>
|
||||
{% endif %}
|
||||
{% 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 %}
|
||||
<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 %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
@@ -192,43 +205,56 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="offcanvas-body pt-0 d-flex flex-column">
|
||||
<a href="#" class="sidebar-link">Favorites</a>
|
||||
<a href="#" class="sidebar-link">Most visited</a>
|
||||
<a href="#" class="sidebar-link">Recently visited</a>
|
||||
<a href="/gallery/favorites/" class="sidebar-link {% if is_special and breadcrumbs.0.label == 'Favorites' %}active-sort{% endif %}">Favorites</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="/gallery/recent/" class="sidebar-link {% if is_special and breadcrumbs.0.label == 'Recent' %}active-sort{% endif %}">Recently visited</a>
|
||||
|
||||
<hr>
|
||||
|
||||
<div class="sidebar-scroll flex-grow-1">
|
||||
{% if prev_url %}
|
||||
<a href="{{ prev_url }}" class="subdir-item">
|
||||
{% if prev_thumb %}
|
||||
<img src="{{ prev_thumb }}" class="subdir-thumb" alt="prev thumb">
|
||||
{% else %}
|
||||
<span class="subdir-fallback"><i class="fa-solid fa-image"></i></span>
|
||||
{% endif %}
|
||||
<span>Previous</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{% if next_url %}
|
||||
<a href="{{ next_url }}" class="subdir-item">
|
||||
{% if next_thumb %}
|
||||
<img src="{{ next_thumb }}" class="subdir-thumb" alt="next thumb">
|
||||
{% else %}
|
||||
<span class="subdir-fallback"><i class="fa-solid fa-image"></i></span>
|
||||
{% endif %}
|
||||
<span>Next</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
<a href="{{ back_url }}" class="subdir-item">
|
||||
{% if back_thumb %}
|
||||
<img src="{{ back_thumb }}" class="subdir-thumb" alt="back thumb">
|
||||
{% else %}
|
||||
<span class="subdir-fallback"><i class="fa-solid fa-image"></i></span>
|
||||
{% if not is_special or is_special and 'favorites' in breadcrumbs.0.path %}
|
||||
{% if prev_url %}
|
||||
<a href="{{ prev_url }}" class="subdir-item">
|
||||
{% if prev_thumb %}
|
||||
<img src="{{ prev_thumb }}" class="subdir-thumb" alt="prev thumb">
|
||||
{% else %}
|
||||
<span class="subdir-fallback"><i class="fa-solid fa-image"></i></span>
|
||||
{% endif %}
|
||||
<span>Previous</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
<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">
|
||||
{% if home_thumb %}
|
||||
|
||||
@@ -1,43 +1,74 @@
|
||||
{% load static %}
|
||||
{% load static form_tags %}
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, height=device-height" />
|
||||
<title>
|
||||
NibasaViewer login
|
||||
</title>
|
||||
<link href="{% static 'css/old_styles.css' %}" rel="stylesheet">
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>NibasaViewer — Login</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 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>
|
||||
<body class="background">
|
||||
<div class="fixed-width mauto-top">
|
||||
<form method="post" action="{% url 'login' %}" class="full-width">
|
||||
{% csrf_token %}
|
||||
<table class="full-width">
|
||||
<tr>
|
||||
<td>
|
||||
{{ form.username.label_tag }}
|
||||
</td>
|
||||
<td>
|
||||
{{ form.username }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
{{ form.password.label_tag }}
|
||||
</td>
|
||||
<td>
|
||||
{{ form.password }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2">
|
||||
<input type="submit" value="login" class="float-right">
|
||||
<body class="theme-{{ theme|default:'dark' }}">
|
||||
|
||||
<section class="vh-100">
|
||||
<div class="container py-5 h-100">
|
||||
<div class="row d-flex justify-content-center align-items-center h-100">
|
||||
<div class="col col-xl-10">
|
||||
<div class="card shadow" style="border-radius: 1rem;">
|
||||
<div class="row g-0">
|
||||
<div class="col-md-6 col-lg-5 d-none d-md-block">
|
||||
<img src="{% static 'imgs/login.jpg' %}"
|
||||
alt="login image" class="img-fluid" style="height:100%; object-fit:cover; border-radius: 1rem 0 0 1rem;" />
|
||||
</div>
|
||||
<div class="col-md-6 col-lg-7 d-flex align-items-center">
|
||||
<div class="card-body p-4 p-lg-5 text-black h-100 login-form">
|
||||
|
||||
<form method="post" action="{% url 'login' %}" class="w-100">
|
||||
{% csrf_token %}
|
||||
{% if form.non_field_errors %}
|
||||
<div class="alert alert-danger">{{ form.non_field_errors }}</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="d-flex align-items-center mb-3 pb-1">
|
||||
<img src="{% static 'imgs/logo.png' %}" alt="Logo" style="width:44px; height:44px; object-fit:contain;" class="me-3">
|
||||
<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 }}">
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
</form>
|
||||
|
||||
</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>
|
||||
</html>
|
||||
|
||||
|
||||
0
viewer/templatetags/__init__.py
Normal file
33
viewer/templatetags/form_tags.py
Normal 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)
|
||||
173
viewer/test_specials.py
Normal 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"])
|
||||
@@ -1,8 +1,8 @@
|
||||
# Django imports
|
||||
from django.urls import path
|
||||
from django.urls import path, re_path
|
||||
|
||||
# Module imports
|
||||
from .views import gallery_view, toggle_settings
|
||||
from .views import gallery_view, toggle_settings, special_gallery_view
|
||||
|
||||
###########################################################################################
|
||||
# URL Patterns. #
|
||||
@@ -13,5 +13,17 @@ urlpatterns = [
|
||||
# Views.
|
||||
path("", gallery_view, name="gallery_view_root"),
|
||||
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"),
|
||||
]
|
||||
|
||||
@@ -20,6 +20,9 @@ from .image import render_image
|
||||
from .models import UserSettings
|
||||
|
||||
|
||||
SPECIALS = ['favorites', 'most-visited', 'recent']
|
||||
|
||||
|
||||
@login_required
|
||||
def index(request):
|
||||
return redirect("gallery_view_root")
|
||||
@@ -51,6 +54,46 @@ def gallery_view(request, path=None):
|
||||
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.
|
||||
|
||||