Add navigation index
This commit is contained in:
parent
7619f63191
commit
57ac6bd92e
@ -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">‹</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
222
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("<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 = " " * 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")
|
||||
|
||||
@ -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:
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user