diff --git a/.gitignore b/.gitignore index 1e18cb9..194eda8 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,6 @@ *.sif *.ipynb backups -.pytest_cache \ No newline at end of file +.pytest_cache +.venv +build diff --git a/README.md b/README.md index 50efcb7..fe0c9ce 100644 --- a/README.md +++ b/README.md @@ -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 cd scientific-gallery-generator diff --git a/gallery/__init__.py b/gallery/__init__.py new file mode 100644 index 0000000..0c93490 --- /dev/null +++ b/gallery/__init__.py @@ -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", +] diff --git a/gallery/api.py b/gallery/api.py new file mode 100644 index 0000000..25652e3 --- /dev/null +++ b/gallery/api.py @@ -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 diff --git a/gallery/assets/css/base.css b/gallery/assets/css/base.css new file mode 100644 index 0000000..f007e1e --- /dev/null +++ b/gallery/assets/css/base.css @@ -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; + } +} diff --git a/gallery/assets/css/comparison.css b/gallery/assets/css/comparison.css new file mode 100644 index 0000000..7e2dee2 --- /dev/null +++ b/gallery/assets/css/comparison.css @@ -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; +} diff --git a/gallery/assets/css/export.css b/gallery/assets/css/export.css new file mode 100644 index 0000000..84d7b5f --- /dev/null +++ b/gallery/assets/css/export.css @@ -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); +} diff --git a/gallery/assets/css/floating-elements.css b/gallery/assets/css/floating-elements.css new file mode 100644 index 0000000..cd8e830 --- /dev/null +++ b/gallery/assets/css/floating-elements.css @@ -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; + } +} diff --git a/gallery/assets/css/folder-metadata.css b/gallery/assets/css/folder-metadata.css new file mode 100644 index 0000000..8508cf3 --- /dev/null +++ b/gallery/assets/css/folder-metadata.css @@ -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; + } +} diff --git a/gallery/assets/css/folder-tree.css b/gallery/assets/css/folder-tree.css new file mode 100644 index 0000000..3c394ad --- /dev/null +++ b/gallery/assets/css/folder-tree.css @@ -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); +} diff --git a/gallery/assets/css/grid.css b/gallery/assets/css/grid.css new file mode 100644 index 0000000..8ad9828 --- /dev/null +++ b/gallery/assets/css/grid.css @@ -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; +} diff --git a/gallery/assets/css/html-plots.css b/gallery/assets/css/html-plots.css new file mode 100644 index 0000000..234a2e8 --- /dev/null +++ b/gallery/assets/css/html-plots.css @@ -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; +} diff --git a/gallery/assets/css/main.css b/gallery/assets/css/main.css new file mode 100644 index 0000000..c6019d7 --- /dev/null +++ b/gallery/assets/css/main.css @@ -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'); diff --git a/gallery/assets/css/metadata-section.css b/gallery/assets/css/metadata-section.css new file mode 100644 index 0000000..7988467 --- /dev/null +++ b/gallery/assets/css/metadata-section.css @@ -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; + } +} diff --git a/gallery/assets/css/metadata.css b/gallery/assets/css/metadata.css new file mode 100644 index 0000000..872fef6 --- /dev/null +++ b/gallery/assets/css/metadata.css @@ -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; + } +} diff --git a/gallery/assets/css/navigation.css b/gallery/assets/css/navigation.css new file mode 100644 index 0000000..1dc5629 --- /dev/null +++ b/gallery/assets/css/navigation.css @@ -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; +} diff --git a/gallery/assets/css/responsive.css b/gallery/assets/css/responsive.css new file mode 100644 index 0000000..1ca81f5 --- /dev/null +++ b/gallery/assets/css/responsive.css @@ -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; + } +} diff --git a/gallery/assets/css/search.css b/gallery/assets/css/search.css new file mode 100644 index 0000000..7bd6995 --- /dev/null +++ b/gallery/assets/css/search.css @@ -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; +} diff --git a/gallery/assets/css/sidebar.css b/gallery/assets/css/sidebar.css new file mode 100644 index 0000000..7718fb1 --- /dev/null +++ b/gallery/assets/css/sidebar.css @@ -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; +} diff --git a/gallery/assets/css/sort-controls.css b/gallery/assets/css/sort-controls.css new file mode 100644 index 0000000..3df2439 --- /dev/null +++ b/gallery/assets/css/sort-controls.css @@ -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; +} diff --git a/gallery/assets/css/sort-debug.css b/gallery/assets/css/sort-debug.css new file mode 100644 index 0000000..e69de29 diff --git a/gallery/assets/css/stats.css b/gallery/assets/css/stats.css new file mode 100644 index 0000000..10868e5 --- /dev/null +++ b/gallery/assets/css/stats.css @@ -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; + } +} diff --git a/gallery/assets/css/variables.css b/gallery/assets/css/variables.css new file mode 100644 index 0000000..72c531e --- /dev/null +++ b/gallery/assets/css/variables.css @@ -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; +} diff --git a/gallery/assets/css/view-controls.css b/gallery/assets/css/view-controls.css new file mode 100644 index 0000000..11580d8 --- /dev/null +++ b/gallery/assets/css/view-controls.css @@ -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; + } +} diff --git a/gallery/assets/css/view-override.css b/gallery/assets/css/view-override.css new file mode 100644 index 0000000..e9bd47c --- /dev/null +++ b/gallery/assets/css/view-override.css @@ -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; +} diff --git a/gallery/assets/js/comparison-manager.js b/gallery/assets/js/comparison-manager.js new file mode 100644 index 0000000..7056775 --- /dev/null +++ b/gallery/assets/js/comparison-manager.js @@ -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 = ` + ${plotInfo.name} +
+ ${plotInfo.name}
+ ${plotInfo.path} +
+ `; + } + + 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); + } +} diff --git a/gallery/assets/js/export-manager.js b/gallery/assets/js/export-manager.js new file mode 100644 index 0000000..b60967b --- /dev/null +++ b/gallery/assets/js/export-manager.js @@ -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 = ` +
+ +
+ `; + 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 = ` +
+

🚀 Export Selected Plots

+

Run the following command in your terminal to export the selected plots:

+ +
+
+ ${fullCommand} +
+
+ + + +
+
+ +
+

📋 Command Breakdown:

+
    +
  • Creates temporary file: ${tempFilePath}
  • +
  • Changes to work directory: ${workDir}
  • +
  • Runs export script: python export_plots.py
  • +
  • Output file: Will be saved in the work directory
  • +
+
+ +
+

💡 Tips:

+
    +
  • The temporary JSON file will be automatically cleaned up after successful export
  • +
  • Use Esc to exit selection mode
  • +
  • Press Ctrl+E to toggle selection mode
  • +
+
+
+ `; + + 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(); + }); + } +}); diff --git a/gallery/assets/js/folder-metadata.js b/gallery/assets/js/folder-metadata.js new file mode 100644 index 0000000..86f9db4 --- /dev/null +++ b/gallery/assets/js/folder-metadata.js @@ -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'); + } + }); +}); diff --git a/gallery/assets/js/gallery-app.js b/gallery/assets/js/gallery-app.js new file mode 100644 index 0000000..849286e --- /dev/null +++ b/gallery/assets/js/gallery-app.js @@ -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); } +} diff --git a/gallery/assets/js/keyboard-manager.js b/gallery/assets/js/keyboard-manager.js new file mode 100644 index 0000000..c535a9c --- /dev/null +++ b/gallery/assets/js/keyboard-manager.js @@ -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'; + } +} diff --git a/gallery/assets/js/main.js b/gallery/assets/js/main.js new file mode 100644 index 0000000..8f924e6 --- /dev/null +++ b/gallery/assets/js/main.js @@ -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; +}); diff --git a/gallery/assets/js/metadata-popup.js b/gallery/assets/js/metadata-popup.js new file mode 100644 index 0000000..085c3a9 --- /dev/null +++ b/gallery/assets/js/metadata-popup.js @@ -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 ` +
+

${plotName}

+
+
+ +
+ `; + } + + let html = ` +
+

${plotName}

+
+
+ `; + + // 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 += ``; + 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 += ``; + 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 += ``; + 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 += ``; + 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 += ``; + if (metadata.generation.generation_time) { + const genDate = new Date(metadata.generation.generation_time); + html += this.formatMetadataField('Generated', genDate.toLocaleString()); + } + } + + html += '
'; + 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 = 'null'; + } else if (typeof value === 'object') { + if (Array.isArray(value)) { + if (value.length <= 3) { + formattedValue = value.map(item => `${item}`).join(' '); + } else { + formattedValue = `${value.slice(0, 3).map(item => `${item}`).join(' ')} +${value.length - 3} more`; + } + } 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 = '' + JSON.stringify(value) + ''; + } else { + formattedValue = `Object with ${keys.length} properties`; + } + } + } else { + // Truncate long strings + const str = String(value); + formattedValue = str.length > 50 ? str.substring(0, 47) + '...' : str; + } + + return ` +
+ + +
+ `; + } +} + +// Global instance +window.metadataPopup = new MetadataPopup(); + +// Global function for template usage +window.showMetadataPopup = function(button, plotName, metadata) { + window.metadataPopup.showPopup(button, plotName, metadata); +}; diff --git a/gallery/assets/js/metadata-section.js b/gallery/assets/js/metadata-section.js new file mode 100644 index 0000000..b8f2485 --- /dev/null +++ b/gallery/assets/js/metadata-section.js @@ -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); + } +}); diff --git a/gallery/assets/js/navigation-manager.js b/gallery/assets/js/navigation-manager.js new file mode 100644 index 0000000..b9a69d6 --- /dev/null +++ b/gallery/assets/js/navigation-manager.js @@ -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 = '🏠 Root'; + return; + } + + let html = '🏠 Root'; + + for (let i = 0; i < pathParts.length; i++) { + const part = pathParts[i]; + html += '/'; + + if (i === pathParts.length - 1) { + html += `${decodeURIComponent(part)}`; + } else { + const levelsUp = pathParts.length - 1 - i; + const relativePath = '../'.repeat(levelsUp) + 'index.html'; + html += `${decodeURIComponent(part)}`; + } + } + + 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 = '
❌ Error loading folder tree
'; + } + } + + /** + * 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 `
${indent}└─ ...
`; + } + + 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 += `
${indent}${arrow}📁 ${folderName} (${totalItems} items)
`; + } else { + html += `
${indent}${arrow}📁 ${folderName} (${totalItems} items)
`; + } + + 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 `
${indent}${arrow}📁 ${folderName} (error loading)
`; + } + } + + /** + * 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 += `
${indent}${arrow}📁 ${displayName} (${totalItems} items)
`; + } else { + html += `
${indent}${arrow}📁 ${displayName} (${totalItems} items)
`; + } + + 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; + } +} diff --git a/gallery/assets/js/recent-plots-manager.js b/gallery/assets/js/recent-plots-manager.js new file mode 100644 index 0000000..29fd3bc --- /dev/null +++ b/gallery/assets/js/recent-plots-manager.js @@ -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 = ` +
+ 📭 No recent plots yet
+ Open some plots to see them here +
+ `; + return; + } + + let html = ''; + recentPlots.forEach(plot => { + html += ` +
+ ${plot.name} +
+
${plot.name}
+
📍 ${plot.path}
+
+
+ `; + }); + + 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); + } + }); + } +} diff --git a/gallery/assets/js/search-manager.js b/gallery/assets/js/search-manager.js new file mode 100644 index 0000000..20eff08 --- /dev/null +++ b/gallery/assets/js/search-manager.js @@ -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 = '
🔍 Searching...
'; + searchResults.style.display = 'block'; + + try { + const results = await this.searchPlots(query); + this.displaySearchResults(results, query); + } catch (error) { + console.error('Search error:', error); + searchResults.innerHTML = '
❌ Search failed
'; + } + } + + /** + * 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 = '
📭 No plots found
'; + 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 += ` +
+
+ +
+
+ ${highlightedName} +
+
+ 📍 ${relativePath} +
+
+
+
+ `; + }); + + searchResults.innerHTML = html; + } + + /** + * Highlight search query in text + */ + highlightText(text, query) { + const regex = new RegExp(`(${query})`, 'gi'); + return text.replace(regex, '$1'); + } + + /** + * 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'; + } +} diff --git a/gallery/assets/js/sort-manager.js b/gallery/assets/js/sort-manager.js new file mode 100644 index 0000000..8ec9a85 --- /dev/null +++ b/gallery/assets/js/sort-manager.js @@ -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(); + } +} diff --git a/gallery/assets/js/stats-manager.js b/gallery/assets/js/stats-manager.js new file mode 100644 index 0000000..9ad8df5 --- /dev/null +++ b/gallery/assets/js/stats-manager.js @@ -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; + } + } +} diff --git a/gallery/assets/js/theme-manager.js b/gallery/assets/js/theme-manager.js new file mode 100644 index 0000000..f23affc --- /dev/null +++ b/gallery/assets/js/theme-manager.js @@ -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'); + } + } +} diff --git a/gallery/assets/js/utils.js b/gallery/assets/js/utils.js new file mode 100644 index 0000000..3c5b432 --- /dev/null +++ b/gallery/assets/js/utils.js @@ -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'; + } + } +} diff --git a/gallery/assets/js/view-manager.js b/gallery/assets/js/view-manager.js new file mode 100644 index 0000000..0d51208 --- /dev/null +++ b/gallery/assets/js/view-manager.js @@ -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]); + } +} diff --git a/gallery/builder.py b/gallery/builder.py new file mode 100644 index 0000000..10cd967 --- /dev/null +++ b/gallery/builder.py @@ -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 diff --git a/gallery/cli.py b/gallery/cli.py new file mode 100644 index 0000000..8befca7 --- /dev/null +++ b/gallery/cli.py @@ -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() diff --git a/gallery/config.py b/gallery/config.py new file mode 100644 index 0000000..9746b9e --- /dev/null +++ b/gallery/config.py @@ -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) diff --git a/gallery/templates/gallery.html b/gallery/templates/gallery.html new file mode 100644 index 0000000..635852f --- /dev/null +++ b/gallery/templates/gallery.html @@ -0,0 +1,373 @@ + + + + + {{ title }} + + + + + + + + + +

{{ title }}

+ + +
+ + 🔍 +
+
+ + + + + + + + +
+ + + {% if folder_metadata %} +
+ + + +
+ {% endif %} + + + {% if items %} +
+
+ + + + +
+
+ + + +
+
+ {% endif %} + + +
+ {% for item in items %} +
+ {% if item.is_html %} + +
+
HTML
+
Click to open interactive plot
+
+
+ {% else %} + + {{ item.name }} + + {% endif %} +
+
{{ item.name }}
+
+ {% if item.creation_time and item.creation_time|int > 0 %} + {{ item.creation_time|int|datetime_from_timestamp|strftime('%Y-%m-%d') }} + {% else %} + Unknown + {% endif %} +
+
+
+ {% endfor %} +
+ + + {% if subdirs %} +

Subdirectories

+ + {% endif %} + + + + + + +
+ + + +
+ + +
+

Keyboard Shortcuts

+
+ Search + Ctrl+K +
+
+ Recent plots + Ctrl+R +
+
+ Compare plots + Ctrl+C +
+
+ Export plots + Ctrl+E +
+
+ Exit selection mode + Esc +
+
+ Toggle theme + Ctrl+T +
+
+ Toggle view + Ctrl+V +
+
+ Sort by name + Ctrl+N +
+
+ Sort by time + Ctrl+M +
+
+ Toggle sort order + Ctrl+O +
+
+ Help + ? +
+
+ + + + + +
+
+
+

Plot Comparison

+ +
+
+
+
+ Plot A + +
+
+
+
+ 📊 Click to select first plot +
+
+
+
+
+
+ Plot B + +
+
+
+
+ 📊 Click to select second plot +
+
+
+
+
+
+
+ + + + + + + + + + + + + + + + + diff --git a/gallery/utils/__init__.py b/gallery/utils/__init__.py new file mode 100644 index 0000000..fa89ece --- /dev/null +++ b/gallery/utils/__init__.py @@ -0,0 +1 @@ +"""Utility modules for gallery generation.""" diff --git a/gallery/utils/backup.py b/gallery/utils/backup.py new file mode 100644 index 0000000..6396f1d --- /dev/null +++ b/gallery/utils/backup.py @@ -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 diff --git a/gallery/utils/metadata.py b/gallery/utils/metadata.py new file mode 100644 index 0000000..1b0b0d7 --- /dev/null +++ b/gallery/utils/metadata.py @@ -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}") diff --git a/gallery/utils/processing.py b/gallery/utils/processing.py new file mode 100644 index 0000000..927483c --- /dev/null +++ b/gallery/utils/processing.py @@ -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) diff --git a/gallery/utils/stats.py b/gallery/utils/stats.py new file mode 100644 index 0000000..0af246d --- /dev/null +++ b/gallery/utils/stats.py @@ -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]}" diff --git a/generate_gallery.py b/generate_gallery.py index 10d99d6..6045591 100644 --- a/generate_gallery.py +++ b/generate_gallery.py @@ -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,93 +30,50 @@ 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) + # Handle source override + if source_override: + from pathlib import Path + from gallery.config import GallerySource - gallery_root.mkdir(parents=True, exist_ok=True) - assets_src = Path("assets") - assets_dst = gallery_root.parent / "assets" + source_path = Path(source_override).resolve() - 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") + # 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 - # Determine which sources to process - if source_override: - source_path = Path(source_override).resolve() + # If not in config, create a temporary source entry + if matching_source is None: + source_name = source_path.name + print( + f"Source {source_override} not in config. " + f"Adding temporarily as '{source_name}'" + ) + config.sources = [ + GallerySource(name=source_name, path=source_path) + ] + else: + config.sources = [matching_source] - # 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 + # Generate gallery using the new API + success = generate( + config=config, + clean_first=clean_first, + verbose=True + ) - # If not in config, create a temporary source entry - if matching_source is None: - source_name = source_path.name - msg = ( - f"Source {source_override} not in config. " - f"Adding temporarily as '{source_name}'" - ) - print(msg) - matching_source = GalleryItem(name=source_name, path=source_path) + if not success: + exit(1) - sources_to_process = [matching_source] - else: - sources_to_process = CONFIG.sources - - 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." - ) - - print(f"Processed {source.name}: {source.path}") - - 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__": diff --git a/pyproject.toml b/pyproject.toml index aa4edef..a7c4cbe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" @@ -22,8 +64,4 @@ line_length = 120 [tool.flake8] max-line-length = 120 -extend-ignore = ["E203", "W503"] - -[build-system] -requires = ["setuptools>=61.0"] -build-backend = "setuptools.build_meta" \ No newline at end of file +extend-ignore = ["E203", "W503"] \ No newline at end of file