feat: recursive subfolder filtering + gallery tree navigation (#140)
* feat: recursive subfolder filtering + gallery tree navigation + thumbnail sizing - Recursive subfolder filtering: selecting an intermediate folder shows all child images - Gallery tree navigation panel with collapsible folder hierarchy (sticky, resizable) - Thumbnail size controls (S/M/L) for adjustable grid density - Fix path collision when multiple gallery roots share overlapping relative paths - Fix auto-detect mode collapsing nested folders to ambiguous basenames - Security test suite for path traversal edge cases Addresses feedback from #126. Fixes code review items P1/P2 from #135. Co-Authored-By: Kai Bowie <kaigtb@proton.me> * style: fix Black formatting in test_path_security.py * feat(gallery): add folder tree sidebar with hierarchical navigation (#126) - Add collapsible folder tree sidebar to gallery view - Support hierarchical year/month/date folder navigation - Include ancestor path expansion for proper tree structure - Add subfolder filtering to gallery image queries - Update admin subfolder dropdown with ancestor support - Fix trailing whitespace in SQL queries Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Kai <noreply@gtb.ai> Co-authored-by: Kai Bowie <kaigtb@proton.me> Co-authored-by: Kai <kai@tachi-02.localdomain> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
Kai
Kai Bowie
Kai
parent
96f19636e6
commit
c01d8e3c12
+27
-8
@@ -499,12 +499,22 @@ class PromptDatabase:
|
||||
)
|
||||
return [row["category"] for row in cursor.fetchall()]
|
||||
|
||||
def get_prompt_subfolders(self, root_dirs: Optional[List[str]] = None) -> List[str]:
|
||||
def get_prompt_subfolders(
|
||||
self,
|
||||
root_dirs: Optional[List[str]] = None,
|
||||
include_ancestors: bool = False,
|
||||
) -> List[str]:
|
||||
"""
|
||||
Get distinct subfolder paths from generated_images.
|
||||
|
||||
Extracts the directory portion of image_path, made relative to
|
||||
root_dirs if provided. Returns sorted unique folder names.
|
||||
|
||||
Args:
|
||||
root_dirs: Gallery root directories for computing relative paths
|
||||
include_ancestors: If True, also include all ancestor path segments
|
||||
so that hierarchical tree navigation works. For example, a path
|
||||
``2026/08-Aug/2026-08-06`` also adds ``2026`` and ``2026/08-Aug``.
|
||||
"""
|
||||
with self.model.get_connection() as conn:
|
||||
cursor = conn.execute(
|
||||
@@ -533,9 +543,18 @@ class PromptDatabase:
|
||||
continue
|
||||
|
||||
if not made_relative:
|
||||
basename = os.path.basename(parent)
|
||||
if basename:
|
||||
folders.add(basename)
|
||||
# Preserve full relative-style path instead of collapsing
|
||||
# to just the basename, which loses hierarchy and creates
|
||||
# ambiguity (e.g. foo/bar and baz/bar both become "bar").
|
||||
folders.add(parent)
|
||||
|
||||
if include_ancestors:
|
||||
ancestors = set()
|
||||
for folder in folders:
|
||||
parts = folder.replace("\\", "/").split("/")
|
||||
for i in range(1, len(parts)):
|
||||
ancestors.add("/".join(parts[:i]))
|
||||
folders.update(ancestors)
|
||||
|
||||
return sorted(folders)
|
||||
|
||||
@@ -1088,10 +1107,10 @@ class PromptDatabase:
|
||||
# Note: Removed ORDER BY from GROUP_CONCAT for SQLite compatibility
|
||||
# We'll sort the IDs manually after fetching
|
||||
cursor = conn.execute("""
|
||||
SELECT LOWER(TRIM(text)) as normalized_text, COUNT(*) as count,
|
||||
SELECT LOWER(TRIM(text)) as normalized_text, COUNT(*) as count,
|
||||
GROUP_CONCAT(id) as ids,
|
||||
GROUP_CONCAT(created_at) as created_dates
|
||||
FROM prompts
|
||||
FROM prompts
|
||||
GROUP BY LOWER(TRIM(text))
|
||||
HAVING COUNT(*) > 1
|
||||
""")
|
||||
@@ -1160,10 +1179,10 @@ class PromptDatabase:
|
||||
# Note: Removed ORDER BY from GROUP_CONCAT for SQLite compatibility
|
||||
# We'll sort the IDs manually after fetching
|
||||
cursor = conn.execute("""
|
||||
SELECT LOWER(TRIM(text)) as normalized_text, COUNT(*) as count,
|
||||
SELECT LOWER(TRIM(text)) as normalized_text, COUNT(*) as count,
|
||||
GROUP_CONCAT(id) as ids,
|
||||
GROUP_CONCAT(created_at) as created_dates
|
||||
FROM prompts
|
||||
FROM prompts
|
||||
GROUP BY LOWER(TRIM(text))
|
||||
HAVING COUNT(*) > 1
|
||||
""")
|
||||
|
||||
+29
-6
@@ -209,12 +209,12 @@ class ImageRoutesMixin:
|
||||
offset = int(request.query.get("offset", 0))
|
||||
subfolder = request.query.get("subfolder", "").strip()
|
||||
|
||||
# Collect (path, mtime, root) from all directories
|
||||
# Collect (path, mtime, root, root_index) from all directories
|
||||
all_images = []
|
||||
for output_path in output_dirs:
|
||||
for root_idx, output_path in enumerate(output_dirs):
|
||||
dir_images = await self._get_gallery_files(output_path)
|
||||
for img_path, mtime in dir_images:
|
||||
all_images.append((img_path, mtime, output_path))
|
||||
all_images.append((img_path, mtime, output_path, root_idx))
|
||||
|
||||
# Sort combined results by mtime (newest first) — no .stat() calls
|
||||
all_images.sort(key=lambda x: x[1], reverse=True)
|
||||
@@ -222,10 +222,10 @@ class ImageRoutesMixin:
|
||||
# Apply subfolder filter if provided
|
||||
if subfolder:
|
||||
filtered = []
|
||||
for img_path, mtime, root in all_images:
|
||||
for img_path, mtime, root, ridx in all_images:
|
||||
rel_dir = str(img_path.relative_to(root).parent)
|
||||
if rel_dir == subfolder or rel_dir.startswith(subfolder + os.sep):
|
||||
filtered.append((img_path, mtime, root))
|
||||
filtered.append((img_path, mtime, root, ridx))
|
||||
all_images = filtered
|
||||
|
||||
total = len(all_images)
|
||||
@@ -235,7 +235,7 @@ class ImageRoutesMixin:
|
||||
|
||||
def _format_page():
|
||||
images = []
|
||||
for media_path, mtime, output_path in paginated:
|
||||
for media_path, mtime, output_path, root_index in paginated:
|
||||
try:
|
||||
stat = media_path.stat()
|
||||
rel_path = media_path.relative_to(output_path)
|
||||
@@ -260,6 +260,7 @@ class ImageRoutesMixin:
|
||||
"path": str(media_path),
|
||||
"relative_path": str(rel_path),
|
||||
"root_dir": str(output_path),
|
||||
"root_index": root_index,
|
||||
"url": f"/prompt_manager/images/serve/{rel_path.as_posix()}",
|
||||
"thumbnail_url": thumbnail_url,
|
||||
"size": stat.st_size,
|
||||
@@ -364,6 +365,17 @@ class ImageRoutesMixin:
|
||||
status=404,
|
||||
)
|
||||
|
||||
# If a root index is provided, use only that root to avoid
|
||||
# path collisions when multiple roots share the same relative path.
|
||||
root_idx = request.query.get("root")
|
||||
if root_idx is not None:
|
||||
try:
|
||||
idx = int(root_idx)
|
||||
if 0 <= idx < len(output_dirs):
|
||||
output_dirs = [output_dirs[idx]]
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
|
||||
# Try each root directory until the file is found
|
||||
for output_path in output_dirs:
|
||||
image_path = (output_path / filepath).resolve()
|
||||
@@ -397,6 +409,17 @@ class ImageRoutesMixin:
|
||||
if rel_dir and rel_dir != ".":
|
||||
subfolders.add(rel_dir)
|
||||
|
||||
include_ancestors = (
|
||||
request.query.get("include_ancestors", "").lower() == "true"
|
||||
)
|
||||
if include_ancestors:
|
||||
ancestors = set()
|
||||
for folder in subfolders:
|
||||
parts = folder.replace("\\", "/").split("/")
|
||||
for i in range(1, len(parts)):
|
||||
ancestors.add("/".join(parts[:i]))
|
||||
subfolders.update(ancestors)
|
||||
|
||||
return web.json_response(
|
||||
{"success": True, "subfolders": sorted(subfolders)}
|
||||
)
|
||||
|
||||
+4
-1
@@ -170,8 +170,11 @@ class PromptRoutesMixin:
|
||||
from ..config import GalleryConfig
|
||||
|
||||
root_dirs = list(GalleryConfig.MONITORING_DIRECTORIES) or None
|
||||
include_ancestors = (
|
||||
request.query.get("include_ancestors", "").lower() == "true"
|
||||
)
|
||||
subfolders = await self._run_in_executor(
|
||||
self.db.get_prompt_subfolders, root_dirs
|
||||
self.db.get_prompt_subfolders, root_dirs, include_ancestors
|
||||
)
|
||||
return web.json_response({"success": True, "subfolders": subfolders})
|
||||
except Exception as e:
|
||||
|
||||
@@ -194,6 +194,37 @@ class TestGetPromptSubfolders(LoraDBTestCase):
|
||||
self.assertIsInstance(folders, list)
|
||||
self.assertGreater(len(folders), 0)
|
||||
|
||||
def test_include_ancestors_adds_intermediate_paths(self):
|
||||
pid = self._save("prompt")
|
||||
self._link_image(pid, "/output/2026/08-Aug/2026-08-06/img.png")
|
||||
|
||||
folders = self.db.get_prompt_subfolders(
|
||||
root_dirs=["/output"], include_ancestors=True
|
||||
)
|
||||
self.assertIn("2026", folders)
|
||||
self.assertIn("2026/08-Aug", folders)
|
||||
self.assertIn("2026/08-Aug/2026-08-06", folders)
|
||||
|
||||
def test_include_ancestors_false_no_intermediates(self):
|
||||
pid = self._save("prompt")
|
||||
self._link_image(pid, "/output/2026/08-Aug/2026-08-06/img.png")
|
||||
|
||||
folders = self.db.get_prompt_subfolders(
|
||||
root_dirs=["/output"], include_ancestors=False
|
||||
)
|
||||
self.assertIn("2026/08-Aug/2026-08-06", folders)
|
||||
self.assertNotIn("2026", folders)
|
||||
self.assertNotIn("2026/08-Aug", folders)
|
||||
|
||||
def test_include_ancestors_sorted(self):
|
||||
pid = self._save("prompt")
|
||||
self._link_image(pid, "/output/a/b/c/img.png")
|
||||
|
||||
folders = self.db.get_prompt_subfolders(
|
||||
root_dirs=["/output"], include_ancestors=True
|
||||
)
|
||||
self.assertEqual(folders, sorted(folders))
|
||||
|
||||
|
||||
# ── LoRA prompt workflow ──────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
"""Tests for path traversal and symlink security in image serving."""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class TestPathSecurity(unittest.TestCase):
|
||||
"""Verify that path resolution and boundary checks prevent escapes."""
|
||||
|
||||
def setUp(self):
|
||||
self.tmpdir = tempfile.mkdtemp()
|
||||
self.output_dir = Path(self.tmpdir) / "output"
|
||||
self.output_dir.mkdir()
|
||||
self.secret_dir = Path(self.tmpdir) / "secret"
|
||||
self.secret_dir.mkdir()
|
||||
|
||||
# Create a legitimate image
|
||||
(self.output_dir / "legit.png").write_bytes(b"PNG")
|
||||
|
||||
# Create a secret file outside the output dir
|
||||
(self.secret_dir / "password.txt").write_text("hunter2")
|
||||
|
||||
def tearDown(self):
|
||||
import shutil
|
||||
|
||||
shutil.rmtree(self.tmpdir, ignore_errors=True)
|
||||
|
||||
def _is_safe_path(self, output_path, filepath):
|
||||
"""Reproduce the safety check from serve_output_image."""
|
||||
image_path = (output_path / filepath).resolve()
|
||||
return image_path.is_relative_to(output_path.resolve()) and image_path.exists()
|
||||
|
||||
def test_normal_path_allowed(self):
|
||||
self.assertTrue(self._is_safe_path(self.output_dir, "legit.png"))
|
||||
|
||||
def test_dot_dot_traversal_blocked(self):
|
||||
self.assertFalse(self._is_safe_path(self.output_dir, "../secret/password.txt"))
|
||||
|
||||
def test_encoded_dot_dot_blocked(self):
|
||||
# Even if someone encodes ../ as %2e%2e%2f, Path resolution catches it
|
||||
self.assertFalse(
|
||||
self._is_safe_path(self.output_dir, "..%2fsecret%2fpassword.txt")
|
||||
)
|
||||
|
||||
def test_absolute_path_blocked(self):
|
||||
secret_path = str(self.secret_dir / "password.txt")
|
||||
self.assertFalse(self._is_safe_path(self.output_dir, secret_path))
|
||||
|
||||
def test_symlink_escape_blocked(self):
|
||||
# Create a symlink inside output_dir pointing outside
|
||||
symlink_path = self.output_dir / "escape_link"
|
||||
try:
|
||||
symlink_path.symlink_to(self.secret_dir / "password.txt")
|
||||
except OSError:
|
||||
self.skipTest("Cannot create symlinks on this platform")
|
||||
|
||||
# resolve() follows the symlink, so it should resolve to
|
||||
# secret_dir which is NOT relative_to output_dir
|
||||
image_path = symlink_path.resolve()
|
||||
self.assertFalse(image_path.is_relative_to(self.output_dir.resolve()))
|
||||
|
||||
def test_symlink_within_root_allowed(self):
|
||||
# A symlink that stays within the output dir should be fine
|
||||
subdir = self.output_dir / "subdir"
|
||||
subdir.mkdir()
|
||||
(subdir / "img.png").write_bytes(b"PNG")
|
||||
|
||||
symlink_path = self.output_dir / "link_to_sub"
|
||||
try:
|
||||
symlink_path.symlink_to(subdir / "img.png")
|
||||
except OSError:
|
||||
self.skipTest("Cannot create symlinks on this platform")
|
||||
|
||||
image_path = symlink_path.resolve()
|
||||
self.assertTrue(image_path.is_relative_to(self.output_dir.resolve()))
|
||||
|
||||
def test_nested_traversal_blocked(self):
|
||||
# Deep nested traversal: sub/../../secret/password.txt
|
||||
sub = self.output_dir / "sub"
|
||||
sub.mkdir()
|
||||
self.assertFalse(
|
||||
self._is_safe_path(self.output_dir, "sub/../../secret/password.txt")
|
||||
)
|
||||
|
||||
def test_null_byte_in_path(self):
|
||||
# Null bytes should not bypass checks
|
||||
try:
|
||||
result = self._is_safe_path(self.output_dir, "legit.png\x00.txt")
|
||||
except ValueError:
|
||||
# Python raises ValueError for embedded null bytes — this is safe
|
||||
result = False
|
||||
self.assertFalse(result)
|
||||
|
||||
|
||||
class TestRootIndexSecurity(unittest.TestCase):
|
||||
"""Verify that root_index parameter is validated safely."""
|
||||
|
||||
def test_negative_index_ignored(self):
|
||||
"""Negative root index should not select any directory."""
|
||||
output_dirs = [Path("/fake/dir1"), Path("/fake/dir2")]
|
||||
idx = -1
|
||||
# The code checks 0 <= idx < len(output_dirs)
|
||||
self.assertFalse(0 <= idx < len(output_dirs))
|
||||
|
||||
def test_out_of_range_index_ignored(self):
|
||||
output_dirs = [Path("/fake/dir1")]
|
||||
idx = 5
|
||||
self.assertFalse(0 <= idx < len(output_dirs))
|
||||
|
||||
def test_valid_index_selects_correct_root(self):
|
||||
output_dirs = [Path("/fake/dir1"), Path("/fake/dir2"), Path("/fake/dir3")]
|
||||
idx = 1
|
||||
self.assertTrue(0 <= idx < len(output_dirs))
|
||||
self.assertEqual(output_dirs[idx], Path("/fake/dir2"))
|
||||
|
||||
def test_non_numeric_index_handled(self):
|
||||
"""Non-numeric root value should not crash."""
|
||||
try:
|
||||
int("abc")
|
||||
self.fail("Should have raised ValueError")
|
||||
except ValueError:
|
||||
pass # This is the expected behavior
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+41
-20
@@ -38,6 +38,11 @@
|
||||
<option value="500">500</option>
|
||||
</select>
|
||||
<span class="text-xs text-pm-secondary">images</span>
|
||||
<span class="text-pm-secondary mx-1">|</span>
|
||||
<label class="text-xs text-pm-secondary">Size:</label>
|
||||
<button id="thumbSmBtn" class="px-2 py-1 bg-pm-input hover:bg-pm-hover text-pm text-xs rounded-pm-sm transition-colors" title="Small thumbnails">S</button>
|
||||
<button id="thumbMdBtn" class="px-2 py-1 bg-pm-accent text-pm-accent-fg text-xs rounded-pm-sm transition-colors" title="Medium thumbnails">M</button>
|
||||
<button id="thumbLgBtn" class="px-2 py-1 bg-pm-input hover:bg-pm-hover text-pm text-xs rounded-pm-sm transition-colors" title="Large thumbnails">L</button>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button id="refreshBtn"
|
||||
@@ -82,31 +87,46 @@
|
||||
</div>
|
||||
|
||||
<!-- Gallery Content -->
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
<div class="max-w-7xl mx-auto p-3">
|
||||
<!-- Loading State -->
|
||||
<div id="loadingState" class="flex items-center justify-center py-12">
|
||||
<div class="text-center">
|
||||
<div class="w-8 h-8 border-4 border-pm-accent-tint border-t-pm-accent rounded-full animate-spin mx-auto mb-3"></div>
|
||||
<p class="text-pm-secondary text-xs">Loading gallery...</p>
|
||||
<div class="flex-1 overflow-hidden">
|
||||
<div class="max-w-full mx-auto p-3 flex gap-0" style="height: calc(100vh - 140px);">
|
||||
<!-- Folder Tree Panel — sticky, scrolls independently -->
|
||||
<div id="folderTreePanel" class="hidden flex-shrink-0 bg-pm-surface border border-pm rounded-pm-sm overflow-y-auto" style="width: 240px; min-width: 160px;">
|
||||
<div class="px-3 py-2 border-b border-pm-subtle flex items-center justify-between sticky top-0 bg-pm-surface z-10">
|
||||
<span class="text-sm font-semibold text-pm">Folders</span>
|
||||
<button id="clearFolderFilterBtn" class="text-sm text-pm-secondary hover:text-pm transition-colors" title="Clear filter">×</button>
|
||||
</div>
|
||||
<div id="folderTree" class="py-1 text-sm">
|
||||
<!-- Tree nodes inserted by JS -->
|
||||
</div>
|
||||
</div>
|
||||
<!-- Resize Handle -->
|
||||
<div id="folderResizeHandle" class="hidden w-1 cursor-col-resize bg-transparent hover:bg-pm-accent/30 flex-shrink-0"></div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div id="errorState" class="hidden flex items-center justify-center py-12">
|
||||
<div class="text-center">
|
||||
<h3 class="text-sm font-semibold text-pm-error mb-2">Error Loading Gallery</h3>
|
||||
<p class="text-pm-secondary text-xs mb-3" id="errorMessage">Something went wrong while loading the images.</p>
|
||||
<button id="retryBtn" class="px-3 py-1 bg-pm-accent hover:bg-pm-accent-hover text-pm-accent-fg text-xs font-medium rounded-pm-sm transition-colors">
|
||||
Try Again
|
||||
</button>
|
||||
<!-- Main Gallery Area — scrolls independently -->
|
||||
<div class="flex-1 min-w-0 overflow-y-auto pl-3">
|
||||
<!-- Loading State -->
|
||||
<div id="loadingState" class="flex items-center justify-center py-12">
|
||||
<div class="text-center">
|
||||
<div class="w-8 h-8 border-4 border-pm-accent-tint border-t-pm-accent rounded-full animate-spin mx-auto mb-3"></div>
|
||||
<p class="text-pm-secondary text-xs">Loading gallery...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Gallery Grid -->
|
||||
<div id="galleryGrid" class="hidden grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-8 gap-2">
|
||||
<!-- Images will be inserted here -->
|
||||
</div>
|
||||
<!-- Error State -->
|
||||
<div id="errorState" class="hidden flex items-center justify-center py-12">
|
||||
<div class="text-center">
|
||||
<h3 class="text-sm font-semibold text-pm-error mb-2">Error Loading Gallery</h3>
|
||||
<p class="text-pm-secondary text-xs mb-3" id="errorMessage">Something went wrong while loading the images.</p>
|
||||
<button id="retryBtn" class="px-3 py-1 bg-pm-accent hover:bg-pm-accent-hover text-pm-accent-fg text-xs font-medium rounded-pm-sm transition-colors">
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Gallery Grid -->
|
||||
<div id="galleryGrid" class="hidden grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-8 gap-2">
|
||||
<!-- Images will be inserted here -->
|
||||
</div>
|
||||
|
||||
<!-- Gallery List -->
|
||||
<div id="galleryList" class="hidden space-y-2">
|
||||
@@ -127,6 +147,7 @@
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div> <!-- /Main Gallery Area -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+5
-2
@@ -320,7 +320,7 @@
|
||||
|
||||
async loadSubfolders() {
|
||||
try {
|
||||
const response = await fetch("/prompt_manager/subfolders");
|
||||
const response = await fetch("/prompt_manager/subfolders?include_ancestors=true");
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
@@ -344,7 +344,10 @@
|
||||
this.subfolders.forEach((folder) => {
|
||||
const option = document.createElement("option");
|
||||
option.value = folder;
|
||||
option.textContent = folder;
|
||||
const depth = folder.split("/").length - 1;
|
||||
const indent = "\u00A0\u00A0".repeat(depth);
|
||||
const label = folder.split("/").pop();
|
||||
option.textContent = indent + label;
|
||||
select.appendChild(option);
|
||||
});
|
||||
select.value = current;
|
||||
|
||||
+177
-4
@@ -6,10 +6,14 @@
|
||||
this.total = 0;
|
||||
this.viewMode = 'grid'; // 'grid' or 'list'
|
||||
this.viewer = null;
|
||||
|
||||
this.currentSubfolder = '';
|
||||
this.expandedFolders = new Set();
|
||||
this.thumbSize = 'md'; // sm, md, lg
|
||||
|
||||
this.initializeEventListeners();
|
||||
this.loadFolderTree();
|
||||
this.loadImages();
|
||||
|
||||
|
||||
// Check thumbnails at startup if enabled
|
||||
this.checkThumbnailsAtStartup();
|
||||
}
|
||||
@@ -104,14 +108,183 @@
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Thumbnail size buttons
|
||||
['Sm', 'Md', 'Lg'].forEach(size => {
|
||||
const btn = document.getElementById('thumb' + size + 'Btn');
|
||||
if (btn) btn.addEventListener('click', () => this.setThumbSize(size.toLowerCase()));
|
||||
});
|
||||
|
||||
// Folder tree events
|
||||
document.getElementById('clearFolderFilterBtn').addEventListener('click', () => {
|
||||
this.currentSubfolder = '';
|
||||
this.currentPage = 1;
|
||||
this.renderFolderTree();
|
||||
this.loadImages();
|
||||
});
|
||||
|
||||
// Resize handle for folder panel
|
||||
const resizeHandle = document.getElementById('folderResizeHandle');
|
||||
if (resizeHandle) {
|
||||
let isResizing = false;
|
||||
resizeHandle.addEventListener('mousedown', (e) => {
|
||||
isResizing = true;
|
||||
document.body.style.cursor = 'col-resize';
|
||||
document.body.style.userSelect = 'none';
|
||||
e.preventDefault();
|
||||
});
|
||||
document.addEventListener('mousemove', (e) => {
|
||||
if (!isResizing) return;
|
||||
const panel = document.getElementById('folderTreePanel');
|
||||
const newWidth = Math.max(160, Math.min(500, e.clientX - panel.getBoundingClientRect().left));
|
||||
panel.style.width = newWidth + 'px';
|
||||
});
|
||||
document.addEventListener('mouseup', () => {
|
||||
if (isResizing) {
|
||||
isResizing = false;
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async loadFolderTree() {
|
||||
try {
|
||||
const response = await fetch('/prompt_manager/gallery/subfolders?include_ancestors=true');
|
||||
if (!response.ok) return;
|
||||
const data = await response.json();
|
||||
if (data.success && data.subfolders.length > 0) {
|
||||
this.folderList = data.subfolders;
|
||||
document.getElementById('folderTreePanel').classList.remove('hidden');
|
||||
const rh = document.getElementById('folderResizeHandle');
|
||||
if (rh) rh.classList.remove('hidden');
|
||||
this.renderFolderTree();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Folder tree error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
renderFolderTree() {
|
||||
const container = document.getElementById('folderTree');
|
||||
container.innerHTML = '';
|
||||
|
||||
// Build tree structure from flat sorted paths
|
||||
const tree = {};
|
||||
for (const path of this.folderList) {
|
||||
const parts = path.replace(/\\/g, '/').split('/');
|
||||
let node = tree;
|
||||
for (const part of parts) {
|
||||
if (!node[part]) node[part] = {};
|
||||
node = node[part];
|
||||
}
|
||||
}
|
||||
|
||||
const renderNode = (obj, parentPath, depth) => {
|
||||
const entries = Object.keys(obj).sort();
|
||||
for (const name of entries) {
|
||||
const fullPath = parentPath ? parentPath + '/' + name : name;
|
||||
const hasChildren = Object.keys(obj[name]).length > 0;
|
||||
const isExpanded = this.expandedFolders.has(fullPath);
|
||||
const isActive = this.currentSubfolder === fullPath;
|
||||
|
||||
const row = document.createElement('div');
|
||||
row.className = 'flex items-center px-2 py-1 cursor-pointer hover:bg-pm-hover transition-colors'
|
||||
+ (isActive ? ' bg-pm-accent/10 text-pm-accent font-medium' : ' text-pm');
|
||||
row.style.paddingLeft = (8 + depth * 16) + 'px';
|
||||
|
||||
// Toggle arrow
|
||||
const arrow = document.createElement('span');
|
||||
arrow.className = 'inline-block w-4 text-center text-pm-secondary mr-1 select-none';
|
||||
if (hasChildren) {
|
||||
arrow.textContent = isExpanded ? '\u25BE' : '\u25B8';
|
||||
arrow.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
if (this.expandedFolders.has(fullPath)) {
|
||||
this.expandedFolders.delete(fullPath);
|
||||
} else {
|
||||
this.expandedFolders.add(fullPath);
|
||||
}
|
||||
this.renderFolderTree();
|
||||
});
|
||||
} else {
|
||||
arrow.textContent = '';
|
||||
}
|
||||
row.appendChild(arrow);
|
||||
|
||||
const label = document.createElement('span');
|
||||
label.textContent = name;
|
||||
label.className = 'truncate';
|
||||
row.appendChild(label);
|
||||
|
||||
// Click to filter
|
||||
row.addEventListener('click', () => {
|
||||
this.currentSubfolder = fullPath;
|
||||
this.currentPage = 1;
|
||||
this.renderFolderTree();
|
||||
this.loadImages();
|
||||
});
|
||||
|
||||
container.appendChild(row);
|
||||
|
||||
if (hasChildren && isExpanded) {
|
||||
renderNode(obj[name], fullPath, depth + 1);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// "All" option at top
|
||||
const allRow = document.createElement('div');
|
||||
allRow.className = 'flex items-center px-2 py-1 cursor-pointer hover:bg-pm-hover transition-colors'
|
||||
+ (!this.currentSubfolder ? ' bg-pm-accent/10 text-pm-accent font-medium' : ' text-pm');
|
||||
allRow.style.paddingLeft = '8px';
|
||||
allRow.innerHTML = '<span class="inline-block w-4 mr-1"></span><span>All Images</span>';
|
||||
allRow.addEventListener('click', () => {
|
||||
this.currentSubfolder = '';
|
||||
this.currentPage = 1;
|
||||
this.renderFolderTree();
|
||||
this.loadImages();
|
||||
});
|
||||
container.appendChild(allRow);
|
||||
|
||||
renderNode(tree, '', 0);
|
||||
}
|
||||
|
||||
setThumbSize(size) {
|
||||
this.thumbSize = size;
|
||||
const grid = document.getElementById('galleryGrid');
|
||||
const sizes = {
|
||||
sm: { cols: 'repeat(auto-fill, minmax(100px, 1fr))', gap: '4px' },
|
||||
md: { cols: 'repeat(auto-fill, minmax(180px, 1fr))', gap: '8px' },
|
||||
lg: { cols: 'repeat(auto-fill, minmax(300px, 1fr))', gap: '12px' },
|
||||
};
|
||||
const s = sizes[size] || sizes.md;
|
||||
grid.style.gridTemplateColumns = s.cols;
|
||||
grid.style.gap = s.gap;
|
||||
// Update button states
|
||||
['Sm', 'Md', 'Lg'].forEach(s => {
|
||||
const btn = document.getElementById('thumb' + s + 'Btn');
|
||||
if (btn) {
|
||||
if (s.toLowerCase() === size) {
|
||||
btn.className = btn.className.replace('bg-pm-input', 'bg-pm-accent').replace('text-pm ', 'text-pm-accent-fg ');
|
||||
} else {
|
||||
btn.className = btn.className.replace('bg-pm-accent', 'bg-pm-input').replace('text-pm-accent-fg', 'text-pm');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async loadImages() {
|
||||
this.showLoading();
|
||||
|
||||
|
||||
try {
|
||||
const offset = (this.currentPage - 1) * this.limit;
|
||||
const response = await fetch(`/prompt_manager/images/output?limit=${this.limit}&offset=${offset}`);
|
||||
let url = `/prompt_manager/images/output?limit=${this.limit}&offset=${offset}`;
|
||||
if (this.currentSubfolder) {
|
||||
url += `&subfolder=${encodeURIComponent(this.currentSubfolder)}`;
|
||||
}
|
||||
const response = await fetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
|
||||
Reference in New Issue
Block a user