diff --git a/frontend.html b/frontend.html
index 93f48a5..af755ff 100644
--- a/frontend.html
+++ b/frontend.html
@@ -52,9 +52,65 @@
}
.play-btn:hover { color: rgba(255, 255, 255, 1); transform: scale(1.1); }
/q .hidden { display: none; }
+ #sidebar {
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 280px;
+ height: 100%;
+ background: #2a2a2a;
+ color: #ccc;
+ overflow-y: auto;
+ padding: 16px;
+ font-family: monospace;
+ font-size: 14px;
+ z-index: 100;
+ transform: translateX(-100%);
+ transition: transform 0.2s;
+ }
+ #sidebar.open {
+ transform: translateX(0);
+ }
+ #sidebar a {
+ color: #aaa;
+ text-decoration: none;
+ display: block;
+ padding: 4px 8px;
+ border-radius: 4px;
+ }
+ #sidebar a:hover {
+ background: #3a3a3a;
+ color: #fff;
+ }
+ .breadcrumb {
+ margin-bottom: 12px;
+ padding-bottom: 8px;
+ border-bottom: 1px solid #444;
+ }
+ .breadcrumb a {
+ color: #888;
+ padding: 0;
+ }
+ .breadcrumb a:hover {
+ color: #fff;
+ }
+ .index-item {
+ margin: 2px 0;
+ }
+ .index-item.folder a {
+ font-weight: bold;
+ color: #ddd;
+ }
+ .index-item.file.current a {
+ background: #444;
+ color: #fff;
+ }
+
‹
@@ -109,6 +165,8 @@
if (link && link.getAttribute('href') !== '#') {
link.click();
}
+ } else if (e.key.toLowerCase() === 'i') {
+ document.getElementById('sidebar').classList.toggle('hidden');
}
});
diff --git a/main.py b/main.py
index 5b4b483..764082f 100644
--- a/main.py
+++ b/main.py
@@ -391,6 +391,16 @@ def _render_page(
else:
toggle_url = "#"
+ # Build folder index sidebar if in navigate mode
+ folder_index = ""
+ if navigate_enabled and use_navigate_urls:
+ current_path = file_path
+ folder_index = _render_folder_index_html(
+ current_path=current_path,
+ current_order=current_order,
+ current_delay=current_delay,
+ )
+
content = template.substitute(
img_url="/api/{file_hash}/data".format(file_hash=navigation_data["file_hash"]),
image_click_url=image_click_url or _get_random_hash(),
@@ -402,6 +412,7 @@ def _render_page(
toggle_url=toggle_url,
extra_meta=extra_meta,
play_button=play_button,
+ folder_index=folder_index,
)
return HTMLResponse(content=content)
@@ -635,6 +646,203 @@ def _get_navigation_data_by_path(
}
+def _collect_zip_paths() -> list[str]:
+ """Collect all internal file paths from all ZipFileIndexers."""
+ paths: list[str] = []
+ for indexer in indexers:
+ if isinstance(indexer, ZipFileIndexer):
+ paths.extend(indexer._file_mapping.values())
+ return sorted(paths)
+
+
+def _get_folder_index(path: str | None) -> tuple[list[str], list[str]]:
+ """Get the contents of a folder (subfolders and files) at a given path.
+
+ Args:
+ path: The folder path (e.g., 'folder/' or None for root).
+
+ Returns:
+ Tuple of (subfolder_names, file_names) sorted alphabetically.
+ """
+ all_paths = _collect_zip_paths()
+ prefix = path or ""
+
+ folders: set[str] = set()
+ files: set[str] = set()
+
+ for p in all_paths:
+ if p.startswith(prefix):
+ remainder = p[len(prefix) :]
+ if "/" in remainder:
+ folder_name = remainder.split("/", 1)[0]
+ folders.add(folder_name)
+ else:
+ files.add(remainder)
+
+ return sorted(folders), sorted(files)
+
+
+def _render_folder_index_html(
+ current_path: str | None = None,
+ current_order: str | None = None,
+ current_delay: int | None = None,
+) -> str:
+ """Render a folder index sidebar showing the full tree from root.
+
+ Args:
+ current_path: The currently viewed file path (for highlighting).
+ current_order: Current navigation order.
+ current_delay: Current navigation delay.
+
+ Returns:
+ HTML string for the folder index sidebar.
+ """
+ all_paths = _collect_zip_paths()
+ lines: list[str] = []
+
+ # Build root-level folders and files
+ root_folders: set[str] = set()
+ root_files: set[str] = set()
+ for p in all_paths:
+ if "/" in p:
+ root_folders.add(p.split("/", 1)[0])
+ else:
+ root_files.add(p)
+
+ # Breadcrumb
+ lines.append("
")
+ lines.append('/ ')
+ if current_path:
+ parts = current_path.split("/")
+ accumulated = ""
+ for part in parts[:-1]:
+ accumulated += f"{part}/"
+ lines.append(f'{part} ')
+ lines.append(" ")
+
+ def _render_folder(folder_prefix: str, depth: int = 0) -> list[str]:
+ """Recursively render a folder's contents."""
+ result: list[str] = []
+ indent = " " * depth
+ prefix = folder_prefix or ""
+
+ folders: set[str] = set()
+ files: set[str] = set()
+ for p in all_paths:
+ if p.startswith(prefix):
+ remainder = p[len(prefix) :]
+ if "/" in remainder:
+ folders.add(remainder.split("/", 1)[0])
+ else:
+ files.add(remainder)
+
+ for folder in sorted(folders):
+ folder_path = f"{prefix}{folder}/" if prefix else f"{folder}/"
+ result.append(
+ f'
"
+ )
+ result.extend(_render_folder(folder_path, depth + 1))
+
+ for file in sorted(files):
+ file_path = f"{prefix}{file}" if prefix else file
+ is_current = file_path == current_path
+ cls = "index-item file current" if is_current else "index-item file"
+ href_params = ""
+ if current_order is not None and current_delay is not None:
+ href_params = f"?order={current_order}&delay={current_delay}"
+ result.append(
+ f'
"
+ )
+
+ return result
+
+ lines.extend(_render_folder(""))
+ return "\n".join(lines)
+
+
+def _render_folder_index_page(
+ path: str,
+ folders: list[str],
+ files: list[str],
+ current_order: str | None = None,
+ current_delay: int | None = None,
+) -> HTMLResponse:
+ """Render a folder index page showing subfolders and files.
+
+ Args:
+ path: The folder path (e.g., 'folder/').
+ folders: List of subfolder names.
+ files: List of file names.
+ current_order: Current navigation order.
+ current_delay: Current navigation delay.
+
+ Returns:
+ HTMLResponse with the folder index page.
+ """
+ with open("frontend.html") as f:
+ content = f.read()
+
+ template = string.Template(content)
+
+ # Build folder index sidebar HTML
+ prefix = path.rstrip("/") or ""
+ lines: list[str] = []
+
+ # Breadcrumb
+ lines.append("
")
+ lines.append('/ ')
+ if prefix:
+ parts = prefix.split("/")
+ accumulated = ""
+ for part in parts:
+ accumulated += f"{part}/"
+ lines.append(f'{part} ')
+ lines.append(" ")
+
+ # Folders
+ for folder in folders:
+ folder_path = f"{prefix}{folder}/" if prefix else f"{folder}/"
+ lines.append(
+ f'
"
+ )
+
+ # Files
+ for file in files:
+ file_path = f"{prefix}{file}" if prefix else file
+ href_params = ""
+ if current_order is not None and current_delay is not None:
+ href_params = f"?order={current_order}&delay={current_delay}"
+ lines.append(
+ f'
"
+ )
+
+ folder_index_html = "\n".join(lines)
+
+ content = template.substitute(
+ img_url="#",
+ image_click_url="#",
+ next_url="#",
+ prev_url="#",
+ filename=path.rstrip("/"),
+ file_hash="",
+ file_path=path.rstrip("/"),
+ toggle_url="#",
+ extra_meta="",
+ play_button="",
+ folder_index=folder_index_html,
+ )
+
+ return HTMLResponse(content=content)
+
+
@app.get("/navigate/{path:path}")
async def navigate_page(
path: str,
@@ -659,6 +867,20 @@ async def navigate_page(
status_code=400, detail="Invalid order. Must be 'next' or 'random'"
)
+ # Handle folder index view (path ends with / or is root)
+ if not path or path.endswith("/"):
+ folder_path = path.rstrip("/") or None
+ folders, files = _get_folder_index(folder_path)
+ if not folders and not files:
+ raise HTTPException(status_code=404, detail="File not found")
+ return _render_folder_index_page(
+ path if path else "/",
+ folders,
+ files,
+ current_order=order,
+ current_delay=delay,
+ )
+
navigation_data = _get_navigation_data_by_path(path, order=order)
if navigation_data is None:
raise HTTPException(status_code=404, detail="File not found")
diff --git a/tests/test_navigate.py b/tests/test_navigate.py
index d72b04e..7c633a4 100644
--- a/tests/test_navigate.py
+++ b/tests/test_navigate.py
@@ -59,13 +59,39 @@ class TestNavigatePage:
response = await client_zip_navigate.get("/navigate/nonexistent.txt")
assert response.status_code == 404
- async def test_returns_404_for_directory_path(
+ async def test_returns_folder_index_for_directory_path(
self, client_zip_navigate: AsyncClient
) -> None:
- """Returns 404 for a directory path (not a file)."""
+ """Returns folder index page for a directory path."""
response = await client_zip_navigate.get("/navigate/folder/")
+ assert response.status_code == 200
+ assert "text/html" in response.headers["content-type"]
+ assert "index-item" in response.text
+
+ async def test_returns_404_for_empty_directory_path(
+ self, client_zip_navigate: AsyncClient
+ ) -> None:
+ """Returns 404 for a non-existent directory path."""
+ response = await client_zip_navigate.get("/navigate/nonexistent/")
assert response.status_code == 404
+ async def test_returns_root_folder_index(
+ self, client_zip_navigate: AsyncClient
+ ) -> None:
+ """Returns folder index page for root path."""
+ response = await client_zip_navigate.get("/navigate/")
+ assert response.status_code == 200
+ assert "text/html" in response.headers["content-type"]
+ assert "index-item" in response.text
+ assert "folder/" in response.text
+
+ async def test_folder_index_has_breadcrumb(
+ self, client_zip_navigate: AsyncClient
+ ) -> None:
+ """Folder index page has breadcrumb navigation."""
+ response = await client_zip_navigate.get("/navigate/folder/")
+ assert "breadcrumb" in response.text
+
async def test_returns_404_on_directory_indexer(
self, client_dir: AsyncClient
) -> None: