From 57ac6bd92eadd5dcb45015b163d5f662e1968e12 Mon Sep 17 00:00:00 2001 From: Timothy Farrell Date: Thu, 7 May 2026 22:27:09 +0000 Subject: [PATCH] Add navigation index --- frontend.html | 58 +++++++++++ main.py | 222 +++++++++++++++++++++++++++++++++++++++++ tests/test_navigate.py | 30 +++++- 3 files changed, 308 insertions(+), 2 deletions(-) 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("") + + 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'
' + f'{indent}📁 {folder}' + 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'
' + f'{indent}📄 {file}' + 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("") + + # Folders + for folder in folders: + folder_path = f"{prefix}{folder}/" if prefix else f"{folder}/" + lines.append( + f'
' + f'📁 {folder}' + 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'
' + f'📄 {file}' + 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: