Add navigation index

This commit is contained in:
Timothy Farrell 2026-05-07 22:27:09 +00:00
parent 7619f63191
commit 57ac6bd92e
3 changed files with 308 additions and 2 deletions

View File

@ -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;
}
</style>
</head>
<body>
<div id="sidebar" class="hidden">
$folder_index
</div>
<div id="container">
<a href="$image_click_url"><div id="img" title="$filename" data-hash="$file_hash" data-path="$file_path"></div></a>
<a href="$prev_url" class="chevron left" id="prev-btn">&#8249;</a>
@ -109,6 +165,8 @@
if (link && link.getAttribute('href') !== '#') {
link.click();
}
} else if (e.key.toLowerCase() === 'i') {
document.getElementById('sidebar').classList.toggle('hidden');
}
});
</script>

222
main.py
View File

@ -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("<nav class='breadcrumb'>")
lines.append('<a href="/navigate/">/</a>')
if current_path:
parts = current_path.split("/")
accumulated = ""
for part in parts[:-1]:
accumulated += f"{part}/"
lines.append(f'<a href="/navigate/{accumulated}">{part}</a>')
lines.append("</nav>")
def _render_folder(folder_prefix: str, depth: int = 0) -> list[str]:
"""Recursively render a folder's contents."""
result: list[str] = []
indent = "&nbsp;&nbsp;" * 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'<div class="index-item folder">'
f'{indent}<a href="/navigate/{folder_path}">📁 {folder}</a>'
f"</div>"
)
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'<div class="{cls}">'
f'{indent}<a href="/navigate/{file_path}{href_params}">📄 {file}</a>'
f"</div>"
)
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("<nav class='breadcrumb'>")
lines.append('<a href="/navigate/">/</a>')
if prefix:
parts = prefix.split("/")
accumulated = ""
for part in parts:
accumulated += f"{part}/"
lines.append(f'<a href="/navigate/{accumulated}">{part}</a>')
lines.append("</nav>")
# Folders
for folder in folders:
folder_path = f"{prefix}{folder}/" if prefix else f"{folder}/"
lines.append(
f'<div class="index-item folder">'
f'<a href="/navigate/{folder_path}">📁 {folder}</a>'
f"</div>"
)
# 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'<div class="index-item file">'
f'<a href="/navigate/{file_path}{href_params}">📄 {file}</a>'
f"</div>"
)
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")

View File

@ -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: