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); }
|
.play-btn:hover { color: rgba(255, 255, 255, 1); transform: scale(1.1); }
|
||||||
/q .hidden { display: none; }
|
/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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<div id="sidebar" class="hidden">
|
||||||
|
$folder_index
|
||||||
|
</div>
|
||||||
<div id="container">
|
<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="$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>
|
<a href="$prev_url" class="chevron left" id="prev-btn">‹</a>
|
||||||
@ -109,6 +165,8 @@
|
|||||||
if (link && link.getAttribute('href') !== '#') {
|
if (link && link.getAttribute('href') !== '#') {
|
||||||
link.click();
|
link.click();
|
||||||
}
|
}
|
||||||
|
} else if (e.key.toLowerCase() === 'i') {
|
||||||
|
document.getElementById('sidebar').classList.toggle('hidden');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
222
main.py
222
main.py
@ -391,6 +391,16 @@ def _render_page(
|
|||||||
else:
|
else:
|
||||||
toggle_url = "#"
|
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(
|
content = template.substitute(
|
||||||
img_url="/api/{file_hash}/data".format(file_hash=navigation_data["file_hash"]),
|
img_url="/api/{file_hash}/data".format(file_hash=navigation_data["file_hash"]),
|
||||||
image_click_url=image_click_url or _get_random_hash(),
|
image_click_url=image_click_url or _get_random_hash(),
|
||||||
@ -402,6 +412,7 @@ def _render_page(
|
|||||||
toggle_url=toggle_url,
|
toggle_url=toggle_url,
|
||||||
extra_meta=extra_meta,
|
extra_meta=extra_meta,
|
||||||
play_button=play_button,
|
play_button=play_button,
|
||||||
|
folder_index=folder_index,
|
||||||
)
|
)
|
||||||
|
|
||||||
return HTMLResponse(content=content)
|
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}")
|
@app.get("/navigate/{path:path}")
|
||||||
async def navigate_page(
|
async def navigate_page(
|
||||||
path: str,
|
path: str,
|
||||||
@ -659,6 +867,20 @@ async def navigate_page(
|
|||||||
status_code=400, detail="Invalid order. Must be 'next' or 'random'"
|
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)
|
navigation_data = _get_navigation_data_by_path(path, order=order)
|
||||||
if navigation_data is None:
|
if navigation_data is None:
|
||||||
raise HTTPException(status_code=404, detail="File not found")
|
raise HTTPException(status_code=404, detail="File not found")
|
||||||
|
|||||||
@ -59,13 +59,39 @@ class TestNavigatePage:
|
|||||||
response = await client_zip_navigate.get("/navigate/nonexistent.txt")
|
response = await client_zip_navigate.get("/navigate/nonexistent.txt")
|
||||||
assert response.status_code == 404
|
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
|
self, client_zip_navigate: AsyncClient
|
||||||
) -> None:
|
) -> 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/")
|
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
|
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(
|
async def test_returns_404_on_directory_indexer(
|
||||||
self, client_dir: AsyncClient
|
self, client_dir: AsyncClient
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user