Files
ETPlot/template.html
T
2025-07-08 08:14:10 +02:00

2006 lines
59 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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;
}
.grid-item.highlighted {
border-color: var(--button-bg);
box-shadow: 0 0 15px rgba(0, 120, 212, 0.3);
transform: translateY(-2px);
animation: highlightPulse 2s ease-in-out;
}
@keyframes highlightPulse {
0%, 100% { transform: translateY(-2px) scale(1); }
50% { transform: translateY(-2px) scale(1.02); }
}
.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;
}
/* ========================================
GALLERY STATISTICS
======================================== */
.gallery-stats {
position: fixed;
bottom: 20px;
left: 20px;
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 0.8rem 1rem;
font-size: 0.85rem;
color: var(--breadcrumb-color);
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
z-index: 500;
opacity: 0.8;
transition: opacity 0.2s ease;
max-width: 200px;
}
.gallery-stats:hover {
opacity: 1;
}
.stats-item {
display: flex;
justify-content: space-between;
align-items: center;
margin: 0.2rem 0;
white-space: nowrap;
}
.stats-label {
margin-right: 0.8rem;
}
.stats-value {
font-weight: 600;
color: var(--text-color);
}
/* ========================================
PLOT COMPARISON OVERLAY
======================================== */
.comparison-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.9);
z-index: 2000;
display: none;
backdrop-filter: blur(4px);
}
.comparison-overlay.open {
display: flex;
align-items: center;
justify-content: center;
}
.comparison-container {
width: 95%;
height: 90%;
background: var(--bg-color);
border-radius: 12px;
padding: 1.5rem;
display: flex;
flex-direction: column;
position: relative;
box-shadow: 0 10px 30px rgba(0,0,0,0.5);
}
.comparison-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--border-color);
}
.comparison-title {
font-size: 1.5rem;
font-weight: 600;
color: var(--text-color);
margin: 0;
}
.comparison-close {
background: var(--button-bg);
color: white;
border: none;
border-radius: 50%;
width: 40px;
height: 40px;
font-size: 1.2rem;
cursor: pointer;
transition: all 0.2s ease;
}
.comparison-close:hover {
background: var(--button-hover);
transform: scale(1.1);
}
.comparison-content {
flex: 1;
display: flex;
gap: 1rem;
overflow: hidden;
}
.comparison-panel {
flex: 1;
display: flex;
flex-direction: column;
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 8px;
overflow: hidden;
}
.comparison-panel-header {
background: var(--tree-bg);
padding: 0.8rem 1rem;
border-bottom: 1px solid var(--border-color);
display: flex;
justify-content: space-between;
align-items: center;
}
.comparison-panel-title {
font-weight: 600;
color: var(--text-color);
font-size: 1rem;
}
.comparison-replace-btn {
background: var(--success-color);
color: white;
border: none;
padding: 0.4rem 0.8rem;
border-radius: 4px;
font-size: 0.85rem;
cursor: pointer;
transition: all 0.2s ease;
}
.comparison-replace-btn:hover {
background: var(--success-hover);
}
.comparison-panel-content {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
overflow: auto;
}
.comparison-plot-container {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
.comparison-plot {
max-width: 100%;
max-height: 100%;
border: 1px solid var(--border-color);
border-radius: 4px;
cursor: pointer;
transition: transform 0.2s ease;
}
.comparison-plot:hover {
transform: scale(1.02);
}
.comparison-placeholder {
width: 100%;
height: 300px;
border: 2px dashed var(--border-color);
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
color: var(--breadcrumb-color);
font-size: 1.1rem;
cursor: pointer;
transition: all 0.2s ease;
}
.comparison-placeholder:hover {
border-color: var(--button-bg);
color: var(--button-bg);
}
.comparison-plot-info {
position: absolute;
bottom: 0.5rem;
left: 0.5rem;
right: 0.5rem;
background: rgba(0, 0, 0, 0.8);
color: white;
padding: 0.5rem;
border-radius: 4px;
font-size: 0.9rem;
opacity: 0;
transition: opacity 0.2s ease;
}
.comparison-plot-container:hover .comparison-plot-info {
opacity: 1;
}
/* Plot selection mode */
.selecting-plots .grid-item {
cursor: pointer !important;
transition: all 0.2s ease;
position: relative;
}
.selecting-plots .grid-item:hover {
transform: translateY(-4px);
box-shadow: 0 6px 20px rgba(0, 120, 212, 0.3);
border-color: var(--button-bg);
}
.selecting-plots .grid-item::before {
content: '📊 Click to compare';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: rgba(0, 120, 212, 0.95);
color: white;
padding: 0.5rem 1rem;
border-radius: 4px;
font-size: 0.9rem;
font-weight: 600;
opacity: 0;
transition: opacity 0.2s ease;
pointer-events: none;
z-index: 10;
white-space: nowrap;
}
.selecting-plots .grid-item:hover::before {
opacity: 1;
}
/* Ensure grid items are clickable in selection mode */
.selecting-plots .grid-item * {
pointer-events: none;
}
.selecting-plots .grid-item {
pointer-events: auto;
}
/* ========================================
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="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 compare-toggle" onclick="app.toggleCompareMode()" id="compareToggle" title="Compare Plots (Ctrl+C)">
⚖️
</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>Compare plots</span>
<span class="shortcut-key">Ctrl+C</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>
<!-- Gallery Statistics -->
<div class="gallery-stats" id="galleryStats">
<div class="stats-item">
<span class="stats-label">📊 Files:</span>
<span class="stats-value" id="fileCount">0</span>
</div>
<div class="stats-item">
<span class="stats-label">📁 Folders:</span>
<span class="stats-value" id="folderCount">0</span>
</div>
<div class="stats-item">
<span class="stats-label">💾 Size:</span>
<span class="stats-value" id="totalSize">0 KB</span>
</div>
<div class="stats-item">
<span class="stats-label">🕒 Updated:</span>
<span class="stats-value" id="lastUpdated">Now</span>
</div>
</div>
<!-- Plot Comparison Overlay -->
<div class="comparison-overlay" id="comparisonOverlay">
<div class="comparison-container">
<div class="comparison-header">
<h2 class="comparison-title">Plot Comparison</h2>
<button class="comparison-close" onclick="app.closeComparison()" title="Close Comparison (Esc)">×</button>
</div>
<div class="comparison-content">
<div class="comparison-panel">
<div class="comparison-panel-header">
<span class="comparison-panel-title" id="leftPlotTitle">Plot A</span>
<button class="comparison-replace-btn" onclick="app.replacePlot('left')" id="leftReplaceBtn">Replace</button>
</div>
<div class="comparison-panel-content">
<div class="comparison-plot-container" id="leftPlotContainer">
<div class="comparison-placeholder" onclick="app.selectPlotForComparison('left')">
📊 Click to select first plot
</div>
</div>
</div>
</div>
<div class="comparison-panel">
<div class="comparison-panel-header">
<span class="comparison-panel-title" id="rightPlotTitle">Plot B</span>
<button class="comparison-replace-btn" onclick="app.replacePlot('right')" id="rightReplaceBtn">Replace</button>
</div>
<div class="comparison-panel-content">
<div class="comparison-plot-container" id="rightPlotContainer">
<div class="comparison-placeholder" onclick="app.selectPlotForComparison('right')">
📊 Click to select second plot
</div>
</div>
</div>
</div>
</div>
</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) }};
// Comparison state
this.comparisonMode = false;
this.comparisonSlot = null; // 'left' or 'right'
this.plots = { left: null, right: null };
this.init();
}
/**
* Initialize the gallery application
*/
init() {
this.initTheme();
this.buildBreadcrumb();
this.buildFolderTree();
this.initSearch();
this.initKeyboardShortcuts();
this.updateRecentPlotsDisplay();
this.trackPlotClicks();
this.handleThumbnailHighlight();
this.updateGalleryStats();
}
/**
* 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 with collapsed empty directories
*/
async buildTreeRecursive(path, depth, currentPath, maxDepth = 5) {
if (depth > maxDepth) {
const indent = ' '.repeat(depth);
return `<div class="tree-item">${indent}└─ ...</div>`;
}
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');
// Check if this is an empty directory (only one subdirectory, no items)
if (items.length === 0 && subdirs.length === 1) {
// This is an empty directory, collapse it with its child
const subdir = subdirs[0];
const href = subdir.getAttribute('href');
if (href) {
const baseUrl = path.replace(/\/[^\/]*$/, '/');
const subPath = baseUrl + href;
// Get the collapsed path by following the chain of empty directories
const collapsedPath = await this.getCollapsedPath(path, subPath);
return await this.buildCollapsedTreeItem(collapsedPath, depth, currentPath, maxDepth);
}
}
// Normal directory processing
const indent = ' '.repeat(depth);
const folderName = path.split('/').filter(p => p !== '' && p !== 'index.html').pop() || 'Gallery';
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 indent = ' '.repeat(depth);
const arrow = depth === 0 ? '' : '└─ ';
const folderName = path.split('/').filter(p => p !== '' && p !== 'index.html').pop() || 'Gallery';
return `<div class="tree-item">${indent}${arrow}📁 ${folderName} (error loading)</div>`;
}
}
/**
* Get the collapsed path by following empty directories
*/
async getCollapsedPath(startPath, currentPath) {
const pathSegments = [];
let path = startPath;
while (true) {
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 folderName = path.split('/').filter(p => p !== '' && p !== 'index.html').pop() || 'Gallery';
pathSegments.push({ name: folderName, path: path });
// If this directory has items or multiple subdirectories, stop collapsing
if (items.length > 0 || subdirs.length !== 1) {
break;
}
// Continue to the single subdirectory
const subdir = subdirs[0];
const href = subdir.getAttribute('href');
if (href) {
const baseUrl = path.replace(/\/[^\/]*$/, '/');
path = baseUrl + href;
} else {
break;
}
} catch (error) {
break;
}
}
return {
segments: pathSegments,
finalPath: path
};
}
/**
* Build a collapsed tree item for empty directory chains
*/
async buildCollapsedTreeItem(collapsedPath, depth, currentPath, maxDepth) {
const indent = ' '.repeat(depth);
const arrow = depth === 0 ? '' : '└─ ';
// Create the collapsed display name
const displayName = collapsedPath.segments.map(seg => seg.name).join(' / ');
const finalPath = collapsedPath.finalPath;
// Get information about the final directory
let totalItems = 0;
let subdirs = [];
try {
const response = await fetch(finalPath);
const htmlContent = await response.text();
const parser = new DOMParser();
const doc = parser.parseFromString(htmlContent, 'text/html');
const subdirElements = doc.querySelectorAll('h2 + ul li a');
const items = doc.querySelectorAll('.grid-item');
totalItems = items.length + subdirElements.length;
subdirs = Array.from(subdirElements);
} catch (error) {
// Handle error case
}
let html = '';
if (finalPath === currentPath) {
html += `<div class="tree-item">${indent}${arrow}📁 <span class="tree-current">${displayName}</span> (${totalItems} items)</div>`;
} else {
html += `<div class="tree-item">${indent}${arrow}📁 <a href="${finalPath}" class="tree-link">${displayName}</a> (${totalItems} items)</div>`;
}
// Process subdirectories of the final path
for (let i = 0; i < subdirs.length; i++) {
const subdir = subdirs[i];
const href = subdir.getAttribute('href');
if (href) {
const baseUrl = finalPath.replace(/\/[^\/]*$/, '/');
const subPath = baseUrl + href;
html += await this.buildTreeRecursive(subPath, depth + 1, currentPath, maxDepth);
}
}
return html;
}
/**
* 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');
// Determine the gallery page URL (directory containing the plot)
const plotDir = plotHref.substring(0, plotHref.lastIndexOf('/'));
const galleryUrl = plotDir + '/index.html';
const plotInfo = {
name: plotName,
path: plotPath,
href: plotHref,
thumbUrl: thumbUrl,
galleryUrl: galleryUrl,
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.galleryUrl || plot.href}', '${plot.name}')" 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 gallery page and highlight thumbnail
*/
openRecentPlot(galleryUrl, plotName) {
// Navigate to gallery page with plot highlight parameter
const url = new URL(galleryUrl, window.location.origin);
url.searchParams.set('highlight', plotName);
window.location.href = url.toString();
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);
}
});
}
/**
* Handle thumbnail highlighting from URL parameters
*/
handleThumbnailHighlight() {
const urlParams = new URLSearchParams(window.location.search);
const highlightPlot = urlParams.get('highlight');
if (highlightPlot) {
// Find and highlight the thumbnail
const gridItems = document.querySelectorAll('.grid-item');
gridItems.forEach(item => {
const plotName = item.querySelector('.plot-name');
if (plotName && plotName.textContent.trim() === highlightPlot) {
item.classList.add('highlighted');
// Scroll to the highlighted item
setTimeout(() => {
item.scrollIntoView({
behavior: 'smooth',
block: 'center'
});
}, 100);
// Remove highlight after animation
setTimeout(() => {
item.classList.remove('highlighted');
}, 3000);
}
});
// Clean up URL
const newUrl = new URL(window.location);
newUrl.searchParams.delete('highlight');
window.history.replaceState({}, document.title, newUrl.toString());
}
}
/**
* Update gallery statistics display
*/
updateGalleryStats() {
// Use stats passed from Python backend
{% if stats %}
document.getElementById('fileCount').textContent = {{ stats.file_count }};
document.getElementById('folderCount').textContent = {{ stats.folder_count }};
document.getElementById('totalSize').textContent = '{{ stats.total_size }}';
{% else %}
// Fallback: count from DOM if stats not available
const gridItems = document.querySelectorAll('.grid-item');
const subdirLinks = document.querySelectorAll('a[href$="/index.html"]');
document.getElementById('fileCount').textContent = gridItems.length;
document.getElementById('folderCount').textContent = subdirLinks.length;
document.getElementById('totalSize').textContent = 'Unknown';
{% endif %}
// Set last updated time
const now = new Date();
const timeStr = now.toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit'
});
document.getElementById('lastUpdated').textContent = timeStr;
}
/**
* Calculate approximate total size of displayed files
*/
async calculateApproximateSize() {
const images = document.querySelectorAll('.grid-item img');
let totalSize = 0;
let loadedCount = 0;
const sizeElement = document.getElementById('totalSize');
sizeElement.textContent = 'Loading...';
// Estimate size based on a sample of images
const sampleSize = Math.min(images.length, 5);
const sampleImages = Array.from(images).slice(0, sampleSize);
if (sampleImages.length === 0) {
sizeElement.textContent = '0 KB';
return;
}
// Calculate average size from sample
for (const img of sampleImages) {
try {
const response = await fetch(img.src, { method: 'HEAD' });
const size = parseInt(response.headers.get('content-length') || '0');
if (size > 0) {
totalSize += size;
loadedCount++;
}
} catch (e) {
// Fallback: estimate 100KB per image
totalSize += 102400;
loadedCount++;
}
}
if (loadedCount > 0) {
const averageSize = totalSize / loadedCount;
const estimatedTotal = averageSize * images.length;
sizeElement.textContent = this.formatFileSize(estimatedTotal);
} else {
sizeElement.textContent = 'Unknown';
}
}
/**
* Format file size in human readable format
*/
formatFileSize(bytes) {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
}
/**
* 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 === 'c') {
e.preventDefault();
this.toggleCompareMode();
}
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') {
// If in plot selection mode, cancel it
if (this.comparisonSlot) {
// Find and remove instruction element
const instruction = document.getElementById('comparisonInstruction');
if (instruction) instruction.remove();
// Reset grid item styles
const gridItems = document.querySelectorAll('.grid-item');
gridItems.forEach(item => {
item.style.cursor = '';
item.style.border = '';
});
// Show overlay again
document.getElementById('comparisonOverlay').classList.add('open');
this.comparisonSlot = null;
return;
}
// Close comparison overlay if open
const comparisonOverlay = document.getElementById('comparisonOverlay');
if (comparisonOverlay && comparisonOverlay.classList.contains('open')) {
this.hideComparisonOverlay();
return;
}
// Other ESC behaviors
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);
}
}
/**
* Toggle comparison mode
*/
toggleCompareMode() {
this.comparisonMode = !this.comparisonMode;
const compareBtn = document.getElementById('compareToggle');
if (this.comparisonMode) {
compareBtn.style.background = 'var(--success-color)';
compareBtn.title = 'Exit Compare Mode (Ctrl+C)';
this.showComparisonOverlay();
} else {
compareBtn.style.background = 'var(--button-bg)';
compareBtn.title = 'Compare Plots (Ctrl+C)';
this.hideComparisonOverlay();
}
}
/**
* Show comparison overlay
*/
showComparisonOverlay() {
const overlay = document.getElementById('comparisonOverlay');
overlay.classList.add('open');
}
/**
* Hide comparison overlay
*/
hideComparisonOverlay() {
const overlay = document.getElementById('comparisonOverlay');
overlay.classList.remove('open');
this.comparisonMode = false;
const compareBtn = document.getElementById('compareToggle');
compareBtn.style.background = 'var(--button-bg)';
compareBtn.title = 'Compare Plots (Ctrl+C)';
}
/**
* Close comparison overlay (alias for hideComparisonOverlay)
*/
closeComparison() {
this.hideComparisonOverlay();
}
/**
* Select plot for comparison - simple approach
*/
selectPlotForComparison(slot) {
this.comparisonSlot = slot;
// Hide overlay temporarily by removing the 'open' class
const overlay = document.getElementById('comparisonOverlay');
overlay.classList.remove('open');
// Show simple alert with instructions
const instruction = document.createElement('div');
instruction.id = 'comparisonInstruction';
instruction.style.cssText = `
position: fixed;
top: 20px;
left: 50%;
transform: translateX(-50%);
background: var(--button-bg);
color: white;
padding: 1rem 2rem;
border-radius: 8px;
z-index: 3000;
font-size: 1.1rem;
font-weight: 600;
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
`;
instruction.innerHTML = `📊 Click any plot to select for ${slot === 'left' ? 'Plot A' : 'Plot B'} (ESC to cancel)`;
document.body.appendChild(instruction);
// Add one-time click listener to all grid items
const gridItems = document.querySelectorAll('.grid-item');
const handleClick = (event) => {
event.preventDefault();
event.stopPropagation();
const gridItem = event.currentTarget;
const img = gridItem.querySelector('img');
const nameEl = gridItem.querySelector('.plot-name');
if (img && nameEl) {
const plotInfo = {
name: nameEl.textContent.trim(),
imgSrc: img.src,
pdfSrc: img.src.replace('.png', '.pdf'),
path: window.location.pathname
};
this.addPlotToComparison(plotInfo, slot);
}
// Clean up
instruction.remove();
gridItems.forEach(item => item.removeEventListener('click', handleClick));
this.comparisonSlot = null;
overlay.classList.add('open');
};
gridItems.forEach(item => {
item.style.cursor = 'pointer';
item.style.border = '2px dashed var(--button-bg)';
item.addEventListener('click', handleClick);
});
// ESC to cancel
const handleEscape = (event) => {
if (event.key === 'Escape') {
instruction.remove();
gridItems.forEach(item => {
item.removeEventListener('click', handleClick);
item.style.cursor = '';
item.style.border = '';
});
this.comparisonSlot = null;
overlay.classList.add('open');
document.removeEventListener('keydown', handleEscape);
}
};
document.addEventListener('keydown', handleEscape);
}
/**
* Add plot to comparison panel
*/
addPlotToComparison(plotInfo, slot) {
this.plots[slot] = plotInfo;
const container = document.getElementById(`${slot}PlotContainer`);
const title = document.getElementById(`${slot}PlotTitle`);
const replaceBtn = document.getElementById(`${slot}ReplaceBtn`);
container.innerHTML = `
<img src="${plotInfo.imgSrc}" class="comparison-plot" alt="${plotInfo.name}"
onclick="window.open('${plotInfo.pdfSrc}', '_blank')" />
<div class="comparison-plot-info">
<strong>${plotInfo.name}</strong><br>
<small>${plotInfo.path}</small>
</div>
`;
title.textContent = plotInfo.name;
replaceBtn.style.display = 'block';
// Reset grid item styles
const gridItems = document.querySelectorAll('.grid-item');
gridItems.forEach(item => {
item.style.cursor = '';
item.style.border = '';
});
}
/**
* Replace plot in comparison
*/
replacePlot(slot) {
this.selectPlotForComparison(slot);
}
}
// ========================================
// GLOBAL FUNCTIONS (for backward compatibility)
// ========================================
let app;
function toggleTheme() { app.toggleTheme(); }
function toggleSidebar() { app.toggleSidebar(); }
function refreshGallery() { app.refreshGallery(); }
// ========================================
// INITIALIZE APPLICATION
// ========================================
document.addEventListener('DOMContentLoaded', function() {
app = new GalleryApp();
});
</script>
</body>
</html>