Convert to proper python package

This commit is contained in:
Kylian Schmidt
2026-04-22 13:48:23 +02:00
parent 7e36688d1d
commit 27ea17246c
52 changed files with 6888 additions and 246 deletions
+2
View File
@@ -4,3 +4,5 @@
*.ipynb
backups
.pytest_cache
.venv
build
+11 -31
View File
@@ -1,23 +1,20 @@
# 🔬 Scientific Gallery Generator
# Gallery: Scientific Plot Gallery Generator
[![Python 3.12+](https://img.shields.io/badge/python-3.12+-blue.svg)](https://www.python.org/downloads/)
[![Python 3.8+](https://img.shields.io/badge/python-3.8+-blue.svg)](https://www.python.org/downloads/)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Maintenance](https://img.shields.io/badge/Maintained%3F-yes-green.svg)](https://GitHub.com/Naereen/StrapDown.js/graphs/commit-activity)
[![Coverage Report](https://gitlab.etp.kit.edu/kschmidt/web/badges/main/coverage.svg)](https://gitlab.etp.kit.edu/kschmidt/web/-/jobs)
A powerful, responsive web-based gallery generator for scientific plots and analysis results. Transform your PDF plots into interactive HTML galleries with search, comparison tools, and hierarchical metadata management.
A professional, production-ready Python package for creating responsive HTML galleries from scientific plot collections. Convert PDFs to PNG, organize plots hierarchically, and generate beautiful static websites.
## ✨ Features
### 🎯 Core Functionality
### Core Functionality
- **PDF to PNG Conversion**: Automatic high-quality thumbnail generation using ImageMagick
- **Incremental Updates**: Only processes files when source is newer than target
- **Responsive Design**: Mobile-friendly interface with multiple view modes
- **Hierarchical Organization**: Support for nested folder structures
- **Search & Navigation**: Real-time search with fuzzy matching
### 📊 Advanced Features
### Advanced Features
- **Plot Comparison**: Side-by-side comparison tool for analyzing differences
- **Metadata Management**: YAML/JSON metadata with inheritance and display
- **Export Capabilities**: Batch export selected plots
@@ -25,29 +22,12 @@ A powerful, responsive web-based gallery generator for scientific plots and anal
- **Theme Support**: Dark/light theme toggle
- **Keyboard Shortcuts**: Power-user navigation
### 🔧 Metadata System
- **Hierarchical Inheritance**: Child directories inherit parent metadata
- **Multiple Formats**: Support for YAML and JSON metadata files
- **Interactive Display**: Collapsible metadata sections with copy-to-clipboard
- **LaTeX Support**: Mathematical expressions rendered with MathJax
- **Path Information**: Easy access to metadata file locations
## 🚀 Quick Start
### Prerequisites (when running barebones)
```bash
# Required system dependencies
sudo apt-get install imagemagick python3 python3-pip
# Python dependencies
pip install jinja2 pyyaml
```
### Installation
1. **Clone the repository**
### Developer-Friendly
- **Python API**: Import and use programmatically in other projects
- **CLI Interface**: Command-line tool for git-clone based deployments
- **Configuration Flexibility**: YAML config file or pure Python objects
- **Error Handling**: Returns False instead of raising, with optional verbose output
- **Source Override**: Process single directories without full regeneration
```bash
git clone <repository-url>
cd scientific-gallery-generator
+36
View File
@@ -0,0 +1,36 @@
"""
Scientific Gallery Generator
A Python package for creating responsive HTML galleries from scientific plot
collections. Supports PDF to PNG conversion, hierarchical directory structures,
and can be used programmatically or via CLI.
Example usage:
from gallery import generate, GalleryConfig, GallerySource
config = GalleryConfig(
web_folder="/path/to/output",
sources=[
GallerySource(name="plots", path="/path/to/plots"),
]
)
success = generate(config, verbose=True)
"""
__version__ = "0.1.0"
__author__ = "K. Schmidt"
from gallery.config import (
GalleryConfig,
GallerySource,
GalleryDefaults,
)
from gallery.api import generate
__all__ = [
"generate",
"GalleryConfig",
"GallerySource",
"GalleryDefaults",
]
+263
View File
@@ -0,0 +1,263 @@
"""
Main API for gallery generation.
Provides the primary entry point for programmatic gallery generation.
"""
import shutil
from pathlib import Path
from typing import Union, List, Dict, Any
from gallery.config import GalleryConfig, GallerySource
from gallery.builder import get_template, build_gallery, copy_assets
def generate(
config: Union[GalleryConfig, str, Path] = None,
web_folder: Union[str, Path] = None,
sources: List[Union[GallerySource, Dict[str, Any]]] = None,
clean_first: bool = False,
verbose: bool = False,
) -> bool:
"""
Generate a scientific gallery from plot sources.
Can be called in two ways:
1. With a GalleryConfig object
2. With explicit parameters (web_folder and sources)
Args:
config: GalleryConfig object or path to YAML config file.
If this is provided, other args are ignored.
web_folder: Output directory for the gallery.
Required if config is not provided.
sources: List of GallerySource objects or dicts.
Required if config is not provided.
clean_first: If True, removes and recreates the gallery directory
verbose: If True, prints progress messages
Returns:
True if gallery generation was successful, False otherwise
Raises:
ValueError: If required arguments are missing or invalid
TypeError: If config type is invalid
Example:
# Using GalleryConfig object
from gallery import generate, GalleryConfig, GallerySource
config = GalleryConfig(
web_folder="/output/path",
sources=[
GallerySource(name="plots", path="/path/to/plots"),
]
)
success = generate(config, verbose=True)
# Using explicit parameters
success = generate(
web_folder="/output/path",
sources=[
{"name": "plots", "path": "/path/to/plots"},
],
verbose=True
)
# Loading from YAML config
success = generate(config="config.yaml", verbose=True)
"""
try:
# Load or create configuration
if config is not None:
if isinstance(config, (str, Path)):
config = GalleryConfig.from_yaml(config)
elif not isinstance(config, GalleryConfig):
raise TypeError(
f"config must be GalleryConfig, str, or Path, "
f"got {type(config)}"
)
else:
if web_folder is None or sources is None:
raise ValueError(
"Either config or both web_folder and sources "
"must be provided"
)
config = GalleryConfig(
web_folder=web_folder,
sources=sources or []
)
# Validate configuration
if not config.sources:
if verbose:
print("Warning: No sources configured")
return False
# Check if web_folder is writable
web_folder_path = Path(config.web_folder)
if not _is_writable(web_folder_path):
if verbose:
print(
f"Error: Cannot write to web_folder: "
f"{config.web_folder}"
)
return False
# Create gallery root directory
gallery_root = web_folder_path / config.plot_root
if clean_first and gallery_root.exists():
if verbose:
print(f"Cleaning gallery directory {gallery_root}...")
try:
shutil.rmtree(gallery_root)
except Exception as e:
if verbose:
print(f"Warning: Could not clean directory: {e}")
return False
try:
gallery_root.mkdir(parents=True, exist_ok=True)
except Exception as e:
if verbose:
print(f"Error: Could not create gallery directory: {e}")
return False
# Copy assets
if not copy_assets(config, verbose=verbose):
if verbose:
print("Warning: Could not copy assets")
# Don't fail, continue with generation
# Get template
try:
template = get_template()
except Exception as e:
if verbose:
print(f"Error: Could not load template: {e}")
return False
# Process sources
source_subdirs = []
for source in config.sources:
try:
source_path = Path(source.path).resolve()
# Validate source exists
if not source_path.exists():
if verbose:
print(
f"Warning: Source {source.path} does not exist. "
f"Skipping."
)
continue
source_web_dir = gallery_root / source.name
try:
source_web_dir.mkdir(parents=True, exist_ok=True)
except Exception as e:
if verbose:
print(
f"Warning: Could not create directory "
f"{source_web_dir}: {e}"
)
continue
source_subdirs.append(source.name)
# Process source
if source_path.is_file() and source_path.suffix == '.pdf':
# Single PDF file
from gallery.utils.processing import process_plot_files
item = process_plot_files(
config=config,
plot_file=source_path,
web_dir=source_web_dir,
)
from gallery.utils.processing import render_gallery_page
render_gallery_page(
config=config,
template=template,
web_dir=source_web_dir,
items=[item],
subdirs=[],
relative_path=Path(source.name)
)
elif source_path.is_dir():
# Directory of plots
build_gallery(
config,
template,
source_path,
source_web_dir,
Path(source.name)
)
else:
if verbose:
print(
f"Warning: Source {source.path} is neither a "
f"directory nor a PDF file. Skipping."
)
continue
if verbose:
print(f"Processed {source.name}: {source.path}")
except Exception as e:
if verbose:
print(
f"Warning: Error processing source "
f"{source.name}: {e}"
)
continue
# Render gallery root index
try:
from gallery.utils.processing import render_gallery_page
render_gallery_page(
config=config,
template=template,
web_dir=gallery_root,
items=[],
subdirs=source_subdirs,
relative_path=Path("."),
title="Gallery Root"
)
except Exception as e:
if verbose:
print(f"Warning: Could not render gallery root: {e}")
# Don't fail, gallery is still usable
if verbose:
print(f"✓ Gallery generated successfully at {gallery_root}")
return True
except Exception as e:
if verbose:
print(f"Error: Gallery generation failed: {e}")
return False
def _is_writable(path: Path) -> bool:
"""
Check if a path is writable.
Creates the directory if it doesn't exist.
Args:
path: Path to check
Returns:
True if writable, False otherwise
"""
try:
path.mkdir(parents=True, exist_ok=True)
# Try to create a test file
test_file = path / ".gallery_test"
test_file.touch()
test_file.unlink()
return True
except Exception:
return False
+71
View File
@@ -0,0 +1,71 @@
/* ========================================
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: 400px; /* Further increased bottom padding to prevent overlap with stats box */
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;
}
/* ========================================
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 adjustments */
@media (max-width: 768px) {
body {
padding: 0.5rem;
padding-bottom: 300px; /* Further increased mobile bottom padding to match desktop */
}
h1 {
font-size: 1.5rem;
}
h2 {
font-size: 1.2rem;
margin: 1rem 0 0.5rem 0;
}
}
+180
View File
@@ -0,0 +1,180 @@
/* ========================================
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;
}
+440
View File
@@ -0,0 +1,440 @@
/* ========================================
EXPORT FUNCTIONALITY STYLES
======================================== */
/* Selection mode styles */
.selection-mode .grid-item {
cursor: pointer;
transition: all 0.2s ease;
}
.selection-mode .grid-item:hover {
transform: scale(1.02);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
/* Selection overlay */
.selection-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.3);
display: none;
justify-content: center;
align-items: center;
border-radius: 8px;
z-index: 5;
}
.selection-mode .selection-overlay {
display: flex;
}
.selection-checkbox {
background: var(--card-background);
border: 2px solid var(--border-color);
border-radius: 50%;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
transition: all 0.2s ease;
}
.grid-item.selected .selection-checkbox {
background: var(--primary-color, #007bff);
border-color: var(--primary-color, #007bff);
color: white;
}
.checkbox-icon {
line-height: 1;
}
/* Selection counter */
.selection-counter {
position: fixed;
bottom: 100px;
right: 20px;
background: var(--card-background);
border: 1px solid var(--border-color);
border-radius: 20px;
padding: 8px 16px;
font-size: 14px;
font-weight: 500;
color: var(--text-color);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 999;
}
/* Export button styles */
.export-btn {
background: #28a745 !important;
}
.export-btn:hover {
background: #218838 !important;
}
.export-btn:disabled {
background: #6c757d !important;
cursor: not-allowed;
}
/* Export messages */
.export-message {
position: fixed;
top: 20px;
right: 20px;
padding: 12px 24px;
border-radius: 6px;
font-weight: 500;
z-index: 1001;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
animation: slideIn 0.3s ease;
}
.export-message-info {
background: #d1ecf1;
color: #0c5460;
border: 1px solid #bee5eb;
}
.export-message-success {
background: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.export-message-warning {
background: #fff3cd;
color: #856404;
border: 1px solid #ffeaa7;
}
.export-message-error {
background: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
@keyframes slideIn {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
/* Selection mode indicator */
.selection-mode::before {
content: "Selection Mode - Click plots to select them";
position: fixed;
top: 0;
left: 0;
right: 0;
background: var(--primary-color, #007bff);
color: white;
text-align: center;
padding: 8px;
font-size: 14px;
font-weight: 500;
z-index: 1000;
}
/* Adjust main content when in selection mode */
.selection-mode {
padding-top: 40px;
}
/* Export instructions overlay */
.export-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1002;
}
.export-instructions {
background: var(--card-background);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 24px;
max-width: 600px;
max-height: 80vh;
overflow-y: auto;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
}
.export-instructions h3 {
margin: 0 0 16px 0;
color: var(--text-color);
}
.export-instructions p {
margin: 0 0 16px 0;
color: var(--text-color);
}
.export-data {
margin: 16px 0;
}
.export-data textarea {
width: 100%;
height: 200px;
font-family: 'Courier New', monospace;
font-size: 12px;
border: 1px solid var(--border-color);
border-radius: 4px;
padding: 8px;
background: var(--background-color);
color: var(--text-color);
resize: vertical;
}
.export-commands {
margin: 16px 0;
padding: 12px;
background: var(--header-background);
border-radius: 4px;
border: 1px solid var(--border-color);
}
.export-command-container {
margin: 20px 0;
border: 1px solid var(--border-color);
border-radius: 8px;
overflow: hidden;
}
.export-command {
background: var(--header-background);
padding: 16px;
border-bottom: 1px solid var(--border-color);
}
.export-command code {
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 13px;
line-height: 1.4;
color: var(--text-color);
word-break: break-all;
display: block;
background: none;
border: none;
padding: 0;
margin: 0;
}
.export-actions {
display: flex;
gap: 8px;
padding: 12px 16px;
background: var(--card-background);
}
.export-actions button {
padding: 8px 16px;
border: 1px solid var(--border-color);
border-radius: 4px;
background: var(--card-background);
color: var(--text-color);
cursor: pointer;
transition: all 0.2s ease;
}
.export-actions button:hover {
background: var(--header-background);
}
.export-actions button:last-child {
background: var(--primary-color, #007bff);
color: white;
border-color: var(--primary-color, #007bff);
}
.export-actions button:last-child:hover {
background: var(--primary-color-dark, #0056b3);
}
.copy-btn, .close-btn {
padding: 8px 16px;
border: 1px solid var(--border-color);
border-radius: 6px;
background: var(--card-background);
color: var(--text-color);
cursor: pointer;
transition: all 0.2s ease;
font-size: 14px;
display: flex;
align-items: center;
gap: 4px;
}
.copy-btn:hover {
background: var(--primary-color, #007bff);
color: white;
border-color: var(--primary-color, #007bff);
}
.close-btn {
background: #dc3545;
color: white;
border-color: #dc3545;
margin-left: auto;
}
.close-btn:hover {
background: #c82333;
border-color: #bd2130;
}
.export-details, .export-tips {
margin: 20px 0;
padding: 16px;
border-radius: 6px;
border: 1px solid var(--border-color);
}
.export-details {
background: var(--header-background);
}
.export-tips {
background: var(--card-background);
border-color: var(--primary-color, #007bff);
border-left: 4px solid var(--primary-color, #007bff);
}
.export-details h4, .export-tips h4 {
margin: 0 0 12px 0;
color: var(--text-color);
font-size: 16px;
}
.export-details ul, .export-tips ul {
margin: 0;
padding-left: 20px;
color: var(--text-color);
}
.export-details li, .export-tips li {
margin: 8px 0;
line-height: 1.5;
}
.export-details code, .export-tips code {
background: var(--background-color);
padding: 2px 6px;
border-radius: 3px;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 12px;
border: 1px solid var(--border-color);
}
.export-tips kbd {
background: var(--header-background);
border: 1px solid var(--border-color);
border-radius: 3px;
padding: 2px 6px;
font-family: inherit;
font-size: 12px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
.copy-feedback {
position: absolute;
top: 10px;
right: 10px;
padding: 8px 12px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
z-index: 1003;
animation: fadeInOut 2s ease-in-out;
}
.copy-feedback-success {
background: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.copy-feedback-error {
background: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
@keyframes fadeInOut {
0% { opacity: 0; transform: translateY(-10px); }
20% { opacity: 1; transform: translateY(0); }
80% { opacity: 1; transform: translateY(0); }
100% { opacity: 0; transform: translateY(-10px); }
}
/* Dark theme adjustments */
[data-theme="dark"] .selection-checkbox {
background: var(--card-background);
border-color: var(--border-color);
}
[data-theme="dark"] .grid-item.selected .selection-checkbox {
background: var(--primary-color, #0d6efd);
border-color: var(--primary-color, #0d6efd);
}
[data-theme="dark"] .export-message-info {
background: #0c5460;
color: #d1ecf1;
border-color: #086972;
}
[data-theme="dark"] .export-message-success {
background: #155724;
color: #d4edda;
border-color: #1e7e34;
}
[data-theme="dark"] .export-message-warning {
background: #856404;
color: #fff3cd;
border-color: #b58b14;
}
[data-theme="dark"] .export-message-error {
background: #721c24;
color: #f8d7da;
border-color: #a94442;
}
[data-theme="dark"] .copy-feedback-success {
background: #155724;
color: #d4edda;
border-color: #1e7e34;
}
[data-theme="dark"] .copy-feedback-error {
background: #721c24;
color: #f8d7da;
border-color: #a94442;
}
[data-theme="dark"] .export-tips kbd {
background: var(--background-color);
color: var(--text-color);
}
+112
View File
@@ -0,0 +1,112 @@
/* ========================================
FLOATING ACTION BUTTONS
======================================== */
.floating-buttons {
position: fixed;
bottom: 80px;
right: 15px;
display: flex;
flex-direction: column;
gap: 10px;
z-index: 1000;
/* Ensure buttons don't interfere with content */
pointer-events: none;
}
.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;
/* Re-enable pointer events for buttons */
pointer-events: auto;
}
.floating-btn:hover {
transform: scale(1.1);
}
.sidebar-toggle {
background: var(--button-bg);
color: white;
}
.theme-toggle {
background: var(--button-bg);
color: white;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.floating-buttons {
bottom: 60px;
right: 10px;
gap: 8px;
}
.floating-btn {
width: 48px;
height: 48px;
font-size: 1.1rem;
}
}
/* ========================================
KEYBOARD SHORTCUTS HELP
======================================== */
.shortcuts-help {
position: fixed;
bottom: 220px;
right: 15px;
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: 1001;
font-size: 0.9rem;
max-width: 280px;
max-height: 400px;
overflow-y: auto;
}
.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;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.shortcuts-help {
bottom: 180px;
right: 10px;
left: 10px;
max-width: none;
max-height: 300px;
}
}
+179
View File
@@ -0,0 +1,179 @@
/* ========================================
FOLDER METADATA STYLES
======================================== */
/* Folder Metadata Container */
.folder-metadata-container {
margin: 1rem 0;
border-radius: 8px;
background: var(--card-bg);
border: 1px solid var(--border-color);
overflow: hidden;
}
/* Folder Metadata Toggle Button */
.folder-metadata-toggle {
width: 100%;
padding: 0.75rem 1rem;
background: var(--card-bg);
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: space-between;
transition: background-color 0.2s ease;
font-size: 0.95rem;
color: var(--text-color);
position: relative;
z-index: 10;
outline: none;
}
.folder-metadata-toggle:hover {
background: var(--button-hover);
color: white;
}
.folder-metadata-toggle:focus {
outline: 2px solid var(--link-color);
outline-offset: 2px;
}
.folder-metadata-icon {
margin-right: 0.5rem;
}
.folder-metadata-label {
flex: 1;
text-align: left;
font-weight: 500;
}
.folder-metadata-arrow {
transition: transform 0.2s ease;
font-size: 0.8rem;
}
.folder-metadata-container.expanded .folder-metadata-arrow {
transform: rotate(180deg);
}
/* Folder Metadata Content - HIDDEN BY DEFAULT */
.folder-metadata-content {
max-height: 0;
overflow: hidden;
transition: max-height 0.3s ease, opacity 0.3s ease;
background: var(--bg-color);
opacity: 0;
display: none; /* Force hide initially */
}
/* Show content when expanded */
.folder-metadata-container.expanded .folder-metadata-content {
max-height: 1000px;
border-top: 1px solid var(--border-color);
opacity: 1;
display: block; /* Show when expanded */
}
/* Folder Metadata Grid */
.folder-metadata-grid {
padding: 1rem;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 0.5rem 1rem;
}
/* Folder Metadata Items */
.folder-metadata-item {
display: flex;
align-items: flex-start;
gap: 0.5rem;
padding: 0.25rem 0;
}
.folder-metadata-key {
font-weight: 600;
color: var(--link-color);
white-space: nowrap;
min-width: fit-content;
}
.folder-metadata-value {
color: var(--text-color);
word-break: break-word;
flex: 1;
}
/* Tags for list items */
.folder-metadata-list {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
}
.folder-metadata-tag {
background: var(--link-color);
color: white;
padding: 0.2rem 0.5rem;
border-radius: 12px;
font-size: 0.8rem;
white-space: nowrap;
}
/* Nested metadata */
.folder-metadata-nested {
background: var(--card-bg);
padding: 0.5rem;
border-radius: 4px;
border-left: 3px solid var(--link-color);
}
.folder-metadata-nested-item {
margin: 0.25rem 0;
font-size: 0.9rem;
}
/* Long text handling */
.folder-metadata-expand {
background: none;
border: none;
color: var(--link-color);
cursor: pointer;
text-decoration: underline;
padding: 0;
margin-left: 0.5rem;
font-size: 0.85rem;
}
.folder-metadata-expand:hover {
color: var(--link-hover);
}
/* Links */
.folder-metadata-value a {
color: var(--link-color);
text-decoration: none;
}
.folder-metadata-value a:hover {
color: var(--link-hover);
text-decoration: underline;
}
/* Responsive design */
@media (max-width: 768px) {
.folder-metadata-grid {
grid-template-columns: 1fr;
gap: 0.5rem;
}
.folder-metadata-item {
flex-direction: column;
gap: 0.25rem;
}
.folder-metadata-key {
white-space: normal;
}
}
+40
View File
@@ -0,0 +1,40 @@
/* ========================================
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);
}
+106
View File
@@ -0,0 +1,106 @@
/* ========================================
PLOT GRID
======================================== */
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 0.8rem;
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;
}
/* 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;
}
+38
View File
@@ -0,0 +1,38 @@
/* Styles for HTML plots */
.plot-item.html-plot .plot-link {
position: relative;
}
.plot-item.html-plot .html-thumbnail {
background: #f5f5f5;
border: 1px solid #ddd;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 200px;
text-align: center;
padding: 20px;
}
.plot-item.html-plot .html-indicator {
position: absolute;
top: 5px;
right: 5px;
background: #4CAF50;
color: white;
padding: 2px 6px;
border-radius: 3px;
font-size: 0.8em;
}
.plot-item.html-plot .html-preview {
color: #666;
margin-top: 10px;
font-size: 0.9em;
}
/* Add a hover effect */
.plot-item.html-plot .plot-link:hover .html-thumbnail {
background: #e8e8e8;
}
+33
View File
@@ -0,0 +1,33 @@
/* ========================================
GALLERY STYLES - MAIN ENTRY POINT
======================================== */
/* Core styles */
@import url('./variables.css');
@import url('./base.css');
/* Component styles */
@import url('./navigation.css');
@import url('./search.css');
@import url('./folder-tree.css');
@import url('./grid.css');
@import url('./sidebar.css');
@import url('./floating-elements.css');
@import url('./stats.css');
@import url('./comparison.css');
@import url('./metadata.css');
@import url('./metadata-section.css');
@import url('./folder-metadata.css');
@import url('./export.css');
/* View controls - must come after grid.css to override */
@import url('./view-controls.css');
/* Sort controls styling */
@import url('./sort-controls.css');
/* View override - force grid layout to work */
@import url('./view-override.css');
/* Responsive design - last to override everything */
@import url('./responsive.css');
+383
View File
@@ -0,0 +1,383 @@
/* ========================================
METADATA SECTION STYLES
======================================== */
/* Metadata Section Container */
.metadata-section {
margin: 1rem 0;
border-radius: 8px;
background: var(--card-bg);
border: 1px solid var(--border-color);
overflow: hidden;
}
/* Toggle Button */
.metadata-toggle-btn {
width: 100%;
padding: 0.75rem 1rem;
background: var(--card-bg);
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: space-between;
transition: background-color 0.2s ease;
font-size: 0.95rem;
color: var(--text-color);
position: relative;
z-index: 10;
}
.metadata-toggle-btn:hover {
background: var(--button-hover);
color: white;
}
.metadata-toggle-btn:focus {
outline: 2px solid var(--link-color);
outline-offset: 2px;
}
.metadata-icon {
margin-right: 0.5rem;
}
.metadata-label {
flex: 1;
text-align: left;
font-weight: 500;
}
.metadata-arrow {
transition: transform 0.2s ease;
font-size: 0.8rem;
}
/* Content Area - Hidden by default */
.metadata-content {
background: var(--bg-color);
border-top: 1px solid var(--border-color);
display: none; /* Hidden by default */
}
/* Metadata Header with File Path */
.metadata-header {
padding: 1rem;
background: var(--card-bg);
border-bottom: 1px solid var(--border-color);
}
.metadata-file-info {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.file-path-label {
font-weight: 600;
color: var(--text-color);
white-space: nowrap;
font-size: 0.9rem;
}
.file-path {
background: var(--bg-color);
border: 1px solid var(--border-color);
border-radius: 4px;
padding: 0.4rem 0.6rem;
font-family: 'Courier New', monospace;
font-size: 0.8rem;
color: var(--link-color);
flex: 1;
min-width: 200px;
word-break: break-all;
user-select: all;
}
.copy-path-btn {
background: var(--link-color);
color: white;
border: none;
border-radius: 4px;
padding: 0.4rem 0.8rem;
cursor: pointer;
font-size: 0.8rem;
transition: all 0.2s ease;
white-space: nowrap;
font-weight: 500;
}
.copy-path-btn:hover {
background: var(--link-hover);
transform: translateY(-1px);
}
.copy-path-btn:active {
transform: scale(0.95);
}
.copy-path-btn.copied {
background: #4CAF50;
transform: scale(1.05);
}
/* Tip Icon with Hover Tooltip */
.tip-icon {
cursor: help;
font-size: 1.2rem;
position: relative;
display: inline-block;
margin-left: 0.25rem;
opacity: 0.8;
transition: opacity 0.2s ease;
}
.tip-icon:hover {
opacity: 1;
}
/* Custom tooltip for tip icon */
.tip-icon::after {
content: attr(title);
position: absolute;
bottom: 125%;
left: 50%;
transform: translateX(-50%);
background: #333;
color: white;
padding: 0.75rem;
border-radius: 6px;
font-size: 0.85rem;
white-space: normal;
width: 280px;
text-align: left;
z-index: 1000;
opacity: 0;
visibility: hidden;
transition: opacity 0.3s ease, visibility 0.3s ease;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
line-height: 1.4;
font-weight: normal;
font-family: var(--font-family, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif);
}
/* Tooltip arrow */
.tip-icon::before {
content: '';
position: absolute;
bottom: 115%;
left: 50%;
transform: translateX(-50%);
border: 6px solid transparent;
border-top-color: #333;
opacity: 0;
visibility: hidden;
transition: opacity 0.3s ease, visibility 0.3s ease;
z-index: 1001;
}
.tip-icon:hover::after,
.tip-icon:hover::before {
opacity: 1;
visibility: visible;
}
/* Grid Layout */
.metadata-grid {
padding: 1rem;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1rem 1.5rem; /* Increased gaps to prevent overlapping */
}
/* Metadata Items */
.metadata-item {
display: flex;
flex-direction: column; /* Stack key and value vertically to prevent overlap */
gap: 0.25rem;
padding: 0.75rem;
background: var(--card-bg);
border-radius: 6px;
border: 1px solid var(--border-color);
word-wrap: break-word; /* Ensure long text wraps */
overflow-wrap: break-word; /* Additional word wrapping */
}
.metadata-key {
font-weight: 600;
color: var(--link-color);
font-size: 0.9rem;
margin-bottom: 0.25rem;
}
.metadata-value {
color: var(--text-color);
word-break: break-word;
overflow-wrap: break-word;
line-height: 1.4;
font-size: 0.9rem;
}
/* LaTeX content styling */
.latex-content {
color: var(--text-color);
line-height: 1.6;
font-family: 'Times New Roman', serif;
}
/* Simple list items - proper list formatting */
.metadata-list {
color: var(--text-color);
line-height: 1.4;
margin: 0;
padding-left: 1.2rem;
list-style-type: disc; /* Add bullet points */
}
.metadata-list li {
margin: 0.25rem 0;
padding: 0;
color: var(--text-color);
}
/* YAML-style formatting for metadata */
.metadata-yaml-list {
margin: 0.5rem 0;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 0.9rem;
line-height: 1.6;
background: var(--card-bg);
padding: 0.8rem;
border-radius: 6px;
border: 1px solid var(--border-color);
}
.yaml-list-item {
color: var(--text-color);
margin: 0.25rem 0;
padding-left: 0;
text-indent: 0;
}
.metadata-yaml-object {
margin: 0.5rem 0;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 0.9rem;
line-height: 1.6;
background: var(--card-bg);
padding: 0.8rem;
border-radius: 6px;
border: 1px solid var(--border-color);
}
.yaml-object-item {
margin: 0.5rem 0;
}
.yaml-key {
color: var(--link-color);
font-weight: 600;
}
.yaml-value {
color: var(--text-color);
margin-left: 0.5rem;
}
.yaml-nested-list {
margin: 0.25rem 0 0 1.5rem;
border-left: 2px solid var(--border-color);
padding-left: 0.8rem;
}
.yaml-nested-item {
color: var(--text-color);
margin: 0.2rem 0;
padding-left: 0;
}
.yaml-nested-object {
margin: 0.25rem 0 0 1.5rem;
border-left: 2px solid var(--border-color);
padding-left: 0.8rem;
}
/* Remove the blue box styling for tags */
.metadata-tag {
display: inline;
background: none;
color: var(--text-color);
padding: 0;
border-radius: 0;
font-size: inherit;
white-space: normal;
}
/* Simplified nested metadata */
.metadata-nested {
background: none;
padding: 0;
border-radius: 0;
border-left: none;
color: var(--text-color);
}
.metadata-nested-item {
margin: 0.25rem 0;
font-size: 0.9rem;
}
/* Long text handling */
.metadata-expand {
background: none;
border: none;
color: var(--link-color);
cursor: pointer;
text-decoration: underline;
padding: 0;
margin-left: 0.5rem;
font-size: 0.85rem;
}
.metadata-expand:hover {
color: var(--link-hover);
}
/* Links */
.metadata-value a {
color: var(--link-color);
text-decoration: none;
}
.metadata-value a:hover {
color: var(--link-hover);
text-decoration: underline;
}
/* Responsive design */
@media (max-width: 768px) {
.metadata-grid {
grid-template-columns: 1fr;
gap: 0.5rem;
}
.metadata-item {
flex-direction: column;
gap: 0.25rem;
}
.metadata-key {
white-space: normal;
}
.metadata-file-info {
flex-direction: column;
align-items: stretch;
gap: 0.5rem;
}
.file-path {
min-width: auto;
}
}
+180
View File
@@ -0,0 +1,180 @@
/* ========================================
METADATA POPUP STYLES
======================================== */
/* Metadata button on thumbnails */
.metadata-btn {
position: absolute;
top: 8px;
right: 8px;
background: rgba(0, 0, 0, 0.7);
color: white;
border: none;
border-radius: 50%;
width: 32px;
height: 32px;
font-size: 16px;
cursor: pointer;
z-index: 10;
transition: all 0.2s ease;
backdrop-filter: blur(4px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
opacity: 0;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
}
.grid-item:hover .metadata-btn {
opacity: 1;
}
.metadata-btn:hover {
background: rgba(0, 0, 0, 0.9);
transform: scale(1.1);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.metadata-btn:active {
transform: scale(0.95);
}
/* Grid item positioning for metadata button */
.grid-item {
position: relative;
}
/* Metadata popup */
.metadata-popup {
background: var(--card-background);
border: 1px solid var(--border-color);
border-radius: 8px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
max-width: 320px;
min-width: 250px;
opacity: 0;
transform: translateY(-10px);
transition: all 0.2s ease;
backdrop-filter: blur(10px);
z-index: 1000;
}
.metadata-popup.show {
opacity: 1;
transform: translateY(0);
}
.metadata-popup-header {
padding: 12px 16px;
border-bottom: 1px solid var(--border-color);
background: var(--header-background);
border-radius: 8px 8px 0 0;
}
.metadata-popup-header h4 {
margin: 0;
font-size: 14px;
font-weight: 600;
color: var(--text-color);
word-break: break-word;
}
.metadata-popup-content {
padding: 12px 16px;
max-height: 300px;
overflow-y: auto;
}
.metadata-field {
display: flex;
margin-bottom: 8px;
gap: 8px;
align-items: flex-start;
}
.metadata-field:last-child {
margin-bottom: 0;
}
.metadata-key {
font-weight: 500;
color: var(--accent-color);
font-size: 12px;
min-width: 80px;
flex-shrink: 0;
}
.metadata-value {
font-size: 12px;
color: var(--text-color);
word-break: break-word;
flex: 1;
}
.metadata-value code {
background: var(--code-background);
padding: 2px 4px;
border-radius: 3px;
font-size: 11px;
font-family: 'Courier New', monospace;
}
.metadata-tag {
background: var(--accent-color);
color: var(--background-color);
padding: 2px 6px;
border-radius: 12px;
font-size: 10px;
font-weight: 500;
margin-right: 4px;
display: inline-block;
}
.metadata-more {
color: var(--breadcrumb-color);
font-style: italic;
font-size: 11px;
}
.no-metadata {
color: var(--breadcrumb-color);
font-style: italic;
margin: 0;
text-align: center;
padding: 20px 0;
}
/* Custom scrollbar for metadata popup */
.metadata-popup-content::-webkit-scrollbar {
width: 6px;
}
.metadata-popup-content::-webkit-scrollbar-track {
background: transparent;
}
.metadata-popup-content::-webkit-scrollbar-thumb {
background: var(--border-color);
border-radius: 3px;
}
.metadata-popup-content::-webkit-scrollbar-thumb:hover {
background: var(--accent-color);
}
/* Responsive adjustments */
@media (max-width: 768px) {
.metadata-popup {
max-width: calc(100vw - 32px);
min-width: 200px;
}
.metadata-btn {
width: 28px;
height: 28px;
font-size: 14px;
top: 6px;
right: 6px;
}
}
+57
View File
@@ -0,0 +1,57 @@
/* ========================================
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;
padding: 0.5rem 1rem;
border: none;
border-radius: 6px;
cursor: pointer;
text-decoration: none;
transition: all 0.2s ease;
font-size: 0.9rem;
display: inline-flex;
align-items: center;
gap: 0.3rem;
}
.nav-btn:hover {
background: var(--button-hover);
transform: translateY(-1px);
}
.nav-btn:disabled {
background: var(--disabled-color);
cursor: not-allowed;
transform: none;
}
+59
View File
@@ -0,0 +1,59 @@
/* ========================================
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%;
}
/* View controls responsive */
.controls-container {
flex-direction: column;
gap: 1rem;
align-items: stretch;
}
.sort-controls {
justify-content: center;
}
.view-controls {
margin-right: 0.5rem !important;
padding: 8px 12px !important;
justify-content: center;
}
.view-btn {
min-width: 40px !important;
min-height: 40px !important;
padding: 8px 12px !important;
}
.view-btn svg {
width: 16px !important;
height: 16px !important;
}
}
+72
View File
@@ -0,0 +1,72 @@
/* ========================================
SEARCH FUNCTIONALITY
======================================== */
.search-container {
position: relative;
max-width: 500px;
margin: 1rem 0;
}
.search-box {
width: 100%;
padding: 0.8rem 3rem 0.8rem 1rem;
border: 1px solid var(--border-color);
border-radius: 8px;
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;
}
+121
View File
@@ -0,0 +1,121 @@
/* ========================================
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;
}
+88
View File
@@ -0,0 +1,88 @@
/* ========================================
SORT CONTROLS STYLING
======================================== */
/* Clean styling for sort controls */
.sort-controls {
display: flex !important;
align-items: center !important;
gap: 8px !important;
padding: 12px 16px !important;
background: var(--tree-bg) !important;
border: 1px solid var(--border-color) !important;
border-radius: 8px !important;
margin-right: 1rem !important;
}
.sort-label {
font-size: 0.9rem !important;
color: var(--text-color) !important;
margin-right: 8px !important;
font-weight: 500 !important;
}
/* Button styling using theme variables */
.sort-btn {
background: var(--card-bg) !important;
border: 1px solid var(--border-color) !important;
border-radius: 6px !important;
padding: 8px 12px !important;
cursor: pointer !important;
transition: all 0.2s ease !important;
font-size: 0.85rem !important;
color: var(--text-color) !important;
display: flex !important;
align-items: center !important;
gap: 4px !important;
outline: none !important;
text-decoration: none !important;
font-family: inherit !important;
}
.sort-btn:hover {
background: var(--button-bg) !important;
color: white !important;
transform: translateY(-1px) !important;
box-shadow: 0 2px 4px rgba(0,0,0,0.2) !important;
border-color: var(--button-bg) !important;
}
.sort-btn.active {
background: var(--button-bg) !important;
color: white !important;
border-color: var(--button-bg) !important;
box-shadow: 0 2px 4px rgba(0,0,0,0.2) !important;
}
.sort-order-btn {
background: var(--card-bg) !important;
border: 1px solid var(--border-color) !important;
border-radius: 6px !important;
padding: 8px 12px !important;
cursor: pointer !important;
transition: all 0.2s ease !important;
font-size: 1rem !important;
color: var(--text-color) !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
min-width: 36px !important;
outline: none !important;
text-decoration: none !important;
font-family: inherit !important;
}
.sort-order-btn:hover {
background: var(--button-bg) !important;
color: white !important;
transform: translateY(-1px) !important;
box-shadow: 0 2px 4px rgba(0,0,0,0.2) !important;
border-color: var(--button-bg) !important;
}
.sort-order-btn.active {
background: var(--button-bg) !important;
color: white !important;
border-color: var(--button-bg) !important;
box-shadow: 0 2px 4px rgba(0,0,0,0.2) !important;
}
View File
+59
View File
@@ -0,0 +1,59 @@
/* ========================================
GALLERY STATISTICS
======================================== */
.gallery-stats {
position: fixed;
bottom: 80px;
left: 15px;
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;
/* Ensure stats don't interfere with content */
pointer-events: none;
}
.gallery-stats:hover {
opacity: 1;
/* Re-enable pointer events on hover */
pointer-events: auto;
}
.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);
}
/* Responsive adjustments */
@media (max-width: 768px) {
.gallery-stats {
bottom: 60px;
left: 10px;
padding: 0.6rem 0.8rem;
font-size: 0.8rem;
max-width: 180px;
}
.stats-item {
margin: 0.15rem 0;
}
}
+33
View File
@@ -0,0 +1,33 @@
/* ========================================
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;
}
+404
View File
@@ -0,0 +1,404 @@
/* ========================================
VIEW CONTROLS AND LAYOUT MODES
======================================== */
/* Controls Container */
.controls-container {
display: flex;
justify-content: space-between;
align-items: center;
margin: 1rem 0;
gap: 2rem;
flex-wrap: wrap;
}
/* Sort Controls */
.sort-controls {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: var(--tree-bg);
border: 1px solid var(--border-color);
border-radius: 8px;
}
.sort-label {
font-size: 0.9rem;
color: var(--text-color);
margin-right: 4px;
font-weight: 500;
}
.sort-btn, .sort-order-btn {
background: var(--card-bg) !important;
border: 1px solid var(--border-color) !important;
border-radius: 6px !important;
padding: 6px 12px !important;
cursor: pointer !important;
transition: all 0.2s ease !important;
font-size: 0.85rem !important;
color: var(--text-color) !important;
display: flex !important;
align-items: center !important;
gap: 4px !important;
outline: none !important;
text-decoration: none !important;
}
.sort-btn:hover, .sort-order-btn:hover {
background: var(--button-bg) !important;
color: white !important;
transform: translateY(-1px) !important;
box-shadow: 0 2px 4px rgba(0,0,0,0.1) !important;
}
.sort-btn.active, .sort-order-btn.active {
background: var(--button-bg) !important;
color: white !important;
border-color: var(--button-bg) !important;
box-shadow: 0 2px 4px rgba(0,0,0,0.1) !important;
}
.sort-order-btn {
min-width: 32px !important;
justify-content: center !important;
font-size: 1rem !important;
}
/* View Controls */
.view-controls {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 12px;
padding: 12px 16px;
background: var(--tree-bg);
border: 1px solid var(--border-color);
border-radius: 8px;
position: relative;
z-index: 10;
}
.view-btn {
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 12px 16px;
cursor: pointer;
transition: all 0.2s ease;
font-size: 1.2rem;
color: var(--text-color);
min-width: 48px;
min-height: 48px;
display: flex;
align-items: center;
justify-content: center;
}
.view-btn svg {
width: 18px;
height: 18px;
}
.view-btn:hover {
background: var(--button-bg);
color: white;
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
}
.view-btn.active {
background: var(--button-bg);
color: white;
border-color: var(--button-bg);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
/* Plot Container Base Styles */
.plot-container {
margin: 1rem 0;
margin-bottom: 450px; /* Add extra bottom margin to prevent overlap with stats box */
transition: all 0.3s ease;
clear: both;
}
.plot-container .plot-item {
transition: all 0.2s ease;
border-radius: 8px;
overflow: hidden;
background: var(--card-bg);
border: 1px solid transparent;
}
.plot-container .plot-item:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
border-color: var(--border-color);
}
.plot-container .plot-link {
color: var(--link-color);
text-decoration: none;
display: block;
}
.plot-container .plot-thumbnail {
width: 100%;
height: auto;
border-radius: 6px;
transition: all 0.2s ease;
}
.plot-container .plot-info {
padding: 0.8rem;
}
.plot-container .plot-name {
word-wrap: break-word;
word-break: break-word;
hyphens: auto;
font-size: 0.9rem;
line-height: 1.3;
font-weight: 500;
color: var(--text-color);
}
.plot-container .plot-date {
font-size: 0.8rem;
color: var(--text-secondary);
margin-top: 0.3rem;
opacity: 0.7; /* Slightly lower opacity for differentiation */
}
/* Grid View - Override any conflicting styles */
.plot-container.grid-view {
display: grid !important;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)) !important;
gap: 1.2rem !important;
padding: 1rem 0 !important;
}
.plot-container.grid-view .plot-item,
.plot-container.grid-view .grid-item {
text-align: center !important;
background: var(--card-bg) !important;
border-radius: 8px !important;
padding: 0.8rem !important;
transition: all 0.2s ease !important;
border: 1px solid transparent !important;
display: block !important;
width: auto !important;
max-width: none !important;
}
.plot-container.grid-view .plot-item:hover,
.plot-container.grid-view .grid-item:hover {
transform: translateY(-2px) !important;
box-shadow: 0 4px 12px rgba(0,0,0,0.1) !important;
border-color: var(--border-color) !important;
}
.plot-container.grid-view .plot-link {
display: block !important;
color: var(--link-color) !important;
text-decoration: none !important;
}
.plot-container.grid-view .plot-thumbnail {
max-width: 100% !important;
height: auto !important;
border: 1px solid var(--border-color) !important;
display: block !important;
width: 100% !important;
object-fit: contain !important;
border-radius: 6px !important;
}
.plot-container.grid-view .plot-info {
padding: 0.8rem 0 0 0 !important;
}
.plot-container.grid-view .plot-name {
max-height: 3.9rem !important;
overflow: hidden !important;
margin-top: 0.8rem !important;
display: block !important;
word-wrap: break-word !important;
word-break: break-word !important;
hyphens: auto !important;
font-size: 0.9rem !important;
line-height: 1.3 !important;
font-weight: 500 !important;
color: var(--text-color) !important;
text-align: center !important;
}
/* Large List View */
.plot-container.list-large-view {
display: flex !important;
flex-direction: column !important;
gap: 0.8rem !important;
}
.plot-container.list-large-view .plot-item {
display: flex !important;
align-items: center !important;
padding: 1rem !important;
gap: 1rem !important;
}
.plot-container.list-large-view .plot-link {
flex-shrink: 0 !important;
width: 120px !important;
height: 90px !important;
overflow: hidden !important;
border-radius: 6px !important;
border: 1px solid var(--border-color) !important;
}
.plot-container.list-large-view .plot-thumbnail {
width: 100% !important;
height: 100% !important;
object-fit: cover !important;
}
.plot-container.list-large-view .plot-info {
flex: 1 !important;
padding: 0 !important;
text-align: left !important;
display: flex !important;
justify-content: space-between !important;
align-items: center !important;
}
.plot-container.list-large-view .plot-name {
font-size: 1rem !important;
line-height: 1.4 !important;
max-height: none !important;
overflow: visible !important;
flex: 1 !important;
}
.plot-container.list-large-view .plot-date {
flex-shrink: 0 !important;
margin-left: 1rem !important;
margin-top: 0 !important;
font-size: 0.85rem !important;
color: var(--text-secondary) !important;
white-space: nowrap !important;
}
/* Compact List View */
.plot-container.list-compact-view {
display: flex !important;
flex-direction: column !important;
gap: 0.4rem !important;
}
.plot-container.list-compact-view .plot-item {
display: flex !important;
align-items: center !important;
padding: 0.6rem 1rem !important;
gap: 0.8rem !important;
border-radius: 4px !important;
}
.plot-container.list-compact-view .plot-link {
flex: 1 !important;
display: flex !important;
align-items: center !important;
}
.plot-container.list-compact-view .plot-thumbnail {
display: none !important;
}
.plot-container.list-compact-view .plot-info {
padding: 0 !important;
flex: 1 !important;
text-align: left !important;
display: flex !important;
justify-content: space-between !important;
align-items: center !important;
}
.plot-container.list-compact-view .plot-name {
font-size: 0.95rem !important;
line-height: 1.2 !important;
margin: 0 !important;
white-space: nowrap !important;
overflow: hidden !important;
text-overflow: ellipsis !important;
flex: 1 !important;
}
.plot-container.list-compact-view .plot-date {
flex-shrink: 0 !important;
margin-left: 1rem !important;
margin-top: 0 !important;
font-size: 0.8rem !important;
color: var(--text-secondary) !important;
white-space: nowrap !important;
}
/* Highlight effect for all views */
.plot-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); }
}
/* Responsive adjustments */
@media (max-width: 768px) {
.view-controls {
width: 100%;
margin-left: 0;
margin-right: 0;
}
.plot-container.grid-view {
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 1rem;
}
.plot-container.list-large-view .plot-link {
width: 80px;
height: 60px;
}
.plot-container.list-large-view .plot-item {
padding: 0.8rem;
}
.plot-container.list-compact-view .plot-item {
padding: 0.5rem 0.8rem;
}
}
@media (max-width: 480px) {
.view-controls {
gap: 4px;
}
.view-btn {
padding: 6px 8px;
font-size: 1rem;
min-width: 32px;
}
.plot-container.grid-view {
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
}
.plot-container.list-large-view .plot-link {
width: 60px;
height: 45px;
}
}
+54
View File
@@ -0,0 +1,54 @@
/* ========================================
VIEW OVERRIDE - Ensure grid view works
======================================== */
/* Force grid layout when grid-view class is present */
body .plot-container.grid-view {
display: grid !important;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)) !important;
gap: 1.2rem !important;
padding: 1rem 0 !important;
}
/* Force grid items to be proper tiles */
body .plot-container.grid-view .plot-item,
body .plot-container.grid-view .grid-item {
display: block !important;
width: auto !important;
max-width: none !important;
text-align: center !important;
background: var(--card-bg) !important;
border-radius: 8px !important;
padding: 0.8rem !important;
border: 1px solid transparent !important;
}
/* Ensure thumbnails are properly sized */
body .plot-container.grid-view .plot-thumbnail {
width: 100% !important;
height: auto !important;
max-width: 100% !important;
display: block !important;
border: 1px solid var(--border-color) !important;
border-radius: 6px !important;
object-fit: cover !important;
aspect-ratio: 4/3;
}
/* Force plot info styling */
body .plot-container.grid-view .plot-info {
padding: 0.5rem 0 0 0 !important;
text-align: center !important;
}
body .plot-container.grid-view .plot-name {
font-size: 0.85rem !important;
line-height: 1.2 !important;
max-height: 2.4rem !important;
overflow: hidden !important;
text-overflow: ellipsis !important;
display: -webkit-box !important;
-webkit-line-clamp: 2 !important;
line-clamp: 2 !important;
-webkit-box-orient: vertical !important;
}
+180
View File
@@ -0,0 +1,180 @@
/**
* Plot comparison functionality
*/
export class ComparisonManager {
constructor() {
this.comparisonMode = false;
this.comparisonSlot = null; // 'left' or 'right'
this.plots = { left: null, right: null };
}
/**
* Toggle comparison mode
*/
toggleCompareMode() {
this.comparisonMode = !this.comparisonMode;
const compareBtn = document.getElementById('compareToggle');
if (!compareBtn) return;
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');
if (overlay) overlay.classList.add('open');
}
/**
* Hide comparison overlay
*/
hideComparisonOverlay() {
const overlay = document.getElementById('comparisonOverlay');
if (overlay) overlay.classList.remove('open');
this.comparisonMode = false;
const compareBtn = document.getElementById('compareToggle');
if (compareBtn) {
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');
if (overlay) 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;
if (overlay) 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;
if (overlay) 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`);
if (container) {
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>
`;
}
if (title) title.textContent = plotInfo.name;
if (replaceBtn) 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);
}
}
+448
View File
@@ -0,0 +1,448 @@
/**
* Export Manager for Gallery
* Handles exporting selected plots to merged PDF
*/
export class ExportManager {
constructor() {
this.selectedPlots = new Set();
this.maxPlots = 4;
this.init();
}
init() {
this.createExportButton();
this.bindEvents();
}
/**
* Create the export button in the floating buttons section
*/
createExportButton() {
const floatingButtons = document.querySelector('.floating-buttons');
if (!floatingButtons) return;
const exportBtn = document.createElement('button');
exportBtn.className = 'floating-btn export-btn';
exportBtn.id = 'exportBtn';
exportBtn.title = 'Export Selected Plots (Ctrl+E)';
exportBtn.innerHTML = '📄';
exportBtn.style.display = 'none'; // Hidden by default
exportBtn.onclick = () => this.exportSelectedPlots();
floatingButtons.appendChild(exportBtn);
// Add selection counter
const selectionCounter = document.createElement('div');
selectionCounter.className = 'selection-counter';
selectionCounter.id = 'selectionCounter';
selectionCounter.style.display = 'none';
selectionCounter.innerHTML = '0/4 selected';
floatingButtons.appendChild(selectionCounter);
}
/**
* Bind events for plot selection
*/
bindEvents() {
// Add selection mode toggle
document.addEventListener('keydown', (e) => {
if (e.ctrlKey && e.key === 'e') {
e.preventDefault();
this.toggleSelectionMode();
}
if (e.key === 'Escape') {
this.exitSelectionMode();
}
});
// Add selection handlers to existing plots
this.addSelectionHandlers();
}
/**
* Add selection handlers to all plot items
*/
addSelectionHandlers() {
const plotItems = document.querySelectorAll('.grid-item');
plotItems.forEach(item => this.addSelectionHandler(item));
}
/**
* Add selection handler to a single plot item
*/
addSelectionHandler(item) {
// Create selection overlay
const overlay = document.createElement('div');
overlay.className = 'selection-overlay';
overlay.innerHTML = `
<div class="selection-checkbox">
<span class="checkbox-icon"></span>
</div>
`;
item.appendChild(overlay);
// Add click handler for selection
overlay.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
this.togglePlotSelection(item);
});
}
/**
* Toggle selection mode
*/
toggleSelectionMode() {
const body = document.body;
const isSelectionMode = body.classList.contains('selection-mode');
if (isSelectionMode) {
this.exitSelectionMode();
} else {
this.enterSelectionMode();
}
}
/**
* Enter selection mode
*/
enterSelectionMode() {
document.body.classList.add('selection-mode');
document.getElementById('exportBtn').style.display = 'block';
document.getElementById('selectionCounter').style.display = 'block';
this.updateSelectionCounter();
}
/**
* Exit selection mode
*/
exitSelectionMode() {
const wasInSelectionMode = document.body.classList.contains('selection-mode');
document.body.classList.remove('selection-mode');
document.getElementById('exportBtn').style.display = 'none';
document.getElementById('selectionCounter').style.display = 'none';
this.clearSelection();
// Show message if user was actually in selection mode
if (wasInSelectionMode) {
this.showMessage('Exited selection mode', 'info');
}
}
/**
* Toggle plot selection
*/
togglePlotSelection(item) {
const plotName = this.getPlotName(item);
const plotPath = this.getPlotPath(item);
if (this.selectedPlots.has(plotName)) {
this.selectedPlots.delete(plotName);
item.classList.remove('selected');
item.querySelector('.checkbox-icon').textContent = '☐';
} else {
if (this.selectedPlots.size >= this.maxPlots) {
this.showMessage(`Maximum ${this.maxPlots} plots can be selected`, 'warning');
return;
}
this.selectedPlots.add(plotName);
item.classList.add('selected');
item.querySelector('.checkbox-icon').textContent = '☑';
}
this.updateSelectionCounter();
}
/**
* Get plot name from grid item
*/
getPlotName(item) {
const plotName = item.querySelector('.plot-name');
return plotName ? plotName.textContent.trim() : '';
}
/**
* Get plot PDF path from grid item
*/
getPlotPath(item) {
const link = item.querySelector('a[href$=".pdf"]');
return link ? link.href : '';
}
/**
* Update selection counter
*/
updateSelectionCounter() {
const counter = document.getElementById('selectionCounter');
if (counter) {
counter.textContent = `${this.selectedPlots.size}/${this.maxPlots} selected`;
}
const exportBtn = document.getElementById('exportBtn');
if (exportBtn) {
exportBtn.disabled = this.selectedPlots.size === 0;
exportBtn.style.opacity = this.selectedPlots.size === 0 ? '0.5' : '1';
}
}
/**
* Clear all selections
*/
clearSelection() {
this.selectedPlots.clear();
document.querySelectorAll('.grid-item.selected').forEach(item => {
item.classList.remove('selected');
const checkbox = item.querySelector('.checkbox-icon');
if (checkbox) checkbox.textContent = '☐';
});
this.updateSelectionCounter();
}
/**
* Export selected plots to merged PDF
*/
async exportSelectedPlots() {
if (this.selectedPlots.size === 0) {
this.showMessage('No plots selected', 'warning');
return;
}
const plotPaths = Array.from(this.selectedPlots).map(plotName => {
const item = Array.from(document.querySelectorAll('.grid-item'))
.find(item => this.getPlotName(item) === plotName);
return this.getPlotPath(item);
});
this.showMessage('Preparing export...', 'info');
try {
await this.createMergedPDF(plotPaths);
} catch (error) {
this.showMessage('Export failed: ' + error.message, 'error');
}
}
/**
* Create merged PDF using Python script
*/
async createMergedPDF(plotPaths) {
// Convert file:// URLs to actual paths
const actualPaths = plotPaths.map(url => {
if (url.startsWith('file://')) {
return url.substring(7); // Remove 'file://' prefix
}
return url;
});
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').split('T')[0];
const outputName = `merged_plots_${timestamp}.pdf`;
const payload = {
plots: actualPaths,
layout: this.calculateLayout(actualPaths.length),
output_name: outputName
};
// Generate a unique temporary filename
const tempFileName = `export_request_${Date.now()}.json`;
// Get work directory from config or fallback
const workDir = window.galleryConfig?.workDir || '/work/kschmidt/web';
// Save the request to a JSON file that can be picked up by a Python script
const requestData = JSON.stringify(payload, null, 2);
// Show improved export instructions with full command
this.showExportInstructions(requestData, tempFileName, workDir);
}
/**
* Calculate optimal layout for given number of plots
*/
calculateLayout(numPlots) {
switch (numPlots) {
case 1: return { rows: 1, cols: 1 };
case 2: return { rows: 1, cols: 2 };
case 3: return { rows: 2, cols: 2 }; // 3 plots in 2x2 grid with one empty
case 4: return { rows: 2, cols: 2 };
default: return { rows: 2, cols: 2 };
}
}
/**
* Show export instructions to user
*/
showExportInstructions(requestData, tempFileName, workDir) {
const tempFilePath = `/tmp/${tempFileName}`;
const fullCommand = `echo '${requestData.replace(/'/g, "'\\''")}' > ${tempFilePath} && cd ${workDir} && python export_plots.py ${tempFilePath}`;
const instructions = `
<div class="export-instructions">
<h3>🚀 Export Selected Plots</h3>
<p>Run the following command in your terminal to export the selected plots:</p>
<div class="export-command-container">
<div class="export-command">
<code id="exportCommand">${fullCommand}</code>
</div>
<div class="export-actions">
<button onclick="this.copyCommand()" class="copy-btn" title="Copy command to clipboard">
📋 Copy Command
</button>
<button onclick="this.copyJSON()" class="copy-btn" title="Copy JSON only">
📄 Copy JSON
</button>
<button onclick="this.close()" class="close-btn">
Close
</button>
</div>
</div>
<div class="export-details">
<h4>📋 Command Breakdown:</h4>
<ul>
<li><strong>Creates temporary file:</strong> <code>${tempFilePath}</code></li>
<li><strong>Changes to work directory:</strong> <code>${workDir}</code></li>
<li><strong>Runs export script:</strong> <code>python export_plots.py</code></li>
<li><strong>Output file:</strong> Will be saved in the work directory</li>
</ul>
</div>
<div class="export-tips">
<h4>💡 Tips:</h4>
<ul>
<li>The temporary JSON file will be automatically cleaned up after successful export</li>
<li>Use <kbd>Esc</kbd> to exit selection mode</li>
<li>Press <kbd>Ctrl+E</kbd> to toggle selection mode</li>
</ul>
</div>
</div>
`;
const overlay = document.createElement('div');
overlay.className = 'export-overlay';
overlay.innerHTML = instructions;
// Add methods to the overlay for button handlers
overlay.copyCommand = function() {
navigator.clipboard.writeText(fullCommand).then(() => {
this.showCopyFeedback('Command copied to clipboard!');
}).catch(() => {
this.showCopyFeedback('Failed to copy. Please select and copy manually.', 'error');
});
};
overlay.copyJSON = function() {
navigator.clipboard.writeText(requestData).then(() => {
this.showCopyFeedback('JSON copied to clipboard!');
}).catch(() => {
this.showCopyFeedback('Failed to copy. Please select and copy manually.', 'error');
});
};
overlay.close = function() {
this.remove();
};
overlay.showCopyFeedback = function(message, type = 'success') {
const feedback = document.createElement('div');
feedback.className = `copy-feedback copy-feedback-${type}`;
feedback.textContent = message;
this.appendChild(feedback);
setTimeout(() => {
if (feedback.parentNode) {
feedback.parentNode.removeChild(feedback);
}
}, 2000);
};
document.body.appendChild(overlay);
// Close on ESC key
const handleEscape = (e) => {
if (e.key === 'Escape') {
overlay.remove();
document.removeEventListener('keydown', handleEscape);
}
};
document.addEventListener('keydown', handleEscape);
// Close on clicking outside
overlay.addEventListener('click', (e) => {
if (e.target === overlay) {
overlay.remove();
document.removeEventListener('keydown', handleEscape);
}
});
}
/**
* Download the PDF blob
*/
downloadPDF(blob) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `merged_plots_${new Date().toISOString().split('T')[0]}.pdf`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
/**
* Show temporary message to user
*/
showMessage(text, type = 'info') {
// Remove existing message
const existing = document.querySelector('.export-message');
if (existing) existing.remove();
const message = document.createElement('div');
message.className = `export-message export-message-${type}`;
message.textContent = text;
document.body.appendChild(message);
setTimeout(() => {
if (message.parentNode) {
message.parentNode.removeChild(message);
}
}, 3000);
}
}
// Add these methods to ExportManager if not present
ExportManager.prototype.isSelectionModeActive = function() {
return document.body.classList.contains('selection-mode');
};
ExportManager.prototype.exitSelectionMode = function() {
document.body.classList.remove('selection-mode');
if (typeof this.clearSelection === 'function') {
this.clearSelection();
}
};
// Ensure a single global instance
window.exportManager = window.exportManager || new ExportManager();
// Listen for ESC key globally to exit selection mode
// (This will work even if focus is not on a plot)
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape' && window.exportManager && window.exportManager.isSelectionModeActive()) {
window.exportManager.exitSelectionMode();
}
});
// Attach improved export logic to export button
document.addEventListener('DOMContentLoaded', function() {
const exportBtn = document.getElementById('exportBtn');
if (exportBtn) {
exportBtn.addEventListener('click', function() {
window.exportManager.exportSelectedPlots();
});
}
});
+81
View File
@@ -0,0 +1,81 @@
/**
* Folder Metadata functionality for Gallery
*
* Handles folder metadata dropdown display and interaction
*/
// Define function immediately (not waiting for DOM)
window.toggleFolderMetadata = function() {
console.log('toggleFolderMetadata called');
const container = document.querySelector('.folder-metadata-container');
const content = document.getElementById('folderMetadataContent');
if (!container) {
console.log('No metadata container found');
return;
}
if (!content) {
console.log('No metadata content found');
return;
}
const isExpanded = container.classList.contains('expanded');
console.log('Current state - expanded:', isExpanded);
if (isExpanded) {
// Collapse
container.classList.remove('expanded');
content.style.display = 'none';
console.log('Collapsed dropdown');
} else {
// Expand
container.classList.add('expanded');
content.style.display = 'block';
console.log('Expanded dropdown');
}
// Save state
localStorage.setItem('folderMetadataExpanded', (!isExpanded).toString());
};
// Also define as regular function for alternative access
function toggleFolderMetadata() {
window.toggleFolderMetadata();
}
// Toggle long text display
window.toggleMetadataText = function(button) {
const longText = button.previousElementSibling;
const fullText = button.nextElementSibling;
if (fullText.style.display === 'none') {
longText.style.display = 'none';
fullText.style.display = 'inline';
button.textContent = 'Show less';
} else {
longText.style.display = 'inline';
fullText.style.display = 'none';
button.textContent = 'Show more';
}
};
// Initialize folder metadata on page load
document.addEventListener('DOMContentLoaded', function() {
console.log('Folder metadata script loaded');
// Make sure all containers start collapsed
const containers = document.querySelectorAll('.folder-metadata-container');
console.log('Found', containers.length, 'metadata containers');
containers.forEach(container => {
const content = container.querySelector('.folder-metadata-content');
if (content) {
// Force initial hidden state
container.classList.remove('expanded');
content.style.display = 'none';
console.log('Initialized container as collapsed');
}
});
});
+73
View File
@@ -0,0 +1,73 @@
/**
* Main Gallery Application
* Orchestrates all the different managers and functionality
*/
import { ThemeManager } from './theme-manager.js';
import { NavigationManager } from './navigation-manager.js';
import { SearchManager } from './search-manager.js';
import { RecentPlotsManager } from './recent-plots-manager.js';
import { ComparisonManager } from './comparison-manager.js';
import { StatsManager } from './stats-manager.js';
import { KeyboardManager } from './keyboard-manager.js';
import { ViewManager } from './view-manager.js';
import { SortManager } from './sort-manager.js';
import { Utils } from './utils.js';
/**
* Main Gallery Application Class
*/
export class GalleryApp {
constructor(config = {}) {
// Configuration from backend template variables
this.SEARCH_DEBOUNCE_MS = config.searchDebounceMs || 300;
this.MAX_RECENT_PLOTS = config.maxRecentPlots || 20;
this.stats = config.stats || null;
// Initialize managers
this.themeManager = new ThemeManager();
this.navigationManager = new NavigationManager();
this.searchManager = new SearchManager(this.SEARCH_DEBOUNCE_MS);
this.recentPlotsManager = new RecentPlotsManager(this.MAX_RECENT_PLOTS);
this.comparisonManager = new ComparisonManager();
this.statsManager = new StatsManager();
this.viewManager = new ViewManager();
this.sortManager = new SortManager();
this.keyboardManager = new KeyboardManager(this);
this.utils = Utils;
// Set global references for backward compatibility
window.themeManager = this.themeManager;
window.searchManager = this.searchManager;
window.recentPlotsManager = this.recentPlotsManager;
window.comparisonManager = this.comparisonManager;
window.viewManager = this.viewManager;
window.sortManager = this.sortManager;
window.utils = this.utils;
this.init();
}
/**
* Initialize the gallery application
*/
init() {
this.navigationManager.buildBreadcrumb();
this.navigationManager.buildFolderTree();
// Update stats with backend data if available
if (this.stats) {
this.statsManager.updateWithBackendStats(this.stats);
}
// Handle URL-based thumbnail highlighting
Utils.handleThumbnailHighlight();
}
// Backward compatibility methods
toggleTheme() { this.themeManager.toggle(); }
toggleSidebar() { this.recentPlotsManager.toggleSidebar(); }
toggleCompareMode() { this.comparisonManager.toggleCompareMode(); }
closeComparison() { this.comparisonManager.closeComparison(); }
selectPlotForComparison(slot) { this.comparisonManager.selectPlotForComparison(slot); }
replacePlot(slot) { this.comparisonManager.replacePlot(slot); }
}
+130
View File
@@ -0,0 +1,130 @@
/**
* Keyboard shortcuts management
*/
export class KeyboardManager {
constructor(galleryApp) {
this.app = galleryApp;
this.init();
}
/**
* Initialize keyboard shortcuts
*/
init() {
document.addEventListener('keydown', (e) => {
if (e.ctrlKey && e.key === 'k') {
e.preventDefault();
const searchBox = document.getElementById('searchBox');
if (searchBox) searchBox.focus();
}
if (e.ctrlKey && e.key === 'r') {
e.preventDefault();
if (this.app.recentPlotsManager) {
this.app.recentPlotsManager.toggleSidebar();
}
}
if (e.ctrlKey && e.key === 'c') {
e.preventDefault();
if (this.app.comparisonManager) {
this.app.comparisonManager.toggleCompareMode();
}
}
if (e.ctrlKey && e.key === 't') {
e.preventDefault();
if (this.app.themeManager) {
this.app.themeManager.toggle();
}
}
if (e.ctrlKey && e.key === 'v') {
e.preventDefault();
if (this.app.viewManager) {
this.app.viewManager.cycleView();
}
}
if (e.ctrlKey && e.key === 'n') {
e.preventDefault();
if (this.app.sortManager) {
this.app.sortManager.setSortType('name');
}
}
if (e.ctrlKey && e.key === 'm') {
e.preventDefault();
if (this.app.sortManager) {
this.app.sortManager.setSortType('time');
}
}
if (e.ctrlKey && e.key === 'o') {
e.preventDefault();
if (this.app.sortManager) {
this.app.sortManager.toggleSortOrder();
}
}
if (e.key === '?' && !e.ctrlKey && !e.altKey && !e.metaKey) {
e.preventDefault();
if (this.app.utils && this.app.utils.toggleShortcutsHelp) {
this.app.utils.toggleShortcutsHelp();
}
}
if (e.key === 'Escape') {
this.handleEscape();
}
});
}
/**
* Handle escape key actions
*/
handleEscape() {
// If in plot selection mode, cancel it
if (this.app.comparisonManager && this.app.comparisonManager.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
const comparisonOverlay = document.getElementById('comparisonOverlay');
if (comparisonOverlay) comparisonOverlay.classList.add('open');
this.app.comparisonManager.comparisonSlot = null;
return;
}
// Close comparison overlay if open
const comparisonOverlay = document.getElementById('comparisonOverlay');
if (comparisonOverlay && comparisonOverlay.classList.contains('open')) {
if (this.app.comparisonManager) {
this.app.comparisonManager.hideComparisonOverlay();
}
return;
}
// Other ESC behaviors
const searchResults = document.getElementById('searchResults');
if (searchResults) searchResults.style.display = 'none';
const sidebar = document.getElementById('sidebar');
if (sidebar && sidebar.classList.contains('open')) {
if (this.app.recentPlotsManager) {
this.app.recentPlotsManager.toggleSidebar();
}
}
const shortcutsHelp = document.getElementById('shortcutsHelp');
if (shortcutsHelp) shortcutsHelp.style.display = 'none';
}
}
+27
View File
@@ -0,0 +1,27 @@
/**
* Main entry point for the Gallery application
* This file initializes the app when the DOM is ready
*/
import { GalleryApp } from './gallery-app.js';
// Global app instance for backward compatibility
let app;
// Global functions for onclick handlers (backward compatibility)
function toggleTheme() { app.toggleTheme(); }
function toggleSidebar() { app.toggleSidebar(); }
// Make functions globally available
window.toggleTheme = toggleTheme;
window.toggleSidebar = toggleSidebar;
// Initialize application when DOM is ready
document.addEventListener('DOMContentLoaded', function() {
// Configuration will be injected by the template
const config = window.galleryConfig || {};
app = new GalleryApp(config);
// Make app globally available
window.app = app;
});
+212
View File
@@ -0,0 +1,212 @@
/**
* Metadata Popup functionality for Gallery
*
* Handles showing metadata in small popups overlaid on plot thumbnails
*/
class MetadataPopup {
constructor() {
this.activePopup = null;
this.init();
}
init() {
// Close popup when clicking outside
document.addEventListener('click', (e) => {
if (!e.target.closest('.metadata-btn') && !e.target.closest('.metadata-popup')) {
this.hidePopup();
}
});
// Close popup on Escape key
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
this.hidePopup();
}
});
}
showPopup(button, plotName, metadata) {
// Hide any existing popup
this.hidePopup();
// Create popup element
const popup = document.createElement('div');
popup.className = 'metadata-popup';
popup.innerHTML = this.formatMetadata(plotName, metadata);
// Position popup relative to button
const rect = button.getBoundingClientRect();
popup.style.position = 'fixed';
popup.style.left = rect.left + 'px';
popup.style.top = (rect.bottom + 5) + 'px';
popup.style.zIndex = '1000';
// Add to DOM
document.body.appendChild(popup);
this.activePopup = popup;
// Adjust position if popup goes off screen
setTimeout(() => {
const popupRect = popup.getBoundingClientRect();
// Adjust horizontal position
if (popupRect.right > window.innerWidth) {
popup.style.left = (rect.right - popupRect.width) + 'px';
}
// Adjust vertical position
if (popupRect.bottom > window.innerHeight) {
popup.style.top = (rect.top - popupRect.height - 5) + 'px';
}
}, 0);
// Animate in
requestAnimationFrame(() => {
popup.classList.add('show');
});
}
hidePopup() {
if (this.activePopup) {
this.activePopup.classList.remove('show');
setTimeout(() => {
if (this.activePopup && this.activePopup.parentNode) {
this.activePopup.parentNode.removeChild(this.activePopup);
}
this.activePopup = null;
}, 200);
}
}
formatMetadata(plotName, metadata) {
if (!metadata || Object.keys(metadata).length === 0) {
return `
<div class="metadata-popup-header">
<h4>${plotName}</h4>
</div>
<div class="metadata-popup-content">
<p class="no-metadata">No metadata available</p>
</div>
`;
}
let html = `
<div class="metadata-popup-header">
<h4>${plotName}</h4>
</div>
<div class="metadata-popup-content">
`;
// Show priority fields first
const priorityFields = ['title', 'description', 'plot_type', 'experiment'];
const processedKeys = new Set();
// Display priority fields first
for (const key of priorityFields) {
if (metadata[key] !== undefined) {
html += this.formatMetadataField(key, metadata[key]);
processedKeys.add(key);
}
}
// Show file info if available
if (metadata.file_info) {
html += `<div class="metadata-section-title">File Information</div>`;
html += this.formatMetadataField('File Size', metadata.file_info.size);
if (metadata.file_info.extension) {
html += this.formatMetadataField('Format', metadata.file_info.extension);
}
processedKeys.add('file_info');
}
// Show timestamps if available
if (metadata.timestamps) {
html += `<div class="metadata-section-title">Timestamps</div>`;
if (metadata.timestamps.created_human) {
html += this.formatMetadataField('Created', metadata.timestamps.created_human);
}
if (metadata.timestamps.modified_human) {
html += this.formatMetadataField('Modified', metadata.timestamps.modified_human);
}
processedKeys.add('timestamps');
}
// Show extracted info if available
if (metadata.extracted_info && Object.keys(metadata.extracted_info).length > 0) {
html += `<div class="metadata-section-title">Plot Details</div>`;
for (const [key, value] of Object.entries(metadata.extracted_info)) {
html += this.formatMetadataField(key, value);
}
processedKeys.add('extracted_info');
}
// Display other fields (excluding generation info unless it's the only data)
const otherKeys = Object.keys(metadata).filter(key =>
!processedKeys.has(key) && key !== 'generation'
);
if (otherKeys.length > 0) {
html += `<div class="metadata-section-title">Additional Information</div>`;
for (const key of otherKeys) {
html += this.formatMetadataField(key, metadata[key]);
}
}
// Show generation info last if there's no other meaningful data
if (processedKeys.size <= 2 && metadata.generation) {
html += `<div class="metadata-section-title">Generation Info</div>`;
if (metadata.generation.generation_time) {
const genDate = new Date(metadata.generation.generation_time);
html += this.formatMetadataField('Generated', genDate.toLocaleString());
}
}
html += '</div>';
return html;
}
formatMetadataField(key, value) {
const displayKey = key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
let formattedValue;
if (value === null || value === undefined) {
formattedValue = '<em>null</em>';
} else if (typeof value === 'object') {
if (Array.isArray(value)) {
if (value.length <= 3) {
formattedValue = value.map(item => `<span class="metadata-tag">${item}</span>`).join(' ');
} else {
formattedValue = `${value.slice(0, 3).map(item => `<span class="metadata-tag">${item}</span>`).join(' ')} <span class="metadata-more">+${value.length - 3} more</span>`;
}
} else {
// Show object as compact JSON for small objects, or just key count for large ones
const keys = Object.keys(value);
if (keys.length <= 3) {
formattedValue = '<code>' + JSON.stringify(value) + '</code>';
} else {
formattedValue = `<em>Object with ${keys.length} properties</em>`;
}
}
} else {
// Truncate long strings
const str = String(value);
formattedValue = str.length > 50 ? str.substring(0, 47) + '...' : str;
}
return `
<div class="metadata-field">
<span class="metadata-key">${displayKey}:</span>
<span class="metadata-value">${formattedValue}</span>
</div>
`;
}
}
// Global instance
window.metadataPopup = new MetadataPopup();
// Global function for template usage
window.showMetadataPopup = function(button, plotName, metadata) {
window.metadataPopup.showPopup(button, plotName, metadata);
};
+142
View File
@@ -0,0 +1,142 @@
/**
* Simple Metadata Section Toggle
*
* Handles showing/hiding the metadata grid with a simple button
*/
// Global function to toggle metadata section visibility
function toggleMetadataSection() {
console.log('toggleMetadataSection called');
const content = document.getElementById('metadataContent');
const arrow = document.getElementById('metadataArrow');
if (!content) {
console.log('No metadata content found');
return;
}
const isVisible = content.style.display !== 'none';
if (isVisible) {
// Hide the content
content.style.display = 'none';
if (arrow) arrow.textContent = '▼';
console.log('Metadata hidden');
} else {
// Show the content
content.style.display = 'block';
if (arrow) arrow.textContent = '▲';
console.log('Metadata shown');
// Trigger MathJax rendering for LaTeX content
if (typeof MathJax !== 'undefined') {
MathJax.typesetPromise([content]).catch(function (err) {
console.log('MathJax typeset failed: ' + err.message);
});
}
}
// Save state to localStorage
localStorage.setItem('metadataVisible', (!isVisible).toString());
}
// Function to expand long text
function expandText(button) {
const longText = button.previousElementSibling;
const fullText = button.nextElementSibling;
if (fullText.style.display === 'none') {
longText.style.display = 'none';
fullText.style.display = 'inline';
button.textContent = 'Show less';
} else {
longText.style.display = 'inline';
fullText.style.display = 'none';
button.textContent = 'Show more';
}
}
// Copy metadata file path to clipboard
async function copyMetadataPath() {
const pathElement = document.getElementById('metadata-file-path');
const copyBtn = document.querySelector('.copy-path-btn');
if (!pathElement || !copyBtn) {
console.log('Path element or copy button not found');
return;
}
const path = pathElement.textContent;
console.log('Attempting to copy path:', path);
try {
// Try using the modern clipboard API
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(path);
} else {
// Fallback for older browsers
const textArea = document.createElement('textarea');
textArea.value = path;
textArea.style.position = 'fixed';
textArea.style.left = '-999999px';
textArea.style.top = '-999999px';
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
document.execCommand('copy');
textArea.remove();
}
// Visual feedback
const originalText = copyBtn.innerHTML;
copyBtn.innerHTML = '✅ Copied!';
copyBtn.classList.add('copied');
setTimeout(() => {
copyBtn.innerHTML = originalText;
copyBtn.classList.remove('copied');
}, 2000);
console.log('Path copied successfully');
} catch (err) {
console.error('Failed to copy path: ', err);
// Show error feedback
const originalText = copyBtn.innerHTML;
copyBtn.innerHTML = '❌ Failed';
setTimeout(() => {
copyBtn.innerHTML = originalText;
}, 2000);
}
}
// Make functions globally available
window.toggleMetadataSection = toggleMetadataSection;
window.expandText = expandText;
window.copyMetadataPath = copyMetadataPath;
// Initialize on page load
document.addEventListener('DOMContentLoaded', function() {
console.log('Metadata section script loaded');
const content = document.getElementById('metadataContent');
if (content) {
// Check if user previously had it expanded
const wasVisible = localStorage.getItem('metadataVisible') === 'true';
if (wasVisible) {
content.style.display = 'block';
const arrow = document.getElementById('metadataArrow');
if (arrow) arrow.textContent = '▲';
} else {
content.style.display = 'none';
const arrow = document.getElementById('metadataArrow');
if (arrow) arrow.textContent = '▼';
}
console.log('Metadata section initialized, visible:', wasVisible);
}
});
+208
View File
@@ -0,0 +1,208 @@
/**
* Navigation functionality - breadcrumbs and folder tree
*/
export class NavigationManager {
/**
* 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 (!breadcrumb) return;
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;
if (!treeContainer) return;
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) {
const subdir = subdirs[0];
const href = subdir.getAttribute('href');
if (href) {
const baseUrl = path.replace(/\/[^\/]*$/, '/');
const subPath = baseUrl + href;
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 (items.length > 0 || subdirs.length !== 1) {
break;
}
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 ? '' : '└─ ';
const displayName = collapsedPath.segments.map(seg => seg.name).join(' / ');
const finalPath = collapsedPath.finalPath;
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>`;
}
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;
}
}
+114
View File
@@ -0,0 +1,114 @@
/**
* Recent plots sidebar management
*/
export class RecentPlotsManager {
constructor(maxRecentPlots = 20) {
this.MAX_RECENT_PLOTS = maxRecentPlots;
this.init();
}
init() {
this.updateRecentPlotsDisplay();
this.trackPlotClicks();
}
/**
* 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');
if (!sidebarContent) return;
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="window.recentPlotsManager.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');
if (sidebar) sidebar.classList.toggle('open');
if (overlay) 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);
}
});
}
}
+217
View File
@@ -0,0 +1,217 @@
/**
* Search functionality
*/
export class SearchManager {
constructor(debounceMs = 300) {
this.searchTimeout = null;
this.SEARCH_DEBOUNCE_MS = debounceMs;
this.init();
}
/**
* Initialize search functionality with debouncing
*/
init() {
const searchBox = document.getElementById('searchBox');
const searchResults = document.getElementById('searchResults');
if (!searchBox || !searchResults) return;
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');
if (!searchResults) return;
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 (!searchResults) return;
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="window.searchManager.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) {
if (window.recentPlotsManager) {
window.recentPlotsManager.addToRecentPlots(href);
}
window.open(href, '_blank');
document.getElementById('searchResults').style.display = 'none';
}
}
+197
View File
@@ -0,0 +1,197 @@
/**
* Sort Manager - handles sorting of plot items by name and creation time
*/
export class SortManager {
constructor() {
this.currentSort = 'name';
this.currentOrder = 'asc';
this.init();
}
/**
* Initialize sort controls
*/
init() {
// Use setTimeout to ensure DOM is ready
setTimeout(() => {
this.setupSortButtons();
this.loadSavedSort();
}, 100);
}
/**
* Setup sort button event listeners
*/
setupSortButtons() {
const sortButtons = document.querySelectorAll('.sort-btn');
const orderButton = document.querySelector('.sort-order-btn');
if (sortButtons.length === 0) {
setTimeout(() => this.setupSortButtons(), 500);
return;
}
sortButtons.forEach((button) => {
const sortType = button.getAttribute('data-sort');
button.addEventListener('click', (e) => {
e.preventDefault();
this.setSortType(sortType);
});
});
if (orderButton) {
orderButton.addEventListener('click', (e) => {
e.preventDefault();
this.toggleSortOrder();
});
}
// Initialize button states
this.updateSortButtons();
this.updateOrderButton();
}
/**
* Set the sort type (name or time)
*/
setSortType(sortType) {
if (sortType === this.currentSort) return;
this.currentSort = sortType;
this.updateSortButtons();
this.sortPlots();
this.saveSortPreference();
}
/**
* Toggle sort order between ascending and descending
*/
toggleSortOrder() {
this.currentOrder = this.currentOrder === 'asc' ? 'desc' : 'asc';
this.updateOrderButton();
this.sortPlots();
this.saveSortPreference();
}
/**
* Update visual state of sort buttons
*/
updateSortButtons() {
const sortButtons = document.querySelectorAll('.sort-btn');
sortButtons.forEach(btn => {
if (btn.getAttribute('data-sort') === this.currentSort) {
btn.classList.add('active');
} else {
btn.classList.remove('active');
}
});
}
/**
* Update visual state of order button
*/
updateOrderButton() {
const orderButton = document.querySelector('.sort-order-btn');
if (orderButton) {
orderButton.textContent = this.currentOrder === 'asc' ? '↑' : '↓';
orderButton.setAttribute('data-order', this.currentOrder);
orderButton.title = `Sort Order: ${this.currentOrder === 'asc' ? 'Ascending' : 'Descending'}`;
}
}
/**
* Sort the plot items
*/
sortPlots() {
const plotContainer = document.getElementById('plotContainer');
if (!plotContainer) return;
const plotItems = Array.from(plotContainer.children);
plotItems.sort((a, b) => {
let valueA, valueB;
if (this.currentSort === 'name') {
valueA = a.getAttribute('data-name') || '';
valueB = b.getAttribute('data-name') || '';
// Natural sort for better number handling
const result = valueA.localeCompare(valueB, undefined, {
numeric: true,
sensitivity: 'base'
});
return this.currentOrder === 'asc' ? result : -result;
} else if (this.currentSort === 'time') {
valueA = parseInt(a.getAttribute('data-time') || '0');
valueB = parseInt(b.getAttribute('data-time') || '0');
const result = valueA - valueB;
return this.currentOrder === 'asc' ? result : -result;
}
return 0;
});
// Re-append sorted items
plotItems.forEach(item => {
plotContainer.appendChild(item);
});
}
/**
* Save sort preferences to localStorage
*/
saveSortPreference() {
try {
localStorage.setItem('gallery-sort-type', this.currentSort);
localStorage.setItem('gallery-sort-order', this.currentOrder);
} catch (e) {
// Ignore localStorage errors
}
}
/**
* Load saved sort preferences
*/
loadSavedSort() {
try {
const savedSort = localStorage.getItem('gallery-sort-type');
const savedOrder = localStorage.getItem('gallery-sort-order');
if (savedSort && ['name', 'time'].includes(savedSort)) {
this.currentSort = savedSort;
}
if (savedOrder && ['asc', 'desc'].includes(savedOrder)) {
this.currentOrder = savedOrder;
}
this.updateSortButtons();
this.updateOrderButton();
// Sort immediately if there are plots
setTimeout(() => this.sortPlots(), 100);
} catch (e) {
// Ignore localStorage errors, use defaults
}
}
/**
* Get current sort settings
*/
getCurrentSort() {
return {
type: this.currentSort,
order: this.currentOrder
};
}
/**
* Refresh sorting (call this when plot content changes)
*/
refresh() {
this.sortPlots();
}
}
+54
View File
@@ -0,0 +1,54 @@
/**
* Statistics manager for gallery display
*/
export class StatsManager {
constructor() {
this.updateGalleryStats();
}
/**
* Update gallery statistics display
*/
updateGalleryStats() {
// Use stats passed from Python backend if available
// Otherwise fallback to DOM counting
const gridItems = document.querySelectorAll('.grid-item');
const subdirLinks = document.querySelectorAll('a[href$="/index.html"]');
const fileCountEl = document.getElementById('fileCount');
const folderCountEl = document.getElementById('folderCount');
const totalSizeEl = document.getElementById('totalSize');
const lastUpdatedEl = document.getElementById('lastUpdated');
if (fileCountEl) fileCountEl.textContent = gridItems.length;
if (folderCountEl) folderCountEl.textContent = subdirLinks.length;
if (totalSizeEl) totalSizeEl.textContent = 'Unknown';
// Set last updated time
const now = new Date();
const timeStr = now.toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit'
});
if (lastUpdatedEl) lastUpdatedEl.textContent = timeStr;
}
/**
* Update stats with backend data
*/
updateWithBackendStats(stats) {
const fileCountEl = document.getElementById('fileCount');
const folderCountEl = document.getElementById('folderCount');
const totalSizeEl = document.getElementById('totalSize');
if (fileCountEl && stats.file_count !== undefined) {
fileCountEl.textContent = stats.file_count;
}
if (folderCountEl && stats.folder_count !== undefined) {
folderCountEl.textContent = stats.folder_count;
}
if (totalSizeEl && stats.total_size !== undefined) {
totalSizeEl.textContent = stats.total_size;
}
}
}
+43
View File
@@ -0,0 +1,43 @@
/**
* Theme management functionality
*/
export class ThemeManager {
constructor() {
this.init();
}
/**
* Initialize theme system and load saved preference
*/
init() {
const savedTheme = localStorage.getItem('theme');
const html = document.documentElement;
const themeToggle = document.getElementById('themeToggle');
if (savedTheme === 'light') {
html.setAttribute('data-theme', 'light');
if (themeToggle) themeToggle.textContent = '🌙';
} else {
html.removeAttribute('data-theme');
if (themeToggle) themeToggle.textContent = '☀️';
}
}
/**
* Toggle between light and dark themes
*/
toggle() {
const html = document.documentElement;
const themeToggle = document.getElementById('themeToggle');
if (html.getAttribute('data-theme') === 'light') {
html.removeAttribute('data-theme');
if (themeToggle) themeToggle.textContent = '☀️';
localStorage.setItem('theme', 'dark');
} else {
html.setAttribute('data-theme', 'light');
if (themeToggle) themeToggle.textContent = '🌙';
localStorage.setItem('theme', 'light');
}
}
}
+107
View File
@@ -0,0 +1,107 @@
/**
* Utility functions and helpers
*/
export class Utils {
/**
* Format file size in human readable format
*/
static 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];
}
/**
* Handle thumbnail highlighting from URL parameters
*/
static 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());
}
}
/**
* Calculate approximate total size of displayed files
*/
static async calculateApproximateSize() {
const images = document.querySelectorAll('.grid-item img');
let totalSize = 0;
let loadedCount = 0;
const sizeElement = document.getElementById('totalSize');
if (!sizeElement) return;
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 = Utils.formatFileSize(estimatedTotal);
} else {
sizeElement.textContent = 'Unknown';
}
}
/**
* Toggle keyboard shortcuts help display
*/
static toggleShortcutsHelp() {
const help = document.getElementById('shortcutsHelp');
if (help) {
help.style.display = help.style.display === 'block' ? 'none' : 'block';
}
}
}
+192
View File
@@ -0,0 +1,192 @@
/**
* View Controls Manager - handles switching between grid, list-large, and list-compact views
*/
export class ViewManager {
constructor() {
this.currentView = 'grid';
this.init();
}
/**
* Initialize view controls
*/
init() {
this.setupViewButtons();
this.loadSavedView();
this.updateControlsVisibility();
}
/**
* Update visibility of view controls based on plot content
*/
updateControlsVisibility() {
const plotContainer = document.getElementById('plotContainer');
const controlsContainer = document.querySelector('.controls-container');
if (!plotContainer || !controlsContainer) return;
const hasPlots = plotContainer.children.length > 0;
controlsContainer.style.display = hasPlots ? 'flex' : 'none';
}
/**
* Setup view toggle buttons
*/
setupViewButtons() {
const viewButtons = document.querySelectorAll('.view-btn');
viewButtons.forEach(button => {
button.addEventListener('click', (e) => {
const newView = button.getAttribute('data-view');
this.switchView(newView);
});
});
}
/**
* Switch to a different view mode
*/
switchView(viewMode) {
if (viewMode === this.currentView) return;
const plotContainer = document.getElementById('plotContainer');
const viewButtons = document.querySelectorAll('.view-btn');
if (!plotContainer) return;
// Remove current view class
plotContainer.classList.remove(
'grid-view',
'list-large-view',
'list-compact-view'
);
// Add new view class
switch (viewMode) {
case 'grid':
plotContainer.classList.add('grid-view');
break;
case 'list-large':
plotContainer.classList.add('list-large-view');
break;
case 'list-compact':
plotContainer.classList.add('list-compact-view');
break;
default:
plotContainer.classList.add('grid-view');
viewMode = 'grid';
}
// Update button states
viewButtons.forEach(btn => {
if (btn.getAttribute('data-view') === viewMode) {
btn.classList.add('active');
} else {
btn.classList.remove('active');
}
});
// Save the preference
this.currentView = viewMode;
this.saveViewPreference(viewMode);
// Trigger any necessary layout updates
this.onViewChanged(viewMode);
}
/**
* Save view preference to localStorage
*/
saveViewPreference(viewMode) {
try {
localStorage.setItem('gallery-view-mode', viewMode);
} catch (e) {
// Ignore localStorage errors
}
}
/**
* Load saved view preference
*/
loadSavedView() {
try {
const savedView = localStorage.getItem('gallery-view-mode');
if (savedView && ['grid', 'list-large', 'list-compact'].includes(savedView)) {
this.switchView(savedView);
}
} catch (e) {
// Ignore localStorage errors, use default
}
}
/**
* Handle view change events - can be extended for additional functionality
*/
onViewChanged(viewMode) {
// Dispatch custom event for other components that might need to know
const event = new CustomEvent('viewChanged', {
detail: { viewMode }
});
document.dispatchEvent(event);
// Update any other UI elements that depend on view mode
this.updateUIForView(viewMode);
}
/**
* Update UI elements based on current view
*/
updateUIForView(viewMode) {
// You can add view-specific UI updates here
// For example, adjusting search result highlighting, etc.
// Update any tooltips or help text
const viewButtons = document.querySelectorAll('.view-btn');
viewButtons.forEach(btn => {
const btnView = btn.getAttribute('data-view');
if (btnView === viewMode) {
btn.style.transform = 'scale(1.05)';
} else {
btn.style.transform = 'scale(1)';
}
});
}
/**
* Get current view mode
*/
getCurrentView() {
return this.currentView;
}
/**
* Refresh controls visibility (call this when gallery content changes)
*/
refresh() {
this.updateControlsVisibility();
}
/**
* Check if current view is grid mode
*/
isGridView() {
return this.currentView === 'grid';
}
/**
* Check if current view is list mode (either variant)
*/
isListView() {
return this.currentView === 'list-large' || this.currentView === 'list-compact';
}
/**
* Cycle through view modes (useful for keyboard shortcuts)
*/
cycleView() {
const views = ['grid', 'list-large', 'list-compact'];
const currentIndex = views.indexOf(this.currentView);
const nextIndex = (currentIndex + 1) % views.length;
this.switchView(views[nextIndex]);
}
}
+185
View File
@@ -0,0 +1,185 @@
"""Gallery building and rendering logic."""
import shutil
from pathlib import Path
from typing import Dict, Any, Optional
from jinja2 import Environment, FileSystemLoader
from gallery.config import GalleryConfig
from gallery.utils.metadata import (
load_folder_metadata,
merge_metadata,
save_metadata_cache,
)
from gallery.utils.processing import (
process_plot_files,
needs_update,
render_gallery_page,
)
def get_template(template_dir: Optional[Path] = None):
"""
Get the Jinja2 template for gallery rendering.
Args:
template_dir: Path to template directory. If None,
uses package default.
Returns:
Jinja2 Template object
"""
if template_dir is None:
# Use package-included template
import gallery
gallery_module_path = Path(gallery.__file__).parent
template_dir = gallery_module_path / "templates"
env = Environment(loader=FileSystemLoader(str(template_dir)))
def datetime_from_timestamp(timestamp: float):
"""Convert a Unix timestamp to a datetime object."""
from datetime import datetime
return datetime.fromtimestamp(timestamp)
def strftime_filter(dt, fmt: str) -> str:
"""Format a datetime object using strftime."""
return dt.strftime(fmt)
env.filters['datetime_from_timestamp'] = datetime_from_timestamp
env.filters['strftime'] = strftime_filter
return env.get_template("gallery.html")
def build_gallery(
config: GalleryConfig,
template,
source_dir: Path,
web_dir: Path,
relative_path: Path = None,
inherited_metadata: Optional[Dict[str, Any]] = None,
) -> None:
"""
Recursively build gallery structure from source directory.
Processes all PDF files in the source directory, converts them to PNG,
copies both to the web directory, and generates index.html files with
navigation and thumbnails. Includes metadata support.
Args:
config: Gallery configuration object
template: Jinja2 template for rendering
source_dir: Source directory containing PDF files
web_dir: Target web directory for gallery output
relative_path: Relative path from gallery root (for navigation)
inherited_metadata: Metadata inherited from parent directories
"""
if relative_path is None:
relative_path = Path(".")
if inherited_metadata is None:
inherited_metadata = {}
folder_metadata = load_folder_metadata(source_dir)
current_metadata = merge_metadata(inherited_metadata, folder_metadata)
# Find all plot files (both PDF and HTML)
pdf_files = list(source_dir.glob("*.pdf"))
html_files = list(source_dir.glob("*.html"))
plot_files = pdf_files + html_files
items = []
plot_metadata_cache = {}
# Process all plot files (PDFs and HTMLs)
for plot_file in plot_files:
item = process_plot_files(
config=config,
plot_file=plot_file,
web_dir=web_dir,
current_metadata=current_metadata,
)
items.append(item)
plot_metadata_cache[plot_file.stem] = item["metadata"]
if config.cache_enabled:
save_metadata_cache(web_dir, plot_metadata_cache)
# Process subdirectories
subdirs = [d for d in source_dir.iterdir() if d.is_dir()]
subdir_names = []
for subdir in subdirs:
subdir_web = web_dir / subdir.name
subdir_web.mkdir(exist_ok=True)
subdir_relative = relative_path / subdir.name
build_gallery(
config,
template,
subdir,
subdir_web,
subdir_relative,
current_metadata if config.inherit_from_parent else {}
)
subdir_names.append(subdir.name)
render_gallery_page(
config=config,
template=template,
web_dir=web_dir,
items=items,
subdirs=subdir_names,
relative_path=relative_path,
metadata=current_metadata
)
def copy_assets(
config: GalleryConfig,
assets_src: Optional[Path] = None,
verbose: bool = False
) -> bool:
"""
Copy assets to the web directory.
Args:
config: Gallery configuration object
assets_src: Path to assets source. If None, uses package default.
verbose: Whether to print status messages
Returns:
True if successful, False otherwise
"""
try:
if assets_src is None:
# Use package-included assets
import gallery
gallery_module_path = Path(gallery.__file__).parent
assets_src = gallery_module_path / "assets"
if not assets_src.exists():
if verbose:
print(
f"Warning: Assets directory {assets_src} not found"
)
return False
gallery_root = Path(config.web_folder) / config.plot_root
assets_dst = gallery_root.parent / "assets"
main_css_src = assets_src / "css" / "main.css"
main_css_dst = assets_dst / "css" / "main.css"
if (not assets_dst.exists() or
needs_update(main_css_src, main_css_dst)):
if assets_dst.exists():
shutil.rmtree(assets_dst)
shutil.copytree(assets_src, assets_dst)
if verbose:
print(f"Updated assets from {assets_src} to {assets_dst}")
return True
except Exception as e:
if verbose:
print(f"Warning: Could not copy assets: {e}")
return False
+117
View File
@@ -0,0 +1,117 @@
"""
Command-line interface for gallery generation.
Provides a CLI entry point for gallery generation when using git clone setup.
"""
import argparse
import sys
from pathlib import Path
from gallery import generate
from gallery.config import GalleryConfig
def main():
"""
Main CLI entry point for gallery generation.
Supports:
- Loading config from YAML file (default: config.yaml)
- Clean gallery directory before generation
- Override to process only a specific source directory
"""
parser = argparse.ArgumentParser(
description='Generate scientific gallery from plot collections',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
gallery # Generate from config.yaml
gallery --config myconfig.yaml # Use custom config file
gallery --clean # Clean and regenerate
gallery --source /path/to/plots # Generate only specific source
"""
)
parser.add_argument(
'--config',
type=str,
default='config.yaml',
help='Path to config.yaml file (default: config.yaml)'
)
parser.add_argument(
'--clean',
action='store_true',
help='Clean gallery directory before generation'
)
parser.add_argument(
'--source',
type=str,
default=None,
help='Override to only recompute a specific source directory. '
'If the directory is not in config, it will be added '
'temporarily.'
)
parser.add_argument(
'-v', '--verbose',
action='store_true',
help='Print verbose output'
)
args = parser.parse_args()
try:
# Load config from file
config = GalleryConfig.from_yaml(args.config)
# Handle source override
if args.source:
source_path = Path(args.source).resolve()
# Check if source is in config
matching_source = None
for source in config.sources:
if Path(source.path).resolve() == source_path:
matching_source = source
break
# If not in config, create a temporary source entry
if matching_source is None:
from gallery.config import GallerySource
source_name = source_path.name
config.sources = [
GallerySource(name=source_name, path=source_path)
]
if args.verbose:
print(
f"Source {args.source} not in config. "
f"Adding temporarily as '{source_name}'"
)
else:
config.sources = [matching_source]
# Generate gallery
success = generate(
config=config,
clean_first=args.clean,
verbose=args.verbose
)
sys.exit(0 if success else 1)
except FileNotFoundError as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
except ValueError as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
if __name__ == '__main__':
main()
+164
View File
@@ -0,0 +1,164 @@
"""
Configuration Management for Scientific Gallery Generator
This module provides dataclasses for managing configuration, including
defaults for gallery generation settings.
"""
from dataclasses import dataclass, field
from pathlib import Path
from typing import List, Dict, Any, Union
import yaml
@dataclass
class GalleryDefaults:
"""Default values for gallery generation."""
png_dpi: int = 400
plot_root: str = "gallery"
cache_enabled: bool = True
inherit_from_parent: bool = True
@dataclass
class GallerySource:
"""Represents a single data source for the gallery."""
name: str
path: Union[str, Path]
def __post_init__(self):
"""Convert path to Path object if needed."""
if isinstance(self.path, str):
self.path = Path(self.path)
@dataclass
class GalleryConfig:
"""
Main configuration for gallery generation.
Can be created programmatically or loaded from YAML.
"""
web_folder: Union[str, Path]
sources: List[Union[GallerySource, Dict[str, Any]]] = field(
default_factory=list
)
png_dpi: int = GalleryDefaults.png_dpi
plot_root: str = GalleryDefaults.plot_root
cache_enabled: bool = GalleryDefaults.cache_enabled
inherit_from_parent: bool = GalleryDefaults.inherit_from_parent
backup_folder: str = ""
def __post_init__(self):
"""Normalize and validate configuration."""
# Convert web_folder to Path
if isinstance(self.web_folder, str):
self.web_folder = Path(self.web_folder)
# Convert sources to GallerySource objects if they're dicts
normalized_sources = []
for source in self.sources:
if isinstance(source, dict):
source = GallerySource(**source)
elif not isinstance(source, GallerySource):
raise TypeError(
f"Source must be dict or GallerySource, "
f"got {type(source)}"
)
normalized_sources.append(source)
self.sources = normalized_sources
@classmethod
def from_yaml(cls, yaml_file: Union[str, Path]) -> "GalleryConfig":
"""
Load configuration from a YAML file.
Args:
yaml_file: Path to the YAML configuration file
Returns:
GalleryConfig instance with loaded settings
Raises:
FileNotFoundError: If the YAML file doesn't exist
yaml.YAMLError: If the YAML file is malformed
"""
yaml_path = Path(yaml_file)
if not yaml_path.exists():
raise FileNotFoundError(f"Config file not found: {yaml_file}")
with open(yaml_path, "r") as f:
data = yaml.safe_load(f)
if data is None:
data = {}
# Extract relevant sections
web_folder = data.get("paths", {}).get("web_folder")
if not web_folder:
raise ValueError(
"web_folder must be specified in config under paths"
)
gallery_cfg = data.get("gallery", {})
sources_data = data.get("sources", [])
# Build sources list
sources = [
{"name": s["name"], "path": s["path"]} for s in sources_data
]
return cls(
web_folder=web_folder,
sources=sources,
png_dpi=gallery_cfg.get("png_dpi", GalleryDefaults.png_dpi),
plot_root=gallery_cfg.get(
"plot_root", GalleryDefaults.plot_root
),
cache_enabled=data.get("metadata", {}).get(
"cache_enabled", GalleryDefaults.cache_enabled
),
inherit_from_parent=data.get("metadata", {}).get(
"inherit_from_parent", GalleryDefaults.inherit_from_parent
),
backup_folder=gallery_cfg.get("backup_folder", ""),
)
def to_yaml(self, yaml_file: Union[str, Path]) -> None:
"""
Save the current configuration to a YAML file.
Args:
yaml_file: Path where to save the YAML configuration
Raises:
IOError: If unable to write to the specified file
"""
yaml_path = Path(yaml_file)
data = {
"paths": {
"work_dir": str(Path.cwd()),
"web_folder": str(self.web_folder),
},
"gallery": {
"plot_root": self.plot_root,
"png_dpi": self.png_dpi,
"backup_folder": self.backup_folder,
},
"ui": {
"max_recent_plots": 20,
"search_debounce_ms": 300,
},
"metadata": {
"cache_enabled": self.cache_enabled,
"inherit_from_parent": self.inherit_from_parent,
"supported_formats": [".yaml", ".yml", ".json"],
},
"sources": [
{"name": s.name, "path": str(s.path)} for s in self.sources
],
}
with open(yaml_path, "w") as f:
yaml.dump(data, f, default_flow_style=False)
+373
View File
@@ -0,0 +1,373 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>{{ title }}</title>
<link rel="stylesheet" href="{{ assets_path }}/css/main.css">
<link rel="stylesheet" href="{{ assets_path }}/css/html-plots.css">
<!-- MathJax for LaTeX rendering -->
<script>
MathJax = {
tex: {
inlineMath: [['$', '$'], ['\\(', '\\)']],
displayMath: [['$$', '$$'], ['\\[', '\\]']]
}
};
</script>
<script id="MathJax-script" async src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js"></script>
</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>
<!-- Folder Metadata Section -->
{% if folder_metadata %}
<div class="metadata-section">
<button class="metadata-toggle-btn" onclick="toggleMetadataSection()">
<span class="metadata-icon">📋</span>
<span class="metadata-label">Folder Information</span>
<span class="metadata-arrow" id="metadataArrow"></span>
</button>
<div class="metadata-content" id="metadataContent" style="display: none;">
<div class="metadata-header">
<div class="metadata-file-info">
<span class="file-path-label">📁 Metadata file:</span>
<code class="file-path" id="metadata-file-path">{{ metadata_file_path }}</code>
<button class="copy-path-btn" onclick="copyMetadataPath()" title="Copy path to clipboard">
📋 Copy
</button>
<span class="tip-icon" title="Tip: Create this file in the source directory to add folder-level metadata that will be inherited by all plots in this folder and its subdirectories. Supports both YAML (.yaml/.yml) and JSON (.json) formats.">
💡
</span>
</div>
</div>
<div class="metadata-grid">
{% for key, value in folder_metadata.items() %}
<div class="metadata-item">
<span class="metadata-key">{{ key }}:</span>
<span class="metadata-value">
{% if value is string and (value.startswith('http://') or value.startswith('https://')) %}
<a href="{{ value }}" target="_blank" rel="noopener noreferrer">{{ value }}</a>
{% elif value is string and '$$' in value %}
<span class="latex-content">{{ value }}</span>
{% elif value is string and value|length > 100 %}
<span class="metadata-long-text">{{ value[:100] }}...</span>
<button class="metadata-expand" onclick="expandText(this)">Show more</button>
<span class="metadata-full-text" style="display: none;">{{ value }}</span>
{% elif value is iterable and value is not string and value is not mapping %}
<div class="metadata-yaml-list">
{% for item in value %}
<div class="yaml-list-item">- {{ item }}</div>
{% endfor %}
</div>
{% elif value is mapping %}
<div class="metadata-yaml-object">
{% for subkey, subvalue in value.items() %}
<div class="yaml-object-item">
<span class="yaml-key">{{ subkey }}:</span>
{% if subvalue is iterable and subvalue is not string and subvalue is not mapping %}
<div class="yaml-nested-list">
{% for nested_item in subvalue %}
<div class="yaml-nested-item">- {{ nested_item }}</div>
{% endfor %}
</div>
{% elif subvalue is mapping %}
<div class="yaml-nested-object">
{% for nested_key, nested_value in subvalue.items() %}
<div class="yaml-nested-item">{{ nested_key }}: {{ nested_value }}</div>
{% endfor %}
</div>
{% else %}
<span class="yaml-value"> {{ subvalue }}</span>
{% endif %}
</div>
{% endfor %}
</div>
{% else %}
{{ value }}
{% endif %}
</span>
</div>
{% endfor %}
</div>
</div>
</div>
{% endif %}
<!-- View Toggle Controls - Only show if there are plot items -->
{% if items %}
<div class="controls-container">
<div class="sort-controls">
<label class="sort-label">Sort by:</label>
<button class="sort-btn active" data-sort="name" title="Sort by Name">
📝 Name
</button>
<button class="sort-btn" data-sort="time" title="Sort by Creation Time">
🕒 Time
</button>
<button class="sort-order-btn" data-order="asc" title="Sort Order">
</button>
</div>
<div class="view-controls">
<button class="view-btn active" data-view="grid" title="Grid View">
<svg width="16" height="16" viewBox="0 0 16 16">
<rect x="1" y="1" width="6" height="6" fill="currentColor"/>
<rect x="9" y="1" width="6" height="6" fill="currentColor"/>
<rect x="1" y="9" width="6" height="6" fill="currentColor"/>
<rect x="9" y="9" width="6" height="6" fill="currentColor"/>
</svg>
</button>
<button class="view-btn" data-view="list-large" title="Large List View">
<svg width="16" height="16" viewBox="0 0 16 16">
<rect x="1" y="2" width="4" height="3" fill="currentColor"/>
<rect x="7" y="2" width="8" height="1" fill="currentColor"/>
<rect x="7" y="4" width="6" height="1" fill="currentColor"/>
<rect x="1" y="7" width="4" height="3" fill="currentColor"/>
<rect x="7" y="7" width="8" height="1" fill="currentColor"/>
<rect x="7" y="9" width="6" height="1" fill="currentColor"/>
<rect x="1" y="12" width="4" height="3" fill="currentColor"/>
<rect x="7" y="12" width="8" height="1" fill="currentColor"/>
<rect x="7" y="14" width="6" height="1" fill="currentColor"/>
</svg>
</button>
<button class="view-btn" data-view="list-compact" title="Compact List View">
<svg width="16" height="16" viewBox="0 0 16 16">
<rect x="1" y="3" width="14" height="1" fill="currentColor"/>
<rect x="1" y="6" width="14" height="1" fill="currentColor"/>
<rect x="1" y="9" width="14" height="1" fill="currentColor"/>
<rect x="1" y="12" width="14" height="1" fill="currentColor"/>
</svg>
</button>
</div>
</div>
{% endif %}
<!-- Plot Container -->
<div class="plot-container grid-view" id="plotContainer">
{% for item in items %}
<div class="plot-item grid-item {% if item.is_html %}html-plot{% endif %}"
data-name="{{ item.name }}"
data-time="{{ item.creation_time|default(0) }}">
{% if item.is_html %}
<a href="{{ item.html_href }}" class="plot-link" target="_blank">
<div class="html-thumbnail">
<div class="html-indicator">HTML</div>
<div class="html-preview">Click to open interactive plot</div>
</div>
</a>
{% else %}
<a href="{{ item.pdf_href }}" class="plot-link">
<img src="{{ item.png_href }}" alt="{{ item.name }}" class="plot-thumbnail">
</a>
{% endif %}
<div class="plot-info">
<div class="plot-name" title="{{ item.name }}">{{ item.name }}</div>
<div class="plot-date" title="Created: {{ item.creation_time|default(0)|int|datetime_from_timestamp|strftime('%Y-%m-%d %H:%M') if item.creation_time and item.creation_time|int > 0 else 'Unknown' }}">
{% if item.creation_time and item.creation_time|int > 0 %}
{{ item.creation_time|int|datetime_from_timestamp|strftime('%Y-%m-%d') }}
{% else %}
Unknown
{% endif %}
</div>
</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>
</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>Export plots</span>
<span class="shortcut-key">Ctrl+E</span>
</div>
<div class="shortcut-item">
<span>Exit selection mode</span>
<span class="shortcut-key">Esc</span>
</div>
<div class="shortcut-item">
<span>Toggle theme</span>
<span class="shortcut-key">Ctrl+T</span>
</div>
<div class="shortcut-item">
<span>Toggle view</span>
<span class="shortcut-key">Ctrl+V</span>
</div>
<div class="shortcut-item">
<span>Sort by name</span>
<span class="shortcut-key">Ctrl+N</span>
</div>
<div class="shortcut-item">
<span>Sort by time</span>
<span class="shortcut-key">Ctrl+M</span>
</div>
<div class="shortcut-item">
<span>Toggle sort order</span>
<span class="shortcut-key">Ctrl+O</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>
<!-- Configuration for JavaScript -->
<script>
// Configuration object for the gallery app
window.galleryConfig = {
searchDebounceMs: {{ ui.search_debounce_ms|default(300) }},
maxRecentPlots: {{ ui.max_recent_plots|default(20) }},
workDir: "{{ paths.work_dir }}",
stats: {% if stats %}{{ stats|tojson }}{% else %}null{% endif %}
};
</script>
<!-- Metadata Popup Script -->
<script src="{{ assets_path }}/js/metadata-popup.js"></script>
<!-- Metadata Section Script -->
<script src="{{ assets_path }}/js/metadata-section.js"></script>
<!-- Folder Metadata Script -->
<script src="{{ assets_path }}/js/folder-metadata.js"></script>
<!-- Main JavaScript Application -->
<script type="module" src="{{ assets_path }}/js/main.js"></script>
</body>
</html>
+1
View File
@@ -0,0 +1 @@
"""Utility modules for gallery generation."""
+40
View File
@@ -0,0 +1,40 @@
"""Backup utilities for gallery."""
import zipfile
import datetime
from pathlib import Path
def create_backup(
web_folder: Path,
backup_folder: Path
) -> bool:
"""
Create a backup of the web folder.
Args:
web_folder: Path to the web folder to backup
backup_folder: Path to the backup directory
Returns:
True if backup was created successfully, False otherwise
"""
try:
today = datetime.date.today().strftime("%Y%m%d")
backup_name = f"backup-{today}.zip"
backup_path = backup_folder / backup_name
backup_folder.mkdir(parents=True, exist_ok=True)
if backup_path.exists():
return True
with zipfile.ZipFile(backup_path, "w", zipfile.ZIP_DEFLATED) as zipf:
for path in web_folder.rglob("*"):
if path.is_file():
arcname = path.relative_to(web_folder.parent)
zipf.write(path, arcname)
return True
except Exception as e:
print(f"Warning: Could not create backup: {e}")
return False
+159
View File
@@ -0,0 +1,159 @@
"""
Metadata Management for Scientific Gallery Generator
This module handles loading, parsing, and caching of metadata for plots
and folders in the gallery system. Supports YAML and JSON formats with
hierarchical inheritance.
Features:
- Load metadata from YAML/JSON files
- Hierarchical metadata inheritance from parent folders
- Plot-specific metadata overrides
- Metadata caching for performance
"""
import json
import yaml
from pathlib import Path
from typing import Dict, Any
def load_metadata_file(metadata_path: Path) -> Dict[str, Any]:
"""
Load metadata from a YAML or JSON file.
Args:
metadata_path: Path to the metadata file
Returns:
Dictionary containing the metadata, empty dict if file doesn't exist
or can't be parsed
"""
if not metadata_path.exists():
return {}
try:
with metadata_path.open('r', encoding='utf-8') as f:
suffix_lower = metadata_path.suffix.lower()
if suffix_lower == '.yaml' or suffix_lower == '.yml':
return yaml.safe_load(f) or {}
elif metadata_path.suffix.lower() == '.json':
return json.load(f) or {}
else:
print(f"Warning: Unknown metadata file format: "
f"{metadata_path}")
return {}
except (yaml.YAMLError, json.JSONDecodeError, IOError) as e:
print(f"Warning: Could not parse metadata file {metadata_path}: {e}")
raise e
def load_folder_metadata(folder_path: Path) -> Dict[str, Any]:
"""
Load folder-level metadata from metadata.yaml, metadata.yml, or metadata.json.
Args:
folder_path: Path to the folder to check for metadata
Returns:
Dictionary containing the folder metadata
"""
# Try YAML first, then JSON for backwards compatibility
for filename in ['metadata.yaml', 'metadata.yml', 'metadata.json']:
metadata_path = folder_path / filename
if metadata_path.exists():
return load_metadata_file(metadata_path)
return {}
def get_metadata_file_path(folder_path: Path) -> str:
"""
Get the metadata file path for a folder.
Returns existing file if found, otherwise suggests metadata.yaml.
Args:
folder_path: Path to the folder to check for metadata
Returns:
String path to the metadata file (existing or suggested)
"""
# Preferred order: YAML first, then JSON
preferred_files = ['metadata.yaml', 'metadata.yml', 'metadata.json']
for filename in preferred_files:
metadata_path = folder_path / filename
if metadata_path.exists():
return str(metadata_path)
# If no file exists, suggest metadata.yaml (preferred format)
return str(folder_path / 'metadata.yaml')
def merge_metadata(
parent_metadata: Dict[str, Any],
child_metadata: Dict[str, Any]
) -> Dict[str, Any]:
"""
Merge parent and child metadata, with child values overriding parent.
Args:
parent_metadata: Metadata from parent folder
child_metadata: Metadata from current folder
Returns:
Merged metadata dictionary
"""
merged = parent_metadata.copy()
merged.update(child_metadata)
return merged
def resolve_metadata_for_plot(
plot_path: Path,
inherited_metadata: Dict[str, Any]
) -> Dict[str, Any]:
"""
Resolve metadata for a specific plot.
Merges inherited metadata with plot-specific metadata.
Args:
plot_path: Path to the plot file (PDF)
inherited_metadata: Metadata inherited from folder hierarchy
Returns:
Final merged metadata for the plot
"""
plot_stem = plot_path.stem
plot_dir = plot_path.parent
# Check for plot-specific metadata files
for suffix in ['.yaml', '.yml', '.json']:
plot_metadata_path = plot_dir / f"{plot_stem}{suffix}"
if plot_metadata_path.exists():
plot_metadata = load_metadata_file(plot_metadata_path)
return merge_metadata(inherited_metadata, plot_metadata)
# No plot-specific metadata found, return inherited metadata
return inherited_metadata.copy()
def save_metadata_cache(
web_dir: Path,
plot_metadata_cache: Dict[str, Dict[str, Any]]
) -> None:
"""
Save plot metadata cache to meta_cache.json in the web directory.
Args:
web_dir: Web directory where the cache file should be saved
plot_metadata_cache: Dictionary mapping plot names to their metadata
"""
cache_path = web_dir / "meta_cache.json"
try:
with cache_path.open('w', encoding='utf-8') as f:
json.dump(plot_metadata_cache, f, indent=2, ensure_ascii=False)
except IOError as e:
print(f"Warning: Could not save metadata cache {cache_path}: {e}")
+234
View File
@@ -0,0 +1,234 @@
"""Plot file processing and HTML rendering."""
import shutil
import subprocess
from pathlib import Path
from typing import Any, Dict
from jinja2 import Template
from gallery.utils.metadata import (
resolve_metadata_for_plot,
get_metadata_file_path,
)
from gallery.utils.stats import (
calculate_directory_stats,
format_file_size,
)
from gallery.config import GalleryConfig
def process_html_file(
html_file: Path,
web_dir: Path,
current_metadata: Dict[str, Any] = None
) -> dict:
"""
Process HTML plot file, copying it to web directory.
Args:
html_file: Path to the source HTML file
web_dir: Target web directory
current_metadata: Current metadata dictionary for the plot
Returns:
Dictionary containing plot information
"""
web_html = web_dir / html_file.name
if needs_update(html_file, web_html):
shutil.copy2(html_file, web_html)
# Get source file creation time
source_creation_time = int(html_file.stat().st_ctime)
# Resolve metadata if provided
plot_metadata = {}
if current_metadata is not None:
plot_metadata = resolve_metadata_for_plot(html_file, current_metadata)
return {
"name": html_file.stem,
"html_href": html_file.name,
"is_html": True,
"metadata": plot_metadata,
"creation_time": source_creation_time
}
def process_plot_files(
config: GalleryConfig,
plot_file: Path,
web_dir: Path,
current_metadata: Dict[str, Any] = None,
) -> dict:
"""
Process plot files (PDF/PNG or HTML), handling conversion and copying.
Args:
config: Gallery configuration object
plot_file: Path to the source plot file (PDF or HTML)
web_dir: Target web directory
current_metadata: Current metadata dictionary for the plot
Returns:
Dictionary containing plot information
"""
if plot_file.suffix.lower() == '.html':
return process_html_file(plot_file, web_dir, current_metadata)
# Handle PDF files
png_file = plot_file.with_suffix(".png")
web_pdf = web_dir / plot_file.name
web_png = web_dir / png_file.name
if needs_update(plot_file, web_pdf):
shutil.copy2(plot_file, web_pdf)
if not png_file.exists():
convert_pdf_to_png(plot_file, config=config)
if needs_update(png_file, web_png):
shutil.copy2(png_file, web_png)
source_creation_time = int(plot_file.stat().st_ctime)
plot_metadata = {}
if current_metadata is not None:
plot_metadata = resolve_metadata_for_plot(plot_file, current_metadata)
return {
"name": plot_file.stem,
"pdf_href": plot_file.name,
"png_href": png_file.name,
"is_html": False,
"metadata": plot_metadata,
"creation_time": source_creation_time
}
def render_gallery_page(
config: GalleryConfig,
template: Template,
web_dir: Path,
items: list,
subdirs: list,
relative_path: Path,
title: str = None,
metadata: dict = None
) -> None:
"""
Unified template rendering for all gallery pages.
Args:
config: Gallery configuration object
template: Jinja2 template object
web_dir: Target web directory
items: List of plot items
subdirs: List of subdirectory names
relative_path: Relative path from gallery root
title: Page title (optional)
metadata: Metadata dictionary (optional)
"""
if title is None:
title = "Gallery" if relative_path == Path(
".") else f"Gallery: {relative_path}"
if metadata is None:
metadata = {}
# Calculate statistics
current_stats = calculate_directory_stats(web_dir)
stats = {
"file_count": len(items),
"folder_count": len(subdirs),
"total_size": format_file_size(current_stats["total_size"]),
"total_size_bytes": current_stats["total_size"]
}
# Calculate relative path to assets
if relative_path == Path("."):
assets_path = "../assets"
else:
depth = len(relative_path.parts)
assets_path = "../" * (depth + 1) + "assets"
# For root level, show only directory structure
if relative_path == Path("."):
items = []
output_html = web_dir / "index.html"
with output_html.open("w") as f:
paths_dict = {
"work_dir": str(Path.cwd()),
"web_folder": str(config.web_folder),
}
ui_dict = {
"max_recent_plots": 20,
"search_debounce_ms": 300,
}
rendered_html = template.render(
title=title,
items=items,
subdirs=subdirs,
relpath=str(relative_path),
paths=paths_dict,
ui=ui_dict,
stats=stats,
folder_metadata=metadata,
assets_path=assets_path,
source_dir=str(web_dir),
metadata_file_path=get_metadata_file_path(web_dir)
)
f.write(rendered_html)
def convert_pdf_to_png(pdf_path: Path, config: GalleryConfig) -> None:
"""
Convert a PDF file to PNG format using ImageMagick.
Only converts if the PNG doesn't exist or if the PDF is newer than
the PNG (with a 30-second buffer to handle filesystem timing issues).
Args:
pdf_path: Path to the source PDF file
config: Gallery configuration object
Raises:
subprocess.CalledProcessError: If ImageMagick conversion fails
"""
png_path = pdf_path.with_suffix(".png")
if png_path.exists():
pdf_mtime = pdf_path.stat().st_mtime
png_mtime = png_path.stat().st_mtime
if png_mtime >= (pdf_mtime + 30):
return
subprocess.run([
"convert",
"-density", str(config.png_dpi),
str(pdf_path),
"-quality", "95",
str(png_path)
], check=True)
def needs_update(source_file: Path, target_file: Path) -> bool:
"""
Check if target file needs updating based on source modification time.
Args:
source_file: Path to the source file
target_file: Path to the target file
Returns:
True if target needs update, False otherwise
"""
if not target_file.exists():
return True
source_mtime = source_file.stat().st_mtime
target_mtime = target_file.stat().st_mtime
return source_mtime > (target_mtime + 30)
+63
View File
@@ -0,0 +1,63 @@
"""Statistics calculation for gallery directories."""
from pathlib import Path
def calculate_directory_stats(directory: Path) -> dict:
"""
Calculate statistics for a directory.
Args:
directory: Path to the directory to analyze
Returns:
Dictionary containing file count, folder count, and total size
"""
stats = {
"file_count": 0,
"folder_count": 0,
"total_size": 0,
"pdf_size": 0,
"png_size": 0,
}
if not directory.exists():
return stats
for item in directory.rglob("*"):
if item.is_file():
stats["file_count"] += 1
size = item.stat().st_size
stats["total_size"] += size
if item.suffix.lower() == '.pdf':
stats["pdf_size"] += size
elif item.suffix.lower() == '.png':
stats["png_size"] += size
elif item.is_dir():
stats["folder_count"] += 1
return stats
def format_file_size(size_bytes: int) -> str:
"""
Format file size in human readable format.
Args:
size_bytes: Size in bytes
Returns:
Formatted size string
"""
if size_bytes == 0:
return "0 B"
size_names = ["B", "KB", "MB", "GB", "TB"]
size = float(size_bytes)
i = 0
while size >= 1024 and i < len(size_names) - 1:
size /= 1024
i += 1
return f"{size:.1f} {size_names[i]}"
+38 -185
View File
@@ -1,130 +1,26 @@
"""
Scientific Gallery Generator
Scientific Gallery Generator - Legacy CLI Entry Point
This module generates static HTML galleries from scientific plot collections.
It converts PDF plots to PNG thumbnails, creates responsive web interfaces,
and organizes plots into hierarchical directory structures.
This module provides backward compatibility for the legacy CLI interface.
For new development, use the gallery package API directly:
Features:
- PDF to PNG conversion with configurable DPI
- Incremental updates (only converts when source is newer)
- Jinja2 templating for consistent HTML generation
- Support for nested folder structures
- Responsive grid layout with search and navigation
from gallery import generate, GalleryConfig
config = GalleryConfig.from_yaml("config.yaml")
generate(config, verbose=True)
Or use the new CLI:
gallery --config config.yaml --verbose
"""
import shutil
from pathlib import Path
from typing import Dict, Any, Optional
from datetime import datetime
from jinja2 import Environment, FileSystemLoader
from utils.config import Config, GalleryItem
from utils.metadata import (
load_folder_metadata,
merge_metadata,
save_metadata_cache,
)
from utils.processing import (
process_plot_files,
needs_update,
render_gallery_page,
)
from gallery import generate
from gallery.config import GalleryConfig
CONFIG = Config.from_yaml("config.yaml")
def datetime_from_timestamp(timestamp: float) -> datetime:
"""Convert a Unix timestamp to a datetime object."""
return datetime.fromtimestamp(timestamp)
def strftime_filter(dt: datetime, fmt: str) -> str:
"""Format a datetime object using strftime."""
return dt.strftime(fmt)
env = Environment(loader=FileSystemLoader("."))
env.filters['datetime_from_timestamp'] = datetime_from_timestamp
env.filters['strftime'] = strftime_filter
template = env.get_template("templates/gallery.html")
def build_gallery(
source_dir: Path,
web_dir: Path,
relative_path: Path = None,
inherited_metadata: Optional[Dict[str, Any]] = None,
) -> None:
def main(clean_first: bool = False, source_override: str = None) -> None:
"""
Recursively build gallery structure from source directory.
Processes all PDF files in the source directory, converts them to PNG,
copies both to the web directory, and generates index.html files with
navigation and thumbnails. Now includes metadata support.
Args:
source_dir: Source directory containing PDF files
web_dir: Target web directory for gallery output
relative_path: Relative path from gallery root (for navigation)
inherited_metadata: Metadata inherited from parent directories
"""
if relative_path is None:
relative_path = Path(".")
if inherited_metadata is None:
inherited_metadata = {}
folder_metadata = load_folder_metadata(source_dir)
current_metadata = merge_metadata(inherited_metadata, folder_metadata)
# Find all plot files (both PDF and HTML)
pdf_files = list(source_dir.glob("*.pdf"))
html_files = list(source_dir.glob("*.html"))
plot_files = pdf_files + html_files
items = []
plot_metadata_cache = {}
# Process all plot files (PDFs and HTMLs)
for plot_file in plot_files:
item = process_plot_files(
CONFIG=CONFIG,
plot_file=plot_file,
web_dir=web_dir,
current_metadata=current_metadata,
)
items.append(item)
plot_metadata_cache[plot_file.stem] = item["metadata"]
save_metadata_cache(web_dir, plot_metadata_cache)
subdirs = [d for d in source_dir.iterdir() if d.is_dir()]
subdir_names = []
for subdir in subdirs:
subdir_web = web_dir / subdir.name
subdir_web.mkdir(exist_ok=True)
subdir_relative = relative_path / subdir.name
build_gallery(subdir, subdir_web, subdir_relative, current_metadata)
subdir_names.append(subdir.name)
render_gallery_page(
CONFIG=CONFIG,
template=template,
web_dir=web_dir,
items=items,
subdirs=subdir_names,
relative_path=relative_path,
metadata=current_metadata
)
def main(
clean_first: bool = False, source_override: Optional[str] = None
) -> None:
"""
Main entry point for gallery generation.
Main entry point for gallery generation (legacy interface).
Args:
clean_first: If True, removes and recreates the gallery directory
@@ -134,34 +30,20 @@ def main(
Processes all configured sources and generates the complete gallery
structure in the web directory. Ensures assets are available.
"""
gallery_root = Path(CONFIG.web_folder) / CONFIG.plot_root
try:
# Load configuration from config.yaml
config = GalleryConfig.from_yaml("config.yaml")
if clean_first and gallery_root.exists():
print(f"Cleaning gallery directory {gallery_root}...")
shutil.rmtree(gallery_root)
gallery_root.mkdir(parents=True, exist_ok=True)
assets_src = Path("assets")
assets_dst = gallery_root.parent / "assets"
if assets_src.exists():
main_css_src = assets_src / "css" / "main.css"
main_css_dst = assets_dst / "css" / "main.css"
if not assets_dst.exists() or needs_update(main_css_src, main_css_dst):
if assets_dst.exists():
shutil.rmtree(assets_dst)
shutil.copytree(assets_src, assets_dst)
print(f"Updated assets from {assets_src} to {assets_dst}")
else:
print(f"Warning: Assets directory {assets_src} not found")
# Determine which sources to process
# Handle source override
if source_override:
from pathlib import Path
from gallery.config import GallerySource
source_path = Path(source_override).resolve()
# Check if source is in config
matching_source = None
for source in CONFIG.sources:
for source in config.sources:
if Path(source.path).resolve() == source_path:
matching_source = source
break
@@ -169,58 +51,29 @@ def main(
# If not in config, create a temporary source entry
if matching_source is None:
source_name = source_path.name
msg = (
print(
f"Source {source_override} not in config. "
f"Adding temporarily as '{source_name}'"
)
print(msg)
matching_source = GalleryItem(name=source_name, path=source_path)
sources_to_process = [matching_source]
config.sources = [
GallerySource(name=source_name, path=source_path)
]
else:
sources_to_process = CONFIG.sources
config.sources = [matching_source]
source_subdirs = []
for source in sources_to_process:
source_path = Path(source.path)
source_web_dir = gallery_root / source.name
source_web_dir.mkdir(parents=True, exist_ok=True)
source_subdirs.append(source.name)
if source_path.is_file() and source_path.suffix == '.pdf':
item = process_plot_files(
CONFIG=CONFIG,
plot_file=source_path,
web_dir=source_web_dir,
)
render_gallery_page(
CONFIG=CONFIG,
template=template,
web_dir=source_web_dir,
items=[item],
subdirs=[],
relative_path=Path(source.name)
)
elif source_path.is_dir():
build_gallery(source_path, source_web_dir, Path(source.name))
else:
print(
f"Warning: Source {source.path} is neither a "
f"directory nor a PDF file. Skipping."
# Generate gallery using the new API
success = generate(
config=config,
clean_first=clean_first,
verbose=True
)
print(f"Processed {source.name}: {source.path}")
if not success:
exit(1)
render_gallery_page(
CONFIG=CONFIG,
template=template,
web_dir=gallery_root,
items=[],
subdirs=source_subdirs,
relative_path=Path("."),
title="Gallery Root"
)
except Exception as e:
print(f"Error: {e}")
exit(1)
if __name__ == "__main__":
+52 -14
View File
@@ -1,20 +1,62 @@
[build-system]
requires = ["setuptools>=65.0", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "plot-gallery"
name = "gallery"
version = "0.1.0"
description = "Host your plots on a personal website"
authors = [
{ name = "K. Schmidt" }
]
description = "Scientific Gallery Generator - Create responsive HTML galleries from plot collections"
readme = "README.md"
requires-python = ">=3.9"
dependencies = [
"jinja2",
"pyyaml",
requires-python = ">=3.8"
license = {text = "MIT"}
authors = [
{name = "K. Schmidt"},
]
keywords = [
"gallery",
"plots",
"scientific-computing",
"html-generation",
"pdf-to-png",
]
classifiers = [
"Development Status :: 3 - Alpha",
"Intended Audience :: Science/Research",
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Topic :: Scientific/Engineering :: Visualization",
]
dependencies = [
"Jinja2>=3.0.0",
"PyYAML>=5.0",
]
[project.optional-dependencies]
dev = [
"pytest>=7.0",
"black>=22.0",
"pylint>=2.0",
"mypy>=0.900",
]
[project.scripts]
gallery = "gallery.cli:main"
[tool.setuptools]
packages = ["gallery", "gallery.utils"]
package-data = {gallery = ["templates/*", "assets/css/*", "assets/js/*"]}
include-package-data = true
[tool.black]
line-length = 120
target-version = ['py39']
target-version = ['py38']
[tool.isort]
profile = "black"
@@ -23,7 +65,3 @@ line_length = 120
[tool.flake8]
max-line-length = 120
extend-ignore = ["E203", "W503"]
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"