Redesigned gallery view layout and added sorting tests

This commit is contained in:
2026-03-22 22:32:43 -04:00
parent 6fd2dd3769
commit 16bd651cfb
5 changed files with 1503 additions and 293 deletions

167
AGENTS.md Normal file
View File

@@ -0,0 +1,167 @@
# AGENTS.md
Guidance for coding agents working in `NibasaViewer`.
This document is intentionally explicit so tasks can be completed with minimal repo discovery.
## 1) Project Snapshot
- Stack: Python 3 + Django 4.2.
- App layout: single app, `viewer`.
- Data model: filesystem-first gallery; images/folders are not stored in Django models.
- Frontend constraint: minimize JavaScript dependencies; Bootstrap bundled JS is allowed when required, and custom JavaScript should be avoided unless explicitly requested.
- Auth: Django built-in auth; gallery views are login-protected.
- Image flow: originals from `GALLERY_ROOT`, thumbnails cached in `THUMBNAILS_ROOT`.
## 2) Source of Truth and Rule Files
- Primary in-repo agent guidance exists in `CLAUDE.md`.
- `AGENTS.md` (this file) complements `CLAUDE.md` for day-to-day agent execution.
- Cursor rules search result: no `.cursorrules` and no files in `.cursor/rules/`.
- Copilot rules search result: no `.github/copilot-instructions.md` present.
- If Cursor/Copilot rule files are added later, treat them as additional required constraints.
## 3) Environment and Setup Commands
- Create venv: `python -m venv .venv`
- Activate venv (bash): `source .venv/bin/activate`
- Install deps: `pip install -r requirements.txt`
- Apply migrations: `python manage.py migrate`
- Run server: `python manage.py runserver`
Local configuration (`NibasaViewer/local_settings.py`) is required for real gallery usage:
```python
from pathlib import Path
GALLERY_ROOT = Path('/path/to/images')
THUMBNAILS_ROOT = Path('/path/to/thumb-cache')
DEBUG = False
ALLOWED_HOSTS = ['yourdomain.example']
SECRET_KEY = 'replace-me'
```
## 4) Build / Lint / Test Commands
There is no dedicated linter or formatter config in-repo (no `ruff`, `flake8`, `black`, `mypy`, `pyright`, `tox`, or `pytest` config files detected).
Use these commands as the standard validation set:
- Django system checks: `python manage.py check`
- Migration consistency check: `python manage.py makemigrations --check --dry-run`
- Python syntax sanity: `python -m compileall NibasaViewer viewer`
- Run Django tests (all): `python manage.py test`
Single-test execution patterns (important for fast iteration):
- Single test module: `python manage.py test viewer.test`
- Single test class: `python manage.py test viewer.test.GalleryViewTests`
- Single test method: `python manage.py test viewer.test.GalleryViewTests.test_search_action_url_uses_nested_path`
Notes:
- If there are currently no tests, add focused tests near the changed behavior before large refactors.
- Prefer `python manage.py test ...` style labels over introducing a second test runner.
## 5) Operational Commands
- Pre-generate thumbnails: `python manage.py makethumbnails`
- Create auth user (shell):
- `python manage.py shell`
- `from django.contrib.auth.models import User`
- `User.objects.create_user('<USERNAME>', '<EMAIL>', '<PASSWORD>')`
- Collect static files (deploy-time): `python manage.py collectstatic`
## 6) Architecture and Change Boundaries
- Keep gallery metadata in filesystem, not DB tables.
- Do not introduce image/folder models unless explicitly requested by humans.
- Keep URL/data flow consistent:
- `/imgs/` -> `GALLERY_ROOT`
- `/thumbs/` -> `THUMBNAILS_ROOT`
- `/gallery/` -> directory browsing and image view routing
- Preserve lazy thumbnail generation behavior unless task explicitly changes performance strategy.
## 7) Code Style Guidelines (Repository-Specific)
### Imports
- Use grouped imports with section comments where practical:
- Standard library imports
- Third-party/Django imports
- Project/local imports
- Keep import order deterministic within each group.
- Follow existing style in touched file; do not mass-reformat untouched files.
### Formatting
- Follow existing formatting conventions in each file.
- Current codebase uses a traditional style with explicit spacing and readability comments.
- Avoid introducing unrelated formatting churn in files not functionally changed.
- Keep lines readable; wrap long imports or argument lists clearly.
### Naming
- Functions/variables: `snake_case`.
- Constants: `UPPER_SNAKE_CASE` (see `CELLS_PER_ROW`, `THUMB_SIZE`).
- Class names: `PascalCase`.
- URL names: descriptive snake_case names (`gallery_view_root`, `gallery_view_path`).
### Types and Data Shapes
- `pathlib.Path` is the preferred filesystem primitive.
- Accept `str` inputs only when ergonomics require it; normalize to `Path` early.
- Type hints are not strictly enforced across the repo; add them when they clarify non-obvious APIs.
- Do not introduce heavy typing frameworks or strict type-checking configs unless asked.
### Error Handling
- Catch specific exceptions when behavior should differ by failure mode.
- Use broad `except Exception` only for intentionally best-effort operations (e.g., thumbnail generation), and keep side effects minimal.
- Never swallow exceptions in critical control paths without rationale.
- User-facing 404/invalid-path behavior should remain explicit and predictable.
### Django View and URL Practices
- Protect gallery routes with `@login_required` unless requirement changes.
- Keep view context keys explicit and template-friendly.
- Preserve pagination constants and behavior unless task explicitly changes UX.
- Use `redirect`/`render` idioms already established in `viewer/views.py`.
### Template / Frontend Constraints
- Minimize JavaScript dependencies wherever possible.
- Bootstrap's own bundled JavaScript is allowed when needed for UI behaviors (offcanvas/dropdowns/tooling).
- Avoid custom project JavaScript unless explicitly requested.
- Keep HTML/CSS compatible with older browsers; prefer conservative layout features.
- Maintain dark-theme image-viewing emphasis and existing navigation metaphors.
## 8) Performance Expectations
- Prefer extension-based image filtering (`is_image_file`) over MIME-content probing.
- Avoid repeated heavy filesystem traversals in hot request paths when simple caching/slicing works.
- Keep thumbnail generation on-demand unless batch generation is requested.
## 9) Safety and Config Hygiene
- Never commit secrets or production-specific local settings.
- Keep `local_settings.py` local; defaults in `settings.py` should remain safe placeholders.
- Avoid destructive git operations unless explicitly requested.
- Do not revert unrelated working tree changes made by humans.
## 10) Agent Workflow Checklist
When implementing a change, follow this minimum loop:
1. Read relevant files and preserve local conventions.
2. Make the smallest change that satisfies the request.
3. Run focused validation commands first, then broader checks.
4. Report what changed, why, and exactly which commands were run.
Suggested pre-handoff command bundle:
- `python manage.py check`
- `python manage.py test` (or a targeted test label)
- `python -m compileall NibasaViewer viewer`
This repository values stability, compatibility, and straightforward Django patterns over novelty.

View File

@@ -2,6 +2,8 @@
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Additional guidance is provided in @AGENTS.md
## Project Overview ## Project Overview
Nibasa Viewer is a lightweight, pure HTML+CSS gallery viewer designed for compatibility with older browsers. It uses Django as a backend framework but serves images directly from the filesystem rather than using database models. Nibasa Viewer is a lightweight, pure HTML+CSS gallery viewer designed for compatibility with older browsers. It uses Django as a backend framework but serves images directly from the filesystem rather than using database models.

View File

@@ -1,176 +1,591 @@
{% load static %} {% load static %}
<!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">
Folder: {{request.path}} <title>Gallery</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/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">
<style>
:root {
--radius: 14px;
--sidebar-width: 284px;
}
body.theme-dark {
--bg: #24282d;
--panel: #2e333a;
--panel-strong: #3a4048;
--text: #ecf0f4;
--muted: #c6ced8;
--line: #606973;
--accent: #91adc4;
--input-bg: #252a30;
}
body.theme-light {
--bg: #d8dadc;
--panel: #f2f2f2;
--panel-strong: #ffffff;
--text: #1e2f40;
--muted: #3b4a57;
--line: #7c8894;
--accent: #32506a;
--input-bg: #f8fafb;
}
html,
body {
height: 100%;
}
body {
margin: 0;
background: var(--bg);
color: var(--text);
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
}
.app-shell {
height: 100vh;
overflow: hidden;
gap: 24px;
padding: 16px;
}
.sidebar {
width: var(--sidebar-width);
flex: 0 0 var(--sidebar-width);
background: var(--panel);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 10px;
box-shadow: 0 5px 20px rgba(0, 0, 0, 0.28);
}
.sidebar hr {
border-color: var(--line);
opacity: 0.85;
margin: 8px 0;
}
.user-row {
background: var(--panel-strong);
border: 1px solid var(--line);
border-radius: 8px;
min-height: 42px;
padding: 0 8px;
gap: 8px;
}
.user-name {
color: var(--text);
font-weight: 600;
font-size: 14px;
}
.sidebar-link {
color: var(--text);
display: block;
padding: 2px 0;
text-decoration: none;
}
.sidebar-link:hover {
color: var(--accent);
}
.sidebar-scroll {
overflow-y: auto;
min-height: 120px;
}
.subdir-item {
color: var(--text);
display: flex;
gap: 10px;
align-items: center;
padding: 6px 0;
text-decoration: none;
}
.subdir-item:hover {
color: var(--accent);
}
.subdir-thumb {
width: 70px;
height: 45px;
object-fit: cover;
border: 1px solid var(--line);
border-radius: 6px;
display: block;
}
.subdir-fallback {
width: 70px;
height: 45px;
border: 1px solid var(--line);
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
color: var(--accent);
font-size: 34px;
line-height: 1;
}
.main-area {
min-width: 0;
}
.top-bar {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 10px;
min-height: 44px;
padding: 4px 10px;
box-shadow: 0 5px 20px rgba(0, 0, 0, 0.22);
}
.breadcrumb-wrap {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
min-width: 0;
}
.crumb-link {
color: var(--text);
text-decoration: none;
font-size: 15px;
max-width: 240px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.crumb-link:hover {
color: var(--accent);
}
.crumb-sep {
color: var(--muted);
}
.gallery-scroll {
overflow-y: auto;
min-height: 0;
padding-top: 12px;
}
.gallery-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(128px, 128px));
justify-content: space-between;
column-gap: 24px;
row-gap: 24px;
padding-bottom: 8px;
}
.thumb-card {
width: 128px;
display: block;
border-radius: 14px;
overflow: hidden;
text-decoration: none;
box-shadow: 0 5px 20px rgba(0, 0, 0, 0.35);
}
.thumb-card img,
.thumb-fallback {
width: 128px;
height: 128px;
object-fit: cover;
border-radius: 14px;
display: block;
border: 2px solid rgba(0, 0, 0, 0.18);
filter: brightness(1);
transition: filter 0.25s ease;
}
.thumb-fallback {
display: flex;
align-items: center;
justify-content: center;
color: var(--accent);
font-size: 44px;
background: var(--panel-strong);
}
.thumb-card:hover img,
.thumb-card:hover .thumb-fallback {
filter: brightness(1.25);
}
.search-input,
.search-input:focus {
background: var(--input-bg);
color: var(--text);
border-color: var(--line);
box-shadow: none;
}
.search-input::placeholder {
color: var(--muted);
opacity: 1;
}
.search-input::-webkit-input-placeholder {
color: var(--muted);
}
.search-input::-moz-placeholder {
color: var(--muted);
opacity: 1;
}
.search-addon {
background: var(--input-bg);
color: var(--muted);
border-color: var(--line);
}
.btn-plain {
color: var(--text);
border: 1px solid var(--line);
background: var(--panel-strong);
}
.btn-plain:hover {
color: var(--accent);
border-color: var(--accent);
}
.offcanvas {
background: var(--panel);
color: var(--text);
width: var(--sidebar-width) !important;
}
.offcanvas .sidebar {
width: 100%;
border: none;
border-radius: 0;
padding: 0;
background: transparent;
}
.sort-menu {
max-height: 220px;
overflow-y: auto;
}
.sort-menu .dropdown-item {
color: #1e2f40;
font-weight: 600;
font-size: 14px;
}
.sort-menu .dropdown-item.active-sort {
text-decoration: underline;
background: transparent;
}
@media (max-width: 991.98px) {
.app-shell {
padding: 12px;
gap: 12px;
}
.offcanvas {
width: 100vw !important;
}
}
@media (max-width: 575.98px) {
.gallery-grid {
grid-template-columns: 1fr;
justify-content: stretch;
}
.thumb-card {
margin-left: auto;
margin-right: auto;
}
.crumb-link {
max-width: 130px;
}
}
@supports not (display: grid) {
.gallery-grid {
display: block;
}
.thumb-card {
display: inline-block;
margin: 0 24px 24px 0;
}
}
</style>
<noscript>
<style>
.offcanvas,
.sidebar-toggle,
.sidebar-close {
display: none !important;
}
.noscript-sidebar {
display: block !important;
margin-bottom: 12px;
width: 100% !important;
flex: 0 0 auto !important;
}
</style>
</noscript>
</head> </head>
<body class="theme-{{ theme }}">
<body class="background"> <div class="app-shell d-flex">
<!-- Navigation bar. --> <aside class="sidebar d-none d-lg-flex flex-column">
<table class="fc mauto"> <div class="d-flex align-items-center user-row">
<tr> <span><i class="fa-solid fa-circle-user fa-lg"></i></span>
<!-- Home button. --> <span class="user-name text-truncate">{{ request.user.username }}</span>
<td> <a class="btn btn-sm btn-plain ms-auto" href="{{ theme_toggle_url }}" aria-label="Toggle theme">
<a href="/gallery/" class="mr-2"> {% if theme == 'dark' %}
<img src="{% static 'imgs/gohome.png' %}" class="small-nav-icon"> <i class="fa-solid fa-sun"></i>
</a>
</td>
<!-- Back button. -->
{% if not search %}
<td>
<a href=".." class="mr-2">
<img src="{% static 'imgs/back.png' %}" class="small-nav-icon">
</a>
</td>
{% endif %}
<!-- Directory path. -->
<td>
<h1 class="mr-2">
{% if not search %}
{{request.path}} - Files: {{num_files}}
{% else %} {% else %}
Search: {{search_text}} - Files: {{num_files}} <i class="fa-solid fa-moon"></i>
{% endif %} {% endif %}
</h1> </a>
</td>
<!-- Page links. -->
{% if num_pages > 1 %}
<td>
<div class="mauto mr-2">
{% for page in pages %}<a href="./?page={{page}}{%if search %}&{{search}}{% endif %}">{{page}}</a>{% if not forloop.last %}<span> </span>{% endif %}{% endfor %}
</div> </div>
</td>
{% endif %}
<!-- Logout button. --> <hr>
<td>
<form action="{{ search_action_url }}" method="get">
<div class="input-group input-group-sm">
<input type="search" name="search" class="form-control search-input" placeholder="Search..." value="{{ search_text }}">
<span class="input-group-text search-addon"><i class="fa-solid fa-magnifying-glass"></i></span>
</div>
</form>
<hr>
<a href="#" class="sidebar-link">Favorites</a>
<a href="#" class="sidebar-link">Most visited</a>
<a href="#" class="sidebar-link">Recently visited</a>
<hr>
<div class="sidebar-scroll flex-grow-1">
{% for subdir in subdirs %}
<a href="{{ subdir.path }}" class="subdir-item">
{% if subdir.thumbnail %}
<img src="{{ subdir.thumbnail }}" alt="{{ subdir.name }}" class="subdir-thumb">
{% else %}
<span class="subdir-fallback"><i class="fa-solid fa-image"></i></span>
{% endif %}
<span>{{ subdir.name|truncatechars:30 }}</span>
</a>
{% empty %}
<small class="text-secondary">No subdirectories</small>
{% endfor %}
</div>
<hr>
<form method="post" action="{% url 'logout' %}"> <form method="post" action="{% url 'logout' %}">
{% csrf_token %} {% csrf_token %}
<button type="submit" class="clear-btn"> <button type="submit" class="btn btn-sm btn-plain w-100">
<img src="{% static 'imgs/boot.png' %}" class="small-nav-icon"> <i class="fa-solid fa-arrow-up-from-bracket"></i>
<span class="ms-1">Logout</span>
</button> </button>
</form> </form>
</td> </aside>
</tr>
</table>
<!-- Search form. --> <div class="noscript-sidebar sidebar d-lg-none" style="display:none;">
{% if not search %} <div class="d-flex align-items-center user-row">
<form action="{% url 'gallery_view_root' %}" method="GET"> <span><i class="fa-solid fa-circle-user fa-lg"></i></span>
<table class="fc mauto"> <span class="user-name text-truncate">{{ request.user.username }}</span>
<tr> <a class="btn btn-sm btn-plain ms-auto" href="{{ theme_toggle_url }}" aria-label="Toggle theme">
<!-- Search title. --> {% if theme == 'dark' %}
<td> <i class="fa-solid fa-sun"></i>
<h3> {% else %}
Search: <i class="fa-solid fa-moon"></i>
</h3> {% endif %}
</td> </a>
</div>
<!-- Search bar. --> <hr>
<td>
<input type="search" name="search" class="search-box">
</td>
<!-- Search submit. --> <form action="{{ search_action_url }}" method="get">
<td> <div class="input-group input-group-sm">
<button type="submit" class="clear-btn"> <input type="search" name="search" class="form-control search-input" placeholder="Search..." value="{{ search_text }}">
<img src="{% static 'imgs/find.png' %}" class="small-nav-icon"> <span class="input-group-text search-addon"><i class="fa-solid fa-magnifying-glass"></i></span>
</button> </div>
</td>
</tr>
</table>
</form> </form>
<hr>
<a href="#" class="sidebar-link">Favorites</a>
<a href="#" class="sidebar-link">Most visited</a>
<a href="#" class="sidebar-link">Recently visited</a>
<hr>
<div class="sidebar-scroll" style="max-height: 200px;">
{% for subdir in subdirs %}
<a href="{{ subdir.path }}" class="subdir-item">
{% if subdir.thumbnail %}
<img src="{{ subdir.thumbnail }}" alt="{{ subdir.name }}" class="subdir-thumb">
{% else %}
<span class="subdir-fallback"><i class="fa-solid fa-image"></i></span>
{% endif %} {% endif %}
<span>{{ subdir.name|truncatechars:30 }}</span>
<!-- Images in current directory. -->
{% if images|length > 0 %}
<table class="fc mauto">
<tr>
<!-- Previous page button. -->
{% if page != 1 %}
<td class="fc">
<a href="./?page={{prev_page}}{%if search %}&{{search}}{% endif %}">
<img src="{% static 'imgs/back.png' %}" class="navigation-icon">
</a> </a>
</td> {% empty %}
{% endif %} <small class="text-secondary">No subdirectories</small>
<!-- Image rows. -->
<td class="fc">
<table class="mauto">
<tbody>
{% for row in images %}
<tr>
{% for image in row %}
<td class="column">
<a href="{{image.path}}">
<!-- Thumbnail. -->
<img src="{{image.thumbnail}}">
<br>
<!-- Image name. -->
<span>
{{image.name|truncatechars:15}}
</span>
</a>
</td>
{% endfor %} {% endfor %}
</tr>
{% endfor %}
</tbody>
</table>
</td>
<!-- Next page button. -->
{% if page != num_pages %}
<td class="fc">
<a href="./?page={{next_page}}{%if search %}&{{search}}{% endif %}">
<img src="{% static 'imgs/forward.png' %}" class="navigation-icon">
</a>
</td>
{% endif %}
</tr>
</table>
{% endif %}
{% if subdirs|length > 0 %}
<!-- Sub-directories title. -->
<div class="centered-container">
<h1>
Sub-directories
</h1>
</div> </div>
<!-- Sub-directory rows. --> <hr>
<div class="centered-container">
<table class="mauto"> <form method="post" action="{% url 'logout' %}">
<tbody> {% csrf_token %}
{% for row in subdirs %} <button type="submit" class="btn btn-sm btn-plain w-100">
<tr> <i class="fa-solid fa-arrow-up-from-bracket"></i>
{% for subdir in row %} <span class="ms-1">Logout</span>
<td class="column"> </button>
<a href="{{subdir.path}}"> </form>
<img src="{% static 'imgs/folder-pictures.png' %}">
<br>
<span>
{{subdir.name}}
</span>
</a>
</td>
{% endfor %}
</tr>
{% endfor %}
</tbody>
</table>
</div> </div>
<main class="main-area d-flex flex-column flex-grow-1">
<div class="top-bar d-flex align-items-center gap-2">
<button class="btn btn-sm btn-plain sidebar-toggle d-lg-none" type="button" data-bs-toggle="offcanvas" data-bs-target="#sidebarOffcanvas" aria-controls="sidebarOffcanvas" aria-label="Open menu">
<i class="fa-solid fa-bars"></i>
</button>
<div class="breadcrumb-wrap flex-grow-1">
{% for crumb in breadcrumbs %}
{% if not forloop.first %}
<span class="crumb-sep">/</span>
{% endif %} {% endif %}
<a href="{{ crumb.path }}" class="crumb-link">{{ crumb.label|truncatechars:45 }}</a>
{% endfor %}
</div>
<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>
</button>
<ul class="dropdown-menu dropdown-menu-end sort-menu">
{% for option in sort_options %}
<li>
<a class="dropdown-item {% if option.is_active %}active-sort{% endif %}" href="{{ option.url }}">{{ option.label }}</a>
</li>
{% endfor %}
</ul>
</div>
</div>
<noscript>
<div class="pt-2">
{% for option in sort_options %}
<a href="{{ option.url }}" class="me-3 {% if option.is_active %}text-decoration-underline{% endif %}">{{ option.label }}</a>
{% endfor %}
</div>
</noscript>
<section class="gallery-scroll flex-grow-1">
<div class="gallery-grid">
{% for image in images %}
<a href="{{ image.path }}" class="thumb-card">
{% if image.thumbnail %}
<img src="{{ image.thumbnail }}" alt="{{ image.name }}">
{% else %}
<div class="thumb-fallback"><i class="fa-solid fa-image"></i></div>
{% endif %}
</a>
{% empty %}
<p class="mb-0">No images found.</p>
{% endfor %}
</div>
</section>
</main>
</div>
<div class="offcanvas offcanvas-start" tabindex="-1" id="sidebarOffcanvas" aria-labelledby="sidebarOffcanvasLabel">
<div class="offcanvas-header pb-2">
<h2 class="h6 mb-0" id="sidebarOffcanvasLabel">Gallery</h2>
<div class="d-flex align-items-center gap-2">
<a class="btn btn-sm btn-plain" href="{{ theme_toggle_url }}" aria-label="Toggle theme">
{% if theme == 'dark' %}
<i class="fa-solid fa-sun"></i>
{% else %}
<i class="fa-solid fa-moon"></i>
{% endif %}
</a>
<button type="button" class="btn btn-sm btn-plain sidebar-close" data-bs-dismiss="offcanvas" aria-label="Close menu">
<i class="fa-solid fa-xmark"></i>
</button>
</div>
</div>
<div class="offcanvas-body pt-0 d-flex flex-column">
<aside class="sidebar d-flex flex-column">
<div class="d-flex align-items-center user-row">
<span><i class="fa-solid fa-circle-user fa-lg"></i></span>
<span class="user-name text-truncate">{{ request.user.username }}</span>
</div>
<hr>
<form action="{{ search_action_url }}" method="get">
<div class="input-group input-group-sm">
<input type="search" name="search" class="form-control search-input" placeholder="Search..." value="{{ search_text }}">
<span class="input-group-text search-addon"><i class="fa-solid fa-magnifying-glass"></i></span>
</div>
</form>
<hr>
<a href="#" class="sidebar-link">Favorites</a>
<a href="#" class="sidebar-link">Most visited</a>
<a href="#" class="sidebar-link">Recently visited</a>
<hr>
<div class="sidebar-scroll flex-grow-1">
{% for subdir in subdirs %}
<a href="{{ subdir.path }}" class="subdir-item">
{% if subdir.thumbnail %}
<img src="{{ subdir.thumbnail }}" alt="{{ subdir.name }}" class="subdir-thumb">
{% else %}
<span class="subdir-fallback"><i class="fa-solid fa-image"></i></span>
{% endif %}
<span>{{ subdir.name|truncatechars:30 }}</span>
</a>
{% empty %}
<small class="text-secondary">No subdirectories</small>
{% endfor %}
</div>
<hr>
<form method="post" action="{% url 'logout' %}">
{% csrf_token %}
<button type="submit" class="btn btn-sm btn-plain w-100">
<i class="fa-solid fa-arrow-up-from-bracket"></i>
<span class="ms-1">Logout</span>
</button>
</form>
</aside>
</div>
</div>
<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>

385
viewer/test.py Normal file
View File

@@ -0,0 +1,385 @@
# Standard library imports.
import os
import tempfile
from pathlib import Path
from unittest.mock import patch
# External library imports.
from PIL import Image
# Django imports.
from django.test import Client, RequestFactory, TestCase, override_settings
from django.contrib.auth.models import User
# Project imports.
from viewer.views import SORT_LABELS, do_recursive_search, gallery_view
class GalleryBaseTests(TestCase):
def setUp(self):
self.tmp_gallery = tempfile.TemporaryDirectory()
self.tmp_thumbs = tempfile.TemporaryDirectory()
self.gallery_root = Path(self.tmp_gallery.name)
self.thumb_root = Path(self.tmp_thumbs.name)
self.settings_override = override_settings(
GALLERY_ROOT=self.gallery_root, THUMBNAILS_ROOT=self.thumb_root
)
self.settings_override.enable()
self.user = User.objects.create_user(
"tester", "tester@example.com", "password123"
)
self.client = Client()
self.client.force_login(self.user)
self.factory = RequestFactory()
self._build_fixture_tree()
def tearDown(self):
self.settings_override.disable()
self.tmp_gallery.cleanup()
self.tmp_thumbs.cleanup()
def _create_image(self, image_path, color):
image_path.parent.mkdir(parents=True, exist_ok=True)
img = Image.new("RGB", (300, 200), color)
img.save(image_path)
def _set_mtime(self, file_path, timestamp):
os.utime(file_path, (timestamp, timestamp))
def _build_fixture_tree(self):
self._create_image(self.gallery_root / "alpha.jpg", "red")
self._create_image(self.gallery_root / "beta.jpg", "green")
self._create_image(self.gallery_root / "gamma.jpg", "blue")
self._create_image(self.gallery_root / "match-root.jpg", "yellow")
self._set_mtime(self.gallery_root / "alpha.jpg", 1000)
self._set_mtime(self.gallery_root / "beta.jpg", 2000)
self._set_mtime(self.gallery_root / "gamma.jpg", 3000)
self._set_mtime(self.gallery_root / "match-root.jpg", 4000)
self.sub_a = self.gallery_root / "sub_a"
self.sub_b = self.gallery_root / "sub_b"
self.empty_sub = self.gallery_root / "empty_sub"
self.nested = self.gallery_root / "nested"
self.sub_a.mkdir(parents=True, exist_ok=True)
self.sub_b.mkdir(parents=True, exist_ok=True)
self.empty_sub.mkdir(parents=True, exist_ok=True)
self.nested.mkdir(parents=True, exist_ok=True)
self._create_image(self.sub_a / "cover.jpg", "orange")
self._create_image(self.sub_a / "same.jpg", "purple")
self._create_image(self.sub_b / "same.jpg", "pink")
self._create_image(self.nested / "match-nested.jpg", "cyan")
self.symlink_supported = hasattr(os, "symlink")
self.symlink_dir = self.gallery_root / "symlink_sub"
self.symlink_file = self.gallery_root / "symlink_file.jpg"
if self.symlink_supported:
try:
os.symlink(self.sub_a, self.symlink_dir)
os.symlink(self.gallery_root / "alpha.jpg", self.symlink_file)
except OSError:
self.symlink_supported = False
class GalleryViewTests(GalleryBaseTests):
def test_gallery_rejects_out_of_root_path(self):
request = self.factory.get("/gallery/../")
request.user = self.user
response = gallery_view(request, "../")
self.assertEqual(response.status_code, 404)
def test_gallery_missing_path_returns_404(self):
response = self.client.get("/gallery/does-not-exist/")
self.assertEqual(response.status_code, 404)
def test_invalid_sort_defaults_to_abc(self):
response = self.client.get("/gallery/?sort=invalid")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.context["sort_key"], "abc")
def test_invalid_theme_defaults_to_dark(self):
response = self.client.get("/gallery/?theme=invalid")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.context["theme"], "dark")
def test_root_path_context_is_empty_string(self):
response = self.client.get("/gallery/")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.context["path"], "")
def test_context_has_no_pagination_keys(self):
response = self.client.get("/gallery/")
context = response.context
self.assertNotIn("page", context)
self.assertNotIn("pages", context)
self.assertNotIn("num_pages", context)
self.assertNotIn("prev_page", context)
self.assertNotIn("next_page", context)
def test_sort_accepts_all_required_keys(self):
for sort_key in ["abc", "cba", "old", "new", "recent", "tnecer"]:
response = self.client.get("/gallery/?sort=" + sort_key)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.context["sort_key"], sort_key)
def test_sort_labels_match_required_text(self):
response = self.client.get("/gallery/?sort=recent")
self.assertEqual(response.status_code, 200)
options = {
item["key"]: item["label"] for item in response.context["sort_options"]
}
self.assertEqual(options, SORT_LABELS)
self.assertEqual(response.context["sort_label"], SORT_LABELS["recent"])
def test_sort_tie_breaker_filename_then_relative_path(self):
response = self.client.get("/gallery/?search=same&sort=abc")
self.assertEqual(response.status_code, 200)
image_paths = [item["path"] for item in response.context["images"]]
self.assertEqual(len(image_paths), 2)
self.assertTrue(image_paths[0].find("/gallery/sub_a/same.jpg") != -1)
self.assertTrue(image_paths[1].find("/gallery/sub_b/same.jpg") != -1)
def test_sort_options_shape_and_is_active(self):
response = self.client.get("/gallery/?sort=cba&theme=light")
self.assertEqual(response.status_code, 200)
options = response.context["sort_options"]
self.assertEqual(len(options), 6)
for option in options:
self.assertIn("key", option)
self.assertIn("label", option)
self.assertIn("url", option)
self.assertIn("is_active", option)
active = [option for option in options if option["is_active"]]
self.assertEqual(len(active), 1)
self.assertEqual(active[0]["key"], "cba")
def test_breadcrumb_links_preserve_query(self):
response = self.client.get(
"/gallery/sub_a/?search=match&sort=recent&theme=light"
)
self.assertEqual(response.status_code, 200)
breadcrumbs = response.context["breadcrumbs"]
self.assertTrue(breadcrumbs[0]["path"].find("/gallery/?") == 0)
self.assertIn("search=match", breadcrumbs[0]["path"])
self.assertIn("sort=recent", breadcrumbs[0]["path"])
self.assertIn("theme=light", breadcrumbs[0]["path"])
def test_subdir_links_preserve_query(self):
response = self.client.get("/gallery/?search=match&sort=recent&theme=light")
self.assertEqual(response.status_code, 200)
subdir_paths = [subdir["path"] for subdir in response.context["subdirs"]]
self.assertTrue(any(path.find("search=match") != -1 for path in subdir_paths))
self.assertTrue(any(path.find("sort=recent") != -1 for path in subdir_paths))
self.assertTrue(any(path.find("theme=light") != -1 for path in subdir_paths))
def test_image_links_preserve_query(self):
response = self.client.get("/gallery/?search=match&sort=recent&theme=light")
self.assertEqual(response.status_code, 200)
image_paths = [image["path"] for image in response.context["images"]]
self.assertTrue(any(path.find("search=match") != -1 for path in image_paths))
self.assertTrue(any(path.find("sort=recent") != -1 for path in image_paths))
self.assertTrue(any(path.find("theme=light") != -1 for path in image_paths))
def test_theme_toggle_url_preserves_query(self):
response = self.client.get("/gallery/?search=match&sort=recent&theme=dark")
self.assertEqual(response.status_code, 200)
url = response.context["theme_toggle_url"]
self.assertIn("search=match", url)
self.assertIn("sort=recent", url)
self.assertIn("theme=light", url)
def test_search_action_url_preserves_query(self):
response = self.client.get("/gallery/?sort=recent&theme=light")
self.assertEqual(response.status_code, 200)
url = response.context["search_action_url"]
self.assertTrue(url.find("/gallery/?") == 0)
self.assertIn("sort=recent", url)
self.assertIn("theme=light", url)
def test_search_action_url_uses_nested_path(self):
response = self.client.get("/gallery/sub_a/?sort=recent&theme=light")
self.assertEqual(response.status_code, 200)
url = response.context["search_action_url"]
self.assertTrue(url.find("/gallery/sub_a/?") == 0)
def test_subdirs_use_immediate_children_only(self):
response = self.client.get("/gallery/?search=match")
self.assertEqual(response.status_code, 200)
names = [subdir["name"] for subdir in response.context["subdirs"]]
self.assertIn("nested", names)
self.assertNotIn("match-nested.jpg", names)
def test_subdir_order_is_deterministic(self):
response = self.client.get("/gallery/")
self.assertEqual(response.status_code, 200)
names = [subdir["name"] for subdir in response.context["subdirs"]]
self.assertEqual(names, sorted(names, key=lambda item: item.lower()))
def test_subdir_without_images_uses_fallback(self):
response = self.client.get("/gallery/")
self.assertEqual(response.status_code, 200)
empty_sub = [
subdir
for subdir in response.context["subdirs"]
if subdir["name"] == "empty_sub"
][0]
self.assertIsNone(empty_sub["thumbnail"])
def test_symlink_entries_ignored_in_gallery(self):
if not self.symlink_supported:
self.skipTest("Symlinks not supported in this environment")
response = self.client.get("/gallery/?search=symlink")
self.assertEqual(response.status_code, 200)
names = [image["name"] for image in response.context["images"]]
self.assertNotIn("symlink_file.jpg", names)
def test_recursive_search_ignores_symlink_directories(self):
if not self.symlink_supported:
self.skipTest("Symlinks not supported in this environment")
subdirs, images = do_recursive_search(self.gallery_root, "cover")
subdir_names = [subdir.name for subdir in subdirs]
image_names = [image.name for image in images]
self.assertNotIn("symlink_sub", subdir_names)
self.assertEqual(image_names.count("cover.jpg"), 1)
def test_sidebar_subdirs_ignore_symlink_dirs(self):
if not self.symlink_supported:
self.skipTest("Symlinks not supported in this environment")
response = self.client.get("/gallery/")
self.assertEqual(response.status_code, 200)
names = [subdir["name"] for subdir in response.context["subdirs"]]
self.assertNotIn("symlink_sub", names)
def test_thumbnail_failure_does_not_break_render(self):
with patch("viewer.views.make_thumbnail", side_effect=Exception("fail")):
response = self.client.get("/gallery/")
self.assertEqual(response.status_code, 200)
self.assertIn("images", response.context)
self.assertIn("subdirs", response.context)
def test_login_required_for_gallery(self):
anon = Client()
response = anon.get("/gallery/")
self.assertEqual(response.status_code, 302)
def test_image_view_still_renders_prev_next(self):
response = self.client.get("/gallery/beta.jpg/", follow=True)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.context["image_path"].name, "beta.jpg")
self.assertEqual(response.context["prev"], "alpha.jpg")
self.assertEqual(response.context["next"], "gamma.jpg")
def test_sort_modes_recent_and_tnecer_use_mtime(self):
recent = self.client.get("/gallery/?sort=recent")
least_recent = self.client.get("/gallery/?sort=tnecer")
self.assertEqual(recent.status_code, 200)
self.assertEqual(least_recent.status_code, 200)
self.assertEqual(recent.context["images"][0]["name"], "match-root.jpg")
self.assertEqual(least_recent.context["images"][0]["name"], "alpha.jpg")
def test_sort_modes_old_and_new_use_creation_timestamp(self):
creation_values = {
"alpha.jpg": 30,
"beta.jpg": 10,
"gamma.jpg": 20,
"match-root.jpg": 40,
}
def fake_creation(path_obj):
return creation_values.get(path_obj.name, 0)
with patch("viewer.views.get_creation_timestamp", side_effect=fake_creation):
old = self.client.get("/gallery/?sort=old")
new = self.client.get("/gallery/?sort=new")
self.assertEqual(old.status_code, 200)
self.assertEqual(new.status_code, 200)
self.assertEqual(old.context["images"][0]["name"], "beta.jpg")
self.assertEqual(new.context["images"][0]["name"], "match-root.jpg")
class GalleryTemplateTests(GalleryBaseTests):
def test_template_includes_bootstrap_and_fontawesome(self):
response = self.client.get("/gallery/")
self.assertEqual(response.status_code, 200)
body = response.content.decode("utf-8")
self.assertIn("bootstrap@5", body)
self.assertIn("font-awesome/7.0.0", body)
def test_template_has_sidebar_controls_and_mobile_buttons(self):
response = self.client.get("/gallery/")
self.assertEqual(response.status_code, 200)
body = response.content.decode("utf-8")
self.assertIn("fa-circle-user", body)
self.assertIn("fa-bars", body)
self.assertIn("fa-xmark", body)
self.assertIn("fa-arrow-up-from-bracket", body)
self.assertIn("Favorites", body)
self.assertIn("Most visited", body)
self.assertIn("Recently visited", body)
def test_template_shows_fallback_icon_for_empty_subdir(self):
response = self.client.get("/gallery/")
self.assertEqual(response.status_code, 200)
body = response.content.decode("utf-8")
self.assertIn("fa-solid fa-image", body)
def test_search_placeholder_contrast_in_dark_theme(self):
response = self.client.get("/gallery/?theme=dark")
self.assertEqual(response.status_code, 200)
body = response.content.decode("utf-8")
self.assertIn(".search-input::placeholder", body)
self.assertIn("color: var(--muted);", body)
def test_sort_dropdown_active_option_underlined(self):
response = self.client.get("/gallery/?sort=recent")
self.assertEqual(response.status_code, 200)
body = response.content.decode("utf-8")
self.assertIn("active-sort", body)
self.assertIn("Modification date most recent", body)
def test_template_has_requested_drop_shadows(self):
response = self.client.get("/gallery/")
self.assertEqual(response.status_code, 200)
body = response.content.decode("utf-8")
self.assertIn("box-shadow: 0 5px 20px", body)

View File

@@ -1,53 +1,270 @@
# Standard library imports. # Standard library imports.
from pathlib import Path from pathlib import Path
from math import ceil from urllib.parse import urlencode
from functools import cmp_to_key
# Django imports. # Django imports.
from django.http import HttpResponseNotFound from django.http import HttpResponseNotFound
from django.conf import settings from django.conf import settings
from django.utils.http import urlencode
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.shortcuts import (render, from django.shortcuts import render, redirect
redirect)
# Project imports. # Project imports.
from .utils import make_thumbnail, is_image_file from .utils import make_thumbnail, is_image_file
########################################################################################### ###########################################################################################
# CONSTANTS. # # Constants. #
########################################################################################### ###########################################################################################
CELLS_PER_ROW = 7 SORT_OPTIONS = [
ROWS_PER_PAGE = 4 ("abc", "Alphabetical A-Z"),
IMAGES_PER_PAGE = CELLS_PER_ROW * ROWS_PER_PAGE ("cba", "Alphabetical Z-A"),
("old", "Creation date old to new"),
("new", "Creation date new to old"),
("recent", "Modification date most recent"),
("tnecer", "Modification date least recent"),
]
SORT_LABELS = dict(SORT_OPTIONS)
########################################################################################### ###########################################################################################
# Helper functions. # # Helper functions. #
########################################################################################### ###########################################################################################
def normalize_sort(sort_value):
return sort_value if sort_value in SORT_LABELS else "abc"
def normalize_theme(theme_value):
return theme_value if theme_value in ("dark", "light") else "dark"
def get_creation_timestamp(path_obj):
try:
stat_data = path_obj.stat()
return getattr(stat_data, "st_birthtime", stat_data.st_ctime)
except OSError:
return 0
def get_modification_timestamp(path_obj):
try:
return path_obj.stat().st_mtime
except OSError:
return 0
def build_query(search_text, sort_key, theme):
query = {"sort": sort_key, "theme": theme}
if search_text != "":
query["search"] = search_text
return query
def append_query(url, query_dict):
if len(query_dict) == 0:
return url
return url + "?" + urlencode(query_dict)
def gallery_url(path_obj=None, is_dir=False, query_dict=None):
if query_dict is None:
query_dict = {}
if path_obj is None:
base_url = "/gallery/"
else:
path_text = str(path_obj).replace("\\", "/")
base_url = "/gallery/" + path_text
if is_dir and not base_url.endswith("/"):
base_url += "/"
return append_query(base_url, query_dict)
def do_recursive_search(start_path, query): def do_recursive_search(start_path, query):
""" """
Gets all images and sub-directories inside the start_path whose name matches the given query, Gets all images and sub-directories inside the start_path whose name matches the given query,
and then joins the results recursively by iterating in each sub-directory. and then joins the results recursively by iterating in each sub-directory.
""" """
# Get all sub-dirs and images that match the query. try:
all_subdirs = sorted([i for i in start_path.iterdir() if i.is_dir()]) entries = [entry for entry in start_path.iterdir() if not entry.is_symlink()]
subdirs = sorted([i for i in all_subdirs if query.lower() in i.name.lower()]) except OSError:
images = sorted([i for i in start_path.iterdir() if i.is_file() and query.lower() in i.name.lower()]) return [], []
all_subdirs = sorted(
[entry for entry in entries if entry.is_dir()],
key=lambda item: (item.name.lower(), str(item).lower()),
)
subdirs = [entry for entry in all_subdirs if query.lower() in entry.name.lower()]
images = sorted(
[
entry
for entry in entries
if entry.is_file()
and is_image_file(entry)
and query.lower() in entry.name.lower()
],
key=lambda item: (item.name.lower(), str(item).lower()),
)
# For all sub-directories, regardless of the query.
for subdir in all_subdirs: for subdir in all_subdirs:
# Do a recursive search.
rec_subdirs, rec_images = do_recursive_search(subdir, query.lower()) rec_subdirs, rec_images = do_recursive_search(subdir, query.lower())
# Join the results if any.
subdirs.extend(rec_subdirs) subdirs.extend(rec_subdirs)
images.extend(rec_images) images.extend(rec_images)
return subdirs, images return subdirs, images
def sort_images(images, sort_key):
def compare(img_a, img_b):
name_a = img_a.name.lower()
name_b = img_b.name.lower()
rel_a = str(img_a.relative_to(settings.GALLERY_ROOT)).lower()
rel_b = str(img_b.relative_to(settings.GALLERY_ROOT)).lower()
if sort_key == "abc":
if name_a < name_b:
return -1
if name_a > name_b:
return 1
elif sort_key == "cba":
if name_a > name_b:
return -1
if name_a < name_b:
return 1
elif sort_key == "old":
created_a = get_creation_timestamp(img_a)
created_b = get_creation_timestamp(img_b)
if created_a < created_b:
return -1
if created_a > created_b:
return 1
elif sort_key == "new":
created_a = get_creation_timestamp(img_a)
created_b = get_creation_timestamp(img_b)
if created_a > created_b:
return -1
if created_a < created_b:
return 1
elif sort_key == "recent":
modified_a = get_modification_timestamp(img_a)
modified_b = get_modification_timestamp(img_b)
if modified_a > modified_b:
return -1
if modified_a < modified_b:
return 1
elif sort_key == "tnecer":
modified_a = get_modification_timestamp(img_a)
modified_b = get_modification_timestamp(img_b)
if modified_a < modified_b:
return -1
if modified_a > modified_b:
return 1
if name_a < name_b:
return -1
if name_a > name_b:
return 1
if rel_a < rel_b:
return -1
if rel_a > rel_b:
return 1
return 0
return sorted(images, key=cmp_to_key(compare))
def build_breadcrumbs(path_text, query_dict):
breadcrumbs = [{"label": "Gallery", "path": gallery_url(None, True, query_dict)}]
if path_text == "":
return breadcrumbs
segments = Path(path_text).parts
current = Path("")
for segment in segments:
current = current.joinpath(segment)
breadcrumbs.append(
{"label": segment, "path": gallery_url(current, True, query_dict)}
)
return breadcrumbs
def build_sort_options(request, search_text, sort_key, theme):
options = []
for option_key, label in SORT_OPTIONS:
query = {"sort": option_key, "theme": theme}
if search_text != "":
query["search"] = search_text
options.append(
{
"key": option_key,
"label": label,
"url": append_query(request.path, query),
"is_active": option_key == sort_key,
}
)
return options
def get_first_image_thumbnail_url(subdir):
try:
images = sorted(
[
entry
for entry in subdir.iterdir()
if entry.is_file() and not entry.is_symlink() and is_image_file(entry)
],
key=lambda item: (
item.name.lower(),
str(item.relative_to(settings.GALLERY_ROOT)).lower(),
),
)
except OSError:
return None
if len(images) == 0:
return None
first_image = images[0]
try:
make_thumbnail(first_image)
rel_path = first_image.relative_to(settings.GALLERY_ROOT)
thumb_path = settings.THUMBNAILS_ROOT.joinpath(rel_path)
if thumb_path.exists():
return "/thumbs/" + str(rel_path).replace("\\", "/")
except Exception:
pass
return None
########################################################################################### ###########################################################################################
# View functions. # # View functions. #
########################################################################################### ###########################################################################################
@@ -55,7 +272,133 @@ def do_recursive_search(start_path, query):
@login_required @login_required
def index(request): def index(request):
return redirect('gallery_view_root') return redirect("gallery_view_root")
def _render_directory(request, path_text, full_path):
"""
Renders the gallery view related to directories, be it the contents of an actual directory
in the file system, or logical gallery directories like search result pages.
"""
search_text = request.GET.get("search", "").strip()
sort_key = normalize_sort(request.GET.get("sort", "abc"))
theme = normalize_theme(request.GET.get("theme", "dark"))
query_state = build_query(search_text, sort_key, theme)
try:
current_entries = [
entry for entry in full_path.iterdir() if not entry.is_symlink()
]
except OSError:
return HttpResponseNotFound("Not found")
current_subdirs = sorted(
[entry for entry in current_entries if entry.is_dir()],
key=lambda item: (
item.name.lower(),
str(item.relative_to(settings.GALLERY_ROOT)).lower(),
),
)
if search_text == "":
images = [
entry
for entry in current_entries
if entry.is_file() and is_image_file(entry)
]
else:
_, images = do_recursive_search(full_path, search_text)
images = sort_images(images, sort_key)
image_data = []
for image in images:
rel_path = image.relative_to(settings.GALLERY_ROOT)
image_url = gallery_url(rel_path, False, query_state)
thumbnail = None
try:
make_thumbnail(image)
thumb_path = settings.THUMBNAILS_ROOT.joinpath(rel_path)
if thumb_path.exists():
thumbnail = "/thumbs/" + str(rel_path).replace("\\", "/")
except Exception:
pass
image_data.append(
{"path": image_url, "name": image.name, "thumbnail": thumbnail}
)
subdir_data = []
for subdir in current_subdirs:
rel_path = subdir.relative_to(settings.GALLERY_ROOT)
subdir_data.append(
{
"path": gallery_url(rel_path, True, query_state),
"name": subdir.name,
"thumbnail": get_first_image_thumbnail_url(subdir),
}
)
next_theme = "light" if theme == "dark" else "dark"
theme_query = {"sort": sort_key, "theme": next_theme}
if search_text != "":
theme_query["search"] = search_text
search_action_query = {"sort": sort_key, "theme": theme}
context = {
"path": path_text,
"search_text": search_text,
"theme": theme,
"sort_key": sort_key,
"sort_label": SORT_LABELS[sort_key],
"sort_options": build_sort_options(request, search_text, sort_key, theme),
"breadcrumbs": build_breadcrumbs(path_text, query_state),
"images": image_data,
"subdirs": subdir_data,
"theme_toggle_url": append_query(request.path, theme_query),
"search_action_url": append_query(request.path, search_action_query),
}
return render(request, "gallery_view.html", context)
def _render_image(request, path_text, full_path):
"""
Renders the view corresponding to an image file.
"""
image = Path("/imgs/").joinpath(path_text)
img_dir = full_path.parent
try:
siblings = [
entry.name
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")
images = sorted(siblings, key=lambda item: item.lower())
if image.name not in images:
return HttpResponseNotFound("Not found")
index = images.index(image.name)
previous = index - 1 if index > 0 else None
following = index + 1 if index < len(images) - 1 else None
context = {
"image_path": image,
"prev": images[previous] if previous is not None else None,
"next": images[following] if following is not None else None,
}
return render(request, "image_view.html", context)
@login_required @login_required
@@ -65,122 +408,20 @@ def gallery_view(request, path = None):
The path should be inside the GALLERY_ROOT path, otherwise a 404 error will be thrown. The path should be inside the GALLERY_ROOT path, otherwise a 404 error will be thrown.
""" """
def list2rows(lst, cells = CELLS_PER_ROW): root = settings.GALLERY_ROOT.resolve()
"""
Converts a list into a matrix with the given amount of cells per row.
"""
rows = []
i = 0
while i < len(lst):
row = []
for c in range(cells):
try: try:
row.append(lst[i + c]) candidate = root.joinpath(path).resolve() if path is not None else root
candidate.relative_to(root)
except Exception: except Exception:
pass return HttpResponseNotFound("Not found")
rows.append(row)
i += cells
return rows
# Get the absolute path to show if any, else show the base gallery path. if not candidate.exists():
full_path = settings.GALLERY_ROOT.joinpath(path) if path is not None else settings.GALLERY_ROOT return HttpResponseNotFound("Not found")
# Get the search query from the request, if any. path_text = path if path is not None else ""
search = request.GET.get('search', default = '')
if full_path.exists(): if candidate.is_dir():
# If the path exists then check if it points to a directory or an image. return _render_directory(request, path_text, candidate)
if full_path.is_dir():
if search == '':
# If there is no search query then get all images and sub-directories of the current path.
subdirs = sorted([i for i in full_path.iterdir() if i.is_dir()])
images = sorted([i for i in full_path.iterdir() if i.is_file() and is_image_file(i)])
else: return _render_image(request, path_text, candidate)
# If there is a search query then search the current directory recursively.
subdirs, images = do_recursive_search(full_path, search)
# Only keep image files.
images = [image for image in images if is_image_file(image)]
# For every sub-directory found, prepare it's name and path for rendering.
subdir_data = []
for subdir in subdirs:
subdir_data.append({
'path': '/gallery/' + str(subdir.relative_to(settings.GALLERY_ROOT)),
'name': subdir.name
})
# Get the page number to show, if any. Default to the first one if unavailable or error.
try:
page = int(request.GET.get('page', 1))
except ValueError:
page = 1
# Compute the page offset to show.
num_pages = ceil((len(images) / CELLS_PER_ROW) / ROWS_PER_PAGE)
page_offset = IMAGES_PER_PAGE * (page - 1)
# Slice the images found with the page offset and prepare them for rendering.
img_data = []
for image in images[page_offset : page_offset + IMAGES_PER_PAGE]:
# Create thumbnails as needed.
make_thumbnail(image)
# Get the path of the image relative to the gallery root.
rel_path = image.relative_to(settings.GALLERY_ROOT)
# For each image, prepare it's path, thumbnail path and name for rendering.
img_data.append({
'path': '/gallery/' + str(rel_path),
'name': image.name,
'thumbnail': '/thumbs/' + str(rel_path)
})
# Rendering context.
context = {
'path': path,
'subdirs': list2rows(subdir_data),
'images': list2rows(img_data),
'pages': range(1, num_pages + 1),
'num_files': len(images),
'page': page,
'prev_page': page - 1,
'next_page': page + 1,
'num_pages': num_pages,
'search': urlencode({'search': search}) if search != '' else None,
'search_text': search
}
# Glorious success!
return render(request, 'gallery_view.html', context)
else:
# If the path points to an image, then get it's real path, and parent directory path.
image = Path('/imgs/').joinpath(path)
img_dir = settings.GALLERY_ROOT.joinpath(path).parent
# Then get all sibling images.
images = sorted([i.name for i in img_dir.iterdir() if i.is_file() and is_image_file(i)])
# Get the current image's index in it's directory.
index = images.index(image.name)
# Get the previous and next image indices.
previous = index - 1 if index > 0 else None
following = index + 1 if index < len(images) - 1 else None
# Set the current, previous and next images as the rendering context.
context = {
'image_path': image,
'prev': images[previous] if previous is not None else None,
'next': images[following] if following is not None else None
}
# Glorious success!
return render(request, 'image_view.html', context)
else:
# 404 if the path wasn't found.
return HttpResponseNotFound('Not found')