1293 lines
36 KiB
HTML
1293 lines
36 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<title>{{ title }}</title>
|
||
<style>
|
||
/* ========================================
|
||
CSS VARIABLES AND THEME DEFINITIONS
|
||
======================================== */
|
||
:root {
|
||
--bg-color: #1e1e1e;
|
||
--text-color: #ffffff;
|
||
--card-bg: #2d2d2d;
|
||
--border-color: #404040;
|
||
--link-color: #569cd6;
|
||
--link-hover: #4a9eff;
|
||
--button-bg: #0078d4;
|
||
--button-hover: #106ebe;
|
||
--breadcrumb-color: #cccccc;
|
||
--tree-bg: #252526;
|
||
--tree-current-bg: #0078d4;
|
||
--success-color: #28a745;
|
||
--success-hover: #218838;
|
||
--disabled-color: #6c757d;
|
||
}
|
||
|
||
[data-theme="light"] {
|
||
--bg-color: #ffffff;
|
||
--text-color: #333333;
|
||
--card-bg: #f8f8f8;
|
||
--border-color: #ddd;
|
||
--link-color: #007acc;
|
||
--link-hover: #005a9e;
|
||
--button-bg: #007acc;
|
||
--button-hover: #005a9e;
|
||
--breadcrumb-color: #666;
|
||
--tree-bg: #f8f8f8;
|
||
--tree-current-bg: #007acc;
|
||
}
|
||
|
||
/* ========================================
|
||
BASE LAYOUT AND TYPOGRAPHY
|
||
======================================== */
|
||
* {
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
body {
|
||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||
margin: 0;
|
||
padding: 1rem;
|
||
padding-bottom: 2rem;
|
||
background-color: var(--bg-color);
|
||
color: var(--text-color);
|
||
transition: background-color 0.3s, color 0.3s;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
h1 {
|
||
font-size: 1.8rem;
|
||
margin-bottom: 0.5rem;
|
||
font-weight: 600;
|
||
}
|
||
|
||
h2 {
|
||
color: var(--text-color);
|
||
font-size: 1.3rem;
|
||
margin: 1.5rem 0 0.5rem 0;
|
||
}
|
||
|
||
/* ========================================
|
||
NAVIGATION COMPONENTS
|
||
======================================== */
|
||
.breadcrumb {
|
||
margin: 0.5rem 0 1rem 0;
|
||
font-size: 0.9rem;
|
||
color: var(--breadcrumb-color);
|
||
}
|
||
|
||
.breadcrumb a {
|
||
color: var(--link-color);
|
||
text-decoration: none;
|
||
}
|
||
|
||
.breadcrumb a:hover {
|
||
text-decoration: underline;
|
||
color: var(--link-hover);
|
||
}
|
||
|
||
.breadcrumb .separator {
|
||
margin: 0 0.3rem;
|
||
color: var(--breadcrumb-color);
|
||
opacity: 0.7;
|
||
}
|
||
|
||
.navigation {
|
||
margin: 1rem 0;
|
||
display: flex;
|
||
gap: 0.5rem;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.nav-btn {
|
||
background: var(--button-bg);
|
||
color: white;
|
||
border: none;
|
||
padding: 0.5rem 1rem;
|
||
border-radius: 6px;
|
||
cursor: pointer;
|
||
text-decoration: none;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 0.3rem;
|
||
font-size: 0.9rem;
|
||
font-weight: 500;
|
||
transition: all 0.2s ease;
|
||
}
|
||
|
||
.nav-btn:hover {
|
||
background: var(--button-hover);
|
||
transform: translateY(-1px);
|
||
}
|
||
|
||
.nav-btn:disabled {
|
||
background: var(--disabled-color);
|
||
cursor: not-allowed;
|
||
transform: none;
|
||
}
|
||
|
||
/* ========================================
|
||
SEARCH FUNCTIONALITY
|
||
======================================== */
|
||
.search-container {
|
||
margin: 1rem 0;
|
||
position: relative;
|
||
max-width: 500px;
|
||
}
|
||
|
||
.search-box {
|
||
width: 100%;
|
||
padding: 0.8rem 2.5rem 0.8rem 1rem;
|
||
border: 2px solid var(--border-color);
|
||
border-radius: 25px;
|
||
background: var(--card-bg);
|
||
color: var(--text-color);
|
||
font-size: 1rem;
|
||
outline: none;
|
||
transition: all 0.3s ease;
|
||
}
|
||
|
||
.search-box:focus {
|
||
border-color: var(--button-bg);
|
||
box-shadow: 0 0 0 3px rgba(0, 120, 212, 0.1);
|
||
}
|
||
|
||
.search-icon {
|
||
position: absolute;
|
||
right: 1rem;
|
||
top: 50%;
|
||
transform: translateY(-50%);
|
||
color: var(--text-color);
|
||
opacity: 0.6;
|
||
pointer-events: none;
|
||
}
|
||
|
||
.search-results {
|
||
background: var(--card-bg);
|
||
border: 1px solid var(--border-color);
|
||
border-radius: 8px;
|
||
margin-top: 0.5rem;
|
||
max-height: 400px;
|
||
overflow-y: auto;
|
||
display: none;
|
||
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||
z-index: 100;
|
||
}
|
||
|
||
.search-result-item {
|
||
padding: 0.75rem;
|
||
margin: 0;
|
||
cursor: pointer;
|
||
transition: background-color 0.2s ease;
|
||
border-bottom: 1px solid var(--border-color);
|
||
}
|
||
|
||
.search-result-item:last-child {
|
||
border-bottom: none;
|
||
}
|
||
|
||
.search-result-item:hover {
|
||
background: var(--button-bg);
|
||
color: white;
|
||
}
|
||
|
||
.search-highlight {
|
||
background: #ffeb3b;
|
||
color: #000;
|
||
font-weight: bold;
|
||
padding: 0.1rem 0.2rem;
|
||
border-radius: 2px;
|
||
}
|
||
|
||
/* ========================================
|
||
FOLDER TREE
|
||
======================================== */
|
||
.folder-tree {
|
||
background: var(--tree-bg);
|
||
border: 1px solid var(--border-color);
|
||
border-radius: 8px;
|
||
padding: 1rem;
|
||
margin: 1rem 0;
|
||
font-family: 'JetBrains Mono', 'Fira Code', 'Courier New', monospace;
|
||
font-size: 0.85rem;
|
||
max-height: 350px;
|
||
overflow-y: auto;
|
||
transition: all 0.3s ease;
|
||
}
|
||
|
||
.tree-item {
|
||
margin: 0.2rem 0;
|
||
white-space: pre;
|
||
font-family: inherit;
|
||
}
|
||
|
||
.tree-current {
|
||
background: var(--tree-current-bg);
|
||
color: white;
|
||
padding: 0.2rem 0.4rem;
|
||
border-radius: 4px;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.tree-link {
|
||
color: var(--link-color);
|
||
text-decoration: none;
|
||
transition: color 0.2s ease;
|
||
}
|
||
|
||
.tree-link:hover {
|
||
text-decoration: underline;
|
||
color: var(--link-hover);
|
||
}
|
||
|
||
/* ========================================
|
||
PLOT GRID
|
||
======================================== */
|
||
.grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||
gap: 1.2rem;
|
||
padding: 1rem 0;
|
||
}
|
||
|
||
.grid-item {
|
||
text-align: center;
|
||
background: var(--card-bg);
|
||
border-radius: 8px;
|
||
padding: 0.8rem;
|
||
transition: all 0.2s ease;
|
||
border: 1px solid transparent;
|
||
}
|
||
|
||
.grid-item:hover {
|
||
transform: translateY(-2px);
|
||
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||
border-color: var(--border-color);
|
||
}
|
||
|
||
.grid-item img {
|
||
max-width: 100%;
|
||
border: 1px solid var(--border-color);
|
||
border-radius: 6px;
|
||
transition: all 0.2s ease;
|
||
}
|
||
|
||
.grid-item a {
|
||
color: var(--link-color);
|
||
text-decoration: none;
|
||
}
|
||
|
||
.plot-name {
|
||
margin-top: 0.8rem;
|
||
word-wrap: break-word;
|
||
word-break: break-word;
|
||
hyphens: auto;
|
||
font-size: 0.9rem;
|
||
line-height: 1.3;
|
||
max-height: 3.9rem;
|
||
overflow: hidden;
|
||
padding: 0 0.2rem;
|
||
font-weight: 500;
|
||
}
|
||
|
||
/* ========================================
|
||
SIDEBAR (RECENT PLOTS)
|
||
======================================== */
|
||
.sidebar {
|
||
position: fixed;
|
||
top: 0;
|
||
right: -350px;
|
||
width: 330px;
|
||
height: 100vh;
|
||
background: var(--card-bg);
|
||
border-left: 2px solid var(--border-color);
|
||
z-index: 2000;
|
||
transition: right 0.3s ease;
|
||
overflow-y: auto;
|
||
box-shadow: -4px 0 15px rgba(0,0,0,0.2);
|
||
}
|
||
|
||
.sidebar.open {
|
||
right: 0;
|
||
}
|
||
|
||
.sidebar-header {
|
||
padding: 1.2rem;
|
||
border-bottom: 1px solid var(--border-color);
|
||
position: sticky;
|
||
top: 0;
|
||
background: var(--card-bg);
|
||
z-index: 1;
|
||
}
|
||
|
||
.sidebar-title {
|
||
margin: 0;
|
||
font-size: 1.2rem;
|
||
color: var(--text-color);
|
||
font-weight: 600;
|
||
}
|
||
|
||
.sidebar-close {
|
||
position: absolute;
|
||
top: 1rem;
|
||
right: 1rem;
|
||
background: none;
|
||
border: none;
|
||
font-size: 1.5rem;
|
||
cursor: pointer;
|
||
color: var(--text-color);
|
||
padding: 0.2rem;
|
||
border-radius: 4px;
|
||
transition: background-color 0.2s ease;
|
||
}
|
||
|
||
.sidebar-close:hover {
|
||
background: var(--border-color);
|
||
}
|
||
|
||
.sidebar-content {
|
||
padding: 1rem;
|
||
}
|
||
|
||
.recent-plot {
|
||
display: flex;
|
||
gap: 0.7rem;
|
||
padding: 0.8rem;
|
||
margin-bottom: 0.8rem;
|
||
border-radius: 6px;
|
||
cursor: pointer;
|
||
transition: all 0.2s ease;
|
||
border: 1px solid var(--border-color);
|
||
}
|
||
|
||
.recent-plot:hover {
|
||
background: var(--button-bg);
|
||
color: white;
|
||
transform: translateX(2px);
|
||
}
|
||
|
||
.recent-plot-thumb {
|
||
width: 60px;
|
||
height: 48px;
|
||
object-fit: cover;
|
||
border-radius: 4px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.recent-plot-info {
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
|
||
.recent-plot-name {
|
||
font-size: 0.9rem;
|
||
font-weight: 600;
|
||
margin-bottom: 0.3rem;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
|
||
.recent-plot-path {
|
||
font-size: 0.8rem;
|
||
color: var(--breadcrumb-color);
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
|
||
.sidebar-overlay {
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
width: 100%;
|
||
height: 100%;
|
||
background: rgba(0,0,0,0.5);
|
||
z-index: 1500;
|
||
display: none;
|
||
backdrop-filter: blur(2px);
|
||
}
|
||
|
||
.sidebar-overlay.open {
|
||
display: block;
|
||
}
|
||
|
||
/* ========================================
|
||
FLOATING ACTION BUTTONS
|
||
======================================== */
|
||
.floating-buttons {
|
||
position: fixed;
|
||
bottom: 20px;
|
||
right: 20px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 10px;
|
||
z-index: 1000;
|
||
}
|
||
|
||
.floating-btn {
|
||
width: 56px;
|
||
height: 56px;
|
||
border-radius: 50%;
|
||
border: none;
|
||
cursor: pointer;
|
||
font-size: 1.3rem;
|
||
box-shadow: 0 3px 10px rgba(0,0,0,0.3);
|
||
transition: all 0.2s ease;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
.floating-btn:hover {
|
||
transform: scale(1.1);
|
||
}
|
||
|
||
.sidebar-toggle {
|
||
background: var(--button-bg);
|
||
color: white;
|
||
}
|
||
|
||
.theme-toggle {
|
||
background: var(--button-bg);
|
||
color: white;
|
||
}
|
||
|
||
.refresh-btn {
|
||
background: var(--success-color);
|
||
color: white;
|
||
}
|
||
|
||
.refresh-btn:hover {
|
||
background: var(--success-hover);
|
||
}
|
||
|
||
.refresh-btn:disabled {
|
||
background: var(--disabled-color);
|
||
cursor: not-allowed;
|
||
transform: none;
|
||
}
|
||
|
||
/* ========================================
|
||
KEYBOARD SHORTCUTS HELP
|
||
======================================== */
|
||
.shortcuts-help {
|
||
position: fixed;
|
||
bottom: 140px;
|
||
right: 20px;
|
||
background: var(--card-bg);
|
||
border: 1px solid var(--border-color);
|
||
border-radius: 8px;
|
||
padding: 1rem;
|
||
display: none;
|
||
box-shadow: 0 4px 15px rgba(0,0,0,0.2);
|
||
z-index: 1000;
|
||
font-size: 0.9rem;
|
||
max-width: 280px;
|
||
}
|
||
|
||
.shortcuts-help h4 {
|
||
margin: 0 0 0.8rem 0;
|
||
color: var(--text-color);
|
||
font-size: 1rem;
|
||
}
|
||
|
||
.shortcut-item {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin: 0.4rem 0;
|
||
}
|
||
|
||
.shortcut-key {
|
||
background: var(--border-color);
|
||
padding: 0.2rem 0.4rem;
|
||
border-radius: 4px;
|
||
font-family: 'JetBrains Mono', 'Courier New', monospace;
|
||
font-size: 0.8rem;
|
||
font-weight: 500;
|
||
}
|
||
|
||
/* ========================================
|
||
SUBDIRECTORIES LIST
|
||
======================================== */
|
||
ul {
|
||
list-style: none;
|
||
padding: 0;
|
||
}
|
||
|
||
ul li {
|
||
margin: 0.5rem 0;
|
||
}
|
||
|
||
ul li a {
|
||
color: var(--link-color);
|
||
text-decoration: none;
|
||
padding: 0.3rem 0;
|
||
display: inline-block;
|
||
transition: color 0.2s ease;
|
||
}
|
||
|
||
ul li a:hover {
|
||
color: var(--link-hover);
|
||
text-decoration: underline;
|
||
}
|
||
|
||
/* ========================================
|
||
RESPONSIVE DESIGN
|
||
======================================== */
|
||
@media (max-width: 768px) {
|
||
body {
|
||
padding: 0.5rem;
|
||
}
|
||
|
||
.grid {
|
||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||
gap: 0.8rem;
|
||
}
|
||
|
||
.sidebar {
|
||
width: 100%;
|
||
right: -100%;
|
||
}
|
||
|
||
.navigation {
|
||
gap: 0.3rem;
|
||
}
|
||
|
||
.nav-btn {
|
||
padding: 0.4rem 0.8rem;
|
||
font-size: 0.8rem;
|
||
}
|
||
|
||
.search-container {
|
||
max-width: 100%;
|
||
}
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<!-- Main Content -->
|
||
<h1>{{ title }}</h1>
|
||
|
||
<!-- Search Bar -->
|
||
<div class="search-container">
|
||
<input type="text" class="search-box" id="searchBox" placeholder="Search plots..." />
|
||
<span class="search-icon">🔍</span>
|
||
<div class="search-results" id="searchResults"></div>
|
||
</div>
|
||
|
||
<!-- Breadcrumb Navigation -->
|
||
<div class="breadcrumb" id="breadcrumb"></div>
|
||
|
||
<!-- Navigation Buttons -->
|
||
<div class="navigation">
|
||
<button class="nav-btn" onclick="goToContent()" id="contentBtn">
|
||
🎯 Go to Content
|
||
</button>
|
||
<button class="nav-btn" onclick="window.history.back()">
|
||
← Back
|
||
</button>
|
||
{% if relpath != "." %}
|
||
<a href="../index.html" class="nav-btn">
|
||
↑ Parent Directory
|
||
</a>
|
||
{% endif %}
|
||
</div>
|
||
|
||
<!-- Folder Tree -->
|
||
<div class="folder-tree" id="folderTree"></div>
|
||
|
||
<!-- Plot Grid -->
|
||
<div class="grid">
|
||
{% for item in items %}
|
||
<div class="grid-item">
|
||
<a href="{{ item.pdf_href }}">
|
||
<img src="{{ item.png_href }}" alt="{{ item.name }}">
|
||
</a>
|
||
<div class="plot-name" title="{{ item.name }}">{{ item.name }}</div>
|
||
</div>
|
||
{% endfor %}
|
||
</div>
|
||
|
||
<!-- Subdirectories -->
|
||
{% if subdirs %}
|
||
<h2>Subdirectories</h2>
|
||
<ul>
|
||
{% for sub in subdirs %}
|
||
<li><a href="{{ sub }}/index.html">📁 {{ sub }}</a></li>
|
||
{% endfor %}
|
||
</ul>
|
||
{% endif %}
|
||
|
||
<!-- Recent Plots Sidebar -->
|
||
<div class="sidebar-overlay" id="sidebarOverlay" onclick="toggleSidebar()"></div>
|
||
<div class="sidebar" id="sidebar">
|
||
<div class="sidebar-header">
|
||
<h3 class="sidebar-title">Recent Plots</h3>
|
||
<button class="sidebar-close" onclick="toggleSidebar()">×</button>
|
||
</div>
|
||
<div class="sidebar-content" id="sidebarContent">
|
||
<div style="text-align: center; color: var(--breadcrumb-color); margin: 2rem 0;">
|
||
No recent plots yet
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Floating Action Buttons -->
|
||
<div class="floating-buttons">
|
||
<button class="floating-btn sidebar-toggle" onclick="toggleSidebar()" id="sidebarToggle" title="Recent Plots (Ctrl+R)">
|
||
📋
|
||
</button>
|
||
<button class="floating-btn theme-toggle" onclick="toggleTheme()" id="themeToggle" title="Toggle Theme (Ctrl+T)">
|
||
☀️
|
||
</button>
|
||
<button class="floating-btn refresh-btn" onclick="refreshGallery()" id="refreshBtn" title="Regenerate Gallery (F5)">
|
||
🔄
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Keyboard Shortcuts Help -->
|
||
<div class="shortcuts-help" id="shortcutsHelp">
|
||
<h4>Keyboard Shortcuts</h4>
|
||
<div class="shortcut-item">
|
||
<span>Search</span>
|
||
<span class="shortcut-key">Ctrl+K</span>
|
||
</div>
|
||
<div class="shortcut-item">
|
||
<span>Recent plots</span>
|
||
<span class="shortcut-key">Ctrl+R</span>
|
||
</div>
|
||
<div class="shortcut-item">
|
||
<span>Refresh</span>
|
||
<span class="shortcut-key">F5</span>
|
||
</div>
|
||
<div class="shortcut-item">
|
||
<span>Toggle theme</span>
|
||
<span class="shortcut-key">Ctrl+T</span>
|
||
</div>
|
||
<div class="shortcut-item">
|
||
<span>Help</span>
|
||
<span class="shortcut-key">?</span>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
// ========================================
|
||
// GALLERY APPLICATION CLASS
|
||
// ========================================
|
||
class GalleryApp {
|
||
constructor() {
|
||
this.searchTimeout = null;
|
||
this.SEARCH_DEBOUNCE_MS = {{ ui.search_debounce_ms|default(300) }};
|
||
this.MAX_RECENT_PLOTS = {{ ui.max_recent_plots|default(20) }};
|
||
this.init();
|
||
}
|
||
|
||
/**
|
||
* Initialize the gallery application
|
||
*/
|
||
init() {
|
||
this.initTheme();
|
||
this.buildBreadcrumb();
|
||
this.buildFolderTree();
|
||
this.initSearch();
|
||
this.initKeyboardShortcuts();
|
||
this.updateRecentPlotsDisplay();
|
||
this.trackPlotClicks();
|
||
this.hideContentButtonIfNeeded();
|
||
}
|
||
|
||
/**
|
||
* Initialize theme system and load saved preference
|
||
*/
|
||
initTheme() {
|
||
const savedTheme = localStorage.getItem('theme');
|
||
const html = document.documentElement;
|
||
const themeToggle = document.getElementById('themeToggle');
|
||
|
||
if (savedTheme === 'light') {
|
||
html.setAttribute('data-theme', 'light');
|
||
themeToggle.textContent = '🌙';
|
||
} else {
|
||
html.removeAttribute('data-theme');
|
||
themeToggle.textContent = '☀️';
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Toggle between light and dark themes
|
||
*/
|
||
toggleTheme() {
|
||
const html = document.documentElement;
|
||
const themeToggle = document.getElementById('themeToggle');
|
||
|
||
if (html.getAttribute('data-theme') === 'light') {
|
||
html.removeAttribute('data-theme');
|
||
themeToggle.textContent = '☀️';
|
||
localStorage.setItem('theme', 'dark');
|
||
} else {
|
||
html.setAttribute('data-theme', 'light');
|
||
themeToggle.textContent = '🌙';
|
||
localStorage.setItem('theme', 'light');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Build breadcrumb navigation based on current path
|
||
*/
|
||
buildBreadcrumb() {
|
||
const currentPath = window.location.pathname;
|
||
const pathParts = currentPath.split('/').filter(part => part !== '' && part !== 'index.html');
|
||
const breadcrumb = document.getElementById('breadcrumb');
|
||
|
||
if (pathParts.length === 0) {
|
||
breadcrumb.innerHTML = '<span>🏠 Root</span>';
|
||
return;
|
||
}
|
||
|
||
let html = '<a href="/">🏠 Root</a>';
|
||
|
||
for (let i = 0; i < pathParts.length; i++) {
|
||
const part = pathParts[i];
|
||
html += '<span class="separator">/</span>';
|
||
|
||
if (i === pathParts.length - 1) {
|
||
html += `<span>${decodeURIComponent(part)}</span>`;
|
||
} else {
|
||
const levelsUp = pathParts.length - 1 - i;
|
||
const relativePath = '../'.repeat(levelsUp) + 'index.html';
|
||
html += `<a href="${relativePath}">${decodeURIComponent(part)}</a>`;
|
||
}
|
||
}
|
||
|
||
breadcrumb.innerHTML = html;
|
||
}
|
||
|
||
/**
|
||
* Build and display the folder tree structure
|
||
*/
|
||
async buildFolderTree() {
|
||
const treeContainer = document.getElementById('folderTree');
|
||
const currentPath = window.location.pathname;
|
||
|
||
try {
|
||
const tree = await this.buildTreeRecursive(currentPath, 0, currentPath);
|
||
treeContainer.innerHTML = tree;
|
||
} catch (error) {
|
||
console.error('Error building folder tree:', error);
|
||
treeContainer.innerHTML = '<div class="tree-item">❌ Error loading folder tree</div>';
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Recursively build tree structure for folders
|
||
*/
|
||
async buildTreeRecursive(path, depth, currentPath, maxDepth = 5) {
|
||
if (depth > maxDepth) {
|
||
const indent = ' '.repeat(depth);
|
||
return `<div class="tree-item">${indent}└─ ...</div>`;
|
||
}
|
||
|
||
const indent = ' '.repeat(depth);
|
||
const folderName = path.split('/').filter(p => p !== '' && p !== 'index.html').pop() || 'Gallery';
|
||
|
||
try {
|
||
const response = await fetch(path);
|
||
const htmlContent = await response.text();
|
||
const parser = new DOMParser();
|
||
const doc = parser.parseFromString(htmlContent, 'text/html');
|
||
|
||
const subdirs = doc.querySelectorAll('h2 + ul li a');
|
||
const items = doc.querySelectorAll('.grid-item');
|
||
const totalItems = items.length + subdirs.length;
|
||
const arrow = depth === 0 ? '' : '└─ ';
|
||
|
||
let html = '';
|
||
if (path === currentPath) {
|
||
html += `<div class="tree-item">${indent}${arrow}📁 <span class="tree-current">${folderName}</span> (${totalItems} items)</div>`;
|
||
} else {
|
||
html += `<div class="tree-item">${indent}${arrow}📁 <a href="${path}" class="tree-link">${folderName}</a> (${totalItems} items)</div>`;
|
||
}
|
||
|
||
for (let i = 0; i < subdirs.length; i++) {
|
||
const subdir = subdirs[i];
|
||
const href = subdir.getAttribute('href');
|
||
if (href) {
|
||
const baseUrl = path.replace(/\/[^\/]*$/, '/');
|
||
const subPath = baseUrl + href;
|
||
html += await this.buildTreeRecursive(subPath, depth + 1, currentPath, maxDepth);
|
||
}
|
||
}
|
||
|
||
return html;
|
||
} catch (error) {
|
||
const arrow = depth === 0 ? '' : '└─ ';
|
||
return `<div class="tree-item">${indent}${arrow}📁 ${folderName} (error loading)</div>`;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Initialize search functionality with debouncing
|
||
*/
|
||
initSearch() {
|
||
const searchBox = document.getElementById('searchBox');
|
||
const searchResults = document.getElementById('searchResults');
|
||
|
||
searchBox.addEventListener('input', (e) => {
|
||
clearTimeout(this.searchTimeout);
|
||
const query = e.target.value.trim();
|
||
|
||
if (query.length === 0) {
|
||
searchResults.style.display = 'none';
|
||
return;
|
||
}
|
||
|
||
this.searchTimeout = setTimeout(() => {
|
||
this.performSearch(query);
|
||
}, this.SEARCH_DEBOUNCE_MS);
|
||
});
|
||
|
||
document.addEventListener('click', (e) => {
|
||
if (!searchBox.contains(e.target) && !searchResults.contains(e.target)) {
|
||
searchResults.style.display = 'none';
|
||
}
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Perform search across plot names
|
||
*/
|
||
async performSearch(query) {
|
||
const searchResults = document.getElementById('searchResults');
|
||
searchResults.innerHTML = '<div style="padding: 1rem;">🔍 Searching...</div>';
|
||
searchResults.style.display = 'block';
|
||
|
||
try {
|
||
const results = await this.searchPlots(query);
|
||
this.displaySearchResults(results, query);
|
||
} catch (error) {
|
||
console.error('Search error:', error);
|
||
searchResults.innerHTML = '<div style="padding: 1rem; color: red;">❌ Search failed</div>';
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Search for plots matching the query
|
||
*/
|
||
async searchPlots(query) {
|
||
const results = [];
|
||
const visited = new Set();
|
||
const lowerQuery = query.toLowerCase();
|
||
|
||
await this.searchInPage(window.location.pathname, lowerQuery, results, visited);
|
||
await this.searchRecursive(window.location.pathname, lowerQuery, results, visited, 0, 5);
|
||
|
||
return results.slice(0, 20);
|
||
}
|
||
|
||
/**
|
||
* Search for plots in a specific page
|
||
*/
|
||
async searchInPage(path, query, results, visited, maxResults = 50) {
|
||
if (visited.has(path) || results.length >= maxResults) return;
|
||
visited.add(path);
|
||
|
||
try {
|
||
const response = await fetch(path);
|
||
const html = await response.text();
|
||
const parser = new DOMParser();
|
||
const doc = parser.parseFromString(html, 'text/html');
|
||
|
||
const items = doc.querySelectorAll('.grid-item');
|
||
items.forEach(item => {
|
||
const nameElement = item.querySelector('.plot-name');
|
||
const imgElement = item.querySelector('img');
|
||
const linkElement = item.querySelector('a');
|
||
|
||
if (nameElement && imgElement && linkElement) {
|
||
const name = nameElement.textContent.toLowerCase();
|
||
if (name.includes(query)) {
|
||
results.push({
|
||
name: nameElement.textContent,
|
||
path: path,
|
||
href: linkElement.href,
|
||
imgSrc: imgElement.src,
|
||
relevance: this.calculateRelevance(name, query)
|
||
});
|
||
}
|
||
}
|
||
});
|
||
} catch (error) {
|
||
console.error('Error searching in', path, error);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Recursively search in subdirectories
|
||
*/
|
||
async searchRecursive(path, query, results, visited, depth, maxDepth) {
|
||
if (depth >= maxDepth || results.length >= 50) return;
|
||
|
||
try {
|
||
const response = await fetch(path);
|
||
const html = await response.text();
|
||
const parser = new DOMParser();
|
||
const doc = parser.parseFromString(html, 'text/html');
|
||
|
||
const subdirs = doc.querySelectorAll('h2 + ul li a');
|
||
for (const subdir of subdirs) {
|
||
const href = subdir.getAttribute('href');
|
||
if (href) {
|
||
const baseUrl = path.replace(/\/[^\/]*$/, '/');
|
||
const subPath = baseUrl + href;
|
||
await this.searchInPage(subPath, query, results, visited);
|
||
await this.searchRecursive(subPath, query, results, visited, depth + 1, maxDepth);
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error('Error in recursive search:', error);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Calculate search relevance score
|
||
*/
|
||
calculateRelevance(text, query) {
|
||
const exactMatch = text === query;
|
||
const startsWith = text.startsWith(query);
|
||
const wordMatch = text.split(/\s+/).some(word => word.startsWith(query));
|
||
|
||
if (exactMatch) return 100;
|
||
if (startsWith) return 80;
|
||
if (wordMatch) return 60;
|
||
return 40;
|
||
}
|
||
|
||
/**
|
||
* Display search results with highlighting
|
||
*/
|
||
displaySearchResults(results, query) {
|
||
const searchResults = document.getElementById('searchResults');
|
||
|
||
if (results.length === 0) {
|
||
searchResults.innerHTML = '<div style="padding: 1rem;">📭 No plots found</div>';
|
||
return;
|
||
}
|
||
|
||
results.sort((a, b) => b.relevance - a.relevance);
|
||
|
||
let html = '';
|
||
results.forEach(result => {
|
||
const highlightedName = this.highlightText(result.name, query);
|
||
const relativePath = this.getRelativePath(result.path);
|
||
|
||
html += `
|
||
<div class="search-result-item" onclick="app.openSearchResult('${result.href}')" title="${result.name}">
|
||
<div style="display: flex; gap: 0.7rem; align-items: center;">
|
||
<img src="${result.imgSrc}" style="width: 50px; height: 40px; object-fit: cover; border-radius: 4px;" />
|
||
<div style="flex: 1; min-width: 0;">
|
||
<div style="font-weight: 600; margin-bottom: 0.3rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">
|
||
${highlightedName}
|
||
</div>
|
||
<div style="font-size: 0.8rem; color: var(--breadcrumb-color); white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">
|
||
📍 ${relativePath}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
});
|
||
|
||
searchResults.innerHTML = html;
|
||
}
|
||
|
||
/**
|
||
* Highlight search query in text
|
||
*/
|
||
highlightText(text, query) {
|
||
const regex = new RegExp(`(${query})`, 'gi');
|
||
return text.replace(regex, '<span class="search-highlight">$1</span>');
|
||
}
|
||
|
||
/**
|
||
* Get relative path for display
|
||
*/
|
||
getRelativePath(fullPath) {
|
||
const parts = fullPath.split('/').filter(p => p !== '' && p !== 'index.html');
|
||
return parts.length > 0 ? parts.join(' / ') : 'Root';
|
||
}
|
||
|
||
/**
|
||
* Open search result and track it
|
||
*/
|
||
openSearchResult(href) {
|
||
this.addToRecentPlots(href);
|
||
window.open(href, '_blank');
|
||
document.getElementById('searchResults').style.display = 'none';
|
||
}
|
||
|
||
/**
|
||
* Add plot to recent plots list
|
||
*/
|
||
addToRecentPlots(plotHref) {
|
||
const plotName = plotHref.split('/').pop().replace('.pdf', '');
|
||
const pathParts = plotHref.split('/').filter(p => p !== '' && p !== plotName + '.pdf');
|
||
const plotPath = pathParts.join(' / ');
|
||
const thumbUrl = plotHref.replace('.pdf', '.png');
|
||
|
||
const plotInfo = {
|
||
name: plotName,
|
||
path: plotPath,
|
||
href: plotHref,
|
||
thumbUrl: thumbUrl,
|
||
timestamp: Date.now()
|
||
};
|
||
|
||
let recentPlots = JSON.parse(localStorage.getItem('recentPlots') || '[]');
|
||
recentPlots = recentPlots.filter(p => p.href !== plotHref);
|
||
recentPlots.unshift(plotInfo);
|
||
recentPlots = recentPlots.slice(0, this.MAX_RECENT_PLOTS);
|
||
|
||
localStorage.setItem('recentPlots', JSON.stringify(recentPlots));
|
||
this.updateRecentPlotsDisplay();
|
||
}
|
||
|
||
/**
|
||
* Update recent plots sidebar display
|
||
*/
|
||
updateRecentPlotsDisplay() {
|
||
const sidebarContent = document.getElementById('sidebarContent');
|
||
const recentPlots = JSON.parse(localStorage.getItem('recentPlots') || '[]');
|
||
|
||
if (recentPlots.length === 0) {
|
||
sidebarContent.innerHTML = `
|
||
<div style="text-align: center; color: var(--breadcrumb-color); margin: 2rem 0;">
|
||
📭 No recent plots yet<br>
|
||
<small style="opacity: 0.7;">Open some plots to see them here</small>
|
||
</div>
|
||
`;
|
||
return;
|
||
}
|
||
|
||
let html = '';
|
||
recentPlots.forEach(plot => {
|
||
html += `
|
||
<div class="recent-plot" onclick="app.openRecentPlot('${plot.href}')" title="${plot.name}">
|
||
<img src="${plot.thumbUrl}" class="recent-plot-thumb" alt="${plot.name}" />
|
||
<div class="recent-plot-info">
|
||
<div class="recent-plot-name">${plot.name}</div>
|
||
<div class="recent-plot-path">📍 ${plot.path}</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
});
|
||
|
||
sidebarContent.innerHTML = html;
|
||
}
|
||
|
||
/**
|
||
* Open recent plot and close sidebar
|
||
*/
|
||
openRecentPlot(href) {
|
||
window.open(href, '_blank');
|
||
this.toggleSidebar();
|
||
}
|
||
|
||
/**
|
||
* Toggle recent plots sidebar
|
||
*/
|
||
toggleSidebar() {
|
||
const sidebar = document.getElementById('sidebar');
|
||
const overlay = document.getElementById('sidebarOverlay');
|
||
|
||
sidebar.classList.toggle('open');
|
||
overlay.classList.toggle('open');
|
||
}
|
||
|
||
/**
|
||
* Track clicks on plot links
|
||
*/
|
||
trackPlotClicks() {
|
||
document.addEventListener('click', (e) => {
|
||
const link = e.target.closest('a[href$=".pdf"]');
|
||
if (link) {
|
||
this.addToRecentPlots(link.href);
|
||
}
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Initialize keyboard shortcuts
|
||
*/
|
||
initKeyboardShortcuts() {
|
||
document.addEventListener('keydown', (e) => {
|
||
if (e.ctrlKey && e.key === 'k') {
|
||
e.preventDefault();
|
||
document.getElementById('searchBox').focus();
|
||
}
|
||
|
||
if (e.ctrlKey && e.key === 'r') {
|
||
e.preventDefault();
|
||
this.toggleSidebar();
|
||
}
|
||
|
||
if (e.ctrlKey && e.key === 't') {
|
||
e.preventDefault();
|
||
this.toggleTheme();
|
||
}
|
||
|
||
if (e.key === 'F5') {
|
||
e.preventDefault();
|
||
this.refreshGallery();
|
||
}
|
||
|
||
if (e.key === '?' && !e.ctrlKey && !e.altKey && !e.metaKey) {
|
||
e.preventDefault();
|
||
this.toggleShortcutsHelp();
|
||
}
|
||
|
||
if (e.key === 'Escape') {
|
||
document.getElementById('searchResults').style.display = 'none';
|
||
if (document.getElementById('sidebar').classList.contains('open')) {
|
||
this.toggleSidebar();
|
||
}
|
||
document.getElementById('shortcutsHelp').style.display = 'none';
|
||
}
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Toggle keyboard shortcuts help display
|
||
*/
|
||
toggleShortcutsHelp() {
|
||
const help = document.getElementById('shortcutsHelp');
|
||
help.style.display = help.style.display === 'block' ? 'none' : 'block';
|
||
}
|
||
|
||
/**
|
||
* Refresh gallery by calling CGI script
|
||
*/
|
||
async refreshGallery() {
|
||
const refreshBtn = document.getElementById('refreshBtn');
|
||
refreshBtn.disabled = true;
|
||
refreshBtn.textContent = '⏳';
|
||
|
||
try {
|
||
const response = await fetch('/cgi-bin/refresh_gallery.py', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
}
|
||
});
|
||
|
||
if (response.ok) {
|
||
refreshBtn.textContent = '✅';
|
||
setTimeout(() => {
|
||
window.location.reload();
|
||
}, 1000);
|
||
} else {
|
||
throw new Error('Refresh failed');
|
||
}
|
||
} catch (error) {
|
||
console.error('Error refreshing gallery:', error);
|
||
refreshBtn.textContent = '❌';
|
||
setTimeout(() => {
|
||
refreshBtn.disabled = false;
|
||
refreshBtn.textContent = '🔄';
|
||
}, 3000);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Navigate to deepest content folder automatically
|
||
*/
|
||
async goToContent() {
|
||
const contentBtn = document.getElementById('contentBtn');
|
||
contentBtn.disabled = true;
|
||
contentBtn.textContent = '🔍 Searching...';
|
||
|
||
let currentPath = window.location.pathname;
|
||
let visited = new Set();
|
||
let deepestWithContent = currentPath;
|
||
|
||
while (true) {
|
||
if (visited.has(currentPath)) break;
|
||
visited.add(currentPath);
|
||
|
||
try {
|
||
const response = await fetch(currentPath);
|
||
const html = await response.text();
|
||
const parser = new DOMParser();
|
||
const doc = parser.parseFromString(html, 'text/html');
|
||
|
||
const items = doc.querySelectorAll('.grid-item');
|
||
const subdirs = doc.querySelectorAll('h2 + ul li a');
|
||
|
||
if (items.length > 0) {
|
||
deepestWithContent = currentPath;
|
||
}
|
||
|
||
if (subdirs.length === 1) {
|
||
const nextPath = subdirs[0].getAttribute('href');
|
||
if (nextPath) {
|
||
const baseUrl = currentPath.replace(/\/[^\/]*$/, '/');
|
||
currentPath = baseUrl + nextPath;
|
||
} else {
|
||
break;
|
||
}
|
||
} else {
|
||
break;
|
||
}
|
||
} catch (error) {
|
||
console.error('Error fetching:', error);
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (deepestWithContent !== window.location.pathname) {
|
||
window.location.href = deepestWithContent;
|
||
} else {
|
||
contentBtn.textContent = '✓ Already at content';
|
||
setTimeout(() => {
|
||
contentBtn.disabled = false;
|
||
contentBtn.textContent = '🎯 Go to Content';
|
||
}, 2000);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Hide 'Go to Content' button if not needed
|
||
*/
|
||
hideContentButtonIfNeeded() {
|
||
const items = document.querySelectorAll('.grid-item');
|
||
const subdirs = document.querySelectorAll('h2 + ul li a');
|
||
const contentBtn = document.getElementById('contentBtn');
|
||
|
||
if (items.length > 0 && subdirs.length === 0) {
|
||
contentBtn.style.display = 'none';
|
||
}
|
||
}
|
||
}
|
||
|
||
// ========================================
|
||
// GLOBAL FUNCTIONS (for backward compatibility)
|
||
// ========================================
|
||
let app;
|
||
|
||
function toggleTheme() { app.toggleTheme(); }
|
||
function toggleSidebar() { app.toggleSidebar(); }
|
||
function refreshGallery() { app.refreshGallery(); }
|
||
function goToContent() { app.goToContent(); }
|
||
|
||
// ========================================
|
||
// INITIALIZE APPLICATION
|
||
// ========================================
|
||
document.addEventListener('DOMContentLoaded', function() {
|
||
app = new GalleryApp();
|
||
});
|
||
</script>
|
||
</body>
|
||
</html>
|