diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ed8ebf5 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +__pycache__ \ No newline at end of file diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml deleted file mode 100644 index 994d98c..0000000 --- a/.gitlab-ci.yml +++ /dev/null @@ -1,31 +0,0 @@ -# You can override the included template(s) by including variable overrides -# SAST customization: https://docs.gitlab.com/ee/user/application_security/sast/#customizing-the-sast-settings -# Secret Detection customization: https://docs.gitlab.com/user/application_security/secret_detection/pipeline/configure -# Dependency Scanning customization: https://docs.gitlab.com/ee/user/application_security/dependency_scanning/#customizing-the-dependency-scanning-settings -# Container Scanning customization: https://docs.gitlab.com/ee/user/application_security/container_scanning/#customizing-the-container-scanning-settings -# Note that environment variables can be set in several places -# See https://docs.gitlab.com/ee/ci/variables/#cicd-variable-precedence -stages: -- build -- test -- deploy -- review -- dast -- staging -- canary -- production -- incremental rollout 10% -- incremental rollout 25% -- incremental rollout 50% -- incremental rollout 100% -- performance -- cleanup -- secret-detection -sast: - stage: test -include: -- template: Auto-DevOps.gitlab-ci.yml -variables: - SECRET_DETECTION_ENABLED: 'true' -secret_detection: - stage: secret-detection diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..47b059e --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,6 @@ +{ + "flake8.args": [ + "--max-line-length=120", + "--ignore=W293,E123,W503", + ], +} \ No newline at end of file diff --git a/README.md b/README.md index b839b08..3b4f21b 100644 --- a/README.md +++ b/README.md @@ -1,93 +1,141 @@ -# web +# Gallery Application - Restructured +This document explains the new modular structure of the gallery application. - -## Getting started - -To make it easy for you to get started with GitLab, here's a list of recommended next steps. - -Already a pro? Just edit this README.md and make it your own. Want to make it easy? [Use the template at the bottom](#editing-this-readme)! - -## Add your files - -- [ ] [Create](https://docs.gitlab.com/ee/user/project/repository/web_editor.html#create-a-file) or [upload](https://docs.gitlab.com/ee/user/project/repository/web_editor.html#upload-a-file) files -- [ ] [Add files using the command line](https://docs.gitlab.com/topics/git/add_files/#add-files-to-a-git-repository) or push an existing Git repository with the following command: +## Project Structure ``` -cd existing_repo -git remote add origin https://gitlab.etp.kit.edu/kschmidt/web.git -git branch -M main -git push -uf origin main +web/ +├── assets/ +│ ├── css/ +│ │ ├── main.css # Main CSS file (imports all others) +│ │ ├── variables.css # CSS custom properties and themes +│ │ ├── base.css # Base layout and typography +│ │ ├── navigation.css # Breadcrumb and navigation styles +│ │ ├── search.css # Search functionality styles +│ │ ├── folder-tree.css # Folder tree component styles +│ │ ├── grid.css # Plot grid and selection styles +│ │ ├── sidebar.css # Recent plots sidebar styles +│ │ ├── floating-elements.css # Floating buttons and help +│ │ ├── stats.css # Gallery statistics styles +│ │ ├── comparison.css # Plot comparison overlay styles +│ │ └── responsive.css # Mobile and responsive styles +│ └── js/ +│ ├── main.js # Main entry point +│ ├── gallery-app.js # Main application orchestrator +│ ├── theme-manager.js # Theme switching functionality +│ ├── navigation-manager.js # Breadcrumb and folder tree +│ ├── search-manager.js # Search functionality +│ ├── recent-plots-manager.js # Recent plots sidebar +│ ├── comparison-manager.js # Plot comparison features +│ ├── stats-manager.js # Gallery statistics +│ ├── keyboard-manager.js # Keyboard shortcuts +│ └── utils.js # Utility functions +├── templates/ +│ └── gallery.html # Clean HTML template +├── template.html # Original monolithic file (backup) +├── config.py +├── config.yaml +├── generate_gallery.py +└── README.md ``` -## Integrate with your tools +## Key Improvements -- [ ] [Set up project integrations](https://gitlab.etp.kit.edu/kschmidt/web/-/settings/integrations) +### 1. **Separation of Concerns** +- **CSS**: Organized into logical components (navigation, search, grid, etc.) +- **JavaScript**: Split into focused managers with single responsibilities +- **HTML**: Clean template focusing on structure -## Collaborate with your team +### 2. **Modular Architecture** +- Each JavaScript module handles a specific feature area +- Modules can be independently maintained and tested +- Clear dependencies and interfaces between modules -- [ ] [Invite team members and collaborators](https://docs.gitlab.com/ee/user/project/members/) -- [ ] [Create a new merge request](https://docs.gitlab.com/ee/user/project/merge_requests/creating_merge_requests.html) -- [ ] [Automatically close issues from merge requests](https://docs.gitlab.com/ee/user/project/issues/managing_issues.html#closing-issues-automatically) -- [ ] [Enable merge request approvals](https://docs.gitlab.com/ee/user/project/merge_requests/approvals/) -- [ ] [Set auto-merge](https://docs.gitlab.com/user/project/merge_requests/auto_merge/) +### 3. **Maintainability** +- Individual files are much smaller and focused +- Easy to locate and modify specific functionality +- Reduced cognitive load when working on features -## Test and Deploy +### 4. **Development Benefits** +- Better IDE support with syntax highlighting and intellisense +- Easier debugging with source maps +- Ability to add build tools if needed -Use the built-in continuous integration in GitLab. +## Module Responsibilities -- [ ] [Get started with GitLab CI/CD](https://docs.gitlab.com/ee/ci/quick_start/) -- [ ] [Analyze your code for known vulnerabilities with Static Application Security Testing (SAST)](https://docs.gitlab.com/ee/user/application_security/sast/) -- [ ] [Deploy to Kubernetes, Amazon EC2, or Amazon ECS using Auto Deploy](https://docs.gitlab.com/ee/topics/autodevops/requirements.html) -- [ ] [Use pull-based deployments for improved Kubernetes management](https://docs.gitlab.com/ee/user/clusters/agent/) -- [ ] [Set up protected environments](https://docs.gitlab.com/ee/ci/environments/protected_environments.html) +### CSS Modules +- **variables.css**: Theme colors and CSS custom properties +- **base.css**: Typography, basic layout, list styles +- **navigation.css**: Breadcrumb and navigation button styles +- **search.css**: Search box, results, and highlighting +- **folder-tree.css**: Collapsible folder tree display +- **grid.css**: Plot thumbnails grid and selection states +- **sidebar.css**: Recent plots sidebar and overlay +- **floating-elements.css**: Action buttons and keyboard help +- **stats.css**: Gallery statistics display +- **comparison.css**: Plot comparison overlay +- **responsive.css**: Mobile and tablet adaptations -*** +### JavaScript Modules +- **ThemeManager**: Light/dark theme switching and persistence +- **NavigationManager**: Breadcrumb building and folder tree construction +- **SearchManager**: Plot search with debouncing and results display +- **RecentPlotsManager**: Recent plots tracking and sidebar management +- **ComparisonManager**: Plot comparison functionality +- **StatsManager**: Gallery statistics calculation and display +- **KeyboardManager**: Keyboard shortcuts and escape handling +- **Utils**: File size formatting, thumbnail highlighting, gallery refresh -# Editing this README - -When you're ready to make this README your own, just edit this file and use the handy template below (or feel free to structure it however you want - this is just a starting point!). Thanks to [makeareadme.com](https://www.makeareadme.com/) for this template. - -## Suggestions for a good README - -Every project is different, so consider which of these sections apply to yours. The sections used in the template are suggestions for most open source projects. Also keep in mind that while a README can be too long and detailed, too long is better than too short. If you think your README is too long, consider utilizing another form of documentation rather than cutting out information. - -## Name -Choose a self-explaining name for your project. - -## Description -Let people know what your project can do specifically. Provide context and add a link to any reference visitors might be unfamiliar with. A list of Features or a Background subsection can also be added here. If there are alternatives to your project, this is a good place to list differentiating factors. - -## Badges -On some READMEs, you may see small images that convey metadata, such as whether or not all the tests are passing for the project. You can use Shields to add some to your README. Many services also have instructions for adding a badge. - -## Visuals -Depending on what you are making, it can be a good idea to include screenshots or even a video (you'll frequently see GIFs rather than actual videos). Tools like ttygif can help, but check out Asciinema for a more sophisticated method. - -## Installation -Within a particular ecosystem, there may be a common way of installing things, such as using Yarn, NuGet, or Homebrew. However, consider the possibility that whoever is reading your README is a novice and would like more guidance. Listing specific steps helps remove ambiguity and gets people to using your project as quickly as possible. If it only runs in a specific context like a particular programming language version or operating system or has dependencies that have to be installed manually, also add a Requirements subsection. +### Main Application +- **GalleryApp**: Orchestrates all managers and provides unified interface +- **main.js**: Entry point that initializes the application ## Usage -Use examples liberally, and show the expected output if you can. It's helpful to have inline the smallest example of usage that you can demonstrate, while providing links to more sophisticated examples if they are too long to reasonably include in the README. -## Support -Tell people where they can go to for help. It can be any combination of an issue tracker, a chat room, an email address, etc. +The restructured application maintains **full backward compatibility** with the original template. All existing functionality works exactly the same way. -## Roadmap -If you have ideas for releases in the future, it is a good idea to list them in the README. +### For Python Backend +Update your template reference to use the new template: +```python +# Instead of template.html, use: +template_path = 'templates/gallery.html' +``` -## Contributing -State if you are open to contributions and what your requirements are for accepting them. +### CSS Asset Path +The template expects CSS/JS assets to be served from `/assets/` relative to the gallery pages. Update your web server configuration to serve these static files. -For people who want to make changes to your project, it's helpful to have some documentation on how to get started. Perhaps there is a script that they should run or some environment variables that they need to set. Make these steps explicit. These instructions could also be useful to your future self. +### No Breaking Changes +- All onclick handlers work the same +- All CSS classes remain unchanged +- All IDs and functionality preserved +- Jinja2 template variables work identically -You can also document commands to lint the code or run tests. These steps help to ensure high code quality and reduce the likelihood that the changes inadvertently break something. Having instructions for running tests is especially helpful if it requires external setup, such as starting a Selenium server for testing in a browser. +## Development Workflow -## Authors and acknowledgment -Show your appreciation to those who have contributed to the project. +### Adding New Features +1. Identify which manager should handle the new functionality +2. Add methods to the appropriate manager class +3. Update the main GalleryApp class if needed +4. Add any new CSS to the appropriate CSS module -## License -For open source projects, say how it is licensed. +### Modifying Existing Features +1. Locate the relevant manager (theme, search, navigation, etc.) +2. Make changes to the specific module +3. Test that the feature works as expected -## Project status -If you have run out of energy or time for your project, put a note at the top of the README saying that development has slowed down or stopped completely. Someone may choose to fork your project or volunteer to step in as a maintainer or owner, allowing your project to keep going. You can also make an explicit request for maintainers. +### Styling Changes +1. Identify the component being styled +2. Edit the appropriate CSS module +3. The main.css file will automatically include changes + +## Benefits of This Structure + +1. **Easier Debugging**: Each feature is isolated in its own file +2. **Better Performance**: Browser can cache individual modules +3. **Team Development**: Multiple developers can work on different features simultaneously +4. **Code Reuse**: Managers can be reused in other projects +5. **Testing**: Individual modules can be unit tested +6. **Documentation**: Each file has a clear, focused purpose + +This restructuring makes the gallery application much more maintainable while preserving all existing functionality. diff --git a/__pycache__/config.cpython-312.pyc b/__pycache__/config.cpython-312.pyc new file mode 100644 index 0000000..9d7e920 Binary files /dev/null and b/__pycache__/config.cpython-312.pyc differ diff --git a/assets/css/base.css b/assets/css/base.css new file mode 100644 index 0000000..f007e1e --- /dev/null +++ b/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/assets/css/comparison.css b/assets/css/comparison.css new file mode 100644 index 0000000..7e2dee2 --- /dev/null +++ b/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/assets/css/export.css b/assets/css/export.css new file mode 100644 index 0000000..84d7b5f --- /dev/null +++ b/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/assets/css/floating-elements.css b/assets/css/floating-elements.css new file mode 100644 index 0000000..cd8e830 --- /dev/null +++ b/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/assets/css/folder-metadata.css b/assets/css/folder-metadata.css new file mode 100644 index 0000000..8508cf3 --- /dev/null +++ b/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/assets/css/folder-tree.css b/assets/css/folder-tree.css new file mode 100644 index 0000000..3c394ad --- /dev/null +++ b/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/assets/css/grid.css b/assets/css/grid.css new file mode 100644 index 0000000..8ad9828 --- /dev/null +++ b/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/assets/css/main.css b/assets/css/main.css new file mode 100644 index 0000000..c6019d7 --- /dev/null +++ b/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/assets/css/metadata-section.css b/assets/css/metadata-section.css new file mode 100644 index 0000000..7988467 --- /dev/null +++ b/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/assets/css/metadata.css b/assets/css/metadata.css new file mode 100644 index 0000000..872fef6 --- /dev/null +++ b/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/assets/css/navigation.css b/assets/css/navigation.css new file mode 100644 index 0000000..1dc5629 --- /dev/null +++ b/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/assets/css/responsive.css b/assets/css/responsive.css new file mode 100644 index 0000000..1ca81f5 --- /dev/null +++ b/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/assets/css/search.css b/assets/css/search.css new file mode 100644 index 0000000..7bd6995 --- /dev/null +++ b/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/assets/css/sidebar.css b/assets/css/sidebar.css new file mode 100644 index 0000000..7718fb1 --- /dev/null +++ b/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/assets/css/sort-controls.css b/assets/css/sort-controls.css new file mode 100644 index 0000000..3df2439 --- /dev/null +++ b/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/assets/css/sort-debug.css b/assets/css/sort-debug.css new file mode 100644 index 0000000..e69de29 diff --git a/assets/css/stats.css b/assets/css/stats.css new file mode 100644 index 0000000..10868e5 --- /dev/null +++ b/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/assets/css/variables.css b/assets/css/variables.css new file mode 100644 index 0000000..72c531e --- /dev/null +++ b/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/assets/css/view-controls.css b/assets/css/view-controls.css new file mode 100644 index 0000000..11580d8 --- /dev/null +++ b/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/assets/css/view-override.css b/assets/css/view-override.css new file mode 100644 index 0000000..e9bd47c --- /dev/null +++ b/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/assets/js/comparison-manager.js b/assets/js/comparison-manager.js new file mode 100644 index 0000000..7056775 --- /dev/null +++ b/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/assets/js/export-manager.js b/assets/js/export-manager.js new file mode 100644 index 0000000..b60967b --- /dev/null +++ b/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:

+ +
+ +
+

💡 Tips:

+ +
+
+ `; + + 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/assets/js/folder-metadata.js b/assets/js/folder-metadata.js new file mode 100644 index 0000000..86f9db4 --- /dev/null +++ b/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/assets/js/gallery-app.js b/assets/js/gallery-app.js new file mode 100644 index 0000000..849286e --- /dev/null +++ b/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/assets/js/keyboard-manager.js b/assets/js/keyboard-manager.js new file mode 100644 index 0000000..c535a9c --- /dev/null +++ b/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/assets/js/main.js b/assets/js/main.js new file mode 100644 index 0000000..8f924e6 --- /dev/null +++ b/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/assets/js/metadata-popup.js b/assets/js/metadata-popup.js new file mode 100644 index 0000000..085c3a9 --- /dev/null +++ b/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}

+
+
+

No metadata available

+
+ `; + } + + 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 += `
File Information
`; + 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 += `
Timestamps
`; + 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 += `
Plot Details
`; + 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 += `
Additional Information
`; + 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 += `
Generation Info
`; + 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 ` +
+ ${displayKey}: + ${formattedValue} +
+ `; + } +} + +// 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/assets/js/metadata-section.js b/assets/js/metadata-section.js new file mode 100644 index 0000000..b8f2485 --- /dev/null +++ b/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/assets/js/navigation-manager.js b/assets/js/navigation-manager.js new file mode 100644 index 0000000..b9a69d6 --- /dev/null +++ b/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/assets/js/recent-plots-manager.js b/assets/js/recent-plots-manager.js new file mode 100644 index 0000000..29fd3bc --- /dev/null +++ b/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/assets/js/search-manager.js b/assets/js/search-manager.js new file mode 100644 index 0000000..20eff08 --- /dev/null +++ b/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/assets/js/sort-manager.js b/assets/js/sort-manager.js new file mode 100644 index 0000000..8ec9a85 --- /dev/null +++ b/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/assets/js/stats-manager.js b/assets/js/stats-manager.js new file mode 100644 index 0000000..9ad8df5 --- /dev/null +++ b/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/assets/js/theme-manager.js b/assets/js/theme-manager.js new file mode 100644 index 0000000..f23affc --- /dev/null +++ b/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/assets/js/utils.js b/assets/js/utils.js new file mode 100644 index 0000000..3c5b432 --- /dev/null +++ b/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/assets/js/view-manager.js b/assets/js/view-manager.js new file mode 100644 index 0000000..0d51208 --- /dev/null +++ b/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/config.yaml b/config.yaml new file mode 100644 index 0000000..e6c4910 --- /dev/null +++ b/config.yaml @@ -0,0 +1,51 @@ +# Gallery Configuration +# =================== + +# Paths Configuration +paths: + # Working directory where the script runs from + work_dir: "/work/kschmidt/web" + + # Web hosting directory where gallery files are served + web_folder: "/web/kschmidt/public_html/" + + # CGI script path (relative to web folder) + cgi_script: "cgi-bin/refresh_gallery.py" + + # Config file path for CGI scripts + config_path: "/work/kschmidt/web" + +# Gallery Settings +gallery: + # Root folder name for plots in web directory + plot_root: "gallery" + + # PNG conversion quality + png_dpi: 400 + + # Backup folder (leave empty to disable) + backup_folder: "" + +# UI Settings +ui: + # Maximum number of recent plots to track + max_recent_plots: 20 + + # Search settings + search_debounce_ms: 300 + +# Metadata Settings +metadata: + # Enable metadata caching + cache_enabled: true + + # Inherit metadata from parent folders + inherit_from_parent: true + +# Data Sources +# Each source represents a collection of plots to include in the gallery +sources: + - name: "ttbar_analysis" + path: "/work/kschmidt/NEEDLE/test_analysis/data/cf_store/test_analysis/cf.PlotVariables1D/run2_2016_nano_v9/" + - name: "needle_benchmarks" + path: "/work/kschmidt/NEEDLE/orchestrator/ml/benchmarks/plots" diff --git a/docs/SORT_IMPLEMENTATION.md b/docs/SORT_IMPLEMENTATION.md new file mode 100644 index 0000000..b0dae4a --- /dev/null +++ b/docs/SORT_IMPLEMENTATION.md @@ -0,0 +1,136 @@ +# Gallery Sort Functionality Implementation Guide + +## Overview +This guide explains how to integrate the new sorting functionality that allows users to sort plots by name and creation time. + +## Frontend Implementation (Complete ✅) + +The frontend implementation is complete and includes: + +### 1. Sort Controls UI +- **Name/Time buttons**: Toggle between sorting by filename and creation time +- **Order button**: Toggle between ascending (↑) and descending (↓) order +- **Positioned**: Left side of the controls container, next to view toggle buttons +- **Responsive**: Adapts to mobile layouts + +### 2. Keyboard Shortcuts +- `Ctrl+N`: Sort by name +- `Ctrl+M`: Sort by time (modification/creation time) +- `Ctrl+O`: Toggle sort order (ascending/descending) + +### 3. Persistence +- Sort preferences are saved to localStorage +- Settings persist across page reloads and navigation + +### 4. Tile Sizing +- Grid view now shows ~6 plots per row on desktop (240px minimum width) +- Responsive design maintains usability on mobile devices + +## Backend Integration (Required) + +To enable time-based sorting, you need to modify your Python gallery generation code: + +### 1. Add Creation Time to Plot Items + +```python +from pathlib import Path + +def add_creation_time_to_items(items, base_path): + """Add creation time to plot items for sorting functionality.""" + for item in items: + try: + # Get creation time from PNG or PDF file + png_path = None + pdf_path = None + + if 'png_href' in item: + png_rel_path = item['png_href'].replace('../', '').replace('./', '') + png_path = Path(base_path) / png_rel_path + + if 'pdf_href' in item: + pdf_rel_path = item['pdf_href'].replace('../', '').replace('./', '') + pdf_path = Path(base_path) / pdf_rel_path + + # Use PNG creation time if available, otherwise PDF + creation_time = 0 + if png_path and png_path.exists(): + creation_time = int(png_path.stat().st_ctime) + elif pdf_path and pdf_path.exists(): + creation_time = int(pdf_path.stat().st_ctime) + + item['creation_time'] = creation_time + + except Exception as e: + print(f"Warning: Could not get creation time for {item.get('name', 'unknown')}: {e}") + item['creation_time'] = 0 + + return items +``` + +### 2. Integrate into Your Gallery Generation + +In your existing gallery generation code, call this function before rendering the template: + +```python +# Your existing code that creates the items list +items = generate_plot_items() # Your existing function + +# Add creation times +items = add_creation_time_to_items(items, gallery_base_path) + +# Pass to template +template.render(items=items, ...) +``` + +### 3. Template Data Structure + +The template now expects each item to have a `creation_time` field: + +```python +item = { + 'name': 'plot_name.png', + 'png_href': './plot_name.png', + 'pdf_href': './plot_name.pdf', + 'creation_time': 1642723200 # Unix timestamp +} +``` + +## File Locations + +### Frontend Files (Ready to use) +- `templates/gallery.html` - Updated with sort controls and data attributes +- `assets/css/view-controls.css` - Styling for sort and view controls +- `assets/css/view-override.css` - Grid layout with larger tiles +- `assets/js/sort-manager.js` - Sort functionality implementation +- `assets/js/gallery-app.js` - Integration of SortManager +- `assets/js/keyboard-manager.js` - Keyboard shortcuts for sorting + +### Backend Integration +- `python/add_creation_time.py` - Example implementation for adding creation times + +## Features Summary + +### ✅ Completed Features +1. **Larger Grid Tiles**: ~6 plots per row instead of 8 +2. **Sort Controls**: Name and time sorting with visual feedback +3. **Sort Order Toggle**: Ascending/descending with visual indicator +4. **Keyboard Shortcuts**: Quick access to all sort functions +5. **Persistence**: Settings saved across sessions +6. **Responsive Design**: Works on all screen sizes +7. **Template Integration**: Data attributes ready for backend + +### 🔄 Next Steps (Backend Integration) +1. Modify your Python gallery generation code to include `creation_time` +2. Use the provided `add_creation_time_to_items()` function +3. Test with real plot files to ensure timestamps are correct + +## Testing + +After backend integration: +1. Navigate to a gallery with multiple plots +2. Click the sort buttons to verify functionality +3. Use keyboard shortcuts to test responsiveness +4. Check that sort order toggles correctly +5. Verify settings persist after page reload + +The frontend is fully functional and will work immediately once the backend provides the `creation_time` data. diff --git a/generate_gallery.py b/generate_gallery.py new file mode 100644 index 0000000..d12191f --- /dev/null +++ b/generate_gallery.py @@ -0,0 +1,448 @@ +""" +Scientific Gallery Generator + +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. + +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 +""" + +import subprocess +import shutil +import os +import sys +from pathlib import Path +from typing import Dict, Any, Optional +from datetime import datetime +from jinja2 import Environment, FileSystemLoader + +from orchestration.config import Config +from orchestration.metadata import ( + load_folder_metadata, + merge_metadata, + resolve_metadata_for_plot, + save_metadata_cache, + get_metadata_file_path +) + + +CONFIG = Config.from_yaml("config.yaml") + + +def datetime_from_timestamp(timestamp): + """Convert a Unix timestamp to a datetime object.""" + return datetime.fromtimestamp(timestamp) + + +def strftime_filter(dt, fmt): + """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 convert_pdf_to_png(pdf_path: Path) -> 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 + + 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 + else: + print(f"PDF {pdf_path.name} is newer than PNG, reconverting...") + + print(f"Converting\n\t{pdf_path}\n → {png_path}") + 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) + + +def build_gallery(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. 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 = {} + + # Load folder-level metadata and merge with inherited metadata + folder_metadata = load_folder_metadata(source_dir) + current_metadata = merge_metadata(inherited_metadata, folder_metadata) + + pdf_files = list(source_dir.glob("*.pdf")) + + subdirs = [d for d in source_dir.iterdir() if d.is_dir()] + + items = [] + plot_metadata_cache = {} + + for pdf_file in pdf_files: + png_file = pdf_file.with_suffix(".png") + + web_pdf = web_dir / pdf_file.name + web_png = web_dir / png_file.name + + if needs_update(pdf_file, web_pdf): + print(f"Copying\n\t{pdf_file}\n → {web_pdf}") + shutil.copy2(pdf_file, web_pdf) + else: + print(f"Skipping {pdf_file.name} (up to date)") + + if not png_file.exists(): + convert_pdf_to_png(pdf_file) + + if needs_update(png_file, web_png): + print(f"Copying {png_file} to {web_png}") + shutil.copy2(png_file, web_png) + else: + print(f"Skipping {png_file.name} (up to date)") + + # Resolve metadata for this specific plot + plot_metadata = resolve_metadata_for_plot(pdf_file, current_metadata) + plot_metadata_cache[pdf_file.stem] = plot_metadata + + # Get source file creation time (in seconds since epoch) + source_creation_time = int(pdf_file.stat().st_ctime) + + items.append({ + "name": pdf_file.stem, + "pdf_href": pdf_file.name, + "png_href": png_file.name, + "metadata": plot_metadata, + "creation_time": source_creation_time + }) + + # Save metadata cache for this directory + save_metadata_cache(web_dir, plot_metadata_cache) + + subdir_names = [] + for subdir in subdirs: + subdir_web = web_dir / subdir.name + subdir_web.mkdir(exist_ok=True) + subdir_relative = relative_path / subdir.name + # Pass current metadata to subdirectories + build_gallery(subdir, subdir_web, subdir_relative, current_metadata) + subdir_names.append(subdir.name) + + output_html = web_dir / "index.html" + + # Always regenerate HTML to ensure subdirectory changes are reflected + # This ensures new subdirectories appear in navigation + force_regeneration = True + if output_html.exists() and not force_regeneration: + html_mtime = output_html.stat().st_mtime + # Check if any subdirectory is newer than the HTML file + for subdir in subdirs: + if subdir.stat().st_mtime > html_mtime: + print(f"Subdirectory {subdir.name} is newer, forcing regeneration") + break + + if relative_path == Path("."): + title = "Gallery" + else: + title = f"Gallery: {relative_path}" + + # Calculate statistics for current directory + current_stats = calculate_directory_stats(web_dir) + stats = { + "file_count": len(items), + "folder_count": len(subdir_names), + "total_size": format_file_size(current_stats["total_size"]), + "total_size_bytes": current_stats["total_size"] + } + + # Calculate relative path to assets based on directory depth + if relative_path == Path("."): + assets_path = "../assets" + else: + # Count directory levels to go back to gallery, then to public_html + depth = len(relative_path.parts) + assets_path = "../" * (depth + 1) + "assets" + + with output_html.open("w") as f: + rendered_html = template.render( + title=title, + items=items, + subdirs=subdir_names, + relpath=str(relative_path), + paths=CONFIG.paths, + ui=CONFIG.ui, + stats=stats, + folder_metadata=current_metadata, + assets_path=assets_path, + source_dir=str(source_dir), + metadata_file_path=get_metadata_file_path(source_dir) + ) + f.write(rendered_html) + + print(f"Generated {output_html}") + + +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]}" + + +def refresh_gallery_cgi(): + """ + CGI handler to refresh the gallery from a web request. + Outputs a minimal HTTP response and triggers gallery regeneration. + """ + import traceback + print("Content-Type: text/plain\n") + try: + main() + print("Gallery refreshed successfully.") + except Exception as e: + print(f"Error refreshing gallery: {e}") + traceback.print_exc(file=sys.stdout) + + +def main(clean_first: bool = False) -> None: + """ + Main entry point for gallery generation. + + Args: + clean_first: If True, removes and recreates the gallery directory + + 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 + + if clean_first and gallery_root.exists(): + print(f"Cleaning gallery directory {gallery_root}...") + shutil.rmtree(gallery_root) + + # Ensure gallery root exists + gallery_root.mkdir(parents=True, exist_ok=True) + + # Always ensure assets are up to date + assets_src = Path("assets") + assets_dst = gallery_root.parent / "assets" + + if assets_src.exists(): + # Update assets if they don't exist or are outdated + 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") + + for source in CONFIG.sources: + source_path = Path(source.path) + + if source_path.is_file() and source_path.suffix == '.pdf': + source_web_dir = gallery_root / source.name + source_web_dir.mkdir(parents=True, exist_ok=True) + + pdf_name = source_path.name + png_name = source_path.with_suffix('.png').name + + web_pdf_path = source_web_dir / pdf_name + web_png_path = source_web_dir / png_name + source_png_path = source_path.with_suffix('.png') + + if needs_update(source_path, web_pdf_path): + print(f"Copying {source_path} to {web_pdf_path}") + shutil.copy2(source_path, web_pdf_path) + else: + print(f"Skipping {source_path.name} (up to date)") + + if not source_png_path.exists(): + convert_pdf_to_png(source_path) + + if needs_update(source_png_path, web_png_path): + print(f"Copying {source_png_path} to {web_png_path}") + shutil.copy2(source_png_path, web_png_path) + else: + print(f"Skipping {source_png_path.name} (up to date)") + + # Get source file creation time for single file + source_creation_time = int(source_path.stat().st_ctime) + + items = [{ + "name": source_path.stem, + "pdf_href": pdf_name, + "png_href": png_name, + "creation_time": source_creation_time + }] + + # Calculate statistics for single file + current_stats = calculate_directory_stats(source_web_dir) + stats = { + "file_count": 1, + "folder_count": 0, + "total_size": format_file_size(current_stats["total_size"]), + "total_size_bytes": current_stats["total_size"] + } + + # Calculate relative path to assets for single file + # Single files are at depth 1 (gallery_root/source.name/index.html) + assets_path = "../assets" + + output_html = source_web_dir / "index.html" + with output_html.open("w") as f: + f.write(template.render( + title=source.name, + items=items, + subdirs=[], + relpath=source.name, + paths=CONFIG.paths, + ui=CONFIG.ui, + stats=stats, + folder_metadata={}, + assets_path=assets_path, + source_dir=str(source_path.parent), + metadata_file_path=get_metadata_file_path(source_path.parent) + )) + + print(f"Generated {output_html}") + print(f"Processed {source.name}: {source.path}") + + elif source_path.is_dir(): + source_web_dir = gallery_root / source.name + source_web_dir.mkdir(parents=True, exist_ok=True) + build_gallery(source_path, source_web_dir, Path(source.name)) + print(f"Processed {source.name}: {source.path}") + else: + print(f"Warning: Source {source.path} is neither a " + f"directory nor a PDF file") + + print("Done") + + +if __name__ == "__main__": + if 'GATEWAY_INTERFACE' in os.environ: + refresh_gallery_cgi() + else: + import argparse + + parser = argparse.ArgumentParser(description='Generate gallery') + parser.add_argument( + '--clean', + action='store_true', + help='Clean gallery directory before generation' + ) + args = parser.parse_args() + main(clean_first=args.clean) diff --git a/orchestration/backup.py b/orchestration/backup.py new file mode 100644 index 0000000..d1937a4 --- /dev/null +++ b/orchestration/backup.py @@ -0,0 +1,23 @@ +import zipfile +import datetime +from pathlib import Path + +WEB_FOLDER = Path("plots") +BACKUP_FOLDER = Path("backups") + +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(): + print(f"Backup already exists: {backup_path}") +else: + print(f"Creating backup: {backup_path}") + 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) + print("✅ Backup complete.") diff --git a/orchestration/config.py b/orchestration/config.py new file mode 100644 index 0000000..9672315 --- /dev/null +++ b/orchestration/config.py @@ -0,0 +1,147 @@ +""" +Scientific Gallery Configuration Management + +This module provides dataclasses and utilities for managing configuration +of the scientific gallery system, including paths, gallery settings, +UI preferences, and data sources. +""" + +from dataclasses import dataclass, field, asdict +from pathlib import Path +import yaml + + +@dataclass +class PathConfig: + """Configuration for system paths and directories.""" + work_dir: str + web_folder: str + cgi_script: str + config_path: str + + +@dataclass +class GalleryConfig: + """Configuration for gallery generation and display settings.""" + plot_root: str + png_dpi: int + backup_folder: str + + +@dataclass +class UIConfig: + """Configuration for user interface behavior and preferences.""" + max_recent_plots: int + search_debounce_ms: int + + +@dataclass +class MetadataConfig: + """Configuration for metadata handling.""" + cache_enabled: bool = True + inherit_from_parent: bool = True + supported_formats: list[str] = field( + default_factory=lambda: ['.yaml', '.yml', '.json'] + ) + + +@dataclass +class GalleryItem: + """Represents a single data source for the gallery.""" + name: str + path: Path + + +@dataclass +class Config: + """ + Main configuration class that aggregates all gallery settings. + + Provides backward compatibility properties and methods for loading + configuration from YAML files. + """ + paths: PathConfig + gallery: GalleryConfig + ui: UIConfig + metadata: MetadataConfig + sources: list[GalleryItem] = field(default_factory=list) + + @property + def web_folder(self): + """Backward compatibility property for web folder path.""" + return self.paths.web_folder + + @property + def png_dpi(self): + """Backward compatibility property for PNG conversion DPI.""" + return self.gallery.png_dpi + + @property + def plot_root(self): + """Backward compatibility property for plot root directory.""" + return self.gallery.plot_root + + @property + def backup_folder(self): + """Backward compatibility property for backup folder path.""" + return self.gallery.backup_folder + + @classmethod + def from_yaml(cls, yaml_file: str) -> "Config": + """ + Load configuration from a YAML file. + + Args: + yaml_file: Path to the YAML configuration file + + Returns: + Config instance with loaded settings + + Raises: + FileNotFoundError: If the YAML file doesn't exist + yaml.YAMLError: If the YAML file is malformed + """ + with open(yaml_file, "r") as f: + data = yaml.safe_load(f) + + paths_data = data.get('paths', {}) + gallery_data = data.get('gallery', {}) + ui_data = data.get('ui', {}) + metadata_data = data.get('metadata', {}) + sources_data = data.get('sources', []) + + paths = PathConfig(**paths_data) + gallery = GalleryConfig(**gallery_data) + ui = UIConfig(**ui_data) + metadata = MetadataConfig(**metadata_data) + + sources = [ + GalleryItem(name=source["name"], path=Path(source["path"])) + for source in sources_data + ] + + return cls( + paths=paths, + gallery=gallery, + ui=ui, + metadata=metadata, + sources=sources + ) + + def to_yaml(self, yaml_file: str) -> 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 + """ + with open(yaml_file, "w") as f: + yaml.dump(asdict(self), f, default_flow_style=False) + + +if __name__ == "__main__": + config = Config.from_yaml("config.yaml") + print("Loaded config successfully:", config) diff --git a/orchestration/metadata.py b/orchestration/metadata.py new file mode 100644 index 0000000..00bf445 --- /dev/null +++ b/orchestration/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 (preferred format). + + 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 + values. + + 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 by merging 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) + print(f"Saved metadata cache: {cache_path}") + except IOError as e: + print(f"Warning: Could not save metadata cache {cache_path}: {e}") diff --git a/orchestration/pdf_export.py b/orchestration/pdf_export.py new file mode 100644 index 0000000..51df501 --- /dev/null +++ b/orchestration/pdf_export.py @@ -0,0 +1,205 @@ +""" +PDF Export Module for Scientific Gallery Generator + +This module handles exporting and merging multiple plots into a single PDF +using PyPDF2 and reportlab for layout. +""" + +import io +import tempfile +from pathlib import Path +from typing import List, Dict, Any +import subprocess + +try: + from PyPDF2 import PdfReader, PdfWriter + from reportlab.pdfgen import canvas + from reportlab.lib.pagesizes import letter, A4 + from reportlab.lib.utils import ImageReader + HAS_PDF_LIBS = True +except ImportError: + HAS_PDF_LIBS = False + + +class PDFExporter: + """Handles exporting multiple plots to a merged PDF""" + + def __init__(self): + self.page_size = A4 + self.margin = 50 + + def merge_plots(self, plot_paths: List[str], layout: Dict[str, int], + output_path: str = None) -> bytes: + """ + Merge multiple PDF plots into a single PDF with grid layout. + + Args: + plot_paths: List of paths to PDF files + layout: Dictionary with 'rows' and 'cols' keys + output_path: Optional output file path + + Returns: + PDF bytes + """ + if not HAS_PDF_LIBS: + return self._merge_with_pdfjam(plot_paths, layout, output_path) + + return self._merge_with_pypdf(plot_paths, layout, output_path) + + def _merge_with_pypdf(self, plot_paths: List[str], layout: Dict[str, int], + output_path: str = None) -> bytes: + """Merge PDFs using PyPDF2 and reportlab""" + from reportlab.pdfgen import canvas + from reportlab.lib.pagesizes import A4 + + # Create a new PDF with the layout + buffer = io.BytesIO() + c = canvas.Canvas(buffer, pagesize=A4) + page_width, page_height = A4 + + rows = layout['rows'] + cols = layout['cols'] + + # Calculate dimensions for each plot + plot_width = (page_width - 2 * self.margin) / cols + plot_height = (page_height - 2 * self.margin) / rows + + # Place each plot in the grid + for i, plot_path in enumerate(plot_paths): + if i >= rows * cols: + break + + row = i // cols + col = i % cols + + # Calculate position + x = self.margin + col * plot_width + y = page_height - self.margin - (row + 1) * plot_height + + try: + # Read the source PDF + with open(plot_path, 'rb') as f: + reader = PdfReader(f) + if len(reader.pages) > 0: + page = reader.pages[0] + + # Convert PDF page to image and place it + # This is a simplified approach - in practice you'd want + # to properly scale and position the PDF content + self._draw_pdf_placeholder(c, x, y, plot_width, plot_height, + Path(plot_path).stem) + + except Exception as e: + print(f"Error processing {plot_path}: {e}") + self._draw_error_placeholder(c, x, y, plot_width, plot_height) + + c.save() + + if output_path: + with open(output_path, 'wb') as f: + f.write(buffer.getvalue()) + + return buffer.getvalue() + + def _merge_with_pdfjam(self, plot_paths: List[str], layout: Dict[str, int], + output_path: str = None) -> bytes: + """Merge PDFs using pdfjam (requires pdfpages LaTeX package)""" + rows = layout['rows'] + cols = layout['cols'] + + # Create temporary output file + with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as tmp_file: + temp_output = tmp_file.name + + try: + # Build pdfjam command + cmd = [ + 'pdfjam', + '--nup', f'{cols}x{rows}', + '--landscape' if cols > rows else '--no-landscape', + '--frame', 'true', + '--delta', '10pt 10pt', + '--offset', '0pt 0pt', + '--outfile', temp_output + ] + + # Add input files + cmd.extend(plot_paths[:rows * cols]) + + # Run pdfjam + result = subprocess.run(cmd, capture_output=True, text=True) + + if result.returncode != 0: + raise Exception(f"pdfjam failed: {result.stderr}") + + # Read the output file + with open(temp_output, 'rb') as f: + pdf_bytes = f.read() + + if output_path: + with open(output_path, 'wb') as f: + f.write(pdf_bytes) + + return pdf_bytes + + finally: + # Clean up temporary file + Path(temp_output).unlink(missing_ok=True) + + def _draw_pdf_placeholder(self, canvas, x: float, y: float, width: float, + height: float, plot_name: str): + """Draw a placeholder for a PDF plot""" + # Draw border + canvas.setStrokeColorRGB(0.5, 0.5, 0.5) + canvas.setLineWidth(1) + canvas.rect(x, y, width, height) + + # Draw plot name + canvas.setFillColorRGB(0, 0, 0) + canvas.setFont("Helvetica", 10) + text_width = canvas.stringWidth(plot_name, "Helvetica", 10) + text_x = x + (width - text_width) / 2 + text_y = y + height / 2 + canvas.drawString(text_x, text_y, plot_name) + + def _draw_error_placeholder(self, canvas, x: float, y: float, width: float, + height: float): + """Draw an error placeholder""" + # Draw red border + canvas.setStrokeColorRGB(1, 0, 0) + canvas.setLineWidth(2) + canvas.rect(x, y, width, height) + + # Draw error text + canvas.setFillColorRGB(1, 0, 0) + canvas.setFont("Helvetica-Bold", 12) + error_text = "Error loading plot" + text_width = canvas.stringWidth(error_text, "Helvetica-Bold", 12) + text_x = x + (width - text_width) / 2 + text_y = y + height / 2 + canvas.drawString(text_x, text_y, error_text) + + +def check_dependencies() -> Dict[str, bool]: + """Check if required dependencies are available""" + deps = { + 'pypdf2': HAS_PDF_LIBS, + 'pdfjam': False + } + + # Check for pdfjam + try: + result = subprocess.run(['pdfjam', '--version'], + capture_output=True, text=True) + deps['pdfjam'] = result.returncode == 0 + except FileNotFoundError: + pass + + return deps + + +if __name__ == "__main__": + # Test the exporter + exporter = PDFExporter() + deps = check_dependencies() + print("Available dependencies:", deps) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..b519773 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,25 @@ +[project] +name = "plot-gallery" +version = "0.1.0" +description = "Host your plots on a personal website" +authors = [ + { name = "K. Schmidt" } +] +readme = "README.md" +requires-python = ">=3.9" + +[tool.black] +line-length = 120 +target-version = ['py39'] + +[tool.isort] +profile = "black" +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 diff --git a/python/add_creation_time.py b/python/add_creation_time.py new file mode 100644 index 0000000..135d807 --- /dev/null +++ b/python/add_creation_time.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 +""" +Gallery Creation Time Integration +Example function to add creation time metadata to plot items. +This should be integrated into your existing Python gallery generation code. +""" + +from pathlib import Path + + +def add_creation_time_to_items(items, base_path): + """ + Add creation time to plot items for sorting functionality. + + Args: + items: List of plot item dictionaries + base_path: Base path where plot files are located + + Returns: + Updated items list with creation_time field + """ + for item in items: + try: + # Try to get creation time from PNG file first, then PDF + png_path = None + pdf_path = None + + # Extract relative path from href + if 'png_href' in item: + png_rel_path = item['png_href'].replace('../', '').replace( + './', '') + png_path = Path(base_path) / png_rel_path + + if 'pdf_href' in item: + pdf_rel_path = item['pdf_href'].replace('../', '').replace( + './', '') + pdf_path = Path(base_path) / pdf_rel_path + + # Use PNG creation time if available, otherwise PDF + creation_time = 0 + if png_path and png_path.exists(): + creation_time = int(png_path.stat().st_ctime) + elif pdf_path and pdf_path.exists(): + creation_time = int(pdf_path.stat().st_ctime) + + # Add creation time as timestamp (JavaScript can handle this) + item['creation_time'] = creation_time + + except Exception as e: + # Fallback to 0 if there's any error + name = item.get('name', 'unknown') + print(f"Warning: Could not get creation time for {name}: {e}") + item['creation_time'] = 0 + + return items + + +def example_integration(): + """ + Example of how to integrate this into your existing gallery generation. + """ + # This would be part of your existing gallery generation code + items = [ + { + 'name': 'plot1.png', + 'png_href': './plot1.png', + 'pdf_href': './plot1.pdf' + }, + { + 'name': 'plot2.png', + 'png_href': './plot2.png', + 'pdf_href': './plot2.pdf' + } + ] + + base_path = "/path/to/your/gallery/directory" + + # Add creation times + items_with_time = add_creation_time_to_items(items, base_path) + + # Now items_with_time can be passed to your Jinja2 template + # The template will have access to item.creation_time for each item + + return items_with_time + + +if __name__ == "__main__": + # Test the function + items = example_integration() + for item in items: + created = item['creation_time'] + print(f"Plot: {item['name']}, Created: {created}") diff --git a/python/add_metadata.py b/python/add_metadata.py new file mode 100644 index 0000000..cb2d5ef --- /dev/null +++ b/python/add_metadata.py @@ -0,0 +1,45 @@ +from typing import Dict, Any +import json +from pathlib import Path + + +def open_metadata(path: str, filename: str = "metadata.json") -> Dict[str, Any]: + """ + Open metadata file and return its contents as a dictionary. + + Args: + path: The directory path where the metadata file is located. + filename: The name of the metadata file (default: "metadata.json"). + + Returns: + A dictionary containing the metadata. + """ + with open(Path(path) / filename, 'r', encoding='utf-8') as f: + try: + return json.load(f) + except json.JSONDecodeError as e: + raise ValueError(f"Error decoding JSON from {filename}: {e}") + except FileNotFoundError: + raise FileNotFoundError(f"Metadata file {filename} not found in {path}") + except Exception as e: + raise RuntimeError(f"Unexpected error reading metadata: {e}") + + +def merge_metadata( + base_metadata: Dict[str, Any], + additional_metadata: Dict[str, Any] +) -> Dict[str, Any]: + """ + Merge two metadata dictionaries. + + Args: + base_metadata: The base metadata dictionary. + additional_metadata: The additional metadata dictionary to merge. + + Returns: + A new dictionary containing the merged metadata. + """ + merged = base_metadata.copy() + for key, value in additional_metadata.items(): + merged[key] = value + return merged diff --git a/templates/gallery.html b/templates/gallery.html new file mode 100644 index 0000000..35c7ea9 --- /dev/null +++ b/templates/gallery.html @@ -0,0 +1,363 @@ + + + + + {{ title }} + + + + + + + + +

{{ title }}

+ + +
+ + 🔍 +
+
+ + + + + + + + +
+ + + {% if folder_metadata %} +
+ + + +
+ {% endif %} + + + {% if items %} +
+
+ + + + +
+
+ + + +
+
+ {% endif %} + + +
+ {% for item in items %} +
+ + {{ item.name }} + +
+
{{ 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/test.ipynb b/test.ipynb new file mode 100644 index 0000000..10fe056 --- /dev/null +++ b/test.ipynb @@ -0,0 +1,235 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 2, + "id": "aedbb9f5", + "metadata": {}, + "outputs": [], + "source": [ + "from dataclasses import dataclass, field, asdict\n", + "from pathlib import Path\n", + "\n", + "import yaml\n", + "\n", + "\n", + "@dataclass\n", + "class GalleryItem:\n", + " name: str\n", + " path: Path\n" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "66624faf", + "metadata": {}, + "outputs": [], + "source": [ + "yaml_file = Path(\"config.yaml\")\n", + "\n", + "with open(yaml_file, \"r\") as f:\n", + " new_config: dict = yaml.safe_load(f)" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "40a472f8", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "{'name': 'test_1_plot',\n", + " 'path': 'data/cf_store/test_analysis/cf.PlotVariables1D/run2_2016_nano_v9/calib__test/sel__test/prod__test/weight__test/nominal/datasets_ttbar_dl/dev1/plot__proc_3_5606769559__cat_incl__var_jet1_pt.pdf'}" + ] + }, + "execution_count": 8, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "new_config[\"sources\"][0]" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "745c97ad", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "[GalleryItem(name='test_1_plot', path=PosixPath('data/cf_store/test_analysis/cf.PlotVariables1D/run2_2016_nano_v9/calib__test/sel__test/prod__test/weight__test/nominal/datasets_ttbar_dl/dev1/plot__proc_3_5606769559__cat_incl__var_jet1_pt.pdf')),\n", + " GalleryItem(name='test_2_dir', path=PosixPath('data/cf_store/test_analysis/cf.PlotVariables1D/run2_2016_nano_v9/calib__test/sel__test/prod__test/weight__test/nominal/datasets_ttbar_dl/dev1'))]" + ] + }, + "execution_count": 10, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "sources = [\n", + " GalleryItem(name=src[\"name\"], path=Path(src[\"path\"]))\n", + " for src in new_config.get(\"sources\", False)\n", + "]\n", + "sources" + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "id": "a0102f9d", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "Config(web_folder='', backup_folder='', png_dpi=400, plot_root='gallery', sources=[{'name': 'test_1_plot', 'path': 'data/cf_store/test_analysis/cf.PlotVariables1D/run2_2016_nano_v9/calib__test/sel__test/prod__test/weight__test/nominal/datasets_ttbar_dl/dev1/plot__proc_3_5606769559__cat_incl__var_jet1_pt.pdf'}, {'name': 'test_2_dir', 'path': 'data/cf_store/test_analysis/cf.PlotVariables1D/run2_2016_nano_v9/calib__test/sel__test/prod__test/weight__test/nominal/datasets_ttbar_dl/dev1/'}])" + ] + }, + "execution_count": 15, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "\n", + "\n", + "@dataclass\n", + "class Config:\n", + " web_folder: str = \"\"\n", + " backup_folder: str = \"\"\n", + " png_dpi: int = 400\n", + " plot_root: str = \"gallery\"\n", + " sources: list[GalleryItem] = field(default_factory=list)\n", + "\n", + " @classmethod\n", + " def from_yaml(cls, yaml_file: str, strict: bool = False) -> \"Config\":\n", + " \"\"\"\n", + " Load configuration from a YAML file.\n", + "\n", + " Args:\n", + " yaml_file (str): Path to the YAML file.\n", + " strict (bool):\n", + " If True, raises an error if a key in the YAML file does not exist in the Config class.\n", + " If False (default), adds all keys as attributes\n", + "\n", + " Returns:\n", + " Config: Instance of this class\n", + " \"\"\"\n", + "\n", + " with open(yaml_file, \"r\") as f:\n", + " new_config: dict = yaml.safe_load(f)\n", + "\n", + " new_config[\"sources\"] = [\n", + " GalleryItem(name=src[\"name\"], path=Path(src[\"path\"]))\n", + " for src in new_config.get(\"sources\", False)\n", + " ]\n", + "\n", + " instance = cls(**{\n", + " k: v\n", + " for k, v in new_config.items()\n", + " if hasattr(cls, k) or not strict\n", + " })\n", + "\n", + " for key in new_config.keys():\n", + " if strict and not hasattr(instance, key):\n", + " raise KeyError(f\"Key '{key}' not found in Config class\")\n", + "\n", + " return instance\n", + "\n", + " def to_yaml(self, yaml_file: str) -> None:\n", + " \"\"\"\n", + " Save the current configuration to a YAML file.\n", + "\n", + " Args:\n", + " yaml_file (str): Path to the YAML file.\n", + " \"\"\"\n", + " with open(yaml_file, \"w\") as f:\n", + " yaml.dump(asdict(self), f, default_flow_style=False)\n", + "\n", + "\n", + "instance = Config(**{\n", + " k: v\n", + " for k, v in new_config.items()\n", + " #if hasattr(Config, k)\n", + "})\n", + "instance" + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "id": "0b516ae9", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "[{'name': 'test_1_plot',\n", + " 'path': 'data/cf_store/test_analysis/cf.PlotVariables1D/run2_2016_nano_v9/calib__test/sel__test/prod__test/weight__test/nominal/datasets_ttbar_dl/dev1/plot__proc_3_5606769559__cat_incl__var_jet1_pt.pdf'},\n", + " {'name': 'test_2_dir',\n", + " 'path': 'data/cf_store/test_analysis/cf.PlotVariables1D/run2_2016_nano_v9/calib__test/sel__test/prod__test/weight__test/nominal/datasets_ttbar_dl/dev1/'}]" + ] + }, + "execution_count": 17, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "new_config[\"sources\"]" + ] + }, + { + "cell_type": "code", + "execution_count": 18, + "id": "b047f0c5", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "[{'name': 'test_1_plot',\n", + " 'path': 'data/cf_store/test_analysis/cf.PlotVariables1D/run2_2016_nano_v9/calib__test/sel__test/prod__test/weight__test/nominal/datasets_ttbar_dl/dev1/plot__proc_3_5606769559__cat_incl__var_jet1_pt.pdf'},\n", + " {'name': 'test_2_dir',\n", + " 'path': 'data/cf_store/test_analysis/cf.PlotVariables1D/run2_2016_nano_v9/calib__test/sel__test/prod__test/weight__test/nominal/datasets_ttbar_dl/dev1/'}]" + ] + }, + "execution_count": 18, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "instance.sources" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.1" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/tests/validate_metadata.py b/tests/validate_metadata.py new file mode 100644 index 0000000..3f21ebe --- /dev/null +++ b/tests/validate_metadata.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +""" +Metadata Validation Utility + +This script validates metadata files in the gallery source directories, +checking for proper YAML/JSON syntax and common field validation. +""" + +import sys +import json +import yaml +from pathlib import Path +from typing import Dict, Any, List + + +def validate_metadata_file(file_path: Path) -> tuple[bool, List[str]]: + """ + Validate a single metadata file. + + Args: + file_path: Path to the metadata file + + Returns: + Tuple of (is_valid, error_messages) + """ + errors = [] + + if not file_path.exists(): + errors.append(f"File does not exist: {file_path}") + return False, errors + + try: + with file_path.open('r', encoding='utf-8') as f: + suffix_lower = file_path.suffix.lower() + if suffix_lower in ['.yaml', '.yml']: + data = yaml.safe_load(f) + elif suffix_lower == '.json': + data = json.load(f) + else: + errors.append(f"Unsupported file format: {file_path}") + return False, errors + + if data is None: + errors.append(f"Empty metadata file: {file_path}") + return False, errors + + # Basic validation + if not isinstance(data, dict): + errors.append(f"Metadata must be a dictionary: {file_path}") + return False, errors + + # Check for common issues + if 'title' in data and not isinstance(data['title'], str): + errors.append(f"Title must be a string: {file_path}") + + if 'tags' in data and not isinstance(data['tags'], list): + errors.append(f"Tags must be a list: {file_path}") + + if 'author' in data and not isinstance(data['author'], dict): + errors.append(f"Author must be a dictionary: {file_path}") + + except (yaml.YAMLError, json.JSONDecodeError) as e: + errors.append(f"Parse error in {file_path}: {e}") + return False, errors + except Exception as e: + errors.append(f"Unexpected error reading {file_path}: {e}") + return False, errors + + return len(errors) == 0, errors + + +def find_metadata_files(root_dir: Path) -> List[Path]: + """ + Find all metadata files in a directory tree. + + Args: + root_dir: Root directory to search + + Returns: + List of metadata file paths + """ + metadata_files = [] + + for pattern in ['**/*.yaml', '**/*.yml', '**/*.json']: + for file_path in root_dir.glob(pattern): + if file_path.name.startswith('meta.') or file_path.stem != file_path.name: + metadata_files.append(file_path) + + return metadata_files + + +def main(): + """Main validation function.""" + if len(sys.argv) != 2: + print("Usage: python validate_metadata.py ") + sys.exit(1) + + root_dir = Path(sys.argv[1]) + + if not root_dir.exists(): + print(f"Error: Directory does not exist: {root_dir}") + sys.exit(1) + + if not root_dir.is_dir(): + print(f"Error: Not a directory: {root_dir}") + sys.exit(1) + + print(f"Validating metadata files in: {root_dir}") + print("-" * 50) + + metadata_files = find_metadata_files(root_dir) + + if not metadata_files: + print("No metadata files found.") + return + + total_files = len(metadata_files) + valid_files = 0 + + for file_path in metadata_files: + is_valid, errors = validate_metadata_file(file_path) + + if is_valid: + print(f"✓ {file_path.relative_to(root_dir)}") + valid_files += 1 + else: + print(f"✗ {file_path.relative_to(root_dir)}") + for error in errors: + print(f" - {error}") + + print("-" * 50) + print(f"Summary: {valid_files}/{total_files} files valid") + + if valid_files != total_files: + sys.exit(1) + + +if __name__ == "__main__": + main()