Redesigned gallery view layout and added sorting tests
This commit is contained in:
@@ -1,176 +1,591 @@
|
||||
{% load static %}
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, height=device-height" />
|
||||
<title>
|
||||
Folder: {{request.path}}
|
||||
</title>
|
||||
<link href="{% static 'css/styles.css' %}" rel="stylesheet">
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Gallery</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">
|
||||
<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>
|
||||
|
||||
<body class="background">
|
||||
<!-- Navigation bar. -->
|
||||
<table class="fc mauto">
|
||||
<tr>
|
||||
<!-- Home button. -->
|
||||
<td>
|
||||
<a href="/gallery/" class="mr-2">
|
||||
<img src="{% static 'imgs/gohome.png' %}" class="small-nav-icon">
|
||||
</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}}
|
||||
<body class="theme-{{ theme }}">
|
||||
<div class="app-shell d-flex">
|
||||
<aside class="sidebar d-none d-lg-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>
|
||||
<a class="btn btn-sm btn-plain ms-auto" href="{{ theme_toggle_url }}" aria-label="Toggle theme">
|
||||
{% if theme == 'dark' %}
|
||||
<i class="fa-solid fa-sun"></i>
|
||||
{% else %}
|
||||
Search: {{search_text}} - Files: {{num_files}}
|
||||
<i class="fa-solid fa-moon"></i>
|
||||
{% endif %}
|
||||
</h1>
|
||||
</td>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- 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 %}
|
||||
<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>
|
||||
</td>
|
||||
{% endif %}
|
||||
</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 class="noscript-sidebar sidebar d-lg-none" style="display:none;">
|
||||
<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>
|
||||
<a class="btn btn-sm btn-plain ms-auto" 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>
|
||||
</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" 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 %}
|
||||
<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>
|
||||
</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 %}
|
||||
<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>
|
||||
|
||||
<!-- Logout button. -->
|
||||
<td>
|
||||
<form method="post" action="{% url 'logout' %}">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="clear-btn">
|
||||
<img src="{% static 'imgs/boot.png' %}" class="small-nav-icon">
|
||||
<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>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<!-- Search form. -->
|
||||
{% if not search %}
|
||||
<form action="{% url 'gallery_view_root' %}" method="GET">
|
||||
<table class="fc mauto">
|
||||
<tr>
|
||||
<!-- Search title. -->
|
||||
<td>
|
||||
<h3>
|
||||
Search:
|
||||
</h3>
|
||||
</td>
|
||||
|
||||
<!-- Search bar. -->
|
||||
<td>
|
||||
<input type="search" name="search" class="search-box">
|
||||
</td>
|
||||
|
||||
<!-- Search submit. -->
|
||||
<td>
|
||||
<button type="submit" class="clear-btn">
|
||||
<img src="{% static 'imgs/find.png' %}" class="small-nav-icon">
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
<!-- 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>
|
||||
</td>
|
||||
{% endif %}
|
||||
|
||||
<!-- 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 %}
|
||||
</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>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sub-directory rows. -->
|
||||
<div class="centered-container">
|
||||
<table class="mauto">
|
||||
<tbody>
|
||||
{% for row in subdirs %}
|
||||
<tr>
|
||||
{% for subdir in row %}
|
||||
<td class="column">
|
||||
<a href="{{subdir.path}}">
|
||||
<img src="{% static 'imgs/folder-pictures.png' %}">
|
||||
<br>
|
||||
<span>
|
||||
{{subdir.name}}
|
||||
</span>
|
||||
</a>
|
||||
</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
<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>
|
||||
|
||||
385
viewer/test.py
Normal file
385
viewer/test.py
Normal 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)
|
||||
509
viewer/views.py
509
viewer/views.py
@@ -1,53 +1,270 @@
|
||||
# Standard library imports.
|
||||
from pathlib import Path
|
||||
from math import ceil
|
||||
from urllib.parse import urlencode
|
||||
from functools import cmp_to_key
|
||||
|
||||
# Django imports.
|
||||
from django.http import HttpResponseNotFound
|
||||
from django.conf import settings
|
||||
from django.utils.http import urlencode
|
||||
from django.http import HttpResponseNotFound
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.shortcuts import (render,
|
||||
redirect)
|
||||
from django.shortcuts import render, redirect
|
||||
|
||||
# Project imports.
|
||||
from .utils import make_thumbnail, is_image_file
|
||||
|
||||
###########################################################################################
|
||||
# CONSTANTS. #
|
||||
# Constants. #
|
||||
###########################################################################################
|
||||
|
||||
CELLS_PER_ROW = 7
|
||||
ROWS_PER_PAGE = 4
|
||||
IMAGES_PER_PAGE = CELLS_PER_ROW * ROWS_PER_PAGE
|
||||
SORT_OPTIONS = [
|
||||
("abc", "Alphabetical A-Z"),
|
||||
("cba", "Alphabetical Z-A"),
|
||||
("old", "Creation date old to new"),
|
||||
("new", "Creation date new to old"),
|
||||
("recent", "Modification date most recent"),
|
||||
("tnecer", "Modification date least recent"),
|
||||
]
|
||||
|
||||
SORT_LABELS = dict(SORT_OPTIONS)
|
||||
|
||||
###########################################################################################
|
||||
# Helper functions. #
|
||||
###########################################################################################
|
||||
|
||||
|
||||
def normalize_sort(sort_value):
|
||||
return sort_value if sort_value in SORT_LABELS else "abc"
|
||||
|
||||
|
||||
def normalize_theme(theme_value):
|
||||
return theme_value if theme_value in ("dark", "light") else "dark"
|
||||
|
||||
|
||||
def get_creation_timestamp(path_obj):
|
||||
try:
|
||||
stat_data = path_obj.stat()
|
||||
return getattr(stat_data, "st_birthtime", stat_data.st_ctime)
|
||||
except OSError:
|
||||
return 0
|
||||
|
||||
|
||||
def get_modification_timestamp(path_obj):
|
||||
try:
|
||||
return path_obj.stat().st_mtime
|
||||
except OSError:
|
||||
return 0
|
||||
|
||||
|
||||
def build_query(search_text, sort_key, theme):
|
||||
query = {"sort": sort_key, "theme": theme}
|
||||
|
||||
if search_text != "":
|
||||
query["search"] = search_text
|
||||
|
||||
return query
|
||||
|
||||
|
||||
def append_query(url, query_dict):
|
||||
if len(query_dict) == 0:
|
||||
return url
|
||||
|
||||
return url + "?" + urlencode(query_dict)
|
||||
|
||||
|
||||
def gallery_url(path_obj=None, is_dir=False, query_dict=None):
|
||||
if query_dict is None:
|
||||
query_dict = {}
|
||||
|
||||
if path_obj is None:
|
||||
base_url = "/gallery/"
|
||||
else:
|
||||
path_text = str(path_obj).replace("\\", "/")
|
||||
base_url = "/gallery/" + path_text
|
||||
|
||||
if is_dir and not base_url.endswith("/"):
|
||||
base_url += "/"
|
||||
|
||||
return append_query(base_url, query_dict)
|
||||
|
||||
|
||||
def do_recursive_search(start_path, query):
|
||||
"""
|
||||
Gets all images and sub-directories inside the start_path whose name matches the given query,
|
||||
and then joins the results recursively by iterating in each sub-directory.
|
||||
"""
|
||||
|
||||
# Get all sub-dirs and images that match the query.
|
||||
all_subdirs = sorted([i for i in start_path.iterdir() if i.is_dir()])
|
||||
subdirs = sorted([i for i in all_subdirs if query.lower() in i.name.lower()])
|
||||
images = sorted([i for i in start_path.iterdir() if i.is_file() and query.lower() in i.name.lower()])
|
||||
try:
|
||||
entries = [entry for entry in start_path.iterdir() if not entry.is_symlink()]
|
||||
except OSError:
|
||||
return [], []
|
||||
|
||||
all_subdirs = sorted(
|
||||
[entry for entry in entries if entry.is_dir()],
|
||||
key=lambda item: (item.name.lower(), str(item).lower()),
|
||||
)
|
||||
|
||||
subdirs = [entry for entry in all_subdirs if query.lower() in entry.name.lower()]
|
||||
|
||||
images = sorted(
|
||||
[
|
||||
entry
|
||||
for entry in entries
|
||||
if entry.is_file()
|
||||
and is_image_file(entry)
|
||||
and query.lower() in entry.name.lower()
|
||||
],
|
||||
key=lambda item: (item.name.lower(), str(item).lower()),
|
||||
)
|
||||
|
||||
# For all sub-directories, regardless of the query.
|
||||
for subdir in all_subdirs:
|
||||
# Do a recursive search.
|
||||
rec_subdirs, rec_images = do_recursive_search(subdir, query.lower())
|
||||
|
||||
# Join the results if any.
|
||||
subdirs.extend(rec_subdirs)
|
||||
images.extend(rec_images)
|
||||
|
||||
return subdirs, images
|
||||
|
||||
|
||||
def sort_images(images, sort_key):
|
||||
def compare(img_a, img_b):
|
||||
name_a = img_a.name.lower()
|
||||
name_b = img_b.name.lower()
|
||||
rel_a = str(img_a.relative_to(settings.GALLERY_ROOT)).lower()
|
||||
rel_b = str(img_b.relative_to(settings.GALLERY_ROOT)).lower()
|
||||
|
||||
if sort_key == "abc":
|
||||
if name_a < name_b:
|
||||
return -1
|
||||
if name_a > name_b:
|
||||
return 1
|
||||
|
||||
elif sort_key == "cba":
|
||||
if name_a > name_b:
|
||||
return -1
|
||||
if name_a < name_b:
|
||||
return 1
|
||||
|
||||
elif sort_key == "old":
|
||||
created_a = get_creation_timestamp(img_a)
|
||||
created_b = get_creation_timestamp(img_b)
|
||||
|
||||
if created_a < created_b:
|
||||
return -1
|
||||
if created_a > created_b:
|
||||
return 1
|
||||
|
||||
elif sort_key == "new":
|
||||
created_a = get_creation_timestamp(img_a)
|
||||
created_b = get_creation_timestamp(img_b)
|
||||
|
||||
if created_a > created_b:
|
||||
return -1
|
||||
if created_a < created_b:
|
||||
return 1
|
||||
|
||||
elif sort_key == "recent":
|
||||
modified_a = get_modification_timestamp(img_a)
|
||||
modified_b = get_modification_timestamp(img_b)
|
||||
|
||||
if modified_a > modified_b:
|
||||
return -1
|
||||
if modified_a < modified_b:
|
||||
return 1
|
||||
|
||||
elif sort_key == "tnecer":
|
||||
modified_a = get_modification_timestamp(img_a)
|
||||
modified_b = get_modification_timestamp(img_b)
|
||||
|
||||
if modified_a < modified_b:
|
||||
return -1
|
||||
if modified_a > modified_b:
|
||||
return 1
|
||||
|
||||
if name_a < name_b:
|
||||
return -1
|
||||
if name_a > name_b:
|
||||
return 1
|
||||
if rel_a < rel_b:
|
||||
return -1
|
||||
if rel_a > rel_b:
|
||||
return 1
|
||||
return 0
|
||||
|
||||
return sorted(images, key=cmp_to_key(compare))
|
||||
|
||||
|
||||
def build_breadcrumbs(path_text, query_dict):
|
||||
breadcrumbs = [{"label": "Gallery", "path": gallery_url(None, True, query_dict)}]
|
||||
|
||||
if path_text == "":
|
||||
return breadcrumbs
|
||||
|
||||
segments = Path(path_text).parts
|
||||
current = Path("")
|
||||
|
||||
for segment in segments:
|
||||
current = current.joinpath(segment)
|
||||
breadcrumbs.append(
|
||||
{"label": segment, "path": gallery_url(current, True, query_dict)}
|
||||
)
|
||||
|
||||
return breadcrumbs
|
||||
|
||||
|
||||
def build_sort_options(request, search_text, sort_key, theme):
|
||||
options = []
|
||||
|
||||
for option_key, label in SORT_OPTIONS:
|
||||
query = {"sort": option_key, "theme": theme}
|
||||
|
||||
if search_text != "":
|
||||
query["search"] = search_text
|
||||
|
||||
options.append(
|
||||
{
|
||||
"key": option_key,
|
||||
"label": label,
|
||||
"url": append_query(request.path, query),
|
||||
"is_active": option_key == sort_key,
|
||||
}
|
||||
)
|
||||
|
||||
return options
|
||||
|
||||
|
||||
def get_first_image_thumbnail_url(subdir):
|
||||
try:
|
||||
images = sorted(
|
||||
[
|
||||
entry
|
||||
for entry in subdir.iterdir()
|
||||
if entry.is_file() and not entry.is_symlink() and is_image_file(entry)
|
||||
],
|
||||
key=lambda item: (
|
||||
item.name.lower(),
|
||||
str(item.relative_to(settings.GALLERY_ROOT)).lower(),
|
||||
),
|
||||
)
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
if len(images) == 0:
|
||||
return None
|
||||
|
||||
first_image = images[0]
|
||||
|
||||
try:
|
||||
make_thumbnail(first_image)
|
||||
rel_path = first_image.relative_to(settings.GALLERY_ROOT)
|
||||
thumb_path = settings.THUMBNAILS_ROOT.joinpath(rel_path)
|
||||
|
||||
if thumb_path.exists():
|
||||
return "/thumbs/" + str(rel_path).replace("\\", "/")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
|
||||
###########################################################################################
|
||||
# View functions. #
|
||||
###########################################################################################
|
||||
@@ -55,132 +272,156 @@ def do_recursive_search(start_path, query):
|
||||
|
||||
@login_required
|
||||
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
|
||||
def gallery_view(request, path = None):
|
||||
def gallery_view(request, path=None):
|
||||
"""
|
||||
Shows a list of subdirectories and image files inside the given path.
|
||||
The path should be inside the GALLERY_ROOT path, otherwise a 404 error will be thrown.
|
||||
"""
|
||||
|
||||
def list2rows(lst, cells = CELLS_PER_ROW):
|
||||
"""
|
||||
Converts a list into a matrix with the given amount of cells per row.
|
||||
"""
|
||||
root = settings.GALLERY_ROOT.resolve()
|
||||
|
||||
rows = []
|
||||
i = 0
|
||||
while i < len(lst):
|
||||
row = []
|
||||
for c in range(cells):
|
||||
try:
|
||||
row.append(lst[i + c])
|
||||
except Exception:
|
||||
pass
|
||||
rows.append(row)
|
||||
i += cells
|
||||
return rows
|
||||
try:
|
||||
candidate = root.joinpath(path).resolve() if path is not None else root
|
||||
candidate.relative_to(root)
|
||||
except Exception:
|
||||
return HttpResponseNotFound("Not found")
|
||||
|
||||
# Get the absolute path to show if any, else show the base gallery path.
|
||||
full_path = settings.GALLERY_ROOT.joinpath(path) if path is not None else settings.GALLERY_ROOT
|
||||
if not candidate.exists():
|
||||
return HttpResponseNotFound("Not found")
|
||||
|
||||
# Get the search query from the request, if any.
|
||||
search = request.GET.get('search', default = '')
|
||||
path_text = path if path is not None else ""
|
||||
|
||||
if full_path.exists():
|
||||
# If the path exists then check if it points to a directory or an image.
|
||||
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)])
|
||||
if candidate.is_dir():
|
||||
return _render_directory(request, path_text, candidate)
|
||||
|
||||
else:
|
||||
# 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')
|
||||
return _render_image(request, path_text, candidate)
|
||||
|
||||
Reference in New Issue
Block a user