Compare commits

..
168 Commits
Author SHA1 Message Date
StarFleetCPTN 7ba40483e0 Add toast deduplication and enhance provider test status notifications 2025-04-19 17:03:33 -07:00
StarFleetCPTN 4b5929331f Update destination_type column name in drive type migration 2025-04-19 14:49:33 -07:00
StarFleetCPTN d19f13f137 Fix column name in drive type migration from dest_type to destination_type 2025-04-19 14:39:10 -07:00
StarFleetCPTN 25609dfe6f update package-lock.json 2025-04-19 13:26:18 -07:00
StarFleetCPTN b9acf47530 Add gomftctl CLI tool and encryption key rotation utilities 2025-04-19 13:22:40 -07:00
StarFleetCPTN 495b844c00 Add audit logging to storage providers 2025-04-19 07:58:14 -07:00
StarFleetCPTN 98e20ea843 Merge branch 'transfer-calendar' into development 2025-04-19 06:25:32 -07:00
StarFleetCPTN 4c5accaae4 Add development builds 2025-04-18 21:30:52 -07:00
StarFleetCPTN 6e8fe9ad72 Update provider type from "drive" to "gdrive" across codebase for Google Drive storage 2025-04-18 21:12:47 -07:00
StarFleetCPTN d9cb1cac03 Add storage provider import UI with credential management and user guide documentation 2025-04-18 16:51:57 -07:00
StarFleetCPTN 5da298ef0b Add storage provider import feature with rclone config support 2025-04-18 16:16:49 -07:00
StarFleetCPTN 23db71c4a8 Add Google Drive and Photos support with headless auth for storage providers 2025-04-18 12:41:10 -07:00
StarFleetCPTN b819162732 feat: Update storage provider form and validation enhancements
- Added new HTML elements and JavaScript functionality to improve the user experience for WebDAV and Hetzner storage providers.
- Updated form field descriptions and placeholders for clarity, ensuring users understand the required input.
- Enhanced validation logic for WebDAV to ensure the host includes the protocol.
- Improved logging for debugging during form submissions, particularly for WebDAV credentials.
- Removed the deprecated provider form component to streamline the codebase.
2025-04-18 03:54:48 -07:00
StarFleetCPTN dc665b9fa4 feat: Enhance configuration forms and validation for storage providers
- Added hidden input fields to ensure critical values are submitted with the form for S3 and other storage providers.
- Implemented JavaScript functionality to auto-populate hidden fields based on user input for S3-compatible providers.
- Updated validation logic for WebDAV and SMB providers to ensure required fields are checked.
- Enhanced the configuration handler to explicitly set fields for various storage provider types, including S3, Wasabi, MinIO, and B2.
- Improved error handling and logging for form submissions and provider credential management.
2025-04-18 02:43:22 -07:00
StarFleetCPTN 776c4971a6 feat: Add UI testing setup and storage provider tests
- Introduced a new script for installing UI testing dependencies, including Node.js and Playwright browsers.
- Added comprehensive tests for managing storage providers, covering creation, editing, deletion, and connection testing.
- Implemented helper functions for user login and provider management in the test setup.
- Enhanced test coverage for various storage provider types, including SFTP, FTP, SMB, S3, and cloud services.
2025-04-16 19:25:41 -07:00
StarFleetCPTN 130a3b0d1c feat: Enhance storage provider form and configuration
- Updated the storage provider form to categorize options into server-based, object storage, web storage, and cloud storage.
- Improved input placeholders for clarity and added descriptions for fields.
- Introduced new storage provider types: WebDAV, Nextcloud, Wasabi, MinIO, and B2.
- Added JavaScript functionality to auto-populate default ports based on selected provider type.
- Updated .gitignore to include test results and logs, and added Playwright configuration for testing.
2025-04-16 19:24:16 -07:00
StarFleetCPTN 31871bd16e feat: Implement storage provider management functionality
- Added new routes and handlers for managing storage providers, including creation, editing, and deletion.
- Introduced a new StorageProvider form component for user input.
- Enhanced the database schema to support storage provider references in transfer configurations.
- Implemented encryption for sensitive fields in storage provider data.
- Added tests for storage provider API endpoints and integration with the database.
- Updated frontend components to support storage provider selection and testing.
2025-04-16 17:18:53 -07:00
StarFleetCPTN 37d507ade6 feat: Implement calendar view for scheduled jobs
- Added a new calendar view to display scheduled jobs for the next two months.
- Introduced components for generating calendar events from job data.
- Integrated FullCalendar for enhanced calendar functionality.
- Updated routing to include a new endpoint for the calendar view.
- Enhanced layout to include a link to the calendar in the navigation.
- Added necessary CSS for FullCalendar styling and functionality.
- Included tooltips for event details and filtering options for job visibility.
2025-04-13 08:49:00 -07:00
StarFleetCPTN 88d0ac815a Merge pull request #92 from StarFleetCPTN/development
style: Add hero logo styling to custom CSS
2025-04-12 15:56:02 -05:00
StarFleetCPTN ecb9ec874c style: Add hero logo styling to custom CSS
- Introduced new styles for the hero logo, including width, height, and margin adjustments.
2025-04-12 13:55:32 -07:00
StarFleetCPTN ee97a7f9be Merge pull request #91 from StarFleetCPTN/development
chore: Update Docusaurus deployment workflow and image handling
2025-04-12 15:50:59 -05:00
StarFleetCPTN 5f4db03982 chore: Update Docusaurus deployment workflow and image handling
- Enhanced the deployment workflow to ensure screenshots are copied to the correct static directory.
- Added a new script to fix image paths in Markdown files for better compatibility with the updated structure.
- Updated Docusaurus configuration to streamline static directory management and support image loading.
- Adjusted documentation to reference the new image paths.
2025-04-12 13:50:30 -07:00
StarFleetCPTN 4c8d122488 Merge pull request #90 from StarFleetCPTN/development
chore: Enhance Docusaurus configuration and update image paths
2025-04-12 15:44:22 -05:00
StarFleetCPTN 05d4861dbb chore: Enhance Docusaurus configuration and update image paths
- Added explicit configuration for static directories in Docusaurus.
- Updated npm script to ensure multiple screenshot paths are created and populated.
- Adjusted image paths in documentation to use relative links for better compatibility.
2025-04-12 13:43:28 -07:00
StarFleetCPTN 3d103cb6f6 Merge pull request #89 from StarFleetCPTN/development
chore: Update Docusaurus deployment workflow and documentation structure
2025-04-12 15:36:47 -05:00
StarFleetCPTN 2ada2b2ea6 chore: Update Docusaurus deployment workflow and documentation structure
- Added a step to copy screenshots to the static directory during deployment.
- Updated .gitignore to keep the README file in the screenshots directory.
- Added a new README file in the screenshots directory to explain its purpose and usage.
- Introduced a new npm script to prepare screenshots for the documentation site.
2025-04-12 13:36:00 -07:00
StarFleetCPTN 7e630f9337 Update docusaurus-deploy.yml 2025-04-12 13:26:47 -07:00
StarFleetCPTN a59da16b4c Update docusaurus-deploy.yml 2025-04-12 13:23:34 -07:00
StarFleetCPTN adccd4314b Merge pull request #88 from StarFleetCPTN/development
chore: Update GitHub Actions to use latest versions of actions for Do…
2025-04-12 15:17:00 -05:00
StarFleetCPTN 4d570e502b chore: Update GitHub Actions to use latest versions of actions for Docusaurus deployment
- Upgraded actions/checkout, actions/setup-node, actions/configure-pages, actions/upload-pages-artifact, and actions/deploy-pages to their respective v4 versions in the Docusaurus deployment workflow.
2025-04-12 13:15:59 -07:00
StarFleetCPTN d948849915 Merge pull request #87 from StarFleetCPTN/development
Documentation and Docker Arm Support
2025-04-12 15:14:08 -05:00
StarFleetCPTN 834f066704 feat: Integrate Docusaurus for documentation and enhance project structure
- Added Docusaurus configuration and initial setup for documentation.
- Created multiple documentation pages covering installation, configuration, and core concepts.
- Implemented a GitHub Actions workflow for automatic deployment of documentation to GitHub Pages.
- Updated .gitignore to exclude Docusaurus build artifacts and dependencies.
- Enhanced README with a link to the online documentation.
2025-04-12 13:01:53 -07:00
StarFleetCPTN 0aa68bc8e7 feat: Add architecture support and improve Dockerfile for multi-platform builds
- Introduced architecture-related build arguments (TARGETOS, TARGETARCH, TARGETVARIANT) to the Dockerfile.
- Updated the build command to use dynamic OS and architecture values.
- Enhanced rclone installation to support multiple architectures (amd64, arm64, arm-v7).
- Updated GitHub workflows to specify supported platforms for Docker builds.
2025-04-11 20:32:48 -07:00
StarFleetCPTN e9a005b658 feat: Implement version checking and update notification in dashboard
- Added functionality to fetch the latest GitHub release version.
- Enhanced the dashboard to display current and latest version information.
- Updated the version comparison logic to handle special cases and non-semver formats.
- Improved the dashboard template to link to the latest release on GitHub.
2025-04-11 20:29:47 -07:00
StarFleetCPTN 82f73cfe60 Merge pull request #86 from StarFleetCPTN/development
New UI Schedule Builder and Bug Fixes
2025-04-11 20:50:34 -05:00
StarFleetCPTN 032e188a24 feat: Enhance README and layout with Discord integration
- Added a Discord badge to the README for community engagement.
- Integrated Discord link in the layout template for easy access to the server.
- Updated job scheduling instructions in the README to include a visual schedule builder and advanced options for better user experience.
2025-04-11 18:49:40 -07:00
StarFleetCPTN 2dbf8de089 feat: Revamp layout and notification components for improved user experience
- Replaced icons with images and updated text labels in the layout template for better clarity.
- Enhanced mobile menu structure and added logo for consistent branding.
- Removed deprecated notification form and dialog components to streamline the codebase.
- Improved notification handling in the backend, ensuring correct URL formatting and payload structure for notifications.
- Added debug logging for notification requests to facilitate troubleshooting.
2025-04-11 18:32:36 -07:00
StarFleetCPTN 1c0459683b feat: Add version check functionality to dashboard
- Introduced new fields for LatestVersion and CurrentVersion in DashboardData struct.
- Implemented isNewerVersionAvailable function to compare current and latest versions.
- Updated dashboard template to display application version and notify users of available updates with appropriate styling.
2025-04-11 18:14:52 -07:00
StarFleetCPTN 856a8403b3 feat: Update notification count display with improved styling and accessibility
- Enhanced the notification count element with a border for better visibility.
- Added a hidden span element to maintain layout consistency when the notification count is zero.
2025-04-11 18:14:41 -07:00
StarFleetCPTN dc6209dbc1 feat: Enhance configuration form with validation and error handling
- Implemented validation for the configuration form to ensure required fields are filled before submission.
- Added a dynamic error display for user feedback, including validation messages for configuration name and paths.
- Ensured at least one concurrent transfer is set by default to improve user experience.
- Updated form layout to include accessibility features and improved error handling for various input fields.
2025-04-11 12:17:03 -07:00
StarFleetCPTN 4b540faec4 feat: Implement form validation for job creation and editing
- Added a comprehensive validation script for the job form to ensure all fields are correctly filled before submission.
- Introduced error handling for job name, schedule, and configuration selection, displaying relevant messages to users.
- Enhanced the user interface with error containers that dynamically show validation messages.
- Updated the job form layout to include validation attributes and improve accessibility.
2025-04-11 12:03:16 -07:00
StarFleetCPTN 2f6c95dd51 feat: Add schedule builder functionality to job form
- Introduced a new scheduling interface allowing users to select between interval, daily, weekly, monthly, or custom cron expressions.
- Implemented dynamic UI updates to show relevant input fields based on the selected schedule type.
- Enhanced cron expression parsing to pre-fill the form based on existing values.
- Updated job form layout to accommodate the new scheduling options and improve user experience.
2025-04-11 07:29:29 -07:00
StarFleetCPTN beb77d3fe9 Merge pull request #85 from StarFleetCPTN/development
feat: Enhance log viewer with CSS class handling and code cleanup
2025-04-10 23:39:58 -05:00
StarFleetCPTN 152f137cad feat: Enhance log viewer with CSS class handling and code cleanup
- Added a helper function to determine CSS classes based on log levels for improved visual representation.
- Refactored log entry rendering to use string concatenation for better readability.
- Updated CSV export logic to ensure proper escaping of messages.
- Cleaned up redundant code by removing the previous implementation of the CSS class function.
2025-04-10 21:38:31 -07:00
StarFleetCPTN 1358cb0e6d Merge pull request #84 from StarFleetCPTN/development
New Log Viwer and Bug Fixes
2025-04-10 22:57:59 -05:00
StarFleetCPTN ca54b450de refactor: Remove log generator functionality and update UI components
- Eliminated the log generator feature from the admin logs template and related handlers.
- Updated icons in the log viewer for consistency and improved visual clarity.
- Adjusted mobile dropdown functionality in the layout template for better user experience.
- Cleaned up debug logging statements in the WebSocket handler for recent logs.
2025-04-10 20:57:07 -07:00
StarFleetCPTN 2221e6a29c feat: Implement log viewer and WebSocket logging
- Added a new log viewer component to display real-time logs.
- Integrated WebSocket support for broadcasting log entries to connected clients.
- Updated main application to initialize logging and handle log directory creation.
- Enhanced error handling for log loading and broadcasting.
- Introduced new routes for accessing the log viewer and WebSocket stream.
2025-04-10 20:11:34 -07:00
StarFleetCPTN db44823ecf fix: Improve database initialization and connection handling
- Added logic to close the database connection after migrations to ensure a clean state.
- Implemented reconnection to the database post-migrations to prevent potential issues.
- Updated rclone connection logic to use 'host' instead of 'endpoint' for webdav and nextcloud providers, enhancing consistency in configuration arguments.
2025-04-09 19:15:17 -07:00
StarFleetCPTN 66623693df Merge pull request #80 from StarFleetCPTN/development
Bug Fixes + New Providers
2025-04-08 23:36:15 -05:00
StarFleetCPTN e2ab2eb603 refactor: Update rclone connection logic for provider handling
- Consolidated provider handling for Hetzner and S3-compatible providers (Minio, Wasabi) to improve clarity and maintainability.
- Adjusted configuration arguments to use the primary provider name consistently across different cases.
- Enhanced endpoint handling for Minio and B2 providers, ensuring correct parameter usage in rclone configuration generation.
2025-04-08 21:35:19 -07:00
StarFleetCPTN 87bdfc5e91 update readme.md with new providers 2025-04-08 21:05:45 -07:00
StarFleetCPTN 78fe589fcf refactor: Remove recovery migration files for transfer_configs, notification_services, and auth_providers
- Deleted migration files that handled recovery for inconsistent states in transfer_configs, notification_services, and auth_providers tables.
- Updated migration management to reflect the removal of these recovery steps, ensuring cleaner migration logic.
2025-04-08 21:00:41 -07:00
StarFleetCPTN bcd09af2d6 fix: Consolidate Hetzner support under SFTP provider
- Updated the rclone connection logic to treat Hetzner as an SFTP provider, simplifying the configuration process.
- Removed redundant case handling for Hetzner, streamlining the code and enhancing maintainability.
2025-04-08 20:33:32 -07:00
StarFleetCPTN de97ad6b50 feat: Add support for Hetzner Storage Box integration
- Introduced new source and destination forms for Hetzner Storage Box, allowing users to configure their storage settings.
- Updated configuration generation logic to handle Hetzner, including necessary parameters such as host, port, username, and authentication type.
- Enhanced user interface with clear instructions and security notes for Hetzner, improving user experience and security awareness.
- Updated existing logic to accommodate Hetzner in rclone configuration generation for both source and destination types.
2025-04-08 20:24:13 -07:00
StarFleetCPTN 417841b3d2 refactor: Enhance user and group management in entrypoint script
- Added checks for existing groups and users before creation to prevent conflicts and improve error handling.
- Updated user and group creation logic to use existing identifiers when available, ensuring smoother execution.
- Improved ownership setting for application directories and .env file to accommodate potential changes in group names.
- Enhanced verification of UID/GID changes with clearer warning messages for better user feedback.
2025-04-08 17:45:10 -07:00
StarFleetCPTN 11f8631929 feat: Add support for Backblaze B2 and Wasabi cloud storage
- Introduced new source and destination forms for Backblaze B2 and Wasabi, allowing users to configure their cloud storage settings.
- Updated the configuration generation logic to handle B2 and Wasabi, including necessary parameters such as access keys, bucket names, and endpoints.
- Enhanced the user interface to provide clear instructions and security notes for both storage options, improving user experience and security awareness.
2025-04-08 17:30:35 -07:00
StarFleetCPTN d650247817 Merge pull request #78 from StarFleetCPTN/development
Bug fixes
2025-04-07 21:15:08 -05:00
StarFleetCPTN 46df27e8c6 feat: Add rclone config regeneration after API updates
- Implemented logic to regenerate the rclone config file after updating configurations in the database.
- Added logging to capture warnings if the regeneration fails, while ensuring the update process continues smoothly.
- Enhanced user feedback by logging successful regeneration of the rclone config.
2025-04-07 19:02:02 -07:00
StarFleetCPTN bb75bc9400 refactor: Remove deprecated file metadata templates and associated JavaScript
- Deleted several unused templates related to file metadata, including details, dialog, list, search, and utility scripts, to streamline the codebase.
- This cleanup enhances maintainability by removing legacy code that is no longer in use.
2025-04-07 13:31:03 -07:00
StarFleetCPTN 8acdfc216d refactor: Remove check location buttons from local destination and source forms
- Eliminated the "Check Location" button from both LocalDestinationForm and LocalSourceForm templates to streamline the user interface.
- This change simplifies the forms by removing unnecessary elements, enhancing overall usability.
2025-04-07 11:32:58 -07:00
StarFleetCPTN 16e7b7e6e5 refactor: Improve toast notification rendering and event handling
- Refactored the ShowToastJS function to create toast elements using DOM methods for better safety and maintainability.
- Changed the event listener for the 'showToast' event from document.body to document for improved event handling.
- Ensured that the message content is set using textContent to prevent potential XSS vulnerabilities.
2025-04-07 11:21:11 -07:00
StarFleetCPTN 0f46b20fc4 refactor: Update form field identifiers and improve URL handling for NextCloud and WebDAV
- Changed input field IDs and names from 'dest_endpoint' and 'source_endpoint' to 'dest_host' and 'source_host' for consistency across NextCloud and WebDAV forms.
- Updated placeholder texts for username, password, and file path fields to be more user-friendly.
- Enhanced the URL parsing and validation logic in the transfer configuration generation to ensure proper handling of WebDAV and NextCloud URLs, improving robustness and error handling.
2025-04-07 11:05:08 -07:00
StarFleetCPTN 3654161245 feat: Enhance login functionality to support external authentication providers
- Updated the Login template to conditionally display external authentication options based on the presence of enabled providers.
- Modified the HandleLoginPage and HandleLogin functions to check for available external providers and pass this information to the Login template.
- Improved user experience by dynamically showing or hiding external sign-in options during the login process.
2025-04-07 09:24:46 -07:00
StarFleetCPTN d691fc837e feat: Add recovery migrations for notification_services and auth_providers tables
- Introduced two new migrations (011b_recover_notification_services_rename and 011c_recover_auth_providers_rename) to check and recover the notification_services and auth_providers tables if they were incorrectly renamed during a previous migration.
- Implemented logic to handle various states of the tables, including logging warnings for potential issues and ensuring safe recovery actions.
- Updated the migration sequence to include these new recovery steps, enhancing the robustness of the database migration process.
2025-04-07 09:06:22 -07:00
StarFleetCPTN b9af8fc051 Merge pull request #76 from StarFleetCPTN/development
Fixes
2025-04-07 02:34:31 -05:00
StarFleetCPTN e14d90e37e feat: Add recovery migration for transfer_configs table rename
- Introduced a new migration (011a_recover_transfer_configs_rename) to check and recover the transfer_configs table if it was incorrectly renamed to _transfer_configs_old during a previous migration.
- Implemented logic to handle various states of the tables, including logging warnings for potential issues.
- Ensured that the migration can be safely added to the existing migration sequence.

This migration enhances the robustness of the database migration process by addressing potential inconsistencies.
2025-04-07 00:24:10 -07:00
StarFleetCPTN 71ec298db5 fix: Update admin user deletion logic to prevent deletion of the last administrator
- Changed the logic in the HandleDeleteUser function to check for other administrators using the actual 'is_admin' column instead of counting all admins.
- Prevented deletion if no other administrators exist, ensuring at least one admin remains in the system.

https://github.com/StarFleetCPTN/GoMFT/issues/67
2025-04-06 23:58:56 -07:00
StarFleetCPTN 53cf4bc3c5 feat: Add migration to clean up invalid boolean values in database
- Introduced a new migration (013_cleanup_invalid_booleans) to update boolean columns represented as integers in the transfer_configs, notification_services, and auth_providers tables.
- Ensured that only valid values (0, 1, or NULL) are retained, setting any invalid values to 0.
- Added error handling for potential issues during the migration process, including checks for table existence before attempting updates.

This migration improves data integrity by standardizing boolean representations across the database.

https://github.com/StarFleetCPTN/GoMFT/issues/69
2025-04-06 23:52:46 -07:00
StarFleetCPTN 0f9a4cffd2 fix: Improve UID/GID management in entrypoint script
- Enhanced the logic for updating user and group IDs by deleting and recreating the user/group if the current IDs do not match the specified values.
- Added verification steps to ensure the UID/GID changes were successful, with appropriate error handling and logging for failures.
- Streamlined the process for handling Alpine Linux user management, improving clarity and maintainability of the script.

https://github.com/StarFleetCPTN/GoMFT/issues/68
2025-04-06 23:35:48 -07:00
StarFleetCPTN 105d223be4 refactor: Simplify job scheduling validation logic
- Removed manual parsing and validation of cron expressions, relying on the cron instance's AddFunc for validation.
- Enhanced error logging to provide more informative messages when scheduling fails, including the original schedule string.
- Streamlined the scheduling process by using the original schedule directly, improving code clarity and maintainability.

https://github.com/StarFleetCPTN/GoMFT/issues/71
2025-04-06 23:27:00 -07:00
StarFleetCPTN 7a54a83c8d fix: Improve migration process by handling potential leftover temporary tables
- Added logic to drop any existing temporary tables from previous failed migration runs before renaming the current table.
- Enhanced error logging to provide warnings if the drop operation fails, ensuring smoother migration execution.

https://github.com/StarFleetCPTN/GoMFT/issues/72
2025-04-06 23:11:41 -07:00
StarFleetCPTN c52267bbb6 Merge pull request #66 from StarFleetCPTN/development
Refactoring and improvements
2025-03-30 12:44:24 -07:00
StarFleetCPTN e93556e537 update readme.md with new env variable. 2025-03-30 12:43:04 -07:00
StarFleetCPTN 391cfbc50c feat: Enhance notification form handling and logging improvements
- Added a hidden input for the notification form to ensure proper handling of the 'is_enabled' state.
- Updated the configuration loading to allow skipping SSL verification with a new environment variable.
- Introduced temporary log file creation for rclone commands, improving error handling and logging of file processing.
- Enhanced the transfer executor to parse rclone logs for file metadata, ensuring accurate tracking of processed files.
- Added debug logging for received form data in the notification service update handler to aid in troubleshooting.
2025-03-30 12:26:50 -07:00
StarFleetCPTN 608da9ce1c feat: Enhance notification service configuration and refactor related components
- Updated notification service models to use pointers for boolean fields, allowing for better handling of enabled states.
- Introduced helper methods for getting and setting enabled states in notification and auth provider models.
- Refactored notification form templates to consolidate event trigger handling, improving code maintainability.
- Added SSL verification configuration to the application settings, allowing users to skip SSL verification for outgoing notifications.
- Enhanced the notification sending logic to respect the SSL verification setting.
- Introduced new migration to alter boolean defaults in the database schema for better compatibility with the updated models.
- Added unit tests for email and Rclone service functionalities to ensure reliability.
2025-03-30 09:56:55 -07:00
StarFleetCPTN b35174f857 feat: Introduce scheduler and notification enhancements
- Integrated new components for job scheduling, including a cron-based scheduler and improved job execution logic.
- Refactored job executor to utilize interfaces for better dependency management and testing.
- Enhanced notification system with a new Notifier interface, allowing for flexible notification service integration.
- Added comprehensive unit tests for job executor, scheduler, and notification components to ensure reliability and correctness.
- Updated metadata handling to support new database interfaces, improving testability and maintainability.
2025-03-29 21:23:52 -07:00
StarFleetCPTN ae18cd1a12 refactor: job scheduling and execution components
- Refactored existing scheduler logic to integrate new components and improve job management.
2025-03-29 19:27:19 -07:00
StarFleetCPTN 68dd9fc173 feat: Add connection testing functionality for Rclone providers
- Introduced a new endpoint to test connections for source and destination providers.
- Implemented a TestResult component to display success or error messages based on connection outcomes.
- Enhanced the configuration form with buttons to initiate connection tests, improving user experience.
- Updated backend logic to handle connection testing and return appropriate feedback to the frontend.
- Refactored existing handlers and routes to accommodate the new testing functionality.
2025-03-29 11:27:48 -07:00
StarFleetCPTN f95720758e feat: Enhance Rclone command configuration with pre-rendered flags and region support
- Added fields for pre-rendering flags in the config form, allowing for better user experience during edits.
- Updated Rclone command options to accept current command ID for pre-selection.
- Introduced region input fields for MinIO source and destination forms, enhancing configuration flexibility.
- Modified backend logic to handle region parameters when generating Rclone configurations.
- Improved flag handling by parsing selected flags and values for better state management in the UI.
2025-03-29 07:59:37 -07:00
StarFleetCPTN d4c07fdc8e refactor: database files 2025-03-29 06:02:03 -07:00
StarFleetCPTN 28fa65df9d feat: Enhance file metadata and notification components
- Added sorting capabilities to file metadata lists, including SortBy and SortDir fields.
- Integrated a toast notification system for user feedback on actions.
- Updated templates and JavaScript for improved user interaction and responsiveness.
- Refactored file metadata handlers to utilize new sorting features and ensure proper data flow.
- Introduced new notification dialog components for better user confirmation on actions.
2025-03-28 22:22:35 -07:00
StarFleetCPTN c52edb75df feat: Implement file metadata management components
- Added new types for file metadata filtering and data structures.
- Created templates for displaying file metadata details, lists, and search functionalities.
- Implemented JavaScript utilities for handling modals and notifications related to file metadata actions.
- Enhanced the user interface with Tailwind CSS for better styling and responsiveness.
- Introduced pagination and search capabilities for file metadata management.
2025-03-28 08:24:09 -07:00
StarFleetCPTN 11fe75dd57 Merge pull request #65 from StarFleetCPTN/development
Bug Fixes
2025-03-27 16:51:55 -07:00
StarFleetCPTN ec107ca0ce Merge branch 'main' of https://github.com/StarFleetCPTN/GoMFT into development
Bug Fixes
2025-03-27 16:48:35 -07:00
StarFleetCPTN 7172d4da90 Merge pull request #64 from StarFleetCPTN/development
Merge pull request #63 from StarFleetCPTN/main
2025-03-27 16:41:52 -07:00
StarFleetCPTN badcbfdb17 Merge pull request #63 from StarFleetCPTN/main
Bug fixes
2025-03-27 16:30:45 -07:00
StarFleetCPTN 748d5c0939 feat: Update port initialization and handling for FTP/SFTP configurations
- Changed default port initialization for source and destination configurations to 0, triggering default settings based on connection type.
- Implemented logic to set default ports for FTP (21) and SFTP (22) in the frontend templates.
- Enhanced input fields for FTP and SFTP forms to initialize and update port values dynamically based on user selections.
- Updated backend configuration generation to include the correct port values for source and destination settings.
2025-03-27 16:29:50 -07:00
StarFleetCPTN dd7426a01f Merge pull request #62 from StarFleetCPTN/development
feat: Implement frontend asset build process and update Docker config…
2025-03-27 15:30:08 -07:00
StarFleetCPTN fde60b7d68 feat: Implement frontend asset build process and update Docker configuration
- Added a new build script using esbuild to bundle JavaScript and CSS assets, including Tailwind CSS and Font Awesome.
- Updated the Dockerfile to include a multi-stage build process for frontend assets, ensuring efficient image creation.
- Enhanced GitHub workflows to automate the installation of Node.js dependencies and build frontend assets during CI/CD.
- Introduced a .gitignore entry for the dist directory to prevent unnecessary files from being tracked.
- Improved caching and content type handling for static files served by the application.
2025-03-27 15:28:40 -07:00
StarFleetCPTN 297e5ca92b Merge pull request #59 from StarFleetCPTN/development
feat: Add notification service structure to new notification form
2025-03-26 19:07:08 -07:00
StarFleetCPTN 405b2e605e feat: Add notification service structure to new notification form
- Introduced a new struct for NotificationService within the notification form data, encompassing various configuration fields for services like Pushbullet, Ntfy, Gotify, and Pushover.
- This addition enhances the form's capability to manage detailed service configurations, improving user experience and flexibility in notification management.
2025-03-26 19:06:18 -07:00
StarFleetCPTN 1f94641dee Merge pull request #58 from StarFleetCPTN/development
Massive Changes
2025-03-26 17:08:00 -07:00
StarFleetCPTN 9249b84ddd chore: Update dependencies and enhance README documentation
- Removed unused dependencies from go.mod and go.sum, including testify and objx.
- Expanded the README to include detailed information on authentication providers and user management features, enhancing clarity on security and integration options.
- Organized user management section to better outline authentication options and security features.
2025-03-26 17:03:26 -07:00
StarFleetCPTN a7cd021ff2 refactor: Remove obsolete test files
- Deleted outdated test files for providers, authentication, password management, configuration, database, email, scheduler, and web handlers.
- Cleaned up the codebase by removing tests that are no longer relevant or have been replaced by new implementations.
- This cleanup helps maintain a more manageable and efficient testing environment.
2025-03-26 16:51:14 -07:00
StarFleetCPTN 8424e97cf8 feat: Add modal functionality for viewing audit log details
- Implemented a modal to display detailed information for audit logs.
- Added event listeners for showing and hiding the modal based on user interactions.
- Enhanced user experience by allowing users to view JSON-formatted details in the modal.
- Included functionality to close the modal when clicking outside of it.
2025-03-26 16:50:20 -07:00
StarFleetCPTN f128eff8de feat: Expand notification service configuration options
- Added new fields for Pushbullet, Ntfy, Gotify, and Pushover services, including API keys, device identifiers, and message templates.
- Updated the notification form to dynamically display existing values for configuration fields.
- Enhanced backend handlers to support the retrieval of additional service configuration data.
- Improved user experience by ensuring that the form reflects current settings for each notification service type.
2025-03-26 16:39:37 -07:00
StarFleetCPTN db1a0ad5f5 feat: Enhance notification services with delete confirmation dialog
- Added a confirmation dialog for deleting notification services to prevent accidental deletions.
- Updated the notifications management interface to include improved delete button functionality.
- Enhanced JavaScript event handling for delete requests, providing user feedback on success and error scenarios.
- Refactored notification service deletion logic to ensure accurate tracking and messaging during deletion operations.
2025-03-26 06:26:39 -07:00
StarFleetCPTN 8e1321d251 feat: Enhance notification services management
- Added new routes and handlers for creating and editing notification services.
- Implemented a dedicated page for adding new notification services with a comprehensive form.
- Updated the notifications management interface to include links for editing existing services.
- Enhanced backend logic to support the retrieval and updating of notification service configurations.
- Improved user experience with toast notifications for success and error messages during service management.
2025-03-25 20:58:30 -07:00
StarFleetCPTN ef0829aaa4 feat: Add support for multiple notification services
- Implemented Pushbullet, Ntfy, Gotify, and Pushover notification services in the notifications management system.
- Enhanced the user interface to include configuration fields for each new service type, including API keys, device identifiers, and message templates.
- Updated backend logic to handle sending notifications through the newly integrated services, including error handling for API responses.
- Added event trigger options for job start, completion, and error notifications for each service.
- Improved test notification functionality to verify configurations for all supported services.
2025-03-24 18:15:41 -07:00
StarFleetCPTN 4d393e17ab feat: Implement notification services management
- Added a new notifications management page with functionality to create, delete, and test notification services.
- Introduced a new template for displaying notification services, including forms for adding and configuring services.
- Updated backend handlers to support notification service operations and improved error handling.
- Enhanced the user interface with toast notifications for success and error messages related to notification actions.
- Refactored existing settings components to integrate with the new notifications management features.
2025-03-24 16:55:32 -07:00
StarFleetCPTN d35f7bfe53 feat: Add notifications management components
- Introduced new templates for displaying and managing user notifications, including pagination support.
- Implemented helper functions for formatting notification times and determining icon and background colors based on notification types.
- Enhanced the user interface with controls for marking notifications as read and adjusting the number of notifications displayed per page.
- Added a dropdown for quick access to notifications and a count badge for unread notifications.
- Included a help section to guide users on managing their notifications effectively.
2025-03-24 16:32:37 -07:00
StarFleetCPTN b91b0e8796 feat: Implement authentication provider management components
- Added new templates for managing authentication providers, including forms for creating and editing providers.
- Implemented backend logic to handle retrieval, creation, and deletion of authentication providers.
- Introduced new database migrations to support the storage of authentication provider data.
- Enhanced the user interface to display available authentication providers and their statuses.
- Added routes and handlers for managing authentication provider actions in the web application.
2025-03-24 14:51:38 -07:00
StarFleetCPTN 233f1779d8 feat: Implement command flag management for rclone configurations
- Added functionality to manage command flags and their values in the configuration form.
- Updated the database schema to include a new field for storing command flag values.
- Enhanced backend logic to process and store flag values for non-boolean flags during configuration creation and updates.
- Improved the user interface to dynamically require destination fields based on selected commands.
- Refactored related templates and handlers to support the new command flag management features.
2025-03-23 20:30:02 -07:00
StarFleetCPTN 0cebfcf1f5 feat: Integrate rclone command functionality into configuration management
- Added support for selecting rclone commands and their associated flags in the configuration form.
- Implemented backend logic to handle rclone command retrieval and flag management.
- Enhanced the database schema to include rclone command and flag fields in the transfer configuration.
- Created new migration scripts to add rclone-related tables and fields.
- Updated the scheduler to execute transfers using the selected rclone command and flags.
- Developed new handlers for rendering rclone command options and flags in the web interface.
- Improved error handling and logging for rclone command execution and configuration updates.
2025-03-23 16:41:49 -07:00
StarFleetCPTN fb3f834c36 feat: Enhance user management and role deletion functionality
- Added toast notifications for user actions, improving user feedback on operations such as creation, deletion, and updates.
- Implemented a confirmation dialog for user deletions, ensuring users are prompted before irreversible actions.
- Refactored user management templates to streamline the user interface and enhance usability.
- Improved error handling for user operations, providing clearer messages for success and failure scenarios.
- Updated JavaScript functions to manage user interactions and modal displays more effectively.
2025-03-23 13:16:18 -07:00
StarFleetCPTN 30d0725331 feat: Enhance user management and dashboard components
- Updated user management templates to improve user experience with a new layout and success/error message handling.
- Implemented password hashing for user creation and management, enhancing security.
- Refactored dashboard components to include better data presentation and responsiveness.
- Added dark mode support for various components, ensuring a consistent user interface across themes.
- Improved toast notification animations for better user feedback during actions.
2025-03-23 11:42:16 -07:00
StarFleetCPTN 3140d9e143 feat: Enhance notifications and pagination functionality
- Implemented pagination for user notifications, allowing users to navigate through their notifications more efficiently.
- Updated the notifications page to display the total count of notifications and provide controls for adjusting the number of notifications shown per page.
- Added backend support for fetching paginated notifications and counting total notifications for users.
- Improved the user interface for notifications, including a new layout and additional help sections to guide users on managing their notifications.
2025-03-23 09:54:06 -07:00
StarFleetCPTN db6259f8a3 feat: Implement configuration and job duplication functionality
- Added duplication feature for configurations and jobs, allowing users to create copies of existing entries.
- Introduced new buttons in the UI for duplicating configurations and jobs, enhancing user experience.
- Implemented backend logic to handle duplication requests, ensuring proper ownership checks and data integrity.
- Added notifications for successful duplication actions to inform users of the outcome.
- Updated relevant templates and handlers to support the new duplication functionality.
2025-03-22 22:19:05 -07:00
StarFleetCPTN 49a586db53 feat: Update dependencies and enhance admin role management
- Upgraded `golang.org/x/crypto` to v0.36.0 and other indirect dependencies to their latest versions.
- Added functionality to assign admin roles to users upon creation in the main application logic.
- Introduced new templates for admin role management, including forms for creating and editing roles.
- Removed deprecated admin tools template to streamline the admin interface.
- Added audit logging for role changes to improve tracking and accountability.
2025-03-22 16:55:36 -07:00
StarFleetCPTN aa2149e2cf feat: Add admin database template component 2025-03-21 19:01:39 -07:00
StarFleetCPTN 8dac5b390c Merge pull request #52 from StarFleetCPTN/development
Admin Tools Fix
2025-03-20 21:30:19 -07:00
StarFleetCPTN faef3c2df9 Update Admin Tools Template to Improve JSON Placeholder Formatting
- Simplified the JSON placeholder in the webhook payload textarea for better readability and usability.
2025-03-20 21:29:25 -07:00
StarFleetCPTN 874c7ad69e Refactor Admin Tools Template and Handler Context
- Updated the AdminTools template to format JSON payloads for better readability.
- Modified the HandleAdminTools function to create a template context with user information before rendering the AdminTools view.
2025-03-20 21:23:59 -07:00
StarFleetCPTN 7de4f57fc3 Merge pull request #51 from StarFleetCPTN/development
Email and Webhook notification testing. Job order for multi configs. Debugging for scheduler
2025-03-20 20:46:11 -07:00
StarFleetCPTN 9a49d87777 Implement Job Configuration Ordering in Job Form
- Added functionality to allow users to select and reorder job configurations in both new and edit job forms.
- Introduced JavaScript logic to handle the display and ordering of selected configurations, including move up/down buttons.
- Updated backend to process and store the order of configurations when creating or updating jobs.
- Enhanced logging for job creation and update processes to include configuration order details.
2025-03-20 20:39:26 -07:00
StarFleetCPTN 73362793ad Add Webhook Notification Testing to Admin Tools
- Introduced a new section for testing webhook notifications within the admin tools.
- Added a form to input webhook URL, secret, custom headers, and payload for testing.
- Implemented backend logic to handle webhook test requests and display results.
- Created a toast notification component to show success or failure messages for webhook tests.
- Updated the AdminToolsData structure to include fields for webhook test results.
2025-03-20 18:49:32 -07:00
StarFleetCPTN d6bd471eb0 Add Email Testing Functionality to Admin Tools
- Introduced a new section in the admin tools for testing email configurations.
- Added a form to send test emails, including recipient, subject, and message fields.
- Implemented backend logic to handle test email requests and send emails using the configured SMTP server.
- Created a toast notification component to display success or failure messages for email tests.
- Updated routes to include a new endpoint for handling test email submissions.
2025-03-20 18:11:57 -07:00
StarFleetCPTN 1a1df435de Enhance logging in scheduler for better debugging
- Added debug logging for job scheduling, execution, and configuration processing.
- Included detailed log messages for cron expression validation and file transfer commands.
- Improved visibility into job history and webhook notifications with additional debug information.
2025-03-19 18:30:26 -07:00
StarFleetCPTN e031e67b3c Merge pull request #46 from StarFleetCPTN/development
Fix GetReleaseURL to dynamically use AppVersion in the release URL fo…
2025-03-19 05:19:11 -07:00
StarFleetCPTN 402072540f Fix GetReleaseURL to dynamically use AppVersion in the release URL format 2025-03-19 05:18:24 -07:00
StarFleetCPTN be19a5efde Update go-release.yml 2025-03-18 22:13:47 -07:00
StarFleetCPTN aa04c76e1f Merge pull request #45 from StarFleetCPTN/development
Enhance Docker and Build Configuration
2025-03-18 21:23:42 -07:00
StarFleetCPTN 1981764f80 Enhance Docker and Build Configuration
- Added support for build arguments in Dockerfile to specify UID, GID, version, build time, and commit hash.
- Updated docker-compose.yaml to utilize UID and GID for non-root user execution, improving file permission handling.
- Introduced entrypoint.sh script to manage user permissions and environment setup at container startup.
- Enhanced application logging to include version information during startup.
- Updated README with new instructions for running the application with specific user IDs and environment variables.
2025-03-18 21:23:15 -07:00
StarFleetCPTN 83be259754 Merge pull request #37 from StarFleetCPTN/development
Implement Two-Factor Authentication (2FA) features and enhancements
2025-03-17 19:26:31 -07:00
StarFleetCPTN 1995d19c3d Implement Two-Factor Authentication (2FA) features and enhancements
- Added configuration options for Two-Factor Authentication, including TOTP encryption key.
- Updated user profile components to manage 2FA status and backup codes.
- Implemented encryption and decryption of TOTP secrets using AES-256-GCM.
- Enhanced backup code generation and validation processes, storing hashed codes securely.
- Introduced new routes and handlers for managing backup codes and 2FA setup.
- Updated README with detailed instructions and security considerations for 2FA implementation.
2025-03-17 19:26:08 -07:00
StarFleetCPTN 2258bb1f70 Merge pull request #35 from StarFleetCPTN/development
Update README to include detailed Two-Factor Authentication (2FA) imp…
2025-03-17 05:25:33 -07:00
StarFleetCPTN 4254b529ef Update README to include detailed Two-Factor Authentication (2FA) implementation instructions and features. Added sections on setup process, login flow, and database changes related to 2FA support. 2025-03-17 05:24:53 -07:00
StarFleetCPTN a2971e3053 Merge pull request #34 from StarFleetCPTN/development
feat: Implement Two-Factor Authentication (2FA) functionality and Bug Fixes
2025-03-17 05:17:49 -07:00
StarFleetCPTN a7d912aa44 feat: Implement Two-Factor Authentication (2FA) functionality
- Added support for Two-Factor Authentication, including setup, verification, and disabling features.
- Introduced new components for 2FA setup and verification, enhancing user security.
- Updated user model to include fields for 2FA secret, status, and backup codes.
- Created migration to add necessary database fields for 2FA.
- Enhanced authentication handlers to manage 2FA processes during login and profile updates.
- Updated routes to include 2FA-related endpoints for setup and verification.
2025-03-17 05:17:03 -07:00
StarFleetCPTN 3dea48c691 Merge pull request #32 from StarFleetCPTN/development
refactor: Remove outdated migration for built-in auth fields and add …
2025-03-16 17:53:57 -07:00
StarFleetCPTN ef4bee88b8 refactor: Remove outdated migration for built-in auth fields and add new migration for updating Google Drive type
- Deleted the migration that updated the use_builtin_auth field to separate source and destination fields.
- Introduced a new migration to update source_type and destination_type from 'google_drive' to 'gdrive' in transfer_configs.
2025-03-16 17:50:43 -07:00
StarFleetCPTN 08d5f0edfc Merge pull request #31 from StarFleetCPTN/development
refactor: Update initial schema migration to correct table naming con…
2025-03-16 17:23:08 -07:00
StarFleetCPTN 1d43a7582d refactor: Update initial schema migration to correct table naming conventions
- Remove renaming logic for job_histories and password_histories tables, ensuring consistency with plural naming.
- Adjust CREATE TABLE statements to reflect the correct plural forms for job_histories and password_histories.
- Update drop table order to match the new naming conventions, maintaining foreign key constraints.
2025-03-16 17:14:14 -07:00
StarFleetCPTN 5e69aff21e Merge pull request #30 from StarFleetCPTN/development
chore: Revise README warning message for clarity
2025-03-16 16:30:39 -07:00
StarFleetCPTN 0c0c62b1f7 chore: Revise README warning message for clarity
- Replace the previous warning message with a more concise and clear statement regarding the active development status of GoMFT.
- Emphasize the potential for rapid changes in configurations and the importance of reviewing release notes before updates.
2025-03-16 16:29:18 -07:00
StarFleetCPTN d085bac227 Merge pull request #29 from StarFleetCPTN/development
New Logo, UI Updates, and Bug Fixes
2025-03-16 16:14:25 -07:00
StarFleetCPTN b1db53b89e feat: Refactor Google Drive and Google Photos authentication handling
- Separate built-in authentication options for source and destination in configuration forms.
- Update UI components to reflect changes in authentication handling for Google Drive and Google Photos.
- Implement path validation for local directories in the configuration forms, enhancing user experience.
- Revise README to clarify supported source and destination types, including updated terminology for Google Drive.
- Introduce new API endpoint for path validation, improving error handling and user feedback.
2025-03-16 16:12:42 -07:00
StarFleetCPTN 522699d96a refactor: Update UI components for improved styling and accessibility
- Change background and text colors in configuration forms to enhance visibility and consistency with dark mode.
- Update button classes and layout for better responsiveness and user experience.
- Revise authentication prompts and information sections for Google Drive and Google Photos to clarify the authentication process.
- Ensure all text elements are styled appropriately for both light and dark themes, improving overall accessibility.
2025-03-16 11:53:22 -07:00
StarFleetCPTN cab1de3306 feat: Update README and UI with new logo and warning message
- Add a new logo for GoMFT in SVG and PNG formats.
- Center the application title in the README and enhance the layout with a warning message about active development.
- Update the home page template to display the new logo prominently.
- Revise the screenshots section to improve visual presentation and clarity.
2025-03-16 11:06:07 -07:00
StarFleetCPTN 15058a7247 Merge pull request #26 from StarFleetCPTN/development
Added Google Drive and Google Photos and More Tests
2025-03-15 23:42:57 -07:00
StarFleetCPTN f1fd33c619 feat: Enhance README with Google Drive and Google Photos configuration details
- Add Google Drive and Google Photos as supported storage options in the features section.
- Include Google OAuth configuration parameters in the environment variable section.
- Update transfer configuration options to specify Google Photos and Google Drive specific settings.
- Improve documentation clarity for file options and performance settings.
2025-03-15 23:42:24 -07:00
StarFleetCPTN d68a80bd78 feat: Add Google Photos support for transfer configurations
- Introduce Google Photos as a source and destination option in the configuration forms.
- Implement authentication handling and configuration management for Google Photos.
- Update database schema and migrations to include new fields related to Google Photos.
- Enhance UI components to support Google Photos-specific settings and forms.
- Add tests for Google Photos integration, ensuring proper functionality and authentication flow.
- Limit concurrent transfers for Google Photos to ensure compliance with API restrictions.
2025-03-15 23:16:49 -07:00
StarFleetCPTN 8607f2098a feat: Integrate Google Drive support and enhance configuration handling
- Add Google Drive as a source and destination option in the configuration forms.
- Implement Google Drive authentication flow and token management.
- Update job and configuration handlers to support Google Drive-specific settings.
- Enhance UI components to include Google Drive configuration templates.
- Introduce new tests for Google Drive integration and ensure proper handling of authentication and configuration.
- Update database migrations to accommodate new fields related to Google Drive configurations.
2025-03-15 17:36:36 -07:00
StarFleetCPTN 0275de0f07 Merge pull request #25 from StarFleetCPTN/development
refactor: Clean up config creation handler by removing debug print st…
2025-03-14 23:24:13 -07:00
StarFleetCPTN a954023800 refactor: Clean up config creation handler by removing debug print statements
- Remove unnecessary print statements for form data and configuration details in the HandleCreateConfig function.
- Update handling of skipProcessedFiles to use a pointer for improved clarity and functionality.
2025-03-14 23:23:41 -07:00
StarFleetCPTN f11c03e22e Merge pull request #24 from StarFleetCPTN/development
Multi config jobs, Webhook notification, Mult thread support, and Testing
2025-03-14 18:59:36 -07:00
StarFleetCPTN 64323dd9ee feat: Add GoMFT Testing Guide and enhance concurrent transfer settings
- Introduce a comprehensive testing guide for the GoMFT application, detailing testing strategies, types, and best practices.
- Add new test utilities and examples for various components, including unit, integration, API, and webhook tests.
- Update the maximum concurrent transfers setting in the UI from 20 to 32 to support increased transfer capabilities.
2025-03-14 18:36:00 -07:00
StarFleetCPTN d5b0a686e0 feat: Implement webhook notifications and admin tools for job management
- Add webhook notification settings to job configuration, allowing users to enable notifications for job success and failure.
- Implement webhook payload structure and authentication using HMAC-SHA256 for secure communication.
- Enhance the admin tools with a log viewer and system management features, including database backup and log file access.
- Update README documentation to include details on webhook integration and admin tools.
- Introduce comprehensive tests for webhook functionality, ensuring correct payload delivery and header validation.
- Add database migrations to support new webhook fields in job configurations.
2025-03-14 18:22:59 -07:00
StarFleetCPTN 558e81c7e8 feat: Enhance configuration handling and UI for job management
- Update .gitignore to include new provider files and ensure proper tracking.
- Refactor job and config forms to support multiple configuration selections, improving user experience.
- Implement logic to handle the initialization of skipProcessedFiles with a default value.
- Add new provider form templates for better organization and management of source and destination configurations.
- Enhance tests for provider forms and job configurations to ensure robust functionality.
- Introduce nullable handling for skipProcessedFiles in the database schema and update related migrations.
- Improve error handling and validation in job creation and editing processes.
2025-03-14 16:30:43 -07:00
StarFleetCPTN d6fa0c1603 feat: Implement multi-configuration support for jobs
- Add support for multiple transfer configurations per job, allowing users to select one or more configurations.
- Update job creation and editing forms to handle multiple configuration selections with checkboxes.
- Enhance job processing logic to iterate through all associated configurations during execution.
- Introduce database migrations to add necessary fields for storing multiple configuration IDs.
- Update dashboard and history views to display configuration details for jobs.
- Refactor related templates and handlers to accommodate the new multi-configuration functionality.
2025-03-13 20:04:13 -07:00
StarFleetCPTN d1967b4402 feat: Add comprehensive tests for providers and scheduler functionalities
- Introduce new test file for providers, covering integration and rendering of source and destination components
- Implement tests for source and destination provider availability
- Enhance scheduler tests with new scenarios for job unscheduling, log rotation, and file processing history
- Add tests for output pattern processing with date and filename variables
- Ensure accessibility and dynamic rendering of provider forms in tests
2025-03-13 19:02:02 -07:00
StarFleetCPTN 3193bf5111 feat: Update dependencies and enhance job run details template
- Add new dependencies: `github.com/joho/godotenv`, `github.com/stretchr/testify`, and `gopkg.in/natefinch/lumberjack.v2`
- Remove indirect dependency on `github.com/joho/godotenv`
- Refactor JobRunDetails template to separate content rendering for improved testing
- Enhance error message display in JobRunDetails template
- Introduce new test files for JWT and password functionalities
- Add comprehensive tests for database operations and error handling
2025-03-13 18:40:40 -07:00
StarFleetCPTN a8b4588ecb feat: Implement comprehensive log viewer with advanced features
- Add new log viewer component in admin tools
- Support dynamic log file browsing and content display
- Implement custom scrollbar for log content
- Add log file refresh, download, and view capabilities
- Update environment variables for log configuration
- Enhance logging system with more flexible configuration options
2025-03-11 20:50:47 -07:00
StarFleetCPTN ed81b2c9be feat: Add multi-threaded file transfers and enhanced logging support
- Implement concurrent file transfer processing with configurable concurrency
- Add new `max_concurrent_transfers` column to transfer configurations
- Enhance scheduler to support multi-threaded file transfers
- Introduce advanced logging system with rotation and configurable log levels
- Update Docker Compose and documentation with new logging configuration options
- Modify directory structure to separate data, backups, and logs
- Add environment variables for comprehensive logging control
- Improve error handling and logging in file transfer processes
2025-03-11 18:00:10 -07:00
StarFleetCPTN 16b234b2d3 Merge pull request #18 from StarFleetCPTN/development
New File Metadata Page and other changes
2025-03-10 16:07:37 -07:00
StarFleetCPTN 5eac95818e feat: Add support for skipping processed files in transfer configs
- Introduce new `skip_processed_files` option for transfer configurations
- Add database migration to support the new column
- Update config form and UI to include skip processed files toggle
- Enhance scheduler to respect skip processed files setting
- Modify file processing logic to optionally skip previously processed files
- Add environment variable support for skip processed files configuration
2025-03-10 16:05:30 -07:00
StarFleetCPTN 6d0c215c38 docs: Update README with file metadata tracking details
- Add new section describing file metadata tracking features
- Include screenshot for file metadata view
- Expand feature list with comprehensive metadata management capabilities
- Update user guide with instructions for managing file metadata
- Remove Font Awesome icons from status badges in components
- Improve layout and styling for mobile responsiveness
2025-03-10 12:39:21 -07:00
StarFleetCPTN 31410984cb feat: Add partial search results rendering for file metadata
- Implement HandleFileMetadataSearchPartial handler for HTMX-powered search results
- Update file_metadata.templ to use new /files/search/partial endpoint
- Add HTMX request detection and browser redirect for search results
- Enhance search results pagination and filtering with partial rendering
- Improve user experience with dynamic, asynchronous search interactions
2025-03-09 22:10:22 -07:00
StarFleetCPTN 4b625b7120 refactor: Improve HTMX partial rendering for file metadata views
- Rename FileMetadataListContent to FileMetadataListPartial for consistency
- Add separate FileMetadataSearchContent template for search results
- Enhance handler logic to support HTMX partial rendering
- Implement more robust HTMX request detection
- Improve error handling and empty state rendering for file lists and search results
2025-03-09 22:02:02 -07:00
StarFleetCPTN 38461c7b7d feat: Add HTMX-powered dynamic loading for file lists and search results
- Implement partial rendering templates for file metadata list and search
- Add HTMX attributes for dynamic pagination and filtering
- Create loading indicators for HTMX requests
- Enhance user experience with smooth, asynchronous content updates
- Improve pagination and filtering interactions using HTMX
2025-03-09 20:28:00 -07:00
StarFleetCPTN 6545d26a93 feat: Enhance file deletion UX with custom confirmation dialogs
- Add FileMetadataDialog component for consistent delete confirmation
- Implement custom JavaScript handlers for file deletion events
- Add Notyf notifications for successful and failed deletions
- Improve delete button behavior in file list, details, and search views
- Enhance error handling and user feedback during file metadata deletion
2025-03-09 18:54:23 -07:00
StarFleetCPTN dfbcb021f5 feat: Enhance file listing and metadata extraction for rclone transfers
- Replace separate rclone size and list commands with single lsjson operation
- Improve file metadata extraction from JSON output
- Add support for hash and file size retrieval from remote sources
- Optimize file processing logic with more robust metadata handling
- Enhance error handling and logging for file listing and transfer processes
2025-03-09 11:37:21 -07:00
StarFleetCPTN db55c92909 feat: Prevent duplicate file transfers during job execution
- Add mechanism to track processed files within a single job run
- Ensure unique file processing by filtering out duplicate entries
- Improve logging for skipped duplicate files
- Enhance file transfer reliability and efficiency
2025-03-09 10:47:24 -07:00
StarFleetCPTN fcf6ee25a8 feat: Add file metadata tracking and search functionality
- Implement FileMetadata model to track processed files
- Create file metadata handlers for listing, searching, and viewing files
- Add file metadata routes and UI components
- Support advanced file search with multiple filters
- Enhance job execution to capture file metadata during transfers
- Implement file hash and duplicate detection logic
2025-03-09 10:22:57 -07:00
StarFleetCPTN 3b9ff3b6a8 chore: Refine config form and UI components
- Add x-init hook to set default form values in config form
- Adjust scrollbar hover color in layout
- Create marker files for providers, source, and destination packages
- Remove unnecessary error styling in local source form
2025-03-08 23:03:41 -08:00
StarFleetCPTN 43f1772038 feat: Modularize provider components for transfer configuration form
- Create provider-specific form templates for source and destination storage types
- Implement common form fields and type selection components
- Support multiple storage providers with consistent form design
- Update .gitignore to exclude generated provider files
2025-03-08 22:09:24 -08:00
StarFleetCPTN bcf55fb973 Merge pull request #17 from StarFleetCPTN/development
docs: Update supported storage options in README
2025-03-08 17:00:45 -08:00
StarFleetCPTN db4d0e018f docs: Update supported storage options in README
- Remove Azure Blob Storage and Google Cloud Storage
- Add NextCloud and WebDAV to supported storage list
2025-03-08 17:00:04 -08:00
383 changed files with 92780 additions and 7496 deletions
+1 -1
View File
@@ -7,7 +7,7 @@ args_bin = []
bin = "./tmp/main"
cmd = "templ generate && go build -o ./tmp/main ."
delay = 1000
exclude_dir = ["assets", "tmp", "vendor", "testdata", "node_modules"]
exclude_dir = ["assets", "tmp", "vendor", "testdata", "node_modules", "docs"]
exclude_file = []
exclude_regex = ["_test.go", "_templ.go"]
exclude_unchanged = false
+224
View File
@@ -0,0 +1,224 @@
name: Development Build
on:
push:
branches:
- 'development'
- 'feature/**'
pull_request:
branches: [ development, feature/** ]
workflow_dispatch:
inputs:
environment:
description: 'Environment to deploy to'
required: true
default: 'development'
type: choice
options:
- development
- staging
env:
# Use github.repository as the default image name
IMAGE_NAME: ${{ github.repository }}
REGISTRY: ghcr.io
DOCKERHUB_IMAGE: starfleetcptn/gomft
jobs:
build:
name: Build Development Binaries
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install Node.js dependencies
run: npm ci
- name: Build frontend assets
run: |
# Build JavaScript and CSS assets
node build.js
# Ensure the dist directory exists
mkdir -p static/dist
# Verify the build output
ls -la static/dist
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.24.x'
cache: true
- name: Install dependencies
run: |
go mod download
# Install templ compiler for template generation
go install github.com/a-h/templ/cmd/templ@latest
- name: Generate template files
run: templ generate
- name: Set Version
id: version
run: |
# For development builds, use branch name or PR number with commit hash
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
VERSION="pr-${{ github.event.pull_request.number }}-$(git rev-parse --short HEAD)"
else
BRANCH=${GITHUB_REF#refs/heads/}
VERSION="${BRANCH//\//-}-$(git rev-parse --short HEAD)"
fi
echo "VERSION=$VERSION" >> $GITHUB_ENV
echo "version=$VERSION" >> $GITHUB_OUTPUT
# Also set build timestamp for versioning
echo "BUILD_TIME=$(date -u +'%Y-%m-%d_%H:%M:%S')" >> $GITHUB_ENV
echo "COMMIT=$(git rev-parse --short HEAD)" >> $GITHUB_ENV
- name: Build for Linux (amd64)
run: |
mkdir -p dist
# Define common ldflags with version information
LDFLAGS="-X github.com/starfleetcptn/gomft/components.AppVersion=$VERSION -X main.Version=$VERSION -X main.BuildTime=$BUILD_TIME -X main.Commit=$COMMIT -X github.com/starfleetcptn/gomft/components.BuildTime=$BUILD_TIME -X github.com/starfleetcptn/gomft/components.Commit=$COMMIT"
# Only build for Linux amd64 for development builds
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -ldflags="$LDFLAGS" -o dist/gomft-$VERSION-linux-amd64 .
# Create checksums
cd dist
sha256sum * > SHA256SUMS.txt
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: dev-binary
path: dist/
retention-days: 7 # Keep development builds for 7 days
docker:
name: Build and Push Development Docker Image
runs-on: ubuntu-latest
needs: build
# Set the permissions needed for the GitHub token to push to GHCR
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
# Set up Node.js for frontend build
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
# Install dependencies
- name: Install dependencies
run: npm ci
# Build frontend assets
- name: Build frontend assets
run: |
node build.js
ls -la static/dist/
# Set version information
- name: Set Version
id: version
run: |
# For development builds, use branch name or PR number with commit hash
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
VERSION="pr-${{ github.event.pull_request.number }}-$(git rev-parse --short HEAD)"
else
BRANCH=${GITHUB_REF#refs/heads/}
VERSION="${BRANCH//\//-}-$(git rev-parse --short HEAD)"
fi
echo "VERSION=$VERSION" >> $GITHUB_ENV
echo "version=$VERSION" >> $GITHUB_OUTPUT
# Also set build timestamp for versioning
echo "BUILD_TIME=$(date -u +'%Y-%m-%d_%H:%M:%S')" >> $GITHUB_ENV
echo "COMMIT=$(git rev-parse --short HEAD)" >> $GITHUB_ENV
# Set up Docker Buildx for efficient builds
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
# Login to GitHub Container Registry
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# Login to DockerHub
- name: Log in to DockerHub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
# Extract metadata for GitHub Container Registry
- name: Extract GitHub Container Registry metadata
id: meta-ghcr
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=sha,format=short
type=raw,value=dev-latest
# Extract metadata for DockerHub
- name: Extract DockerHub metadata
id: meta-dockerhub
uses: docker/metadata-action@v5
with:
images: ${{ env.DOCKERHUB_IMAGE }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=sha,format=short
type=raw,value=dev-latest
# Build and push Docker image to both registries
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: |
${{ steps.meta-ghcr.outputs.tags }}
${{ steps.meta-dockerhub.outputs.tags }}
labels: ${{ steps.meta-ghcr.outputs.labels }}
platforms: linux/amd64
build-args: |
VERSION=${{ env.VERSION }}
BUILD_TIME=${{ env.BUILD_TIME }}
COMMIT=${{ env.COMMIT }}
UID=1000
GID=1000
cache-from: type=gha
cache-to: type=gha,mode=max
@@ -26,6 +26,41 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0 # Needed to get all tags for versioning
# Set up Node.js for frontend build
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
# Install dependencies
- name: Install dependencies
run: npm ci
# Build frontend assets
- name: Build frontend assets
run: |
node build.js
ls -la static/dist/
# Set version information
- name: Set Version
id: version
run: |
if [[ "${{ github.event.inputs.manual_version }}" != "" ]]; then
echo "VERSION=${{ github.event.inputs.manual_version }}" >> $GITHUB_ENV
elif [[ "${{ github.ref }}" == refs/tags/* ]]; then
VERSION=${GITHUB_REF#refs/tags/}
echo "VERSION=$VERSION" >> $GITHUB_ENV
else
VERSION=$(git describe --tags --abbrev=0 2>/dev/null || echo "dev")-$(git rev-parse --short HEAD)
echo "VERSION=$VERSION" >> $GITHUB_ENV
fi
echo "BUILD_TIME=$(date -u +'%Y-%m-%d_%H:%M:%S')" >> $GITHUB_ENV
echo "COMMIT=$(git rev-parse --short HEAD)" >> $GITHUB_ENV
# Set up Docker Buildx for efficient builds
- name: Set up Docker Buildx
@@ -61,5 +96,12 @@ jobs:
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
platforms: linux/amd64,linux/arm64,linux/arm/v7
build-args: |
VERSION=${{ env.VERSION }}
BUILD_TIME=${{ env.BUILD_TIME }}
COMMIT=${{ env.COMMIT }}
UID=1000
GID=1000
cache-from: type=gha
cache-to: type=gha,mode=max
+42
View File
@@ -27,6 +27,41 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0 # Needed to get all tags for versioning
# Set up Node.js for frontend build
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
# Install dependencies
- name: Install dependencies
run: npm ci
# Build frontend assets
- name: Build frontend assets
run: |
node build.js
ls -la static/dist/
# Set version information
- name: Set Version
id: version
run: |
if [[ "${{ github.event.inputs.manual_version }}" != "" ]]; then
echo "VERSION=${{ github.event.inputs.manual_version }}" >> $GITHUB_ENV
elif [[ "${{ github.ref }}" == refs/tags/* ]]; then
VERSION=${GITHUB_REF#refs/tags/}
echo "VERSION=$VERSION" >> $GITHUB_ENV
else
VERSION=$(git describe --tags --abbrev=0 2>/dev/null || echo "dev")-$(git rev-parse --short HEAD)
echo "VERSION=$VERSION" >> $GITHUB_ENV
fi
echo "BUILD_TIME=$(date -u +'%Y-%m-%d_%H:%M:%S')" >> $GITHUB_ENV
echo "COMMIT=$(git rev-parse --short HEAD)" >> $GITHUB_ENV
# Set up Docker Buildx for efficient builds
- name: Set up Docker Buildx
@@ -62,5 +97,12 @@ jobs:
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
platforms: linux/amd64,linux/arm64,linux/arm/v7
build-args: |
VERSION=${{ env.VERSION }}
BUILD_TIME=${{ env.BUILD_TIME }}
COMMIT=${{ env.COMMIT }}
UID=1000
GID=1000
cache-from: type=gha
cache-to: type=gha,mode=max
+83
View File
@@ -0,0 +1,83 @@
name: Deploy Docusaurus to GitHub Pages
on:
push:
branches: [main]
paths: ['docs/**']
# Allows you to run this workflow manually from the Actions tab
workflow_dispatch:
# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages
permissions:
contents: read
pages: write
id-token: write
# Allow only one concurrent deployment
concurrency:
group: "pages"
cancel-in-progress: true
jobs:
build:
name: Deploy Docusaurus
runs-on: ubuntu-latest
defaults:
run:
working-directory: ./docs
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 18
cache: npm
cache-dependency-path: ./docs/package-lock.json
- name: Install dependencies
run: npm ci
- name: Copy screenshots to static directory
run: |
npm run prepare-screenshots
# Ensure the images referenced in the docs are definitely available
mkdir -p static/img
cp -v ../screenshots/dashboard.gomft.png static/img/
cp -v ../screenshots/transfer.config.gomft.png static/img/
ls -la static/img/dashboard.gomft.png static/img/transfer.config.gomft.png
- name: Fix Markdown image paths
run: npm run fix-image-paths
- name: Verify screenshots exist
run: |
echo "Checking if screenshots were copied correctly..."
if [ -f "static/img/dashboard.gomft.png" ]; then
echo "✅ Found dashboard.gomft.png in static/img/"
else
echo "❌ Missing dashboard.gomft.png in static/img/"
exit 1
fi
if [ -f "static/img/transfer.config.gomft.png" ]; then
echo "✅ Found transfer.config.gomft.png in static/img/"
else
echo "❌ Missing transfer.config.gomft.png in static/img/"
exit 1
fi
- name: Build
run: npm run build
- name: Setup Pages
uses: actions/configure-pages@v4
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
path: ./docs/build
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
+34 -8
View File
@@ -27,6 +27,26 @@ jobs:
with:
fetch-depth: 0
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install Node.js dependencies
run: npm ci
- name: Build frontend assets
run: |
# Build JavaScript and CSS assets
node build.js
# Ensure the dist directory exists
mkdir -p static/dist
# Verify the build output
ls -la static/dist
- name: Set up Go
uses: actions/setup-go@v5
with:
@@ -57,23 +77,29 @@ jobs:
echo "VERSION=$VERSION" >> $GITHUB_ENV
echo "version=$VERSION" >> $GITHUB_OUTPUT
fi
# Also set build timestamp for versioning
echo "BUILD_TIME=$(date -u +'%Y-%m-%d_%H:%M:%S')" >> $GITHUB_ENV
echo "COMMIT=$(git rev-parse --short HEAD)" >> $GITHUB_ENV
- name: Build for multiple platforms
run: |
mkdir -p dist
# Define common ldflags with version information
LDFLAGS="-X github.com/starfleetcptn/gomft/components.AppVersion=$VERSION -X main.Version=$VERSION -X main.BuildTime=$BUILD_TIME -X main.Commit=$COMMIT -X github.com/starfleetcptn/gomft/components.BuildTime=$BUILD_TIME -X github.com/starfleetcptn/gomft/components.Commit=$COMMIT"
# Linux builds
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -ldflags="-X main.version=$VERSION" -o dist/gomft-$VERSION-linux-amd64 .
GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -ldflags="-X main.version=$VERSION" -o dist/gomft-$VERSION-linux-arm64 .
GOOS=linux GOARCH=arm GOARM=7 CGO_ENABLED=0 go build -ldflags="-X main.version=$VERSION" -o dist/gomft-$VERSION-linux-armv7 .
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -ldflags="$LDFLAGS" -o dist/gomft-$VERSION-linux-amd64 .
GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -ldflags="$LDFLAGS" -o dist/gomft-$VERSION-linux-arm64 .
GOOS=linux GOARCH=arm GOARM=7 CGO_ENABLED=0 go build -ldflags="$LDFLAGS" -o dist/gomft-$VERSION-linux-armv7 .
# macOS builds
GOOS=darwin GOARCH=amd64 CGO_ENABLED=0 go build -ldflags="-X main.version=$VERSION" -o dist/gomft-$VERSION-darwin-amd64 .
GOOS=darwin GOARCH=arm64 CGO_ENABLED=0 go build -ldflags="-X main.version=$VERSION" -o dist/gomft-$VERSION-darwin-arm64 .
GOOS=darwin GOARCH=amd64 CGO_ENABLED=0 go build -ldflags="$LDFLAGS" -o dist/gomft-$VERSION-darwin-amd64 .
GOOS=darwin GOARCH=arm64 CGO_ENABLED=0 go build -ldflags="$LDFLAGS" -o dist/gomft-$VERSION-darwin-arm64 .
# Windows builds
GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -ldflags="-X main.version=$VERSION" -o dist/gomft-$VERSION-windows-amd64.exe .
GOOS=windows GOARCH=arm64 CGO_ENABLED=0 go build -ldflags="-X main.version=$VERSION" -o dist/gomft-$VERSION-windows-arm64.exe .
GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -ldflags="$LDFLAGS" -o dist/gomft-$VERSION-windows-amd64.exe .
GOOS=windows GOARCH=arm64 CGO_ENABLED=0 go build -ldflags="$LDFLAGS" -o dist/gomft-$VERSION-windows-arm64.exe .
# Create checksums
cd dist
@@ -95,4 +121,4 @@ jobs:
generate_release_notes: true
draft: false
# The following line is not needed as we set permissions at workflow level
# token: ${{ secrets.GITHUB_TOKEN }}
# token: ${{ secrets.GITHUB_TOKEN }}
+48 -5
View File
@@ -43,22 +43,65 @@ node_modules/
Thumbs.db
# Ignore all Go files in the components directory
components/*.go
!components/components.go
*_templ.go
# Ignore the data directory
data/
# Ignore .env files
.env
*.env
# Ignore the tmp directory
tmp/
# Ignore the configs directory
configs/
# Ignore the backups directory
backups/
# Ignore the tests directory
test-results/
playwright-report/
# Ignore Dirs
source/
destination/
archive/
/source/
/destination/
/archive/
# Ignore the dist directory
static/dist/
# Ignore binaries
gomft
# Docusaurus files
docs/.docusaurus/
docs/.cache-loader/
docs/build/
docs/build-searchindex/
docs/static/search/
docs/static/js/
docs/node_modules/
docs/.env*
docs/*.log
# Added by Claude Task Master
logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
dev-debug.log
# Environment variables
# Editor directories and files
.idea
.vscode
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# OS specific
# Task files
tasks.json
tasks/
+105 -14
View File
@@ -1,7 +1,45 @@
# Build frontend assets
FROM node:20-alpine AS frontend-builder
WORKDIR /app
# Copy all files needed for the build first
COPY package.json package-lock.json ./
COPY build.js ./
COPY static/ ./static/
COPY tailwind.config.js ./
# Debug: Show the contents of build.js
RUN echo "Contents of build.js:" && cat build.js
# Remove the build script from postinstall
RUN sed -i 's/"postinstall": "npm run build",//' package.json
# Install dependencies and build with verbose output
RUN npm ci && \
echo "Building frontend assets..." && \
node build.js && \
echo "Build complete. Contents of dist:" && \
ls -la static/dist/ && \
echo "Sample of app.js:" && \
head -n 10 static/dist/app.js && \
echo "Sample of app.css:" && \
head -n 10 static/dist/app.css
# Go build stage
FROM golang:1.24-alpine AS builder
WORKDIR /app
# Accept build arguments for version information
ARG VERSION=dev
ARG BUILD_TIME=unknown
ARG COMMIT=unknown
# Architecture-related build arguments
ARG TARGETOS=linux
ARG TARGETARCH=amd64
ARG TARGETVARIANT=""
# Install build dependencies
RUN apk add --no-cache git build-base
@@ -12,20 +50,47 @@ RUN go install github.com/a-h/templ/cmd/templ@latest
COPY go.mod go.sum ./
RUN go mod download
# Copy the rest of the source code
# Create static directory structure
RUN mkdir -p /app/static/dist
# Copy built frontend assets from frontend-builder BEFORE copying Go source
COPY --from=frontend-builder /app/static/dist/ /app/static/dist/
# Copy the rest of the static files
COPY static/ /app/static/
# Verify static files are in place before Go build
RUN echo "Verifying static files before Go build:" && \
ls -la /app/static/dist/
# Now copy the rest of the source code
COPY . .
# Generate template files from .templ files
RUN templ generate
# Build the application
RUN CGO_ENABLED=1 GOOS=linux go build -o gomft
# Compile the main application with version information
RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build \
-ldflags "-X github.com/starfleetcptn/gomft/components.AppVersion=${VERSION} -X main.Version=${VERSION} -X main.BuildTime=${BUILD_TIME} -X main.Commit=${COMMIT} -X github.com/starfleetcptn/gomft/components.BuildTime=${BUILD_TIME} -X github.com/starfleetcptn/gomft/components.Commit=${COMMIT}" \
-o /app/gomft
# Install rclone
# Compile the command line tool with version information
RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build \
-ldflags "-X main.Version=${VERSION} -X main.BuildTime=${BUILD_TIME} -X main.Commit=${COMMIT}" \
-o /app/gomftctl ./cmd/gomftctl
# Install rclone with appropriate architecture
RUN apk add --no-cache curl unzip && \
curl -O https://downloads.rclone.org/rclone-current-linux-amd64.zip && \
unzip rclone-current-linux-amd64.zip && \
cd rclone-*-linux-amd64 && \
if [ "$TARGETARCH" = "arm64" ]; then \
RCLONE_ARCH="arm64"; \
elif [ "$TARGETARCH" = "arm" ]; then \
RCLONE_ARCH="arm-v7"; \
else \
RCLONE_ARCH="amd64"; \
fi && \
curl -O https://downloads.rclone.org/rclone-current-linux-${RCLONE_ARCH}.zip && \
unzip rclone-current-linux-${RCLONE_ARCH}.zip && \
cd rclone-*-linux-${RCLONE_ARCH} && \
cp rclone /usr/local/bin/ && \
chmod 755 /usr/local/bin/rclone && \
cd .. && \
@@ -34,27 +99,53 @@ RUN apk add --no-cache curl unzip && \
# Create a smaller runtime image
FROM alpine:3.19
# Add arguments for UID and GID with defaults
ARG UID=1000
ARG GID=1000
ARG USERNAME=gomft
ARG TARGETOS
ARG TARGETARCH
WORKDIR /app
# Install runtime dependencies
RUN apk add --no-cache ca-certificates tzdata sqlite bash
RUN apk add --no-cache ca-certificates tzdata sqlite bash shadow su-exec \
&& apk add --no-cache --virtual .user-deps \
shadow curl xz
# Copy the binary from the builder stage
# Create user and group with specified IDs
RUN addgroup -g ${GID} ${USERNAME} && \
adduser -D -u ${UID} -G ${USERNAME} -s /bin/sh ${USERNAME}
# Copy the binaries from the builder stage
COPY --from=builder /app/gomft /app/
COPY --from=builder /app/gomftctl /app/
COPY --from=builder /usr/local/bin/rclone /usr/local/bin/rclone
# Copy static files and configurations
COPY static/ /app/static/
# Copy components
COPY components/ /app/components/
# Create data directory
RUN mkdir -p /app/data/gomft
# Copy entrypoint script
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
# Create data and backup directories
RUN mkdir -p /app/data /app/backups
# Create a placeholder .env file with proper permissions
RUN touch /app/.env && chmod 644 /app/.env && chown ${USERNAME}:${USERNAME} /app/.env
# Set executable permissions
RUN chmod +x /app/gomft
RUN chmod +x /app/gomft /app/gomftctl
# Set ownership of application files
RUN chown -R ${USERNAME}:${USERNAME} /app
# Expose the application port
EXPOSE 8080
# Use our entrypoint script
ENTRYPOINT ["/entrypoint.sh"]
# Run the application
CMD ["/app/gomft"]
+203 -214
View File
@@ -1,63 +1,136 @@
# GoMFT - Go Managed File Transfer
<p align="center">
<img src="static/img/logo.svg" alt="GoMFT Logo" width="200">
</p>
<h1 align="center">GoMFT - Go Managed File Transfer</h1>
GoMFT is a web-based managed file transfer application built with Go, leveraging rclone for robust file transfer capabilities. It provides a user-friendly interface for configuring, scheduling, and monitoring file transfers across various storage providers.
<p align="center">
<a href="https://discord.gg/f9dwtM3j">
<img src="https://img.shields.io/discord/1351354052654403675?color=7289da&logo=discord&logoColor=white&label=Discord" alt="Join our Discord server!" />
</a>
<a href="https://starfleetcptn.github.io/GoMFT/">
<img src="https://img.shields.io/badge/docs-online-blue.svg" alt="Documentation" />
</a>
</p>
## 📚 Documentation
Comprehensive documentation is available at [https://starfleetcptn.github.io/GoMFT/](https://starfleetcptn.github.io/GoMFT/).
The documentation includes:
- [Getting Started Guide](https://starfleetcptn.github.io/GoMFT/docs/getting-started)
- [Installation Instructions](https://starfleetcptn.github.io/GoMFT/docs/installation)
- [Configuration Reference](https://starfleetcptn.github.io/GoMFT/docs/configuration)
- [User Guide](https://starfleetcptn.github.io/GoMFT/docs/user-guide)
- [Storage Provider Setup](https://starfleetcptn.github.io/GoMFT/docs/storage-providers)
- [Advanced Features](https://starfleetcptn.github.io/GoMFT/docs/advanced)
- [Troubleshooting](https://starfleetcptn.github.io/GoMFT/docs/troubleshooting)
- [API Reference](https://starfleetcptn.github.io/GoMFT/docs/api)
> [!WARNING]
> This application is actively under development. As such, any aspect of the application—including configurations, data structures, and database fields—may change rapidly and without prior notice. Please review all release notes thoroughly before updating.
---
## Screenshots
### Dashboard
![Dashboard Overview](screenshots/dashboard.gomft.png)
*Dashboard showing active transfers and system status*
![Dashboard Overview Dark](screenshots/dashboard.dark.gomft.png)
*Dashboard dark mode showing active transfers and system status*
### Configuration Interface
![Transfer Configuration](screenshots/new.configuration.gomft.png)
*Setting up transfer configurations with multiple storage options*
### Job Management
![Job Scheduling](screenshots/new.job.gomft.png)
*Scheduling transfers with flexible cron expressions*
### User Management
![User Management](screenshots/user.management.gomft.png)
*Create user accounts and manage them*
<table>
<tr>
<td width="33%">
<a href="screenshots/dashboard.gomft.png">
<img src="screenshots/dashboard.gomft.png" alt="Dashboard Overview" width="100%">
</a>
<p align="center"><em>Dashboard showing active transfers</em></p>
</td>
<td width="33%">
<a href="screenshots/dashboard.dark.gomft.png">
<img src="screenshots/dashboard.dark.gomft.png" alt="Dashboard Dark Mode" width="100%">
</a>
<p align="center"><em>Dashboard dark mode</em></p>
</td>
</tr>
</table>
---
## Features
- **Multiple Storage Support**: Leverage rclone's extensive support for cloud storage providers:
- Google Drive
- Google Photos
- Amazon S3
- MinIO
- Backblaze B2
- Azure Blob Storage
- Google Cloud Storage
- NextCloud
- WebDAV
- SFTP
- FTP
- SMB/CIFS shares
- Hetzner Storage Box
- Backblaze B2
- Wasabi
- Local filesystem
- And more via rclone
- **Multiple Notification Services**: Get job status updates through various notification channels:
- Email notifications with configurable SMTP settings
- Webhooks with authentication for custom integrations
- Pushbullet notifications with optional device targeting
- Ntfy.sh (both public and self-hosted) for simple push notifications
- Gotify server integration for self-hosted notifications
- Pushover notifications with customizable sounds and priorities
- Configurable message templates for all notification types
- Event-based triggers (job start, completion, errors)
- **Scheduled Transfers**: Configure transfers using cron expressions with flexible scheduling options
- **Transfer Monitoring**: Real-time status updates and detailed transfer logs with bytes and files transferred statistics
- **File Metadata Tracking**: Complete history and status of all transferred files with detailed information:
- Process status (processed, archived, deleted)
- File size and hash information
- Advanced search and filtering capabilities
- Metadata retention for compliance and auditing
- Detailed file view with processing timestamps and job association
- Powerful filtering by status, filename, job, and date ranges
- Advanced search interface with multiple criteria
- Bulk management and record deletion capabilities
- Responsive design with mobile-friendly interface
- **Multi-threaded File Transfers**: Significantly improve performance with concurrent file processing:
- Configurable number of concurrent transfers (1-32) per job
- Automatic queue management to prevent system overload
- Independent configuration for each transfer job
- Optimized for both high-volume small files and large file transfers
- Maximizes bandwidth utilization for cloud storage providers
- **Web Interface**: User-friendly interface for managing transfers, built with Templ components
- **Command Line Tools**: Administrative tasks can be performed via the `gomftctl` CLI tool
- **File Pattern Matching**: Support for file patterns to filter files during transfers
- **File Output Patterns**: Dynamic naming of destination files using patterns with date variables
- **Archive Function**: Option to archive transferred files for backup and compliance
- **Transfer Configurations**: Full control over source and destination connection parameters
- **Job Management**: Create, edit, and monitor transfer jobs with scheduling
- **Security**: Role-based access control with admin-managed user accounts and secure password management
- **Authentication Providers**: Flexible authentication options:
- Built-in email/password authentication
- Authentik integration for enterprise SSO
- OpenID Connect (OIDC) support for standard identity providers
- OAuth2 integration for popular providers (Google, GitHub, etc.)
- Multiple provider support with fallback options
- Automatic user provisioning from external providers
- Role mapping from external identity providers
- **Password Recovery**: Self-service password reset via email with secure token-based authentication
- **User Profile Management**: Personal settings including theme preferences
- **Modern UI**: Built with Templ, HTMX and Tailwind CSS for a responsive experience
- **Docker Support**: Easy deployment with Docker images and Docker Compose support
- **Portable Deployment**: Run on any platform that supports Docker or Go
---
## Prerequisites
- Go 1.21 or later
- rclone installed and configured
- SQLite 3
---
## Installation
### Standard Installation
@@ -71,11 +144,18 @@ cd gomft
2. Install dependencies:
```bash
go mod download
go install github.com/a-h/templ/cmd/templ@latest
```
3. Build the application:
3. Generate template code:
```bash
templ generate
```
4. Build the application and CLI tools:
```bash
go build -o gomft
go build -o gomftctl ./cmd/gomftctl
```
### Docker Installation
@@ -88,19 +168,58 @@ docker pull starfleetcptn/gomft:latest
```
2. Run the container:
#### Basic run
```bash
docker run -d \
--name gomft \
-p 8080:8080 \
-v /path/to/data:/app/data \
-v /path/to/backups:/app/backups \
starfleetcptn/gomft:latest
```
#### Run with specific user ID and group ID (using environment variables)
```bash
docker run -d \
--name gomft \
-p 8080:8080 \
-v /path/to/data:/app/data \
-v /path/to/backups:/app/backups \
-e PUID=$(id -u) \
-e PGID=$(id -g) \
starfleetcptn/gomft:latest
```
#### Or specify user IDs directly
```bash
docker run -d \
--name gomft \
-p 8080:8080 \
-v /path/to/data:/app/data \
-v /path/to/backups:/app/backups \
-e PUID=1001 \
-e PGID=1001 \
starfleetcptn/gomft:latest
```
#### Using a .env file for configuration
```bash
docker run -d \
--name gomft \
-p 8080:8080 \
-v /path/to/data:/app/data \
-v /path/to/backups:/app/backups \
-v /path/to/.env:/app/.env \
-e PUID=$(id -u) \
-e PGID=$(id -g) \
starfleetcptn/gomft:latest
```
3. Access the web interface at `http://localhost:8080`
#### Docker Compose Example
For production deployments, you can use Docker Compose:
For production deployments, you can use Docker Compose with environment variables:
```yaml
version: '3'
@@ -113,11 +232,38 @@ services:
- "8080:8080"
volumes:
- ./data:/app/data
- ./backups:/app/data/gomft/backups
- ./backups:/app/backups
- ./.env:/app/.env
environment:
- PUID=1000
- PGID=1000
- TZ=UTC
- SERVER_ADDRESS=:8080
- DATA_DIR=/app/data
- BACKUP_DIR=/app/backups
- JWT_SECRET=change_this_to_a_secure_random_string
- BASE_URL=http://localhost:8080
- GOOGLE_CLIENT_ID=your_google_client_id
- GOOGLE_CLIENT_SECRET=your_google_client_secret
- TOTP_ENCRYPTION_KEY=your_32_byte_encryption_key_here
- GOMFT_ENCRYPTION_KEY=your_32_byte_encryption_key_here
- EMAIL_ENABLED=true
- EMAIL_HOST=smtp.example.com
- EMAIL_PORT=587
- EMAIL_FROM_EMAIL=gomft@example.com
- EMAIL_FROM_NAME=GoMFT
- EMAIL_ENABLE_TLS=true
- EMAIL_REQUIRE_AUTH=true
- EMAIL_USERNAME=smtp_username
- EMAIL_PASSWORD=smtp_password
- LOGS_DIR=/app/data/logs
- LOG_MAX_SIZE=10
- LOG_MAX_BACKUPS=5
- LOG_MAX_AGE=30
- LOG_COMPRESS=true
- LOG_LEVEL=info
# The user directive is no longer needed when using PUID/PGID environment variables
```
Save this as `docker-compose.yml` and run:
```bash
@@ -126,204 +272,47 @@ docker-compose up -d
For more information and available tags, visit the [GoMFT Docker Hub page](https://hub.docker.com/r/starfleetcptn/gomft).
## Configuration
## Command Line Tools
GoMFT uses a configuration file located at `./data/gomft/config.json`. On first run, a default configuration will be created:
GoMFT includes a command line tool called `gomftctl` for administrative tasks:
```json
{
"server_address": ":8080",
"data_dir": "./data/gomft",
"backup_dir": "./data/gomft/backups",
"jwt_secret": "your-secret-key",
"base_url": "http://localhost:8080",
"email": {
"enabled": false,
"host": "smtp.example.com",
"port": 587,
"username": "user@example.com",
"password": "your-password",
"from_email": "gomft@example.com",
"from_name": "GoMFT",
"reply_to": "",
"enable_tls": true,
"require_auth": true
}
}
```
### Configuration Options
- `server_address`: The address and port to run the server on
- `data_dir`: Directory for storing application data
- `backup_dir`: Directory for storing database backups
- `jwt_secret`: Secret key for JWT token generation
- `base_url`: Base URL for generating links in emails (e.g., password reset links)
- `email`: Email configuration settings for system notifications and password resets
- `enabled`: Set to `true` to enable email functionality
- `host`: SMTP server hostname
- `port`: SMTP server port (usually 587 for TLS, 465 for SSL, or 25 for non-secure)
- `username`: Username for SMTP authentication
- `password`: Password for SMTP authentication
- `from_email`: Email address used as sender
- `from_name`: Name displayed as the sender
- `reply_to`: Optional reply-to email address
- `enable_tls`: Set to `true` to use TLS for secure email transmission
- `require_auth`: Set to `true` to require authentication for SMTP connections, or `false` for servers that don't need authentication
## Usage
1. Start the server:
```bash
./gomft
# View available commands
./gomftctl --help
# Migrate provider data
./gomftctl migrate-providers
# Rotate security keys
./gomftctl rotate-key --type jwt
# Manage users
./gomftctl user create --email admin@example.com --password secure_password --admin
./gomftctl user list
# Backup database
./gomftctl backup
```
2. Access the web interface at `http://localhost:8080`
3. Log in with the default admin account:
- Email: `admin@example.com`
- Password: `admin`
- **Important**: Change this password immediately after first login
4. Create transfer configurations:
- Navigate to "Transfer Configs" section
- Configure source and destination locations with connection details
- Set file patterns and archive options as needed
5. Create jobs using your configurations:
- Navigate to "Jobs" section
- Select an existing transfer config
- Set up a schedule using cron expressions or run manually
- Enable/disable as needed
6. Monitor transfers:
- View active and completed transfers on the Dashboard
- Check detailed transfer history with performance metrics
- View job run details including any error messages
### User Management
GoMFT uses a role-based access control system:
- **Administrators**: Can create and manage users, access all features
- **Regular Users**: Can manage transfers and view history
User management features:
- Only administrators can create new user accounts
- User passwords are securely hashed with bcrypt
- Password history tracking prevents reuse of recent passwords
- Account lockout after multiple failed login attempts
- Self-service password reset via secure email links
- JWT-based authentication with tokens
- User theme preference settings (light/dark)
### Transfer Configuration Options
1. **Source/Destination Types**:
- Local filesystem
- Amazon S3
- MinIO (S3-compatible storage)
- Backblaze B2
- SFTP
- FTP
- SMB/CIFS shares
- And many more via rclone
2. **Connection Options**:
- Host/server addresses
- Authentication (username/password or key files)
- Port configurations
- Cloud credentials (access keys, secret keys)
- Bucket and region settings
- Custom endpoints
- Custom rclone flags
3. **File Options**:
- File patterns for filtering (e.g., `*.txt`, `data_*.csv`)
- Output patterns for dynamic naming
- Archive options for transferred files
4. **Schedule Options**:
- Cron expressions for flexible scheduling
- Manual execution
- Enable/disable schedules
### Email Notifications
GoMFT supports email notifications for various features:
- **Password Reset**: Users can request password reset links sent to their registered email
- **Styled Emails**: Professional HTML emails that match the application's design theme
- **Secure Tokens**: One-time use secure tokens with 15-minute expiration for enhanced security
- **Flexible Configuration**: Easily configure your SMTP server settings
- **Authentication Options**: Support for both authenticated and unauthenticated SMTP servers
- **TLS Support**: Secure communication with your SMTP server
- **Development Mode**: When emails are disabled, reset links are logged to the console
To configure email functionality:
1. Edit the `config.json` file and provide your SMTP server details
2. Set `"enabled": true` in the email configuration section
3. Ensure the `base_url` setting is configured correctly for your deployment
## Development
### Project Structure
```
.
├── components/ # Templ components for UI
├── internal/
│ ├── api/ # REST API handlers
│ ├── auth/ # Authentication/authorization
│ ├── config/ # Configuration management
│ ├── db/ # Database models and operations
│ ├── email/ # Email service for notifications and password resets
│ ├── scheduler/ # Job scheduling and execution
│ └── web/ # Web interface handlers
├── static/ # Static assets
│ ├── css/
│ └── js/
└── main.go # Application entry point
```
### Technology Stack
- **Backend**: Go with Gin web framework
- **Frontend**: Templ for Go HTML components
- **UI Enhancement**: HTMX for dynamic interactions
- **Styling**: Tailwind CSS
- **Authentication**: JWT (JSON Web Tokens)
- **Database**: GORM with SQLite
- **File Transfer**: rclone
- **Deployment**: Docker containerization and traditional installation
### Building from Source
1. Install development dependencies:
```bash
go install github.com/cosmtrek/air@latest # Hot reload for development
go install github.com/a-h/templ/cmd/templ@latest # Templ template compiler
```
2. Generate template code:
```bash
templ generate
```
3. Run in development mode:
```bash
air
```
See the [Admin Tools documentation](https://starfleetcptn.github.io/GoMFT/docs/advanced/admin-tools) for more details.
## Contributing
Contributions are welcome! Please see our [Contributing Guide](https://starfleetcptn.github.io/GoMFT/docs/contributing) for details on how to get started.
1. Fork the repository
2. Create a feature branch
3. Commit your changes
4. Push to the branch
5. Create a Pull Request
3. Make your changes
4. Submit a pull request
We also welcome documentation improvements. The documentation source is available in the `docs/` directory.
## License
MIT License - see LICENSE file for details
[MIT License](LICENSE) - see the full license terms
The GoMFT logo is licensed under the Creative Commons Attribution 4.0 International Public License.
The gopher design is from https://github.com/egonelbre/gophers.
The original Go gopher was designed by Renee French (http://reneefrench.blogspot.com/).
+122
View File
@@ -0,0 +1,122 @@
import * as esbuild from 'esbuild';
import path from 'path';
import { fileURLToPath } from 'url';
import { execSync } from 'child_process';
import fs from 'fs';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const isWatch = process.argv.includes('--watch');
// Ensure the dist directory exists
const distDir = path.join(__dirname, 'static', 'dist');
if (!fs.existsSync(distDir)) {
fs.mkdirSync(distDir, { recursive: true });
}
// Copy Font Awesome files
const fontAwesomeSrcDir = path.join(__dirname, 'node_modules', '@fortawesome', 'fontawesome-free');
const fontAwesomeDestDir = path.join(distDir, 'fontawesome');
// Copy CSS files
const cssFiles = [
'css/all.min.css',
'css/fontawesome.min.css',
'css/solid.min.css',
'css/regular.min.css',
'css/brands.min.css'
];
cssFiles.forEach(file => {
const srcFile = path.join(fontAwesomeSrcDir, file);
const destFile = path.join(fontAwesomeDestDir, file);
const destDir = path.dirname(destFile);
if (!fs.existsSync(destDir)) {
fs.mkdirSync(destDir, { recursive: true });
}
if (fs.existsSync(srcFile)) {
fs.copyFileSync(srcFile, destFile);
}
});
// Copy webfonts
const webfontsSrcDir = path.join(fontAwesomeSrcDir, 'webfonts');
const webfontsDestDir = path.join(fontAwesomeDestDir, 'webfonts');
if (!fs.existsSync(webfontsDestDir)) {
fs.mkdirSync(webfontsDestDir, { recursive: true });
}
fs.readdirSync(webfontsSrcDir).forEach(file => {
fs.copyFileSync(
path.join(webfontsSrcDir, file),
path.join(webfontsDestDir, file)
);
});
const commonConfig = {
sourcemap: true,
minify: true,
bundle: true,
platform: 'browser',
target: ['es2020'],
};
async function buildTailwind() {
console.log('Building Tailwind CSS...');
execSync('npx tailwindcss -i ./static/css/app.css -o ./static/dist/app.css --minify');
}
async function build() {
try {
// Build vendor JavaScript bundle (CDN dependencies)
await esbuild.build({
...commonConfig,
entryPoints: ['static/js/vendor.js'],
outfile: 'static/dist/vendor.js',
format: 'iife',
});
// Build application JavaScript
await esbuild.build({
...commonConfig,
entryPoints: ['static/js/app.js'],
outfile: 'static/dist/app.js',
format: 'iife',
});
// Build initialization JavaScript
await esbuild.build({
...commonConfig,
entryPoints: ['static/js/init.js'],
outfile: 'static/dist/init.js',
format: 'iife',
});
// Build CSS with Tailwind
await buildTailwind();
console.log('Build completed successfully!');
} catch (error) {
console.error('Build failed:', error);
process.exit(1);
}
}
if (isWatch) {
// Watch mode
console.log('Starting watch mode...');
const ctx = await esbuild.context(commonConfig);
await ctx.watch();
// Watch Tailwind CSS
execSync('npx tailwindcss -i ./static/css/app.css -o ./static/dist/app.css --watch');
console.log('Watching for changes...');
} else {
// Single build
build();
}
+772
View File
@@ -0,0 +1,772 @@
package main
import (
"context"
"encoding/base64"
"fmt"
"io"
"log"
"os"
"path/filepath"
"strings"
"time"
"github.com/spf13/cobra"
"github.com/starfleetcptn/gomft/internal/config"
"github.com/starfleetcptn/gomft/internal/db"
"github.com/starfleetcptn/gomft/internal/encryption"
"github.com/starfleetcptn/gomft/internal/encryption/keyrotation"
"golang.org/x/crypto/bcrypt"
)
func main() {
// Create root command
rootCmd := &cobra.Command{
Use: "gomftctl",
Short: "GoMFT Control Tool - Command line utilities for GoMFT",
Long: `GoMFT Control Tool (gomftctl) provides command line utilities for managing
your GoMFT installation, including database migrations, security key rotation,
and other administrative functions.`,
}
// Add commands
rootCmd.AddCommand(createMigrateCmd())
rootCmd.AddCommand(createKeyRotationCmd())
rootCmd.AddCommand(createVersionCmd())
rootCmd.AddCommand(createBackupCmd())
rootCmd.AddCommand(createUserCmd())
rootCmd.AddCommand(createEncryptionKeyRotationCmd())
// Execute the root command
if err := rootCmd.Execute(); err != nil {
fmt.Println(err)
os.Exit(1)
}
}
// createMigrateCmd creates the migrate command for provider data migration
func createMigrateCmd() *cobra.Command {
var dryRun, validationOnly, force, debugMode, autoFill bool
var backupDir string
migrateCmd := &cobra.Command{
Use: "migrate-providers",
Short: "Migrate provider data to the new storage provider model",
Long: `Migrate provider data extracts unique provider configurations from existing
transfer configs and creates dedicated storage provider records.
This command should be run when upgrading from older versions of GoMFT that
stored provider configuration directly in transfer configs.`,
Run: func(cmd *cobra.Command, args []string) {
// Load configuration
cfg, err := config.Load()
if err != nil {
log.Fatalf("Failed to load configuration: %v", err)
}
// Set backup directory if not provided
if backupDir == "" {
backupDir = cfg.BackupDir
}
// Initialize database
dbPath := filepath.Join(cfg.DataDir, "gomft.db")
database, err := db.Initialize(dbPath)
if err != nil {
log.Fatalf("Failed to initialize database: %v", err)
}
defer database.Close()
// Create migration options
options := db.MigrateProviderDataOptions{
DryRun: dryRun,
ValidationOnly: validationOnly,
Force: force,
BackupDir: backupDir,
DebugMode: debugMode,
AutoFill: autoFill,
}
// Run migration
fmt.Println("Starting provider data migration...")
stats, err := database.MigrateProviderData(options)
if err != nil {
fmt.Println("\nMigration failed with error:")
fmt.Printf("Error: %v\n", err)
// Add more detailed error information
fmt.Println("\nDetailed error information:")
fmt.Println("===========================")
// Unwrap nested errors if possible
var currentErr error = err
depth := 1
for currentErr != nil {
fmt.Printf("%d. %v\n", depth, currentErr)
if unwrapped, ok := currentErr.(interface{ Unwrap() error }); ok {
currentErr = unwrapped.Unwrap()
depth++
} else {
break
}
}
// Print database connection information (without sensitive details)
fmt.Println("\nDatabase information:")
fmt.Printf("- Database path: %s\n", dbPath)
fmt.Printf("- Migration options: dryRun=%v, validationOnly=%v, force=%v\n",
options.DryRun, options.ValidationOnly, options.Force)
log.Fatalf("Migration failed. See details above.")
}
// Print report
fmt.Println(db.FormatMigrationReport(stats))
},
}
// Add flags
migrateCmd.Flags().BoolVar(&dryRun, "dry-run", false, "Simulate migration without making changes")
migrateCmd.Flags().BoolVar(&validationOnly, "validate-only", false, "Only validate if migration is possible without making changes")
migrateCmd.Flags().BoolVar(&force, "force", false, "Force migration even if validation fails")
migrateCmd.Flags().StringVar(&backupDir, "backup-dir", "", "Directory to store backup data (defaults to config backup_dir)")
migrateCmd.Flags().BoolVar(&debugMode, "debug", false, "Enable debug mode with more detailed error messages")
migrateCmd.Flags().BoolVar(&autoFill, "auto-fill", false, "Automatically fill missing required fields with placeholder values")
return migrateCmd
}
// createKeyRotationCmd creates the key rotation command
func createKeyRotationCmd() *cobra.Command {
var keyType string
var writeToEnv bool
keyRotationCmd := &cobra.Command{
Use: "rotate-key",
Short: "Rotate security keys used by GoMFT",
Long: `Rotate security keys generates new cryptographic keys for GoMFT.
Available key types:
- jwt: JSON Web Token signing key
- totp: TOTP encryption key
- encryption: General encryption key used for sensitive data
This command will generate a new key and provide instructions for updating
your configuration. The application must be restarted for changes to take effect.`,
Run: func(cmd *cobra.Command, args []string) {
// Validate key type
validTypes := map[string]string{
"jwt": "JWT_SECRET",
"totp": "TOTP_ENCRYPTION_KEY",
"encryption": "GOMFT_ENCRYPTION_KEY",
}
envVar, valid := validTypes[keyType]
if !valid {
log.Fatalf("Invalid key type: %s. Valid types are: jwt, totp, encryption", keyType)
}
// Load configuration
_, err := config.Load()
if err != nil {
log.Fatalf("Failed to load configuration: %v", err)
}
// Generate a new key
newKey, err := generateSecureKey()
if err != nil {
log.Fatalf("Failed to generate secure key: %v", err)
}
fmt.Printf("Generated new %s key: %s\n\n", keyType, newKey)
if writeToEnv {
// Read current .env file
envPath := ".env"
envContent, err := os.ReadFile(envPath)
if err != nil {
log.Fatalf("Failed to read .env file: %v", err)
}
// Update .env file with new key
updatedEnv, updated := updateEnvVar(string(envContent), envVar, newKey)
if !updated {
// If the variable wasn't found, append it
updatedEnv = updatedEnv + fmt.Sprintf("\n%s=%s\n", envVar, newKey)
}
// Write updated content back to .env file
if err := os.WriteFile(envPath, []byte(updatedEnv), 0644); err != nil {
log.Fatalf("Failed to write updated .env file: %v", err)
}
fmt.Printf("Updated %s in .env file\n", envVar)
fmt.Println("Please restart the GoMFT application for changes to take effect.")
} else {
// Print instructions for manual update
fmt.Println("To use this key, update your .env file with:")
fmt.Printf("%s=%s\n\n", envVar, newKey)
fmt.Println("Then restart the GoMFT application for changes to take effect.")
}
},
}
// Add flags
keyRotationCmd.Flags().StringVar(&keyType, "type", "", "Type of key to rotate (jwt, totp, encryption)")
keyRotationCmd.Flags().BoolVar(&writeToEnv, "write", false, "Write the new key directly to .env file")
keyRotationCmd.MarkFlagRequired("type")
return keyRotationCmd
}
// createVersionCmd creates the version command
func createVersionCmd() *cobra.Command {
return &cobra.Command{
Use: "version",
Short: "Display version information",
Run: func(cmd *cobra.Command, args []string) {
// Import the version from the components package
fmt.Println("GoMFT Control Tool")
fmt.Println("Version: Same as GoMFT application")
fmt.Println("Visit https://github.com/starfleetcptn/gomft for more information")
},
}
}
// createBackupCmd creates the backup command
func createBackupCmd() *cobra.Command {
var outputDir string
backupCmd := &cobra.Command{
Use: "backup",
Short: "Create a backup of the GoMFT database",
Long: `Create a backup of the GoMFT database and configuration.
The backup includes the SQLite database file and the .env configuration file.`,
Run: func(cmd *cobra.Command, args []string) {
// Load configuration
cfg, err := config.Load()
if err != nil {
log.Fatalf("Failed to load configuration: %v", err)
}
// Set output directory if not provided
if outputDir == "" {
outputDir = cfg.BackupDir
}
// Ensure output directory exists
if err := os.MkdirAll(outputDir, 0755); err != nil {
log.Fatalf("Failed to create backup directory: %v", err)
}
// Create timestamp for backup filename
timestamp := fmt.Sprintf("%s", filepath.Base(os.Args[0]))
// Create backup
dbPath := filepath.Join(cfg.DataDir, "gomft.db")
backupPath := filepath.Join(outputDir, fmt.Sprintf("gomft-backup-%s.db", timestamp))
// Copy database file
if err := copyFile(dbPath, backupPath); err != nil {
log.Fatalf("Failed to create database backup: %v", err)
}
// Copy .env file if it exists
envPath := ".env"
backupEnvPath := filepath.Join(outputDir, fmt.Sprintf("gomft-env-backup-%s.env", timestamp))
if _, err := os.Stat(envPath); err == nil {
if err := copyFile(envPath, backupEnvPath); err != nil {
log.Fatalf("Failed to backup .env file: %v", err)
}
fmt.Printf("Configuration backed up to: %s\n", backupEnvPath)
}
fmt.Printf("Database backed up to: %s\n", backupPath)
},
}
// Add flags
backupCmd.Flags().StringVar(&outputDir, "output-dir", "", "Directory to store backup files (defaults to config backup_dir)")
return backupCmd
}
// createUserCmd creates the user management command
func createUserCmd() *cobra.Command {
userCmd := &cobra.Command{
Use: "user",
Short: "User management commands",
Long: `Commands for managing GoMFT users, including creating, updating, and listing users.`,
}
// Add subcommands
userCmd.AddCommand(createUserCreateCmd())
userCmd.AddCommand(createUserResetPasswordCmd())
userCmd.AddCommand(createUserListCmd())
return userCmd
}
// createUserCreateCmd creates the user create command
func createUserCreateCmd() *cobra.Command {
var email, password string
var isAdmin bool
createCmd := &cobra.Command{
Use: "create",
Short: "Create a new user",
Long: `Create a new user with the specified email and password.`,
Run: func(cmd *cobra.Command, args []string) {
// Load configuration
cfg, err := config.Load()
if err != nil {
log.Fatalf("Failed to load configuration: %v", err)
}
// Initialize database
dbPath := filepath.Join(cfg.DataDir, "gomft.db")
database, err := db.Initialize(dbPath)
if err != nil {
log.Fatalf("Failed to initialize database: %v", err)
}
defer database.Close()
// Create user by first generating password hash
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
log.Fatalf("Failed to hash password: %v", err)
}
// Create user object
user := &db.User{
Email: email,
PasswordHash: string(hashedPassword),
LastPasswordChange: time.Now(),
}
// Set admin status if requested
if isAdmin {
user.SetIsAdmin(true)
}
// Save user to database
if err := database.CreateUser(user); err != nil {
log.Fatalf("Failed to create user: %v", err)
}
fmt.Printf("User created successfully:\n")
fmt.Printf(" ID: %d\n", user.ID)
fmt.Printf(" Email: %s\n", user.Email)
fmt.Printf(" Admin: %t\n", user.GetIsAdmin())
},
}
// Add flags
createCmd.Flags().StringVar(&email, "email", "", "User email address")
createCmd.Flags().StringVar(&password, "password", "", "User password")
createCmd.Flags().BoolVar(&isAdmin, "admin", false, "Grant admin privileges to the user")
createCmd.MarkFlagRequired("email")
createCmd.MarkFlagRequired("password")
return createCmd
}
// createUserResetPasswordCmd creates the user reset-password command
func createUserResetPasswordCmd() *cobra.Command {
var email, newPassword string
resetCmd := &cobra.Command{
Use: "reset-password",
Short: "Reset a user's password",
Long: `Reset the password for a user with the specified email address.`,
Run: func(cmd *cobra.Command, args []string) {
// Load configuration
cfg, err := config.Load()
if err != nil {
log.Fatalf("Failed to load configuration: %v", err)
}
// Initialize database
dbPath := filepath.Join(cfg.DataDir, "gomft.db")
database, err := db.Initialize(dbPath)
if err != nil {
log.Fatalf("Failed to initialize database: %v", err)
}
defer database.Close()
// Find user by email
var user db.User
if err := database.Where("email = ?", email).First(&user).Error; err != nil {
log.Fatalf("Failed to find user with email %s: %v", email, err)
}
// Generate new password hash
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
if err != nil {
log.Fatalf("Failed to hash password: %v", err)
}
// Update user password
user.PasswordHash = string(hashedPassword)
user.LastPasswordChange = time.Now()
// Save user to database
if err := database.Save(&user).Error; err != nil {
log.Fatalf("Failed to update user: %v", err)
}
fmt.Printf("Password reset successfully for user: %s\n", email)
},
}
// Add flags
resetCmd.Flags().StringVar(&email, "email", "", "User email address")
resetCmd.Flags().StringVar(&newPassword, "password", "", "New password")
resetCmd.MarkFlagRequired("email")
resetCmd.MarkFlagRequired("password")
return resetCmd
}
// createUserListCmd creates the user list command
func createUserListCmd() *cobra.Command {
return &cobra.Command{
Use: "list",
Short: "List all users",
Long: `List all users in the GoMFT system.`,
Run: func(cmd *cobra.Command, args []string) {
// Load configuration
cfg, err := config.Load()
if err != nil {
log.Fatalf("Failed to load configuration: %v", err)
}
// Initialize database
dbPath := filepath.Join(cfg.DataDir, "gomft.db")
database, err := db.Initialize(dbPath)
if err != nil {
log.Fatalf("Failed to initialize database: %v", err)
}
defer database.Close()
// Get all users
var users []db.User
if err := database.Find(&users).Error; err != nil {
log.Fatalf("Failed to get users: %v", err)
}
// Print users
fmt.Println("GoMFT Users:")
fmt.Println("ID\tEmail\tAdmin\tLast Updated")
fmt.Println("--------------------------------------------------")
for _, user := range users {
lastUpdated := "Never"
if !user.UpdatedAt.IsZero() {
lastUpdated = user.UpdatedAt.Format("2006-01-02 15:04:05")
}
fmt.Printf("%d\t%s\t%t\t%s\n", user.ID, user.Email, user.GetIsAdmin(), lastUpdated)
}
},
}
}
// createEncryptionKeyRotationCmd creates the encryption key rotation command
func createEncryptionKeyRotationCmd() *cobra.Command {
var dryRun bool
var batchSize, maxErrors int
var backupDir string
var skipBackup bool
var oldKeyEnvVar string
var modelsFlag string
rotateCmd := &cobra.Command{
Use: "rotate-encryption-key",
Short: "Rotate encryption keys for sensitive data",
Long: `Rotate encryption keys for sensitive data stored in the database.
This command will:
1. Create a backup of your database (unless --skip-backup is specified)
2. Re-encrypt all sensitive data with a new encryption key
3. Provide instructions for updating your configuration
The application must be stopped before running this command to prevent data corruption.
`,
Run: func(cmd *cobra.Command, args []string) {
// Load configuration
cfg, err := config.Load()
if err != nil {
log.Fatalf("Failed to load configuration: %v", err)
}
// Set backup directory if not provided
if backupDir == "" {
backupDir = cfg.BackupDir
}
// Create backup if needed
if !skipBackup {
dbPath := filepath.Join(cfg.DataDir, "gomft.db")
backupPath := filepath.Join(backupDir, fmt.Sprintf("gomft_backup_before_key_rotation_%s.db",
time.Now().Format("20060102_150405")))
fmt.Printf("Creating database backup at %s...\n", backupPath)
if err := copyFile(dbPath, backupPath); err != nil {
log.Fatalf("Failed to create backup: %v", err)
}
fmt.Println("Backup created successfully.")
} else {
fmt.Println("Skipping database backup as requested.")
}
// Initialize database
dbPath := filepath.Join(cfg.DataDir, "gomft.db")
database, err := db.Initialize(dbPath)
if err != nil {
log.Fatalf("Failed to initialize database: %v", err)
}
defer database.Close()
// Setup old encryption service
if oldKeyEnvVar == "" {
oldKeyEnvVar = encryption.DefaultKeyEnvVar
}
// Get the current encryption service
oldService, err := encryption.GetGlobalEncryptionService()
if err != nil {
log.Fatalf("Failed to get current encryption service: %v", err)
}
// Generate new key
newKey, err := encryption.GenerateKey(encryption.AES256KeySize)
if err != nil {
log.Fatalf("Failed to generate new encryption key: %v", err)
}
// Create new key manager for the new key
newKeyManager := &keyManager{key: newKey}
// Setup new encryption service with the new key
newService, err := encryption.NewEncryptionService(newKeyManager)
if err != nil {
log.Fatalf("Failed to create new encryption service: %v", err)
}
// Create rotation options
options := keyrotation.RotationOptions{
DryRun: dryRun,
BatchSize: batchSize,
MaxErrors: maxErrors,
Timeout: 24 * time.Hour,
ProgressCallback: func(modelName string, processed, total int) {
fmt.Printf("\rProcessing %s: %d/%d records (%.1f%%)",
modelName, processed, total, float64(processed)/float64(total)*100)
},
}
// Create rotation utility
rotationUtil, err := keyrotation.NewRotationUtility(
database.DB, // Use the underlying gorm.DB
oldService,
newService,
nil, // No auditor needed, keyrotation will use the global one
options,
)
if err != nil {
log.Fatalf("Failed to create rotation utility: %v", err)
}
// Find models with encrypted fields
var models []interface{}
if modelsFlag == "auto" {
fmt.Println("Automatically detecting models with encrypted fields...")
models, err = rotationUtil.FindModelsWithEncryptedFields()
if err != nil {
log.Fatalf("Failed to find models with encrypted fields: %v", err)
}
if len(models) == 0 {
log.Fatalf("No models with encrypted fields found")
}
} else if modelsFlag != "" {
// TODO: Support manual model specification
log.Fatalf("Manual model specification not yet implemented, use --models=auto")
} else {
log.Fatalf("No models specified, use --models=auto to automatically detect models")
}
// Create migration plan
fmt.Println("Creating encryption migration plan...")
plan, err := rotationUtil.CreateEncryptionMigrationPlan(models)
if err != nil {
log.Fatalf("Failed to create migration plan: %v", err)
}
// Print plan
fmt.Println("\nEncryption Migration Plan:")
fmt.Printf("Total models: %d\n", len(plan.ModelPlans))
fmt.Printf("Total records: %d\n", plan.EstimatedRecords)
fmt.Printf("Estimated duration: %s\n", plan.EstimatedDuration.Round(time.Second))
fmt.Println("\nModels to process:")
for name, modelPlan := range plan.ModelPlans {
fmt.Printf("- %s: %d records, %d encrypted fields\n",
name, modelPlan.RecordCount, len(modelPlan.EncryptedFields))
}
// Confirm if not in dry run mode
if !dryRun {
fmt.Println("\nWARNING: This operation will re-encrypt all sensitive data with a new key.")
fmt.Println("Make sure the application is stopped before proceeding.")
fmt.Print("\nDo you want to continue? [y/N]: ")
var response string
fmt.Scanln(&response)
if strings.ToLower(response) != "y" {
fmt.Println("Operation cancelled.")
return
}
}
// Perform key rotation
fmt.Println("\nStarting key rotation...")
startTime := time.Now()
stats, err := rotationUtil.RotateKeysForModels(context.Background(), models)
if err != nil {
fmt.Println("\nKey rotation failed with error:")
fmt.Printf("Error: %v\n", err)
// Add more detailed error information
fmt.Println("\nDetailed error information:")
fmt.Println("===========================")
// Unwrap nested errors if possible
var currentErr error = err
depth := 1
for currentErr != nil {
fmt.Printf("%d. %v\n", depth, currentErr)
if unwrapped, ok := currentErr.(interface{ Unwrap() error }); ok {
currentErr = unwrapped.Unwrap()
depth++
} else {
break
}
}
// Print rotation configuration details
fmt.Println("\nRotation configuration:")
fmt.Printf("- Dry run: %v\n", dryRun)
fmt.Printf("- Batch size: %d\n", batchSize)
fmt.Printf("- Max errors: %d\n", maxErrors)
fmt.Printf("- Models: %s\n", modelsFlag)
fmt.Printf("- Old key env var: %s\n", oldKeyEnvVar)
log.Fatalf("Key rotation failed. See details above.")
}
duration := time.Since(startTime).Round(time.Second)
// Print results
fmt.Println("\nKey rotation completed successfully!")
fmt.Printf("Total records processed: %d/%d\n", stats.ProcessedRecords, stats.TotalRecords)
fmt.Printf("Failed records: %d\n", stats.FailedRecords)
fmt.Printf("Duration: %s\n", duration)
if len(stats.Errors) > 0 {
fmt.Printf("\nErrors (%d):\n", len(stats.Errors))
for i, err := range stats.Errors {
if i >= 10 {
fmt.Printf("... and %d more errors\n", len(stats.Errors)-10)
break
}
fmt.Printf("- %s\n", err)
}
}
// Print next steps
if !dryRun {
fmt.Println("\nNext steps:")
fmt.Println("1. Update your environment variable or .env file with the new encryption key:")
fmt.Printf(" %s=%s\n", oldKeyEnvVar, base64.StdEncoding.EncodeToString(newKey))
fmt.Println("2. Restart your GoMFT application")
fmt.Println("\nIMPORTANT: Keep a backup of both the old and new keys until you verify everything works correctly.")
} else {
fmt.Println("\nDry run completed. No changes were made to the database.")
fmt.Println("Run without --dry-run to perform the actual key rotation.")
}
},
}
// Add flags
rotateCmd.Flags().BoolVar(&dryRun, "dry-run", false, "Simulate key rotation without making changes")
rotateCmd.Flags().IntVar(&batchSize, "batch-size", 100, "Number of records to process in each batch")
rotateCmd.Flags().IntVar(&maxErrors, "max-errors", 50, "Maximum number of errors before aborting")
rotateCmd.Flags().StringVar(&backupDir, "backup-dir", "", "Directory to store backup data (defaults to config backup_dir)")
rotateCmd.Flags().BoolVar(&skipBackup, "skip-backup", false, "Skip database backup (not recommended)")
rotateCmd.Flags().StringVar(&oldKeyEnvVar, "old-key-env", "", "Environment variable containing the old encryption key (defaults to GOMFT_ENCRYPTION_KEY)")
rotateCmd.Flags().StringVar(&modelsFlag, "models", "auto", "Models to process (use 'auto' for automatic detection)")
return rotateCmd
}
// keyManager is a simple implementation of the encryption.KeyManager interface
// that uses a fixed key for the new encryption service
type keyManager struct {
key []byte
}
func (km *keyManager) Initialize() error {
// Already initialized with the key
return nil
}
func (km *keyManager) GetPrimaryKey() ([]byte, error) {
return km.key, nil
}
func (km *keyManager) GetEnvironmentVariableName() string {
return "TEMP_KEY_MANAGER"
}
func (km *keyManager) StoreKeyEnvironment(key []byte) error {
// Not needed for this implementation
return nil
}
// Helper functions
// generateSecureKey creates a cryptographically secure random key encoded as base64
func generateSecureKey() (string, error) {
return config.GenerateSecureKey()
}
// updateEnvVar updates an environment variable in the .env file content
func updateEnvVar(content, key, value string) (string, bool) {
lines := strings.Split(content, "\n")
prefix := key + "="
updated := false
for i, line := range lines {
if strings.HasPrefix(line, prefix) {
lines[i] = prefix + value
updated = true
break
}
}
return strings.Join(lines, "\n"), updated
}
// copyFile copies a file from src to dst
func copyFile(src, dst string) error {
srcFile, err := os.Open(src)
if err != nil {
return err
}
defer srcFile.Close()
dstFile, err := os.Create(dst)
if err != nil {
return err
}
defer dstFile.Close()
_, err = io.Copy(dstFile, srcFile)
return err
}
+125
View File
@@ -0,0 +1,125 @@
package components
import (
"context"
)
// AdminLayout renders the main admin dashboard
templ AdminLayout(ctx context.Context) {
@LayoutWithContext("Administration", ctx) {
<div class="flex flex-col md:flex-row bg-gray-50 dark:bg-gray-900 min-h-screen">
<!-- Sidebar Navigation -->
<nav class="md:w-64 md:min-h-screen bg-white border-r border-gray-200 dark:bg-gray-800 dark:border-gray-700">
<div class="p-4 overflow-y-auto">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Admin Console</h2>
<ul class="space-y-2">
<li>
<a href="/admin" class="flex items-center p-2 text-gray-900 rounded-lg dark:text-white bg-gray-100 dark:bg-gray-700 group">
<i class="fas fa-tachometer-alt w-5 h-5 text-gray-500 dark:text-gray-400 mr-3"></i>
Dashboard
</a>
</li>
<li>
<a href="/admin/users" class="flex items-center p-2 text-gray-900 rounded-lg dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700 group">
<i class="fas fa-users w-5 h-5 text-gray-500 dark:text-gray-400 mr-3"></i>
User Management
</a>
</li>
<li>
<a href="/admin/roles" class="flex items-center p-2 text-gray-900 rounded-lg dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700 group">
<i class="fas fa-user-shield w-5 h-5 text-gray-500 dark:text-gray-400 mr-3"></i>
Role Management
</a>
</li>
<li>
<a href="/admin/audit" class="flex items-center p-2 text-gray-900 rounded-lg dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700 group">
<i class="fas fa-clipboard-list w-5 h-5 text-gray-500 dark:text-gray-400 mr-3"></i>
Audit Logs
</a>
</li>
<li>
<a href="/admin/database" class="flex items-center p-2 text-gray-900 rounded-lg dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700 group">
<i class="fas fa-database w-5 h-5 text-gray-500 dark:text-gray-400 mr-3"></i>
Database Tools
</a>
</li>
<li>
<a href="/admin/settings" class="flex items-center p-2 text-gray-900 rounded-lg dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700 group">
<i class="fas fa-cog w-5 h-5 text-gray-500 dark:text-gray-400 mr-3"></i>
System Settings
</a>
</li>
</ul>
</div>
</nav>
<!-- Main Content -->
<div class="flex-1 p-8">
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<!-- Statistics Cards -->
<div class="bg-white p-6 rounded-lg shadow-sm dark:bg-gray-800">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-500 dark:text-gray-400">Total Users</p>
<p class="text-2xl font-bold text-gray-900 dark:text-white">1,234</p>
</div>
<div class="bg-blue-100 p-3 rounded-full dark:bg-blue-900">
<i class="fas fa-users text-blue-500 w-6 h-6 dark:text-blue-300"></i>
</div>
</div>
</div>
<div class="bg-white p-6 rounded-lg shadow-sm dark:bg-gray-800">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-500 dark:text-gray-400">Active Sessions</p>
<p class="text-2xl font-bold text-gray-900 dark:text-white">42</p>
</div>
<div class="bg-green-100 p-3 rounded-full dark:bg-green-900">
<i class="fas fa-signal text-green-500 w-6 h-6 dark:text-green-300"></i>
</div>
</div>
</div>
<div class="bg-white p-6 rounded-lg shadow-sm dark:bg-gray-800">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-500 dark:text-gray-400">System Health</p>
<p class="text-2xl font-bold text-green-500 dark:text-green-400">98%</p>
</div>
<div class="bg-green-100 p-3 rounded-full dark:bg-green-900">
<i class="fas fa-heartbeat text-green-500 w-6 h-6 dark:text-green-300"></i>
</div>
</div>
</div>
</div>
<!-- Recent Activity Table -->
<div class="bg-white rounded-lg shadow-sm dark:bg-gray-800">
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Recent Activity</h3>
</div>
<div class="overflow-x-auto">
<table class="w-full">
<thead class="bg-gray-50 dark:bg-gray-700">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">User</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">Action</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">Timestamp</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
<!-- Example Rows -->
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700">
<td class="px-6 py-4 text-sm text-gray-900 dark:text-white">admin@example.com</td>
<td class="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">Updated user permissions</td>
<td class="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">2024-02-20 14:32:10</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
}
}
+384
View File
@@ -0,0 +1,384 @@
package components
import (
"context"
"fmt"
"time"
)
// AuditLogEntry represents an audit log entry for display
type AuditLogEntry struct {
ID uint
Action string
EntityType string
EntityID uint
Username string
UserID uint
Timestamp time.Time
Details map[string]interface{}
DetailsSummary string
}
type AuditLogsData struct {
Logs []AuditLogEntry
TotalPages int
CurrentPage int
TotalRecords int
FilterAction string
FilterEntity string
FilterUser string
FilterDateFrom string
FilterDateTo string
}
// AdminAuditLogs renders the audit logs page
templ AdminAuditLogs(ctx context.Context, data AuditLogsData) {
@LayoutWithContext("Audit Logs", ctx) {
<div id="audit-logs-container" style="min-height: 100vh;" class="audit-logs-page bg-gray-50 dark:bg-gray-900">
<div class="pb-8 w-full">
<!-- Page Header -->
<div class="mb-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
<i class="fas fa-clipboard-list w-6 h-6 mr-2 text-blue-500 dark:text-blue-400"></i>
Audit Logs
</h1>
<a href="/admin/audit/export" class="flex items-center justify-center text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg px-5 py-2.5 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800">
<i class="fas fa-file-export w-4 h-4 mr-2"></i>
Export Logs
</a>
</div>
<!-- Toast container for notifications -->
<div id="toast-container" class="fixed top-5 right-5 z-50 flex flex-col gap-2"></div>
<!-- Status and Error Messages -->
<div id="status-message" class="hidden mb-4 p-4 text-sm text-green-700 bg-green-100 rounded-lg dark:bg-green-200 dark:text-green-800" role="alert"></div>
<div id="error-message" class="hidden mb-4 p-4 text-sm text-red-700 bg-red-100 rounded-lg dark:bg-red-200 dark:text-red-800" role="alert">
<div class="font-medium error-title"></div>
<div class="error-details mt-1"></div>
</div>
<!-- Filters -->
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 mb-6">
<div class="p-4 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Filter Logs</h3>
</div>
<div class="p-4">
<form method="GET" action="/admin/audit" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4">
<!-- Action filter -->
<div>
<label for="filter-action" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Action</label>
<select id="filter-action" name="action" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500">
<option value="">All Actions</option>
if data.FilterAction == "create" {
<option value="create" selected>Create</option>
} else {
<option value="create">Create</option>
}
if data.FilterAction == "update" {
<option value="update" selected>Update</option>
} else {
<option value="update">Update</option>
}
if data.FilterAction == "delete" {
<option value="delete" selected>Delete</option>
} else {
<option value="delete">Delete</option>
}
if data.FilterAction == "login" {
<option value="login" selected>Login</option>
} else {
<option value="login">Login</option>
}
if data.FilterAction == "logout" {
<option value="logout" selected>Logout</option>
} else {
<option value="logout">Logout</option>
}
</select>
</div>
<!-- Entity filter -->
<div>
<label for="filter-entity" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Entity Type</label>
<select id="filter-entity" name="entity" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500">
<option value="">All Entities</option>
if data.FilterEntity == "user" {
<option value="user" selected>User</option>
} else {
<option value="user">User</option>
}
if data.FilterEntity == "role" {
<option value="role" selected>Role</option>
} else {
<option value="role">Role</option>
}
if data.FilterEntity == "config" {
<option value="config" selected>Config</option>
} else {
<option value="config">Config</option>
}
if data.FilterEntity == "job" {
<option value="job" selected>Job</option>
} else {
<option value="job">Job</option>
}
</select>
</div>
<!-- User filter -->
<div>
<label for="filter-user" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Username</label>
<input type="text" id="filter-user" name="user" value={data.FilterUser} placeholder="Filter by username" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" />
</div>
<!-- Date range -->
<div>
<label for="filter-date-from" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">From Date</label>
<input type="date" id="filter-date-from" name="date_from" value={data.FilterDateFrom} class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" />
</div>
<div>
<label for="filter-date-to" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">To Date</label>
<input type="date" id="filter-date-to" name="date_to" value={data.FilterDateTo} class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" />
</div>
<!-- Filter buttons -->
<div class="lg:col-span-5 flex items-center justify-end gap-2">
<a href="/admin/audit" class="px-4 py-2 text-sm font-medium text-gray-900 bg-white rounded-lg border border-gray-200 hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700">
Clear Filters
</a>
<button type="submit" class="px-4 py-2 text-sm font-medium text-white bg-blue-700 rounded-lg hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800">
Apply Filters
</button>
</div>
</form>
</div>
</div>
<!-- Audit Logs Table -->
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800">
<div class="p-4 border-b border-gray-200 dark:border-gray-700 flex justify-between items-center">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Audit Log Entries</h3>
<span class="text-sm text-gray-600 dark:text-gray-400">Total entries: {fmt.Sprint(data.TotalRecords)}</span>
</div>
<div class="overflow-x-auto">
<table class="w-full">
<thead class="bg-gray-50 dark:bg-gray-700">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">Timestamp</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">User</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">Action</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">Entity</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">Details</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
if len(data.Logs) == 0 {
<tr>
<td colspan="5" class="px-6 py-4 text-center text-gray-500 dark:text-gray-400">
No audit logs found matching your filters.
</td>
</tr>
} else {
for _, log := range data.Logs {
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700">
<td class="px-6 py-4 text-sm text-gray-900 dark:text-white">
{log.Timestamp.Format("2006-01-02 15:04:05")}
</td>
<td class="px-6 py-4 text-sm text-gray-900 dark:text-white">
{log.Username}
</td>
<td class="px-6 py-4 text-sm">
<span class={getActionClass(log.Action)}>
{log.Action}
</span>
</td>
<td class="px-6 py-4 text-sm text-gray-900 dark:text-white">
{log.EntityType}
<span class="text-xs text-gray-500 dark:text-gray-400">
(ID: {fmt.Sprint(log.EntityID)})
</span>
</td>
<td class="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">
<button
data-details={log.DetailsSummary}
data-modal-target="details-modal"
data-modal-toggle="details-modal"
class="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300 underline text-xs">
View Details
</button>
</td>
</tr>
}
}
</tbody>
</table>
</div>
<!-- Pagination -->
if data.TotalPages > 1 {
<div class="px-6 py-4 bg-gray-50 dark:bg-gray-700 border-t border-gray-200 dark:border-gray-600">
<div class="flex items-center justify-between">
<div class="text-sm text-gray-700 dark:text-gray-400">
Showing page {fmt.Sprint(data.CurrentPage)} of {fmt.Sprint(data.TotalPages)}
</div>
<div class="flex items-center space-x-2">
if data.CurrentPage > 1 {
<a href={buildPaginationURL(data, data.CurrentPage-1)} class="px-3 py-1 text-sm text-gray-500 bg-white border border-gray-300 rounded-md hover:bg-gray-100 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700">
Previous
</a>
}
<!-- Page numbers -->
<div class="hidden sm:flex space-x-1">
<!-- First page -->
if data.CurrentPage > 3 {
<a href={buildPaginationURL(data, 1)} class="px-3 py-1 text-sm text-gray-500 bg-white border border-gray-300 rounded-md hover:bg-gray-100 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700">
1
</a>
if data.CurrentPage > 4 {
<span class="px-3 py-1 text-sm text-gray-500 dark:text-gray-400">...</span>
}
}
<!-- Pages around current -->
for i := max(1, data.CurrentPage-2); i <= min(data.TotalPages, data.CurrentPage+2); i++ {
if i == data.CurrentPage {
<span class="px-3 py-1 text-sm text-white bg-blue-600 border border-blue-600 rounded-md dark:bg-blue-700">
{fmt.Sprint(i)}
</span>
} else {
<a href={buildPaginationURL(data, i)} class="px-3 py-1 text-sm text-gray-500 bg-white border border-gray-300 rounded-md hover:bg-gray-100 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700">
{fmt.Sprint(i)}
</a>
}
}
<!-- Last page -->
if data.CurrentPage < data.TotalPages-2 {
if data.CurrentPage < data.TotalPages-3 {
<span class="px-3 py-1 text-sm text-gray-500 dark:text-gray-400">...</span>
}
<a href={buildPaginationURL(data, data.TotalPages)} class="px-3 py-1 text-sm text-gray-500 bg-white border border-gray-300 rounded-md hover:bg-gray-100 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700">
{fmt.Sprint(data.TotalPages)}
</a>
}
</div>
if data.CurrentPage < data.TotalPages {
<a href={buildPaginationURL(data, data.CurrentPage+1)} class="px-3 py-1 text-sm text-gray-500 bg-white border border-gray-300 rounded-md hover:bg-gray-100 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700">
Next
</a>
}
</div>
</div>
</div>
}
</div>
</div>
</div>
<!-- Details Modal -->
<div id="details-modal" tabindex="-1" aria-hidden="true" class="hidden overflow-y-auto overflow-x-hidden fixed top-0 right-0 left-0 z-50 justify-center items-center w-full md:inset-0 h-[calc(100%-1rem)] max-h-full bg-gray-900/50 dark:bg-gray-900/80 backdrop-blur-sm">
<div class="relative p-4 w-full max-w-2xl max-h-full mx-auto">
<div class="relative bg-white rounded-lg shadow dark:bg-gray-700">
<div class="flex items-center justify-between p-4 md:p-5 border-b rounded-t dark:border-gray-600">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
Audit Log Details
</h3>
<button type="button" class="text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm w-8 h-8 ms-auto inline-flex justify-center items-center dark:hover:bg-gray-600 dark:hover:text-white" data-modal-hide="details-modal">
<i class="fas fa-times"></i>
<span class="sr-only">Close modal</span>
</button>
</div>
<div class="p-4 md:p-5 space-y-4">
<pre id="details-content" class="bg-gray-100 dark:bg-gray-800 p-4 rounded-lg text-sm text-gray-900 dark:text-gray-300 overflow-x-auto"></pre>
</div>
<div class="flex items-center p-4 md:p-5 border-t border-gray-200 rounded-b dark:border-gray-600">
<button data-modal-hide="details-modal" type="button" class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800">Close</button>
</div>
</div>
</div>
</div>
<script>
// Modal functionality
document.addEventListener('DOMContentLoaded', function() {
const modal = document.getElementById('details-modal');
const detailsContent = document.getElementById('details-content');
// Handle modal show/hide
document.querySelectorAll('[data-modal-target="details-modal"]').forEach(button => {
button.addEventListener('click', function() {
const details = this.getAttribute('data-details');
detailsContent.textContent = JSON.stringify(JSON.parse(details), null, 2);
modal.classList.remove('hidden');
});
});
document.querySelectorAll('[data-modal-hide="details-modal"]').forEach(button => {
button.addEventListener('click', function() {
modal.classList.add('hidden');
});
});
// Close modal when clicking outside
modal.addEventListener('click', function(e) {
if (e.target === modal) {
modal.classList.add('hidden');
}
});
});
</script>
}
}
// Helper functions
func getActionClass(action string) string {
baseClass := "px-2 py-1 text-xs rounded-full "
switch action {
case "create":
return baseClass + "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300"
case "update":
return baseClass + "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300"
case "delete":
return baseClass + "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300"
case "login":
return baseClass + "bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300"
case "logout":
return baseClass + "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300"
default:
return baseClass + "bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300"
}
}
func buildPaginationURL(data AuditLogsData, page int) templ.SafeURL {
url := fmt.Sprintf("/admin/audit?page=%d", page)
if data.FilterAction != "" {
url += fmt.Sprintf("&action=%s", data.FilterAction)
}
if data.FilterEntity != "" {
url += fmt.Sprintf("&entity=%s", data.FilterEntity)
}
if data.FilterUser != "" {
url += fmt.Sprintf("&user=%s", data.FilterUser)
}
if data.FilterDateFrom != "" {
url += fmt.Sprintf("&date_from=%s", data.FilterDateFrom)
}
if data.FilterDateTo != "" {
url += fmt.Sprintf("&date_to=%s", data.FilterDateTo)
}
return templ.SafeURL(url)
}
+442
View File
@@ -0,0 +1,442 @@
package components
import (
"context"
"time"
)
// BackupFile represents a database backup file
type BackupFile struct {
Name string
Size string
Created time.Time
FullPath string
}
// AdminDatabaseTools renders the database tools page with backup and restore functionality
templ AdminDatabaseTools(ctx context.Context, backups []BackupFile) {
@LayoutWithContext("Database Tools", ctx) {
<div id="database-tools-container" style="min-height: 100vh;" class="database-tools-page bg-gray-50 dark:bg-gray-900">
<div class="pb-8 w-full">
<!-- Page Header -->
<div class="mb-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
<i class="fas fa-database w-6 h-6 mr-2 text-blue-500 dark:text-blue-400"></i>
Database Tools
</h1>
<form id="backup-form" method="POST" action="/admin/database/backup-database">
<button type="submit" class="flex items-center justify-center text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg px-5 py-2.5 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800">
<i class="fas fa-download w-4 h-4 mr-2"></i>
Create Backup
</button>
</form>
</div>
<!-- Status and Error Messages -->
<div id="toast-container" class="fixed top-5 right-5 z-50 flex flex-col gap-2"></div>
<!-- Action Cards Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8">
<!-- Optimize Database Card -->
<div class="bg-white border border-gray-200 rounded-lg shadow-sm p-6 dark:bg-gray-800 dark:border-gray-700">
<div class="flex items-center mb-4">
<div class="bg-purple-100 p-3 rounded-full dark:bg-purple-900 mr-4">
<i class="fas fa-broom text-purple-500 w-6 h-6 dark:text-purple-300"></i>
</div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Optimize Database</h3>
</div>
<p class="text-gray-600 dark:text-gray-400 mb-4">Vacuum the database to reclaim space and optimize performance.</p>
<form id="vacuum-form" method="POST" action="/admin/database/vacuum-database">
<button type="submit" class="w-full px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 focus:outline-none focus:ring-2 focus:ring-purple-500 dark:bg-purple-700 dark:hover:bg-purple-800">
<i class="fas fa-magic mr-2"></i>
Optimize Database
</button>
</form>
</div>
<!-- Clear Job History Card -->
<div class="bg-white border border-gray-200 rounded-lg shadow-sm p-6 dark:bg-gray-800 dark:border-gray-700">
<div class="flex items-center mb-4">
<div class="bg-orange-100 p-3 rounded-full dark:bg-orange-900 mr-4">
<i class="fas fa-eraser text-orange-500 w-6 h-6 dark:text-orange-300"></i>
</div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Clear Job History</h3>
</div>
<p class="text-gray-600 dark:text-gray-400 mb-4">Remove old job history records to free up database space.</p>
<form id="clear-job-form" method="POST" action="/admin/database/clear-job-history">
<button type="submit" class="w-full px-4 py-2 bg-orange-600 text-white rounded-lg hover:bg-orange-700 focus:outline-none focus:ring-2 focus:ring-orange-500 dark:bg-orange-700 dark:hover:bg-orange-800">
<i class="fas fa-trash-alt mr-2"></i>
Clear Job History
</button>
</form>
</div>
<!-- Export Configuration Card -->
<div class="bg-white border border-gray-200 rounded-lg shadow-sm p-6 dark:bg-gray-800 dark:border-gray-700">
<div class="flex items-center mb-4">
<div class="bg-green-100 p-3 rounded-full dark:bg-green-900 mr-4">
<i class="fas fa-file-export text-green-500 w-6 h-6 dark:text-green-300"></i>
</div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Export Configuration</h3>
</div>
<p class="text-gray-600 dark:text-gray-400 mb-4">Export system configurations and jobs for backup or migration.</p>
<div class="flex space-x-2">
<a href="/admin/database/export-configs" class="flex-1 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-green-500 dark:bg-green-700 dark:hover:bg-green-800 text-center">
<i class="fas fa-cogs mr-2"></i>
Export Configs
</a>
<a href="/admin/database/export-jobs" class="flex-1 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-green-500 dark:bg-green-700 dark:hover:bg-green-800 text-center">
<i class="fas fa-tasks mr-2"></i>
Export Jobs
</a>
</div>
</div>
</div>
<!-- Database Backup List -->
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800">
<div class="p-6 border-b border-gray-200 dark:border-gray-700 flex justify-between items-center">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Database Backups</h3>
<a href="/admin/database/refresh-backups" class="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300">
<i class="fas fa-sync-alt mr-1"></i>
Refresh
</a>
</div>
<div class="overflow-x-auto">
<table class="w-full">
<thead class="bg-gray-50 dark:bg-gray-700">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">Backup Name</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">Size</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">Created</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
if len(backups) == 0 {
<tr>
<td colspan="4" class="px-6 py-4 text-center text-gray-500 dark:text-gray-400">
No backups found. Create your first backup to get started.
</td>
</tr>
} else {
for _, backup := range backups {
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700">
<td class="px-6 py-4 text-sm text-gray-900 dark:text-white">{ backup.Name }</td>
<td class="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">{ backup.Size }</td>
<td class="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">{ backup.Created.Format("2006-01-02 15:04:05") }</td>
<td class="px-6 py-4 text-sm text-right">
<div class="flex items-center justify-end space-x-2">
<button type="button"
data-modal-target="confirm-restore-modal"
data-modal-toggle="confirm-restore-modal"
data-backup-name={ backup.Name }
class="text-yellow-600 hover:text-yellow-900 dark:text-yellow-400 dark:hover:text-yellow-300">
<i class="fas fa-undo-alt"></i>
</button>
<a href={ templ.SafeURL("/admin/database/download-backup/" + backup.Name) }
class="text-blue-600 hover:text-blue-900 dark:text-blue-400 dark:hover:text-blue-300">
<i class="fas fa-download"></i>
</a>
<button type="button"
data-modal-target="confirm-delete-modal"
data-modal-toggle="confirm-delete-modal"
data-backup-name={ backup.Name }
class="text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300">
<i class="fas fa-trash"></i>
</button>
</div>
</td>
</tr>
}
}
</tbody>
</table>
</div>
</div>
<!-- Upload Backup Section -->
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 mt-8">
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Restore from Backup File</h3>
</div>
<div class="p-6">
<form id="upload-backup-form" method="POST" action="/admin/database/restore-database" enctype="multipart/form-data">
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Upload Backup File</label>
<div class="flex items-center justify-center w-full">
<label class="flex flex-col w-full h-32 border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-lg cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700">
<div class="flex flex-col items-center justify-center pt-5 pb-6">
<i class="fas fa-cloud-upload-alt text-3xl text-gray-400 mb-2"></i>
<p class="mb-2 text-sm text-gray-500 dark:text-gray-400"><span class="font-semibold">Click to upload</span> or drag and drop</p>
<p class="text-xs text-gray-500 dark:text-gray-400">SQLite backup file</p>
</div>
<input type="file" name="backup_file" class="hidden" accept=".db,.sqlite,.backup" />
</label>
</div>
</div>
<div class="flex items-center justify-end">
<button type="submit" class="px-4 py-2 bg-yellow-600 text-white rounded-lg hover:bg-yellow-700 focus:outline-none focus:ring-2 focus:ring-yellow-500 dark:bg-yellow-700 dark:hover:bg-yellow-800">
<i class="fas fa-undo-alt mr-2"></i>
Restore Database
</button>
</div>
</form>
</div>
</div>
<!-- Help Section -->
<div class="bg-gray-50 dark:bg-gray-800 rounded-lg shadow-sm mt-8 p-4 border border-gray-200 dark:border-gray-700">
<div class="flex items-start mb-2">
<div class="flex items-center h-5">
<i class="fas fa-info-circle w-4 h-4 text-blue-500 dark:text-blue-400 mr-2"></i>
</div>
<div class="ml-2 text-sm">
<h4 class="font-medium text-gray-900 dark:text-white">Important Safety Information</h4>
<ul class="mt-2 text-gray-600 dark:text-gray-400 space-y-1 list-disc list-inside">
<li>Always create a backup before making significant changes</li>
<li>Restoring a backup will overwrite all current data</li>
<li>Database optimization may take time for large databases</li>
<li>Clear job history operations cannot be undone</li>
</ul>
</div>
</div>
</div>
</div>
</div>
<!-- Restore Confirmation Modal -->
<div id="confirm-restore-modal" tabindex="-1" aria-hidden="true" class="hidden overflow-y-auto overflow-x-hidden fixed top-0 right-0 left-0 z-50 justify-center items-center w-full md:inset-0 h-[calc(100%-1rem)] max-h-full bg-gray-900/50 dark:bg-gray-900/80 backdrop-blur-sm">
<div class="relative p-4 w-full max-w-md max-h-full mx-auto">
<div class="relative bg-white rounded-lg shadow dark:bg-gray-700">
<div class="p-6 text-center">
<i class="fas fa-exclamation-triangle text-yellow-400 text-3xl mb-4"></i>
<h3 class="mb-5 text-lg font-normal text-gray-500 dark:text-gray-400">Are you sure you want to restore this backup? This will overwrite your current database.</h3>
<form method="POST" id="restore-form" action="/admin/database/restore-database">
<input type="hidden" name="filename" id="restore-filename" value="" />
<button type="submit" class="text-white bg-yellow-600 hover:bg-yellow-800 focus:ring-4 focus:outline-none focus:ring-yellow-300 dark:focus:ring-yellow-800 font-medium rounded-lg text-sm inline-flex items-center px-5 py-2.5 text-center mr-2">
Yes, restore it
</button>
<button type="button" data-modal-hide="confirm-restore-modal" class="text-gray-500 bg-white hover:bg-gray-100 focus:ring-4 focus:outline-none focus:ring-gray-200 rounded-lg border border-gray-200 text-sm font-medium px-5 py-2.5 hover:text-gray-900 focus:z-10 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-500 dark:hover:text-white dark:hover:bg-gray-600 dark:focus:ring-gray-600">
No, cancel
</button>
</form>
</div>
</div>
</div>
</div>
<!-- Delete Confirmation Modal -->
<div id="confirm-delete-modal" tabindex="-1" aria-hidden="true" class="hidden overflow-y-auto overflow-x-hidden fixed top-0 right-0 left-0 z-50 justify-center items-center w-full md:inset-0 h-[calc(100%-1rem)] max-h-full bg-gray-900/50 dark:bg-gray-900/80 backdrop-blur-sm">
<div class="relative p-4 w-full max-w-md max-h-full mx-auto">
<div class="relative bg-white rounded-lg shadow dark:bg-gray-700">
<div class="p-6 text-center">
<i class="fas fa-trash-alt text-red-400 text-3xl mb-4"></i>
<h3 class="mb-5 text-lg font-normal text-gray-500 dark:text-gray-400">Are you sure you want to delete this backup file?</h3>
<form method="POST" id="delete-form" action="">
<button type="submit" class="text-white bg-red-600 hover:bg-red-800 focus:ring-4 focus:outline-none focus:ring-red-300 dark:focus:ring-red-800 font-medium rounded-lg text-sm inline-flex items-center px-5 py-2.5 text-center mr-2">
Yes, delete it
</button>
<button type="button" data-modal-hide="confirm-delete-modal" class="text-gray-500 bg-white hover:bg-gray-100 focus:ring-4 focus:outline-none focus:ring-gray-200 rounded-lg border border-gray-200 text-sm font-medium px-5 py-2.5 hover:text-gray-900 focus:z-10 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-500 dark:hover:text-white dark:hover:bg-gray-600 dark:focus:ring-gray-600">
No, cancel
</button>
</form>
</div>
</div>
</div>
</div>
<script>
// Handle restore modal
document.addEventListener('DOMContentLoaded', function() {
// Parse URL parameters for error and status messages
const urlParams = new URLSearchParams(window.location.search);
const errorParam = urlParams.get('error');
const detailsParam = urlParams.get('details');
const statusParam = urlParams.get('status');
// Function to create and show a toast
function showToast(message, type) {
const toastContainer = document.getElementById('toast-container');
// Create toast element
const toast = document.createElement('div');
toast.id = 'toast-' + type + '-' + Date.now();
toast.className = 'flex items-center w-full max-w-xs p-4 mb-4 rounded-lg shadow text-gray-500 bg-white dark:text-gray-400 dark:bg-gray-800 transform translate-y-16 opacity-0 transition-all duration-300 ease-out';
toast.role = 'alert';
// Set toast content based on type
let iconClass, bgColorClass, textColorClass;
if (type === 'success') {
iconClass = 'text-green-500 bg-green-100 dark:bg-green-800 dark:text-green-200';
bgColorClass = 'text-green-500 dark:text-green-200';
textColorClass = 'text-green-500 dark:text-green-200';
} else if (type === 'error') {
iconClass = 'text-red-500 bg-red-100 dark:bg-red-800 dark:text-red-200';
bgColorClass = 'text-red-500 dark:text-red-200';
textColorClass = 'text-red-500 dark:text-red-200';
} else {
iconClass = 'text-blue-500 bg-blue-100 dark:bg-blue-800 dark:text-blue-200';
bgColorClass = 'text-blue-500 dark:text-blue-200';
textColorClass = 'text-blue-500 dark:text-blue-200';
}
// Set inner HTML with appropriate icon and message
toast.innerHTML = `
<div class="inline-flex items-center justify-center flex-shrink-0 w-8 h-8 rounded-lg ${iconClass}">
${type === 'success'
? '<i class="fas fa-check"></i>'
: type === 'error'
? '<i class="fas fa-exclamation-circle"></i>'
: '<i class="fas fa-info-circle"></i>'}
</div>
<div class="ml-3 text-sm font-normal">${message}</div>
<button type="button" class="ml-auto -mx-1.5 -my-1.5 bg-white text-gray-400 hover:text-gray-900 rounded-lg focus:ring-2 focus:ring-gray-300 p-1.5 hover:bg-gray-100 inline-flex h-8 w-8 dark:text-gray-500 dark:hover:text-white dark:bg-gray-800 dark:hover:bg-gray-700" data-dismiss-target="#${toast.id}" aria-label="Close">
<span class="sr-only">Close</span>
<i class="fas fa-times"></i>
</button>
`;
// Add toast to container
toastContainer.appendChild(toast);
// Trigger animation after a small delay to ensure the DOM has updated
setTimeout(() => {
toast.classList.remove('translate-y-16', 'opacity-0');
toast.classList.add('translate-y-0', 'opacity-100');
}, 10);
// Add event listener to close button
const closeButton = toast.querySelector('button[data-dismiss-target]');
closeButton.addEventListener('click', function() {
// Animate out before removing
toast.classList.add('opacity-0', 'translate-y-4');
setTimeout(() => {
toast.remove();
}, 300);
});
// Auto-remove toast after 5 seconds
setTimeout(() => {
toast.classList.add('opacity-0', 'translate-y-4');
setTimeout(() => {
toast.remove();
}, 300);
}, 5000);
}
// Display error message if present
if (errorParam) {
const errorMessage = decodeURIComponent(errorParam).replace(/\+/g, ' ');
let details = '';
if (detailsParam) {
details = decodeURIComponent(detailsParam).replace(/\+/g, ' ');
}
const fullMessage = details ? `${errorMessage}: ${details}` : errorMessage;
showToast(fullMessage, 'error');
}
// Display status message if present
if (statusParam) {
const statusMessage = decodeURIComponent(statusParam).replace(/\+/g, ' ');
showToast(statusMessage, 'success');
}
// Display modals when buttons are clicked
const restoreButtons = document.querySelectorAll('[data-modal-target="confirm-restore-modal"]');
restoreButtons.forEach(button => {
button.addEventListener('click', function() {
const backupName = this.getAttribute('data-backup-name');
document.getElementById('restore-filename').value = backupName;
document.getElementById('restore-form').action = `/admin/database/restore-database/${backupName}`;
// Show the modal
const modal = document.getElementById('confirm-restore-modal');
modal.classList.remove('hidden');
modal.classList.add('flex');
});
});
// Handle delete modal
const deleteButtons = document.querySelectorAll('[data-modal-target="confirm-delete-modal"]');
deleteButtons.forEach(button => {
button.addEventListener('click', function() {
const backupName = this.getAttribute('data-backup-name');
document.getElementById('delete-form').action = `/admin/database/delete-backup/${backupName}`;
// Show the modal
const modal = document.getElementById('confirm-delete-modal');
modal.classList.remove('hidden');
modal.classList.add('flex');
});
});
// Handle modal hide buttons
const hideButtons = document.querySelectorAll('[data-modal-hide]');
hideButtons.forEach(button => {
button.addEventListener('click', function() {
const modalId = this.getAttribute('data-modal-hide');
const modal = document.getElementById(modalId);
modal.classList.add('hidden');
modal.classList.remove('flex');
});
});
// File upload preview
const fileInput = document.querySelector('input[type="file"]');
if (fileInput) {
fileInput.addEventListener('change', function(e) {
const fileName = e.target.files[0]?.name || 'No file selected';
const fileLabel = this.parentElement.querySelector('p.mb-2');
if (fileLabel) {
fileLabel.textContent = fileName;
}
});
}
// Add form submission events
const backupForm = document.getElementById('backup-form');
if (backupForm) {
backupForm.addEventListener('submit', function(e) {
console.log('Backup form submitted');
});
}
const vacuumForm = document.getElementById('vacuum-form');
if (vacuumForm) {
vacuumForm.addEventListener('submit', function(e) {
console.log('Vacuum form submitted');
});
}
const clearJobForm = document.getElementById('clear-job-form');
if (clearJobForm) {
clearJobForm.addEventListener('submit', function(e) {
console.log('Clear job form submitted');
});
}
});
// Set dark background color if in dark mode
if (document.documentElement.classList.contains('dark')) {
document.getElementById('database-tools-container').style.backgroundColor = '#111827';
}
// Add event listener for theme changes
document.addEventListener('DOMContentLoaded', function() {
const themeToggle = document.getElementById('theme-toggle');
if (themeToggle) {
themeToggle.addEventListener('click', function() {
setTimeout(function() {
const isDark = document.documentElement.classList.contains('dark');
document.getElementById('database-tools-container').style.backgroundColor = isDark ? '#111827' : 'rgb(249, 250, 251)';
}, 50);
});
}
});
</script>
}
}
+538
View File
@@ -0,0 +1,538 @@
package components
import (
"context"
"time"
)
// LogEntry represents a log entry for display
type LogEntry struct {
Timestamp time.Time
Level string
Message string
Source string
Details map[string]interface{}
}
// LogViewerData represents the data for the log viewer component
type LogViewerData struct {
Logs []LogEntry
CurrentFilter string
LogFilePath string
}
// Helper function to get the appropriate CSS class for log levels
func getLogLevelClass(level string) string {
baseClass := "px-4 py-2 text-sm font-medium whitespace-nowrap "
switch level {
case "debug":
return baseClass + "text-purple-500 dark:text-purple-400"
case "info":
return baseClass + "text-blue-500 dark:text-blue-400"
case "warn":
return baseClass + "text-yellow-500 dark:text-yellow-400"
case "error":
return baseClass + "text-red-500 dark:text-red-400"
case "fatal":
return baseClass + "text-red-700 dark:text-red-600 font-bold"
default:
return baseClass + "text-gray-500 dark:text-gray-400"
}
}
// AdminLogs renders the log viewer page
templ AdminLogs(ctx context.Context, data LogViewerData) {
@LayoutWithContext("Log Viewer", ctx) {
<div class="log-viewer-page">
<!-- Page Header -->
<div class="mb-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
<i class="fas fa-stream w-6 h-6 mr-2 text-blue-500 dark:text-blue-400"></i> Log Viewer
</h1>
<div class="flex gap-2">
<button id="pause-logs" class="flex items-center justify-center text-white bg-yellow-500 hover:bg-yellow-600 focus:ring-4 focus:ring-yellow-300 font-medium rounded-lg px-4 py-2 dark:bg-yellow-600 dark:hover:bg-yellow-700 focus:outline-none dark:focus:ring-yellow-800">
<i class="fas fa-pause w-4 h-4 mr-2"></i> Pause
</button>
<button id="resume-logs" class="hidden flex items-center justify-center text-white bg-green-500 hover:bg-green-600 focus:ring-4 focus:ring-green-300 font-medium rounded-lg px-4 py-2 dark:bg-green-600 dark:hover:bg-green-700 focus:outline-none dark:focus:ring-green-800">
<i class="fas fa-play w-4 h-4 mr-2"></i> Resume
</button>
<button id="clear-logs" class="flex items-center justify-center text-white bg-red-500 hover:bg-red-600 focus:ring-4 focus:ring-red-300 font-medium rounded-lg px-4 py-2 dark:bg-red-600 dark:hover:bg-red-700 focus:outline-none dark:focus:ring-red-800">
<i class="fas fa-trash w-4 h-4 mr-2"></i> Clear
</button>
<button id="download-logs" class="flex items-center justify-center text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg px-4 py-2 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800">
<i class="fas fa-download w-4 h-4 mr-2"></i> Download
</button>
</div>
</div>
<!-- Log Information -->
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 mb-4 p-4">
<div class="text-sm text-gray-600 dark:text-gray-300">
<p><i class="fas fa-info-circle mr-2 text-blue-500 dark:text-blue-400"></i> Viewing logs from: <span class="font-mono">{ data.LogFilePath }</span></p>
<p><i class="fas fa-circle text-green-500 dark:text-green-400 mr-2"></i> Real-time log streaming is active, logs are automatically captured and displayed</p>
</div>
</div>
<!-- Filters -->
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 mb-6">
<div class="p-4 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Filter Logs</h3>
</div>
<div class="p-4">
<div class="flex flex-wrap gap-4">
<!-- Log Level Filter -->
<div class="flex-1 min-w-[200px]">
<label for="filter-level" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Log Level</label>
<select id="filter-level" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500">
<option value="">All Levels</option>
<option value="debug">Debug</option>
<option value="info">Info</option>
<option value="warn">Warning</option>
<option value="error">Error</option>
<option value="fatal">Fatal</option>
</select>
</div>
<!-- Source Filter -->
<div class="flex-1 min-w-[200px]">
<label for="filter-source" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Source</label>
<select id="filter-source" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500">
<option value="">All Sources</option>
<option value="api">API</option>
<option value="web">Web</option>
<option value="scheduler">Scheduler</option>
<option value="auth">Authentication</option>
<option value="database">Database</option>
</select>
</div>
<!-- Search Filter -->
<div class="flex-1 min-w-[200px]">
<label for="filter-search" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Search</label>
<input type="text" id="filter-search" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="Search logs...">
</div>
</div>
</div>
</div>
<!-- Log Table -->
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 overflow-hidden">
<div class="p-4 border-b border-gray-200 dark:border-gray-700 flex justify-between items-center">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Live Logs</h3>
<div class="flex items-center">
<span id="connection-status" class="flex items-center text-sm text-green-500 dark:text-green-400">
<span class="inline-block w-2 h-2 bg-green-500 dark:bg-green-400 rounded-full mr-2"></span>
Connected
</span>
</div>
</div>
<div class="overflow-x-auto" style="max-height: 60vh; overflow-y: auto;">
<table class="w-full">
<thead class="bg-gray-50 dark:bg-gray-700 sticky top-0 z-10">
<tr>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">Timestamp</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase w-[100px]">Level</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase w-[120px]">Source</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">Message</th>
</tr>
</thead>
<tbody id="log-entries" class="divide-y divide-gray-200 dark:divide-gray-700">
<!-- Log entries will be inserted here dynamically -->
if len(data.Logs) == 0 {
<tr>
<td colspan="4" class="px-4 py-6 text-center text-gray-500 dark:text-gray-400">Waiting for logs...</td>
</tr>
} else {
for _, log := range data.Logs {
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700">
<td class="px-4 py-2 text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">{ log.Timestamp.Format("2006-01-02 15:04:05.000") }</td>
<td class={ getLogLevelClass(log.Level) }>{ log.Level }</td>
<td class="px-4 py-2 text-sm text-gray-500 dark:text-gray-400">{ log.Source }</td>
<td class="px-4 py-2 text-sm text-gray-900 dark:text-white font-mono">{ log.Message }</td>
</tr>
}
}
</tbody>
</table>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
const logEntries = document.getElementById('log-entries');
const pauseButton = document.getElementById('pause-logs');
const resumeButton = document.getElementById('resume-logs');
const clearButton = document.getElementById('clear-logs');
const downloadButton = document.getElementById('download-logs');
const connectionStatus = document.getElementById('connection-status');
const filterLevel = document.getElementById('filter-level');
const filterSource = document.getElementById('filter-source');
const filterSearch = document.getElementById('filter-search');
let isPaused = false;
let logs = [];
let filteredLogs = [];
let ws;
let knownSources = new Set();
let reconnectTimer = null;
let pingInterval = null;
// Connect to WebSocket
function connectWebSocket() {
// Clear any existing reconnect timer
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
// Clear any existing ping interval
if (pingInterval) {
clearInterval(pingInterval);
pingInterval = null;
}
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = protocol + '//' + window.location.host + '/admin/logs/ws';
console.log("Connecting to WebSocket:", wsUrl);
connectionStatus.innerHTML = '<span class="inline-block w-2 h-2 bg-yellow-500 dark:bg-yellow-400 rounded-full mr-2"></span>Connecting...';
connectionStatus.className = 'flex items-center text-sm text-yellow-500 dark:text-yellow-400';
try {
ws = new WebSocket(wsUrl);
ws.onopen = function() {
console.log("WebSocket connection established");
connectionStatus.innerHTML = '<span class="inline-block w-2 h-2 bg-green-500 dark:bg-green-400 rounded-full mr-2"></span>Connected';
connectionStatus.className = 'flex items-center text-sm text-green-500 dark:text-green-400';
// Set up ping interval to keep connection alive
pingInterval = setInterval(function() {
if (ws && ws.readyState === WebSocket.OPEN) {
console.debug("Sending ping to server");
// Send a simple ping message
ws.send(JSON.stringify({type: "ping"}));
}
}, 30000); // 30 seconds
};
ws.onclose = function(event) {
console.log("WebSocket connection closed", event);
connectionStatus.innerHTML = '<span class="inline-block w-2 h-2 bg-red-500 dark:bg-red-400 rounded-full mr-2"></span>Disconnected';
connectionStatus.className = 'flex items-center text-sm text-red-500 dark:text-red-400';
// Clear the ping interval
if (pingInterval) {
clearInterval(pingInterval);
pingInterval = null;
}
// Attempt to reconnect after 5 seconds
console.log("Scheduling reconnect in 5 seconds...");
reconnectTimer = setTimeout(connectWebSocket, 5000);
};
ws.onerror = function(error) {
console.error("WebSocket error:", error);
connectionStatus.innerHTML = '<span class="inline-block w-2 h-2 bg-red-500 dark:bg-red-400 rounded-full mr-2"></span>Error';
connectionStatus.className = 'flex items-center text-sm text-red-500 dark:text-red-400';
// Don't set up reconnect here, let onclose handle it
};
ws.onmessage = function(event) {
// Debug log the received data
console.debug("Raw log entry received:", event.data);
try {
const logEntry = JSON.parse(event.data);
// Debug log the parsed entry
console.debug("Parsed log entry:", logEntry);
// Extract source and add to known sources for filtering
const source = logEntry.Source || logEntry.source || '';
if (source && !knownSources.has(source)) {
knownSources.add(source);
updateSourceFilter();
}
// Handle potential log prefixes in the message
const message = logEntry.Message || logEntry.message || '';
if (message.startsWith("DEBUG:")) {
logEntry.Level = "debug";
logEntry.Message = message.substring(7).trim();
} else if (message.startsWith("INFO:")) {
logEntry.Level = "info";
logEntry.Message = message.substring(6).trim();
} else if (message.startsWith("ERROR:")) {
logEntry.Level = "error";
logEntry.Message = message.substring(7).trim();
} else if (message.startsWith("WARN:")) {
logEntry.Level = "warn";
logEntry.Message = message.substring(6).trim();
} else if (message.startsWith("WARNING:")) {
logEntry.Level = "warn";
logEntry.Message = message.substring(9).trim();
} else if (message.startsWith("FATAL:")) {
logEntry.Level = "fatal";
logEntry.Message = message.substring(7).trim();
}
// Add to logs array
logs.push(logEntry);
// Apply filters and update display if not paused
if (!isPaused) {
applyFilters();
}
} catch (error) {
console.error("Error processing log entry:", error);
}
};
} catch (error) {
console.error("Error creating WebSocket:", error);
connectionStatus.innerHTML = '<span class="inline-block w-2 h-2 bg-red-500 dark:bg-red-400 rounded-full mr-2"></span>Connection Failed';
connectionStatus.className = 'flex items-center text-sm text-red-500 dark:text-red-400';
// Retry connection after 5 seconds
reconnectTimer = setTimeout(connectWebSocket, 5000);
}
}
// Update the source filter dropdown with dynamically discovered sources
function updateSourceFilter() {
// Remember the current selection
const currentValue = filterSource.value;
// Clear existing options except the first "All Sources" option
while (filterSource.options.length > 1) {
filterSource.remove(1);
}
// Add sorted sources to dropdown
Array.from(knownSources).sort().forEach(source => {
const option = document.createElement('option');
option.value = source.toLowerCase();
option.textContent = source;
filterSource.appendChild(option);
});
// Restore previous selection if it still exists
if (currentValue) {
for (let i = 0; i < filterSource.options.length; i++) {
if (filterSource.options[i].value === currentValue) {
filterSource.selectedIndex = i;
break;
}
}
}
}
// Apply filters to logs
function applyFilters() {
const levelFilter = filterLevel.value.toLowerCase();
const sourceFilter = filterSource.value.toLowerCase();
const searchFilter = filterSearch.value.toLowerCase();
filteredLogs = logs.filter(log => {
// Handle capitalized properties from the server
const level = (log.Level || log.level || '').toLowerCase();
const source = (log.Source || log.source || '').toLowerCase();
const message = (log.Message || log.message || '').toLowerCase();
return (levelFilter === '' || level === levelFilter) &&
(sourceFilter === '' || source === sourceFilter) &&
(searchFilter === '' || message.includes(searchFilter));
});
renderLogs();
}
// Render logs to the table
function renderLogs() {
// Clear existing logs
logEntries.innerHTML = '';
if (filteredLogs.length === 0) {
const emptyRow = document.createElement('tr');
emptyRow.innerHTML = `<td colspan="4" class="px-4 py-6 text-center text-gray-500 dark:text-gray-400">No logs found</td>`;
logEntries.appendChild(emptyRow);
return;
}
// Add filtered logs
filteredLogs.forEach(log => {
// Handle capitalized property names from the server
const timestamp = log.Timestamp || log.timestamp;
const level = log.Level || log.level || 'unknown';
const source = log.Source || log.source || 'unknown';
const message = log.Message || log.message || '';
let formattedTime;
try {
// Convert to date object
const date = new Date(timestamp);
// Format in local time with milliseconds
const options = {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false
};
// Format main part of the timestamp
formattedTime = date.toLocaleString(undefined, options);
// Add milliseconds
const ms = String(date.getMilliseconds()).padStart(3, '0');
formattedTime += "." + ms;
} catch (e) {
console.error("Error formatting timestamp:", e);
formattedTime = String(timestamp);
}
const row = document.createElement('tr');
row.className = 'hover:bg-gray-50 dark:hover:bg-gray-700';
let levelClass = 'px-4 py-2 text-sm font-medium whitespace-nowrap ';
switch(level.toLowerCase()) {
case 'debug':
levelClass += 'text-purple-500 dark:text-purple-400';
break;
case 'info':
levelClass += 'text-blue-500 dark:text-blue-400';
break;
case 'warn':
levelClass += 'text-yellow-500 dark:text-yellow-400';
break;
case 'error':
levelClass += 'text-red-500 dark:text-red-400';
break;
case 'fatal':
levelClass += 'text-red-700 dark:text-red-600 font-bold';
break;
default:
levelClass += 'text-gray-500 dark:text-gray-400';
}
row.innerHTML =
'<td class="px-4 py-2 text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">' + formattedTime + '</td>' +
'<td class="' + levelClass + '">' + level + '</td>' +
'<td class="px-4 py-2 text-sm text-gray-500 dark:text-gray-400">' + source + '</td>' +
'<td class="px-4 py-2 text-sm text-gray-900 dark:text-white font-mono">' + message + '</td>';
logEntries.appendChild(row);
});
// Auto-scroll to bottom unless user has scrolled up
const container = logEntries.parentElement;
if (container.scrollTop + container.clientHeight >= container.scrollHeight - 100) {
container.scrollTop = container.scrollHeight;
}
}
// Pause button click
pauseButton.addEventListener('click', function() {
isPaused = true;
pauseButton.classList.add('hidden');
resumeButton.classList.remove('hidden');
});
// Resume button click
resumeButton.addEventListener('click', function() {
isPaused = false;
resumeButton.classList.add('hidden');
pauseButton.classList.remove('hidden');
applyFilters(); // Re-apply filters and update
});
// Clear button click
clearButton.addEventListener('click', function() {
logs = [];
applyFilters();
});
// Download button click
downloadButton.addEventListener('click', function() {
// Create CSV from logs
let csv = 'Timestamp,Level,Source,Message\n';
logs.forEach(log => {
// Handle capitalized property names from the server
const timestamp = log.Timestamp || log.timestamp;
const level = log.Level || log.level || 'unknown';
const source = log.Source || log.source || 'unknown';
const message = log.Message || log.message || '';
let formattedTime;
try {
// Convert to date object
const date = new Date(timestamp);
// Format in local time with milliseconds
const options = {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false
};
// Format main part of the timestamp
formattedTime = date.toLocaleString(undefined, options);
// Add milliseconds
const ms = String(date.getMilliseconds()).padStart(3, '0');
formattedTime += "." + ms;
} catch (e) {
formattedTime = String(timestamp);
}
// Properly escape CSV fields
let escapedMessage = '';
if (message) {
escapedMessage = message.split('"').join('""');
}
csv += '"' + formattedTime + '","' + level + '","' + source + '","' + escapedMessage + '"\n';
});
// Create and trigger download
const blob = new Blob([csv], { type: 'text/csv' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
const date = new Date().toISOString().replace(/[:.]/g, '-').substring(0, 19);
a.setAttribute('href', url);
a.setAttribute('download', 'gomft-logs-' + date + '.csv');
a.click();
});
// Filter change handlers
filterLevel.addEventListener('change', applyFilters);
filterSource.addEventListener('change', applyFilters);
// Debounce search input
let searchTimeout;
filterSearch.addEventListener('input', function() {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(applyFilters, 300);
});
// Initial connection
connectWebSocket();
});
</script>
}
}
+175
View File
@@ -0,0 +1,175 @@
package components
import (
"context"
"fmt"
"strings"
)
type RoleFormData struct {
Role *Role
IsNew bool
ErrorMessage string
AllPermissions []string
}
templ AdminRoleForm(ctx context.Context, data RoleFormData) {
@LayoutWithContext(getFormTitle(data.IsNew), ctx) {
<div class="mb-6">
<nav class="flex" aria-label="Breadcrumb">
<ol class="inline-flex items-center space-x-1 md:space-x-3">
<li class="inline-flex items-center">
<a href="/admin" class="inline-flex items-center text-sm font-medium text-gray-700 hover:text-blue-600 dark:text-gray-400 dark:hover:text-white">
<i class="fas fa-shield-alt w-4 h-4 mr-2"></i>
Admin
</a>
</li>
<li>
<div class="flex items-center">
<i class="fas fa-chevron-right w-3 h-3 text-gray-400 mx-1"></i>
<a href="/admin/roles" class="ml-1 text-sm font-medium text-gray-700 hover:text-blue-600 md:ml-2 dark:text-gray-400 dark:hover:text-white">Roles</a>
</div>
</li>
<li aria-current="page">
<div class="flex items-center">
<i class="fas fa-chevron-right w-3 h-3 text-gray-400 mx-1"></i>
<span class="ml-1 text-sm font-medium text-gray-500 md:ml-2 dark:text-gray-400">
if data.IsNew {
New Role
} else {
Edit Role
}
</span>
</div>
</li>
</ol>
</nav>
</div>
if data.ErrorMessage != "" {
<div class="p-4 mb-6 text-sm text-red-800 rounded-lg bg-red-50 dark:bg-red-900/50 dark:text-red-400" role="alert">
<span class="font-medium">Error!</span> { data.ErrorMessage }
</div>
}
<div class="bg-white rounded-lg shadow-sm dark:bg-gray-800">
<div class="p-6 space-y-6">
<form method="POST" action={ getFormAction(data.IsNew, data.Role) }>
if !data.IsNew {
<input type="hidden" name="_method" value="PUT"/>
}
<!-- Role Name -->
<div class="mb-6">
<label for="name" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Role Name</label>
<input
type="text"
id="name"
name="name"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white"
placeholder="Enter role name"
required
if !data.IsNew {
value={ data.Role.Name }
}
/>
</div>
<!-- Role Description -->
<div class="mb-6">
<label for="description" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Description</label>
<textarea
id="description"
name="description"
rows="3"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white"
placeholder="Enter role description"
>
if !data.IsNew {
{ data.Role.Description }
}
</textarea>
</div>
<!-- Permissions -->
<div class="mb-6">
<label class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Permissions</label>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
for _, perm := range data.AllPermissions {
<div class="flex items-center">
<input
type="checkbox"
id={ "perm_" + perm }
name="permissions[]"
value={ perm }
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"
if !data.IsNew && hasPermission(data.Role, perm) {
checked
}
/>
<label for={ "perm_" + perm } class="ml-2 text-sm font-medium text-gray-900 dark:text-gray-300">
{ formatPermissionLabel(perm) }
</label>
</div>
}
</div>
</div>
<!-- Form Actions -->
<div class="flex items-center justify-end space-x-3">
<a href="/admin/roles" class="text-gray-500 bg-white hover:bg-gray-100 focus:ring-4 focus:outline-none focus:ring-blue-300 rounded-lg border border-gray-200 text-sm font-medium px-5 py-2.5 hover:text-gray-900 focus:z-10 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-500 dark:hover:text-white dark:hover:bg-gray-600 dark:focus:ring-gray-600">
Cancel
</a>
<button type="submit" class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800">
if data.IsNew {
Create Role
} else {
Update Role
}
</button>
</div>
</form>
</div>
</div>
}
}
// Helper functions
func getFormTitle(isNew bool) string {
if isNew {
return "New Role"
}
return "Edit Role"
}
func getFormAction(isNew bool, role *Role) templ.SafeURL {
if isNew {
return "/admin/roles"
}
return templ.SafeURL("/admin/roles/" + fmt.Sprint(role.ID))
}
func hasPermission(role *Role, permission string) bool {
if role == nil || role.Permissions == nil {
return false
}
for _, p := range role.Permissions {
if p == permission {
return true
}
}
return false
}
func formatPermissionLabel(permission string) string {
// Convert permission strings like "users.create" to "Create Users"
parts := strings.Split(permission, ".")
if len(parts) != 2 {
return permission
}
resource := strings.Title(parts[0])
action := strings.Title(parts[1])
return action + " " + resource
}
+316
View File
@@ -0,0 +1,316 @@
package components
import (
"context"
"fmt"
)
type Role struct {
ID uint
Name string
Description string
Permissions []string
}
type RolesData struct {
Roles []Role
Error string
}
templ AdminRoles(ctx context.Context, data RolesData) {
@LayoutWithContext("Role Management", ctx) {
<!-- Toast container for status messages -->
<div id="toast-container" class="fixed top-5 right-5 z-50 flex flex-col gap-2"></div>
<!-- Roles Management Content -->
<div class="mb-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
<i class="fas fa-user-shield w-6 h-6 mr-2 text-blue-500 dark:text-blue-400"></i>
Role Management
</h1>
<a href="/admin/roles/new" class="flex items-center justify-center text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg px-5 py-2.5 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800">
<i class="fas fa-plus w-4 h-4 mr-2"></i>
New Role
</a>
</div>
<!-- Roles Table -->
<div class="bg-white rounded-lg shadow-sm dark:bg-gray-800">
<div class="overflow-x-auto">
<table class="w-full">
<thead class="bg-gray-50 dark:bg-gray-700">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">Role Name</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">Description</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">Permissions</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
if len(data.Roles) == 0 {
<tr>
<td colspan="4" class="px-6 py-4 text-center text-gray-500 dark:text-gray-400">
No roles found. Create your first role to get started.
</td>
</tr>
} else {
for _, role := range data.Roles {
<!-- Dialog for this role -->
@RoleDialog(
fmt.Sprintf("delete-role-dialog-%d", role.ID),
"Delete Role",
fmt.Sprintf("Are you sure you want to delete role \"%s\"? This action cannot be undone.", role.Name),
"bg-red-600 hover:bg-red-700 dark:bg-red-600 dark:hover:bg-red-700",
"Delete Role",
"delete",
role.ID,
role.Name,
)
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700">
<td class="px-6 py-4 text-sm text-gray-900 dark:text-white">{ role.Name }</td>
<td class="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">{ role.Description }</td>
<td class="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">
<div class="flex flex-wrap gap-1">
for _, perm := range role.Permissions {
<span class="px-2 py-1 text-xs rounded-full bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300">
{ perm }
</span>
}
</div>
</td>
<td class="px-6 py-4 text-sm text-right">
<div class="flex items-center justify-end space-x-2">
<a href={ templ.SafeURL("/admin/roles/" + fmt.Sprint(role.ID)) } class="text-blue-600 hover:text-blue-900 dark:text-blue-400 dark:hover:text-blue-300">
<i class="fas fa-edit"></i>
</a>
<button
data-role-id={ fmt.Sprint(role.ID) }
data-role-name={ role.Name }
onclick={ showModal(fmt.Sprintf("delete-role-dialog-%d", role.ID)) }
class="text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300">
<i class="fas fa-trash"></i>
</button>
</div>
</td>
</tr>
}
}
</tbody>
</table>
</div>
</div>
<script>
// Function to create and show a toast
function showToast(message, type) {
const toastContainer = document.getElementById('toast-container');
// Create toast element
const toast = document.createElement('div');
toast.id = 'toast-' + type + '-' + Date.now();
toast.className = 'flex items-center w-full max-w-xs p-4 mb-4 rounded-lg shadow text-gray-500 bg-white dark:text-gray-400 dark:bg-gray-800 transform translate-y-16 opacity-0 transition-all duration-300 ease-out';
toast.role = 'alert';
// Set toast content based on type
let iconClass, bgColorClass, textColorClass;
if (type === 'success') {
iconClass = 'text-green-500 bg-green-100 dark:bg-green-800 dark:text-green-200';
bgColorClass = 'text-green-500 dark:text-green-200';
textColorClass = 'text-green-500 dark:text-green-200';
} else if (type === 'error') {
iconClass = 'text-red-500 bg-red-100 dark:bg-red-800 dark:text-red-200';
bgColorClass = 'text-red-500 dark:text-red-200';
textColorClass = 'text-red-500 dark:text-red-200';
} else {
iconClass = 'text-blue-500 bg-blue-100 dark:bg-blue-800 dark:text-blue-200';
bgColorClass = 'text-blue-500 dark:text-blue-200';
textColorClass = 'text-blue-500 dark:text-blue-200';
}
// Set inner HTML with appropriate icon and message
toast.innerHTML = `
<div class="inline-flex items-center justify-center flex-shrink-0 w-8 h-8 rounded-lg ${iconClass}">
${type === 'success'
? '<i class="fas fa-check"></i>'
: type === 'error'
? '<i class="fas fa-exclamation-circle"></i>'
: '<i class="fas fa-info-circle"></i>'}
</div>
<div class="ml-3 text-sm font-normal">${message}</div>
<button type="button" class="ml-auto -mx-1.5 -my-1.5 bg-white text-gray-400 hover:text-gray-900 rounded-lg focus:ring-2 focus:ring-gray-300 p-1.5 hover:bg-gray-100 inline-flex h-8 w-8 dark:text-gray-500 dark:hover:text-white dark:bg-gray-800 dark:hover:bg-gray-700" data-dismiss-target="#${toast.id}" aria-label="Close">
<span class="sr-only">Close</span>
<i class="fas fa-times"></i>
</button>
`;
// Add toast to container
toastContainer.appendChild(toast);
// Trigger animation after a small delay to ensure the DOM has updated
setTimeout(() => {
toast.classList.remove('translate-y-16', 'opacity-0');
toast.classList.add('translate-y-0', 'opacity-100');
}, 10);
// Add event listener to close button
const closeButton = toast.querySelector('button[data-dismiss-target]');
closeButton.addEventListener('click', function() {
// Animate out before removing
toast.classList.add('opacity-0', 'translate-y-4');
setTimeout(() => {
toast.remove();
}, 300);
});
// Auto-remove toast after 5 seconds
setTimeout(() => {
toast.classList.add('opacity-0', 'translate-y-4');
setTimeout(() => {
toast.remove();
}, 300);
}, 5000);
}
// Handle modal hide buttons
document.addEventListener('DOMContentLoaded', function() {
const hideButtons = document.querySelectorAll('[data-modal-hide]');
hideButtons.forEach(button => {
button.addEventListener('click', function() {
const modalId = this.getAttribute('data-modal-hide');
const modal = document.getElementById(modalId);
modal.classList.add('hidden');
modal.classList.remove('flex');
});
});
// Check for URL parameters after page load to show success/error messages
const urlParams = new URLSearchParams(window.location.search);
const successMsg = urlParams.get('success');
const errorMsg = urlParams.get('error');
// Show success toast if parameter exists
if (successMsg) {
showToast(decodeURIComponent(successMsg), 'success');
}
// Show error toast if parameter exists
if (errorMsg) {
showToast(decodeURIComponent(errorMsg), 'error');
}
// Show error from the data if it exists
if (document.querySelector('[data-error-message]')) {
const errorMsg = document.querySelector('[data-error-message]').getAttribute('data-error-message');
if (errorMsg) {
showToast(errorMsg, 'error');
}
}
// Set up window variables to track deletions
window.lastDeletedRole = null;
window.isRoleDeletingRequest = false;
});
// Handle role deletion
document.addEventListener('DOMContentLoaded', function() {
const confirmDeleteButtons = document.querySelectorAll('[id^="delete-role-btn-"]');
confirmDeleteButtons.forEach(button => {
button.addEventListener('click', function() {
const roleId = this.getAttribute('data-role-id');
const roleName = this.getAttribute('data-role-name');
// Store deletion info for event tracking
window.lastDeletedRole = {
id: roleId,
name: roleName
};
// Send DELETE request
fetch(`/admin/roles/${roleId}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json'
}
})
.then(response => {
if (response.ok) {
// Show success toast
showToast(`Role "${roleName}" deleted successfully`, 'success');
// Reload page on success after a short delay
setTimeout(() => {
window.location.reload();
}, 1000);
} else {
// Handle error
return response.json().then(data => {
showToast(`Error: ${data.error || 'Failed to delete role'}`, 'error');
}).catch(() => {
showToast(`Error deleting role "${roleName}"`, 'error');
});
}
})
.catch(error => {
console.error('Error:', error);
showToast(`Error: ${error.message || 'Failed to connect to server'}`, 'error');
});
});
});
});
</script>
<!-- Store error message if it exists -->
if data.Error != "" {
<div data-error-message={ data.Error } class="hidden"></div>
}
}
}
// RoleDialog for confirmation actions
templ RoleDialog(id string, title string, message string, confirmClass string, confirmText string, action string, roleID uint, roleName string) {
<div id={ id } tabindex="-1" aria-hidden="true" class="hidden fixed top-0 right-0 left-0 z-50 justify-center items-center w-full md:inset-0 h-[calc(100%-1rem)] max-h-full">
<!-- Backdrop -->
<div id={ fmt.Sprintf("%s-backdrop", id) } class="fixed inset-0 bg-gray-900/50 dark:bg-gray-900/80 backdrop-blur-sm"></div>
<!-- Modal content -->
<div class="relative p-4 w-full max-w-md max-h-full mx-auto">
<div class="relative bg-white rounded-lg shadow dark:bg-gray-700">
<div class="p-6 text-center">
if action == "delete" {
<i class="fas fa-trash-alt text-red-400 text-3xl mb-4"></i>
} else {
<i class="fas fa-exclamation-triangle text-yellow-400 text-3xl mb-4"></i>
}
<h3 class="mb-5 text-lg font-normal text-gray-500 dark:text-gray-400">{ message }</h3>
<button
type="button"
class="text-white font-medium rounded-lg text-sm px-5 py-2.5 text-center me-2 bg-red-600 hover:bg-red-700 focus:ring-4 focus:outline-none focus:ring-red-300 dark:bg-red-500 dark:hover:bg-red-600 dark:focus:ring-red-800"
id={ fmt.Sprintf("delete-role-btn-%d", roleID) }
data-role-id={ fmt.Sprint(roleID) }
data-role-name={ roleName }
onclick={ triggerRoleDelete(id, roleID, roleName) }>
{ confirmText }
</button>
<button type="button" onclick={ closeModal(id) } class="text-gray-500 bg-white hover:bg-gray-100 focus:ring-4 focus:outline-none focus:ring-gray-200 rounded-lg border border-gray-200 text-sm font-medium px-5 py-2.5 hover:text-gray-900 focus:z-10 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-500 dark:hover:text-white dark:hover:bg-gray-600 dark:focus:ring-gray-600">
Cancel
</button>
</div>
</div>
</div>
</div>
}
script triggerRoleDelete(dialogId string, roleID uint, roleName string) {
// Hide the dialog
document.getElementById(dialogId).classList.add("hidden");
document.getElementById(dialogId).classList.remove("flex");
// Store data in a way that's accessible to event handlers
window.lastDeletedRole = {
id: roleID,
name: roleName
};
// Add custom marker to track this deletion
window.isRoleDeletingRequest = true;
}
-578
View File
@@ -1,578 +0,0 @@
package components
import (
"context"
"fmt"
"time"
)
type BackupFile struct {
Name string
Size string
ModTime time.Time
}
type AdminToolsData struct {
JobHistoryCount int
DatabaseSize string
LastBackupTime *time.Time
BackupCount int
SystemUptime string
ActiveJobs int
TotalConfigs int
TotalJobs int
TotalUsers int
DatabasePath string
BackupPath string
MaintenanceMessage string
BackupFiles []BackupFile
}
// Dialog component for confirmation dialogs
templ Dialog(id string, title string, message string, confirmClass string, confirmText string, formId string, targetAction string) {
<div id={ id } class="hidden fixed inset-0 bg-secondary-900/50 dark:bg-secondary-900/80 backdrop-blur-sm z-50 flex items-center justify-center">
<div class="bg-white dark:bg-secondary-800 rounded-lg shadow-xl max-w-md w-full mx-4 overflow-hidden">
<div class="px-6 pt-5 pb-3 text-center">
<div class="flex justify-center mb-2">
<i class="fas fa-exclamation-triangle text-yellow-400 text-3xl"></i>
</div>
<h3 class="text-xl font-medium text-secondary-900 dark:text-secondary-100">
{ title }
</h3>
</div>
<div class="px-6 py-4 text-center">
<p class="text-secondary-700 dark:text-secondary-300">
{ message }
</p>
</div>
<div class="px-6 py-4 flex justify-end space-x-3">
<button type="button" class="btn-secondary" onclick={ hideDialog(id) }>
Cancel
</button>
if formId != "" {
<button
type="button"
class={ confirmClass }
onclick={ submitFormAndHideDialog(formId, id) }>
{ confirmText }
</button>
} else {
<button
type="button"
class={ confirmClass }
hx-target={ targetAction }
onclick={ hideDialog(id) }>
{ confirmText }
</button>
}
</div>
</div>
</div>
}
script hideDialog(id string) {
document.getElementById(id).classList.add("hidden");
}
script submitFormAndHideDialog(formId string, dialogId string) {
document.getElementById(formId).submit();
document.getElementById(dialogId).classList.add("hidden");
}
script showDialog(id string) {
document.getElementById(id).classList.remove("hidden");
}
// Backup dialog component specifically for restore and delete actions
templ BackupActionDialog(id string, title string, message string, confirmClass string, confirmText string, action string, backupName string) {
<div id={ id } class="hidden fixed inset-0 bg-secondary-900/50 dark:bg-secondary-900/80 backdrop-blur-sm z-50 flex items-center justify-center">
<div class="bg-white dark:bg-secondary-800 rounded-lg shadow-xl max-w-md w-full mx-4 overflow-hidden">
<div class="px-6 pt-5 pb-3 text-center">
<div class="flex justify-center mb-2">
<i class="fas fa-exclamation-triangle text-yellow-400 text-5xl"></i>
</div>
<h3 class="text-xl font-medium text-secondary-900 dark:text-secondary-100">
{ title }
</h3>
</div>
<div class="px-6 py-4 text-center">
<p class="text-secondary-700 dark:text-secondary-300">
{ message }
</p>
</div>
<div class="px-6 py-4 flex justify-center space-x-3">
<button type="button" class="btn-secondary" onclick={ hideDialog(id) }>
Cancel
</button>
if action == "restore" {
<button
type="button"
class="btn-warning"
hx-post={ fmt.Sprintf("/admin/restore-database/%s", backupName) }
hx-swap="none"
onclick={ hideDialog(id) }>
{ confirmText }
</button>
} else if action == "delete" {
<button
type="button"
class="btn-danger"
hx-delete={ fmt.Sprintf("/admin/delete-backup/%s", backupName) }
hx-target="closest tr"
hx-swap="delete"
onclick={ hideDialog(id) }>
{ confirmText }
</button>
}
</div>
</div>
</div>
}
templ AdminTools(ctx context.Context, data AdminToolsData) {
@LayoutWithContext("Admin Tools", ctx) {
<div class="py-6">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex items-center justify-between mb-8">
<h1 class="text-3xl font-bold text-secondary-900 dark:text-secondary-100">
<i class="fas fa-tools mr-2 text-primary-600 dark:text-primary-400"></i>
Admin Tools
</h1>
<div class="text-sm text-secondary-500 dark:text-secondary-400">
<span id="current-date" class="font-medium"></span>
<script>
document.getElementById('current-date').textContent = new Date().toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
</script>
</div>
</div>
if data.MaintenanceMessage != "" {
<div class="mb-6 bg-yellow-50 dark:bg-yellow-900/20 border-l-4 border-yellow-400 p-4 rounded">
<div class="flex">
<div class="flex-shrink-0">
<i class="fas fa-exclamation-triangle text-yellow-400"></i>
</div>
<div class="ml-3">
<p class="text-sm text-yellow-700 dark:text-yellow-300">
{ data.MaintenanceMessage }
</p>
</div>
</div>
</div>
}
<!-- System Overview -->
<div class="mb-8">
<h2 class="text-xl font-semibold text-secondary-900 dark:text-secondary-100 mb-4">
<i class="fas fa-chart-line mr-2 text-primary-500"></i>
System Overview
</h2>
<div class="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4">
<div class="card overflow-hidden">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<i class="fas fa-database text-blue-500 text-2xl"></i>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400 truncate">
Database Size
</dt>
<dd>
<div class="text-lg font-medium text-secondary-900 dark:text-secondary-100">
{ data.DatabaseSize }
</div>
</dd>
</dl>
</div>
</div>
</div>
</div>
<div class="card overflow-hidden">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<i class="fas fa-history text-green-500 text-2xl"></i>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400 truncate">
Job History Records
</dt>
<dd>
<div class="text-lg font-medium text-secondary-900 dark:text-secondary-100">
{ fmt.Sprint(data.JobHistoryCount) }
</div>
</dd>
</dl>
</div>
</div>
</div>
</div>
<div class="card overflow-hidden">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<i class="fas fa-clock text-purple-500 text-2xl"></i>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400 truncate">
System Uptime
</dt>
<dd>
<div class="text-lg font-medium text-secondary-900 dark:text-secondary-100">
{ data.SystemUptime }
</div>
</dd>
</dl>
</div>
</div>
</div>
</div>
<div class="card overflow-hidden">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<i class="fas fa-tasks text-orange-500 text-2xl"></i>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400 truncate">
Active Jobs
</dt>
<dd>
<div class="text-lg font-medium text-secondary-900 dark:text-secondary-100">
{ fmt.Sprint(data.ActiveJobs) }
</div>
</dd>
</dl>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Admin Tools Cards -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Backup & Restore -->
<div class="card">
<div class="card-header">
<h3 class="text-lg font-medium text-secondary-900 dark:text-secondary-100">
<i class="fas fa-save mr-2 text-primary-500"></i>
Backup & Restore
</h3>
</div>
<div class="card-body space-y-4">
<div class="flex items-center justify-between text-sm text-secondary-500 dark:text-secondary-400 mb-2">
if data.LastBackupTime != nil {
<span>Last backup: { data.LastBackupTime.Format("Jan 02, 2006 15:04:05") }</span>
} else {
<span>Last backup: Never</span>
}
</div>
<button id="backup-form"
hx-post="/admin/backup-database"
hx-swap="none"
hx-indicator="#backup-indicator"
hx-on::after-request="htmx.trigger('#refresh-backups-trigger', 'click')"
class="btn-primary w-full flex items-center justify-center">
<i class="fas fa-database mr-2"></i>
<span>Backup Database</span>
<div id="backup-indicator" class="htmx-indicator ml-2">
<i class="fas fa-circle-notch fa-spin"></i>
</div>
</button>
<!-- Hidden refresh trigger -->
<button id="refresh-backups-trigger"
class="hidden"
hx-get="/admin/refresh-backups"
hx-target="#backups-container"></button>
<form id="export-configs-form" action="/admin/export-configs" method="GET">
<button type="submit" class="btn-secondary w-full flex items-center justify-center">
<i class="fas fa-file-export mr-2"></i>
<span>Export All Configurations</span>
<div id="export-configs-indicator" class="htmx-indicator ml-2">
<i class="fas fa-circle-notch fa-spin"></i>
</div>
</button>
</form>
<form id="export-jobs-form" action="/admin/export-jobs" method="GET">
<button type="submit" class="btn-secondary w-full flex items-center justify-center">
<i class="fas fa-file-export mr-2"></i>
<span>Export All Jobs</span>
<div id="export-jobs-indicator" class="htmx-indicator ml-2">
<i class="fas fa-circle-notch fa-spin"></i>
</div>
</button>
</form>
<div class="border-t border-secondary-200 dark:border-secondary-700 pt-4 mt-4">
<h4 class="text-sm font-medium text-secondary-900 dark:text-secondary-100 mb-2">Restore Database</h4>
<form id="restore-form" hx-post="/admin/restore-database" hx-encoding="multipart/form-data" hx-swap="none" hx-indicator="#restore-indicator">
<div class="flex items-center space-x-2">
<div class="flex-grow">
<label class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
Select Backup File
</label>
<input type="file" name="backup_file" class="block w-full text-sm text-secondary-500 dark:text-secondary-400
file:mr-4 file:py-2 file:px-4
file:rounded-md file:border-0
file:text-sm file:font-medium
file:bg-primary-50 file:text-primary-700
dark:file:bg-primary-900 dark:file:text-primary-300
hover:file:bg-primary-100 dark:hover:file:bg-primary-800
focus:outline-none" required />
</div>
<button type="submit" class="btn-warning flex-shrink-0 flex items-center justify-center h-10">
<div class="flex items-center justify-center">
<i class="fas fa-upload mr-1"></i>
<span>Restore</span>
<div id="restore-indicator" class="htmx-indicator ml-2">
<i class="fas fa-circle-notch fa-spin"></i>
</div>
</div>
</button>
</div>
<p class="text-xs text-red-600 dark:text-red-400 mt-2">
<i class="fas fa-exclamation-triangle mr-1"></i>
Warning: This will replace your current database. Make sure to backup first!
</p>
</form>
</div>
</div>
</div>
<!-- Maintenance Tools -->
<div class="card">
<div class="card-header">
<h3 class="text-lg font-medium text-secondary-900 dark:text-secondary-100">
<i class="fas fa-wrench mr-2 text-primary-500"></i>
Maintenance Tools
</h3>
</div>
<div class="card-body space-y-4">
<!-- Clear Job History Button and Dialog -->
@Dialog("clear-job-dialog", "Clear Job History", "Are you sure you want to clear all job history? This cannot be undone.", "btn-danger", "Clear History", "purge-form", "")
<form id="purge-form" hx-post="/admin/clear-job-history" hx-swap="none" hx-indicator="#clear-history-indicator">
<button type="button" class="btn-danger w-full flex items-center justify-center" onclick={ showDialog("clear-job-dialog") }>
<i class="fas fa-trash-alt mr-2"></i>
<span>Clear Job History</span>
<div id="clear-history-indicator" class="htmx-indicator ml-2">
<i class="fas fa-circle-notch fa-spin"></i>
</div>
</button>
</form>
<div class="border-t border-secondary-200 dark:border-secondary-700 pt-4 mt-4">
<form id="vacuum-form" hx-post="/admin/vacuum-database" hx-swap="none" hx-indicator="#vacuum-indicator">
<button type="submit" class="btn-secondary w-full flex items-center justify-center">
<i class="fas fa-compress-alt mr-2"></i>
<span>Optimize Database</span>
<div id="vacuum-indicator" class="htmx-indicator ml-2">
<i class="fas fa-circle-notch fa-spin"></i>
</div>
</button>
<p class="text-xs text-secondary-500 dark:text-secondary-400 mt-2">
Runs VACUUM to optimize the database and reclaim unused space.
</p>
</form>
</div>
</div>
</div>
</div>
<!-- Available Backups -->
<div id="backups-container" class="mt-8">
@BackupsList(data)
</div>
<!-- System Information -->
<div class="mt-8">
<div class="card">
<div class="card-header">
<h3 class="text-lg font-medium text-secondary-900 dark:text-secondary-100">
<i class="fas fa-info-circle mr-2 text-primary-500"></i>
System Information
</h3>
</div>
<div class="card-body">
<dl class="grid grid-cols-1 gap-x-4 gap-y-6 sm:grid-cols-2">
<div class="sm:col-span-1">
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">Database Path</dt>
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100 font-mono bg-secondary-50 dark:bg-secondary-900 p-2 rounded overflow-auto">
{ data.DatabasePath }
</dd>
</div>
<div class="sm:col-span-1">
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">Backup Directory</dt>
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100 font-mono bg-secondary-50 dark:bg-secondary-900 p-2 rounded overflow-auto">
{ data.BackupPath }
</dd>
</div>
<div class="sm:col-span-1">
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">Total Users</dt>
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100">
{ fmt.Sprint(data.TotalUsers) }
</dd>
</div>
<div class="sm:col-span-1">
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">Total Configurations</dt>
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100">
{ fmt.Sprint(data.TotalConfigs) }
</dd>
</div>
<div class="sm:col-span-1">
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">Total Jobs</dt>
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100">
{ fmt.Sprint(data.TotalJobs) }
</dd>
</div>
</dl>
</div>
</div>
</div>
</div>
</div>
}
}
// BackupsList is a separate component for the backups list that can be refreshed via HTMX
templ BackupsList(data AdminToolsData) {
if len(data.BackupFiles) > 0 {
<div class="card">
<div class="card-header flex justify-between items-center">
<h3 class="text-lg font-medium text-secondary-900 dark:text-secondary-100">
<i class="fas fa-history mr-2 text-primary-500"></i>
Available Backups
</h3>
<button
hx-get="/admin/refresh-backups"
hx-target="#backups-container"
hx-indicator="#refresh-backups-indicator"
class="text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300"
title="Refresh backups list">
<i class="fas fa-sync-alt"></i>
<span id="refresh-backups-indicator" class="htmx-indicator ml-1">
<i class="fas fa-circle-notch fa-spin"></i>
</span>
</button>
</div>
<div class="card-body">
<div class="bg-secondary-50 dark:bg-secondary-900 rounded-lg overflow-hidden">
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-secondary-200 dark:divide-secondary-700">
<thead>
<tr class="bg-secondary-100 dark:bg-secondary-800">
<th class="px-4 py-2 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400">Name</th>
<th class="px-4 py-2 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400">Size</th>
<th class="px-4 py-2 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400">Date</th>
<th class="px-4 py-2 text-right text-xs font-medium text-secondary-500 dark:text-secondary-400">Action</th>
</tr>
</thead>
<tbody class="divide-y divide-secondary-200 dark:divide-secondary-700">
for _, backup := range data.BackupFiles {
<tr class="hover:bg-secondary-100 dark:hover:bg-secondary-800 transition-colors">
<td class="px-4 py-2 text-sm text-secondary-900 dark:text-secondary-100">{ backup.Name }</td>
<td class="px-4 py-2 text-sm text-secondary-500 dark:text-secondary-400">{ backup.Size }</td>
<td class="px-4 py-2 text-sm text-secondary-500 dark:text-secondary-400">{ backup.ModTime.Format("Jan 02, 2006 15:04:05") }</td>
<td class="px-4 py-2 text-right">
<a href={ templ.SafeURL(fmt.Sprintf("/admin/download-backup/%s", backup.Name)) }
class="text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300 mr-2"
title="Download backup">
<i class="fas fa-download"></i>
</a>
<!-- Restore Dialog -->
@BackupActionDialog(
fmt.Sprintf("restore-dialog-%s", backup.Name),
"RESTORE BACKUP",
fmt.Sprintf("Are you sure you want to restore the backup '%s'? This will replace your current database.", backup.Name),
"btn-warning",
"Restore",
"restore",
backup.Name,
)
<button
type="button"
class="text-yellow-600 hover:text-yellow-700 dark:text-yellow-400 dark:hover:text-yellow-300 mr-2"
title="Restore this backup"
onclick={ showDialog(fmt.Sprintf("restore-dialog-%s", backup.Name)) }>
<i class="fas fa-upload"></i>
</button>
<!-- Delete Dialog -->
@BackupActionDialog(
fmt.Sprintf("delete-dialog-%s", backup.Name),
"DELETE BACKUP",
fmt.Sprintf("Are you sure you want to delete the backup '%s'? This cannot be undone.", backup.Name),
"btn-danger",
"Delete",
"delete",
backup.Name,
)
<button
type="button"
class="text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"
title="Delete backup"
onclick={ showDialog(fmt.Sprintf("delete-dialog-%s", backup.Name)) }>
<i class="fas fa-trash-alt"></i>
</button>
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
</div>
} else {
<div class="card">
<div class="card-header flex justify-between items-center">
<h3 class="text-lg font-medium text-secondary-900 dark:text-secondary-100">
<i class="fas fa-history mr-2 text-primary-500"></i>
Available Backups
</h3>
<button
hx-get="/admin/refresh-backups"
hx-target="#backups-container"
hx-indicator="#refresh-backups-indicator"
class="text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300"
title="Refresh backups list">
<i class="fas fa-sync-alt"></i>
<span id="refresh-backups-indicator" class="htmx-indicator ml-1">
<i class="fas fa-circle-notch fa-spin"></i>
</span>
</button>
</div>
<div class="card-body">
<div class="text-center py-6">
<div class="mx-auto w-12 h-12 rounded-full bg-secondary-100 dark:bg-secondary-800 flex items-center justify-center mb-4">
<i class="fas fa-folder-open text-secondary-400 dark:text-secondary-500"></i>
</div>
<h3 class="text-sm font-medium text-secondary-900 dark:text-secondary-100">No Backups Available</h3>
<p class="mt-1 text-sm text-secondary-500 dark:text-secondary-400">
Create a backup using the "Backup Database" button.
</p>
</div>
</div>
</div>
}
}
+655
View File
@@ -0,0 +1,655 @@
package components
import (
"fmt"
"encoding/json"
"context"
"github.com/starfleetcptn/gomft/internal/db"
)
templ AuthProviderForm(ctx context.Context, provider *db.AuthProvider, isNew bool) {
@LayoutWithContext(getPageTitle(isNew), ctx) {
<div id="auth-provider-form-container" style="min-height: 100vh; background-color: rgb(249, 250, 251);" class="auth-provider-form-page bg-gray-50 dark:bg-gray-900">
<div class="p-4 pb-8 w-full">
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4 mb-6">
<div>
if isNew {
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
<i class="fas fa-user-shield w-6 h-6 mr-2 text-blue-500 dark:text-blue-400"></i>
New Authentication Provider
</h1>
<p class="text-gray-500 dark:text-gray-400">Configure a new external authentication source</p>
} else {
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
<i class="fas fa-user-shield w-6 h-6 mr-2 text-blue-500 dark:text-blue-400"></i>
Edit Authentication Provider
</h1>
<p class="text-gray-500 dark:text-gray-400">Update an existing external authentication source</p>
}
</div>
<div>
<a href="/admin/settings/auth-providers" class="text-gray-700 bg-gray-100 hover:bg-gray-200 focus:ring-4 focus:outline-none focus:ring-gray-300 font-medium rounded-lg text-sm px-4 py-2 text-center inline-flex items-center dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600 dark:focus:ring-gray-700">
<i class="fas fa-arrow-left w-4 h-4 mr-2"></i> Back to Providers
</a>
</div>
</div>
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 p-6">
if isNew {
<form method="POST" action="/admin/settings/auth-providers" class="space-y-6">
@formContent(provider, isNew)
</form>
} else {
<form method="POST" action={ templ.SafeURL(fmt.Sprintf("/admin/settings/auth-providers/%d", provider.ID)) } class="space-y-6">
<input type="hidden" name="_method" value="PUT" />
@formContent(provider, isNew)
</form>
}
</div>
<!-- Help Section -->
<div class="bg-gray-50 dark:bg-gray-800 rounded-lg shadow-sm mt-8 p-4 border border-gray-200 dark:border-gray-700">
<div class="flex items-start mb-2">
<div class="flex items-center h-5">
<i class="fas fa-info-circle w-4 h-4 text-blue-500 dark:text-blue-400 mr-2"></i>
</div>
<div class="ml-2 text-sm">
<p class="text-gray-700 dark:text-gray-300">Authentication providers allow users to sign in using external identity providers.</p>
</div>
</div>
<div class="flex items-start mt-4">
<div class="flex items-center h-5">
<i class="fas fa-key w-4 h-4 text-blue-500 dark:text-blue-400 mr-2"></i>
</div>
<div class="ml-2 text-sm">
<p class="text-gray-700 dark:text-gray-300">Make sure to enter the correct callback URL in your external provider's configuration.</p>
</div>
</div>
<div class="flex items-start mt-4">
<div class="flex items-center h-5">
<i class="fas fa-user-check w-4 h-4 text-indigo-500 dark:text-indigo-400 mr-2"></i>
</div>
<div class="ml-2 text-sm">
<p class="text-gray-700 dark:text-gray-300">Configure attribute mappings to match the fields in your identity provider's user data.</p>
</div>
</div>
</div>
</div>
</div>
<script>
function updateFormFields() {
// Hide all provider-specific sections first
document.querySelectorAll('.provider-specific-section').forEach(section => {
section.style.display = 'none';
});
// Show the relevant section based on selected provider type
const providerType = document.getElementById('type').value;
if (providerType === 'authentik') {
document.getElementById('authentikSection').style.display = 'block';
} else if (providerType === 'oidc') {
document.getElementById('oidcSection').style.display = 'block';
} else if (providerType === 'saml') {
document.getElementById('samlSection').style.display = 'block';
}
}
function testProviderConnection() {
const providerId = window.location.pathname.split('/').slice(-2)[0];
const testBtn = event.target;
const originalText = testBtn.innerHTML;
testBtn.disabled = true;
testBtn.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i> Testing...';
fetch(`/admin/settings/auth-providers/${providerId}/test`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
})
.then(response => response.json())
.then(data => {
if (data.error) {
showToast('error', data.error);
} else if (data.success) {
showToast('success', data.message || 'Connection test successful');
} else {
showToast('error', 'Connection test failed');
}
testBtn.disabled = false;
testBtn.innerHTML = originalText;
})
.catch(error => {
console.error('Error:', error);
showToast('error', 'Connection test failed');
testBtn.disabled = false;
testBtn.innerHTML = originalText;
});
}
// Handle icon URL preview
function updateIconPreview() {
const iconUrl = document.getElementById('icon_url').value;
const previewImg = document.getElementById('preview-img');
const previewPlaceholder = document.getElementById('preview-placeholder');
if (iconUrl && iconUrl.trim() !== '') {
// Create image element if it doesn't exist
if (!previewImg) {
const img = document.createElement('img');
img.id = 'preview-img';
img.className = 'max-w-full max-h-full';
img.onerror = function() {
// If image fails to load, show placeholder
this.style.display = 'none';
if (previewPlaceholder) {
previewPlaceholder.style.display = 'block';
} else {
const icon = document.createElement('i');
icon.id = 'preview-placeholder';
icon.className = 'fas fa-exclamation-circle text-red-500 text-xl';
document.getElementById('icon-preview').appendChild(icon);
}
};
img.onload = function() {
// If image loads successfully, hide placeholder
this.style.display = 'block';
if (previewPlaceholder) {
previewPlaceholder.style.display = 'none';
}
};
document.getElementById('icon-preview').appendChild(img);
}
// Update image source
if (previewImg) {
previewImg.src = iconUrl;
previewImg.style.display = 'block';
if (previewPlaceholder) {
previewPlaceholder.style.display = 'none';
}
}
} else {
// If no URL, show placeholder
if (previewImg) {
previewImg.style.display = 'none';
}
if (previewPlaceholder) {
previewPlaceholder.style.display = 'block';
}
}
}
// Initialize the form on page load
document.addEventListener('DOMContentLoaded', function() {
updateFormFields();
// Set dark background color if in dark mode
if (document.documentElement.classList.contains('dark')) {
document.getElementById('auth-provider-form-container').style.backgroundColor = '#111827';
}
// Add event listener for theme changes
const themeToggle = document.getElementById('theme-toggle');
if (themeToggle) {
themeToggle.addEventListener('click', function() {
setTimeout(function() {
const isDark = document.documentElement.classList.contains('dark');
document.getElementById('auth-provider-form-container').style.backgroundColor = isDark ? '#111827' : 'rgb(249, 250, 251)';
}, 50);
});
}
// Add event listener for icon URL changes
const iconUrlInput = document.getElementById('icon_url');
if (iconUrlInput) {
iconUrlInput.addEventListener('input', updateIconPreview);
// Initial preview
updateIconPreview();
}
});
</script>
}
}
func getPageTitle(isNew bool) string {
if isNew {
return "New Authentication Provider"
}
return "Edit Authentication Provider"
}
templ formContent(provider *db.AuthProvider, isNew bool) {
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
<!-- Name -->
<div>
<label for="name" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Name <span class="text-red-600">*</span></label>
<input
type="text"
id="name"
name="name"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="e.g. Authentik SSO"
required
value={ getValue(provider, "name") }
/>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">A descriptive name for this authentication provider</p>
</div>
<!-- Provider Type -->
<div>
<label for="type" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Provider Type <span class="text-red-600">*</span></label>
<select id="type" name="type" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" required onchange="updateFormFields()">
<option value="">Select a provider type</option>
if provider != nil && provider.Type == db.ProviderTypeAuthentik {
<option value="authentik" selected>Authentik</option>
} else {
<option value="authentik">Authentik</option>
}
if provider != nil && provider.Type == db.ProviderTypeOIDC {
<option value="oidc" selected>OpenID Connect (OIDC)</option>
} else {
<option value="oidc">OpenID Connect (OIDC)</option>
}
if provider != nil && provider.Type == db.ProviderTypeSAML {
<option value="saml" selected>SAML 2.0</option>
} else {
<option value="saml">SAML 2.0</option>
}
if provider != nil && provider.Type == db.ProviderTypeOAuth2 {
<option value="oauth2" selected>OAuth 2.0</option>
} else {
<option value="oauth2">OAuth 2.0</option>
}
</select>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">The type of external authentication service</p>
</div>
<!-- Enabled -->
<div>
<div class="flex items-center">
if provider == nil || provider.GetEnabled() {
<input
type="checkbox"
id="enabled"
name="enabled"
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"
checked
/>
} else {
<input
type="checkbox"
id="enabled"
name="enabled"
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"
/>
}
<label for="enabled" class="ml-2 block text-sm font-medium text-gray-700 dark:text-gray-300">Enabled</label>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Whether this authentication provider is active and available for login</p>
</div>
<!-- Description -->
<div>
<label for="description" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Description</label>
<textarea
id="description"
name="description"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
rows="3"
placeholder="Optional description of this provider"
>
if provider != nil {
{ provider.Description }
}
</textarea>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Additional information about this authentication provider</p>
</div>
</div>
<!-- Icon URL -->
<div class="border-t border-gray-200 dark:border-gray-700 pt-6">
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-4">Provider Icon</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label for="icon_url" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Icon URL</label>
<input
type="url"
id="icon_url"
name="icon_url"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="https://example.com/icon.svg"
value={ provider.IconURL }
/>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">URL to the provider's icon image (SVG recommended)</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Icon Preview</label>
<div class="flex items-center">
<div id="icon-preview" class="border border-gray-300 dark:border-gray-700 rounded-lg p-4 flex items-center justify-center w-20 h-20 bg-white dark:bg-gray-800">
if provider != nil && provider.IconURL != "" {
<img src={ provider.IconURL } class="max-w-full max-h-full" id="preview-img" />
} else {
<i class="fas fa-image text-gray-400 text-xl" id="preview-placeholder"></i>
}
</div>
<div class="ml-4">
<p class="text-sm text-gray-500 dark:text-gray-400">Preview of the icon that will be displayed on the login button.</p>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">If no URL is provided, a default icon will be used based on the provider type.</p>
</div>
</div>
</div>
</div>
</div>
<div class="border-t border-gray-200 dark:border-gray-700 pt-6">
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-4">Connection Settings</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Provider URL -->
<div>
<label for="provider_url" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Provider URL <span class="text-red-600">*</span></label>
<input
type="url"
id="provider_url"
name="provider_url"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="https://authentik.example.com"
required
value={ getValue(provider, "provider_url") }
/>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">The base URL of the authentication provider</p>
</div>
<!-- Client ID -->
<div>
<label for="client_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Client ID <span class="text-red-600">*</span></label>
<input
type="text"
id="client_id"
name="client_id"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
required
value={ getValue(provider, "client_id") }
/>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">The client identifier assigned by the authentication provider</p>
</div>
<!-- Client Secret -->
<div>
<label for="client_secret" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Client Secret
if !isNew {
<span class="text-gray-500 text-xs font-normal ml-2">(leave empty to keep current)</span>
} else {
<span class="text-red-600">*</span>
}
</label>
if isNew {
<input
type="password"
id="client_secret"
name="client_secret"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
required
/>
} else {
<input
type="password"
id="client_secret"
name="client_secret"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
/>
}
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">The client secret for authentication with the provider</p>
</div>
<!-- Redirect URL -->
<div>
<label for="redirect_url" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Redirect URL <span class="text-red-600">*</span></label>
<input
type="url"
id="redirect_url"
name="redirect_url"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="https://your-app.example.com/auth/callback"
required
value={ getRedirectURL(provider) }
/>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">The callback URL that will handle the authentication response</p>
</div>
<!-- Scopes -->
<div>
<label for="scopes" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Scopes</label>
<input
type="text"
id="scopes"
name="scopes"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="openid profile email"
value={ getValue(provider, "scopes") }
/>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Space-separated list of scopes to request from the provider</p>
</div>
</div>
</div>
<div class="border-t border-gray-200 dark:border-gray-700 pt-6 provider-specific-section" id="authentikSection">
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-4">Authentik Settings</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Authentik Tenant ID -->
<div>
<label for="authentik_tenant" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Tenant ID</label>
<input
type="text"
id="authentik_tenant"
name="authentik_tenant"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="default"
value={ getConfigValue(provider, "tenant_id") }
/>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Authentik tenant ID (optional, defaults to 'default')</p>
</div>
</div>
</div>
<div class="border-t border-gray-200 dark:border-gray-700 pt-6 provider-specific-section" id="oidcSection">
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-4">OpenID Connect Settings</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- OIDC Discovery URL -->
<div>
<label for="oidc_discovery_url" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Discovery URL</label>
<input
type="url"
id="oidc_discovery_url"
name="oidc_discovery_url"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="https://provider/.well-known/openid-configuration"
value={ getConfigValue(provider, "discovery_url") }
/>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">URL to the OIDC discovery document</p>
</div>
</div>
</div>
<div class="border-t border-gray-200 dark:border-gray-700 pt-6 provider-specific-section" id="samlSection">
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-4">SAML Settings</h3>
<div class="grid grid-cols-1 gap-6">
<!-- SAML Metadata URL -->
<div>
<label for="saml_metadata_url" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Metadata URL</label>
<input
type="url"
id="saml_metadata_url"
name="saml_metadata_url"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="https://provider/metadata.xml"
value={ getConfigValue(provider, "metadata_url") }
/>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">URL to the SAML metadata XML</p>
</div>
</div>
</div>
<div class="border-t border-gray-200 dark:border-gray-700 pt-6">
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-4">User Attribute Mapping</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Username Attribute -->
<div>
<label for="attr_username" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Username Attribute</label>
<input
type="text"
id="attr_username"
name="attr_username"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="preferred_username"
value={ getAttributeValue(provider, "username") }
/>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">The attribute to use as the username</p>
</div>
<!-- Email Attribute -->
<div>
<label for="attr_email" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Email Attribute</label>
<input
type="text"
id="attr_email"
name="attr_email"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="email"
value={ getAttributeValue(provider, "email") }
/>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">The attribute to use as the email address</p>
</div>
<!-- Display Name Attribute -->
<div>
<label for="attr_name" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Display Name Attribute</label>
<input
type="text"
id="attr_name"
name="attr_name"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="name"
value={ getAttributeValue(provider, "name") }
/>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">The attribute to use as the display name</p>
</div>
<!-- Groups Attribute -->
<div>
<label for="attr_groups" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Groups Attribute</label>
<input
type="text"
id="attr_groups"
name="attr_groups"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="groups"
value={ getAttributeValue(provider, "groups") }
/>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">The attribute that contains user groups</p>
</div>
</div>
</div>
<div class="flex justify-end gap-4 mt-6">
<a href="/admin/settings/auth-providers" class="text-gray-700 bg-gray-100 hover:bg-gray-200 focus:ring-4 focus:outline-none focus:ring-gray-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center inline-flex items-center dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600 dark:focus:ring-gray-700">
Cancel
</a>
if !isNew {
<button type="button" class="text-white bg-blue-600 hover:bg-blue-700 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center inline-flex items-center dark:bg-blue-500 dark:hover:bg-blue-600 dark:focus:ring-blue-700" onclick="testProviderConnection()">
<i class="fas fa-check-circle w-4 h-4 mr-2"></i>
Test Connection
</button>
}
<button type="submit" class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center inline-flex items-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800">
if isNew {
<i class="fas fa-plus w-4 h-4 mr-2"></i>
Create Provider
} else {
<i class="fas fa-save w-4 h-4 mr-2"></i>
Save Changes
}
</button>
</div>
}
func getValue(provider *db.AuthProvider, field string) string {
if provider == nil {
return ""
}
switch field {
case "name":
return provider.Name
case "provider_url":
return provider.ProviderURL
case "client_id":
return provider.ClientID
case "redirect_url":
return provider.RedirectURL
case "scopes":
return provider.Scopes
case "icon_url":
return provider.IconURL
default:
return ""
}
}
func getRedirectURL(provider *db.AuthProvider) string {
if provider == nil || provider.RedirectURL == "" {
return fmt.Sprintf("https://%s/auth/callback", "your-app-domain.com")
}
return provider.RedirectURL
}
func getConfigValue(provider *db.AuthProvider, key string) string {
if provider == nil || provider.Config == "" {
return ""
}
var config map[string]interface{}
if err := json.Unmarshal([]byte(provider.Config), &config); err != nil {
return ""
}
if value, ok := config[key]; ok {
if strValue, ok := value.(string); ok {
return strValue
}
}
return ""
}
func getAttributeValue(provider *db.AuthProvider, key string) string {
if provider == nil || provider.AttributeMapping == "" {
return ""
}
var mapping map[string]string
if err := json.Unmarshal([]byte(provider.AttributeMapping), &mapping); err != nil {
return ""
}
if value, ok := mapping[key]; ok {
return value
}
return ""
}
+380
View File
@@ -0,0 +1,380 @@
package components
import (
"context"
"fmt"
"time"
"github.com/starfleetcptn/gomft/internal/db"
)
// formatTime formats a time.Time value as a human-readable string
func formatTime(t time.Time) string {
return t.Format("Jan 02, 2006 15:04")
}
templ AuthProviders(ctx context.Context, providers []db.AuthProvider) {
@LayoutWithContext("Authentication Providers", ctx) {
<!-- Toast container for notifications -->
<div id="toast-container" class="fixed top-5 right-5 z-50 flex flex-col gap-2"></div>
<div id="auth-providers-container" style="min-height: 100vh; background-color: rgb(249, 250, 251);" class="auth-providers-page bg-gray-50 dark:bg-gray-900">
<div class="p-4 pb-8 w-full">
<div class="mb-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
<i class="fas fa-user-shield w-6 h-6 mr-2 text-blue-500 dark:text-blue-400"></i>
Authentication Providers
</h1>
<p class="text-gray-500 dark:text-gray-400">Manage external authentication sources like Authentik, OIDC, SAML, etc.</p>
</div>
<div>
<a href="/admin/settings/auth-providers/new" class="flex items-center justify-center text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg px-5 py-2.5 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800">
<i class="fas fa-plus w-4 h-4 mr-2"></i>
New Provider
</a>
</div>
</div>
<div class="mt-6">
if len(providers) > 0 {
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full text-sm text-left rtl:text-right">
<thead class="text-xs uppercase bg-gray-100 dark:bg-gray-700">
<tr>
<th scope="col" class="px-6 py-3">Name</th>
<th scope="col" class="px-6 py-3">Icon</th>
<th scope="col" class="px-6 py-3">Type</th>
<th scope="col" class="px-6 py-3">Status</th>
<th scope="col" class="px-6 py-3">Provider URL</th>
<th scope="col" class="px-6 py-3">Last Used</th>
<th scope="col" class="px-6 py-3">Actions</th>
</tr>
</thead>
<tbody>
for _, provider := range providers {
<tr class="border-b border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50">
<td class="px-6 py-4 font-medium text-gray-900 dark:text-white whitespace-nowrap">
{ provider.Name }
</td>
<td class="px-6 py-4">
<div class="flex items-center justify-center">
if provider.IconURL != "" {
<img src={ provider.IconURL } class="w-6 h-6" alt={ provider.Name + " icon" } />
} else {
if provider.Type == db.ProviderTypeAuthentik {
<img src="/static/img/authentik.svg" class="w-6 h-6" alt="Authentik" />
} else if provider.Type == db.ProviderTypeOIDC {
<img src="/static/img/oidc.svg" class="w-6 h-6" alt="OIDC" />
} else if provider.Type == db.ProviderTypeSAML {
<img src="/static/img/saml.svg" class="w-6 h-6" alt="SAML" />
} else if provider.Type == db.ProviderTypeOAuth2 {
<img src="/static/img/oauth2.svg" class="w-6 h-6" alt="OAuth2" />
} else {
<i class="fas fa-user-shield text-blue-500 text-lg"></i>
}
}
</div>
</td>
<td class="px-6 py-4 uppercase">
{ string(provider.Type) }
</td>
<td class="px-6 py-4">
if provider.GetEnabled() {
<span class="px-2 py-1 text-xs rounded-full bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
Active
</span>
} else {
<span class="px-2 py-1 text-xs rounded-full bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200">
Disabled
</span>
}
</td>
<td class="px-6 py-4 max-w-[200px] truncate">
{ provider.ProviderURL }
</td>
<td class="px-6 py-4">
if provider.LastUsed.Valid {
{ formatTime(provider.LastUsed.Time) }
} else {
<span class="text-gray-400 dark:text-gray-500">Never</span>
}
</td>
<td class="px-6 py-4">
<div class="flex gap-2">
<a href={ templ.SafeURL("/admin/settings/auth-providers/" + fmt.Sprint(provider.ID) + "/edit") }
class="text-gray-700 bg-gray-100 hover:bg-gray-200 focus:ring-4 focus:outline-none focus:ring-gray-300 font-medium rounded-lg text-sm px-3 py-1.5 text-center inline-flex items-center dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600 dark:focus:ring-gray-700"
title="Edit">
<i class="fas fa-edit w-3.5 h-3.5 mr-1.5"></i>
Edit
</a>
<button
type="button"
data-provider-id={ fmt.Sprint(provider.ID) }
data-provider-name={ provider.Name }
class="delete-provider-btn text-white bg-red-700 hover:bg-red-800 focus:ring-4 focus:outline-none focus:ring-red-300 font-medium rounded-lg text-sm px-3 py-1.5 text-center inline-flex items-center dark:bg-red-600 dark:hover:bg-red-700 dark:focus:ring-red-800"
title="Delete">
<i class="fas fa-trash-alt w-3.5 h-3.5 mr-1.5"></i>
Delete
</button>
<button
data-provider-id={ fmt.Sprint(provider.ID) }
onclick="testProvider(this)"
class="text-blue-700 bg-blue-100 hover:bg-blue-200 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-3 py-1.5 text-center inline-flex items-center dark:bg-blue-700 dark:text-blue-300 dark:hover:bg-blue-600 dark:focus:ring-blue-800"
title="Test Connection">
<i class="fas fa-check-circle w-3.5 h-3.5 mr-1.5"></i>
Test
</button>
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
} else {
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 p-8 flex flex-col items-center justify-center text-center">
<div class="inline-flex h-16 w-16 flex-shrink-0 items-center justify-center rounded-full bg-gray-100 mb-4 dark:bg-gray-700">
<i class="fas fa-user-shield text-gray-400 dark:text-gray-500 text-3xl"></i>
</div>
<h3 class="mb-2 text-lg font-semibold text-gray-900 dark:text-white">No Authentication Providers</h3>
<p class="text-gray-500 dark:text-gray-400 mb-4">
You haven't set up any external authentication providers yet.
</p>
<a href="/admin/settings/auth-providers/new" class="inline-flex items-center px-3 py-2 text-sm font-medium text-center text-white bg-blue-700 rounded-lg hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800">
<i class="fas fa-plus w-4 h-4 mr-2"></i>
Add First Provider
</a>
</div>
}
</div>
<!-- Help Section -->
<div class="bg-gray-50 dark:bg-gray-800 rounded-lg shadow-sm mt-8 p-4 border border-gray-200 dark:border-gray-700">
<div class="flex items-start mb-2">
<div class="flex items-center h-5">
<i class="fas fa-info-circle w-4 h-4 text-blue-500 dark:text-blue-400 mr-2"></i>
</div>
<div class="ml-2 text-sm">
<p class="text-gray-700 dark:text-gray-300">Authentication providers allow users to sign in using external identity providers.</p>
</div>
</div>
<div class="flex items-start mt-4">
<div class="flex items-center h-5">
<i class="fas fa-key w-4 h-4 text-blue-500 dark:text-blue-400 mr-2"></i>
</div>
<div class="ml-2 text-sm">
<p class="text-gray-700 dark:text-gray-300">Make sure to configure callback URLs in your provider's settings.</p>
</div>
</div>
<div class="flex items-start mt-4">
<div class="flex items-center h-5">
<i class="fas fa-user-check w-4 h-4 text-indigo-500 dark:text-indigo-400 mr-2"></i>
</div>
<div class="ml-2 text-sm">
<p class="text-gray-700 dark:text-gray-300">Test your connections to ensure proper communication with external authentication systems.</p>
</div>
</div>
</div>
</div>
</div>
<!-- Delete Provider Modal using Flowbite style -->
<div id="deleteProviderModal" tabindex="-1" aria-hidden="true" class="hidden overflow-y-auto overflow-x-hidden fixed top-0 right-0 left-0 z-50 justify-center items-center w-full md:inset-0 h-[calc(100%-1rem)] max-h-full bg-gray-900/50 dark:bg-gray-900/80 backdrop-blur-sm">
<div class="relative p-4 w-full max-w-md max-h-full mx-auto">
<div class="relative bg-white rounded-lg shadow dark:bg-gray-700">
<div class="p-6 text-center">
<i class="fas fa-trash-alt text-red-400 text-3xl mb-4"></i>
<h3 class="mb-5 text-lg font-normal text-gray-500 dark:text-gray-400">Are you sure you want to delete the provider <strong id="providerNameConfirm"></strong>?</h3>
<button
type="button"
id="confirmDeleteBtn"
class="text-white bg-red-600 hover:bg-red-800 focus:ring-4 focus:outline-none focus:ring-red-300 dark:focus:ring-red-800 font-medium rounded-lg text-sm inline-flex items-center px-5 py-2.5 text-center">
Delete
</button>
<button type="button" onclick="closeDeleteModal()" class="text-gray-500 bg-white hover:bg-gray-100 focus:ring-4 focus:outline-none focus:ring-gray-200 rounded-lg border border-gray-200 text-sm font-medium px-5 py-2.5 hover:text-gray-900 focus:z-10 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-500 dark:hover:text-white dark:hover:bg-gray-600 dark:focus:ring-gray-600">
Cancel
</button>
</div>
</div>
</div>
</div>
<script>
// Toast notification function
function showToast(type, message) {
const toastContainer = document.getElementById('toast-container');
// Create toast element
const toast = document.createElement('div');
toast.id = 'toast-' + type + '-' + Date.now();
toast.className = 'flex items-center w-full max-w-xs p-4 mb-4 rounded-lg shadow text-gray-500 bg-white dark:text-gray-400 dark:bg-gray-800 transform translate-y-16 opacity-0 transition-all duration-300 ease-out';
toast.role = 'alert';
// Set toast content based on type
let iconClass, bgColorClass, textColorClass;
if (type === 'success') {
iconClass = 'text-green-500 bg-green-100 dark:bg-green-800 dark:text-green-200';
bgColorClass = 'text-green-500 dark:text-green-200';
textColorClass = 'text-green-500 dark:text-green-200';
} else if (type === 'error') {
iconClass = 'text-red-500 bg-red-100 dark:bg-red-800 dark:text-red-200';
bgColorClass = 'text-red-500 dark:text-red-200';
textColorClass = 'text-red-500 dark:text-red-200';
} else {
iconClass = 'text-blue-500 bg-blue-100 dark:bg-blue-800 dark:text-blue-200';
bgColorClass = 'text-blue-500 dark:text-blue-200';
textColorClass = 'text-blue-500 dark:text-blue-200';
}
// Set inner HTML with appropriate icon and message
toast.innerHTML = `
<div class="inline-flex items-center justify-center flex-shrink-0 w-8 h-8 rounded-lg ${iconClass}">
${type === 'success'
? '<i class="fas fa-check"></i>'
: type === 'error'
? '<i class="fas fa-exclamation-circle"></i>'
: '<i class="fas fa-info-circle"></i>'}
</div>
<div class="ml-3 text-sm font-normal">${message}</div>
<button type="button" class="ml-auto -mx-1.5 -my-1.5 bg-white text-gray-400 hover:text-gray-900 rounded-lg focus:ring-2 focus:ring-gray-300 p-1.5 hover:bg-gray-100 inline-flex h-8 w-8 dark:text-gray-500 dark:hover:text-white dark:bg-gray-800 dark:hover:bg-gray-700" data-dismiss-target="#${toast.id}" aria-label="Close">
<span class="sr-only">Close</span>
<i class="fas fa-times"></i>
</button>
`;
// Add toast to container
toastContainer.appendChild(toast);
// Trigger animation after a small delay to ensure the DOM has updated
setTimeout(() => {
toast.classList.remove('translate-y-16', 'opacity-0');
toast.classList.add('translate-y-0', 'opacity-100');
}, 10);
// Add event listener to close button
const closeButton = toast.querySelector('button[data-dismiss-target]');
closeButton.addEventListener('click', function() {
// Animate out before removing
toast.classList.add('opacity-0', 'translate-y-4');
setTimeout(() => {
toast.remove();
}, 300);
});
// Auto-remove toast after 5 seconds
setTimeout(() => {
toast.classList.add('opacity-0', 'translate-y-4');
setTimeout(() => {
toast.remove();
}, 300);
}, 5000);
}
function confirmDeleteProvider(providerId, providerName) {
document.getElementById('providerNameConfirm').textContent = providerName;
document.getElementById('confirmDeleteBtn').onclick = () => deleteProvider(providerId);
// Show the modal
document.getElementById('deleteProviderModal').classList.remove('hidden');
document.getElementById('deleteProviderModal').classList.add('flex');
}
function closeDeleteModal() {
document.getElementById('deleteProviderModal').classList.add('hidden');
document.getElementById('deleteProviderModal').classList.remove('flex');
}
function deleteProvider(providerId) {
fetch(`/admin/settings/auth-providers/${providerId}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
},
})
.then(response => response.json())
.then(data => {
if (data.error) {
showToast('error', data.error);
} else {
showToast('success', 'Authentication provider deleted successfully');
setTimeout(() => {
window.location.reload();
}, 1000);
}
closeDeleteModal();
})
.catch(error => {
console.error('Error:', error);
showToast('error', 'Failed to delete authentication provider');
closeDeleteModal();
});
}
function testProvider(btn) {
const providerId = btn.getAttribute('data-provider-id');
const originalText = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '<i class="fas fa-spinner fa-spin w-3.5 h-3.5 mr-1.5"></i> Testing...';
fetch(`/admin/settings/auth-providers/${providerId}/test`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
})
.then(response => response.json())
.then(data => {
if (data.error) {
showToast('error', data.error);
} else if (data.success) {
showToast('success', data.message || 'Connection test successful');
} else {
showToast('error', 'Connection test failed');
}
btn.disabled = false;
btn.innerHTML = originalText;
})
.catch(error => {
console.error('Error:', error);
showToast('error', 'Connection test failed');
btn.disabled = false;
btn.innerHTML = originalText;
});
}
// Set dark background color if in dark mode
document.addEventListener('DOMContentLoaded', function() {
if (document.documentElement.classList.contains('dark')) {
document.getElementById('auth-providers-container').style.backgroundColor = '#111827';
}
// Add event listener for theme changes
const themeToggle = document.getElementById('theme-toggle');
if (themeToggle) {
themeToggle.addEventListener('click', function() {
setTimeout(function() {
const isDark = document.documentElement.classList.contains('dark');
document.getElementById('auth-providers-container').style.backgroundColor = isDark ? '#111827' : 'rgb(249, 250, 251)';
}, 50);
});
}
// Set up event listeners for delete buttons
document.querySelectorAll('.delete-provider-btn').forEach(button => {
button.addEventListener('click', function() {
const providerId = this.getAttribute('data-provider-id');
const providerName = this.getAttribute('data-provider-name');
confirmDeleteProvider(providerId, providerName);
});
});
});
</script>
}
}
+52
View File
@@ -0,0 +1,52 @@
package components
import (
"fmt"
"github.com/starfleetcptn/gomft/internal/db"
)
// getProviderIcon returns the appropriate icon for a provider
func getProviderIcon(provider db.AuthProvider) templ.Component {
// If provider has a custom icon URL, use it
if provider.IconURL != "" {
return templ.Raw(fmt.Sprintf(`<img src="%s" class="w-5 h-5" alt="%s icon" />`, provider.IconURL, provider.Name))
}
// Otherwise fall back to default icons based on type
switch provider.Type {
case db.ProviderTypeAuthentik:
return templ.Raw(`<img src="/static/img/authentik.svg" class="w-5 h-5" alt="Authentik" />`)
case db.ProviderTypeOIDC:
return templ.Raw(`<img src="/static/img/oidc.svg" class="w-5 h-5" alt="OIDC" />`)
case db.ProviderTypeSAML:
return templ.Raw(`<img src="/static/img/saml.svg" class="w-5 h-5" alt="SAML" />`)
case db.ProviderTypeOAuth2:
return templ.Raw(`<img src="/static/img/oauth2.svg" class="w-5 h-5" alt="OAuth2" />`)
default:
return templ.Raw(`<i class="fas fa-user-shield text-blue-500"></i>`)
}
}
templ AuthProviderButtons(providers []db.AuthProvider) {
if len(providers) == 0 {
<div class="text-sm text-gray-500 dark:text-gray-400 italic">
No external authentication providers available
</div>
} else {
<div class="space-y-2 w-full">
for _, provider := range providers {
if provider.GetEnabled() {
<a
href={ templ.SafeURL(fmt.Sprintf("/auth/provider/%d", provider.ID)) }
class="w-full inline-flex items-center justify-center px-4 py-2.5 bg-gray-100 border border-gray-300 rounded-lg font-medium text-gray-700 hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 dark:bg-gray-700 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-600"
>
<span class="flex-shrink-0 w-5 h-5 mr-2.5">
@getProviderIcon(provider)
</span>
<span>{ provider.Name }</span>
</a>
}
}
</div>
}
}
+651
View File
@@ -0,0 +1,651 @@
package components
import (
"context"
"fmt"
"github.com/starfleetcptn/gomft/internal/db"
"time"
"encoding/json"
"strings"
"strconv"
"sort"
)
// JobCalendarData contains the data for the calendar view
type JobCalendarData struct {
Jobs []db.Job
}
// generateCalendarEvents converts jobs to calendar events in JSON format
func generateCalendarEvents(jobs []db.Job) string {
type CalendarEvent struct {
ID string `json:"id"`
Title string `json:"title"`
Start string `json:"start"`
End string `json:"end,omitempty"`
AllDay bool `json:"allDay,omitempty"`
URL string `json:"url,omitempty"`
ClassName string `json:"className,omitempty"`
Description string `json:"description,omitempty"`
Enabled bool `json:"enabled"`
JobID uint `json:"jobId"`
JobName string `json:"jobName"`
RunTimes []string `json:"runTimes,omitempty"` // Store additional run times for this day
RunCount int `json:"runCount,omitempty"` // Count of runs on this day
Schedule string `json:"schedule,omitempty"` // Store the schedule for tooltip
}
var events []CalendarEvent
// Set the range for future occurrences - 2 months seems to be a good balance
now := time.Now()
twoMonthsLater := now.AddDate(0, 2, 0)
// Map to track events by job ID and date to consolidate multiple occurrences
eventsByJobAndDay := make(map[string][]time.Time)
// Store job information for easy access
jobInfo := make(map[uint]struct {
Name string
Enabled bool
Schedule string
})
// First, gather all runs and group them by job ID and day
for _, job := range jobs {
// Skip jobs with no next run time
if job.NextRun == nil {
continue
}
// Store job information
jobName := job.Name
if jobName == "" {
jobName = job.Config.Name
}
jobInfo[job.ID] = struct {
Name string
Enabled bool
Schedule string
}{
Name: jobName,
Enabled: job.GetEnabled(),
Schedule: job.Schedule,
}
// Create the first occurrence based on NextRun
nextRun := *job.NextRun
// Skip past events that are more than a day old
oneDayAgo := now.AddDate(0, 0, -1)
if nextRun.Before(oneDayAgo) {
// For past events, if we have LastRun, use that instead
if job.LastRun != nil {
nextRun = *job.LastRun
// Still skip if it's too old
if nextRun.Before(oneDayAgo) {
continue
}
} else {
continue
}
}
// Add initial run to the map
dateKey := fmt.Sprintf("%d-%s", job.ID, nextRun.Format("2006-01-02"))
eventsByJobAndDay[dateKey] = append(eventsByJobAndDay[dateKey], nextRun)
// Try to determine future occurrences based on the cron schedule
var interval time.Duration
schedule := strings.ToLower(job.Schedule)
// Determine interval based on schedule
switch {
case strings.Contains(schedule, "every minute") || strings.Contains(schedule, "* * * * *"):
interval = 1 * time.Minute
case strings.Contains(schedule, "every 5 minutes") || strings.Contains(schedule, "*/5 * * * *"):
interval = 5 * time.Minute
case strings.Contains(schedule, "every 10 minutes") || strings.Contains(schedule, "*/10 * * * *"):
interval = 10 * time.Minute
case strings.Contains(schedule, "every 15 minutes") || strings.Contains(schedule, "*/15 * * * *"):
interval = 15 * time.Minute
case strings.Contains(schedule, "every 30 minutes") || strings.Contains(schedule, "*/30 * * * *"):
interval = 30 * time.Minute
case strings.Contains(schedule, "hourly") || strings.Contains(schedule, "0 * * * *"):
interval = 1 * time.Hour
case strings.Contains(schedule, "every 2 hours") || strings.Contains(schedule, "0 */2 * * *"):
interval = 2 * time.Hour
case strings.Contains(schedule, "every 3 hours") || strings.Contains(schedule, "0 */3 * * *"):
interval = 3 * time.Hour
case strings.Contains(schedule, "every 4 hours") || strings.Contains(schedule, "0 */4 * * *"):
interval = 4 * time.Hour
case strings.Contains(schedule, "every 6 hours") || strings.Contains(schedule, "0 */6 * * *"):
interval = 6 * time.Hour
case strings.Contains(schedule, "every 12 hours") || strings.Contains(schedule, "0 */12 * * *"):
interval = 12 * time.Hour
case strings.Contains(schedule, "daily") || strings.Contains(schedule, "0 0 * * *"):
interval = 24 * time.Hour
case strings.Contains(schedule, "weekly") || strings.Contains(schedule, "0 0 * * 0"):
interval = 7 * 24 * time.Hour
case strings.Contains(schedule, "monthly") || strings.Contains(schedule, "0 0 1 * *"):
// Approximate as 30 days
interval = 30 * 24 * time.Hour
default:
// For other schedules, try a simple cron expression check
if strings.Contains(schedule, "* * * * *") {
// Every minute
interval = 1 * time.Minute
} else if strings.Contains(schedule, "*/") {
// Likely a recurring job with specific interval
interval = 1 * time.Hour // Default to hourly as a safe guess
} else {
continue
}
}
// Generate future occurrences
// Limit the number of runs we'll capture per day
maxRunsPerDay := 20
// Generate future occurrences up to our limits
currentTime := nextRun.Add(interval)
for currentTime.Before(twoMonthsLater) {
dateKey := fmt.Sprintf("%d-%s", job.ID, currentTime.Format("2006-01-02"))
// Check if we already have too many runs for this day
if len(eventsByJobAndDay[dateKey]) < maxRunsPerDay {
eventsByJobAndDay[dateKey] = append(eventsByJobAndDay[dateKey], currentTime)
}
currentTime = currentTime.Add(interval)
}
}
// Now convert the map to calendar events, consolidating runs on the same day
for dateKey, runTimes := range eventsByJobAndDay {
// Parse job ID from date key
parts := strings.Split(dateKey, "-")
jobIDStr := parts[0]
jobID, _ := strconv.ParseUint(jobIDStr, 10, 32)
// Get the job info
job, exists := jobInfo[uint(jobID)]
if !exists {
continue
}
// Sort run times chronologically
sort.Slice(runTimes, func(i, j int) bool {
return runTimes[i].Before(runTimes[j])
})
// Use the first run time as the event time
firstRunTime := runTimes[0]
// Format run times for display in tooltip
formattedTimes := make([]string, 0, len(runTimes))
for i, rt := range runTimes {
// Limit to showing max 10 times in tooltip
if i >= 10 {
formattedTimes = append(formattedTimes, fmt.Sprintf("... and %d more", len(runTimes)-10))
break
}
formattedTimes = append(formattedTimes, rt.Format("15:04:05"))
}
// Set class based on job enabled status
className := ""
if job.Enabled {
className = "bg-blue-200 border-blue-600 text-blue-800 dark:bg-blue-800 dark:border-blue-500 dark:text-blue-100"
} else {
className = "bg-gray-200 border-gray-400 text-gray-700 dark:bg-gray-700 dark:border-gray-500 dark:text-gray-300"
}
// Create a single event for this job on this day
title := job.Name
if len(runTimes) > 1 {
title = fmt.Sprintf("%s (%d runs)", job.Name, len(runTimes))
}
// Create event
events = append(events, CalendarEvent{
ID: fmt.Sprintf("job-%d-%s", jobID, firstRunTime.Format("20060102")),
Title: title,
Start: firstRunTime.Format(time.RFC3339),
AllDay: false,
URL: fmt.Sprintf("/jobs/%d", jobID),
ClassName: className,
Description: fmt.Sprintf("Schedule: %s", job.Schedule),
Enabled: job.Enabled,
JobID: uint(jobID),
JobName: job.Name,
RunTimes: formattedTimes,
RunCount: len(runTimes),
Schedule: job.Schedule,
})
}
// Debug info
fmt.Printf("Generated %d consolidated calendar events\n", len(events))
eventsJSON, err := json.Marshal(events)
if err != nil {
return "[]" // Return empty array if marshaling fails
}
return string(eventsJSON)
}
// JobCalendar displays scheduled jobs in a calendar view
templ JobCalendar(ctx context.Context, data JobCalendarData) {
@LayoutWithContext("Transfer Calendar", ctx) {
<div class="p-6">
<div class="w-full">
<div class="flex flex-col md:flex-row justify-between items-start md:items-center mb-6">
<div class="flex items-center mb-4 md:mb-0">
<i class="fas fa-calendar-alt text-blue-500 mr-2"></i>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Transfer Calendar</h1>
</div>
<a href="/jobs/new" class="text-white bg-blue-600 hover:bg-blue-700 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 dark:bg-blue-600 dark:hover:bg-blue-700 flex items-center">
<i class="fas fa-plus mr-2"></i>
New Job
</a>
</div>
<div class="mb-6">
<div class="flex flex-wrap gap-2">
<button id="showAll" class="flex items-center text-sm px-4 py-2 rounded-lg bg-blue-100 text-blue-800 hover:bg-blue-200 dark:bg-blue-800 dark:text-blue-100 dark:hover:bg-blue-700">
<i class="fas fa-calendar-check mr-2"></i>All Jobs
</button>
<button id="showActive" class="flex items-center text-sm px-4 py-2 rounded-lg bg-gray-100 text-gray-800 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-100 dark:hover:bg-gray-600">
<i class="fas fa-toggle-on mr-2"></i>Active Only
</button>
<button id="showInactive" class="flex items-center text-sm px-4 py-2 rounded-lg bg-gray-100 text-gray-800 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-100 dark:hover:bg-gray-600">
<i class="fas fa-toggle-off mr-2"></i>Inactive Only
</button>
<button id="showNext" class="flex items-center text-sm px-4 py-2 rounded-lg bg-gray-100 text-gray-800 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-100 dark:hover:bg-gray-600">
<i class="fas fa-step-forward mr-2"></i>Next Occurrences Only
</button>
</div>
</div>
<!-- Hidden div to store calendar event data -->
<div id="calendar-data" style="display: none;">{ generateCalendarEvents(data.Jobs) }</div>
<!-- Loading indicator -->
<div id="calendar-loading" class="flex items-center justify-center p-8">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 dark:border-blue-400"></div>
<span class="ml-3 text-gray-600 dark:text-gray-400">Loading calendar...</span>
</div>
<div id="calendar" class="w-full bg-white dark:bg-gray-800 rounded-lg shadow-md overflow-hidden hidden"></div>
</div>
</div>
<!-- Info section -->
<div class="bg-white dark:bg-gray-800 p-6 rounded-lg shadow-md mb-8 mt-6 mx-6">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">About the Calendar View</h2>
<p class="text-gray-700 dark:text-gray-300 mb-4">
This calendar displays your scheduled transfer jobs for the next 2 months. Click on any event to view or edit the job details.
</p>
<div class="bg-blue-50 dark:bg-blue-900 p-4 rounded-lg mb-4">
<p class="text-blue-700 dark:text-blue-300 text-sm">
<i class="fas fa-info-circle mr-2"></i>
Jobs with multiple runs on the same day are consolidated into a single event. Hover over any event to see all scheduled run times for that day.
</p>
</div>
<h3 class="text-lg font-medium text-gray-900 dark:text-white mt-4 mb-2">Filter Options</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3 mb-4">
<div class="flex items-start">
<i class="fas fa-calendar-check mt-1 text-blue-500 dark:text-blue-400 mr-2"></i>
<div>
<p class="font-medium text-gray-800 dark:text-gray-200">All Jobs</p>
<p class="text-sm text-gray-600 dark:text-gray-400">Shows all scheduled occurrences in the selected timeframe.</p>
</div>
</div>
<div class="flex items-start">
<i class="fas fa-toggle-on mt-1 text-blue-500 dark:text-blue-400 mr-2"></i>
<div>
<p class="font-medium text-gray-800 dark:text-gray-200">Active Only</p>
<p class="text-sm text-gray-600 dark:text-gray-400">Shows only enabled jobs that will actually run.</p>
</div>
</div>
<div class="flex items-start">
<i class="fas fa-toggle-off mt-1 text-blue-500 dark:text-blue-400 mr-2"></i>
<div>
<p class="font-medium text-gray-800 dark:text-gray-200">Inactive Only</p>
<p class="text-sm text-gray-600 dark:text-gray-400">Shows disabled jobs that won't run unless re-enabled.</p>
</div>
</div>
<div class="flex items-start">
<i class="fas fa-step-forward mt-1 text-blue-500 dark:text-blue-400 mr-2"></i>
<div>
<p class="font-medium text-gray-800 dark:text-gray-200">Next Occurrences Only</p>
<p class="text-sm text-gray-600 dark:text-gray-400">Shows only the next upcoming occurrence of each job.</p>
</div>
</div>
</div>
<h3 class="text-lg font-medium text-gray-900 dark:text-white mt-4 mb-2">Legend</h3>
<div class="flex flex-wrap gap-4">
<div class="flex items-center">
<div class="w-4 h-4 rounded-full bg-blue-500 mr-2"></div>
<span class="text-gray-700 dark:text-gray-300">Active Jobs</span>
</div>
<div class="flex items-center">
<div class="w-4 h-4 rounded-full bg-gray-500 mr-2"></div>
<span class="text-gray-700 dark:text-gray-300">Inactive Jobs</span>
</div>
</div>
</div>
<!-- Local assets are now loaded through vendor.js bundle -->
<script>
// Function to initialize the calendar
function initializeCalendar() {
// Get the event data from the hidden div
try {
// Show loading indicator
const loadingIndicator = document.getElementById('calendar-loading');
const calendarElement = document.getElementById('calendar');
// Parse the data in a non-blocking way
try {
const eventsData = JSON.parse(document.getElementById('calendar-data').textContent);
// Check if FullCalendar is available
if (typeof FullCalendar === 'undefined') {
console.error('FullCalendar is not loaded yet. Waiting...');
setTimeout(initializeCalendar, 100);
return;
}
// Initialize calendar with events data
const calendar = new FullCalendar.Calendar(calendarElement, {
initialView: 'dayGridMonth',
plugins: [
FullCalendar.dayGridPlugin,
FullCalendar.timeGridPlugin,
FullCalendar.listPlugin,
FullCalendar.interactionPlugin
],
headerToolbar: {
left: 'prev,next today',
center: 'title',
right: 'dayGridMonth,timeGridWeek,listWeek'
},
events: eventsData,
eventTimeFormat: {
hour: '2-digit',
minute: '2-digit',
hour12: true
},
eventDidMount: function(info) {
// Log the event data for debugging
console.log('Event mounted:', info.event.title, 'Enabled:', info.event.extendedProps.enabled);
// Add tooltips to events
if (info.event.extendedProps) {
// Only add tooltips to visible events to improve performance
if (info.view.type === 'dayGridMonth' &&
info.event.start >= calendar.view.activeStart &&
info.event.start <= calendar.view.activeEnd) {
// Create enhanced tooltip content with run times
let tooltipContent = `<div class="p-2">`;
tooltipContent += `<div class="font-bold mb-1">${info.event.title}</div>`;
tooltipContent += `<div class="text-sm mb-2">Schedule: ${info.event.extendedProps.schedule || 'Unknown'}</div>`;
// Show the run times if available
if (info.event.extendedProps.runTimes && info.event.extendedProps.runTimes.length > 0) {
tooltipContent += `<div class="font-bold text-xs mt-1">Run Times:</div>`;
tooltipContent += `<div class="text-xs">`;
// Show the run times in a list
info.event.extendedProps.runTimes.forEach(time => {
tooltipContent += `<div>${time}</div>`;
});
tooltipContent += `</div>`;
}
tooltipContent += `<div class="text-xs mt-2">Click to view/edit job</div>`;
tooltipContent += `</div>`;
tippy(info.el, {
content: tooltipContent,
allowHTML: true,
placement: 'top',
arrow: true,
interactive: true,
maxWidth: 300,
theme: document.documentElement.classList.contains('dark') ? 'dark' : 'light'
});
}
}
},
eventClick: function(info) {
// Use the URL property to navigate to job details
if (info.event.url) {
window.location.href = info.event.url;
return false; // Prevents the default action
}
},
eventWillUnmount: function(info) {
// Cleanup any tooltips to prevent memory leaks
if (info.el._tippy) {
info.el._tippy.destroy();
}
},
themeSystem: 'standard',
loading: function(isLoading) {
if (!isLoading) {
// Hide loading indicator and show calendar
loadingIndicator.classList.add('hidden');
calendarElement.classList.remove('hidden');
}
}
});
calendar.render();
// Handle filter buttons with optimized filtering
document.getElementById('showAll').addEventListener('click', function() {
updateActiveButton(this);
// Show all events
calendar.getEvents().forEach(event => {
event.setProp('display', '');
});
calendar.render(); // Re-render to apply changes
});
document.getElementById('showActive').addEventListener('click', function() {
updateActiveButton(this);
// Show only active events
calendar.getEvents().forEach(event => {
const isEnabled = event.extendedProps.enabled;
event.setProp('display', isEnabled ? '' : 'none');
});
calendar.render(); // Re-render to apply changes
});
document.getElementById('showInactive').addEventListener('click', function() {
updateActiveButton(this);
// Show only inactive events
calendar.getEvents().forEach(event => {
const isEnabled = event.extendedProps.enabled;
event.setProp('display', !isEnabled ? '' : 'none');
});
calendar.render(); // Re-render to apply changes
});
document.getElementById('showNext').addEventListener('click', function() {
updateActiveButton(this);
console.log("Next Occurrences Only filter clicked");
// First reset all events to make sure none are hidden
calendar.getEvents().forEach(event => {
event.setProp('display', 'none');
});
// Get a map of all jobs
const jobIDs = new Set();
calendar.getEvents().forEach(event => {
if (event.extendedProps && event.extendedProps.jobId) {
jobIDs.add(event.extendedProps.jobId);
}
});
console.log("Found jobs:", Array.from(jobIDs));
// For each job, find the next occurrence and show it
const now = new Date();
jobIDs.forEach(jobId => {
// Get all events for this job
const jobEvents = calendar.getEvents().filter(event =>
event.extendedProps &&
event.extendedProps.jobId === jobId
);
console.log(`Job ${jobId}: Found ${jobEvents.length} events`);
// Find future events
const futureEvents = jobEvents.filter(event =>
new Date(event.start) >= now
).sort((a, b) =>
new Date(a.start) - new Date(b.start)
);
// If we have future events, show the earliest one
if (futureEvents.length > 0) {
console.log(`Job ${jobId}: Next occurrence at ${futureEvents[0].start}`);
futureEvents[0].setProp('display', '');
} else {
// If no future events, find most recent past event
const pastEvents = jobEvents.filter(event =>
new Date(event.start) < now
).sort((a, b) =>
new Date(b.start) - new Date(a.start)
);
if (pastEvents.length > 0) {
console.log(`Job ${jobId}: Most recent occurrence at ${pastEvents[0].start}`);
pastEvents[0].setProp('display', '');
}
}
});
calendar.render(); // Re-render to apply changes
});
function updateActiveButton(activeBtn) {
// Reset all buttons
document.querySelectorAll('#showAll, #showActive, #showInactive, #showNext').forEach(btn => {
btn.classList.remove('bg-blue-100', 'text-blue-800', 'dark:bg-blue-800', 'dark:text-blue-100');
btn.classList.add('bg-gray-100', 'text-gray-800', 'dark:bg-gray-700', 'dark:text-gray-100');
});
// Set active button
activeBtn.classList.remove('bg-gray-100', 'text-gray-800', 'dark:bg-gray-700', 'dark:text-gray-100');
activeBtn.classList.add('bg-blue-100', 'text-blue-800', 'dark:bg-blue-800', 'dark:text-blue-100');
}
// Handle theme changes
const themeToggle = document.getElementById('theme-toggle');
if (themeToggle) {
themeToggle.addEventListener('click', function() {
setTimeout(function() {
// Update tooltips theme
document.querySelectorAll('[data-tippy-root]').forEach(tooltip => {
tooltip.className = document.documentElement.classList.contains('dark')
? 'tippy-box dark-theme'
: 'tippy-box light-theme';
});
}, 100);
});
}
} catch (error) {
console.error("Error initializing calendar:", error);
loadingIndicator.innerHTML = '<div class="text-red-500"><i class="fas fa-exclamation-triangle mr-2"></i>Error loading calendar data</div>';
}
} catch (error) {
console.error("Error during initial calendar setup:", error);
}
}
// Wait for vendor.js to load before initializing calendar
document.addEventListener('DOMContentLoaded', function() {
setTimeout(initializeCalendar, 100);
});
</script>
<style>
.fc-event {
cursor: pointer;
border-radius: 4px;
padding: 2px 4px;
border-left-width: 4px;
}
/* Style consolidated events */
.fc-event-title {
font-weight: 500;
font-size: 0.85em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Tooltip styles */
.tippy-box {
border-radius: 6px;
font-size: 13px;
}
.tippy-box[data-theme~='dark'] {
background-color: #1f2937;
color: #f3f4f6;
}
.tippy-box[data-theme~='light'] {
background-color: #ffffff;
color: #1f2937;
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
}
@media (prefers-color-scheme: dark) {
.fc {
--fc-page-bg-color: #1f2937;
--fc-border-color: #374151;
--fc-neutral-bg-color: #374151;
--fc-neutral-text-color: #e5e7eb;
--fc-today-bg-color: rgba(59, 130, 246, 0.15);
}
.fc-day-today {
background-color: rgba(59, 130, 246, 0.15) !important;
}
.fc-col-header-cell {
background-color: #111827;
}
.fc-scrollgrid-sync-inner {
color: #e5e7eb;
}
.fc-daygrid-day-number {
color: #e5e7eb;
}
}
</style>
}
}
+790 -1800
View File
File diff suppressed because it is too large Load Diff
+329 -106
View File
@@ -6,55 +6,68 @@ import (
"github.com/starfleetcptn/gomft/internal/db"
)
// Dialog component for confirmation dialogs
// Dialog component for confirmation dialogs using Flowbite modal
templ ConfigDialog(id string, title string, message string, confirmClass string, confirmText string, action string, configID uint, configName string) {
<div id={ id } class="hidden fixed inset-0 bg-secondary-900/50 dark:bg-secondary-900/80 backdrop-blur-sm z-50 flex items-center justify-center">
<div class="bg-white dark:bg-secondary-800 rounded-lg shadow-xl max-w-md w-full mx-4 overflow-hidden">
<div class="px-6 pt-5 pb-3 text-center">
<div class="flex justify-center mb-2">
<i class="fas fa-exclamation-triangle text-yellow-400 text-3xl"></i>
<div id={ id } tabindex="-1" aria-hidden="true" class="hidden fixed top-0 right-0 left-0 z-50 justify-center items-center w-full md:inset-0 h-[calc(100%-1rem)] max-h-full">
<!-- Backdrop -->
<div id={ fmt.Sprintf("%s-backdrop", id) } class="fixed inset-0 bg-gray-900/50 dark:bg-gray-900/80 backdrop-blur-sm"></div>
<!-- Modal content -->
<div class="relative p-4 w-full max-w-md max-h-full mx-auto">
<div class="relative bg-white rounded-lg shadow dark:bg-gray-700">
<div class="p-6 text-center">
if action == "delete" {
<i class="fas fa-trash-alt text-red-400 text-3xl mb-4"></i>
} else {
<i class="fas fa-exclamation-circle text-yellow-400 text-3xl mb-4"></i>
}
<h3 class="mb-5 text-lg font-normal text-gray-500 dark:text-gray-400">{ message }</h3>
<button
type="button"
class={ confirmClass }
hx-delete={ fmt.Sprintf("/configs/%d", configID) }
hx-target="closest li"
hx-swap="delete"
data-config-name={ configName }
data-config-id={ fmt.Sprint(configID) }
id={ fmt.Sprintf("delete-config-btn-%d", configID) }
onclick={ triggerConfigDelete(id, configID, configName) }>
{ confirmText }
</button>
<button type="button" onclick={ closeModal(id) } class="text-gray-500 bg-white hover:bg-gray-100 focus:ring-4 focus:outline-none focus:ring-gray-200 rounded-lg border border-gray-200 text-sm font-medium px-5 py-2.5 hover:text-gray-900 focus:z-10 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-500 dark:hover:text-white dark:hover:bg-gray-600 dark:focus:ring-gray-600">
Cancel
</button>
</div>
<h3 class="text-xl font-medium text-secondary-900 dark:text-secondary-100">
{ title }
</h3>
</div>
<div class="px-6 py-4 text-center">
<p class="text-secondary-700 dark:text-secondary-300">
{ message }
</p>
</div>
<div class="px-6 py-4 flex justify-end space-x-3">
<button type="button" class="btn-secondary" onclick={ hideConfigDialog(id) }>
Cancel
</button>
<button
type="button"
class={ confirmClass }
hx-delete={ fmt.Sprintf("/configs/%d", configID) }
hx-target="closest li"
hx-swap="delete"
data-config-name={ configName }
data-config-id={ fmt.Sprint(configID) }
id={ fmt.Sprintf("delete-config-btn-%d", configID) }
onclick={ triggerConfigDelete(id, configID, configName) }>
{ confirmText }
</button>
</div>
</div>
</div>
}
script hideConfigDialog(id string) {
document.getElementById(id).classList.add("hidden");
script closeModal(id string) {
const modal = document.getElementById(id);
const backdrop = document.getElementById(id + '-backdrop');
if (modal) {
modal.classList.add('hidden');
modal.classList.remove('flex');
}
if (backdrop) {
backdrop.remove();
}
document.body.style.overflow = '';
}
script showConfigDialog(id string) {
document.getElementById(id).classList.remove("hidden");
script showModal(id string) {
const modal = document.getElementById(id);
if (modal) {
modal.classList.remove('hidden');
modal.classList.add('flex');
document.body.style.overflow = 'hidden';
}
}
script triggerConfigDelete(dialogId string, configID uint, configName string) {
// Hide the dialog
document.getElementById(dialogId).classList.add("hidden");
document.getElementById(dialogId).classList.remove("flex");
// Store data in a way that's accessible to event handlers
window.lastDeletedConfig = {
@@ -68,44 +81,123 @@ script triggerConfigDelete(dialogId string, configID uint, configName string) {
type ConfigsData struct {
Configs []db.TransferConfig
Error string
ErrorDetails string
Status string
}
templ Configs(ctx context.Context, data ConfigsData) {
@LayoutWithContext("Transfer Configurations", ctx) {
<!-- Status and Error Messages -->
<div id="toast-container" class="fixed top-5 right-5 z-50 flex flex-col gap-2"></div>
<script>
// Debug notification system
console.log("Configs template loaded, setting up notification system");
// Create a global notyf instance if it doesn't exist yet
if (!window.notyf) {
window.notyf = new Notyf({
duration: 3000,
position: {
x: 'right',
y: 'top',
},
types: [
{
type: 'success',
background: '#38c172',
icon: {
className: 'fas fa-check-circle',
tagName: 'i'
}
},
{
type: 'error',
background: '#e3342f',
icon: {
className: 'fas fa-exclamation-circle',
tagName: 'i'
}
}
]
// Function to create and show a toast
function showToast(message, type) {
const toastContainer = document.getElementById('toast-container');
// Create toast element
const toast = document.createElement('div');
toast.id = 'toast-' + type + '-' + Date.now();
toast.className = 'flex items-center w-full max-w-xs p-4 mb-4 rounded-lg shadow text-gray-500 bg-white dark:text-gray-400 dark:bg-gray-800 transform translate-y-16 opacity-0 transition-all duration-300 ease-out';
toast.role = 'alert';
// Set toast content based on type
let iconClass, bgColorClass, textColorClass;
if (type === 'success') {
iconClass = 'text-green-500 bg-green-100 dark:bg-green-800 dark:text-green-200';
bgColorClass = 'text-green-500 dark:text-green-200';
textColorClass = 'text-green-500 dark:text-green-200';
} else if (type === 'error') {
iconClass = 'text-red-500 bg-red-100 dark:bg-red-800 dark:text-red-200';
bgColorClass = 'text-red-500 dark:text-red-200';
textColorClass = 'text-red-500 dark:text-red-200';
} else {
iconClass = 'text-blue-500 bg-blue-100 dark:bg-blue-800 dark:text-blue-200';
bgColorClass = 'text-blue-500 dark:text-blue-200';
textColorClass = 'text-blue-500 dark:text-blue-200';
}
// Set inner HTML with appropriate icon and message
toast.innerHTML = `
<div class="inline-flex items-center justify-center flex-shrink-0 w-8 h-8 rounded-lg ${iconClass}">
${type === 'success'
? '<i class="fas fa-check"></i>'
: type === 'error'
? '<i class="fas fa-exclamation-circle"></i>'
: '<i class="fas fa-info-circle"></i>'}
</div>
<div class="ml-3 text-sm font-normal">${message}</div>
<button type="button" class="ml-auto -mx-1.5 -my-1.5 bg-white text-gray-400 hover:text-gray-900 rounded-lg focus:ring-2 focus:ring-gray-300 p-1.5 hover:bg-gray-100 inline-flex h-8 w-8 dark:text-gray-500 dark:hover:text-white dark:bg-gray-800 dark:hover:bg-gray-700" data-dismiss-target="#${toast.id}" aria-label="Close">
<span class="sr-only">Close</span>
<i class="fas fa-times"></i>
</button>
`;
// Add toast to container
toastContainer.appendChild(toast);
// Trigger animation after a small delay to ensure the DOM has updated
setTimeout(() => {
toast.classList.remove('translate-y-16', 'opacity-0');
toast.classList.add('translate-y-0', 'opacity-100');
}, 10);
// Add event listener to close button
const closeButton = toast.querySelector('button[data-dismiss-target]');
closeButton.addEventListener('click', function() {
// Animate out before removing
toast.classList.add('opacity-0', 'translate-y-4');
setTimeout(() => {
toast.remove();
}, 300);
});
console.log("Notyf initialized:", window.notyf);
// Auto-remove toast after 5 seconds
setTimeout(() => {
toast.classList.add('opacity-0', 'translate-y-4');
setTimeout(() => {
toast.remove();
}, 300);
}, 5000);
}
// Show status messages based on URL parameters
document.addEventListener('DOMContentLoaded', function() {
// Check for error message
const urlParams = new URLSearchParams(window.location.search);
const errorMsg = urlParams.get('error');
const errorDetails = urlParams.get('details');
const status = urlParams.get('status');
if (errorMsg) {
let message = errorMsg;
if (errorDetails) {
message += ": " + errorDetails;
}
showToast(message, 'error');
}
if (status === 'gdrive_auth_success') {
showToast("Google Drive authentication completed successfully", 'success');
}
// Handle modal hide buttons
const hideButtons = document.querySelectorAll('[data-modal-hide]');
hideButtons.forEach(button => {
button.addEventListener('click', function() {
const modalId = this.getAttribute('data-modal-hide');
const modal = document.getElementById(modalId);
modal.classList.add('hidden');
modal.classList.remove('flex');
});
});
});
// Track all HTMX events for debugging
document.addEventListener('htmx:beforeRequest', function(event) {
@@ -119,6 +211,18 @@ templ Configs(ctx context.Context, data ConfigsData) {
// This is definitely a delete request - store this information
window.isConfigDeleteRequest = true;
}
// Check if this is a duplication request
if (path && method === 'POST' && path.match(/^\/configs\/\d+\/duplicate$/)) {
window.isConfigDuplicateRequest = true;
// Store the config ID for reference
const configId = path.match(/^\/configs\/(\d+)\/duplicate$/)[1];
const configButton = document.querySelector(`button[hx-post="/configs/${configId}/duplicate"]`);
if (configButton) {
window.duplicatingConfigName = configButton.closest('li').querySelector('.text-blue-600').textContent.trim();
}
}
});
// Track HTMX after-request events for config deletion
@@ -151,13 +255,29 @@ templ Configs(ctx context.Context, data ConfigsData) {
configName = window.lastDeletedConfig.name;
}
window.notyf.success(`Configuration "${configName}" deleted successfully`);
showToast(`Configuration "${configName}" deleted successfully`, 'success');
// Clear flags
window.currentlyDeletingConfig = false;
window.isConfigDeleteRequest = false;
window.lastDeletedConfig = null;
}
// Check if this is a duplication request
const isDuplicateRequest = window.isConfigDuplicateRequest &&
event.detail.pathInfo &&
event.detail.pathInfo.requestPath &&
event.detail.pathInfo.requestPath.match(/^\/configs\/\d+\/duplicate$/);
// If this is a successful duplication request, show notification
if (isDuplicateRequest && event.detail.successful) {
const configName = window.duplicatingConfigName || "configuration";
showToast(`Configuration "${configName}" duplicated successfully`, 'success');
// Clear flags
window.isConfigDuplicateRequest = false;
window.duplicatingConfigName = null;
}
});
// Track HTMX error events for config deletion
@@ -194,68 +314,129 @@ templ Configs(ctx context.Context, data ConfigsData) {
errorMsg = `Error: ${error.error}`;
}
window.notyf.error(errorMsg);
showToast(errorMsg, 'error');
// Clear flags
window.currentlyDeletingConfig = false;
window.isConfigDeleteRequest = false;
window.lastDeletedConfig = null;
}
// Check if this is a duplication request
const isDuplicateRequest = window.isConfigDuplicateRequest &&
event.detail.pathInfo &&
event.detail.pathInfo.requestPath &&
event.detail.pathInfo.requestPath.match(/^\/configs\/\d+\/duplicate$/);
if (isDuplicateRequest) {
const configName = window.duplicatingConfigName || "configuration";
let errorMsg = `Failed to duplicate configuration "${configName}"`;
if (event.detail.xhr && event.detail.xhr.responseText) {
try {
const error = JSON.parse(event.detail.xhr.responseText);
errorMsg = `Error: ${error.error}`;
} catch (e) {
// If not JSON, use the response text directly
if (event.detail.xhr.responseText.trim()) {
errorMsg = event.detail.xhr.responseText;
}
}
}
showToast(errorMsg, 'error');
// Clear flags
window.isConfigDuplicateRequest = false;
window.duplicatingConfigName = null;
}
});
</script>
<div class="py-6">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center mb-8">
<h1 class="text-3xl font-bold text-secondary-900 dark:text-secondary-100">
<i class="fas fa-cogs mr-2 text-primary-600 dark:text-primary-400"></i>
<div id="configs-container" style="min-height: 100vh; background-color: rgb(249, 250, 251);" class="configs-page bg-gray-50 dark:bg-gray-900">
<div class="pb-8 w-full">
<div class="mb-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
<i class="fas fa-exchange-alt w-6 h-6 mr-2 text-blue-500 dark:text-blue-400"></i>
Transfer Configurations
</h1>
<a href="/configs/new" class="btn-primary">
<i class="fas fa-plus mr-2"></i>
<a href="/configs/new" class="flex items-center justify-center text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg px-5 py-2.5 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800">
<i class="fas fa-plus w-4 h-4 mr-2"></i>
New Configuration
</a>
</div>
<div class="mt-6">
if len(data.Configs) == 0 {
<div class="card p-12 flex flex-col items-center justify-center text-center">
<div class="inline-block p-4 rounded-full bg-secondary-100 dark:bg-secondary-700 mb-4">
<i class="fas fa-folder-open text-secondary-400 dark:text-secondary-500 text-3xl"></i>
</div>
<h3 class="mt-2 text-lg font-medium text-secondary-900 dark:text-secondary-100">No configurations</h3>
<p class="mt-1 text-sm text-secondary-500 dark:text-secondary-400">Get started by creating a new transfer configuration.</p>
<div class="mt-6">
<a href="/configs/new" class="btn-primary">
<i class="fas fa-plus mr-2"></i>
Create First Configuration
</a>
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 p-8 flex flex-col items-center justify-center text-center">
<div class="inline-flex h-16 w-16 flex-shrink-0 items-center justify-center rounded-full bg-gray-100 mb-4 dark:bg-gray-700">
<i class="fas fa-exchange-alt text-gray-400 dark:text-gray-500 text-3xl"></i>
</div>
<h3 class="mb-2 text-lg font-semibold text-gray-900 dark:text-white">No configurations</h3>
<p class="text-gray-500 dark:text-gray-400 mb-4">Get started by creating a new transfer configuration.</p>
<a href="/configs/new" class="inline-flex items-center px-3 py-2 text-sm font-medium text-center text-white bg-blue-700 rounded-lg hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800">
<i class="fas fa-plus w-4 h-4 mr-2"></i>
Create First Configuration
</a>
</div>
} else {
<div class="card overflow-hidden">
<ul role="list" class="divide-y divide-secondary-200 dark:divide-secondary-700">
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 overflow-hidden">
<ul class="divide-y divide-gray-200 dark:divide-gray-700">
for _, config := range data.Configs {
<li>
<div class="block hover:bg-secondary-50 dark:hover:bg-secondary-750 transition-colors">
<div class="block hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
<div class="px-4 py-4 sm:px-6">
<div class="flex items-center justify-between">
<div class="flex items-center">
<p class="text-sm font-medium text-primary-600 dark:text-primary-400 truncate">
<p class="text-sm font-medium text-blue-600 dark:text-blue-400 truncate">
{ config.Name }
</p>
<!-- Google Drive Authentication Badge -->
if (config.DestinationType == "drive" || config.SourceType == "drive" || config.SourceType == "gphotos" || config.DestinationType == "gphotos") && !config.GetGoogleAuthenticated() {
<span class="ml-2 bg-yellow-100 text-yellow-800 text-xs font-medium mr-2 px-2.5 py-0.5 rounded-full dark:bg-yellow-900 dark:text-yellow-300">
<i class="fas fa-exclamation-triangle w-3 h-3 mr-1 inline"></i>
Authentication Required
</span>
}
<!-- Google Drive Authentication Status Indicator -->
if (config.DestinationType == "drive" || config.SourceType == "drive" || config.SourceType == "gphotos" || config.DestinationType == "gphotos") && config.GetGoogleAuthenticated() {
<span class="ml-2 bg-green-100 text-green-800 text-xs font-medium mr-2 px-2.5 py-0.5 rounded-full dark:bg-green-900 dark:text-green-300">
<i class="fas fa-check-circle w-3 h-3 mr-1 inline"></i>
Authenticated
</span>
}
</div>
<div class="ml-2 flex-shrink-0 flex space-x-2">
<a href={ templ.SafeURL(fmt.Sprintf("/configs/%d", config.ID)) } class="btn-secondary btn-sm">
<i class="fas fa-edit mr-1"></i>
<!-- Google Drive Authentication Button -->
if (config.DestinationType == "drive" || config.SourceType == "drive" || config.SourceType == "gphotos" || config.DestinationType == "gphotos") && !config.GetGoogleAuthenticated() {
<a href={ templ.SafeURL(fmt.Sprintf("/configs/%d/gdrive-auth", config.ID)) } class="text-yellow-700 bg-yellow-100 hover:bg-yellow-200 focus:ring-4 focus:outline-none focus:ring-yellow-300 font-medium rounded-lg text-sm px-3 py-1.5 text-center inline-flex items-center dark:bg-yellow-900 dark:text-yellow-300 dark:hover:bg-yellow-800 dark:focus:ring-yellow-800">
<i class="fas fa-key w-3.5 h-3.5 mr-1.5"></i>
Authenticate
</a>
}
<a href={ templ.SafeURL(fmt.Sprintf("/configs/%d", config.ID)) } class="text-gray-700 bg-gray-100 hover:bg-gray-200 focus:ring-4 focus:outline-none focus:ring-gray-300 font-medium rounded-lg text-sm px-3 py-1.5 text-center inline-flex items-center dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600 dark:focus:ring-gray-700">
<i class="fas fa-edit w-3.5 h-3.5 mr-1.5"></i>
Edit
</a>
<!-- Duplicate config button -->
<button
type="button"
hx-post={ fmt.Sprintf("/configs/%d/duplicate", config.ID) }
hx-target="body"
class="text-indigo-700 bg-indigo-100 hover:bg-indigo-200 focus:ring-4 focus:outline-none focus:ring-indigo-300 font-medium rounded-lg text-sm px-3 py-1.5 text-center inline-flex items-center dark:bg-indigo-700 dark:text-indigo-300 dark:hover:bg-indigo-600 dark:focus:ring-indigo-800">
<i class="fas fa-clone w-3.5 h-3.5 mr-1.5"></i>
Duplicate
</button>
<!-- Add delete dialog for each configuration -->
@ConfigDialog(
fmt.Sprintf("delete-config-dialog-%d", config.ID),
"Delete Configuration",
fmt.Sprintf("Are you sure you want to delete the configuration '%s'? This cannot be undone.", config.Name),
"btn-danger",
"text-white bg-red-600 hover:bg-red-800 focus:ring-4 focus:outline-none focus:ring-red-300 dark:focus:ring-red-800 font-medium rounded-lg text-sm inline-flex items-center px-5 py-2.5 text-center",
"Delete",
"delete",
config.ID,
@@ -263,26 +444,26 @@ templ Configs(ctx context.Context, data ConfigsData) {
)
<button
type="button"
onclick={ showConfigDialog(fmt.Sprintf("delete-config-dialog-%d", config.ID)) }
class="btn-danger btn-sm">
<i class="fas fa-trash-alt mr-1"></i>
onclick={ showModal(fmt.Sprintf("delete-config-dialog-%d", config.ID)) }
class="text-white bg-red-700 hover:bg-red-800 focus:ring-4 focus:outline-none focus:ring-red-300 font-medium rounded-lg text-sm px-3 py-1.5 text-center inline-flex items-center dark:bg-red-600 dark:hover:bg-red-700 dark:focus:ring-red-800">
<i class="fas fa-trash-alt w-3.5 h-3.5 mr-1.5"></i>
Delete
</button>
</div>
</div>
<div class="mt-2 sm:flex sm:justify-between">
<div class="sm:flex">
<p class="flex items-center text-sm text-secondary-500 dark:text-secondary-400">
<i class="fas fa-upload flex-shrink-0 mr-1.5 h-5 w-5 text-secondary-400 dark:text-secondary-500"></i>
<div class="mt-3 sm:flex sm:justify-between">
<div class="sm:flex flex-col md:flex-row gap-2 md:gap-6">
<p class="flex items-center text-sm text-gray-500 dark:text-gray-400">
<i class="fas fa-upload w-4 h-4 mr-1.5 text-gray-400 dark:text-gray-500"></i>
Source: { config.SourceType }: { config.SourcePath }
</p>
<p class="mt-2 flex items-center text-sm text-secondary-500 dark:text-secondary-400 sm:mt-0 sm:ml-6">
<i class="fas fa-download flex-shrink-0 mr-1.5 h-5 w-5 text-secondary-400 dark:text-secondary-500"></i>
<p class="mt-2 md:mt-0 flex items-center text-sm text-gray-500 dark:text-gray-400">
<i class="fas fa-download w-4 h-4 mr-1.5 text-gray-400 dark:text-gray-500"></i>
Destination: { config.DestinationType }: { config.DestinationPath }
</p>
</div>
<div class="mt-2 flex items-center text-sm text-secondary-500 dark:text-secondary-400 sm:mt-0">
<i class="fas fa-calendar-alt flex-shrink-0 mr-1.5 h-5 w-5 text-secondary-400 dark:text-secondary-500"></i>
<div class="mt-2 md:mt-0 flex items-center text-sm text-gray-500 dark:text-gray-400">
<i class="far fa-clock w-4 h-4 mr-1.5 text-gray-400 dark:text-gray-500"></i>
<p>
Updated: { config.UpdatedAt.Format("2006-01-02 15:04:05") }
</p>
@@ -297,14 +478,56 @@ templ Configs(ctx context.Context, data ConfigsData) {
}
</div>
<!-- Help Notice -->
<div class="mt-8 text-center">
<p class="text-sm text-secondary-500 dark:text-secondary-400">
<i class="fas fa-info-circle mr-1 text-primary-500"></i>
Configurations define how files are transferred between systems
</p>
<!-- Help Section -->
<div class="bg-gray-50 dark:bg-gray-800 rounded-lg shadow-sm mt-8 p-4 border border-gray-200 dark:border-gray-700">
<div class="flex items-start mb-2">
<div class="flex items-center h-5">
<i class="fas fa-info-circle w-4 h-4 text-blue-500 dark:text-blue-400 mr-2"></i>
</div>
<div class="ml-2 text-sm">
<p class="text-gray-700 dark:text-gray-300">Configurations define how files are transferred between systems</p>
</div>
</div>
<div class="flex items-start mt-4">
<div class="flex items-center h-5">
<i class="fab fa-google-drive w-4 h-4 text-blue-500 dark:text-blue-400 mr-2"></i>
</div>
<div class="ml-2 text-sm">
<p class="text-gray-700 dark:text-gray-300">Google Drive and Google Photos configurations require authentication. Click the "Authenticate" button to complete setup.</p>
</div>
</div>
<div class="flex items-start mt-4">
<div class="flex items-center h-5">
<i class="fas fa-clone w-4 h-4 text-indigo-500 dark:text-indigo-400 mr-2"></i>
</div>
<div class="ml-2 text-sm">
<p class="text-gray-700 dark:text-gray-300">When duplicating configurations, you may need to re-enter sensitive credentials for security reasons. Make sure to edit duplicated configurations to add any required credentials.</p>
</div>
</div>
</div>
</div>
</div>
<script>
// Set dark background color if in dark mode
if (document.documentElement.classList.contains('dark')) {
document.getElementById('configs-container').style.backgroundColor = '#111827';
}
// Add event listener for theme changes
document.addEventListener('DOMContentLoaded', function() {
const themeToggle = document.getElementById('theme-toggle');
if (themeToggle) {
themeToggle.addEventListener('click', function() {
setTimeout(function() {
const isDark = document.documentElement.classList.contains('dark');
document.getElementById('configs-container').style.backgroundColor = isDark ? '#111827' : 'rgb(249, 250, 251)';
}, 50);
});
}
});
</script>
}
}
+271 -93
View File
@@ -4,7 +4,9 @@ import (
"context"
"fmt"
"github.com/starfleetcptn/gomft/internal/db"
"os/exec"
"strconv"
"strings"
)
type DashboardData struct {
@@ -12,19 +14,105 @@ type DashboardData struct {
ActiveTransfers int
CompletedToday int
FailedTransfers int
Configs map[uint]db.TransferConfig
RcloneVersion string
LatestVersion string
CurrentVersion string
}
// GetRcloneVersion executes the rclone --version command and returns the version string
func GetRcloneVersion() string {
cmd := exec.Command("rclone", "--version")
output, err := cmd.Output()
if err != nil {
return "Unknown"
}
// Parse the version from output (typically first line contains "rclone v1.XX.X")
outputStr := string(output)
lines := strings.Split(outputStr, "\n")
if len(lines) > 0 {
parts := strings.Split(lines[0], " ")
for _, part := range parts {
if strings.HasPrefix(part, "v") {
return part
}
}
}
return "Unknown"
}
// isNewerVersionAvailable checks if the latest version is newer than the current version
func isNewerVersionAvailable(current, latest string) bool {
// If either version is empty, we can't do a comparison
if current == "" || latest == "" {
return false
}
// Special case for DEV versions - always show update available
if current == "DEV" {
return true
}
// Strip 'v' prefix if present for comparison
if strings.HasPrefix(current, "v") {
current = current[1:]
}
if strings.HasPrefix(latest, "v") {
latest = latest[1:]
}
// Split versions into components
currentParts := strings.Split(current, ".")
latestParts := strings.Split(latest, ".")
// Handle non-semver format in either version
if len(currentParts) < 2 || len(latestParts) < 2 {
// If format doesn't match semver pattern, do string comparison
return current != latest && latest != ""
}
// Compare major, minor, patch versions
for i := 0; i < len(currentParts) && i < len(latestParts); i++ {
// Parse to integers
currentNum, err1 := strconv.Atoi(currentParts[i])
latestNum, err2 := strconv.Atoi(latestParts[i])
// If either can't be parsed, do string comparison
if err1 != nil || err2 != nil {
if currentParts[i] < latestParts[i] {
return true
} else if currentParts[i] > latestParts[i] {
return false
}
continue
}
// Compare numbers
if latestNum > currentNum {
return true
} else if latestNum < currentNum {
return false
}
}
// If all components are equal but latest has more components, it's newer
return len(latestParts) > len(currentParts)
}
templ Dashboard(ctx context.Context, data DashboardData) {
@LayoutWithContext("Dashboard", ctx) {
<div class="py-6">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex items-center justify-between mb-8">
<h1 class="text-3xl font-bold text-secondary-900 dark:text-secondary-100">
<i class="fas fa-tachometer-alt mr-2 text-primary-600 dark:text-primary-400"></i>
<div id="dashboard-container" style="min-height: 100vh;" class="bg-gray-50 dark:bg-gray-900">
<div class="pb-8 w-full">
<!-- Page Header -->
<div class="mb-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
<i class="fas fa-chart-pie w-6 h-6 mr-2 text-blue-500 dark:text-blue-400"></i>
Dashboard
</h1>
<div class="text-sm text-secondary-500 dark:text-secondary-400">
<span id="current-date" class="font-medium"></span>
<div class="bg-white dark:bg-gray-800 p-2 px-4 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
<span id="current-date" class="font-medium text-gray-700 dark:text-gray-300"></span>
<script>
document.getElementById('current-date').textContent = new Date().toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
</script>
@@ -32,154 +120,224 @@ templ Dashboard(ctx context.Context, data DashboardData) {
</div>
<!-- Stats Overview Cards -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<div class="card overflow-hidden">
<div class="p-6 flex items-center">
<div class="rounded-full bg-blue-100 dark:bg-blue-900 p-3 mr-4">
<i class="fas fa-exchange-alt text-blue-600 dark:text-blue-300 text-xl"></i>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 mb-6">
<!-- Active Transfers -->
<div class="p-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 sm:p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Active Transfers</h3>
<div class="inline-flex items-center justify-center w-10 h-10 text-blue-600 bg-blue-100 rounded-lg dark:text-blue-300 dark:bg-blue-900">
<i class="fas fa-exchange-alt w-6 h-6 flex items-center justify-center"></i>
</div>
<div>
<p class="text-sm font-medium text-secondary-500 dark:text-secondary-400">Active Transfers</p>
<p class="text-2xl font-bold text-secondary-900 dark:text-secondary-100">{ strconv.Itoa(data.ActiveTransfers) }</p>
</div>
<div class="flex items-center">
<div class="flex-shrink-0">
<span class="text-4xl font-bold leading-none text-gray-900 dark:text-white">{ strconv.Itoa(data.ActiveTransfers) }</span>
</div>
<div class="ml-3 flex items-baseline text-sm font-medium">
<span class="text-gray-500 dark:text-gray-400">Currently in progress</span>
</div>
</div>
</div>
<div class="card overflow-hidden">
<div class="p-6 flex items-center">
<div class="rounded-full bg-green-100 dark:bg-green-900 p-3 mr-4">
<i class="fas fa-check-circle text-green-600 dark:text-green-300 text-xl"></i>
<!-- Completed Today -->
<div class="p-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 sm:p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Completed Today</h3>
<div class="inline-flex items-center justify-center w-10 h-10 text-green-600 bg-green-100 rounded-lg dark:text-green-300 dark:bg-green-900">
<i class="fas fa-check-circle w-6 h-6 flex items-center justify-center"></i>
</div>
<div>
<p class="text-sm font-medium text-secondary-500 dark:text-secondary-400">Completed Today</p>
<p class="text-2xl font-bold text-secondary-900 dark:text-secondary-100">{ strconv.Itoa(data.CompletedToday) }</p>
</div>
<div class="flex items-center">
<div class="flex-shrink-0">
<span class="text-4xl font-bold leading-none text-gray-900 dark:text-white">{ strconv.Itoa(data.CompletedToday) }</span>
</div>
<div class="ml-3 flex items-baseline text-sm font-medium">
<span class="text-gray-500 dark:text-gray-400">Successfully completed</span>
</div>
</div>
</div>
<div class="card overflow-hidden">
<div class="p-6 flex items-center">
<div class="rounded-full bg-red-100 dark:bg-red-900 p-3 mr-4">
<i class="fas fa-exclamation-circle text-red-600 dark:text-red-300 text-xl"></i>
<!-- Failed Transfers -->
<div class="p-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 sm:p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Failed Transfers</h3>
<div class="inline-flex items-center justify-center w-10 h-10 text-red-600 bg-red-100 rounded-lg dark:text-red-300 dark:bg-red-900">
<i class="fas fa-exclamation-circle w-6 h-6 flex items-center justify-center"></i>
</div>
<div>
<p class="text-sm font-medium text-secondary-500 dark:text-secondary-400">Failed Transfers</p>
<p class="text-2xl font-bold text-secondary-900 dark:text-secondary-100">{ strconv.Itoa(data.FailedTransfers) }</p>
</div>
<div class="flex items-center">
<div class="flex-shrink-0">
<span class="text-4xl font-bold leading-none text-gray-900 dark:text-white">{ strconv.Itoa(data.FailedTransfers) }</span>
</div>
<div class="ml-3 flex items-baseline text-sm font-medium">
<span class="text-gray-500 dark:text-gray-400">Require attention</span>
</div>
</div>
</div>
</div>
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
<div class="card">
<div class="card-header">
<h3 class="text-lg font-medium text-secondary-900 dark:text-secondary-100">
<i class="fas fa-history mr-2 text-primary-500"></i>
<!-- Main Content Area -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-4">
<!-- Recent Jobs Card -->
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800">
<div class="flex items-center justify-between px-4 py-4 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white flex items-center">
<i class="fas fa-history w-6 h-6 mr-2 text-blue-500 dark:text-blue-400 flex-shrink-0"></i>
Recent Jobs
</h3>
</div>
<div class="card-body">
<div class="p-4">
if len(data.RecentJobs) == 0 {
<div class="py-8 text-center">
<div class="inline-block p-4 rounded-full bg-secondary-100 dark:bg-secondary-800 mb-4">
<i class="fas fa-inbox text-secondary-400 text-3xl"></i>
<!-- Empty state with Flowbite styling -->
<div class="flex flex-col items-center justify-center py-8 px-4 text-center">
<div class="inline-flex h-16 w-16 flex-shrink-0 items-center justify-center rounded-full bg-gray-100 mb-4 dark:bg-gray-700">
<i class="fas fa-clipboard-list text-gray-500 dark:text-gray-400 text-3xl"></i>
</div>
<p class="text-secondary-500 dark:text-secondary-400">No recent jobs found</p>
<a href="/jobs/new" class="mt-4 inline-flex items-center text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300">
<span>Create your first job</span>
<i class="fas fa-arrow-right ml-1"></i>
<h3 class="mb-2 text-lg font-semibold text-gray-900 dark:text-white">No recent jobs found</h3>
<p class="text-gray-500 dark:text-gray-400 mb-4">Get started by creating your first job.</p>
<a href="/jobs/new" class="inline-flex items-center px-3 py-2 text-sm font-medium text-center text-white bg-blue-700 rounded-lg hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800">
<i class="fas fa-plus w-4 h-4 mr-2"></i>
Create your first job
</a>
</div>
} else {
<div class="flow-root">
<ul role="list" class="-my-5 divide-y divide-secondary-200 dark:divide-secondary-700">
<ul class="divide-y divide-gray-200 dark:divide-gray-700">
for _, job := range data.RecentJobs {
<li class="py-4 hover:bg-secondary-50 dark:hover:bg-secondary-800 px-4 rounded-lg transition-colors">
<div class="flex items-center space-x-4">
<li class="py-3 sm:py-4 hover:bg-gray-50 dark:hover:bg-gray-700 px-3 rounded-lg transition-colors">
<div class="flex items-center">
<div class="flex-shrink-0">
if job.Status == "completed" {
<span class="h-10 w-10 rounded-full bg-green-100 flex items-center justify-center dark:bg-green-900">
<i class="fas fa-check text-green-600 dark:text-green-300"></i>
<span class="inline-flex items-center justify-center h-12 w-12 rounded-full text-green-600 bg-green-100 dark:bg-green-900">
<i class="fas fa-check-circle w-6 h-6 flex items-center justify-center"></i>
</span>
} else if job.Status == "failed" {
<span class="h-10 w-10 rounded-full bg-red-100 flex items-center justify-center dark:bg-red-900">
<i class="fas fa-times text-red-600 dark:text-red-300"></i>
<span class="inline-flex items-center justify-center h-12 w-12 rounded-full text-red-600 bg-red-100 dark:bg-red-900">
<i class="fas fa-exclamation-circle w-6 h-6 flex items-center justify-center"></i>
</span>
} else {
<span class="h-10 w-10 rounded-full bg-blue-100 flex items-center justify-center dark:bg-blue-900">
<i class="fas fa-sync-alt text-blue-600 dark:text-blue-300"></i>
<span class="inline-flex items-center justify-center h-12 w-12 rounded-full text-blue-600 bg-blue-100 dark:bg-blue-900">
<i class="fas fa-tasks w-6 h-6 flex items-center justify-center"></i>
</span>
}
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-secondary-900 truncate dark:text-secondary-100">
{ job.Job.Config.Name }
<div class="flex-1 min-w-0 ms-4">
<p class="text-sm font-medium text-gray-900 truncate dark:text-white">
{ getConfigNameForHistory(job, data.Configs) }
</p>
<div class="flex items-center mt-1">
<i class="fas fa-clock text-xs text-secondary-500 dark:text-secondary-400 mr-1"></i>
<p class="text-sm text-secondary-500 truncate dark:text-secondary-400">
<i class="fas fa-clock w-4 h-4 text-gray-500 dark:text-gray-400 mr-1"></i>
<p class="text-sm text-gray-500 truncate dark:text-gray-400">
Started: { job.StartTime.Format("Jan 02, 2006 15:04:05") }
</p>
</div>
</div>
<div>
<a href={ templ.SafeURL(fmt.Sprintf("/job-runs/%d", job.ID)) }
class="inline-flex items-center px-3 py-1.5 border border-secondary-300 text-sm leading-5 font-medium rounded-full text-secondary-700 bg-white hover:bg-secondary-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 dark:bg-secondary-800 dark:text-secondary-200 dark:border-secondary-600 dark:hover:bg-secondary-700">
<i class="fas fa-eye mr-1"></i>
View
</a>
</div>
<a href={ templ.SafeURL(fmt.Sprintf("/job-runs/%d", job.ID)) }
class="inline-flex items-center text-sm font-medium text-blue-600 hover:underline dark:text-blue-500">
View
<i class="fas fa-arrow-right w-3 h-3 ms-2"></i>
</a>
</div>
</li>
}
</ul>
</div>
<div class="mt-6">
<a href="/jobs" class="w-full flex justify-center items-center px-4 py-2 border border-secondary-300 shadow-sm text-sm font-medium rounded-lg text-secondary-700 bg-white hover:bg-secondary-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 dark:bg-secondary-800 dark:text-secondary-200 dark:border-secondary-600 dark:hover:bg-secondary-700">
<i class="fas fa-list-ul mr-2"></i>
<div class="pt-4 border-t border-gray-200 dark:border-gray-700 mt-4">
<a href="/jobs" class="inline-flex items-center justify-center w-full px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-lg hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700">
<i class="fas fa-list w-4 h-4 mr-2"></i>
View all jobs
</a>
</div>
}
</div>
</div>
<div class="card">
<div class="card-header">
<h3 class="text-lg font-medium text-secondary-900 dark:text-secondary-100">
<i class="fas fa-bolt mr-2 text-primary-500"></i>
<!-- Quick Actions Card -->
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800">
<div class="flex items-center justify-between px-4 py-4 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white flex items-center">
<i class="fas fa-bolt w-6 h-6 mr-2 text-blue-500 dark:text-blue-400 flex-shrink-0"></i>
Quick Actions
</h3>
</div>
<div class="card-body space-y-4">
<a href="/configs/new" class="btn-primary text-center flex items-center justify-center">
<i class="fas fa-plus-circle mr-2"></i>
<div class="p-4 space-y-4">
<a href="/configs/new" class="inline-flex items-center justify-center w-full text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg px-5 py-2.5 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800">
<i class="fas fa-plus w-5 h-5 mr-2"></i>
Create New Config
</a>
<a href="/jobs/new" class="btn-primary text-center flex items-center justify-center">
<i class="fas fa-play-circle mr-2"></i>
<a href="/jobs/new" class="inline-flex items-center justify-center w-full text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg px-5 py-2.5 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800">
<i class="fas fa-tasks w-5 h-5 mr-2"></i>
Create New Job
</a>
<a href="/history" class="btn-secondary text-center flex items-center justify-center">
<i class="fas fa-history mr-2"></i>
<a href="/history" class="inline-flex items-center justify-center w-full text-gray-900 bg-white border border-gray-300 focus:outline-none hover:bg-gray-100 focus:ring-4 focus:ring-gray-200 font-medium rounded-lg px-5 py-2.5 dark:bg-gray-800 dark:text-white dark:border-gray-600 dark:hover:bg-gray-700 dark:hover:border-gray-600 dark:focus:ring-gray-700">
<i class="fas fa-history w-5 h-5 mr-2"></i>
View Transfer History
</a>
<!-- System Status Card -->
<div class="mt-6 bg-secondary-50 dark:bg-secondary-800 rounded-lg p-4 border border-secondary-200 dark:border-secondary-700">
<h4 class="text-sm font-medium text-secondary-900 dark:text-secondary-100 mb-2">System Status</h4>
<div class="space-y-2">
<div class="flex items-center justify-between">
<span class="text-xs text-secondary-500 dark:text-secondary-400">Server</span>
<span class="badge badge-success">Online</span>
</div>
<div class="flex items-center justify-between">
<span class="text-xs text-secondary-500 dark:text-secondary-400">Scheduler</span>
<span class="badge badge-success">Running</span>
</div>
<div class="flex items-center justify-between">
<span class="text-xs text-secondary-500 dark:text-secondary-400">Database</span>
<span class="badge badge-success">Connected</span>
<!-- System Status Card with Flowbite design -->
<div class="bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 shadow-sm mt-6">
<div class="px-4 py-2 bg-gray-50 dark:bg-gray-700 rounded-t-lg border-b border-gray-200 dark:border-gray-600">
<h4 class="text-sm font-medium text-gray-900 dark:text-white">System Status</h4>
</div>
<div class="px-4 py-3">
<div class="space-y-3">
<div class="flex items-center justify-between">
<span class="text-sm text-gray-700 dark:text-gray-300">Server</span>
<span class="bg-green-100 text-green-800 text-xs font-medium inline-flex items-center px-2.5 py-0.5 rounded-full dark:bg-green-900 dark:text-green-300">
<span class="w-2 h-2 mr-1 bg-green-500 rounded-full"></span>
Online
</span>
</div>
<div class="flex items-center justify-between">
<span class="text-sm text-gray-700 dark:text-gray-300">Scheduler</span>
<span class="bg-green-100 text-green-800 text-xs font-medium inline-flex items-center px-2.5 py-0.5 rounded-full dark:bg-green-900 dark:text-green-300">
<span class="w-2 h-2 mr-1 bg-green-500 rounded-full"></span>
Running
</span>
</div>
<div class="flex items-center justify-between">
<span class="text-sm text-gray-700 dark:text-gray-300">Database</span>
<span class="bg-green-100 text-green-800 text-xs font-medium inline-flex items-center px-2.5 py-0.5 rounded-full dark:bg-green-900 dark:text-green-300">
<span class="w-2 h-2 mr-1 bg-green-500 rounded-full"></span>
Connected
</span>
</div>
<div class="flex items-center justify-between">
<span class="text-sm text-gray-700 dark:text-gray-300">Rclone</span>
<span class="bg-blue-100 text-blue-800 text-xs font-medium inline-flex items-center px-2.5 py-0.5 rounded-full dark:bg-blue-900 dark:text-blue-300">
<i class="fas fa-terminal w-3 h-3 mr-1"></i>
if data.RcloneVersion != "" {
{ data.RcloneVersion }
} else {
Unknown
}
</span>
</div>
<div class="flex items-center justify-between">
<span class="text-sm text-gray-700 dark:text-gray-300">Application Version</span>
if data.CurrentVersion != "" && data.LatestVersion != "" && isNewerVersionAvailable(data.CurrentVersion, data.LatestVersion) {
<div class="flex items-center">
<span class="bg-yellow-100 text-yellow-800 text-xs font-medium inline-flex items-center px-2.5 py-0.5 rounded-full dark:bg-yellow-900 dark:text-yellow-300 mr-2">
<i class="fas fa-exclamation-triangle w-3 h-3 mr-1"></i>
{ data.CurrentVersion }
</span>
<a href={ templ.SafeURL(fmt.Sprintf("https://github.com/starfleetcptn/gomft/releases/tag/%s", data.LatestVersion)) } target="_blank"
class="bg-green-100 text-green-800 text-xs font-medium inline-flex items-center px-2.5 py-0.5 rounded-full dark:bg-green-900 dark:text-green-300 hover:bg-green-200 dark:hover:bg-green-800">
<i class="fas fa-arrow-circle-up w-3 h-3 mr-1"></i>
{ data.LatestVersion } Available
</a>
</div>
} else {
<span class="bg-green-100 text-green-800 text-xs font-medium inline-flex items-center px-2.5 py-0.5 rounded-full dark:bg-green-900 dark:text-green-300">
<i class="fas fa-check-circle w-3 h-3 mr-1"></i>
if data.CurrentVersion != "" {
{ data.CurrentVersion }
} else {
Up to date
}
</span>
}
</div>
</div>
</div>
</div>
@@ -187,6 +345,26 @@ templ Dashboard(ctx context.Context, data DashboardData) {
</div>
</div>
</div>
<script>
// Set dark background color if in dark mode
if (document.documentElement.classList.contains('dark')) {
document.getElementById('dashboard-container').style.backgroundColor = '#111827';
}
// Add event listener for theme changes
document.addEventListener('DOMContentLoaded', function() {
const themeToggle = document.getElementById('theme-toggle');
if (themeToggle) {
themeToggle.addEventListener('click', function() {
setTimeout(function() {
const isDark = document.documentElement.classList.contains('dark');
document.getElementById('dashboard-container').style.backgroundColor = isDark ? '#111827' : 'rgb(249, 250, 251)';
}, 50);
});
}
});
</script>
</div>
}
}
+191
View File
@@ -0,0 +1,191 @@
package components
import (
"github.com/starfleetcptn/gomft/internal/db"
"time"
"fmt"
)
type NotificationsData struct {
Notifications []db.UserNotification
UnreadCount int64
// Pagination fields
CurrentPage int
TotalPages int
TotalCount int
PerPage int
}
// FormatNotificationTime formats a notification time in a user-friendly way
func FormatNotificationTime(t time.Time) string {
now := time.Now()
diff := now.Sub(t)
if diff < time.Minute {
return "just now"
} else if diff < time.Hour {
minutes := int(diff.Minutes())
if minutes == 1 {
return "1 minute ago"
}
return fmt.Sprintf("%d minutes ago", minutes)
} else if diff < 24*time.Hour {
hours := int(diff.Hours())
if hours == 1 {
return "1 hour ago"
}
return fmt.Sprintf("%d hours ago", hours)
} else if diff < 48*time.Hour {
return "yesterday"
} else {
days := int(diff.Hours() / 24)
if days < 7 {
return fmt.Sprintf("%d days ago", days)
} else {
return t.Format("Jan 2")
}
}
}
// GetNotificationIcon returns the appropriate icon class based on notification type
func GetNotificationIcon(notificationType db.NotificationType) string {
switch notificationType {
case db.NotificationJobStart:
return "fas fa-play text-blue-600 dark:text-blue-300"
case db.NotificationJobComplete:
return "fas fa-check-circle text-green-600 dark:text-green-300"
case db.NotificationJobFail:
return "fas fa-exclamation-circle text-red-600 dark:text-red-300"
case db.NotificationConfigUpdate:
return "fas fa-cog text-blue-600 dark:text-blue-300"
case db.NotificationSystemAlert:
return "fas fa-bell text-yellow-600 dark:text-yellow-300"
default:
return "fas fa-info-circle text-blue-600 dark:text-blue-300"
}
}
// GetNotificationBgColor returns the appropriate background color class based on notification type
func GetNotificationBgColor(notificationType db.NotificationType) string {
switch notificationType {
case db.NotificationJobStart:
return "bg-blue-100 dark:bg-blue-900"
case db.NotificationJobComplete:
return "bg-green-100 dark:bg-green-900"
case db.NotificationJobFail:
return "bg-red-100 dark:bg-red-900"
case db.NotificationConfigUpdate:
return "bg-blue-100 dark:bg-blue-900"
case db.NotificationSystemAlert:
return "bg-yellow-100 dark:bg-yellow-900"
default:
return "bg-blue-100 dark:bg-blue-900"
}
}
// GetNotificationBadgeColor returns the appropriate badge color class based on notification type
func GetNotificationBadgeColor(notificationType db.NotificationType) string {
switch notificationType {
case db.NotificationJobStart:
return "bg-primary-700"
case db.NotificationJobComplete:
return "bg-green-600"
case db.NotificationJobFail:
return "bg-red-600"
case db.NotificationConfigUpdate:
return "bg-blue-500"
case db.NotificationSystemAlert:
return "bg-yellow-500"
default:
return "bg-primary-700"
}
}
// GetNotificationBadgeIcon returns the appropriate badge icon class based on notification type
func GetNotificationBadgeIcon(notificationType db.NotificationType) string {
switch notificationType {
case db.NotificationJobStart:
return "fas fa-play"
case db.NotificationJobComplete:
return "fas fa-check"
case db.NotificationJobFail:
return "fas fa-times"
case db.NotificationConfigUpdate:
return "fas fa-wrench"
case db.NotificationSystemAlert:
return "fas fa-exclamation"
default:
return "fas fa-info"
}
}
templ NotificationDropdown(data NotificationsData) {
<div
class="block py-2 px-4 text-base font-medium text-center text-gray-700 bg-gray-50 dark:bg-gray-600 dark:text-gray-300">
Notifications
</div>
<div>
if len(data.Notifications) == 0 {
<div class="py-4 px-4 text-center text-gray-500 dark:text-gray-400">
<i class="fas fa-bell-slash text-2xl mb-2"></i>
<p>No notifications</p>
</div>
} else {
for _, notification := range data.Notifications {
<a
href={ templ.SafeURL(notification.Link) }
class={ "flex py-3 px-4 border-b hover:bg-gray-100 dark:hover:bg-gray-600 dark:border-gray-600", templ.KV("bg-blue-50 dark:bg-blue-900/20", !notification.IsRead) }>
<div class="flex-shrink-0">
<div class={ "w-11 h-11 rounded-full flex items-center justify-center", GetNotificationBgColor(notification.Type) }>
<i class={ GetNotificationIcon(notification.Type) }></i>
</div>
// <div class={ "flex absolute justify-center items-center ml-6 -mt-5 w-5 h-5 rounded-full border border-white dark:border-gray-700", GetNotificationBadgeColor(notification.Type) }>
// <i class={ "text-white text-xs", GetNotificationBadgeIcon(notification.Type) }></i>
// </div>
</div>
<div class="pl-3 w-full">
<div class="text-gray-500 font-normal text-sm mb-1.5 dark:text-gray-400">
<span class="font-semibold text-gray-900 dark:text-white">{ notification.Title }:</span> { notification.Message }
</div>
<div class="text-xs font-medium text-primary-600 dark:text-primary-500">
{ FormatNotificationTime(notification.CreatedAt) }
</div>
</div>
</a>
}
}
</div>
<div class="flex">
<a href="/notifications" class="block w-1/2 py-2 text-md font-medium text-center text-gray-900 bg-gray-50 hover:bg-gray-100 dark:bg-gray-600 dark:text-white dark:hover:bg-gray-500">
<div class="inline-flex items-center">
<i class="far fa-eye mr-2"></i>
View all
</div>
</a>
<a href="/notifications/mark-all-read"
hx-post="/notifications/mark-all-read"
hx-swap="none"
hx-target="#notification-count"
class="block w-1/2 py-2 text-md font-medium text-center text-gray-900 bg-gray-50 hover:bg-gray-100 dark:bg-gray-600 dark:text-white dark:hover:bg-gray-500 border-l border-gray-200 dark:border-gray-700">
<div class="inline-flex items-center">
<i class="fas fa-check-double mr-2"></i>
Mark all read
</div>
</a>
</div>
}
templ NotificationCount(count int64) {
if count > 0 {
<div class="absolute inline-flex items-center justify-center w-5 h-5 text-xs font-bold text-white bg-red-500 border border-white dark:border-gray-800 rounded-full -translate-y-1/2 translate-x-1/2" style="top: 0; right: 0;" id="notification-count">
if count > 99 {
99+
} else {
{ fmt.Sprintf("%d", count) }
}
</div>
} else {
<!-- Empty element to ensure proper swap when count is zero -->
<span class="hidden"></span>
}
}
@@ -0,0 +1,315 @@
package components
import (
"context"
"fmt"
"strconv"
)
// Note: To implement pagination, update the NotificationsData struct in notifications.templ
// to include these fields:
// CurrentPage int
// TotalPages int
// TotalCount int
// PerPage int
templ NotificationsPage(ctx context.Context, data NotificationsData) {
@LayoutWithContext("Notifications", ctx) {
<div class="py-6 px-4 mx-auto max-w-7xl lg:px-8">
<!-- Page Header -->
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between mb-8">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
<i class="fas fa-bell w-6 h-6 mr-2 text-blue-500 dark:text-blue-400"></i>
Notifications
</h1>
<div class="text-sm text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-gray-800 rounded-lg px-4 py-2 inline-flex items-center">
<i class="fas fa-info-circle mr-2"></i>
<span class="font-medium">Total: { fmt.Sprint(data.TotalCount) } notifications</span>
</div>
</div>
<!-- Controls and Filters -->
<div class="mb-6 p-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:bg-gray-800 dark:border-gray-700">
<div class="flex flex-col sm:flex-row sm:items-center gap-4">
<div class="ml-auto">
<label class="inline-flex items-center text-sm text-gray-700 dark:text-gray-300">
Show
<select
id="perPage"
name="perPage"
class="mx-2 bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
hx-get={ fmt.Sprintf("/notifications?page=1&perPage=%s", "${this.value}") }
hx-target="body"
hx-swap="innerHTML"
hx-trigger="change"
>
<option value="10" selected?={ data.PerPage == 10 }>10</option>
<option value="25" selected?={ data.PerPage == 25 }>25</option>
<option value="50" selected?={ data.PerPage == 50 }>50</option>
<option value="100" selected?={ data.PerPage == 100 }>100</option>
</select>
entries
</label>
</div>
<a
href="/notifications/mark-all-read"
hx-post="/notifications/mark-all-read"
hx-swap="none"
hx-target="#notification-bell"
class="inline-flex items-center py-2.5 px-4 text-sm font-medium text-white bg-blue-700 rounded-lg border border-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800"
>
<i class="fas fa-check-double w-4 h-4 mr-2"></i>
Mark all as read
</a>
</div>
</div>
<!-- Main Content -->
if len(data.Notifications) == 0 {
<div class="p-8 text-center bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700">
<div class="flex justify-center mb-4">
<span class="inline-flex items-center justify-center w-16 h-16 rounded-full bg-gray-100 dark:bg-gray-700">
<i class="fas fa-bell-slash text-gray-400 dark:text-gray-500 text-3xl"></i>
</span>
</div>
<h3 class="text-lg font-medium text-gray-900 dark:text-white">No notifications</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">You don't have any notifications yet.</p>
</div>
} else {
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 overflow-hidden">
<ul class="divide-y divide-gray-200 dark:divide-gray-700">
for _, notification := range data.Notifications {
<li id={ "notification-" + fmt.Sprintf("%d", notification.ID) }
class={ templ.KV("bg-blue-50 dark:bg-blue-900/20", !notification.IsRead) }>
<div class="block transition-colors px-4 py-4 sm:px-6">
<div class="flex items-start">
<div class="flex-shrink-0 mr-4">
<div class={ "w-12 h-12 rounded-full flex items-center justify-center", GetNotificationBgColor(notification.Type) }>
<i class={ GetNotificationIcon(notification.Type) }></i>
</div>
</div>
<div class="flex-1 min-w-0">
<div class="flex justify-between items-start">
<div>
<h3 class="text-base font-medium text-gray-900 dark:text-white">{ notification.Title }</h3>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">{ notification.Message }</p>
</div>
<div class="flex items-center text-xs text-gray-500 dark:text-gray-400">
<span>{ FormatNotificationTime(notification.CreatedAt) }</span>
if !notification.IsRead {
<button
hx-post={ "/notifications/" + fmt.Sprintf("%d", notification.ID) + "/read" }
hx-target={ "#notification-" + fmt.Sprintf("%d", notification.ID) }
hx-swap="outerHTML"
class="ml-4 text-blue-700 bg-blue-100 hover:bg-blue-200 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-3 py-1.5 text-center inline-flex items-center dark:text-blue-400 dark:bg-blue-900/30 dark:hover:bg-blue-900/40 dark:focus:ring-blue-800"
>
<i class="fas fa-check w-3.5 h-3.5 mr-1.5"></i>
Mark as read
</button>
}
</div>
</div>
<div class="mt-2">
<a
href={ templ.SafeURL(notification.Link) }
class="text-blue-700 bg-blue-100 hover:bg-blue-200 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-3 py-1.5 text-center inline-flex items-center dark:text-blue-400 dark:bg-blue-900/30 dark:hover:bg-blue-900/40 dark:focus:ring-blue-800">
<i class="fas fa-eye w-3.5 h-3.5 mr-1.5"></i>
View details
</a>
</div>
</div>
</div>
</div>
</li>
}
</ul>
<!-- Pagination -->
if data.TotalPages > 1 {
<div class="mt-6 flex flex-col sm:flex-row justify-between items-center py-4 px-4 border-t border-gray-200 dark:border-gray-700">
<!-- Mobile pagination -->
<div class="flex-1 flex justify-between gap-4 sm:hidden w-full mb-4 sm:mb-0">
if data.CurrentPage > 1 {
<a
href={ templ.SafeURL("/notifications?page=" + strconv.Itoa(data.CurrentPage-1) + "&perPage=" + strconv.Itoa(data.PerPage)) }
hx-get={ "/notifications?page=" + strconv.Itoa(data.CurrentPage-1) + "&perPage=" + strconv.Itoa(data.PerPage) }
hx-target="body"
hx-swap="innerHTML"
class="w-full inline-flex justify-center items-center px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-100 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400 dark:hover:bg-gray-700"
>
<i class="fas fa-chevron-left mr-1"></i> Previous
</a>
} else {
<span class="w-full inline-flex justify-center items-center px-4 py-2 text-sm font-medium text-gray-400 bg-gray-100 border border-gray-300 rounded-lg dark:bg-gray-800 dark:border-gray-600 dark:text-gray-600 cursor-not-allowed">
<i class="fas fa-chevron-left mr-1"></i> Previous
</span>
}
if data.CurrentPage < data.TotalPages {
<a
href={ templ.SafeURL("/notifications?page=" + strconv.Itoa(data.CurrentPage+1) + "&perPage=" + strconv.Itoa(data.PerPage)) }
hx-get={ "/notifications?page=" + strconv.Itoa(data.CurrentPage+1) + "&perPage=" + strconv.Itoa(data.PerPage) }
hx-target="body"
hx-swap="innerHTML"
class="w-full inline-flex justify-center items-center px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-100 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400 dark:hover:bg-gray-700"
>
Next <i class="fas fa-chevron-right ml-1"></i>
</a>
} else {
<span class="w-full inline-flex justify-center items-center px-4 py-2 text-sm font-medium text-gray-400 bg-gray-100 border border-gray-300 rounded-lg dark:bg-gray-800 dark:border-gray-600 dark:text-gray-600 cursor-not-allowed">
Next <i class="fas fa-chevron-right ml-1"></i>
</span>
}
</div>
<!-- Desktop pagination -->
<div class="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
<div>
<p class="text-sm text-gray-700 dark:text-gray-300">
Showing
<span class="font-medium">{ fmt.Sprint((data.CurrentPage-1)*data.PerPage + 1) }</span>
to
<span class="font-medium">{ fmt.Sprint(minInt((data.CurrentPage)*data.PerPage, data.TotalCount)) }</span>
of
<span class="font-medium">{ fmt.Sprint(data.TotalCount) }</span>
notifications
</p>
</div>
<div>
<nav class="isolate inline-flex -space-x-px rounded-md shadow-sm" aria-label="Pagination">
<!-- First page -->
<a
href={ templ.SafeURL("/notifications?page=1&perPage=" + strconv.Itoa(data.PerPage)) }
hx-get={ "/notifications?page=1&perPage=" + strconv.Itoa(data.PerPage) }
hx-target="body"
hx-swap="innerHTML"
class={ "relative inline-flex items-center justify-center rounded-l-lg border border-gray-300 bg-white px-3 py-2 text-sm font-medium text-gray-500 hover:bg-gray-50 focus:z-20 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400 dark:hover:bg-gray-700", templ.KV("opacity-50 pointer-events-none", data.CurrentPage == 1) }
>
<span class="sr-only">First</span>
<i class="fas fa-angle-double-left text-xs"></i>
</a>
<!-- Previous page -->
<a
href={ templ.SafeURL("/notifications?page=" + strconv.Itoa(maxInt(1, data.CurrentPage-1)) + "&perPage=" + strconv.Itoa(data.PerPage)) }
hx-get={ "/notifications?page=" + strconv.Itoa(maxInt(1, data.CurrentPage-1)) + "&perPage=" + strconv.Itoa(data.PerPage) }
hx-target="body"
hx-swap="innerHTML"
class={ "relative inline-flex items-center justify-center border border-gray-300 bg-white px-3 py-2 text-sm font-medium text-gray-500 hover:bg-gray-50 focus:z-20 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400 dark:hover:bg-gray-700", templ.KV("opacity-50 pointer-events-none", data.CurrentPage == 1) }
>
<span class="sr-only">Previous</span>
<i class="fas fa-angle-left text-xs"></i>
</a>
<!-- Page numbers -->
@notificationPageNumbers(data.CurrentPage, data.TotalPages, data.PerPage)
<!-- Next page -->
<a
href={ templ.SafeURL("/notifications?page=" + strconv.Itoa(minInt(data.TotalPages, data.CurrentPage+1)) + "&perPage=" + strconv.Itoa(data.PerPage)) }
hx-get={ "/notifications?page=" + strconv.Itoa(minInt(data.TotalPages, data.CurrentPage+1)) + "&perPage=" + strconv.Itoa(data.PerPage) }
hx-target="body"
hx-swap="innerHTML"
class={ "relative inline-flex items-center justify-center border border-gray-300 bg-white px-3 py-2 text-sm font-medium text-gray-500 hover:bg-gray-50 focus:z-20 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400 dark:hover:bg-gray-700", templ.KV("opacity-50 pointer-events-none", data.CurrentPage == data.TotalPages) }
>
<span class="sr-only">Next</span>
<i class="fas fa-angle-right text-xs"></i>
</a>
<!-- Last page -->
<a
href={ templ.SafeURL("/notifications?page=" + strconv.Itoa(data.TotalPages) + "&perPage=" + strconv.Itoa(data.PerPage)) }
hx-get={ "/notifications?page=" + strconv.Itoa(data.TotalPages) + "&perPage=" + strconv.Itoa(data.PerPage) }
hx-target="body"
hx-swap="innerHTML"
class={ "relative inline-flex items-center justify-center rounded-r-lg border border-gray-300 bg-white px-3 py-2 text-sm font-medium text-gray-500 hover:bg-gray-50 focus:z-20 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400 dark:hover:bg-gray-700", templ.KV("opacity-50 pointer-events-none", data.CurrentPage == data.TotalPages) }
>
<span class="sr-only">Last</span>
<i class="fas fa-angle-double-right text-xs"></i>
</a>
</nav>
</div>
</div>
</div>
}
</div>
}
<!-- Help Section -->
<div class="bg-gray-50 dark:bg-gray-800 rounded-lg shadow-sm mt-8 p-4 border border-gray-200 dark:border-gray-700">
<div class="flex items-start mb-2">
<div class="flex items-center h-5">
<i class="fas fa-info-circle w-4 h-4 text-blue-500 dark:text-blue-400 mr-2"></i>
</div>
<div class="ml-2 text-sm">
<p class="text-gray-700 dark:text-gray-300">Notifications keep you updated on important system events and alerts.</p>
</div>
</div>
<div class="flex items-start mt-4">
<div class="flex items-center h-5">
<i class="fas fa-bell w-4 h-4 text-blue-500 dark:text-blue-400 mr-2"></i>
</div>
<div class="ml-2 text-sm">
<p class="text-gray-700 dark:text-gray-300">Unread notifications are highlighted in blue. Mark them as read to clear the highlight.</p>
</div>
</div>
<div class="flex items-start mt-4">
<div class="flex items-center h-5">
<i class="fas fa-list-ol w-4 h-4 text-blue-500 dark:text-blue-400 mr-2"></i>
</div>
<div class="ml-2 text-sm">
<p class="text-gray-700 dark:text-gray-300">Adjust how many notifications to display per page using the dropdown menu.</p>
</div>
</div>
</div>
</div>
}
}
// Helper function for pagination math
func minInt(a, b int) int {
if a < b {
return a
}
return b
}
// Helper function for pagination math
func maxInt(a, b int) int {
if a > b {
return a
}
return b
}
// Add the pageNumbers template for consistent pagination layout
templ notificationPageNumbers(currentPage int, totalPages int, perPage int) {
// Show at most 5 page numbers with the current page in the middle when possible
for i := 1; i <= totalPages; i++ {
if i == currentPage {
<span aria-current="page" class="relative z-10 inline-flex items-center bg-blue-600 px-4 py-2 text-sm font-medium text-white focus:z-20 border border-blue-600 dark:bg-blue-600">
{ fmt.Sprint(i) }
</span>
} else if i == 1 || i == totalPages || (i >= currentPage-2 && i <= currentPage+2) {
<a
href={ templ.SafeURL("/notifications?page=" + strconv.Itoa(i) + "&perPage=" + strconv.Itoa(perPage)) }
hx-get={ "/notifications?page=" + strconv.Itoa(i) + "&perPage=" + strconv.Itoa(perPage) }
hx-target="body"
hx-swap="innerHTML"
hx-indicator={ fmt.Sprintf("#page-indicator-%d", i) }
class="relative inline-flex items-center px-4 py-2 text-sm font-medium text-gray-500 hover:bg-gray-50 focus:z-20 border border-gray-300 bg-white dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400 dark:hover:bg-gray-700 relative"
>
{ fmt.Sprint(i) }
<span id={ fmt.Sprintf("page-indicator-%d", i) } class="htmx-indicator absolute top-0 right-0 w-4 h-4 -mt-1 -mr-1">
<i class="fas fa-spinner fa-spin text-xs"></i>
</span>
</a>
} else if i == currentPage-3 || i == currentPage+3 {
<span class="relative inline-flex items-center px-4 py-2 text-sm font-medium text-gray-700 border border-gray-300 bg-white dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400">
...
</span>
}
}
}
+91
View File
@@ -0,0 +1,91 @@
package components
import (
"context"
"fmt"
)
// ErrorPage renders a generic error page with customizable title, message, and details
templ ErrorPage(ctx context.Context, code int, title, message, details string) {
@LayoutWithContext(fmt.Sprintf("Error %d - %s", code, title), ctx) {
<div class="min-h-screen flex flex-col items-center justify-center px-4 sm:px-6 lg:px-8">
<div class="w-full max-w-lg bg-white dark:bg-gray-800 shadow-lg rounded-lg overflow-hidden">
<div class="px-6 py-8">
<div class="text-center">
if code == 404 {
<div class="mx-auto flex items-center justify-center h-24 w-24 rounded-full bg-blue-100 dark:bg-blue-900">
<svg class="h-14 w-14 text-blue-600 dark:text-blue-300" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
</div>
} else if code == 401 {
<div class="mx-auto flex items-center justify-center h-24 w-24 rounded-full bg-yellow-100 dark:bg-yellow-900">
<svg class="h-14 w-14 text-yellow-600 dark:text-yellow-300" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path>
</svg>
</div>
} else if code == 500 {
<div class="mx-auto flex items-center justify-center h-24 w-24 rounded-full bg-red-100 dark:bg-red-900">
<svg class="h-14 w-14 text-red-600 dark:text-red-300" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>
</svg>
</div>
} else {
<div class="mx-auto flex items-center justify-center h-24 w-24 rounded-full bg-gray-100 dark:bg-gray-700">
<svg class="h-14 w-14 text-gray-600 dark:text-gray-300" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
</div>
}
<h1 class="mt-6 text-3xl font-extrabold text-gray-900 dark:text-white">
{ fmt.Sprintf("%d - %s", code, title) }
</h1>
<p class="mt-3 text-lg text-gray-500 dark:text-gray-400">
{ message }
</p>
if details != "" {
<div class="mt-4 p-4 rounded-md bg-gray-50 dark:bg-gray-700 overflow-auto">
<p class="text-sm text-gray-600 dark:text-gray-300 font-mono">
{ details }
</p>
</div>
}
</div>
</div>
<div class="px-6 py-4 bg-gray-50 dark:bg-gray-700 border-t border-gray-200 dark:border-gray-600 flex justify-between">
<a href="/dashboard" class="text-sm font-medium text-blue-600 dark:text-blue-400 hover:text-blue-500 dark:hover:text-blue-300">
Go to Dashboard
</a>
<a href="/" class="text-sm font-medium text-blue-600 dark:text-blue-400 hover:text-blue-500 dark:hover:text-blue-300">
Go to Home
</a>
</div>
</div>
</div>
}
}
// NotFoundError renders a 404 Not Found error page
templ NotFoundError(ctx context.Context) {
@ErrorPage(ctx, 404, "Page Not Found", "The page you're looking for doesn't exist or has been moved.", "")
}
// UnauthorizedError renders a 401 Unauthorized error page
templ UnauthorizedError(ctx context.Context) {
@ErrorPage(ctx, 401, "Unauthorized", "You don't have permission to access this page.", "Please log in or contact your administrator if you believe this is a mistake.")
}
// ServerError renders a 500 Internal Server Error page
templ ServerError(ctx context.Context, details string) {
@ErrorPage(ctx, 500, "Server Error", "Something went wrong on our end.", details)
}
// GenericError renders a generic error page
templ GenericError(ctx context.Context, title, message, details string) {
@ErrorPage(ctx, 0, title, message, details)
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,256 @@
package details
import (
"context"
"fmt"
"strconv"
"github.com/starfleetcptn/gomft/components"
"github.com/starfleetcptn/gomft/components/file_metadata"
"github.com/starfleetcptn/gomft/components/file_metadata/dialog" // Import dialog package
"github.com/starfleetcptn/gomft/components/file_metadata/utils" // Import utils package
)
// FileMetadataDetails renders the details view for a file metadata, matching original structure
templ FileMetadataDetails(ctx context.Context, data file_metadata.FileMetadataDetailsData) {
@components.LayoutWithContext("File Details", ctx) {
<!-- Status and Error Messages -->
<div id="toast-container" class="fixed top-5 right-5 z-50 flex flex-col gap-2"></div>
@utils.FileMetadataJS() // Include JS for toasts, etc.
<div id="file-details-container" style="min-height: 100vh;" class="bg-gray-50 dark:bg-gray-900">
<div class="pb-8 w-full">
<div class="mb-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
<i class="fas fa-file-alt w-6 h-6 mr-2 text-blue-500"></i>
File Details: { data.File.FileName }
</h1>
<a href="/files" class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800">
<i class="fas fa-arrow-left mr-2"></i> Back to Files
</a>
</div>
<div class="bg-white border border-gray-200 rounded-lg shadow dark:bg-gray-800 dark:border-gray-700 w-full">
<!-- Card header -->
<div class="p-4 md:p-5 border-b border-gray-200 dark:border-gray-700">
<h5 class="text-xl font-bold leading-none text-gray-900 dark:text-white">
{ data.File.FileName }
</h5>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
File ID: { strconv.FormatUint(uint64(data.File.ID), 10) }
</p>
</div>
<!-- Card content -->
<div class="p-4 md:p-5">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- File Information -->
<div>
<h6 class="text-lg font-semibold mb-4 text-gray-900 dark:text-white flex items-center">
<i class="fas fa-file-alt mr-2 text-gray-500 dark:text-gray-400"></i> File Information
</h6>
<div class="overflow-x-auto relative shadow-md sm:rounded-lg">
<table class="w-full text-sm text-left text-gray-500 dark:text-gray-400">
<tbody>
<tr class="border-b dark:border-gray-700">
<th scope="row" class="py-3 px-4 font-medium text-gray-900 whitespace-nowrap dark:text-white bg-gray-50 dark:bg-gray-800">
Filename
</th>
<td class="py-3 px-4 bg-white dark:bg-gray-800">
{ data.File.FileName }
</td>
</tr>
<tr class="border-b dark:border-gray-700">
<th scope="row" class="py-3 px-4 font-medium text-gray-900 whitespace-nowrap dark:text-white bg-gray-50 dark:bg-gray-800">
Size
</th>
<td class="py-3 px-4 bg-white dark:bg-gray-800">
{ utils.FormatFileSize(data.File.FileSize) }
</td>
</tr>
<tr class="border-b dark:border-gray-700">
<th scope="row" class="py-3 px-4 font-medium text-gray-900 whitespace-nowrap dark:text-white bg-gray-50 dark:bg-gray-800">
Hash
</th>
<td class="py-3 px-4 break-all bg-white dark:bg-gray-800">
if data.File.FileHash != "" {
<span class="font-mono">{ data.File.FileHash }</span>
} else {
<span class="text-gray-400 dark:text-gray-500 italic">Not available</span>
}
</td>
</tr>
<tr class="border-b dark:border-gray-700">
<th scope="row" class="py-3 px-4 font-medium text-gray-900 whitespace-nowrap dark:text-white bg-gray-50 dark:bg-gray-800">
Status
</th>
<td class="py-3 px-4 bg-white dark:bg-gray-800">
<span class={ "text-xs font-medium px-2.5 py-0.5 rounded", utils.GetStatusBadgeClass(data.File.Status) }>
{ data.File.Status }
</span>
</td>
</tr>
<tr class="border-b dark:border-gray-700">
<th scope="row" class="py-3 px-4 font-medium text-gray-900 whitespace-nowrap dark:text-white bg-gray-50 dark:bg-gray-800">
Original Path
</th>
<td class="py-3 px-4 break-all bg-white dark:bg-gray-800">
<div class="flex items-center">
<i class="fas fa-folder mr-2 text-yellow-500"></i>
<span>{ data.File.OriginalPath }</span>
</div>
</td>
</tr>
<tr class="border-b dark:border-gray-700">
<th scope="row" class="py-3 px-4 font-medium text-gray-900 whitespace-nowrap dark:text-white bg-gray-50 dark:bg-gray-800">
Destination Path
</th>
<td class="py-3 px-4 break-all bg-white dark:bg-gray-800">
<div class="flex items-center">
<i class="fas fa-folder-open mr-2 text-blue-500"></i>
<span>{ data.File.DestinationPath }</span>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Processing Information -->
<div>
<h6 class="text-lg font-semibold mb-4 text-gray-900 dark:text-white flex items-center">
<i class="fas fa-cogs mr-2 text-gray-500 dark:text-gray-400"></i> Processing Information
</h6>
<div class="overflow-x-auto relative shadow-md sm:rounded-lg">
<table class="w-full text-sm text-left text-gray-500 dark:text-gray-400">
<tbody>
<tr class="border-b dark:border-gray-700">
<th scope="row" class="py-3 px-4 font-medium text-gray-900 whitespace-nowrap dark:text-white bg-gray-50 dark:bg-gray-800">
Job
</th>
<td class="py-3 px-4 bg-white dark:bg-gray-800">
<a href={ templ.SafeURL(fmt.Sprintf("/files/job/%d", data.File.JobID)) } class="font-medium text-blue-600 dark:text-blue-500 hover:underline">
{ data.File.Job.Name }
</a>
</td>
</tr>
<tr class="border-b dark:border-gray-700">
<th scope="row" class="py-3 px-4 font-medium text-gray-900 whitespace-nowrap dark:text-white bg-gray-50 dark:bg-gray-800">
Processed Time
</th>
<td class="py-3 px-4 bg-white dark:bg-gray-800">
<div class="flex items-center">
<i class="far fa-clock mr-2 text-gray-500"></i>
<span>{ data.File.ProcessedTime.Format("2006-01-02 15:04:05") }</span>
</div>
</td>
</tr>
<tr class="border-b dark:border-gray-700">
<th scope="row" class="py-3 px-4 font-medium text-gray-900 whitespace-nowrap dark:text-white bg-gray-50 dark:bg-gray-800">
Creation Time
</th>
<td class="py-3 px-4 bg-white dark:bg-gray-800">
<div class="flex items-center">
<i class="fas fa-calendar-plus mr-2 text-green-500"></i>
<span>{ data.File.CreationTime.Format("2006-01-02 15:04:05") }</span>
</div>
</td>
</tr>
<tr class="border-b dark:border-gray-700">
<th scope="row" class="py-3 px-4 font-medium text-gray-900 whitespace-nowrap dark:text-white bg-gray-50 dark:bg-gray-800">
Modification Time
</th>
<td class="py-3 px-4 bg-white dark:bg-gray-800">
<div class="flex items-center">
<i class="fas fa-calendar-alt mr-2 text-purple-500"></i>
<span>{ data.File.ModTime.Format("2006-01-02 15:04:05") }</span>
</div>
</td>
</tr>
if data.File.Status == "error" && data.File.ErrorMessage != "" {
<tr class="border-b dark:border-gray-700">
<th scope="row" class="py-3 px-4 font-medium text-gray-900 whitespace-nowrap dark:text-white bg-gray-50 dark:bg-gray-800">
Error
</th>
<td class="py-3 px-4 break-all bg-white dark:bg-gray-800">
<div class="flex items-start">
<i class="fas fa-exclamation-triangle mt-1 mr-2 text-red-500"></i>
<span class="text-red-600 dark:text-red-400">{ data.File.ErrorMessage }</span>
</div>
</td>
</tr>
}
<tr class="border-b dark:border-gray-700">
<th scope="row" class="py-3 px-4 font-medium text-gray-900 whitespace-nowrap dark:text-white bg-gray-50 dark:bg-gray-800">
Record Created
</th>
<td class="py-3 px-4 bg-white dark:bg-gray-800">
{ data.File.CreatedAt.Format("2006-01-02 15:04:05") }
</td>
</tr>
<tr class="border-b dark:border-gray-700">
<th scope="row" class="py-3 px-4 font-medium text-gray-900 whitespace-nowrap dark:text-white bg-gray-50 dark:bg-gray-800">
Record Updated
</th>
<td class="py-3 px-4 bg-white dark:bg-gray-800">
{ data.File.UpdatedAt.Format("2006-01-02 15:04:05") }
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Delete dialog component call -->
@dialog.FileMetadataDialog(
fmt.Sprintf("delete-file-dialog-%d", data.File.ID),
"Delete File Metadata",
fmt.Sprintf("Are you sure you want to delete the metadata for '%s'? This cannot be undone.", data.File.FileName),
"text-white bg-red-700 hover:bg-red-800 focus:ring-4 focus:ring-red-300 font-medium rounded-lg text-sm px-5 py-2.5 dark:bg-red-600 dark:hover:bg-red-700 focus:outline-none dark:focus:ring-red-800", // Use correct classes
"Delete",
"delete",
data.File.ID,
data.File.FileName,
"details", // Indicate this is from the details view
)
<!-- Action buttons -->
<div class="mt-6 flex flex-wrap justify-end gap-3">
<a href="/files" class="py-2.5 px-5 text-sm font-medium text-gray-900 focus:outline-none bg-white rounded-lg border border-gray-200 hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-4 focus:ring-gray-100 dark:focus:ring-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700">
<i class="fas fa-list mr-2"></i> Back to Files
</a>
<button
type="button"
onclick={ templ.ComponentScript{Call: fmt.Sprintf("showModal('delete-file-dialog-%d')", data.File.ID)} }
class="text-white bg-red-700 hover:bg-red-800 focus:ring-4 focus:ring-red-300 font-medium rounded-lg text-sm px-5 py-2.5 dark:bg-red-600 dark:hover:bg-red-700 focus:outline-none dark:focus:ring-red-800">
<i class="fas fa-trash mr-2"></i> Delete Record
</button>
</div>
</div>
</div>
</div>
<script>
// Set dark background color if in dark mode
if (document.documentElement.classList.contains('dark')) {
document.getElementById('file-details-container').style.backgroundColor = '#111827';
}
// Add event listener for theme changes
document.addEventListener('DOMContentLoaded', function() {
const themeToggle = document.getElementById('theme-toggle');
if (themeToggle) {
themeToggle.addEventListener('click', function() {
setTimeout(function() {
const isDark = document.documentElement.classList.contains('dark');
document.getElementById('file-details-container').style.backgroundColor = isDark ? '#111827' : 'rgb(249, 250, 251)';
}, 50);
});
}
});
</script>
</div>
}
}
@@ -0,0 +1,57 @@
package dialog
import (
"fmt"
"github.com/starfleetcptn/gomft/components/file_metadata/utils"
)
// FileMetadataDialog renders a confirmation dialog for file metadata actions
templ FileMetadataDialog(id string, title string, message string, confirmClass string, confirmText string, action string, fileID uint, fileName string, section string) {
@utils.FileMetadataJS()
<div id={ id } tabindex="-1" aria-hidden="true" class="hidden fixed top-0 right-0 left-0 z-50 justify-center items-center w-full md:inset-0 h-[calc(100%-1rem)] max-h-full">
<!-- Backdrop -->
<div id={ fmt.Sprintf("%s-backdrop", id) } class="fixed inset-0 bg-gray-900/50 dark:bg-gray-900/80 backdrop-blur-sm"></div>
<!-- Modal content -->
<div class="relative p-4 w-full max-w-md max-h-full mx-auto">
<div class="relative bg-white rounded-lg shadow dark:bg-gray-700">
<div class="p-6 text-center">
if action == "delete" {
<i class="fas fa-trash-alt text-red-400 text-3xl mb-4"></i>
} else {
<i class="fas fa-exclamation-triangle text-yellow-400 text-3xl mb-4"></i>
}
<h3 class="mb-5 text-lg font-normal text-gray-500 dark:text-gray-400">{ message }</h3>
if section == "list" {
<button
type="button"
class="text-white font-medium rounded-lg text-sm px-5 py-2.5 text-center me-2 bg-red-600 hover:bg-red-700 focus:ring-4 focus:outline-none focus:ring-red-300 dark:bg-red-500 dark:hover:bg-red-600 dark:focus:ring-red-800"
hx-delete={ fmt.Sprintf("/files/%d", fileID) }
hx-target={ fmt.Sprintf("#file-row-%d", fileID) }
hx-swap="delete"
data-file-name={ fileName }
data-file-id={ fmt.Sprint(fileID) }
id={ fmt.Sprintf("delete-file-btn-%d", fileID) }
onclick={ templ.ComponentScript{Call: fmt.Sprintf("triggerFileDelete('%s', %d, '%s')", id, fileID, fileName)} }>
{ confirmText }
</button>
} else {
<button
type="button"
class="text-white font-medium rounded-lg text-sm px-5 py-2.5 text-center me-2 bg-red-600 hover:bg-red-700 focus:ring-4 focus:outline-none focus:ring-red-300 dark:bg-red-500 dark:hover:bg-red-600 dark:focus:ring-red-800"
hx-delete={ fmt.Sprintf("/files/%d", fileID) }
hx-redirect="/files"
data-file-name={ fileName }
data-file-id={ fmt.Sprint(fileID) }
id={ fmt.Sprintf("delete-file-btn-%d", fileID) }
onclick={ templ.ComponentScript{Call: fmt.Sprintf("triggerFileDelete('%s', %d, '%s')", id, fileID, fileName)} }>
{ confirmText }
</button>
}
<button type="button" onclick={ templ.ComponentScript{Call: fmt.Sprintf("closeModal('%s')", id)} } class="text-gray-500 bg-white hover:bg-gray-100 focus:ring-4 focus:outline-none focus:ring-gray-200 rounded-lg border border-gray-200 text-sm font-medium px-5 py-2.5 hover:text-gray-900 focus:z-10 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-500 dark:hover:text-white dark:hover:bg-gray-600 dark:focus:ring-gray-600">
Cancel
</button>
</div>
</div>
</div>
</div>
}
@@ -0,0 +1,116 @@
package list
import (
"context"
"strconv"
"github.com/starfleetcptn/gomft/components/file_metadata"
"github.com/starfleetcptn/gomft/components/file_metadata/utils"
"github.com/starfleetcptn/gomft/components" // Import the main components package
)
// FileMetadataList renders the list of file metadata
templ FileMetadataList(ctx context.Context, data file_metadata.FileMetadataListData) {
@components.LayoutWithContext("Files", ctx) { // Call using components package
<!-- Status and Error Messages -->
<div id="toast-container" class="fixed top-5 right-5 z-50 flex flex-col gap-2"></div>
@utils.FileMetadataJS() // Use capitalized function name
<div id="list-container" style="min-height: 100vh;" class="bg-gray-50 dark:bg-gray-900">
<div class="pb-8 w-full">
<div class="mb-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
<i class="fas fa-file-alt w-6 h-6 mr-2 text-blue-500"></i>
Files
</h1>
<div class="flex gap-3">
<a href="/files/search" class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800">
<i class="fas fa-search mr-2"></i> Advanced Search
</a>
</div>
</div>
<!-- Filter Form -->
<div class="p-4 mb-6 bg-white border border-gray-200 rounded-lg shadow dark:bg-gray-800 dark:border-gray-700 w-full">
<h5 class="mb-4 text-lg font-semibold text-gray-900 dark:text-white">Filter Files</h5>
<form
hx-get="/files/partial"
hx-target="#file-list-container"
hx-swap="innerHTML"
hx-indicator="#filter-loading"
hx-headers='{"X-HX-Request": "true"}'
class="grid grid-cols-1 md:grid-cols-3 gap-4">
if data.Job == nil {
<div>
<label for="job_id" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Job</label>
<input type="text" id="job_id" name="job_id" value={ data.Filter.JobID } placeholder="Job ID" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"/>
</div>
}
<div>
<label for="status" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Status</label>
<select id="status" name="status" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500">
<option value="">All Statuses</option>
<option value="processed" selected?={ data.Filter.Status == "processed" }>Processed</option>
<option value="archived" selected?={ data.Filter.Status == "archived" }>Archived</option>
<option value="deleted" selected?={ data.Filter.Status == "deleted" }>Deleted</option>
<option value="archived_and_deleted" selected?={ data.Filter.Status == "archived_and_deleted" }>Archived & Deleted</option>
<option value="error" selected?={ data.Filter.Status == "error" }>Error</option>
</select>
</div>
<div>
<label for="filename" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Filename</label>
<input type="text" id="filename" name="filename" value={ data.Filter.FileName } placeholder="Filename or partial match"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" />
</div>
<div class="md:col-span-3 flex justify-end items-center">
<button type="submit" class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800">
<i class="fas fa-filter mr-2"></i> Apply Filters
</button>
<div id="filter-loading" class="htmx-indicator ml-2 flex items-center">
<i class="fas fa-circle-notch fa-spin text-blue-600"></i>
</div>
</div>
</form>
</div>
<div class="bg-white border border-gray-200 rounded-lg shadow dark:bg-gray-800 dark:border-gray-700 w-full">
<!-- Card header -->
<div class="p-4 md:p-5 border-b border-gray-200 dark:border-gray-700">
<h5 class="text-xl font-bold leading-none text-gray-900 dark:text-white">
File List
</h5>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
Showing { strconv.FormatInt(int64((data.Page - 1) * data.Limit + 1), 10) } to { strconv.FormatInt(int64(min(data.Page * data.Limit, int(data.TotalCount))), 10) } of { strconv.FormatInt(data.TotalCount, 10) } files
</p>
</div>
<!-- Card content -->
<div id="file-list-container" class="p-4 md:p-5">
@FileMetadataListPartial(ctx, data, "/files/partial", "#file-list-container")
</div>
</div>
</div>
<script>
// Set dark background color if in dark mode
if (document.documentElement.classList.contains('dark')) {
document.getElementById('list-container').style.backgroundColor = '#111827';
}
// Add event listener for theme changes
document.addEventListener('DOMContentLoaded', function() {
const themeToggle = document.getElementById('theme-toggle');
if (themeToggle) {
themeToggle.addEventListener('click', function() {
setTimeout(function() {
const isDark = document.documentElement.classList.contains('dark');
document.getElementById('list-container').style.backgroundColor = isDark ? '#111827' : 'rgb(249, 250, 251)';
}, 50);
});
}
});
</script>
</div>
}
}
@@ -0,0 +1,269 @@
package list
import (
"context"
"fmt"
"net/url"
"strconv"
"github.com/starfleetcptn/gomft/components/file_metadata"
"github.com/starfleetcptn/gomft/components/file_metadata/dialog"
"github.com/starfleetcptn/gomft/components/file_metadata/utils"
)
// Helper function to generate sorting links
func sortLink(currentSortBy, currentSortDir, targetSortBy, basePath string, filter file_metadata.FileMetadataFilter, limit int) string {
nextSortDir := "asc"
if currentSortBy == targetSortBy && currentSortDir == "asc" {
nextSortDir = "desc"
}
q := url.Values{}
q.Set("page", "1")
q.Set("limit", strconv.Itoa(limit))
q.Set("sort_by", targetSortBy)
q.Set("sort_dir", nextSortDir)
if filter.Status != "" {
q.Set("status", filter.Status)
}
if filter.FileName != "" {
q.Set("filename", filter.FileName)
}
if filter.JobID != "" {
q.Set("job_id", filter.JobID)
}
if filter.Hash != "" {
q.Set("hash", filter.Hash)
}
if filter.StartDate != "" {
q.Set("start_date", filter.StartDate)
}
if filter.EndDate != "" {
q.Set("end_date", filter.EndDate)
}
return fmt.Sprintf("%s?%s", basePath, q.Encode())
}
// Helper function to get sort icon class
func sortIconClass(currentSortBy, currentSortDir, targetSortBy string) string {
if currentSortBy == targetSortBy {
if currentSortDir == "asc" {
return "fas fa-sort-up ml-1"
}
return "fas fa-sort-down ml-1"
}
return "fas fa-sort text-gray-400 ml-1"
}
// FileMetadataListPartial renders the list of file metadata in a table format
// Added basePath and targetContainerID parameters
templ FileMetadataListPartial(ctx context.Context, data file_metadata.FileMetadataListData, basePath string, targetContainerID string) {
<!-- Container for dynamically generated dialogs -->
<div id="dialog-container">
for _, file := range data.Files {
@dialog.FileMetadataDialog(
fmt.Sprintf("delete-file-dialog-%d", file.ID),
"Delete File Metadata",
fmt.Sprintf("Are you sure you want to delete the metadata for '%s'? This cannot be undone.", file.FileName),
"text-white bg-red-700 hover:bg-red-800 focus:ring-4 focus:ring-red-300 font-medium rounded-lg text-sm px-5 py-2.5 dark:bg-red-600 dark:hover:bg-red-700 focus:outline-none dark:focus:ring-red-800",
"Delete",
"delete",
file.ID,
file.FileName,
"list",
)
}
</div>
<!-- File List Table -->
<div class="relative overflow-x-auto shadow-md sm:rounded-lg">
<table class="w-full text-sm text-left text-gray-500 dark:text-gray-400">
<thead class="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-700 dark:text-gray-400">
<tr>
<th scope="col" class="px-6 py-3">
<a
href="#"
hx-get={ sortLink(data.SortBy, data.SortDir, "id", basePath, data.Filter, data.Limit) }
hx-target={ targetContainerID }
hx-swap="innerHTML"
class="flex items-center hover:text-blue-600 dark:hover:text-blue-400"
>
ID <i class={ sortIconClass(data.SortBy, data.SortDir, "id") }></i>
</a>
</th>
<th scope="col" class="px-6 py-3">
<a
href="#"
hx-get={ sortLink(data.SortBy, data.SortDir, "filename", basePath, data.Filter, data.Limit) }
hx-target={ targetContainerID }
hx-swap="innerHTML"
class="flex items-center hover:text-blue-600 dark:hover:text-blue-400"
>
Filename <i class={ sortIconClass(data.SortBy, data.SortDir, "filename") }></i>
</a>
</th>
<th scope="col" class="px-6 py-3">
<a
href="#"
hx-get={ sortLink(data.SortBy, data.SortDir, "size", basePath, data.Filter, data.Limit) }
hx-target={ targetContainerID }
hx-swap="innerHTML"
class="flex items-center hover:text-blue-600 dark:hover:text-blue-400"
>
Size <i class={ sortIconClass(data.SortBy, data.SortDir, "size") }></i>
</a>
</th>
<th scope="col" class="px-6 py-3">
<a
href="#"
hx-get={ sortLink(data.SortBy, data.SortDir, "processed_time", basePath, data.Filter, data.Limit) }
hx-target={ targetContainerID }
hx-swap="innerHTML"
class="flex items-center hover:text-blue-600 dark:hover:text-blue-400"
>
Processed time <i class={ sortIconClass(data.SortBy, data.SortDir, "processed_time") }></i>
</a>
</th>
<th scope="col" class="px-6 py-3">
<a
href="#"
hx-get={ sortLink(data.SortBy, data.SortDir, "status", basePath, data.Filter, data.Limit) }
hx-target={ targetContainerID }
hx-swap="innerHTML"
class="flex items-center hover:text-blue-600 dark:hover:text-blue-400"
>
Status <i class={ sortIconClass(data.SortBy, data.SortDir, "status") }></i>
</a>
</th>
if data.Job == nil {
<th scope="col" class="px-6 py-3">Job</th>
}
<th scope="col" class="px-6 py-3">Actions</th>
</tr>
</thead>
<tbody>
for _, file := range data.Files {
<tr id={ fmt.Sprintf("file-row-%d", file.ID) } class="bg-white border-b dark:bg-gray-800 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600">
<td class="px-6 py-4 font-medium text-gray-900 dark:text-white">
{ strconv.FormatUint(uint64(file.ID), 10) }
</td>
<td class="px-6 py-4 font-medium text-gray-900 whitespace-nowrap dark:text-white">
<a href={ templ.SafeURL(fmt.Sprintf("/files/%d", file.ID)) } class="hover:underline">
{ file.FileName }
</a>
</td>
<td class="px-6 py-4">
{ utils.FormatFileSize(file.FileSize) }
</td>
<td class="px-6 py-4">
{ file.ProcessedTime.Format("2006-01-02 15:04:05") }
</td>
<td class="px-6 py-4">
<span class={ "text-xs font-medium px-2.5 py-0.5 rounded", utils.GetStatusBadgeClass(file.Status) }>
{ file.Status }
</span>
</td>
if data.Job == nil {
<td class="px-6 py-4">
if file.Job.ID > 0 {
<a href={ templ.SafeURL(fmt.Sprintf("/files/job/%d", file.Job.ID)) } class="font-medium text-blue-600 dark:text-blue-500 hover:underline">
{ file.Job.Name }
</a>
} else {
<span class="text-gray-400 dark:text-gray-500 italic">N/A</span>
}
</td>
}
<td class="px-6 py-4">
<div class="flex space-x-3">
<a href={ templ.SafeURL(fmt.Sprintf("/files/%d", file.ID)) } class="font-medium text-blue-600 dark:text-blue-500 hover:underline">
<i class="fas fa-eye"></i>
</a>
<button
type="button"
onclick={ templ.ComponentScript{Call: fmt.Sprintf("showModal('delete-file-dialog-%d')", file.ID)} }
data-file-id={ strconv.FormatUint(uint64(file.ID), 10) }
data-file-name={ file.FileName }
class="font-medium text-red-600 dark:text-red-500 hover:underline">
<i class="fas fa-trash"></i>
</button>
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
<!-- Pagination with HTMX (Update links to include sorting and targetContainerID) -->
if data.TotalPages > 1 {
<nav class="flex items-center flex-column flex-wrap md:flex-row justify-between p-4" aria-label="Table navigation">
<span class="text-sm font-normal text-gray-500 dark:text-gray-400 mb-4 md:mb-0">
Showing <span class="font-semibold text-gray-900 dark:text-white">{ strconv.Itoa((data.Page-1)*data.Limit+1) }-{ strconv.Itoa(func() int {
end := data.Page*data.Limit
if int64(end) > data.TotalCount {
return int(data.TotalCount)
}
return end
}()) }</span> of <span class="font-semibold text-gray-900 dark:text-white">{ strconv.FormatInt(data.TotalCount, 10) }</span>
</span>
<ul class="inline-flex -space-x-px rtl:space-x-reverse text-sm h-8">
<li>
if data.Page == 1 {
<span class="flex items-center justify-center px-3 h-8 ms-0 leading-tight text-gray-500 bg-white border border-gray-300 rounded-s-lg cursor-not-allowed dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400">
Previous
</span>
} else {
<a hx-get={ fmt.Sprintf("%s?page=%d&limit=%d&status=%s&filename=%s&job_id=%s&sort_by=%s&sort_dir=%s", basePath, data.Page - 1, data.Limit, data.Filter.Status, data.Filter.FileName, data.Filter.JobID, data.SortBy, data.SortDir) }
hx-target={ targetContainerID }
hx-swap="innerHTML"
class="flex items-center justify-center px-3 h-8 ms-0 leading-tight text-gray-500 bg-white border border-gray-300 rounded-s-lg hover:bg-gray-100 hover:text-gray-700 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white">
Previous
</a>
}
</li>
for i := 1; i <= data.TotalPages; i++ {
if i == 1 || i == data.TotalPages || (i >= data.Page-2 && i <= data.Page+2) {
<li>
if i == data.Page {
<span aria-current="page" class="flex items-center justify-center px-3 h-8 text-blue-600 border border-gray-300 bg-blue-50 hover:bg-blue-100 hover:text-blue-700 dark:border-gray-700 dark:bg-gray-700 dark:text-white">
{ strconv.Itoa(i) }
</span>
} else {
<a hx-get={ fmt.Sprintf("%s?page=%d&limit=%d&status=%s&filename=%s&job_id=%s&sort_by=%s&sort_dir=%s", basePath, i, data.Limit, data.Filter.Status, data.Filter.FileName, data.Filter.JobID, data.SortBy, data.SortDir) }
hx-target={ targetContainerID }
hx-swap="innerHTML"
class="flex items-center justify-center px-3 h-8 leading-tight text-gray-500 bg-white border border-gray-300 hover:bg-gray-100 hover:text-gray-700 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white">
{ strconv.Itoa(i) }
</a>
}
</li>
} else if (i == 2 && data.Page > 4) || (i == data.TotalPages-1 && data.Page < data.TotalPages-3) {
<li>
<span class="flex items-center justify-center px-3 h-8 leading-tight text-gray-500 bg-white border border-gray-300 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400">
...
</span>
</li>
}
}
<li>
if data.Page == data.TotalPages {
<span class="flex items-center justify-center px-3 h-8 leading-tight text-gray-500 bg-white border border-gray-300 rounded-e-lg cursor-not-allowed dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400">
Next
</span>
} else {
<a hx-get={ fmt.Sprintf("%s?page=%d&limit=%d&status=%s&filename=%s&job_id=%s&sort_by=%s&sort_dir=%s", basePath, data.Page + 1, data.Limit, data.Filter.Status, data.Filter.FileName, data.Filter.JobID, data.SortBy, data.SortDir) }
hx-target={ targetContainerID }
hx-swap="innerHTML"
class="flex items-center justify-center px-3 h-8 leading-tight text-gray-500 bg-white border border-gray-300 rounded-e-lg hover:bg-gray-100 hover:text-gray-700 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white">
Next
</a>
}
</li>
</ul>
</nav>
}
}
@@ -0,0 +1,131 @@
package search
import (
"context"
"github.com/starfleetcptn/gomft/components/file_metadata"
"github.com/starfleetcptn/gomft/components/file_metadata/utils"
"github.com/starfleetcptn/gomft/components" // Import the main components package
)
// FileMetadataSearch renders the search interface for file metadata
templ FileMetadataSearch(ctx context.Context, data file_metadata.FileMetadataSearchData) {
@components.LayoutWithContext("Search Files", ctx) {
<!-- Status and Error Messages -->
<div id="toast-container" class="fixed top-5 right-5 z-50 flex flex-col gap-2"></div>
@utils.FileMetadataJS() // Include JS for toasts, etc.
<div id="search-container" style="min-height: 100vh;" class="bg-gray-50 dark:bg-gray-900">
<div class="pb-8 w-full">
<div class="mb-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
<i class="fas fa-search w-6 h-6 mr-2 text-blue-500"></i>
Search Files
</h1>
<div class="flex gap-3">
<a href="/files" class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800">
<i class="fas fa-arrow-left mr-2"></i> Back to Files
</a>
</div>
</div>
<!-- Advanced Search Form -->
<div class="p-4 mb-6 bg-white border border-gray-200 rounded-lg shadow dark:bg-gray-800 dark:border-gray-700 w-full">
<h5 class="mb-4 text-lg font-semibold text-gray-900 dark:text-white">Advanced File Search</h5>
<form
hx-get="/files/search/partial"
hx-target="#search-results-container"
hx-swap="innerHTML"
hx-indicator="#search-form-loading"
hx-headers='{"X-HX-Request": "true"}'
hx-boost="false"
class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label for="job_id" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Job ID</label>
<input type="text" id="job_id" name="job_id" value={ data.Filter.JobID } placeholder="Job ID"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" />
</div>
<div>
<label for="status" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Status</label>
<select id="status" name="status" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500">
<option value="">All Statuses</option>
<option value="processed" selected?={ data.Filter.Status == "processed" }>Processed</option>
<option value="archived" selected?={ data.Filter.Status == "archived" }>Archived</option>
<option value="deleted" selected?={ data.Filter.Status == "deleted" }>Deleted</option>
<option value="archived_and_deleted" selected?={ data.Filter.Status == "archived_and_deleted" }>Archived & Deleted</option>
<option value="error" selected?={ data.Filter.Status == "error" }>Error</option>
</select>
</div>
<div>
<label for="filename" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Filename</label>
<input type="text" id="filename" name="filename" value={ data.Filter.FileName } placeholder="Filename or partial match"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" />
</div>
<div>
<label for="hash" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">File Hash</label>
<input type="text" id="hash" name="hash" value={ data.Filter.Hash } placeholder="MD5 hash"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" />
</div>
<div>
<label for="start_date" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Processed After</label>
<input type="date" id="start_date" name="start_date" value={ data.Filter.StartDate }
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" />
</div>
<div>
<label for="end_date" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Processed Before</label>
<input type="date" id="end_date" name="end_date" value={ data.Filter.EndDate }
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" />
</div>
<div class="md:col-span-2 flex justify-end">
<button type="submit" class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800">
<i class="fas fa-search mr-2"></i> Search Files
</button>
<div id="search-form-loading" class="htmx-indicator ml-2 flex items-center">
<i class="fas fa-circle-notch fa-spin text-blue-600"></i>
</div>
</div>
</form>
</div>
<!-- Results Container (Initially empty, populated by HTMX) -->
<div id="search-results-container" class="bg-white border border-gray-200 rounded-lg shadow dark:bg-gray-800 dark:border-gray-700 w-full mt-6">
<div class="p-4 md:p-5 border-b border-gray-200 dark:border-gray-700">
<h5 class="text-xl font-bold leading-none text-gray-900 dark:text-white">
Search Results
</h5>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
Enter search criteria above and click "Search Files".
</p>
</div>
<div class="p-4 md:p-5">
<!-- Content will be loaded here by HTMX -->
<div class="text-center text-gray-500 dark:text-gray-400 py-8">
No results yet.
</div>
</div>
</div>
</div>
<script>
// Set dark background color if in dark mode
if (document.documentElement.classList.contains('dark')) {
document.getElementById('search-container').style.backgroundColor = '#111827';
}
// Add event listener for theme changes
document.addEventListener('DOMContentLoaded', function() {
const themeToggle = document.getElementById('theme-toggle');
if (themeToggle) {
themeToggle.addEventListener('click', function() {
setTimeout(function() {
const isDark = document.documentElement.classList.contains('dark');
document.getElementById('search-container').style.backgroundColor = isDark ? '#111827' : 'rgb(249, 250, 251)';
}, 50);
});
}
});
</script>
</div>
}
}
@@ -0,0 +1,33 @@
package search
import (
"context"
"github.com/starfleetcptn/gomft/components/file_metadata"
"github.com/starfleetcptn/gomft/components/file_metadata/list"
)
// FileMetadataSearchContent renders only the search results table and pagination
templ FileMetadataSearchContent(ctx context.Context, data file_metadata.FileMetadataSearchData) {
<!-- Search Results -->
<div id="search-results">
if len(data.Files) > 0 {
@list.FileMetadataListPartial(ctx, file_metadata.FileMetadataListData{
Files: data.Files,
Page: data.Page,
Limit: data.Limit,
TotalCount: data.TotalCount,
TotalPages: data.TotalPages,
Filter: data.Filter, // Pass filter data for pagination links
SortBy: data.SortBy,
SortDir: data.SortDir,
}, "/files/search/partial", "#search-results-container") // Pass correct base path and target ID
} else {
<div class="p-6 text-center text-gray-500 dark:text-gray-400">
<svg class="mx-auto mb-4 w-12 h-12 text-gray-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
</svg>
<p>No files found matching your search criteria.</p>
</div>
}
</div>
}
+43
View File
@@ -0,0 +1,43 @@
package file_metadata
import "github.com/starfleetcptn/gomft/internal/db"
// FileMetadataFilter represents filter parameters for file metadata queries
type FileMetadataFilter struct {
Status string
JobID string
FileName string
Hash string
StartDate string
EndDate string
}
// FileMetadataListData contains data for the file metadata list template
type FileMetadataListData struct {
Files []db.FileMetadata
TotalCount int64
Page int
Limit int
TotalPages int
Job *db.Job // Optional: if viewing files for a specific job
Filter FileMetadataFilter
SortBy string // Added for sorting
SortDir string // Added for sorting ("asc" or "desc")
}
// FileMetadataDetailsData contains data for the file metadata details template
type FileMetadataDetailsData struct {
File db.FileMetadata
}
// FileMetadataSearchData contains data for the file metadata search template
type FileMetadataSearchData struct {
Files []db.FileMetadata
TotalCount int64
Page int
Limit int
TotalPages int
Filter FileMetadataFilter
SortBy string // Added for sorting
SortDir string // Added for sorting ("asc" or "desc")
}
@@ -0,0 +1,114 @@
package utils
// FileMetadataJS provides common JavaScript functions for file metadata components,
// including HTMX event listeners for delete toasts.
templ FileMetadataJS() {
<script>
// Function to create and show a toast (Defined locally for guaranteed availability)
function showToast(message, type = 'info') {
const toastContainer = document.getElementById('toast-container');
if (!toastContainer) {
console.error("Toast container not found!"); // Keep this error log
return;
}
// Create toast element
const toast = document.createElement('div');
toast.id = 'toast-' + type + '-' + Date.now();
// Use classes similar to the original file_metadata.templ for consistency
toast.className = 'flex items-center w-full max-w-xs p-4 mb-4 rounded-lg shadow text-gray-500 bg-white dark:text-gray-400 dark:bg-gray-800 transform translate-y-16 opacity-0 transition-all duration-300 ease-out';
toast.role = 'alert';
// Set toast content based on type
let iconClass;
if (type === 'success') {
iconClass = 'text-green-500 bg-green-100 dark:bg-green-800 dark:text-green-200';
} else if (type === 'error') {
iconClass = 'text-red-500 bg-red-100 dark:bg-red-800 dark:text-red-200';
} else { // Default to info
iconClass = 'text-blue-500 bg-blue-100 dark:bg-blue-800 dark:text-blue-200';
}
// Set inner HTML with appropriate icon and message
toast.innerHTML = `
<div class="inline-flex items-center justify-center flex-shrink-0 w-8 h-8 rounded-lg ${iconClass}">
${type === 'success'
? '<i class="fas fa-check"></i>'
: type === 'error'
? '<i class="fas fa-exclamation-circle"></i>'
: '<i class="fas fa-info-circle"></i>'}
</div>
<div class="ml-3 text-sm font-normal">${message}</div>
<button type="button" class="ml-auto -mx-1.5 -my-1.5 bg-white text-gray-400 hover:text-gray-900 rounded-lg focus:ring-2 focus:ring-gray-300 p-1.5 hover:bg-gray-100 inline-flex h-8 w-8 dark:text-gray-500 dark:hover:text-white dark:bg-gray-800 dark:hover:bg-gray-700" data-dismiss-target="#${toast.id}" aria-label="Close">
<span class="sr-only">Close</span>
<i class="fas fa-times"></i>
</button>
`;
// Add toast to container
toastContainer.appendChild(toast);
// Trigger animation after a small delay
setTimeout(() => {
toast.classList.remove('translate-y-16', 'opacity-0');
toast.classList.add('translate-y-0', 'opacity-100');
}, 10);
// Add event listener to close button
const closeButton = toast.querySelector('button[data-dismiss-target]');
closeButton.addEventListener('click', function() {
toast.classList.add('opacity-0', 'translate-y-4');
setTimeout(() => { toast.remove(); }, 300);
});
// Auto-remove toast after 5 seconds
setTimeout(() => {
toast.classList.add('opacity-0', 'translate-y-4');
setTimeout(() => { toast.remove(); }, 300);
}, 5000);
}
// --- HTMX Event Listener for Delete Toasts ---
// Ensure listener is attached only once using a flag
if (!window._gomft_fileMetadataListenerAttached) {
document.body.addEventListener('htmx:afterRequest', function(event) {
const triggerElement = event.detail.elt;
// Check if the element that triggered this request was the file delete button from the dialog
if (triggerElement && triggerElement.id && triggerElement.id.startsWith('delete-file-btn-')) {
// Get the path directly from the element's hx-delete attribute
const path = triggerElement.getAttribute('hx-delete');
// Ensure requestConfig exists before accessing verb (robustness)
const method = event.detail.requestConfig ? event.detail.requestConfig.verb : null;
// Check if the method was delete and the path from the attribute matches the expected pattern
if (method === 'delete' && path && path.match(/^\/files\/\d+$/)) {
const fileName = triggerElement.getAttribute('data-file-name') || "Unknown"; // Get filename from the button
// Call the locally defined showToast function
if (event.detail.successful) {
showToast(`File "${fileName}" metadata deleted successfully`, 'success');
} else {
let errorMsg = `Failed to delete file "${fileName}" metadata`;
if (event.detail.xhr && event.detail.xhr.responseText) {
try {
const responseJson = JSON.parse(event.detail.xhr.responseText);
errorMsg = responseJson.error ? `Error: ${responseJson.error}` : `Error: ${event.detail.xhr.responseText}`;
} catch (e) {
errorMsg = `Error: ${event.detail.xhr.responseText}`;
}
}
showToast(errorMsg, 'error');
}
}
}
});
// Set the flag to true after attaching the listener
window._gomft_fileMetadataListenerAttached = true;
console.log("[FileMetadataJS] HTMX afterRequest listener attached."); // Log attachment once
}
</script>
}
@@ -0,0 +1,34 @@
package utils
import "fmt"
// GetStatusBadgeClass returns the appropriate CSS class for a file status badge
func GetStatusBadgeClass(status string) string {
switch status {
case "processed":
return "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300"
case "archived":
return "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300"
case "deleted":
return "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300"
case "archived_and_deleted":
return "bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-300"
case "error":
return "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300"
default:
return "bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300"
}
}
// FormatFileSize formats a file size in bytes to a human-readable string
func FormatFileSize(size int64) string {
if size < 1024 {
return fmt.Sprintf("%d B", size)
} else if size < 1024*1024 {
return fmt.Sprintf("%.2f KB", float64(size)/1024)
} else if size < 1024*1024*1024 {
return fmt.Sprintf("%.2f MB", float64(size)/(1024*1024))
} else {
return fmt.Sprintf("%.2f GB", float64(size)/(1024*1024*1024))
}
}
+183 -203
View File
@@ -6,102 +6,91 @@ import (
templ ForgotPassword(ctx context.Context, errorMessage string, successMessage string) {
@LayoutWithContext("Forgot Password", ctx) {
<div class="min-h-[calc(100vh-4rem)] flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8 bg-secondary-50 dark:bg-secondary-900">
<div class="max-w-md w-full">
<div class="card overflow-hidden shadow-lg">
<div class="p-8">
<div class="text-center mb-8">
<div class="inline-flex items-center justify-center w-20 h-20 rounded-full bg-primary-100 dark:bg-primary-900 mb-4">
<i class="fas fa-key text-primary-600 dark:text-primary-400 text-3xl"></i>
</div>
<h2 class="text-3xl font-bold text-secondary-900 dark:text-secondary-100">Password Reset</h2>
<p class="mt-2 text-secondary-600 dark:text-secondary-400">Enter your email to receive a reset link</p>
<div class="min-h-[calc(100vh-4rem)] flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8 bg-gray-50 dark:bg-gray-900">
<div class="max-w-md w-full space-y-8">
<div class="bg-white dark:bg-gray-800 shadow-lg rounded-lg p-8">
<div class="text-center mb-8">
<div class="mx-auto mb-4 flex h-20 w-20 items-center justify-center rounded-full bg-primary-100 dark:bg-primary-900">
<i class="fas fa-key text-primary-600 dark:text-primary-400 text-3xl"></i>
</div>
if errorMessage != "" {
<div class="bg-red-100 dark:bg-red-900 border border-red-400 dark:border-red-700 text-red-700 dark:text-red-300 px-4 py-3 rounded-lg mb-6" role="alert">
<div class="flex items-center">
<i class="fas fa-exclamation-circle mr-2"></i>
<span class="block sm:inline">{ errorMessage }</span>
</div>
</div>
}
if successMessage != "" {
<div class="bg-green-100 dark:bg-green-900 border border-green-400 dark:border-green-700 text-green-700 dark:text-green-300 px-4 py-3 rounded-lg mb-6" role="alert">
<div class="flex items-center">
<i class="fas fa-check-circle mr-2"></i>
<span class="block sm:inline">{ successMessage }</span>
</div>
</div>
}
<form
class="space-y-6"
method="POST"
action="/forgot-password"
x-data="{
email: '',
loading: false,
validate() {
return this.email && this.email.includes('@');
}
}">
<div>
<label for="email" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Email address</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-envelope text-secondary-400 dark:text-secondary-600"></i>
</div>
<input
id="email"
name="email"
type="email"
required
x-model="email"
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
placeholder="you@example.com"/>
</div>
<p class="mt-2 text-sm text-secondary-500 dark:text-secondary-400">
We'll send a password reset link to this email
</p>
</div>
<div>
<button
type="submit"
class="btn-primary w-full flex justify-center py-3"
x-bind:disabled="!validate()"
@click="loading = true">
<span x-show="!loading" class="flex items-center">
<i class="fas fa-paper-plane mr-2"></i>
Send Reset Link
</span>
<span x-show="loading" class="flex items-center">
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Processing...
</span>
</button>
</div>
</form>
<h2 class="text-3xl font-bold text-gray-900 dark:text-white">Password Reset</h2>
<p class="mt-2 text-gray-600 dark:text-gray-300">Enter your email to receive a reset link</p>
</div>
<div class="px-8 py-4 bg-secondary-50 dark:bg-secondary-800 border-t border-secondary-200 dark:border-secondary-700 text-center">
<p class="text-sm text-secondary-600 dark:text-secondary-400">
<a href="/login" class="text-primary-600 dark:text-primary-400 hover:text-primary-500 dark:hover:text-primary-300 flex items-center justify-center">
<i class="fas fa-arrow-left mr-2"></i>
Back to login
</a>
</p>
if errorMessage != "" {
<div class="mb-6 flex items-center rounded-lg bg-red-50 p-4 text-red-800 dark:bg-gray-800 dark:text-red-400" role="alert">
<i class="fas fa-exclamation-circle mr-2"></i>
<span class="[&:not(:first-child)]:ms-2">{ errorMessage }</span>
</div>
}
if successMessage != "" {
<div class="mb-6 flex items-center rounded-lg bg-green-50 p-4 text-green-800 dark:bg-gray-800 dark:text-green-400" role="alert">
<i class="fas fa-check-circle mr-2"></i>
<span class="[&:not(:first-child)]:ms-2">{ successMessage }</span>
</div>
}
<form
class="space-y-6"
method="POST"
action="/forgot-password"
x-data="{
email: '',
loading: false,
validate() {
return this.email && this.email.includes('@');
}
}">
<div>
<label for="email" class="mb-2 block text-sm font-medium text-gray-900 dark:text-white">Email address</label>
<div class="relative">
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
<i class="fas fa-envelope text-gray-500 dark:text-gray-400"></i>
</div>
<input
id="email"
name="email"
type="email"
required
x-model="email"
class="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 pl-10 text-gray-900 focus:border-primary-500 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400 dark:focus:border-primary-500 dark:focus:ring-primary-500"
placeholder="you@example.com"/>
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
We'll send a password reset link to this email
</p>
</div>
<button
type="submit"
class="w-full rounded-lg bg-primary-600 px-5 py-3 text-center text-sm font-medium text-white hover:bg-primary-700 focus:outline-none focus:ring-4 focus:ring-primary-300 dark:bg-primary-600 dark:hover:bg-primary-700 dark:focus:ring-primary-800"
x-bind:disabled="!validate()"
@click="loading = true">
<span x-show="!loading" class="flex items-center justify-center">
<i class="fas fa-paper-plane mr-2"></i>
Send Reset Link
</span>
<span x-show="loading" class="flex items-center justify-center">
<svg class="me-3 h-5 w-5 animate-spin text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Processing...
</span>
</button>
</form>
<div class="mt-6 border-t border-gray-200 px-8 py-4 text-center dark:border-gray-700">
<a href="/login" class="inline-flex items-center text-sm font-medium text-primary-600 hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300">
<i class="fas fa-arrow-left mr-2"></i>
Back to login
</a>
</div>
</div>
<!-- Security Notice -->
<div class="mt-8 text-center">
<div class="inline-flex items-center text-sm text-secondary-500 dark:text-secondary-400">
<div class="text-center">
<div class="inline-flex items-center text-sm text-gray-600 dark:text-gray-400">
<i class="fas fa-shield-alt mr-2 text-primary-500"></i>
<span>Secure, encrypted connection</span>
</div>
@@ -114,125 +103,116 @@ templ ForgotPassword(ctx context.Context, errorMessage string, successMessage st
// Reset password page for when users click the link from their email
templ ResetPassword(ctx context.Context, token string, errorMessage string) {
@LayoutWithContext("Reset Password", ctx) {
<div class="min-h-[calc(100vh-4rem)] flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8 bg-secondary-50 dark:bg-secondary-900">
<div class="max-w-md w-full">
<div class="card overflow-hidden shadow-lg">
<div class="p-8">
<div class="text-center mb-8">
<div class="inline-flex items-center justify-center w-20 h-20 rounded-full bg-primary-100 dark:bg-primary-900 mb-4">
<i class="fas fa-lock-open text-primary-600 dark:text-primary-400 text-3xl"></i>
</div>
<h2 class="text-3xl font-bold text-secondary-900 dark:text-secondary-100">Reset Password</h2>
<p class="mt-2 text-secondary-600 dark:text-secondary-400">Create a new password for your account</p>
<div class="min-h-[calc(100vh-4rem)] flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8 bg-gray-50 dark:bg-gray-900">
<div class="max-w-md w-full space-y-8">
<div class="bg-white dark:bg-gray-800 shadow-lg rounded-lg p-8">
<div class="text-center mb-8">
<div class="mx-auto mb-4 flex h-20 w-20 items-center justify-center rounded-full bg-primary-100 dark:bg-primary-900">
<i class="fas fa-lock-open text-primary-600 dark:text-primary-400 text-3xl"></i>
</div>
if errorMessage != "" {
<div class="bg-red-100 dark:bg-red-900 border border-red-400 dark:border-red-700 text-red-700 dark:text-red-300 px-4 py-3 rounded-lg mb-6" role="alert">
<div class="flex items-center">
<i class="fas fa-exclamation-circle mr-2"></i>
<span class="block sm:inline">{ errorMessage }</span>
</div>
</div>
}
<form
class="space-y-6"
method="POST"
action="/reset-password"
x-data="{
password: '',
confirmPassword: '',
loading: false,
validate() {
return this.password &&
this.password.length >= 8 &&
this.password === this.confirmPassword;
}
}">
<input type="hidden" name="token" value={token}/>
<div>
<label for="password" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">New Password</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
</div>
<input
id="password"
name="password"
type="password"
required
x-model="password"
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
placeholder="••••••••"/>
</div>
<p class="mt-1 text-sm text-secondary-500 dark:text-secondary-400">
Minimum 8 characters
</p>
</div>
<div>
<label for="confirm-password" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Confirm New Password</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-lock text-secondary-400 dark:text-secondary-600"></i>
</div>
<input
id="confirm-password"
name="confirm-password"
type="password"
required
x-model="confirmPassword"
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
placeholder="••••••••"/>
</div>
<p class="mt-1 text-sm"
x-bind:class="{'text-red-500': confirmPassword && password !== confirmPassword, 'text-secondary-500 dark:text-secondary-400': !confirmPassword || password === confirmPassword}">
<span x-show="!confirmPassword || password === confirmPassword">
Passwords must match
</span>
<span x-show="confirmPassword && password !== confirmPassword">
<i class="fas fa-exclamation-triangle mr-1"></i>
Passwords do not match
</span>
</p>
</div>
<div>
<button
type="submit"
class="btn-primary w-full flex justify-center py-3"
x-bind:disabled="!validate()"
@click="loading = true">
<span x-show="!loading" class="flex items-center">
<i class="fas fa-check-circle mr-2"></i>
Reset Password
</span>
<span x-show="loading" class="flex items-center">
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Processing...
</span>
</button>
</div>
</form>
<h2 class="text-3xl font-bold text-gray-900 dark:text-white">Reset Password</h2>
<p class="mt-2 text-gray-600 dark:text-gray-300">Create a new password for your account</p>
</div>
<div class="px-8 py-4 bg-secondary-50 dark:bg-secondary-800 border-t border-secondary-200 dark:border-secondary-700 text-center">
<p class="text-sm text-secondary-600 dark:text-secondary-400">
<a href="/login" class="text-primary-600 dark:text-primary-400 hover:text-primary-500 dark:hover:text-primary-300 flex items-center justify-center">
<i class="fas fa-arrow-left mr-2"></i>
Back to login
</a>
</p>
if errorMessage != "" {
<div class="mb-6 flex items-center rounded-lg bg-red-50 p-4 text-red-800 dark:bg-gray-800 dark:text-red-400" role="alert">
<i class="fas fa-exclamation-circle mr-2"></i>
<span class="[&:not(:first-child)]:ms-2">{ errorMessage }</span>
</div>
}
<form
class="space-y-6"
method="POST"
action="/reset-password"
x-data="{
password: '',
confirmPassword: '',
loading: false,
validate() {
return this.password &&
this.password.length >= 8 &&
this.password === this.confirmPassword;
}
}">
<input type="hidden" name="token" value={token}/>
<div>
<label for="password" class="mb-2 block text-sm font-medium text-gray-900 dark:text-white">New Password</label>
<div class="relative">
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
<i class="fas fa-key text-gray-500 dark:text-gray-400"></i>
</div>
<input
id="password"
name="password"
type="password"
required
x-model="password"
class="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 pl-10 text-gray-900 focus:border-primary-500 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400 dark:focus:border-primary-500 dark:focus:ring-primary-500"
placeholder="••••••••"/>
</div>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
Minimum 8 characters
</p>
</div>
<div>
<label for="confirm-password" class="mb-2 block text-sm font-medium text-gray-900 dark:text-white">Confirm New Password</label>
<div class="relative">
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
<i class="fas fa-lock text-gray-500 dark:text-gray-400"></i>
</div>
<input
id="confirm-password"
name="confirm-password"
type="password"
required
x-model="confirmPassword"
class="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 pl-10 text-gray-900 focus:border-primary-500 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400 dark:focus:border-primary-500 dark:focus:ring-primary-500"
placeholder="••••••••"/>
</div>
<p class="mt-1 text-sm"
x-bind:class="{'text-red-500': confirmPassword && password !== confirmPassword, 'text-gray-500 dark:text-gray-400': !confirmPassword || password === confirmPassword}">
<span x-show="!confirmPassword || password === confirmPassword">
Passwords must match
</span>
<span x-show="confirmPassword && password !== confirmPassword">
<i class="fas fa-exclamation-triangle mr-1"></i>
Passwords do not match
</span>
</p>
</div>
<button
type="submit"
class="w-full rounded-lg bg-primary-600 px-5 py-3 text-center text-sm font-medium text-white hover:bg-primary-700 focus:outline-none focus:ring-4 focus:ring-primary-300 dark:bg-primary-600 dark:hover:bg-primary-700 dark:focus:ring-primary-800"
x-bind:disabled="!validate()"
@click="loading = true">
<span x-show="!loading" class="flex items-center justify-center">
<i class="fas fa-check-circle mr-2"></i>
Reset Password
</span>
<span x-show="loading" class="flex items-center justify-center">
<svg class="me-3 h-5 w-5 animate-spin text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Processing...
</span>
</button>
</form>
<div class="mt-6 border-t border-gray-200 px-8 py-4 text-center dark:border-gray-700">
<a href="/login" class="inline-flex items-center text-sm font-medium text-primary-600 hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300">
<i class="fas fa-arrow-left mr-2"></i>
Back to login
</a>
</div>
</div>
<!-- Security Notice -->
<div class="mt-8 text-center">
<div class="inline-flex items-center text-sm text-secondary-500 dark:text-secondary-400">
<div class="text-center">
<div class="inline-flex items-center text-sm text-gray-600 dark:text-gray-400">
<i class="fas fa-shield-alt mr-2 text-primary-500"></i>
<span>Secure, encrypted connection</span>
</div>
+169
View File
@@ -0,0 +1,169 @@
package components
import (
"context"
)
// GDriveHeadlessAuthData contains data needed for rendering the headless auth page
type GDriveHeadlessAuthData struct {
AuthCommand string
ConfigID string
}
// GDriveHeadlessAuth renders the headless authentication page for Google Drive/Photos
templ GDriveHeadlessAuth(ctx context.Context, data GDriveHeadlessAuthData) {
// Force the layout to display as authenticated content
@LayoutWithContext("Google Authentication - Headless Mode", ctx) {
<style>
/* Ensure proper styling for the headless auth page */
body.dark .auth-page {
background-color: #111827 !important;
}
</style>
<div id="auth-container" class="auth-page w-full pb-8 bg-gray-50 dark:bg-gray-900" style="min-height: 100vh; background-color: rgb(249, 250, 251);">
<div class="max-w-4xl mx-auto">
<div class="mb-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
<i class="fas fa-key w-6 h-6 mr-2 text-blue-500 dark:text-blue-400"></i>
Headless Google Authentication
</h1>
<a href="/configs" class="flex items-center justify-center text-gray-700 bg-gray-100 hover:bg-gray-200 focus:ring-4 focus:ring-gray-300 font-medium rounded-lg px-5 py-2.5 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600 focus:outline-none dark:focus:ring-gray-700">
<i class="fas fa-arrow-left w-4 h-4 mr-2"></i>
Back to Configurations
</a>
</div>
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 p-6">
<div class="bg-blue-50 dark:bg-blue-900/30 border-l-4 border-blue-500 p-4 mb-6">
<div class="flex">
<div class="flex-shrink-0 mt-0.5">
<i class="fas fa-info-circle h-5 w-5 text-blue-500"></i>
</div>
<div class="ml-3">
<p class="text-sm text-blue-700 dark:text-blue-300">
You need to authenticate with Google using a web browser. Since you're running GoMFT behind a reverse proxy or in a headless environment, you'll need to complete authentication on a machine with a web browser.
</p>
</div>
</div>
</div>
<div class="mb-8">
<h2 class="text-lg font-medium mb-3 text-gray-900 dark:text-white">Step 1: Run the following command on a machine with a web browser</h2>
<div class="relative mb-4">
<pre id="auth-command-text" class="bg-gray-50 dark:bg-gray-900 rounded-md p-4 overflow-x-auto text-sm font-mono">{ data.AuthCommand }</pre>
<button id="copy-command" class="absolute top-2 right-2 bg-gray-200 dark:bg-gray-700 p-1.5 rounded hover:bg-gray-300 dark:hover:bg-gray-600" title="Copy to clipboard">
<i class="fas fa-copy h-5 w-5 text-gray-700 dark:text-gray-300"></i>
</button>
</div>
<div>
<h3 class="text-md font-medium mb-2 text-gray-900 dark:text-white">What this command does:</h3>
<ul class="list-disc ml-6 text-sm text-gray-700 dark:text-gray-300 space-y-1">
<li>Opens a browser window on the machine where you run it</li>
<li>Allows you to authenticate with Google</li>
<li>Generates an authentication token</li>
</ul>
</div>
</div>
<div class="mb-6">
<h2 class="text-lg font-medium mb-3 text-gray-900 dark:text-white">Step 2: Paste the authentication token below</h2>
<p class="text-sm text-gray-700 dark:text-gray-300 mb-4">
After completing authentication in the browser, you'll receive a token. Copy and paste that token here:
</p>
<form action="/configs/gdrive-headless-token" method="POST" class="space-y-4">
<input type="hidden" name="config_id" value={ data.ConfigID } />
<div>
<label for="auth_token" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Authentication Token</label>
<textarea
id="auth_token"
name="auth_token"
rows="5"
class="mt-1 block w-full rounded-md border border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white dark:placeholder-gray-400"
placeholder="Paste your authentication token here..."
required
></textarea>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">The token will look like a long JSON string containing access credentials.</p>
</div>
<div class="flex items-center justify-end mt-6">
<a href="/configs" class="mr-4 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-gray-500 dark:hover:text-gray-400">
Cancel
</a>
<button
type="submit"
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
Submit Token
</button>
</div>
</form>
</div>
</div>
<!-- Help Section -->
<div class="bg-gray-50 dark:bg-gray-800 rounded-lg shadow-sm mt-8 p-4 border border-gray-200 dark:border-gray-700">
<div class="flex items-start">
<div class="flex items-center h-5">
<i class="fas fa-info-circle w-4 h-4 text-blue-500 dark:text-blue-400 mr-2"></i>
</div>
<div class="ml-2 text-sm">
<p class="text-gray-700 dark:text-gray-300">This authentication process is necessary for GoMFT to access your Google Drive or Google Photos account.</p>
<p class="mt-1 text-gray-600 dark:text-gray-400">The token is only used for authentication and is stored securely. You'll only need to complete this process once for each configuration.</p>
</div>
</div>
</div>
</div>
</div>
<script>
// Set dark background color if in dark mode
document.addEventListener('DOMContentLoaded', function() {
if (document.documentElement.classList.contains('dark')) {
document.getElementById('auth-container').style.backgroundColor = '#111827';
}
// Add event listener for theme changes
const themeToggle = document.getElementById('theme-toggle');
if (themeToggle) {
themeToggle.addEventListener('click', function() {
setTimeout(function() {
const isDark = document.documentElement.classList.contains('dark');
document.getElementById('auth-container').style.backgroundColor = isDark ? '#111827' : 'rgb(249, 250, 251)';
}, 50);
});
}
// Store the actual command text
const actualCommand = document.getElementById('auth-command-text').textContent.trim();
// Add click handler to copy button
document.getElementById('copy-command').addEventListener('click', function() {
// Copy the actual command text, not the template variable
navigator.clipboard.writeText(actualCommand).then(function() {
// Show a success message
const button = document.getElementById('copy-command');
const originalTitle = button.getAttribute('title');
button.setAttribute('title', 'Copied!');
// Also show visual feedback
button.classList.add('bg-green-200', 'dark:bg-green-700');
button.classList.remove('bg-gray-200', 'dark:bg-gray-700');
setTimeout(function() {
button.setAttribute('title', originalTitle);
button.classList.remove('bg-green-200', 'dark:bg-green-700');
button.classList.add('bg-gray-200', 'dark:bg-gray-700');
}, 2000);
}).catch(function(err) {
console.error('Failed to copy text: ', err);
alert('Failed to copy command. Please select and copy it manually.');
});
});
});
</script>
}
}
+194 -123
View File
@@ -13,6 +13,7 @@ type HistoryData struct {
SearchTerm string
PageSize int
Total int
Configs map[uint]db.TransferConfig // Map of config IDs to configs for quick lookup
}
// min returns the smaller of x or y
@@ -23,69 +24,113 @@ func min(x, y int) int {
return y
}
// getConfigNameForHistory returns the appropriate name for the config used in a job history entry
func getConfigNameForHistory(history db.JobHistory, configs map[uint]db.TransferConfig) string {
// If ConfigID is set in the history record, use that to get the config name
if history.ConfigID > 0 {
if config, exists := configs[history.ConfigID]; exists {
return config.Name
}
}
// Fallback to the Job's default Config if it exists
if history.Job.Config.ID > 0 {
return history.Job.Config.Name
}
// If we can't determine the config name, show a default with the job name
if history.Job.Name != "" {
return fmt.Sprintf("%s (unknown config)", history.Job.Name)
}
return "Unknown Configuration"
}
// HistoryContent renders only the content part of the history page for HTMX requests
templ HistoryContent(ctx context.Context, data HistoryData) {
if len(data.History) == 0 {
<div class="text-center py-12 bg-white dark:bg-secondary-800 rounded-lg shadow">
<div class="inline-block p-4 rounded-full bg-secondary-100 dark:bg-secondary-700 mb-4">
<i class="fas fa-inbox text-secondary-400 dark:text-secondary-500 text-3xl"></i>
<div class="p-8 text-center bg-white dark:bg-gray-800 rounded-lg shadow">
<div class="flex justify-center mb-4">
<span class="inline-flex items-center justify-center w-16 h-16 rounded-full bg-gray-100 dark:bg-gray-700">
<i class="fas fa-inbox text-gray-400 dark:text-gray-500 text-3xl"></i>
</span>
</div>
<h3 class="mt-2 text-lg font-medium text-secondary-900 dark:text-secondary-100">No transfer history</h3>
<h3 class="text-lg font-medium text-gray-900 dark:text-gray-100">No transfer history</h3>
if data.SearchTerm != "" {
<p class="mt-1 text-sm text-secondary-500 dark:text-secondary-400">No results found for "{ data.SearchTerm }". Try a different search term or <button hx-get="/history" hx-target="#history-content" hx-vals={ fmt.Sprintf(`{"pageSize": %d}`, data.PageSize) } class="text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300">clear search</button>.</p>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
No results found for "{ data.SearchTerm }". Try a different search term or
<button
hx-get="/history"
hx-target="#history-content"
hx-vals={ fmt.Sprintf(`{"pageSize": %d}`, data.PageSize) }
class="font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
>
clear search
</button>.
</p>
} else if data.CurrentPage > 1 {
<p class="mt-1 text-sm text-secondary-500 dark:text-secondary-400">No more results on this page. <button hx-get="/history" hx-target="#history-content" hx-vals={ fmt.Sprintf(`{"page": 1, "pageSize": %d}`, data.PageSize) } class="text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300">Return to first page</button>.</p>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
No more results on this page.
<button
hx-get="/history"
hx-target="#history-content"
hx-vals={ fmt.Sprintf(`{"page": 1, "pageSize": %d}`, data.PageSize) }
class="font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
>
Return to first page
</button>.
</p>
} else {
<p class="mt-1 text-sm text-secondary-500 dark:text-secondary-400">Transfer history will appear here once jobs have run.</p>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Transfer history will appear here once jobs have run.</p>
}
</div>
} else {
<div class="bg-white dark:bg-secondary-800 shadow overflow-hidden rounded-lg">
<ul role="list" class="divide-y divide-secondary-200 dark:divide-secondary-700">
<div class="bg-white dark:bg-gray-800 shadow rounded-lg overflow-hidden">
<ul class="divide-y divide-gray-200 dark:divide-gray-700">
for _, history := range data.History {
<li class="hover:bg-secondary-50 dark:hover:bg-secondary-750 transition-colors">
<li class="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
<div class="px-4 py-4 sm:px-6">
<div class="flex items-center justify-between">
<div class="flex items-center">
<p class="text-sm font-medium text-primary-600 dark:text-primary-400 truncate">{ history.Job.Config.Name }</p>
<p class="text-sm font-medium text-blue-600 dark:text-blue-400 truncate">{ getConfigNameForHistory(history, data.Configs) }</p>
if history.Status == "completed" {
<span class="ml-2 px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-300">
<i class="fas fa-check mr-1"></i> Completed
<span class="ml-2 px-2.5 py-0.5 inline-flex text-xs font-medium rounded-full bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-300">
Completed
</span>
} else if history.Status == "failed" {
<span class="ml-2 px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-300">
<i class="fas fa-times mr-1"></i> Failed
<span class="ml-2 px-2.5 py-0.5 inline-flex text-xs font-medium rounded-full bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-300">
Failed
</span>
} else {
<span class="ml-2 px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-300">
<i class="fas fa-sync-alt mr-1"></i> { history.Status }
<span class="ml-2 px-2.5 py-0.5 inline-flex text-xs font-medium rounded-full bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-300">
{ history.Status }
</span>
}
</div>
<div>
<a href={ templ.SafeURL(fmt.Sprintf("/job-runs/%d", history.ID)) }
class="inline-flex items-center px-3 py-1.5 border border-secondary-300 dark:border-secondary-600 text-sm leading-5 font-medium rounded-full text-secondary-700 dark:text-secondary-200 bg-white dark:bg-secondary-700 hover:bg-secondary-50 dark:hover:bg-secondary-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 transition-colors">
<i class="fas fa-eye mr-1"></i>
class="text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600 font-medium rounded-lg text-sm px-3 py-1.5 inline-flex items-center border border-gray-300 dark:border-gray-600 focus:ring-4 focus:ring-gray-100 dark:focus:ring-gray-700 transition-colors">
<i class="fas fa-eye mr-1.5"></i>
View Details
</a>
</div>
</div>
<div class="mt-2 sm:flex sm:justify-between">
<div class="sm:flex">
<p class="flex items-center text-sm text-secondary-500 dark:text-secondary-400">
<i class="fas fa-calendar-alt flex-shrink-0 mr-1.5 h-5 w-5 text-secondary-400 dark:text-secondary-500"></i>
<p class="flex items-center text-sm text-gray-500 dark:text-gray-400">
<i class="fas fa-calendar-alt mr-1.5 h-5 w-5 text-gray-400 dark:text-gray-500"></i>
Started: { history.StartTime.Format("Jan 02, 2006 15:04:05") }
</p>
if history.EndTime != nil {
<p class="mt-2 flex items-center text-sm text-secondary-500 dark:text-secondary-400 sm:mt-0 sm:ml-6">
<i class="fas fa-clock flex-shrink-0 mr-1.5 h-5 w-5 text-secondary-400 dark:text-secondary-500"></i>
<p class="mt-2 flex items-center text-sm text-gray-500 dark:text-gray-400 sm:mt-0 sm:ml-6">
<i class="fas fa-clock mr-1.5 h-5 w-5 text-gray-400 dark:text-gray-500"></i>
Duration: { history.EndTime.Sub(history.StartTime).String() }
</p>
}
</div>
<div class="mt-2 flex items-center text-sm text-secondary-500 dark:text-secondary-400 sm:mt-0">
<div class="mt-2 flex items-center text-sm text-gray-500 dark:text-gray-400 sm:mt-0">
if history.BytesTransferred > 0 {
<i class="fas fa-upload flex-shrink-0 mr-1.5 h-5 w-5 text-secondary-400 dark:text-secondary-500"></i>
<i class="fas fa-upload mr-1.5 h-5 w-5 text-gray-400 dark:text-gray-500"></i>
<p>
{ formatBytes(history.BytesTransferred) } transferred
</p>
@@ -93,9 +138,9 @@ templ HistoryContent(ctx context.Context, data HistoryData) {
</div>
</div>
if history.ErrorMessage != "" {
<div class="mt-2 p-2 bg-red-50 dark:bg-red-900/20 rounded border border-red-200 dark:border-red-800">
<div class="mt-2 p-3 rounded-lg border bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800">
<p class="text-sm text-red-600 dark:text-red-400">
<i class="fas fa-exclamation-triangle mr-1"></i>
<i class="fas fa-exclamation-triangle mr-1.5"></i>
Error: { history.ErrorMessage }
</p>
</div>
@@ -108,22 +153,26 @@ templ HistoryContent(ctx context.Context, data HistoryData) {
<!-- Pagination -->
if data.TotalPages > 1 {
<div class="mt-6 flex items-center justify-between">
<div class="flex-1 flex justify-between sm:hidden">
<div class="mt-6 flex flex-col sm:flex-row justify-between items-center py-4 bg-white dark:bg-gray-800 rounded-lg shadow px-4">
<!-- Mobile pagination -->
<div class="flex-1 flex justify-between gap-4 sm:hidden w-full mb-4 sm:mb-0">
if data.CurrentPage > 1 {
<button
hx-get="/history"
hx-target="#history-content"
hx-vals={ fmt.Sprintf(`{"page": %d, "pageSize": %d, "search": "%s"}`, data.CurrentPage-1, data.PageSize, data.SearchTerm) }
hx-indicator="#mobile-prev-indicator"
class="relative inline-flex items-center px-4 py-2 border border-secondary-300 dark:border-secondary-600 text-sm font-medium rounded-md text-secondary-700 dark:text-secondary-200 bg-white dark:bg-secondary-700 hover:bg-secondary-50 dark:hover:bg-secondary-600 transition-colors">
<i class="fas fa-chevron-left mr-1"></i> Previous
<span id="mobile-prev-indicator" class="htmx-indicator ml-1">
<i class="fas fa-spinner fa-spin"></i>
class="w-full inline-flex justify-center items-center px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-100 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400 dark:hover:bg-gray-700 relative"
>
<span class="flex items-center">
<i class="fas fa-chevron-left mr-1"></i> Previous
</span>
<span id="mobile-prev-indicator" class="htmx-indicator absolute right-2">
<i class="fas fa-spinner fa-spin text-xs"></i>
</span>
</button>
} else {
<span class="relative inline-flex items-center px-4 py-2 border border-secondary-300 dark:border-secondary-700 text-sm font-medium rounded-md text-secondary-300 dark:text-secondary-600 bg-secondary-100 dark:bg-secondary-800 cursor-not-allowed">
<span class="w-full inline-flex justify-center items-center px-4 py-2 text-sm font-medium text-gray-400 bg-gray-100 border border-gray-300 rounded-lg dark:bg-gray-800 dark:border-gray-600 dark:text-gray-600 cursor-not-allowed">
<i class="fas fa-chevron-left mr-1"></i> Previous
</span>
}
@@ -134,21 +183,26 @@ templ HistoryContent(ctx context.Context, data HistoryData) {
hx-target="#history-content"
hx-vals={ fmt.Sprintf(`{"page": %d, "pageSize": %d, "search": "%s"}`, data.CurrentPage+1, data.PageSize, data.SearchTerm) }
hx-indicator="#mobile-next-indicator"
class="ml-3 relative inline-flex items-center px-4 py-2 border border-secondary-300 dark:border-secondary-600 text-sm font-medium rounded-md text-secondary-700 dark:text-secondary-200 bg-white dark:bg-secondary-700 hover:bg-secondary-50 dark:hover:bg-secondary-600 transition-colors">
Next <i class="fas fa-chevron-right ml-1"></i>
<span id="mobile-next-indicator" class="htmx-indicator ml-1">
<i class="fas fa-spinner fa-spin"></i>
class="w-full inline-flex justify-center items-center px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-100 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400 dark:hover:bg-gray-700 relative"
>
<span class="flex items-center">
Next <i class="fas fa-chevron-right ml-1"></i>
</span>
<span id="mobile-next-indicator" class="htmx-indicator absolute right-2">
<i class="fas fa-spinner fa-spin text-xs"></i>
</span>
</button>
} else {
<span class="ml-3 relative inline-flex items-center px-4 py-2 border border-secondary-300 dark:border-secondary-700 text-sm font-medium rounded-md text-secondary-300 dark:text-secondary-600 bg-secondary-100 dark:bg-secondary-800 cursor-not-allowed">
<span class="w-full inline-flex justify-center items-center px-4 py-2 text-sm font-medium text-gray-400 bg-gray-100 border border-gray-300 rounded-lg dark:bg-gray-800 dark:border-gray-600 dark:text-gray-600 cursor-not-allowed">
Next <i class="fas fa-chevron-right ml-1"></i>
</span>
}
</div>
<!-- Desktop pagination -->
<div class="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
<div>
<p class="text-sm text-secondary-700 dark:text-secondary-300">
<p class="text-sm text-gray-700 dark:text-gray-300">
Showing
<span class="font-medium">{ fmt.Sprint((data.CurrentPage-1)*data.PageSize + 1) }</span>
to
@@ -159,24 +213,23 @@ templ HistoryContent(ctx context.Context, data HistoryData) {
</p>
</div>
<div>
<nav class="relative z-0 inline-flex rounded-md shadow-sm -space-x-px" aria-label="Pagination">
<nav class="isolate inline-flex -space-x-px rounded-md shadow-sm" aria-label="Pagination">
if data.CurrentPage > 1 {
<button
hx-get="/history"
hx-target="#history-content"
hx-vals={ fmt.Sprintf(`{"page": %d, "pageSize": %d, "search": "%s"}`, data.CurrentPage-1, data.PageSize, data.SearchTerm) }
hx-indicator="#prev-indicator"
class="relative inline-flex items-center px-2 py-2 rounded-l-md border border-secondary-300 dark:border-secondary-600 bg-white dark:bg-secondary-700 text-sm font-medium text-secondary-500 dark:text-secondary-400 hover:bg-secondary-50 dark:hover:bg-secondary-600 transition-colors">
<span class="sr-only">Previous</span>
<i class="fas fa-chevron-left h-5 w-5"></i>
<span id="prev-indicator" class="htmx-indicator ml-1">
<i class="fas fa-spinner fa-spin"></i>
class="relative inline-flex items-center justify-center rounded-l-lg border border-gray-300 bg-white px-3 py-2 text-sm font-medium text-gray-500 hover:bg-gray-50 focus:z-20 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400 dark:hover:bg-gray-700"
>
<i class="fas fa-chevron-left text-xs"></i>
<span id="prev-indicator" class="htmx-indicator absolute top-0 right-0 w-4 h-4 -mt-1 -mr-1">
<i class="fas fa-spinner fa-spin text-xs"></i>
</span>
</button>
} else {
<span class="relative inline-flex items-center px-2 py-2 rounded-l-md border border-secondary-300 dark:border-secondary-700 bg-secondary-100 dark:bg-secondary-800 text-sm font-medium text-secondary-300 dark:text-secondary-600 cursor-not-allowed">
<span class="sr-only">Previous</span>
<i class="fas fa-chevron-left h-5 w-5"></i>
<span class="relative inline-flex items-center justify-center rounded-l-lg border border-gray-300 bg-gray-100 px-3 py-2 text-sm font-medium text-gray-400 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-600 cursor-not-allowed">
<i class="fas fa-chevron-left text-xs"></i>
</span>
}
@@ -189,17 +242,16 @@ templ HistoryContent(ctx context.Context, data HistoryData) {
hx-target="#history-content"
hx-vals={ fmt.Sprintf(`{"page": %d, "pageSize": %d, "search": "%s"}`, data.CurrentPage+1, data.PageSize, data.SearchTerm) }
hx-indicator="#next-indicator"
class="relative inline-flex items-center px-2 py-2 rounded-r-md border border-secondary-300 dark:border-secondary-600 bg-white dark:bg-secondary-700 text-sm font-medium text-secondary-500 dark:text-secondary-400 hover:bg-secondary-50 dark:hover:bg-secondary-600 transition-colors">
<span class="sr-only">Next</span>
<i class="fas fa-chevron-right h-5 w-5"></i>
<span id="next-indicator" class="htmx-indicator ml-1">
<i class="fas fa-spinner fa-spin"></i>
class="relative inline-flex items-center justify-center rounded-r-lg border border-gray-300 bg-white px-3 py-2 text-sm font-medium text-gray-500 hover:bg-gray-50 focus:z-20 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400 dark:hover:bg-gray-700"
>
<i class="fas fa-chevron-right text-xs"></i>
<span id="next-indicator" class="htmx-indicator absolute top-0 right-0 w-4 h-4 -mt-1 -mr-1">
<i class="fas fa-spinner fa-spin text-xs"></i>
</span>
</button>
} else {
<span class="relative inline-flex items-center px-2 py-2 rounded-r-md border border-secondary-300 dark:border-secondary-700 bg-secondary-100 dark:bg-secondary-800 text-sm font-medium text-secondary-300 dark:text-secondary-600 cursor-not-allowed">
<span class="sr-only">Next</span>
<i class="fas fa-chevron-right h-5 w-5"></i>
<span class="relative inline-flex items-center justify-center rounded-r-lg border border-gray-300 bg-gray-100 px-3 py-2 text-sm font-medium text-gray-400 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-600 cursor-not-allowed">
<i class="fas fa-chevron-right text-xs"></i>
</span>
}
</nav>
@@ -212,42 +264,45 @@ templ HistoryContent(ctx context.Context, data HistoryData) {
templ History(ctx context.Context, data HistoryData) {
@LayoutWithContext("Transfer History", ctx) {
<div id="history-page" class="py-6">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex items-center justify-between mb-8">
<h1 class="text-3xl font-bold text-secondary-900 dark:text-secondary-100">
<i class="fas fa-history mr-2 text-primary-600 dark:text-primary-400"></i>
<div id="history-container" style="min-height: 100vh;" class="bg-gray-50 dark:bg-gray-900">
<div class="pb-8 w-full">
<!-- Page Header -->
<div class="mb-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
<i class="fas fa-history w-6 h-6 mr-2 text-blue-500"></i>
Transfer History
</h1>
<div class="text-sm text-secondary-500 dark:text-secondary-400">
<div class="text-sm text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-gray-800 rounded-lg px-4 py-2 inline-flex items-center">
<i class="fas fa-info-circle mr-2"></i>
<span class="font-medium">Total: { fmt.Sprint(data.Total) } transfers</span>
</div>
</div>
<!-- Search and Pagination Controls -->
<div class="mt-4 flex flex-col sm:flex-row justify-between items-center bg-secondary-50 dark:bg-secondary-800 p-4 rounded-lg mb-6">
<form hx-get="/history" hx-target="#history-content" hx-indicator="#search-indicator" class="w-full sm:w-auto mb-4 sm:mb-0">
<div class="flex">
<div class="relative flex-grow">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-search text-secondary-400 dark:text-secondary-500"></i>
</div>
<input
type="text"
name="search"
value={ data.SearchTerm }
placeholder="Search by job name or status..."
class="block w-full pl-10 pr-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md leading-5 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-secondary-100 placeholder-secondary-500 dark:placeholder-secondary-400 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
/>
<input type="hidden" name="page" value="1" />
<input type="hidden" name="pageSize" value={ fmt.Sprint(data.PageSize) } />
<div class="mb-6 p-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:bg-gray-800 dark:border-gray-700">
<form hx-get="/history" hx-target="#history-content" hx-indicator="#search-indicator" class="flex flex-col sm:flex-row gap-4 items-start sm:items-center">
<div class="w-full sm:w-auto relative flex-grow">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-search text-gray-400 dark:text-gray-500"></i>
</div>
<input
type="text"
name="search"
value={ data.SearchTerm }
placeholder="Search by job name or status..."
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full pl-10 pr-3 py-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
/>
<input type="hidden" name="page" value="1" />
<input type="hidden" name="pageSize" value={ fmt.Sprint(data.PageSize) } />
</div>
<div class="flex gap-2">
<button
type="submit"
class="ml-3 inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 dark:bg-primary-500 dark:hover:bg-primary-600 transition-colors"
class="inline-flex items-center py-2.5 px-4 text-sm font-medium text-white bg-blue-700 rounded-lg border border-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800"
>
<span>Search</span>
<span id="search-indicator" class="htmx-indicator ml-1">
<i class="fas fa-search mr-2 -ml-1"></i>
Search
<span id="search-indicator" class="htmx-indicator ml-2">
<i class="fas fa-spinner fa-spin"></i>
</span>
</button>
@@ -257,77 +312,93 @@ templ History(ctx context.Context, data HistoryData) {
hx-target="#history-content"
hx-indicator="#clear-indicator"
hx-vals={ fmt.Sprintf(`{"pageSize": %d}`, data.PageSize) }
class="ml-2 inline-flex items-center px-4 py-2 border border-secondary-300 dark:border-secondary-600 text-sm font-medium rounded-md text-secondary-700 dark:text-secondary-200 bg-white dark:bg-secondary-700 hover:bg-secondary-50 dark:hover:bg-secondary-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 transition-colors"
class="py-2.5 px-5 text-sm font-medium text-gray-900 focus:outline-none bg-white rounded-lg border border-gray-200 hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-4 focus:ring-gray-200 dark:focus:ring-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700 inline-flex items-center"
>
<i class="fas fa-times mr-1"></i>
<i class="fas fa-times mr-2"></i>
Clear
<span id="clear-indicator" class="htmx-indicator ml-1">
<span id="clear-indicator" class="htmx-indicator ml-2">
<i class="fas fa-spinner fa-spin"></i>
</span>
</button>
}
</div>
<div class="ml-auto">
<label class="inline-flex items-center text-sm text-gray-700 dark:text-gray-300">
Show
<select
name="pageSize"
hx-get="/history"
hx-target="#history-content"
hx-include="[name='search']"
hx-indicator="#size-indicator"
class="mx-2 bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
>
<option value="10" selected?={ data.PageSize == 10 }>10</option>
<option value="25" selected?={ data.PageSize == 25 }>25</option>
<option value="50" selected?={ data.PageSize == 50 }>50</option>
<option value="100" selected?={ data.PageSize == 100 }>100</option>
</select>
entries
<span id="size-indicator" class="htmx-indicator ml-2">
<i class="fas fa-spinner fa-spin"></i>
</span>
</label>
</div>
</form>
<div class="flex items-center">
<span class="text-sm text-secondary-700 dark:text-secondary-300">
Show
<select
name="pageSize"
hx-get="/history"
hx-target="#history-content"
hx-include="[name='search']"
hx-indicator="#size-indicator"
class="mx-1 rounded-md border-secondary-300 dark:border-secondary-600 py-1 text-base bg-white dark:bg-secondary-700 text-secondary-900 dark:text-secondary-100 focus:border-primary-500 focus:outline-none focus:ring-primary-500 sm:text-sm"
>
<option value="10" selected?={ data.PageSize == 10 }>10</option>
<option value="25" selected?={ data.PageSize == 25 }>25</option>
<option value="50" selected?={ data.PageSize == 50 }>50</option>
<option value="100" selected?={ data.PageSize == 100 }>100</option>
</select>
entries
<span id="size-indicator" class="htmx-indicator ml-1">
<i class="fas fa-spinner fa-spin"></i>
</span>
</span>
</div>
</div>
<div id="history-content" class="mt-6">
@HistoryContent(ctx, data)
</div>
</div>
<script>
// Set dark background color if in dark mode
if (document.documentElement.classList.contains('dark')) {
document.getElementById('history-container').style.backgroundColor = '#111827';
}
// Add event listener for theme changes
document.addEventListener('DOMContentLoaded', function() {
const themeToggle = document.getElementById('theme-toggle');
if (themeToggle) {
themeToggle.addEventListener('click', function() {
setTimeout(function() {
const isDark = document.documentElement.classList.contains('dark');
document.getElementById('history-container').style.backgroundColor = isDark ? '#111827' : 'rgb(249, 250, 251)';
}, 50);
});
}
});
</script>
</div>
}
}
templ pageNumbers(currentPage int, totalPages int, pageSize int, searchTerm string) {
// Show at most 5 page numbers with the current page in the middle when possible
{{startPage := max(1, currentPage-2)}}
{{endPage := min(totalPages, startPage+4)}}
// Adjust startPage if we're near the end
if endPage - startPage < 4 && startPage > 1 {
startPage = max(1, endPage-4)
}
for i := startPage; i <= endPage; i++ {
for i := 1; i <= totalPages; i++ {
if i == currentPage {
<span aria-current="page" class="relative inline-flex items-center px-4 py-2 border border-primary-500 bg-primary-50 dark:bg-primary-900/30 text-sm font-medium text-primary-600 dark:text-primary-400">
<span aria-current="page" class="relative z-10 inline-flex items-center bg-blue-600 px-4 py-2 text-sm font-medium text-white focus:z-20 border border-blue-600">
{ fmt.Sprint(i) }
</span>
} else {
} else if i == 1 || i == totalPages || (i >= currentPage-2 && i <= currentPage+2) {
<button
hx-get="/history"
hx-target="#history-content"
hx-vals={ fmt.Sprintf(`{"page": %d, "pageSize": %d, "search": "%s"}`, i, pageSize, searchTerm) }
hx-indicator="#page-indicator"
class="relative inline-flex items-center px-4 py-2 border border-secondary-300 dark:border-secondary-600 bg-white dark:bg-secondary-700 text-sm font-medium text-secondary-700 dark:text-secondary-300 hover:bg-secondary-50 dark:hover:bg-secondary-600 transition-colors">
hx-indicator={ fmt.Sprintf("#page-indicator-%d", i) }
class="relative inline-flex items-center px-4 py-2 text-sm font-medium text-gray-500 hover:bg-gray-50 focus:z-20 border border-gray-300 bg-white dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400 dark:hover:bg-gray-700 relative"
>
{ fmt.Sprint(i) }
<span id="page-indicator" class="htmx-indicator ml-1">
<i class="fas fa-spinner fa-spin"></i>
<span id={ fmt.Sprintf("page-indicator-%d", i) } class="htmx-indicator absolute top-0 right-0 w-4 h-4 -mt-1 -mr-1">
<i class="fas fa-spinner fa-spin text-xs"></i>
</span>
</button>
} else if i == currentPage-3 || i == currentPage+3 {
<span class="relative inline-flex items-center px-4 py-2 text-sm font-medium text-gray-700 border border-gray-300 bg-white dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400">
...
</span>
}
}
}
+124 -99
View File
@@ -6,130 +6,155 @@ import (
templ Home(ctx context.Context) {
@LayoutWithContext("Home", ctx) {
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div class="text-center">
<h1 class="text-4xl font-bold text-secondary-900 dark:text-secondary-100 mb-4">
<i class="fas fa-exchange-alt text-primary-600 dark:text-primary-400 mr-3"></i>
<div class="p-4 md:p-6 2xl:p-10">
<!-- Page Header -->
<div class="mb-8 text-center">
<h1 class="text-3xl font-bold text-gray-900 dark:text-white flex items-center justify-center">
<i class="fas fa-exchange-alt w-8 h-8 mr-3 text-blue-500"></i>
Welcome to GoMFT
</h1>
<p class="text-xl text-secondary-600 dark:text-secondary-300 mb-12">A modern managed file transfer solution</p>
if isLoggedIn(ctx) {
<div class="space-y-8">
<!-- Feature Cards -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<div class="card hover:shadow-lg transition-shadow duration-300">
<div class="p-6">
<div class="rounded-full bg-primary-100 dark:bg-primary-900 p-4 w-16 h-16 flex items-center justify-center mb-4 mx-auto">
<i class="fas fa-cogs text-primary-600 dark:text-primary-300 text-2xl"></i>
</div>
<h2 class="text-2xl font-semibold text-secondary-900 dark:text-secondary-100 mb-4 text-center">Transfer Configs</h2>
<p class="text-secondary-600 dark:text-secondary-400 mb-6 text-center">Set up and manage file transfer configurations with ease.</p>
<div class="flex justify-center">
<a href="/configs" class="btn-primary inline-flex items-center">
<span>View Configs</span>
<i class="fas fa-arrow-right ml-2"></i>
</a>
</div>
</div>
</div>
<div class="card hover:shadow-lg transition-shadow duration-300">
<div class="p-6">
<div class="rounded-full bg-blue-100 dark:bg-blue-900 p-4 w-16 h-16 flex items-center justify-center mb-4 mx-auto">
<i class="fas fa-calendar-alt text-blue-600 dark:text-blue-300 text-2xl"></i>
</div>
<h2 class="text-2xl font-semibold text-secondary-900 dark:text-secondary-100 mb-4 text-center">Schedule Jobs</h2>
<p class="text-secondary-600 dark:text-secondary-400 mb-6 text-center">Create and monitor automated file transfer jobs.</p>
<div class="flex justify-center">
<a href="/jobs" class="btn-primary inline-flex items-center">
<span>View Jobs</span>
<i class="fas fa-arrow-right ml-2"></i>
</a>
</div>
</div>
</div>
<div class="card hover:shadow-lg transition-shadow duration-300">
<div class="p-6">
<div class="rounded-full bg-green-100 dark:bg-green-900 p-4 w-16 h-16 flex items-center justify-center mb-4 mx-auto">
<i class="fas fa-history text-green-600 dark:text-green-300 text-2xl"></i>
</div>
<h2 class="text-2xl font-semibold text-secondary-900 dark:text-secondary-100 mb-4 text-center">Track History</h2>
<p class="text-secondary-600 dark:text-secondary-400 mb-6 text-center">View detailed history of all file transfer operations.</p>
<div class="flex justify-center">
<a href="/history" class="btn-primary inline-flex items-center">
<span>View History</span>
<i class="fas fa-arrow-right ml-2"></i>
</a>
</div>
<p class="text-lg text-gray-500 dark:text-gray-400 mt-2">
A modern managed file transfer solution
</p>
</div>
if isLoggedIn(ctx) {
<!-- Logged-in User View -->
<div class="space-y-8">
<!-- Feature Cards -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<!-- Transfer Configs Card -->
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 hover:shadow-lg transition-shadow duration-300">
<div class="p-6 text-center">
<div class="inline-flex h-14 w-14 flex-shrink-0 items-center justify-center rounded-full bg-blue-100 mb-4 dark:bg-blue-900">
<i class="fas fa-cogs text-blue-600 dark:text-blue-300 text-2xl"></i>
</div>
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-3">Transfer Configs</h2>
<p class="text-gray-500 dark:text-gray-400 mb-5">Set up and manage file transfer configurations with ease.</p>
<a href="/configs" class="inline-flex items-center px-4 py-2 text-sm font-medium text-center text-white bg-blue-700 rounded-lg hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800">
View Configs
<i class="fas fa-arrow-right ml-2"></i>
</a>
</div>
</div>
<!-- Quick Access Section -->
<div class="mt-12">
<h2 class="text-2xl font-bold text-secondary-900 dark:text-secondary-100 mb-6">
<i class="fas fa-bolt text-primary-600 dark:text-primary-400 mr-2"></i>
Quick Access
</h2>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<a href="/dashboard" class="card p-4 hover:bg-secondary-50 dark:hover:bg-secondary-800 flex items-center transition-colors">
<i class="fas fa-tachometer-alt text-primary-600 dark:text-primary-400 text-xl mr-3"></i>
<span class="text-secondary-900 dark:text-secondary-100">Dashboard</span>
<!-- Schedule Jobs Card -->
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 hover:shadow-lg transition-shadow duration-300">
<div class="p-6 text-center">
<div class="inline-flex h-14 w-14 flex-shrink-0 items-center justify-center rounded-full bg-indigo-100 mb-4 dark:bg-indigo-900">
<i class="fas fa-calendar-alt text-indigo-600 dark:text-indigo-300 text-2xl"></i>
</div>
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-3">Schedule Jobs</h2>
<p class="text-gray-500 dark:text-gray-400 mb-5">Create and monitor automated file transfer jobs.</p>
<a href="/jobs" class="inline-flex items-center px-4 py-2 text-sm font-medium text-center text-white bg-blue-700 rounded-lg hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800">
View Jobs
<i class="fas fa-arrow-right ml-2"></i>
</a>
<a href="/configs/new" class="card p-4 hover:bg-secondary-50 dark:hover:bg-secondary-800 flex items-center transition-colors">
<i class="fas fa-plus-circle text-primary-600 dark:text-primary-400 text-xl mr-3"></i>
<span class="text-secondary-900 dark:text-secondary-100">New Config</span>
</a>
<a href="/jobs/new" class="card p-4 hover:bg-secondary-50 dark:hover:bg-secondary-800 flex items-center transition-colors">
<i class="fas fa-play-circle text-primary-600 dark:text-primary-400 text-xl mr-3"></i>
<span class="text-secondary-900 dark:text-secondary-100">New Job</span>
</a>
<a href="/profile" class="card p-4 hover:bg-secondary-50 dark:hover:bg-secondary-800 flex items-center transition-colors">
<i class="fas fa-user-circle text-primary-600 dark:text-primary-400 text-xl mr-3"></i>
<span class="text-secondary-900 dark:text-secondary-100">Profile</span>
</div>
</div>
<!-- Track History Card -->
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 hover:shadow-lg transition-shadow duration-300">
<div class="p-6 text-center">
<div class="inline-flex h-14 w-14 flex-shrink-0 items-center justify-center rounded-full bg-green-100 mb-4 dark:bg-green-900">
<i class="fas fa-history text-green-600 dark:text-green-300 text-2xl"></i>
</div>
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-3">Track History</h2>
<p class="text-gray-500 dark:text-gray-400 mb-5">View detailed history of all file transfer operations.</p>
<a href="/history" class="inline-flex items-center px-4 py-2 text-sm font-medium text-center text-white bg-blue-700 rounded-lg hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800">
View History
<i class="fas fa-arrow-right ml-2"></i>
</a>
</div>
</div>
</div>
} else {
<div class="max-w-md mx-auto card p-8 hover:shadow-lg transition-shadow duration-300">
<div class="rounded-full bg-primary-100 dark:bg-primary-900 p-4 w-20 h-20 flex items-center justify-center mb-6 mx-auto">
<i class="fas fa-sign-in-alt text-primary-600 dark:text-primary-300 text-3xl"></i>
<!-- Quick Access Section -->
<div class="mt-10">
<div class="flex items-center mb-4">
<i class="fas fa-bolt text-blue-500 w-6 h-6 mr-2"></i>
<h2 class="text-xl font-bold text-gray-900 dark:text-white">Quick Access</h2>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<a href="/dashboard" class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 p-4 flex items-center transition-colors hover:bg-gray-50 dark:hover:bg-gray-700">
<div class="inline-flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full bg-blue-100 mr-3 dark:bg-blue-900">
<i class="fas fa-tachometer-alt text-blue-600 dark:text-blue-300"></i>
</div>
<span class="font-medium text-gray-900 dark:text-white">Dashboard</span>
</a>
<a href="/configs/new" class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 p-4 flex items-center transition-colors hover:bg-gray-50 dark:hover:bg-gray-700">
<div class="inline-flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full bg-blue-100 mr-3 dark:bg-blue-900">
<i class="fas fa-plus-circle text-blue-600 dark:text-blue-300"></i>
</div>
<span class="font-medium text-gray-900 dark:text-white">New Config</span>
</a>
<a href="/jobs/new" class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 p-4 flex items-center transition-colors hover:bg-gray-50 dark:hover:bg-gray-700">
<div class="inline-flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full bg-blue-100 mr-3 dark:bg-blue-900">
<i class="fas fa-play-circle text-blue-600 dark:text-blue-300"></i>
</div>
<span class="font-medium text-gray-900 dark:text-white">New Job</span>
</a>
<a href="/profile" class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 p-4 flex items-center transition-colors hover:bg-gray-50 dark:hover:bg-gray-700">
<div class="inline-flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full bg-blue-100 mr-3 dark:bg-blue-900">
<i class="fas fa-user-circle text-blue-600 dark:text-blue-300"></i>
</div>
<span class="font-medium text-gray-900 dark:text-white">Profile</span>
</a>
</div>
</div>
</div>
} else {
<!-- Login Card for Non-Logged-In Users -->
<div class="max-w-md mx-auto">
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800">
<div class="p-6 text-center">
<div class="inline-flex items-center justify-center w-32 h-32 mb-6">
<img src="/static/img/logo.svg" alt="GoMFT Logo" class="w-full h-full"/>
</div>
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-3">Get Started</h2>
<p class="text-gray-500 dark:text-gray-400 mb-6">Log in to access the file transfer management system.</p>
<a href="/login" class="inline-flex items-center px-5 py-2.5 text-sm font-medium text-center text-white bg-blue-700 rounded-lg hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800">
<i class="fas fa-lock mr-2"></i>
Log In
</a>
</div>
<h2 class="text-2xl font-semibold text-secondary-900 dark:text-secondary-100 mb-4">Get Started</h2>
<p class="text-secondary-600 dark:text-secondary-400 mb-6">Log in to access the file transfer management system.</p>
<a href="/login" class="btn-primary inline-flex items-center px-8 py-3">
<i class="fas fa-lock mr-2"></i>
<span>Log In</span>
</a>
<!-- Features Preview -->
<div class="mt-12 pt-8 border-t border-secondary-200 dark:border-secondary-700">
<h3 class="text-lg font-medium text-secondary-900 dark:text-secondary-100 mb-4">Key Features</h3>
<ul class="space-y-3 text-left">
<div class="p-6 bg-gray-50 dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 rounded-b-lg">
<h3 class="text-base font-medium text-gray-900 dark:text-white mb-4">Key Features</h3>
<ul class="space-y-3">
<li class="flex items-start">
<i class="fas fa-check-circle text-green-500 mt-1 mr-2"></i>
<span class="text-secondary-600 dark:text-secondary-400">Secure file transfers with encryption</span>
<i class="fas fa-check-circle text-green-500 mt-0.5 mr-2 flex-shrink-0"></i>
<span class="text-gray-500 dark:text-gray-400">Secure file transfers with encryption</span>
</li>
<li class="flex items-start">
<i class="fas fa-check-circle text-green-500 mt-1 mr-2"></i>
<span class="text-secondary-600 dark:text-secondary-400">Automated scheduling and monitoring</span>
<i class="fas fa-check-circle text-green-500 mt-0.5 mr-2 flex-shrink-0"></i>
<span class="text-gray-500 dark:text-gray-400">Automated scheduling and monitoring</span>
</li>
<li class="flex items-start">
<i class="fas fa-check-circle text-green-500 mt-1 mr-2"></i>
<span class="text-secondary-600 dark:text-secondary-400">Comprehensive transfer history tracking</span>
<i class="fas fa-check-circle text-green-500 mt-0.5 mr-2 flex-shrink-0"></i>
<span class="text-gray-500 dark:text-gray-400">Comprehensive transfer history tracking</span>
</li>
<li class="flex items-start">
<i class="fas fa-check-circle text-green-500 mt-1 mr-2"></i>
<span class="text-secondary-600 dark:text-secondary-400">User-friendly interface with dark mode support</span>
<i class="fas fa-check-circle text-green-500 mt-0.5 mr-2 flex-shrink-0"></i>
<span class="text-gray-500 dark:text-gray-400">User-friendly interface with dark mode support</span>
</li>
</ul>
</div>
</div>
}
</div>
<!-- Help Notice -->
<div class="mt-6">
<div class="p-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900/50 dark:text-blue-400 border border-blue-200 dark:border-blue-800 flex items-start">
<i class="fas fa-info-circle flex-shrink-0 mr-3 mt-0.5"></i>
<div>
<h4 class="text-sm font-medium mb-1">About GoMFT</h4>
<p>GoMFT is a modern, secure file transfer solution that allows you to effortlessly move files between different systems and storage providers with robust tracking and monitoring.</p>
</div>
</div>
</div>
</div>
}
</div>
}
}
+1739 -251
View File
File diff suppressed because it is too large Load Diff
+156 -206
View File
@@ -15,221 +15,171 @@ type JobRunDetailsData struct {
templ JobRunDetails(ctx context.Context, data JobRunDetailsData) {
@LayoutWithContext("Job Run Details", ctx) {
<div class="py-6">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="mb-6">
<a href="/dashboard" class="text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300">
<i class="fas fa-arrow-left mr-1"></i> Back to Dashboard
</a>
@JobRunDetailsContent(ctx, data)
}
}
// JobRunDetailsContent is the same as JobRunDetails but without the layout wrapper
// This is used for testing
templ JobRunDetailsContent(ctx context.Context, data JobRunDetailsData) {
<div class="py-6 px-4 mx-auto max-w-7xl lg:px-8">
<div class="mb-6">
<a href="/dashboard" class="text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300 inline-flex items-center">
<i class="fas fa-arrow-left mr-2"></i> Back to Dashboard
</a>
</div>
<div class="flex items-center justify-between mb-8">
<h1 class="text-3xl font-bold text-gray-900 dark:text-white flex items-center">
<i class="fas fa-file-alt mr-3 text-blue-600 dark:text-blue-400"></i>
Job Run Details
</h1>
</div>
<!-- Job Run Information Card -->
<div class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-sm mb-8">
<div class="px-4 py-5 sm:px-6 border-b border-gray-200 dark:border-gray-700">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<h3 class="text-lg font-medium text-gray-900 dark:text-white">{ data.Job.Name }</h3>
if data.JobHistory.Status == "completed" {
<span class="px-3 py-1 text-sm font-medium rounded-full bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-300 inline-flex items-center">
<i class="fas fa-check mr-2"></i> Completed
</span>
} else if data.JobHistory.Status == "failed" {
<span class="px-3 py-1 text-sm font-medium rounded-full bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-300 inline-flex items-center">
<i class="fas fa-times mr-2"></i> Failed
</span>
} else {
<span class="px-3 py-1 text-sm font-medium rounded-full bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-300 inline-flex items-center">
<i class="fas fa-spinner fa-spin mr-2"></i> Running
</span>
}
</div>
<div class="flex items-center justify-between mb-8">
<h1 class="text-3xl font-bold text-secondary-900 dark:text-secondary-100">
<i class="fas fa-file-alt mr-2 text-primary-600 dark:text-primary-400"></i>
Job Run Details
</h1>
</div>
<!-- Job Run Information Card -->
<div class="bg-white dark:bg-secondary-800 shadow overflow-hidden rounded-lg mb-8">
<div class="px-4 py-5 sm:px-6 border-b border-secondary-200 dark:border-secondary-700">
<div class="flex items-center justify-between">
<h3 class="text-lg leading-6 font-medium text-secondary-900 dark:text-secondary-100">
if data.Job.Name != "" {
{ data.Job.Name }
} else {
{ data.Config.Name }
}
</h3>
if data.JobHistory.Status == "completed" {
<span class="px-3 py-1 inline-flex text-sm leading-5 font-semibold rounded-full bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-300">
<i class="fas fa-check mr-1"></i> Completed
</span>
} else if data.JobHistory.Status == "failed" {
<span class="px-3 py-1 inline-flex text-sm leading-5 font-semibold rounded-full bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-300">
<i class="fas fa-times mr-1"></i> Failed
</span>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Config: { data.Config.Name }</p>
</div>
<div class="px-4 py-5 sm:p-6">
<dl class="grid grid-cols-1 gap-x-6 gap-y-6 sm:grid-cols-2 lg:grid-cols-3">
<div class="sm:col-span-1">
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400 flex items-center mb-1">
<i class="fas fa-calendar-alt mr-2 text-gray-400 dark:text-gray-500"></i> Start Time
</dt>
<dd class="text-sm text-gray-900 dark:text-white">
{ data.JobHistory.StartTime.Format("Jan 02, 2006 15:04:05") }
</dd>
</div>
<div class="sm:col-span-1">
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400 flex items-center mb-1">
<i class="fas fa-calendar-check mr-2 text-gray-400 dark:text-gray-500"></i> End Time
</dt>
<dd class="text-sm text-gray-900 dark:text-white">
if data.JobHistory.EndTime != nil {
{ data.JobHistory.EndTime.Format("Jan 02, 2006 15:04:05") }
} else {
<span class="px-3 py-1 inline-flex text-sm leading-5 font-semibold rounded-full bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-300">
<i class="fas fa-sync-alt mr-1"></i> { data.JobHistory.Status }
</span>
<span class="italic text-gray-500 dark:text-gray-400">In progress</span>
}
</div>
if data.Job.Name != "" && data.Job.Name != data.Config.Name {
<p class="mt-1 text-sm text-secondary-500 dark:text-secondary-400">
Config: { data.Config.Name }
</p>
}
</dd>
</div>
<div class="px-4 py-5 sm:p-6">
<dl class="grid grid-cols-1 gap-x-4 gap-y-6 sm:grid-cols-2 lg:grid-cols-3">
<div class="sm:col-span-1">
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">
<i class="fas fa-calendar-alt mr-1"></i> Start Time
</dt>
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100">
{ data.JobHistory.StartTime.Format("Jan 02, 2006 15:04:05") }
</dd>
</div>
<div class="sm:col-span-1">
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">
<i class="fas fa-calendar-check mr-1"></i> End Time
</dt>
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100">
if data.JobHistory.EndTime != nil {
{ data.JobHistory.EndTime.Format("Jan 02, 2006 15:04:05") }
} else {
<span class="text-secondary-500 dark:text-secondary-400">Still running...</span>
}
</dd>
</div>
<div class="sm:col-span-1">
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">
<i class="fas fa-clock mr-1"></i> Duration
</dt>
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100">
if data.JobHistory.EndTime != nil {
{ formatDuration(data.JobHistory.EndTime.Sub(data.JobHistory.StartTime)) }
} else {
{ formatDuration(time.Since(data.JobHistory.StartTime)) } (ongoing)
}
</dd>
</div>
<div class="sm:col-span-1">
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">
<i class="fas fa-upload mr-1"></i> Data Transferred
</dt>
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100">
{ formatBytes(data.JobHistory.BytesTransferred) }
</dd>
</div>
<div class="sm:col-span-1">
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">
<i class="fas fa-file mr-1"></i> Files Transferred
</dt>
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100">
{ fmt.Sprint(data.JobHistory.FilesTransferred) }
</dd>
</div>
<div class="sm:col-span-1">
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">
<i class="fas fa-calendar-day mr-1"></i> Job Schedule
</dt>
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100">
{ data.Job.Schedule }
</dd>
</div>
</dl>
</div>
</div>
<!-- Transfer Configuration Details -->
<div class="bg-white dark:bg-secondary-800 shadow overflow-hidden rounded-lg mb-8">
<div class="px-4 py-5 sm:px-6 border-b border-secondary-200 dark:border-secondary-700">
<h3 class="text-lg leading-6 font-medium text-secondary-900 dark:text-secondary-100">
<i class="fas fa-cog mr-2 text-primary-600 dark:text-primary-400"></i>
Transfer Configuration
</h3>
</div>
<div class="px-4 py-5 sm:p-6">
<dl class="grid grid-cols-1 gap-x-4 gap-y-6 sm:grid-cols-2">
<div class="sm:col-span-1">
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">Source Type</dt>
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100">
<span class="px-2 py-1 text-xs font-medium rounded bg-secondary-100 dark:bg-secondary-700">
{ data.Config.SourceType }
</span>
</dd>
</div>
<div class="sm:col-span-1">
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">Destination Type</dt>
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100">
<span class="px-2 py-1 text-xs font-medium rounded bg-secondary-100 dark:bg-secondary-700">
{ data.Config.DestinationType }
</span>
</dd>
</div>
<div class="sm:col-span-1">
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">Source Path</dt>
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100 font-mono bg-secondary-50 dark:bg-secondary-900 p-2 rounded">
if data.Config.SourceType == "sftp" {
{ data.Config.SourceUser } `@` { data.Config.SourceHost }:{ data.Config.SourcePath }
} else {
{ data.Config.SourcePath }
}
</dd>
</div>
<div class="sm:col-span-1">
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">Destination Path</dt>
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100 font-mono bg-secondary-50 dark:bg-secondary-900 p-2 rounded">
if data.Config.DestinationType == "sftp" {
data.Config.DestUser@data.Config.DestHost:data.Config.DestinationPath
} else {
{ data.Config.DestinationPath }
}
</dd>
</div>
<div class="sm:col-span-1">
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">File Pattern</dt>
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100 font-mono">
{ data.Config.FilePattern }
</dd>
</div>
if data.Config.ArchiveEnabled {
<div class="sm:col-span-1">
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">Archive Path</dt>
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100 font-mono bg-secondary-50 dark:bg-secondary-900 p-2 rounded">
{ data.Config.ArchivePath }
</dd>
</div>
<div class="sm:col-span-1">
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400 flex items-center mb-1">
<i class="fas fa-clock mr-2 text-gray-400 dark:text-gray-500"></i> Duration
</dt>
<dd class="text-sm text-gray-900 dark:text-white">
if data.JobHistory.EndTime != nil {
{ data.JobHistory.EndTime.Sub(data.JobHistory.StartTime).String() }
} else {
<span class="italic text-gray-500 dark:text-gray-400">In progress</span>
}
</dl>
</dd>
</div>
</div>
<!-- Error Information (if any) -->
if data.JobHistory.ErrorMessage != "" {
<div class="bg-white dark:bg-secondary-800 shadow overflow-hidden rounded-lg mb-8 border-l-4 border-red-500">
<div class="px-4 py-5 sm:px-6 border-b border-secondary-200 dark:border-secondary-700">
<h3 class="text-lg leading-6 font-medium text-red-600 dark:text-red-400">
<i class="fas fa-exclamation-triangle mr-2"></i>
Error Details
</h3>
</div>
<div class="px-4 py-5 sm:p-6 bg-red-50 dark:bg-red-900/20">
<pre class="text-sm text-red-600 dark:text-red-400 whitespace-pre-wrap font-mono">{ data.JobHistory.ErrorMessage }</pre>
</div>
<div class="sm:col-span-1">
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400 flex items-center mb-1">
<i class="fas fa-upload mr-2 text-gray-400 dark:text-gray-500"></i> Data Transferred
</dt>
<dd class="text-sm text-gray-900 dark:text-white">
{ formatBytes(data.JobHistory.BytesTransferred) }
</dd>
</div>
}
<!-- Action Buttons -->
<div class="flex flex-col sm:flex-row gap-4 mt-8">
<a href="/jobs" class="btn-secondary text-center flex items-center justify-center">
<i class="fas fa-list-ul mr-2"></i>
View All Jobs
</a>
<a href={ templ.SafeURL(fmt.Sprintf("/jobs/%d", data.Job.ID)) } class="btn-primary text-center flex items-center justify-center">
<i class="fas fa-edit mr-2"></i>
Edit Job
</a>
</div>
<div class="sm:col-span-1">
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400 flex items-center mb-1">
<i class="fas fa-file mr-2 text-gray-400 dark:text-gray-500"></i> Files Transferred
</dt>
<dd class="text-sm text-gray-900 dark:text-white">
{ fmt.Sprintf("%d files", data.JobHistory.FilesTransferred) }
</dd>
</div>
<div class="sm:col-span-1">
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400 flex items-center mb-1">
<i class="fas fa-calendar-day mr-2 text-gray-400 dark:text-gray-500"></i> Job Schedule
</dt>
<dd class="text-sm text-gray-900 dark:text-white">
{ data.Job.Schedule }
</dd>
</div>
</dl>
</div>
</div>
}
<!-- Transfer Configuration Details -->
<div class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-sm mb-8">
<div class="px-4 py-5 sm:px-6 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-medium text-gray-900 dark:text-white flex items-center">
<i class="fas fa-cog mr-3 text-blue-600 dark:text-blue-400"></i>
Transfer Configuration
</h3>
</div>
<div class="px-4 py-5 sm:p-6">
<dl class="grid grid-cols-1 gap-x-6 gap-y-6 sm:grid-cols-2">
<div class="sm:col-span-1">
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">Source Type</dt>
<dd class="text-sm text-gray-900 dark:text-white">
<span class="px-2.5 py-0.5 text-xs font-medium rounded bg-gray-100 dark:bg-gray-700">{ data.Config.SourceType }</span>
</dd>
</div>
<div class="sm:col-span-1">
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">Destination Type</dt>
<dd class="text-sm text-gray-900 dark:text-white">
<span class="px-2.5 py-0.5 text-xs font-medium rounded bg-gray-100 dark:bg-gray-700">{ data.Config.DestinationType }</span>
</dd>
</div>
<div class="sm:col-span-1">
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">Source Path</dt>
<dd class="text-sm text-gray-900 dark:text-white font-mono bg-gray-50 dark:bg-gray-900 p-2 rounded-lg border border-gray-200 dark:border-gray-700">{ data.Config.SourcePath }</dd>
</div>
<div class="sm:col-span-1">
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">Destination Path</dt>
<dd class="text-sm text-gray-900 dark:text-white font-mono bg-gray-50 dark:bg-gray-900 p-2 rounded-lg border border-gray-200 dark:border-gray-700">{ data.Config.DestinationPath }</dd>
</div>
<div class="sm:col-span-1">
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">File Pattern</dt>
<dd class="text-sm text-gray-900 dark:text-white font-mono">{ data.Config.FilePattern }</dd>
</div>
</dl>
</div>
</div>
<!-- Error Information (if any) -->
if data.JobHistory.Status == "failed" && data.JobHistory.ErrorMessage != "" {
<div class="p-4 mb-8 text-red-800 border-l-4 border-red-300 bg-red-50 dark:bg-red-900/20 dark:text-red-400 dark:border-red-800 rounded-lg">
<div class="flex items-center mb-2">
<i class="fas fa-exclamation-triangle flex-shrink-0 mr-2 text-red-600 dark:text-red-500"></i>
<h3 class="text-lg font-medium">Error Information</h3>
</div>
<div class="mt-2">
<pre class="text-sm whitespace-pre-wrap font-mono p-3 bg-white dark:bg-gray-900 rounded-lg border border-red-200 dark:border-red-800">{ data.JobHistory.ErrorMessage }</pre>
</div>
</div>
}
<!-- Action Buttons -->
<div class="flex flex-col sm:flex-row gap-4 mt-8">
<a href="/jobs" class="text-gray-900 bg-white border border-gray-300 focus:outline-none hover:bg-gray-100 focus:ring-4 focus:ring-gray-200 font-medium rounded-lg text-sm px-5 py-2.5 dark:bg-gray-800 dark:text-white dark:border-gray-600 dark:hover:bg-gray-700 dark:hover:border-gray-600 dark:focus:ring-gray-700 inline-flex items-center justify-center">
<i class="fas fa-list-ul mr-2"></i> View All Jobs
</a>
<a href={ templ.SafeURL(fmt.Sprintf("/jobs/%d", data.Job.ID)) } class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800 inline-flex items-center justify-center">
<i class="fas fa-edit mr-2"></i> Edit Job
</a>
</div>
</div>
}
// formatDuration formats a duration in a human-readable way
+279 -185
View File
@@ -6,55 +6,46 @@ import (
"github.com/starfleetcptn/gomft/internal/db"
)
// Dialog component for confirmation dialogs - copied from admin_tools.templ
// Dialog component for confirmation dialogs using Flowbite modal
templ JobDialog(id string, title string, message string, confirmClass string, confirmText string, action string, jobID uint, jobName string) {
<div id={ id } class="hidden fixed inset-0 bg-secondary-900/50 dark:bg-secondary-900/80 backdrop-blur-sm z-50 flex items-center justify-center">
<div class="bg-white dark:bg-secondary-800 rounded-lg shadow-xl max-w-md w-full mx-4 overflow-hidden">
<div class="px-6 pt-5 pb-3 text-center">
<div class="flex justify-center mb-2">
<i class="fas fa-exclamation-triangle text-yellow-400 text-3xl"></i>
<div id={ id } tabindex="-1" aria-hidden="true" class="hidden fixed top-0 right-0 left-0 z-50 justify-center items-center w-full md:inset-0 h-[calc(100%-1rem)] max-h-full">
<!-- Backdrop -->
<div id={ fmt.Sprintf("%s-backdrop", id) } class="fixed inset-0 bg-gray-900/50 dark:bg-gray-900/80 backdrop-blur-sm"></div>
<!-- Modal content -->
<div class="relative p-4 w-full max-w-md max-h-full mx-auto">
<div class="relative bg-white rounded-lg shadow dark:bg-gray-700">
<div class="p-6 text-center">
if action == "delete" {
<i class="fas fa-trash-alt text-red-400 text-3xl mb-4"></i>
} else {
<i class="fas fa-exclamation-triangle text-yellow-400 text-3xl mb-4"></i>
}
<h3 class="mb-5 text-lg font-normal text-gray-500 dark:text-gray-400">{ message }</h3>
<button
type="button"
class={ confirmClass }
hx-delete={ fmt.Sprintf("/jobs/%d", jobID) }
hx-target="closest li"
hx-swap="delete"
data-job-name={ jobName }
data-job-id={ fmt.Sprint(jobID) }
id={ fmt.Sprintf("delete-btn-%d", jobID) }
onclick={ triggerJobDelete(id, jobID, jobName) }>
{ confirmText }
</button>
<button type="button" onclick={ closeModal(id) } class="text-gray-500 bg-white hover:bg-gray-100 focus:ring-4 focus:outline-none focus:ring-gray-200 rounded-lg border border-gray-200 text-sm font-medium px-5 py-2.5 hover:text-gray-900 focus:z-10 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-500 dark:hover:text-white dark:hover:bg-gray-600 dark:focus:ring-gray-600">
Cancel
</button>
</div>
<h3 class="text-xl font-medium text-secondary-900 dark:text-secondary-100">
{ title }
</h3>
</div>
<div class="px-6 py-4 text-center">
<p class="text-secondary-700 dark:text-secondary-300">
{ message }
</p>
</div>
<div class="px-6 py-4 flex justify-end space-x-3">
<button type="button" class="btn-secondary" onclick={ hideJobDialog(id) }>
Cancel
</button>
<button
type="button"
class={ confirmClass }
hx-delete={ fmt.Sprintf("/jobs/%d", jobID) }
hx-target="closest li"
hx-swap="delete"
data-job-name={ jobName }
data-job-id={ fmt.Sprint(jobID) }
id={ fmt.Sprintf("delete-btn-%d", jobID) }
onclick={ triggerJobDelete(id, jobID, jobName) }>
{ confirmText }
</button>
</div>
</div>
</div>
}
script hideJobDialog(id string) {
document.getElementById(id).classList.add("hidden");
}
script showJobDialog(id string) {
document.getElementById(id).classList.remove("hidden");
}
script triggerJobDelete(dialogId string, jobID uint, jobName string) {
// Hide the dialog
document.getElementById(dialogId).classList.add("hidden");
document.getElementById(dialogId).classList.remove("flex");
// Add debugging info
console.log(`Job deletion triggered for: ${jobName} (ID: ${jobID})`);
@@ -70,42 +61,89 @@ script triggerJobDelete(dialogId string, jobID uint, jobName string) {
}
type JobsData struct {
Jobs []db.Job
Jobs []db.Job
ConfigCount map[uint]int // Maps job ID to number of configs
}
templ Jobs(ctx context.Context, data JobsData) {
@LayoutWithContext("Transfer Jobs", ctx) {
@LayoutWithContext("Scheduled Jobs", ctx) {
<!-- Status and Error Messages -->
<div id="toast-container" class="fixed top-5 right-5 z-50 flex flex-col gap-2"></div>
<script>
// Debug notification system
console.log("Jobs template loaded, setting up notification system");
// Create a global notyf instance immediately
window.notyf = new Notyf({
duration: 3000,
position: {
x: 'right',
y: 'top',
},
types: [
{
type: 'success',
background: '#38c172',
icon: {
className: 'fas fa-check-circle',
tagName: 'i'
}
},
{
type: 'error',
background: '#e3342f',
icon: {
className: 'fas fa-exclamation-circle',
tagName: 'i'
}
}
]
});
console.log("Notyf initialized:", window.notyf);
// Function to create and show a toast
function showToast(message, type) {
const toastContainer = document.getElementById('toast-container');
// Create toast element
const toast = document.createElement('div');
toast.id = 'toast-' + type + '-' + Date.now();
toast.className = 'flex items-center w-full max-w-xs p-4 mb-4 rounded-lg shadow text-gray-500 bg-white dark:text-gray-400 dark:bg-gray-800 transform translate-y-16 opacity-0 transition-all duration-300 ease-out';
toast.role = 'alert';
// Set toast content based on type
let iconClass, bgColorClass, textColorClass;
if (type === 'success') {
iconClass = 'text-green-500 bg-green-100 dark:bg-green-800 dark:text-green-200';
bgColorClass = 'text-green-500 dark:text-green-200';
textColorClass = 'text-green-500 dark:text-green-200';
} else if (type === 'error') {
iconClass = 'text-red-500 bg-red-100 dark:bg-red-800 dark:text-red-200';
bgColorClass = 'text-red-500 dark:text-red-200';
textColorClass = 'text-red-500 dark:text-red-200';
} else {
iconClass = 'text-blue-500 bg-blue-100 dark:bg-blue-800 dark:text-blue-200';
bgColorClass = 'text-blue-500 dark:text-blue-200';
textColorClass = 'text-blue-500 dark:text-blue-200';
}
// Set inner HTML with appropriate icon and message
toast.innerHTML = `
<div class="inline-flex items-center justify-center flex-shrink-0 w-8 h-8 rounded-lg ${iconClass}">
${type === 'success'
? '<i class="fas fa-check"></i>'
: type === 'error'
? '<i class="fas fa-exclamation-circle"></i>'
: '<i class="fas fa-info-circle"></i>'}
</div>
<div class="ml-3 text-sm font-normal">${message}</div>
<button type="button" class="ml-auto -mx-1.5 -my-1.5 bg-white text-gray-400 hover:text-gray-900 rounded-lg focus:ring-2 focus:ring-gray-300 p-1.5 hover:bg-gray-100 inline-flex h-8 w-8 dark:text-gray-500 dark:hover:text-white dark:bg-gray-800 dark:hover:bg-gray-700" data-dismiss-target="#${toast.id}" aria-label="Close">
<span class="sr-only">Close</span>
<i class="fas fa-times"></i>
</button>
`;
// Add toast to container
toastContainer.appendChild(toast);
// Trigger animation after a small delay to ensure the DOM has updated
setTimeout(() => {
toast.classList.remove('translate-y-16', 'opacity-0');
toast.classList.add('translate-y-0', 'opacity-100');
}, 10);
// Add event listener to close button
const closeButton = toast.querySelector('button[data-dismiss-target]');
closeButton.addEventListener('click', function() {
// Animate out before removing
toast.classList.add('opacity-0', 'translate-y-4');
setTimeout(() => {
toast.remove();
}, 300);
});
// Auto-remove toast after 5 seconds
setTimeout(() => {
toast.classList.add('opacity-0', 'translate-y-4');
setTimeout(() => {
toast.remove();
}, 300);
}, 5000);
}
// Global function to handle job running
window.runJob = function(button) {
@@ -125,22 +163,33 @@ templ Jobs(ctx context.Context, data JobsData) {
displayName = event.detail.headers['HX-Job-Name'];
}
console.log(`Showing success notification for job: ${displayName}`);
window.notyf.success(`Job "${displayName}" started successfully!`);
showToast(`Job "${displayName}" started successfully!`, 'success');
} else {
let errorMsg = `Failed to start job "${jobName}"`;
if (event.detail.xhr && event.detail.xhr.responseText) {
errorMsg = `Error: ${event.detail.xhr.responseText}`;
}
console.log(`Showing error notification: ${errorMsg}`);
window.notyf.error(errorMsg);
showToast(errorMsg, 'error');
}
}, { once: true });
};
// Handle modal hide buttons
document.addEventListener('DOMContentLoaded', function() {
const hideButtons = document.querySelectorAll('[data-modal-hide]');
hideButtons.forEach(button => {
button.addEventListener('click', function() {
const modalId = this.getAttribute('data-modal-hide');
const modal = document.getElementById(modalId);
modal.classList.add('hidden');
modal.classList.remove('flex');
});
});
});
// Track all HTMX events for debugging
document.addEventListener('htmx:beforeRequest', function(event) {
console.log("HTMX before request:", event.detail);
document.addEventListener('htmx:beforeRequest', function(event) {
// Check if this is a DELETE request by examining the URL and method
const path = event.detail.path;
const method = event.detail.verb;
@@ -157,9 +206,7 @@ templ Jobs(ctx context.Context, data JobsData) {
});
// Track HTMX after-request events for job deletion
document.addEventListener('htmx:afterRequest', function(event) {
console.log("HTMX after request:", event.detail);
document.addEventListener('htmx:afterRequest', function(event) {
// Check for job deletion multiple ways
const isDeleteRequest =
// Check global flag from the triggerJobDelete function
@@ -190,7 +237,7 @@ templ Jobs(ctx context.Context, data JobsData) {
}
console.log(`Showing success notification for deleted job: ${jobName}`);
window.notyf.success(`Job "${jobName}" deleted successfully`);
showToast(`Job "${jobName}" deleted successfully`, 'success');
// Clear flags
window.currentlyDeletingJob = false;
@@ -233,7 +280,7 @@ templ Jobs(ctx context.Context, data JobsData) {
}
console.log(`Showing error notification: ${errorMsg}`);
window.notyf.error(errorMsg);
showToast(errorMsg, 'error');
// Clear flags
window.currentlyDeletingJob = false;
@@ -243,140 +290,187 @@ templ Jobs(ctx context.Context, data JobsData) {
});
</script>
<div class="py-6">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center mb-8">
<h1 class="text-3xl font-bold text-secondary-900 dark:text-secondary-100">
<i class="fas fa-exchange-alt mr-2 text-primary-600 dark:text-primary-400"></i>
Transfer Jobs
<div id="jobs-container" style="min-height: 100vh; background-color: rgb(249, 250, 251);" class="jobs-page bg-gray-50 dark:bg-gray-900">
<div class="pb-8 w-full">
<div class="mb-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
<i class="fas fa-calendar-alt w-6 h-6 mr-2 text-blue-500 dark:text-blue-400"></i>
Scheduled Jobs
</h1>
<a href="/jobs/new" class="btn-primary">
<i class="fas fa-plus mr-2"></i>
<a href="/jobs/new" class="flex items-center justify-center text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg px-5 py-2.5 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800">
<i class="fas fa-plus w-4 h-4 mr-2"></i>
New Job
</a>
</div>
<div class="mt-6">
if len(data.Jobs) == 0 {
<div class="card p-12 flex flex-col items-center justify-center text-center">
<div class="inline-block p-4 rounded-full bg-secondary-100 dark:bg-secondary-700 mb-4">
<i class="fas fa-tasks text-secondary-400 dark:text-secondary-500 text-3xl"></i>
<div class="bg-white dark:bg-gray-800 shadow-md rounded-lg p-12 flex flex-col items-center justify-center text-center">
<div class="inline-block p-4 rounded-full bg-gray-100 dark:bg-gray-700 mb-4">
<i class="fas fa-list text-3xl text-gray-400 dark:text-gray-500"></i>
</div>
<h3 class="mt-2 text-lg font-medium text-secondary-900 dark:text-secondary-100">No jobs</h3>
<p class="mt-1 text-sm text-secondary-500 dark:text-secondary-400">Get started by creating a new transfer job.</p>
<h3 class="mt-2 text-lg font-medium text-gray-900 dark:text-white">No jobs</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Get started by creating a new transfer job.</p>
<div class="mt-6">
<a href="/jobs/new" class="btn-primary">
<a href="/jobs/new" class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800">
<i class="fas fa-plus mr-2"></i>
New Job
</a>
</div>
</div>
} else {
<div class="card overflow-hidden">
<ul role="list" class="divide-y divide-secondary-200 dark:divide-secondary-700">
for _, job := range data.Jobs {
<li>
<div class="block hover:bg-secondary-50 dark:hover:bg-secondary-750 transition-colors">
<div class="px-4 py-4 sm:px-6">
<div class="flex items-center justify-between">
<div class="flex items-center">
<p class="text-sm font-medium text-primary-600 dark:text-primary-400 truncate">
if job.Name != "" {
{ job.Name }
<div class="bg-white dark:bg-gray-800 shadow-md sm:rounded-lg overflow-hidden">
<div class="relative overflow-x-auto">
<ul role="list" class="divide-y divide-gray-200 dark:divide-gray-700">
for _, job := range data.Jobs {
<li>
<div class="block hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
<div class="px-4 py-4 sm:px-6">
<div class="flex items-center justify-between">
<div class="flex items-center">
<p class="text-sm font-medium text-blue-600 dark:text-blue-400 truncate">
if job.Name != "" {
{ job.Name }
} else {
{ job.Config.Name }
}
</p>
if job.GetEnabled() {
<span class="inline-flex items-center bg-green-100 text-green-800 text-xs font-medium mr-2 px-2.5 py-0.5 rounded-full dark:bg-green-900 dark:text-green-300 ml-2">
<i class="fas fa-circle-check text-green-500 dark:text-green-400 mr-1"></i>
Active
</span>
} else {
{ job.Config.Name }
<span class="inline-flex items-center bg-gray-100 text-gray-800 text-xs font-medium mr-2 px-2.5 py-0.5 rounded-full dark:bg-gray-700 dark:text-gray-300 ml-2">
<i class="fas fa-circle-xmark text-gray-500 dark:text-gray-400 mr-1"></i>
Inactive
</span>
}
</p>
if job.Enabled {
<span class="ml-2 px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-300">
<i class="fas fa-check-circle mr-1"></i>
Active
</span>
} else {
<span class="ml-2 px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-secondary-100 dark:bg-secondary-700 text-secondary-800 dark:text-secondary-300">
<i class="fas fa-pause-circle mr-1"></i>
Inactive
</span>
}
</div>
<div class="ml-2 flex-shrink-0 flex space-x-2">
<button
hx-post={ fmt.Sprintf("/jobs/%d/run", job.ID) }
hx-swap="none"
class="btn-primary btn-sm"
data-job-id={ fmt.Sprint(job.ID) }
data-job-name={ job.Name }
onclick="window.runJob(this)">
<i class="fas fa-play mr-1"></i>
Run Now
</button>
<a href={ templ.SafeURL(fmt.Sprintf("/jobs/%d", job.ID)) } class="btn-secondary btn-sm">
<i class="fas fa-edit mr-1"></i>
Edit
</a>
<!-- Add delete dialog for each job -->
@JobDialog(
fmt.Sprintf("delete-job-dialog-%d", job.ID),
"Delete Job",
fmt.Sprintf("Are you sure you want to delete the job '%s'? This cannot be undone.", determineJobName(job)),
"btn-danger",
"Delete",
"delete",
job.ID,
determineJobName(job),
)
<button
type="button"
onclick={ showJobDialog(fmt.Sprintf("delete-job-dialog-%d", job.ID)) }
class="btn-danger btn-sm">
<i class="fas fa-trash-alt mr-1"></i>
Delete
</button>
</div>
</div>
<div class="mt-2 sm:flex sm:justify-between">
<div class="sm:flex">
<p class="flex items-center text-sm text-secondary-500 dark:text-secondary-400">
<i class="fas fa-cog flex-shrink-0 mr-1.5 h-5 w-5 text-secondary-400 dark:text-secondary-500"></i>
Config: { job.Config.Name }
</p>
<p class="mt-2 flex items-center text-sm text-secondary-500 dark:text-secondary-400 sm:mt-0 sm:ml-6">
<i class="fas fa-calendar-alt flex-shrink-0 mr-1.5 h-5 w-5 text-secondary-400 dark:text-secondary-500"></i>
Schedule: { job.Schedule }
</p>
if job.LastRun != nil {
<p class="mt-2 flex items-center text-sm text-secondary-500 dark:text-secondary-400 sm:mt-0 sm:ml-6">
<i class="fas fa-history flex-shrink-0 mr-1.5 h-5 w-5 text-secondary-400 dark:text-secondary-500"></i>
Last Run: { job.LastRun.Format("2006-01-02 15:04:05") }
</p>
}
</div>
if job.NextRun != nil {
<div class="mt-2 flex items-center text-sm text-secondary-500 dark:text-secondary-400 sm:mt-0">
<i class="fas fa-clock flex-shrink-0 mr-1.5 h-5 w-5 text-secondary-400 dark:text-secondary-500"></i>
<p>
Next Run: { job.NextRun.Format("2006-01-02 15:04:05") }
</p>
</div>
}
<div class="ml-2 flex-shrink-0 flex space-x-2">
<button
hx-post={ fmt.Sprintf("/jobs/%d/run", job.ID) }
hx-swap="none"
class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-xs px-3 py-1.5 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800"
data-job-id={ fmt.Sprint(job.ID) }
data-job-name={ job.Name }
onclick="window.runJob(this)">
<i class="fas fa-play mr-1"></i>
Run Now
</button>
<a href={ templ.SafeURL(fmt.Sprintf("/jobs/%d", job.ID)) } class="py-1.5 px-3 text-xs font-medium text-gray-900 focus:outline-none bg-white rounded-lg border border-gray-200 hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-4 focus:ring-gray-100 dark:focus:ring-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700">
<i class="fas fa-pen-to-square mr-1"></i>
Edit
</a>
<button
hx-post={ fmt.Sprintf("/jobs/%d/duplicate", job.ID) }
hx-swap="innerHTML"
hx-target="body"
class="text-indigo-700 bg-indigo-100 hover:bg-indigo-200 focus:ring-4 focus:outline-none focus:ring-indigo-300 font-medium rounded-lg text-xs px-3 py-1.5 text-center inline-flex items-center dark:bg-indigo-700 dark:text-indigo-300 dark:hover:bg-indigo-600 dark:focus:ring-indigo-800"
data-job-id={ fmt.Sprint(job.ID) }
data-job-name={ job.Name }>
<i class="fas fa-clone mr-1"></i>
Duplicate
</button>
<!-- Add delete dialog for each job -->
@JobDialog(
fmt.Sprintf("delete-job-dialog-%d", job.ID),
"Delete Job",
fmt.Sprintf("Are you sure you want to delete the job '%s'? This cannot be undone.", determineJobName(job)),
"text-white bg-red-700 hover:bg-red-800 focus:ring-4 focus:ring-red-300 font-medium rounded-lg text-sm px-5 py-2.5 dark:bg-red-600 dark:hover:bg-red-700 focus:outline-none dark:focus:ring-red-800",
"Delete",
"delete",
job.ID,
determineJobName(job),
)
<button
type="button"
onclick={ showModal(fmt.Sprintf("delete-job-dialog-%d", job.ID)) }
class="focus:outline-none text-white bg-red-700 hover:bg-red-800 focus:ring-4 focus:ring-red-300 font-medium rounded-lg text-xs px-3 py-1.5 dark:bg-red-600 dark:hover:bg-red-700 dark:focus:ring-red-900">
<i class="fas fa-trash mr-1"></i>
Delete
</button>
</div>
</div>
<div class="mt-2 sm:flex sm:justify-between">
<div class="sm:flex flex-wrap">
<p class="flex items-center text-sm text-gray-500 dark:text-gray-400">
<i class="fas fa-gear text-gray-400 dark:text-gray-500 mr-1.5"></i>
Configs:
<span class="ml-1">
if count, ok := data.ConfigCount[job.ID]; ok && count > 1 {
{ fmt.Sprintf("%d configurations", count) }
} else if job.ConfigID > 0 {
{ job.Config.Name }
} else {
{ "None" }
}
</span>
</p>
<p class="mt-2 flex items-center text-sm text-gray-500 dark:text-gray-400 sm:mt-0 sm:ml-6">
<i class="fas fa-calendar text-gray-400 dark:text-gray-500 mr-1.5"></i>
Schedule: { job.Schedule }
</p>
if job.LastRun != nil {
<p class="mt-2 flex items-center text-sm text-gray-500 dark:text-gray-400 sm:mt-0 sm:ml-6">
<i class="fas fa-clock text-gray-400 dark:text-gray-500 mr-1.5"></i>
Last Run: { job.LastRun.Format("2006-01-02 15:04:05") }
</p>
}
</div>
if job.NextRun != nil {
<div class="mt-2 flex items-center text-sm text-gray-500 dark:text-gray-400 sm:mt-0">
<i class="fas fa-hourglass-start text-gray-400 dark:text-gray-500 mr-1.5"></i>
<p>
Next Run: { job.NextRun.Format("2006-01-02 15:04:05") }
</p>
</div>
}
</div>
</div>
</div>
</div>
</li>
}
</ul>
</li>
}
</ul>
</div>
</div>
}
</div>
<!-- Help Notice -->
<div class="mt-8 text-center">
<p class="text-sm text-secondary-500 dark:text-secondary-400">
<i class="fas fa-info-circle mr-1 text-primary-500"></i>
Transfer jobs run according to their schedule and transfer files between configured sources and destinations
</p>
<div class="mt-8 p-4 bg-gray-50 border border-gray-200 rounded-lg dark:bg-gray-800 dark:border-gray-700">
<div class="flex">
<div class="flex-shrink-0">
<i class="fas fa-info-circle text-blue-400 dark:text-blue-400"></i>
</div>
<div class="ml-3">
<p class="text-sm text-blue-700 dark:text-blue-400">
Transfer jobs run according to their schedule and transfer files between configured sources and destinations
</p>
</div>
</div>
</div>
</div>
</div>
<script>
// Set dark background color if in dark mode
if (document.documentElement.classList.contains('dark')) {
document.getElementById('jobs-container').style.backgroundColor = '#111827';
}
// Add event listener for theme changes
document.addEventListener('DOMContentLoaded', function() {
const themeToggle = document.getElementById('theme-toggle');
if (themeToggle) {
themeToggle.addEventListener('click', function() {
setTimeout(function() {
const isDark = document.documentElement.classList.contains('dark');
document.getElementById('jobs-container').style.backgroundColor = isDark ? '#111827' : 'rgb(249, 250, 251)';
}, 50);
});
}
});
</script>
}
}
+483 -384
View File
@@ -2,9 +2,23 @@ package components
import (
"context"
"fmt"
"strings"
"github.com/gin-gonic/gin"
"time"
"github.com/starfleetcptn/gomft/components/shared/toast"
)
// AppVersion will be set at build time using ldflags
// Example build command:
// go build -ldflags "-X github.com/starfleetcptn/gomft/components.AppVersion=1.2.3"
var AppVersion = "DEV"
// GetReleaseURL returns the URL to the specific GitHub release
func GetReleaseURL() templ.SafeURL {
return templ.SafeURL(fmt.Sprintf("https://github.com/starfleetcptn/gomft/releases/tag/%s", AppVersion))
}
// CreateTemplateContext creates a new context with user information from Gin's context
func CreateTemplateContext(c *gin.Context) context.Context {
ctx := context.Background()
@@ -29,435 +43,520 @@ templ Layout(title string) {
templ LayoutWithContext(title string, ctx context.Context) {
<!DOCTYPE html>
<html lang="en" class="light h-full">
<html lang="en" class="h-full bg-gray-50 dark:bg-gray-900">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover"/>
<meta name="theme-color" content="#2563eb"/>
<meta name="apple-mobile-web-app-capable" content="yes"/>
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"/>
<link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png">
<link rel="manifest" href="/static/site.webmanifest">
<link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png"/>
<link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png"/>
<link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png"/>
<link rel="manifest" href="/static/site.webmanifest"/>
<title>{ title } - GoMFT</title>
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<script src="https://unpkg.com/alpinejs@3.13.5/dist/cdn.min.js" defer></script>
<script src="https://cdn.tailwindcss.com"></script>
<script src="/static/js/app.js"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" />
<link rel="stylesheet" href="/static/css/app.css"/>
<!-- Notyf Toast Notifications -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/notyf@3/notyf.min.css" />
<script src="https://cdn.jsdelivr.net/npm/notyf@3/notyf.min.js"></script>
<script>
// Initialize Notyf and make it available globally
document.addEventListener('DOMContentLoaded', function() {
window.notyfInstance = new Notyf({
duration: 3000,
position: {
x: 'right',
y: 'bottom',
},
types: [
{
type: 'success',
className: 'notyf__toast--success',
background: '#10B981',
icon: {
className: 'fas fa-check-circle',
tagName: 'i'
}
},
{
type: 'error',
className: 'notyf__toast--error',
background: '#EF4444',
icon: {
className: 'fas fa-exclamation-circle',
tagName: 'i'
}
}
]
});
});
</script>
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
colors: {
primary: {
50: '#f0f9ff',
100: '#e0f2fe',
200: '#bae6fd',
300: '#7dd3fc',
400: '#38bdf8',
500: '#0ea5e9',
600: '#0284c7',
700: '#0369a1',
800: '#075985',
900: '#0c4a6e',
950: '#082f49',
},
secondary: {
50: '#f8fafc',
100: '#f1f5f9',
200: '#e2e8f0',
300: '#cbd5e1',
400: '#94a3b8',
500: '#64748b',
600: '#475569',
700: '#334155',
800: '#1e293b',
900: '#0f172a',
950: '#020617',
},
},
fontFamily: {
sans: ['Inter var', 'ui-sans-serif', 'system-ui', '-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'Roboto', 'Helvetica Neue', 'Arial', 'sans-serif'],
},
boxShadow: {
'custom': '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)',
'custom-lg': '0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)',
},
}
}
}
</script>
<style type="text/tailwindcss">
@layer components {
.btn-primary {
@apply px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-all duration-200 shadow-md hover:shadow-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2;
}
.btn-secondary {
@apply px-4 py-2 bg-secondary-200 text-secondary-800 rounded-lg hover:bg-secondary-300 transition-all duration-200 shadow-sm hover:shadow-md focus:outline-none focus:ring-2 focus:ring-secondary-300 focus:ring-offset-2 dark:bg-secondary-700 dark:text-secondary-100 dark:hover:bg-secondary-600;
}
.btn-danger {
@apply px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-all duration-200 shadow-md hover:shadow-lg focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2;
}
.btn-warning {
@apply px-4 py-2 bg-yellow-600 text-white rounded-lg hover:bg-yellow-700 transition-all duration-200 shadow-md hover:shadow-lg focus:outline-none focus:ring-2 focus:ring-yellow-500 focus:ring-offset-2;
}
.form-input {
@apply w-full px-3 py-2 border border-secondary-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 shadow-sm transition-all duration-200 dark:bg-secondary-800 dark:border-secondary-700 dark:text-white;
}
.form-checkbox {
@apply rounded border-secondary-300 text-primary-600 shadow-sm focus:border-primary-300 focus:ring focus:ring-primary-200 focus:ring-opacity-50 dark:border-secondary-700 dark:bg-secondary-800;
}
.card {
@apply bg-white dark:bg-secondary-800 rounded-xl shadow-custom overflow-hidden border border-secondary-200 dark:border-secondary-700 transition-all duration-200 hover:shadow-custom-lg;
}
.card-header {
@apply px-6 py-4 border-b border-secondary-200 dark:border-secondary-700 bg-secondary-50 dark:bg-secondary-900;
}
.card-body {
@apply p-6;
}
.badge {
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium;
}
.badge-success {
@apply bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-100;
}
.badge-warning {
@apply bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-100;
}
.badge-danger {
@apply bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-100;
}
.badge-info {
@apply bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-100;
}
.table-container {
@apply overflow-hidden rounded-lg shadow-sm border border-secondary-200 dark:border-secondary-700;
}
.table {
@apply min-w-full divide-y divide-secondary-200 dark:divide-secondary-700;
}
.table thead {
@apply bg-secondary-50 dark:bg-secondary-900;
}
.table th {
@apply px-6 py-3 text-left text-xs font-medium text-secondary-500 uppercase tracking-wider dark:text-secondary-400;
}
.table tbody {
@apply bg-white dark:bg-secondary-800 divide-y divide-secondary-200 dark:divide-secondary-700;
}
.table td {
@apply px-6 py-4 whitespace-nowrap text-sm text-secondary-900 dark:text-secondary-100;
}
.nav-link {
@apply inline-flex items-center px-1 pt-1 text-secondary-500 hover:text-secondary-700 dark:text-secondary-400 dark:hover:text-secondary-200 border-b-2 border-transparent hover:border-primary-500 transition-all duration-200;
}
.nav-link-active {
@apply inline-flex items-center px-1 pt-1 text-primary-600 dark:text-primary-400 border-b-2 border-primary-500;
}
}
<!-- Font Awesome -->
<link rel="stylesheet" href="/static/dist/fontawesome/css/all.min.css"/>
<!-- Application assets -->
<link rel="stylesheet" href="/static/dist/app.css"/>
<link rel="stylesheet" href="https://rsms.me/inter/inter.css"/>
/* Dark mode styles */
.dark body {
@apply bg-secondary-900 text-secondary-100;
}
.dark .bg-white {
@apply bg-secondary-800;
}
.dark .text-secondary-800 {
@apply text-secondary-100;
}
.dark .text-secondary-700 {
@apply text-secondary-200;
}
.dark .text-secondary-500 {
@apply text-secondary-300;
}
.dark .hover\:text-secondary-700:hover {
@apply text-secondary-100;
}
.dark .hover\:bg-secondary-100:hover {
@apply bg-secondary-700;
}
.dark .bg-secondary-50 {
@apply bg-secondary-900;
}
.dark .bg-secondary-200 {
@apply bg-secondary-700;
}
.dark .shadow-sm {
@apply shadow-secondary-900;
<script>
// Immediate theme application
// This script runs before the DOM is fully loaded
(function() {
const isDark = localStorage.theme === 'dark' ||
(!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches);
if (isDark) {
document.documentElement.classList.add('dark');
document.addEventListener('DOMContentLoaded', function() {
document.body.classList.add('dark');
// Apply dark theme to containers
const containers = ['jobs-container', 'configs-container'];
containers.forEach(function(id) {
const container = document.getElementById(id);
if (container) {
container.classList.add('dark');
container.style.backgroundColor = '#111827';
}
});
});
}
})();
</script>
<style>
html, body {
margin: 0;
padding: 0;
overflow-x: hidden;
width: 100%;
}
/* Additional dark mode improvements */
.dark .bg-secondary-50 {
@apply bg-secondary-900;
}
.dark .bg-secondary-100 {
@apply bg-secondary-800;
}
.dark .border-secondary-200 {
@apply border-secondary-700;
}
/* Custom animations */
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.animate-fadeIn {
animation: fadeIn 0.3s ease-in-out;
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
.pb-mobile-nav {
padding-bottom: 4rem;
}
::-webkit-scrollbar-track {
background: theme('colors.secondary.100');
border-radius: 4px;
@media (min-width: 640px) {
.pb-mobile-nav {
padding-bottom: 0;
}
}
.dark ::-webkit-scrollbar-track {
background: theme('colors.secondary.800');
/* Ensure dark mode background extends to full height */
body.dark {
background-color: #111827; /* gray-900 */
}
::-webkit-scrollbar-thumb {
background: theme('colors.secondary.300');
border-radius: 4px;
/* Force dark background on specific pages */
body.dark .configs-page,
body.dark .jobs-page {
background-color: #111827 !important; /* gray-900 */
min-height: 100vh;
}
.dark ::-webkit-scrollbar-thumb {
background: theme('colors.secondary.600');
/* Ensure dark mode fills entire page */
body.dark {
background-color: #111827 !important;
}
::-webkit-scrollbar-thumb:hover {
background: theme('colors.secondary.400');
/* Force container backgrounds */
#jobs-container.dark, #configs-container.dark {
background-color: #111827 !important;
min-height: 100vh;
}
.dark ::-webkit-scrollbar-thumb:hover {
background: theme('colors.secondary.500');
/* Override any white backgrounds in dark mode */
body.dark .bg-white {
background-color: #1f2937 !important;
}
</style>
<link rel="stylesheet" href="https://rsms.me/inter/inter.css">
</head>
<body class="min-h-screen flex flex-col bg-secondary-50 dark:bg-secondary-900 h-full">
<body class="min-h-full bg-gray-50 dark:bg-gray-900" style="min-height: 100vh; display: flex; flex-direction: column;" hx-on::after-swap="initFlowbite()">
if isLoggedIn(ctx) {
<nav class="bg-white dark:bg-secondary-800 shadow-sm sticky top-0 z-10">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-16">
<div class="flex">
<!-- Application Shell -->
<div class="flex min-h-screen bg-gray-50 dark:bg-gray-900">
<!-- Desktop Sidebar -->
<aside class="hidden md:flex md:w-64 md:flex-col md:fixed md:inset-y-0">
<div class="flex flex-col flex-grow pt-5 bg-white dark:bg-gray-800 overflow-y-auto border-r border-gray-200 dark:border-gray-700">
<div class="flex items-center flex-shrink-0 px-4">
<a href="/" class="flex items-center text-xl font-bold text-primary-600 dark:text-primary-400">
<i class="fas fa-exchange-alt mr-2"></i>
<img src="/static/img/logo.png" alt="GoMFT" class="w-8 h-8 mr-2">
GoMFT
</a>
<div class="hidden sm:ml-6 sm:flex sm:space-x-8">
<a href="/dashboard" class="nav-link">
<i class="fas fa-tachometer-alt mr-1"></i> Dashboard
</div>
<nav class="flex-1 px-2 py-4 bg-white dark:bg-gray-800">
<div class="space-y-1">
<a href="/dashboard" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
<i class="fas fa-chart-pie mr-3 text-gray-500 dark:text-gray-400"></i>
Dashboard
</a>
<a href="/configs" class="nav-link">
<i class="fas fa-cogs mr-1"></i> Configs
<a href="/storage-providers" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
<i class="fas fa-server mr-3 text-gray-500 dark:text-gray-400"></i>
Storage Providers
</a>
<a href="/jobs" class="nav-link">
<i class="fas fa-tasks mr-1"></i> Jobs
<a href="/configs" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
<i class="fas fa-exchange-alt mr-3 text-gray-500 dark:text-gray-400"></i>
Transfer Configurations
</a>
<a href="/history" class="nav-link">
<i class="fas fa-history mr-1"></i> History
<a href="/jobs" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
<i class="fas fa-calendar-alt mr-3 text-gray-500 dark:text-gray-400"></i>
Scheduled Jobs
</a>
<a href="/calendar" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
<i class="fas fa-calendar-week mr-3 text-gray-500 dark:text-gray-400"></i>
Transfer Calendar
</a>
<a href="/history" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
<i class="fas fa-history mr-3 text-gray-500 dark:text-gray-400"></i>
Transfer History
</a>
<a href="/files" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
<i class="fas fa-file-alt mr-3 text-gray-500 dark:text-gray-400"></i>
Files
</a>
if isAdmin(ctx) {
<a href="/admin/users" class="nav-link">
<i class="fas fa-users mr-1"></i> Users
<p class="px-3 text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Administration
</p>
<a href="/admin/users" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
<i class="fas fa-users w-4 h-4 mr-2 text-gray-500 dark:text-gray-400"></i>
User Management
</a>
<a href="/admin/tools" class="nav-link">
<i class="fas fa-tools mr-1"></i> Admin Tools
<a href="/admin/roles" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
<i class="fas fa-user-shield w-4 h-4 mr-2 text-gray-500 dark:text-gray-400"></i>
Role Management
</a>
<a href="/admin/audit" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
<i class="fas fa-clipboard-list w-4 h-4 mr-2 text-gray-500 dark:text-gray-400"></i>
Audit Logs
</a>
<a href="/admin/logs" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
<i class="fas fa-stream w-4 h-4 mr-2 text-gray-500 dark:text-gray-400"></i>
Log Viewer
</a>
<a href="/admin/database" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
<i class="fas fa-database w-4 h-4 mr-2 text-gray-500 dark:text-gray-400"></i>
Database Tools
</a>
// Settings Dropdown
<button type="button" class="flex items-center w-full p-2 text-base text-gray-900 transition duration-75 rounded-lg group hover:bg-gray-100 dark:text-white dark:hover:bg-gray-700" aria-controls="dropdown-settings" data-collapse-toggle="dropdown-settings">
<i class="fas fa-cog w-4 h-4 mr-2 text-gray-500 dark:text-gray-400"></i>
<span class="flex-1 ms-3 text-left rtl:text-right whitespace-nowrap">Settings</span>
<svg class="w-3 h-3" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 10 6">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 4 4 4-4"/>
</svg>
</button>
<ul id="dropdown-settings" class="hidden py-2 space-y-2">
// <li>
// <a href="#" class="flex items-center w-full p-2 text-gray-900 transition duration-75 rounded-lg pl-11 group hover:bg-gray-100 dark:text-white dark:hover:bg-gray-700">General (Coming Soon)</a>
// </li>
<li>
<a href="/admin/settings/auth-providers" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
<i class="fas fa-user-lock w-4 h-4 mr-2 text-gray-500 dark:text-gray-400"></i>
Authentication Providers
</a>
</li>
<li>
<a href="/admin/settings/notifications" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
<i class="fas fa-bell w-4 h-4 mr-2 text-gray-500 dark:text-gray-400"></i>
Notification Services
</a>
</li>
</ul>
}
</div>
</div>
<div class="flex items-center space-x-4">
<!-- Theme Toggle -->
<button
id="theme-toggle"
type="button"
class="text-secondary-500 dark:text-secondary-400 hover:bg-secondary-100 dark:hover:bg-secondary-700 focus:outline-none focus:ring-2 focus:ring-primary-500 rounded-lg text-sm p-2"
onclick="toggleTheme()"
>
<i class="fas fa-sun hidden dark:block"></i>
<i class="fas fa-moon block dark:hidden"></i>
</button>
<!-- Mobile menu button -->
<div class="sm:hidden flex items-center">
<button
id="mobile-menu-button"
class="text-secondary-500 dark:text-secondary-400 hover:bg-secondary-100 dark:hover:bg-secondary-700 focus:outline-none focus:ring-2 focus:ring-primary-500 rounded-lg text-sm p-2"
x-data="{}"
@click="$dispatch('toggle-mobile-menu')"
>
</nav>
</div>
</aside>
<!-- Mobile menu -->
<div class="md:hidden fixed top-0 z-30 w-full">
<div class="bg-white dark:bg-gray-800 shadow-sm">
<div class="flex items-center justify-between px-4 py-3">
<a href="/" class="flex items-center text-primary-600 dark:text-primary-400">
<i class="fas fa-exchange-alt text-xl"></i>
</a>
<div class="flex items-center space-x-3">
<button data-drawer-target="mobile-menu" data-drawer-toggle="mobile-menu" class="p-2 text-gray-500 rounded-lg hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700">
<i class="fas fa-bars text-lg"></i>
</button>
</div>
<!-- User Menu -->
<div x-data="{ open: false }" class="relative">
<button @click="open = !open" class="flex text-sm rounded-full focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500">
<span class="sr-only">Open user menu</span>
<div class="h-8 w-8 rounded-full bg-primary-100 text-primary-700 flex items-center justify-center dark:bg-primary-900 dark:text-primary-300">
<span class="text-sm font-medium">{ getUserInitial(ctx) }</span>
</div>
</button>
<div x-show="open"
@click.away="open = false"
class="origin-top-right absolute right-0 mt-2 w-48 rounded-md shadow-lg py-1 bg-white ring-1 ring-black ring-opacity-5 focus:outline-none dark:bg-secondary-800 dark:ring-secondary-700 animate-fadeIn"
role="menu"
aria-orientation="vertical"
aria-labelledby="user-menu-button"
tabindex="-1">
<div class="px-4 py-2 text-xs text-secondary-500 dark:text-secondary-400 border-b border-secondary-200 dark:border-secondary-700">
Signed in as <span class="font-medium">{ getUserEmail(ctx) }</span>
</div>
<a href="/profile" class="block px-4 py-2 text-sm text-secondary-700 hover:bg-secondary-100 dark:text-secondary-200 dark:hover:bg-secondary-700" role="menuitem">
<i class="fas fa-user-circle mr-2"></i> Profile
</a>
<form method="POST" action="/logout">
<button type="submit" class="block w-full text-left px-4 py-2 text-sm text-red-600 hover:bg-secondary-100 dark:hover:bg-secondary-700" role="menuitem">
<i class="fas fa-sign-out-alt mr-2"></i> Sign out
</button>
</form>
</div>
</div>
</div>
</div>
</div>
</nav>
<!-- Mobile Navigation Menu -->
<div
x-data="{ open: false }"
x-on:toggle-mobile-menu.window="open = !open"
x-show="open"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 transform -translate-y-2"
x-transition:enter-end="opacity-100 transform translate-y-0"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100 transform translate-y-0"
x-transition:leave-end="opacity-0 transform -translate-y-2"
class="sm:hidden bg-white dark:bg-secondary-800 shadow-md"
>
<div class="px-2 pt-2 pb-3 space-y-1">
<a href="/dashboard" class="block px-3 py-2 rounded-md text-base font-medium text-secondary-700 dark:text-secondary-300 hover:bg-primary-50 dark:hover:bg-secondary-700 hover:text-primary-600 dark:hover:text-primary-400">
<i class="fas fa-tachometer-alt mr-2"></i> Dashboard
</a>
<a href="/configs" class="block px-3 py-2 rounded-md text-base font-medium text-secondary-700 dark:text-secondary-300 hover:bg-primary-50 dark:hover:bg-secondary-700 hover:text-primary-600 dark:hover:text-primary-400">
<i class="fas fa-cogs mr-2"></i> Configs
</a>
<a href="/jobs" class="block px-3 py-2 rounded-md text-base font-medium text-secondary-700 dark:text-secondary-300 hover:bg-primary-50 dark:hover:bg-secondary-700 hover:text-primary-600 dark:hover:text-primary-400">
<i class="fas fa-tasks mr-2"></i> Jobs
</a>
<a href="/history" class="block px-3 py-2 rounded-md text-base font-medium text-secondary-700 dark:text-secondary-300 hover:bg-primary-50 dark:hover:bg-secondary-700 hover:text-primary-600 dark:hover:text-primary-400">
<i class="fas fa-history mr-2"></i> History
</a>
if isAdmin(ctx) {
<a href="/admin/users" class="block px-3 py-2 rounded-md text-base font-medium text-secondary-700 dark:text-secondary-300 hover:bg-primary-50 dark:hover:bg-secondary-700 hover:text-primary-600 dark:hover:text-primary-400">
<i class="fas fa-users mr-2"></i> Users
<!-- Add this drawer element for mobile menu -->
<div id="mobile-menu" class="fixed top-0 left-0 z-40 h-screen p-4 overflow-y-auto transition-transform -translate-x-full bg-white w-64 dark:bg-gray-800" tabindex="-1">
<div class="flex items-center justify-between">
<a href="/" class="flex items-center text-xl font-bold text-primary-600 dark:text-primary-400">
<!-- GoMFT Logo -->
<img src="/static/img/logo.png" alt="GoMFT" class="w-8 h-8 mr-2">
</a>
<a href="/admin/tools" class="block px-3 py-2 rounded-md text-base font-medium text-secondary-700 dark:text-secondary-300 hover:bg-primary-50 dark:hover:bg-secondary-700 hover:text-primary-600 dark:hover:text-primary-400">
<i class="fas fa-tools mr-2"></i> Admin Tools
</a>
}
<a href="/profile" class="block px-3 py-2 rounded-md text-base font-medium text-secondary-700 dark:text-secondary-300 hover:bg-primary-50 dark:hover:bg-secondary-700 hover:text-primary-600 dark:hover:text-primary-400">
<i class="fas fa-user-circle mr-2"></i> Profile
</a>
<form method="POST" action="/logout">
<button type="submit" class="w-full text-left block px-3 py-2 rounded-md text-base font-medium text-red-600 hover:bg-red-50 dark:hover:bg-red-900 hover:text-red-700 dark:hover:text-red-400">
<i class="fas fa-sign-out-alt mr-2"></i> Sign out
<button type="button" data-drawer-hide="mobile-menu" class="text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm p-1.5 inline-flex items-center dark:hover:bg-gray-600 dark:hover:text-white">
<i class="fas fa-times"></i>
</button>
</form>
</div>
<!-- Copy the same navigation items from the desktop sidebar -->
<nav class="mt-5">
<div class="space-y-1">
<!-- Copy the same links from the desktop sidebar -->
<a href="/dashboard" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
<i class="fas fa-chart-pie mr-3 text-gray-500 dark:text-gray-400"></i>
Dashboard
</a>
<a href="/storage-providers" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
<i class="fas fa-server mr-3 text-gray-500 dark:text-gray-400"></i>
Storage Providers
</a>
<a href="/configs" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
<i class="fas fa-exchange-alt mr-3 text-gray-500 dark:text-gray-400"></i>
Transfer Configurations
</a>
<a href="/jobs" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
<i class="fas fa-calendar-alt mr-3 text-gray-500 dark:text-gray-400"></i>
Scheduled Jobs
</a>
<a href="/calendar" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
<i class="fas fa-calendar-week mr-3 text-gray-500 dark:text-gray-400"></i>
Transfer Calendar
</a>
<a href="/history" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
<i class="fas fa-history mr-3 text-gray-500 dark:text-gray-400"></i>
Transfer History
</a>
<a href="/files" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
<i class="fas fa-file-alt mr-3 text-gray-500 dark:text-gray-400"></i>
Files
</a>
if isAdmin(ctx) {
<p class="px-3 text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Administration
</p>
<a href="/admin/users" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
<i class="fas fa-users w-4 h-4 mr-2 text-gray-500 dark:text-gray-400"></i>
User Management
</a>
<a href="/admin/roles" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
<i class="fas fa-user-shield w-4 h-4 mr-2 text-gray-500 dark:text-gray-400"></i>
Role Management
</a>
<a href="/admin/audit" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
<i class="fas fa-clipboard-list w-4 h-4 mr-2 text-gray-500 dark:text-gray-400"></i>
Audit Logs
</a>
<a href="/admin/logs" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
<i class="fas fa-stream w-4 h-4 mr-2 text-gray-500 dark:text-gray-400"></i>
Log Viewer
</a>
<a href="/admin/database" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
<i class="fas fa-database w-4 h-4 mr-2 text-gray-500 dark:text-gray-400"></i>
Database Tools
</a>
// Settings Dropdown
<button type="button" class="flex items-center w-full p-2 text-base text-gray-900 transition duration-75 rounded-lg group hover:bg-gray-100 dark:text-white dark:hover:bg-gray-700" aria-controls="dropdown-settings-mobile" data-collapse-toggle="dropdown-settings-mobile">
<i class="fas fa-cog w-4 h-4 mr-2 text-gray-500 dark:text-gray-400"></i>
<span class="flex-1 ms-3 text-left rtl:text-right whitespace-nowrap">Settings</span>
<svg class="w-3 h-3" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 10 6">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 4 4 4-4"/>
</svg>
</button>
<ul id="dropdown-settings-mobile" class="hidden py-2 space-y-2">
// <li>
// <a href="#" class="flex items-center w-full p-2 text-gray-900 transition duration-75 rounded-lg pl-11 group hover:bg-gray-100 dark:text-white dark:hover:bg-gray-700">General (Coming Soon)</a>
// </li>
<li>
<a href="/admin/settings/auth-providers" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
<i class="fas fa-user-lock w-4 h-4 mr-2 text-gray-500 dark:text-gray-400"></i>
Authentication Providers
</a>
</li>
<li>
<a href="/admin/settings/notifications" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
<i class="fas fa-bell w-4 h-4 mr-2 text-gray-500 dark:text-gray-400"></i>
Notification Services
</a>
</li>
</ul>
}
</div>
</nav>
</div>
<!-- Main Content -->
<div class="md:pl-64 flex flex-col flex-1 w-full bg-gray-50 dark:bg-gray-900">
<!-- Main header -->
<header class="bg-white dark:bg-gray-800 shadow-sm">
<div class="flex items-center justify-between px-4 py-3 sm:px-6 lg:px-8">
<div class="flex items-center space-x-3">
// <h1 class="text-lg font-semibold text-gray-900 dark:text-white">{ title }</h1>
</div>
<!-- Theme toggle and user menu -->
<div class="flex items-center space-x-4">
<!-- Theme Toggle -->
<button
id="theme-toggle"
type="button"
class="p-2 text-gray-500 rounded-lg hover:text-gray-900 hover:bg-gray-100 dark:text-gray-400 dark:hover:text-white dark:hover:bg-gray-700 focus:ring-4 focus:ring-gray-300 dark:focus:ring-gray-600"
onclick="toggleTheme()"
>
<i class="fas fa-sun hidden dark:block"></i>
<i class="fas fa-moon block dark:hidden"></i>
</button>
<!-- Notification Bell -->
<div class="relative">
<button
type="button"
class="p-2 text-gray-500 rounded-lg hover:text-gray-900 hover:bg-gray-100 dark:text-gray-400 dark:hover:text-white dark:hover:bg-gray-700 focus:ring-4 focus:ring-gray-300 dark:focus:ring-gray-600 relative"
id="notification-bell"
data-dropdown-toggle="notification-dropdown"
hx-get="/notifications/dropdown"
hx-trigger="click once"
hx-target="#notification-dropdown-content"
data-dropdown-placement="bottom-start"
>
<span class="sr-only">View notifications</span>
<i class="fas fa-bell"></i>
<!-- Notification count will be placed here via HTMX -->
</button>
<!-- Place the notification count container outside but still in the relative container -->
<div id="notification-count-container" class="absolute top-0 right-0" hx-get="/notifications/count" hx-trigger="load, notification-updated from:body"></div>
</div>
<!-- Notification dropdown -->
<div
class="hidden overflow-hidden z-50 my-4 max-w-sm md:max-w-md w-full text-base list-none bg-white rounded divide-y divide-gray-100 shadow-lg dark:divide-gray-600 dark:bg-gray-700 rounded-xl fixed"
id="notification-dropdown"
style="min-width: 320px; width: 100%;"
data-dropdown-placement="bottom-start"
>
<div id="notification-dropdown-content">
<!-- Content will be loaded dynamically via HTMX -->
<div class="block py-2 px-4 text-base font-medium text-center text-gray-700 bg-gray-50 dark:bg-gray-600 dark:text-gray-300">
Notifications
</div>
<div class="py-4 px-4 text-center text-gray-500 dark:text-gray-400">
<div class="animate-pulse flex flex-col items-center">
<div class="rounded-full bg-gray-200 dark:bg-gray-700 h-12 w-12 mb-2"></div>
<div class="h-2 bg-gray-200 dark:bg-gray-700 rounded w-24 mb-4"></div>
<div class="h-2 bg-gray-200 dark:bg-gray-700 rounded w-full mb-2"></div>
<div class="h-2 bg-gray-200 dark:bg-gray-700 rounded w-full mb-2"></div>
<div class="h-2 bg-gray-200 dark:bg-gray-700 rounded w-3/4"></div>
</div>
</div>
<a href="/notifications" class="block py-2 text-md font-medium text-center text-gray-900 bg-gray-50 hover:bg-gray-100 dark:bg-gray-600 dark:text-white dark:hover:underline">
<div class="inline-flex items-center">
<svg aria-hidden="true" class="mr-2 w-4 h-4 text-gray-500 dark:text-gray-400" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
<path d="M10 12a2 2 0 100-4 2 2 0 000 4z"></path>
<path fill-rule="evenodd" d="M.458 10C1.732 5.943 5.522 3 10 3s8.268 2.943 9.542 7c-1.274 4.057-5.064 7-9.542 7S1.732 14.057.458 10zM14 10a4 4 0 11-8 0 4 4 0 018 0z" clip-rule="evenodd"></path>
</svg>
View all
</div>
</a>
</div>
</div>
<!-- User menu dropdown -->
<div class="relative">
<button
type="button"
class="flex text-sm rounded-full focus:ring-4 focus:ring-gray-300 dark:focus:ring-gray-600"
id="user-menu-button"
data-dropdown-toggle="user-dropdown"
data-dropdown-placement="bottom-end"
>
<span class="sr-only">Open user menu</span>
<div class="h-8 w-8 rounded-full bg-primary-100 text-primary-700 flex items-center justify-center dark:bg-primary-900 dark:text-primary-300">
<span class="text-sm font-medium">{ getUserInitial(ctx) }</span>
</div>
</button>
<!-- Dropdown menu -->
<div
class="z-50 hidden my-4 text-base list-none bg-white divide-y divide-gray-100 rounded-lg shadow dark:bg-gray-700 dark:divide-gray-600"
id="user-dropdown"
>
<div class="px-4 py-3">
<span class="block text-sm text-gray-900 dark:text-white">{ getUserEmail(ctx) }</span>
</div>
<ul class="py-2" aria-labelledby="user-menu-button">
<li>
<a
href="/profile"
class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:hover:bg-gray-600 dark:text-gray-200 dark:hover:text-white"
>
<i class="fas fa-user-circle mr-2 w-4"></i>
Profile
</a>
</li>
<li>
<form method="POST" action="/logout">
<button
type="submit"
class="w-full text-left flex items-center px-4 py-2 text-sm text-red-600 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white"
>
<i class="fas fa-sign-out-alt mr-2 w-4"></i>
Sign out
</button>
</form>
</li>
</ul>
</div>
</div>
</div>
</div>
</header>
<!-- Toast Container -->
@toast.Container()
<!-- Page Content -->
<main class="flex-1 bg-gray-50 dark:bg-gray-900">
<div class="py-6 bg-gray-50 dark:bg-gray-900">
<div class="max-w-screen-2xl mx-auto px-4 sm:px-6 lg:px-8">
<!-- Notification area -->
<div id="notification-area" class="hidden" hx-swap-oob="true"></div>
{ children... }
</div>
</div>
</main>
<!-- Footer -->
<footer class="bg-gray-50 dark:bg-gray-900 border-t border-gray-200 dark:border-gray-700">
<div class="max-w-screen-2xl mx-auto py-4 px-4 sm:px-6 lg:px-8">
<div class="flex items-center justify-between text-sm text-gray-500 dark:text-gray-400">
<span>GoMFT &copy; { getCurrentYear() }</span>
<div class="flex items-center space-x-4">
<a href="https://github.com/starfleetcptn/gomft" class="hover:text-primary-600 dark:hover:text-primary-400">
<i class="fab fa-github"></i>
</a>
<a href="https://discord.gg/f9dwtM3j" class="hover:text-primary-600 dark:hover:text-primary-400" target="_blank">
<i class="fab fa-discord"></i>
</a>
<a href={ GetReleaseURL() } class="hover:text-primary-600 dark:hover:text-primary-400">
<i class="fas fa-tag"></i> { AppVersion }
</a>
</div>
</div>
</div>
</footer>
</div>
</div>
}
<main class="flex-grow w-full max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8 animate-fadeIn pb-mobile-nav">
<!-- Hidden notification area for HTMX targets -->
<div id="notification-area" class="hidden" hx-swap-oob="true"></div>
{ children... }
</main>
<footer class="bg-white dark:bg-secondary-800 shadow-inner mt-auto w-full">
<div class="max-w-7xl mx-auto py-4 px-4 sm:px-6 lg:px-8">
<p class="text-center text-sm text-secondary-500 dark:text-secondary-400">
GoMFT &copy; { getCurrentYear() } | Secure File Transfer Solution
</p>
</div>
</footer>
if isLoggedIn(ctx) {
<!-- Mobile Bottom Navigation -->
<div class="sm:hidden fixed bottom-0 left-0 right-0 bg-white dark:bg-secondary-800 border-t border-secondary-200 dark:border-secondary-700 shadow-lg mobile-nav-container z-10">
<div class="grid grid-cols-5 h-16">
<a href="/dashboard" class="flex flex-col items-center justify-center text-secondary-500 dark:text-secondary-400 hover:text-primary-600 dark:hover:text-primary-400">
<i class="fas fa-tachometer-alt text-lg"></i>
<span class="text-xs mt-1">Dashboard</span>
</a>
<a href="/configs" class="flex flex-col items-center justify-center text-secondary-500 dark:text-secondary-400 hover:text-primary-600 dark:hover:text-primary-400">
<i class="fas fa-cogs text-lg"></i>
<span class="text-xs mt-1">Configs</span>
</a>
<a href="/jobs" class="flex flex-col items-center justify-center text-secondary-500 dark:text-secondary-400 hover:text-primary-600 dark:hover:text-primary-400">
<i class="fas fa-tasks text-lg"></i>
<span class="text-xs mt-1">Jobs</span>
</a>
<a href="/history" class="flex flex-col items-center justify-center text-secondary-500 dark:text-secondary-400 hover:text-primary-600 dark:hover:text-primary-400">
<i class="fas fa-history text-lg"></i>
<span class="text-xs mt-1">History</span>
</a>
<button
type="button"
class="flex flex-col items-center justify-center text-secondary-500 dark:text-secondary-400 hover:text-primary-600 dark:hover:text-primary-400"
x-data="{}"
@click="$dispatch('toggle-mobile-menu')"
>
<i class="fas fa-ellipsis-h text-lg"></i>
<span class="text-xs mt-1">More</span>
</button>
</div>
} else {
<!-- Public page layout -->
<div class="flex flex-col min-h-screen bg-gray-50 dark:bg-gray-900">
<main class="flex-grow">
{ children... }
</main>
<footer class="bg-gray-50 dark:bg-gray-900 border-t border-gray-200 dark:border-gray-700">
<div class="max-w-screen-2xl mx-auto py-4 px-4 sm:px-6 lg:px-8">
<div class="flex items-center justify-between text-sm text-gray-500 dark:text-gray-400">
<span>GoMFT &copy; { getCurrentYear() }</span>
<div class="flex items-center space-x-4">
<a href="https://github.com/starfleetcptn/gomft" class="hover:text-primary-600 dark:hover:text-primary-400">
<i class="fab fa-github"></i>
</a>
<a href="https://discord.gg/f9dwtM3j" class="hover:text-primary-600 dark:hover:text-primary-400" target="_blank">
<i class="fab fa-discord"></i>
</a>
<a href={ GetReleaseURL() } class="hover:text-primary-600 dark:hover:text-primary-400">
<i class="fas fa-tag"></i> { AppVersion }
</a>
</div>
</div>
</div>
</footer>
</div>
}
<!-- Scripts -->
<!-- Alpine.js and dependencies -->
<script defer src="/static/dist/vendor.js"></script>
<!-- Shared Scripts -->
@toast.ShowToastJS()
<!-- Application scripts -->
<script defer src="/static/dist/app.js"></script>
<script defer src="/static/dist/init.js"></script>
<!-- Dropdown fix for mobile -->
<script>
document.addEventListener("DOMContentLoaded", function() {
// Initialize the mobile dropdown separately
const mobileDropdownButton = document.querySelector('[data-collapse-toggle="dropdown-settings-mobile"]');
const mobileDropdown = document.getElementById("dropdown-settings-mobile");
if (mobileDropdownButton && mobileDropdown) {
// Show dropdown if on admin pages
if (window.location.pathname.startsWith("/admin")) {
// Keep it hidden by default, will be toggled by button
mobileDropdown.classList.add("hidden");
}
mobileDropdownButton.addEventListener("click", function() {
mobileDropdown.classList.toggle("hidden");
});
}
});
</script>
</body>
</html>
}
@@ -494,17 +593,17 @@ func isLoggedIn(ctx context.Context) bool {
func getUserInitial(ctx context.Context) string {
// Try as string first
if username, ok := ctx.Value("username").(string); ok && username != "" {
return string(username[0])
return strings.ToUpper(string(username[0]))
}
// Try as interface{} (from JWT claims)
if username, ok := ctx.Value("username").(interface{}); ok {
if strVal, ok := username.(string); ok && strVal != "" {
return string(strVal[0])
return strings.ToUpper(string(strVal[0]))
}
}
// Try email as fallback
if email, ok := ctx.Value("email").(string); ok && email != "" {
return string(email[0])
return strings.ToUpper(string(email[0]))
}
return "U"
}
@@ -513,12 +612,12 @@ func getUserInitial(ctx context.Context) string {
func getUserEmail(ctx context.Context) string {
// Try as string first
if email, ok := ctx.Value("email").(string); ok && email != "" {
return email
return strings.ToLower(email)
}
// Try as interface{} (from JWT claims)
if email, ok := ctx.Value("email").(interface{}); ok {
if strVal, ok := email.(string); ok && strVal != "" {
return strVal
return strings.ToLower(strVal)
}
}
return "user@example.com"
@@ -526,5 +625,5 @@ func getUserEmail(ctx context.Context) string {
// Helper function to get current year
func getCurrentYear() string {
return "2025"
}
return time.Now().Format("2006")
}
+117 -117
View File
@@ -5,133 +5,133 @@ import (
"strings"
)
templ Login(ctx context.Context, errorMessage string) {
templ Login(ctx context.Context, errorMessage string, hasExternalProviders bool) {
@LayoutWithContext("Login", ctx) {
<div class="min-h-[calc(100vh-4rem)] flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8 bg-secondary-50 dark:bg-secondary-900">
<div class="max-w-md w-full">
<div class="card overflow-hidden shadow-lg">
<div class="p-8">
<div class="text-center mb-8">
<div class="inline-flex items-center justify-center w-20 h-20 rounded-full bg-primary-100 dark:bg-primary-900 mb-4">
<i class="fas fa-lock text-primary-600 dark:text-primary-400 text-3xl"></i>
</div>
<h2 class="text-3xl font-bold text-secondary-900 dark:text-secondary-100">Sign In</h2>
<p class="mt-2 text-secondary-600 dark:text-secondary-400">Access your GoMFT account</p>
<div class="min-h-[calc(100vh-4rem)] flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8 bg-gray-50 dark:bg-gray-900">
<div class="max-w-md w-full space-y-8">
<div class="bg-white dark:bg-gray-800 shadow-lg rounded-lg p-8">
<div class="text-center mb-8">
<div class="mx-auto mb-4 flex h-20 w-20 items-center justify-center rounded-full bg-primary-100 dark:bg-primary-900">
<i class="fas fa-lock text-primary-600 dark:text-primary-400 text-3xl"></i>
</div>
if errorMessage != "" {
if strings.HasPrefix(errorMessage, "Password reset") || strings.Contains(errorMessage, "success") {
<div class="bg-green-100 dark:bg-green-900 border border-green-400 dark:border-green-700 text-green-700 dark:text-green-300 px-4 py-3 rounded-lg mb-6" role="alert">
<div class="flex items-center">
<i class="fas fa-check-circle mr-2"></i>
<span class="block sm:inline">{ errorMessage }</span>
</div>
</div>
} else {
<div class="bg-red-100 dark:bg-red-900 border border-red-400 dark:border-red-700 text-red-700 dark:text-red-300 px-4 py-3 rounded-lg mb-6" role="alert">
<div class="flex items-center">
<i class="fas fa-exclamation-circle mr-2"></i>
<span class="block sm:inline">{ errorMessage }</span>
</div>
</div>
}
}
<form
class="space-y-6"
method="POST"
action="/login"
x-data="{
email: '',
password: '',
loading: false,
validate() {
return this.email && this.password;
}
}">
<div class="space-y-4">
<div>
<label for="email" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Email address</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-envelope text-secondary-400 dark:text-secondary-600"></i>
</div>
<input
id="email"
name="email"
type="email"
required
x-model="email"
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
placeholder="you@example.com"/>
</div>
</div>
<div>
<label for="password" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Password</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
</div>
<input
id="password"
name="password"
type="password"
required
x-model="password"
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
placeholder="••••••••"/>
</div>
</div>
</div>
<div class="flex items-center justify-between">
<div class="flex items-center">
<input
id="remember-me"
name="remember-me"
type="checkbox"
class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-secondary-300 dark:border-secondary-700 rounded"/>
<label for="remember-me" class="ml-2 block text-sm text-secondary-700 dark:text-secondary-300">Remember me</label>
</div>
<div class="text-sm">
<a href="/forgot-password" class="font-medium text-primary-600 dark:text-primary-400 hover:text-primary-500 dark:hover:text-primary-300">Forgot password?</a>
</div>
</div>
<div>
<button
type="submit"
class="btn-primary w-full flex justify-center py-3"
x-bind:disabled="!validate()"
@click="loading = true">
<span x-show="!loading" class="flex items-center">
<i class="fas fa-sign-in-alt mr-2"></i>
Sign in
</span>
<span x-show="loading" class="flex items-center">
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Processing...
</span>
</button>
</div>
</form>
<h2 class="text-3xl font-bold text-gray-900 dark:text-white">Sign In</h2>
<p class="mt-2 text-gray-600 dark:text-gray-300">Access your GoMFT account</p>
</div>
<div class="px-8 py-4 bg-secondary-50 dark:bg-secondary-800 border-t border-secondary-200 dark:border-secondary-700 text-center">
<p class="text-sm text-secondary-600 dark:text-secondary-400">
if errorMessage != "" {
if strings.HasPrefix(errorMessage, "Password reset") || strings.Contains(errorMessage, "success") {
<div class="mb-6 flex items-center rounded-lg bg-green-50 p-4 text-green-800 dark:bg-gray-800 dark:text-green-400" role="alert">
<i class="fas fa-check-circle mr-2"></i>
<span class="[&:not(:first-child)]:ms-2">{ errorMessage }</span>
</div>
} else {
<div class="mb-6 flex items-center rounded-lg bg-red-50 p-4 text-red-800 dark:bg-gray-800 dark:text-red-400" role="alert">
<i class="fas fa-exclamation-circle mr-2"></i>
<span class="[&:not(:first-child)]:ms-2">{ errorMessage }</span>
</div>
}
}
<form
class="space-y-6"
method="POST"
action="/login"
x-data="{
email: '',
password: '',
loading: false,
validate() {
return this.email && this.password;
}
}">
<div class="space-y-4">
<div>
<label for="email" class="mb-2 block text-sm font-medium text-gray-900 dark:text-white">Email address</label>
<div class="relative">
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
<i class="fas fa-envelope text-gray-500 dark:text-gray-400"></i>
</div>
<input
id="email"
name="email"
type="email"
required
x-model="email"
class="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 pl-10 text-gray-900 focus:border-primary-500 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400 dark:focus:border-primary-500 dark:focus:ring-primary-500"
placeholder="you@example.com"/>
</div>
</div>
<div>
<label for="password" class="mb-2 block text-sm font-medium text-gray-900 dark:text-white">Password</label>
<div class="relative">
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
<i class="fas fa-key text-gray-500 dark:text-gray-400"></i>
</div>
<input
id="password"
name="password"
type="password"
required
x-model="password"
class="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 pl-10 text-gray-900 focus:border-primary-500 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400 dark:focus:border-primary-500 dark:focus:ring-primary-500"
placeholder="••••••••"/>
</div>
</div>
</div>
<div class="flex items-center justify-between">
<div class="flex items-center">
<input
id="remember-me"
name="remember-me"
type="checkbox"
class="h-4 w-4 rounded border-gray-300 bg-gray-100 text-primary-600 focus:ring-2 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-700 dark:ring-offset-gray-800"/>
<label for="remember-me" class="ms-2 text-sm text-gray-900 dark:text-gray-300">Remember me</label>
</div>
<a href="/forgot-password" class="text-sm font-medium text-primary-600 hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300">Forgot password?</a>
</div>
<button
type="submit"
class="w-full rounded-lg bg-primary-600 px-5 py-3 text-center text-sm font-medium text-white hover:bg-primary-700 focus:outline-none focus:ring-4 focus:ring-primary-300 dark:bg-primary-600 dark:hover:bg-primary-700 dark:focus:ring-primary-800"
x-bind:disabled="!validate()"
@click="loading = true">
<span x-show="!loading" class="flex items-center justify-center">
<i class="fas fa-sign-in-alt mr-2"></i>
Sign in
</span>
<span x-show="loading" class="flex items-center justify-center">
<svg class="me-3 h-5 w-5 animate-spin text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Processing...
</span>
</button>
</form>
<div class="mt-6 border-t border-gray-200 px-8 py-4 text-center dark:border-gray-700">
<p class="text-sm text-gray-600 dark:text-gray-300 mb-3">
<i class="fas fa-info-circle mr-1"></i>
Contact an administrator to create an account
</p>
if hasExternalProviders {
<!-- External Authentication Providers -->
<div id="external-auth-providers" class="mt-4">
<p class="text-sm text-gray-600 dark:text-gray-300 mb-3">Or sign in with:</p>
<div id="provider-buttons" class="flex flex-col gap-2" hx-get="/auth/providers" hx-trigger="load" hx-target="#provider-buttons">
<div class="animate-pulse flex justify-center">
<div class="h-10 bg-gray-200 rounded w-full max-w-[200px] dark:bg-gray-700"></div>
</div>
</div>
</div>
}
</div>
</div>
<!-- Security Notice -->
<div class="mt-8 text-center">
<div class="inline-flex items-center text-sm text-secondary-500 dark:text-secondary-400">
<div class="text-center">
<div class="inline-flex items-center text-sm text-gray-600 dark:text-gray-400">
<i class="fas fa-shield-alt mr-2 text-primary-500"></i>
<span>Secure, encrypted connection</span>
</div>
@@ -0,0 +1,43 @@
package dialog
import (
"fmt"
// "strconv" // No longer needed here
)
// NotificationDialog component for confirmation dialogs using Flowbite modal
templ NotificationDialog(id string, title string, message string, confirmClass string, confirmText string, action string, serviceID uint, serviceName string) {
<div id={ id } tabindex="-1" aria-hidden="true" class="hidden fixed top-0 right-0 left-0 z-50 justify-center items-center w-full md:inset-0 h-[calc(100%-1rem)] max-h-full">
<!-- Backdrop -->
<div id={ fmt.Sprintf("%s-backdrop", id) } class="fixed inset-0 bg-gray-900/50 dark:bg-gray-900/80 backdrop-blur-sm"></div>
<!-- Modal content -->
<div class="relative p-4 w-full max-w-md max-h-full mx-auto">
<div class="relative bg-white rounded-lg shadow dark:bg-gray-700">
<div class="p-6 text-center">
if action == "delete" {
<i class="fas fa-trash-alt text-red-400 text-3xl mb-4"></i>
} else {
<i class="fas fa-exclamation-triangle text-yellow-400 text-3xl mb-4"></i>
}
<h3 class="mb-5 text-lg font-normal text-gray-500 dark:text-gray-400">{ message }</h3>
<button
type="button"
class={ confirmClass }
hx-delete={ fmt.Sprintf("/admin/settings/notifications/%d", serviceID) }
hx-target="body"
data-service-name={ serviceName }
data-service-id={ fmt.Sprint(serviceID) }
id={ fmt.Sprintf("delete-btn-%d", serviceID) }
onclick={ templ.ComponentScript{Call: fmt.Sprintf("triggerServiceDelete('%s', %d, '%s')", id, serviceID, serviceName)} }>
{ confirmText }
</button>
<button type="button" onclick={ templ.ComponentScript{Call: fmt.Sprintf("closeModal('%s')", id)} } class="text-gray-500 bg-white hover:bg-gray-100 focus:ring-4 focus:outline-none focus:ring-gray-200 rounded-lg border border-gray-200 text-sm font-medium px-5 py-2.5 hover:text-gray-900 focus:z-10 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-500 dark:hover:text-white dark:hover:bg-gray-600 dark:focus:ring-gray-600">
Cancel
</button>
</div>
</div>
</div>
</div>
}
// Scripts (triggerServiceDelete, closeModal, showModal) are now expected to be defined globally or in the calling template (e.g., list.templ).
@@ -0,0 +1,20 @@
package dialog
// DialogScripts provides the JavaScript function specific to the notification delete confirmation dialog.
templ DialogScripts() {
<script type="text/javascript">
// Called when the delete confirmation button is clicked.
// Primarily closes the modal; the actual delete is handled by hx-delete.
function triggerServiceDelete(dialogId, serviceId, serviceName) {
console.log(`Confirmed delete for service: ${serviceName} (ID: ${serviceId}). Closing modal: ${dialogId}`);
// Call the global closeModal function defined elsewhere (e.g., app.js)
if (typeof closeModal === 'function') {
closeModal(dialogId);
} else {
console.error('Global closeModal function not found.');
}
// Optional: Show a "Deleting..." toast here if desired.
// The hx-delete attribute on the button will trigger the actual backend request.
}
</script>
}
@@ -0,0 +1,32 @@
package fields
import (
"github.com/starfleetcptn/gomft/components/notifications/types"
// No utils needed for this specific template yet
)
templ EmailFields(data types.NotificationFormData) {
<!-- TODO: Populate value attributes if editing an email service -->
<div id="email_fields" class="hidden notification-fields">
<div class="mb-6">
<label for="smtp_host" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">SMTP Host</label>
<input type="text" id="smtp_host" name="smtp_host" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="smtp.example.com"/>
</div>
<div class="mb-6">
<label for="smtp_port" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">SMTP Port</label>
<input type="number" id="smtp_port" name="smtp_port" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="587"/>
</div>
<div class="mb-6">
<label for="smtp_username" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">SMTP Username</label>
<input type="text" id="smtp_username" name="smtp_username" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="user@example.com"/>
</div>
<div class="mb-6">
<label for="smtp_password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">SMTP Password</label>
<input type="password" id="smtp_password" name="smtp_password" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"/>
</div>
<div class="mb-6">
<label for="from_email" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">From Email</label>
<input type="email" id="from_email" name="from_email" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="notifications@example.com"/>
</div>
</div>
}
@@ -0,0 +1,88 @@
package fields
import (
"github.com/starfleetcptn/gomft/components/notifications/types"
// "github.com/starfleetcptn/gomft/components/notifications/form/utils" // Removed as unused
)
templ GotifyFields(data types.NotificationFormData) {
<div id="gotify_fields" class="hidden notification-fields">
<div class="mb-6">
<label for="gotify_url" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Gotify Server URL</label>
if data.NotificationService.GotifyURL != "" {
<input type="url" id="gotify_url" name="gotify_url" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="https://gotify.example.com" value={ data.NotificationService.GotifyURL }/>
} else {
<input type="url" id="gotify_url" name="gotify_url" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="https://gotify.example.com" value=""/>
}
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">URL of your Gotify server</p>
</div>
<div class="mb-6">
<label for="gotify_token" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Application Token</label>
if data.NotificationService.GotifyToken != "" {
<input type="text" id="gotify_token" name="gotify_token" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="A-M-XiEQj.zX5d" value={ data.NotificationService.GotifyToken }/>
} else {
<input type="text" id="gotify_token" name="gotify_token" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="A-M-XiEQj.zX5d" value=""/>
}
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Find this in your Gotify application settings</p>
</div>
<div class="mb-6">
<label for="gotify_priority" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Default Priority</label>
<select id="gotify_priority" name="gotify_priority" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500">
<option value="0">Low (0)</option>
if data.NotificationService.GotifyPriority != "" && data.NotificationService.GotifyPriority == "5" {
<option value="5" selected="selected">Normal (5)</option>
} else {
<option value="5">Normal (5)</option>
}
<option value="8">High (8)</option>
</select>
</div>
<div class="mb-6">
<label for="gotify_title_template" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Message Title Template</label>
if data.NotificationService.GotifyTitleTemplate != "" {
<input type="text" id="gotify_title_template" name="gotify_title_template" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" value={ data.NotificationService.GotifyTitleTemplate }/>
} else {
<input type="text" id="gotify_title_template" name="gotify_title_template" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="{{job.name}} {{job.status}}" value=""/>
}
</div>
<div class="mb-6">
<label for="gotify_message_template" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Message Body Template</label>
<textarea
id="gotify_message_template"
name="gotify_message_template"
rows="4"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="Job '{{job.name}}' {{job.status}} at {{job.completed_at}}. {{job.file_count}} files transferred ({{job.transfer_bytes}} bytes)."
>
if data.NotificationService.GotifyMessageTemplate != "" {
data.NotificationService.GotifyMessageTemplate
}</textarea>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Use placeholders for dynamic values. Available variables: job.*, instance.*, timestamp, notification.*</p>
</div>
// Removed duplicate Event Triggers section - now handled in form.templ
<!-- Test notification button for Gotify -->
<div class="mb-6 p-4 bg-gray-50 border border-gray-200 rounded-lg dark:bg-gray-700 dark:border-gray-600">
<div class="flex items-center justify-between mb-2">
<h4 class="text-base font-medium text-gray-900 dark:text-white">Test Configuration</h4>
<button
type="button"
id="test-gotify-btn"
hx-post="/admin/settings/notifications/test"
hx-trigger="click"
hx-target="#test-notification-result"
hx-swap="outerHTML"
class="px-3 py-2 text-xs font-medium text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 rounded-lg dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800"
>
<i class="fas fa-paper-plane mr-1"></i>
Send Test Notification
</button>
</div>
<p class="text-sm text-gray-500 dark:text-gray-400">
Send a test notification to verify your Gotify configuration works correctly before saving.
</p>
<div id="test-notification-result" class="mt-3 hidden">
<!-- Result will be shown here -->
</div>
</div>
</div>
}
@@ -0,0 +1,105 @@
package fields
import (
"github.com/starfleetcptn/gomft/components/notifications/types"
// "github.com/starfleetcptn/gomft/components/notifications/form/utils" // Removed as unused
)
templ NtfyFields(data types.NotificationFormData) {
<div id="ntfy_fields" class="hidden notification-fields">
<div class="mb-6">
<label for="ntfy_server" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Ntfy Server</label>
if data.NotificationService.NtfyServer != "" {
<input type="url" id="ntfy_server" name="ntfy_server" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="https://ntfy.sh" value={ data.NotificationService.NtfyServer }/>
} else {
<input type="url" id="ntfy_server" name="ntfy_server" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="https://ntfy.sh" value="https://ntfy.sh"/>
}
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">The Ntfy server URL (default: ntfy.sh)</p>
</div>
<div class="mb-6">
<label for="ntfy_topic" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Topic</label>
if data.NotificationService.NtfyTopic != "" {
<input type="text" id="ntfy_topic" name="ntfy_topic" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="your-unique-topic" value={ data.NotificationService.NtfyTopic }/>
} else {
<input type="text" id="ntfy_topic" name="ntfy_topic" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="your-unique-topic" value="gomft"/>
}
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Choose a unique, unguessable topic name</p>
</div>
<div class="mb-6">
<label for="ntfy_priority" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Default Priority</label>
<select id="ntfy_priority" name="ntfy_priority" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500">
<option value="1">Low (1)</option>
if data.NotificationService.NtfyPriority == "3" {
<option value="3" selected="selected">Default (3)</option>
} else {
<option value="3">Default (3)</option>
}
<option value="4">High (4)</option>
<option value="5">Urgent (5)</option>
</select>
</div>
<div class="mb-6">
<label for="ntfy_username" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Username (Optional)</label>
if data.NotificationService.NtfyUsername != "" {
<input type="text" id="ntfy_username" name="ntfy_username" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="Username for protected topics" value={ data.NotificationService.NtfyUsername }/>
} else {
<input type="text" id="ntfy_username" name="ntfy_username" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="Username for protected topics" value=""/>
}
</div>
<div class="mb-6">
<label for="ntfy_password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Password (Optional)</label>
if data.NotificationService.NtfyPassword != "" {
<input type="password" id="ntfy_password" name="ntfy_password" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="Password for protected topics" value={ data.NotificationService.NtfyPassword }/>
} else {
<input type="password" id="ntfy_password" name="ntfy_password" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="Password for protected topics" value=""/>
}
</div>
<div class="mb-6">
<label for="ntfy_title_template" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Notification Title Template</label>
if data.NotificationService.NtfyTitleTemplate != "" {
<input type="text" id="ntfy_title_template" name="ntfy_title_template" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" value={ data.NotificationService.NtfyTitleTemplate }/>
} else {
<input type="text" id="ntfy_title_template" name="ntfy_title_template" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="{{job.name}} {{job.status}}" value=""/>
}
</div>
<div class="mb-6">
<label for="ntfy_message_template" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Message Body Template</label>
<textarea
id="ntfy_message_template"
name="ntfy_message_template"
rows="4"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="Job '{{job.name}}' {{job.status}} at {{job.completed_at}}. {{job.file_count}} files transferred ({{job.transfer_bytes}} bytes)."
>
if data.NotificationService.NtfyMessageTemplate != "" {
data.NotificationService.NtfyMessageTemplate
} </textarea>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Use placeholders for dynamic values. Available variables: job.*, instance.*, timestamp, notification.*</p>
</div>
// Removed duplicate Event Triggers section - now handled in form.templ
<!-- Test notification button for Ntfy -->
<div class="mb-6 p-4 bg-gray-50 border border-gray-200 rounded-lg dark:bg-gray-700 dark:border-gray-600">
<div class="flex items-center justify-between mb-2">
<h4 class="text-base font-medium text-gray-900 dark:text-white">Test Configuration</h4>
<button
type="button"
id="test-ntfy-btn"
hx-post="/admin/settings/notifications/test"
hx-trigger="click"
hx-target="#test-notification-result"
hx-swap="outerHTML"
class="px-3 py-2 text-xs font-medium text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 rounded-lg dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800"
>
<i class="fas fa-paper-plane mr-1"></i>
Send Test Notification
</button>
</div>
<p class="text-sm text-gray-500 dark:text-gray-400">
Send a test notification to verify your Ntfy configuration works correctly before saving.
</p>
<div id="test-notification-result" class="mt-3 hidden">
<!-- Result will be shown here -->
</div>
</div>
</div>
}
@@ -0,0 +1,75 @@
package fields
import (
"github.com/starfleetcptn/gomft/components/notifications/types"
// "github.com/starfleetcptn/gomft/components/notifications/form/utils" // Removed as unused
)
templ PushbulletFields(data types.NotificationFormData) {
<div id="pushbullet_fields" class="hidden notification-fields">
<div class="mb-6">
<label for="pushbullet_api_key" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">API Key</label>
if data.NotificationService.PushbulletAPIKey != "" {
<input type="text" id="pushbullet_api_key" name="pushbullet_api_key" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="o.XyzAbCdEfGhIjKlMnOpQrSt" value={ data.NotificationService.PushbulletAPIKey }/>
} else {
<input type="text" id="pushbullet_api_key" name="pushbullet_api_key" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="o.XyzAbCdEfGhIjKlMnOpQrSt" value=""/>
}
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Get your API key from <a href="https://www.pushbullet.com/#settings/account" target="_blank" class="text-blue-500 hover:underline">Pushbullet Account Settings</a></p>
</div>
<div class="mb-6">
<label for="pushbullet_device_iden" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Device Identifier (Optional)</label>
if data.NotificationService.PushbulletDeviceID != "" {
<input type="text" id="pushbullet_device_iden" name="pushbullet_device_iden" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="Leave empty to send to all devices" value={ data.NotificationService.PushbulletDeviceID }/>
} else {
<input type="text" id="pushbullet_device_iden" name="pushbullet_device_iden" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="Leave empty to send to all devices" value=""/>
}
</div>
<div class="mb-6">
<label for="pushbullet_title_template" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Notification Title Template</label>
if data.NotificationService.PushbulletTitleTemplate != "" {
<input type="text" id="pushbullet_title_template" name="pushbullet_title_template" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" value={ data.NotificationService.PushbulletTitleTemplate }/>
} else {
<input type="text" id="pushbullet_title_template" name="pushbullet_title_template" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="{{job.name}} {{job.status}}" value=""/>
}
</div>
<div class="mb-6">
<label for="pushbullet_body_template" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Message Body Template</label>
<textarea
id="pushbullet_body_template"
name="pushbullet_body_template"
rows="4"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="Job '{{job.name}}' {{job.status}} at {{job.completed_at}}. {{job.file_count}} files transferred ({{job.transfer_bytes}} bytes)."
>
if data.NotificationService.PushbulletBodyTemplate != "" {
data.NotificationService.PushbulletBodyTemplate
} </textarea>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Use placeholders for dynamic values. Available variables: job.*, instance.*, timestamp, notification.*</p>
</div>
// Removed duplicate Event Triggers section - now handled in form.templ
<!-- Test notification button for Pushbullet -->
<div class="mb-6 p-4 bg-gray-50 border border-gray-200 rounded-lg dark:bg-gray-700 dark:border-gray-600">
<div class="flex items-center justify-between mb-2">
<h4 class="text-base font-medium text-gray-900 dark:text-white">Test Configuration</h4>
<button
type="button"
id="test-pushbullet-btn"
hx-post="/admin/settings/notifications/test"
hx-trigger="click"
hx-target="#test-notification-result"
hx-swap="outerHTML"
class="px-3 py-2 text-xs font-medium text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 rounded-lg dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800"
>
<i class="fas fa-paper-plane mr-1"></i>
Send Test Notification
</button>
</div>
<p class="text-sm text-gray-500 dark:text-gray-400">
Send a test notification to verify your Pushbullet configuration works correctly before saving.
</p>
<div id="test-notification-result" class="mt-3 hidden">
<!-- Result will be shown here -->
</div>
</div>
</div>
}
@@ -0,0 +1,126 @@
package fields
import (
"github.com/starfleetcptn/gomft/components/notifications/types"
// "github.com/starfleetcptn/gomft/components/notifications/form/utils" // Removed as unused
)
templ PushoverFields(data types.NotificationFormData) {
<div id="pushover_fields" class="hidden notification-fields">
<div class="mb-6">
<label for="pushover_app_token" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">API Token/Key</label>
if data.NotificationService.PushoverAPIToken != "" {
<input type="text" id="pushover_app_token" name="pushover_app_token" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="azGDORePK8gMaC0QOYAMyEEuzJnyUi" value={ data.NotificationService.PushoverAPIToken }/>
} else {
<input type="text" id="pushover_app_token" name="pushover_app_token" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="azGDORePK8gMaC0QOYAMyEEuzJnyUi" value=""/>
}
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Your application's API token/key from <a href="https://pushover.net/apps" target="_blank" class="text-blue-500 hover:underline">Pushover Dashboard</a></p>
</div>
<div class="mb-6">
<label for="pushover_user_key" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">User Key</label>
if data.NotificationService.PushoverUserKey != "" {
<input type="text" id="pushover_user_key" name="pushover_user_key" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="uQiRzpo4DXghDmr9QzzfQu27cmVRsG" value={ data.NotificationService.PushoverUserKey }/>
} else {
<input type="text" id="pushover_user_key" name="pushover_user_key" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="uQiRzpo4DXghDmr9QzzfQu27cmVRsG" value=""/>
}
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Your user key from <a href="https://pushover.net/" target="_blank" class="text-blue-500 hover:underline">Pushover Dashboard</a></p>
</div>
<div class="mb-6">
<label for="pushover_device" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Device Name (Optional)</label>
if data.NotificationService.PushoverDevice != "" {
<input type="text" id="pushover_device" name="pushover_device" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="Leave empty to send to all devices" value={ data.NotificationService.PushoverDevice }/>
} else {
<input type="text" id="pushover_device" name="pushover_device" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="Leave empty to send to all devices" value=""/>
}
</div>
<div class="mb-6">
<label for="pushover_priority" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Default Priority</label>
<select id="pushover_priority" name="pushover_priority" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500">
<option value="-2">Lowest (-2)</option>
<option value="-1">Low (-1)</option>
if data.NotificationService.PushoverPriority != "" && data.NotificationService.PushoverPriority == "0" {
<option value="0" selected="selected">Normal (0)</option>
} else {
<option value="0">Normal (0)</option>
}
<option value="1">High (1)</option>
<option value="2">Emergency (2)</option>
</select>
</div>
<div class="mb-6">
<label for="pushover_sound" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Sound</label>
<select id="pushover_sound" name="pushover_sound" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500">
<option value="pushover">Pushover (default)</option>
<option value="bike">Bike</option>
<option value="bugle">Bugle</option>
<option value="cashregister">Cash Register</option>
<option value="classical">Classical</option>
<option value="cosmic">Cosmic</option>
<option value="falling">Falling</option>
<option value="gamelan">Gamelan</option>
<option value="incoming">Incoming</option>
<option value="intermission">Intermission</option>
<option value="magic">Magic</option>
<option value="mechanical">Mechanical</option>
<option value="pianobar">Piano Bar</option>
<option value="siren">Siren</option>
<option value="spacealarm">Space Alarm</option>
<option value="tugboat">Tug Boat</option>
<option value="alien">Alien Alarm (long)</option>
<option value="climb">Climb (long)</option>
<option value="persistent">Persistent (long)</option>
<option value="echo">Echo (long)</option>
<option value="updown">Up Down (long)</option>
<option value="vibrate">Vibrate Only</option>
<option value="none">None (silent)</option>
</select>
</div>
<div class="mb-6">
<label for="pushover_title_template" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Message Title Template</label>
if data.NotificationService.PushoverTitleTemplate != "" {
<input type="text" id="pushover_title_template" name="pushover_title_template" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" value={ data.NotificationService.PushoverTitleTemplate }/>
} else {
<input type="text" id="pushover_title_template" name="pushover_title_template" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="'{{job.name}}' {{job.status}}" value=""/>
}
</div>
<div class="mb-6">
<label for="pushover_message_template" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Message Body Template</label>
<textarea
id="pushover_message_template"
name="pushover_message_template"
rows="4"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="Job '{{job.name}}' {{job.status}} at {{job.completed_at}}. {{job.file_count}} files transferred ({{job.transfer_bytes}} bytes)."
>
if data.NotificationService.PushoverMessageTemplate != "" {
data.NotificationService.PushoverMessageTemplate
} </textarea>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Use placeholders for dynamic values. Available variables: job.*, instance.*, timestamp, notification.*</p>
</div>
// Removed duplicate Event Triggers section - now handled in form.templ
<!-- Test notification button for Pushover -->
<div class="mb-6 p-4 bg-gray-50 border border-gray-200 rounded-lg dark:bg-gray-700 dark:border-gray-600">
<div class="flex items-center justify-between mb-2">
<h4 class="text-base font-medium text-gray-900 dark:text-white">Test Configuration</h4>
<button
type="button"
id="test-pushover-btn"
hx-post="/admin/settings/notifications/test"
hx-trigger="click"
hx-target="#test-notification-result"
hx-swap="outerHTML"
class="px-3 py-2 text-xs font-medium text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 rounded-lg dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800"
>
<i class="fas fa-paper-plane mr-1"></i>
Send Test Notification
</button>
</div>
<p class="text-sm text-gray-500 dark:text-gray-400">
Send a test notification to verify your Pushover configuration works correctly before saving.
</p>
<div id="test-notification-result" class="mt-3 hidden">
<!-- Result will be shown here -->
</div>
</div>
</div>
}
@@ -0,0 +1,142 @@
package fields
import (
"github.com/starfleetcptn/gomft/components/notifications/types"
// "github.com/starfleetcptn/gomft/components/notifications/form/utils" // Removed as unused
)
templ WebhookFields(data types.NotificationFormData) {
<div id="webhook_fields" class="hidden notification-fields">
<div class="mb-6">
<label for="webhook_url" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Webhook URL</label>
if data.NotificationService.WebhookURL != "" {
<input type="url" id="webhook_url" name="webhook_url" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="https://api.example.com/webhook" value={ data.NotificationService.WebhookURL }/>
} else {
<input type="url" id="webhook_url" name="webhook_url" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="https://api.example.com/webhook" value=""/>
}
</div>
<div class="mb-6">
<label for="method" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">HTTP Method</label>
<select id="method" name="method" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500">
if data.NotificationService.Method != "" {
if data.NotificationService.Method == "POST" {
<option value="POST" selected="selected">POST</option>
} else {
<option value="POST">POST</option>
}
if data.NotificationService.Method == "PUT" {
<option value="PUT" selected="selected">PUT</option>
} else {
<option value="PUT">PUT</option>
}
} else {
<option value="POST">POST</option>
<option value="PUT">PUT</option>
}
</select>
</div>
<div class="mb-6">
<label for="headers" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Headers (JSON)</label>
if data.NotificationService.Headers != "" {
<textarea id="headers" name="headers" rows="3" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder='{"Content-Type": "application/json", "Authorization": "Bearer token"}'>{ data.NotificationService.Headers }</textarea>
} else {
<textarea id="headers" name="headers" rows="3" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder='{"Content-Type": "application/json", "Authorization": "Bearer token"}'></textarea>
}
</div>
<div class="mb-6">
<label for="payload_template" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Payload Template (JSON)</label>
<textarea
id="payload_template"
name="payload_template"
rows="5"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder='{
"event": "{{job.event}}",
"job": {
"id": "{{job.id}}",
"name": "{{job.name}}",
"status": "{{job.status}}",
"message": "{{job.message}}",
"started_at": "{{job.started_at}}",
"completed_at": "{{job.completed_at}}",
"duration_seconds": {{job.duration_seconds}},
"config_id": "{{job.config_id}}",
"config_name": "{{job.config_name}}",
"transfer_bytes": {{job.transfer_bytes}},
"file_count": {{job.file_count}}
},
"instance": {
"id": "{{instance.id}}",
"name": "{{instance.name}}",
"version": "{{instance.version}}",
"environment": "{{instance.environment}}"
},
"timestamp": "{{timestamp}}",
"notification_id": "{{notification.id}}"
}'
>
if data.NotificationService.PayloadTemplate != "" {
data.NotificationService.PayloadTemplate
} </textarea>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Use placeholders for dynamic values. Available variables: job.*, instance.*, timestamp, notification.*</p>
</div>
// Removed duplicate Event Triggers section - now handled in form.templ
<div class="mb-6">
<label for="secret_key" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Secret Key (for signature verification)</label>
if data.NotificationService.SecretKey != "" {
<input type="text" id="secret_key" name="secret_key" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="Optional signature verification key" value={ data.NotificationService.SecretKey }/>
}
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">If provided, all webhooks will include an X-GoMFT-Signature header</p>
</div>
<div class="mb-6">
<label for="retry_policy" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Retry Policy</label>
<select id="retry_policy" name="retry_policy" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500">
if data.NotificationService.RetryPolicy != "" {
if data.NotificationService.RetryPolicy == "none" {
<option value="none" selected="selected">No retries</option>
} else {
<option value="none">No retries</option>
}
if data.NotificationService.RetryPolicy == "simple" {
<option value="simple" selected="selected">Simple (3 retries)</option>
} else {
<option value="simple">Simple (3 retries)</option>
}
if data.NotificationService.RetryPolicy == "exponential" {
<option value="exponential" selected="selected">Exponential backoff</option>
} else {
<option value="exponential">Exponential backoff</option>
}
} else {
<option value="none">No retries</option>
<option value="simple">Simple (3 retries)</option>
<option value="exponential">Exponential backoff</option>
}
</select>
</div>
<!-- Test notification button -->
<div class="mb-6 p-4 bg-gray-50 border border-gray-200 rounded-lg dark:bg-gray-700 dark:border-gray-600">
<div class="flex items-center justify-between mb-2">
<h4 class="text-base font-medium text-gray-900 dark:text-white">Test Configuration</h4>
<button
type="button"
id="test-webhook-btn"
hx-post="/admin/settings/notifications/test"
hx-trigger="click"
hx-target="#test-notification-result"
hx-swap="outerHTML"
class="px-3 py-2 text-xs font-medium text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 rounded-lg dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800"
>
<i class="fas fa-paper-plane mr-1"></i>
Send Test Notification
</button>
</div>
<p class="text-sm text-gray-500 dark:text-gray-400">
Send a test notification to verify your configuration works correctly before saving.
</p>
<div id="test-notification-result" class="mt-3 hidden">
<!-- Result will be shown here -->
</div>
</div>
</div>
}
+219
View File
@@ -0,0 +1,219 @@
package form
import (
"context"
"fmt"
"github.com/starfleetcptn/gomft/components" // For LayoutWithContext
"github.com/starfleetcptn/gomft/components/notifications/types"
"github.com/starfleetcptn/gomft/components/notifications/form/utils"
"github.com/starfleetcptn/gomft/components/notifications/form/fields" // Import fields
)
templ NotificationForm(ctx context.Context, data types.NotificationFormData) {
@components.LayoutWithContext(utils.GetNotificationFormTitle(data.IsNew), ctx) {
<script>
// Toggle notification fields based on selection
document.addEventListener('DOMContentLoaded', function() {
const typeSelector = document.getElementById('notification_type');
// Ensure typeSelector exists before adding listener
if (!typeSelector) {
console.warn("Notification type selector not found.");
return;
}
const allFields = document.querySelectorAll('.notification-fields');
const commonFields = document.querySelectorAll('.common-fields');
function toggleFields() {
// Hide all specific fields first
allFields.forEach(field => field.classList.add('hidden'));
// Show/hide common fields based on selection
const selectedType = typeSelector.value;
if (selectedType) {
// Show common fields (name, description, is_enabled, submit)
commonFields.forEach(field => field.classList.remove('hidden'));
// Show the selected type's specific fields
const fieldsToShow = document.getElementById(`${selectedType}_fields`);
if (fieldsToShow) {
fieldsToShow.classList.remove('hidden');
}
} else {
// Hide common fields if no type selected
commonFields.forEach(field => field.classList.add('hidden'));
}
}
typeSelector.addEventListener('change', toggleFields);
// Initialize form state on load (if editing or if a type is pre-selected)
toggleFields();
});
</script>
<!-- Status and Error Messages (Handled by shared toast component in layout) -->
<div id="notification-form-container" class="notifications-page bg-gray-50 dark:bg-gray-900 min-h-screen">
<div class="pb-8 w-full max-w-4xl mx-auto">
<!-- Success Message (hidden, used for HTMX responses) -->
if data.SuccessMessage != "" {
<div class="hidden success-message">{ data.SuccessMessage }</div>
}
<!-- Error Message (hidden, used for HTMX responses) -->
if data.ErrorMessage != "" {
<div class="hidden error-message">{ data.ErrorMessage }</div>
}
<div class="mb-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
<i class="fas fa-bell w-6 h-6 mr-2 text-blue-500 dark:text-blue-400"></i>
{ utils.GetNotificationFormTitle(data.IsNew) }
</h1>
<a href="/admin/settings/notifications" class="flex items-center justify-center text-gray-700 bg-gray-100 hover:bg-gray-200 focus:ring-4 focus:ring-gray-300 font-medium rounded-lg px-5 py-2.5 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600 focus:outline-none dark:focus:ring-gray-700">
<i class="fas fa-arrow-left w-4 h-4 mr-2"></i>
Back to Notification Services
</a>
</div>
<!-- Add Notification Service Form -->
<div class="mb-6 p-6 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800">
<form id="notification-form"
if data.IsNew {
hx-post="/admin/settings/notifications"
hx-redirect="/admin/settings/notifications"
} else {
hx-put={ fmt.Sprintf("/admin/settings/notifications/%d", data.NotificationService.ID) }
hx-redirect="/admin/settings/notifications"
}
hx-target="body">
<div class="mb-6">
<label for="notification_type" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Notification Type</label>
<select id="notification_type" name="type" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500">
<option value="">Select a type</option>
if data.NotificationService != nil && data.NotificationService.Type == "webhook" {
<option value="webhook" selected="selected">Webhook</option>
} else {
<option value="webhook">Webhook</option>
}
if data.NotificationService != nil && data.NotificationService.Type == "pushbullet" {
<option value="pushbullet" selected="selected">Pushbullet</option>
} else {
<option value="pushbullet">Pushbullet</option>
}
if data.NotificationService != nil && data.NotificationService.Type == "ntfy" {
<option value="ntfy" selected="selected">Ntfy</option>
} else {
<option value="ntfy">Ntfy</option>
}
if data.NotificationService != nil && data.NotificationService.Type == "gotify" {
<option value="gotify" selected="selected">Gotify</option>
} else {
<option value="gotify">Gotify</option>
}
if data.NotificationService != nil && data.NotificationService.Type == "pushover" {
<option value="pushover" selected="selected">Pushover</option>
} else {
<option value="pushover">Pushover</option>
}
<option value="email" disabled>Email (Coming Soon)</option>
</select>
</div>
<div class="mb-6 hidden common-fields">
<label for="notification_name" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Name</label>
if data.NotificationService.Name != "" {
<input type="text" id="notification_name" name="name" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="My Notification Service" required value={ data.NotificationService.Name }/>
} else {
<input type="text" id="notification_name" name="name" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="My Notification Service" required value=""/>
}
</div>
<div class="mb-6 hidden common-fields">
<label for="notification_description" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Description</label>
if data.NotificationService.Description != "" {
<textarea id="notification_description" name="description" rows="3" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="Description for this notification service">{ data.NotificationService.Description }</textarea>
} else {
<textarea id="notification_description" name="description" rows="3" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="Description for this notification service"></textarea>
}
</div>
<!-- Dynamic fields based on notification type -->
@fields.EmailFields(data)
@fields.WebhookFields(data)
@fields.PushbulletFields(data)
@fields.NtfyFields(data)
@fields.GotifyFields(data)
@fields.PushoverFields(data)
<div class="mb-6 hidden common-fields">
<label class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Event Triggers</label>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-2">Select the job events that should trigger this notification.</p>
<div class="flex flex-wrap gap-4">
for _, event := range []string{"job_start", "job_complete", "job_error"} {
<div class="flex items-center">
<input
id={ "trigger_" + event }
name="event_triggers[]"
type="checkbox"
value={ event }
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"
if utils.IsEventTriggerSelected(data.NotificationService, event, data.IsNew) {
checked
}
/>
<label for={ "trigger_" + event } class="ml-2 text-sm font-medium text-gray-900 dark:text-gray-300">{ utils.FormatEventTriggerName(event) }</label>
</div>
}
</div>
</div>
<div class="flex items-start mb-6 hidden common-fields">
<div class="flex items-center h-5">
<input type="hidden" name="is_enabled" value="false">
<input
id="is_enabled"
name="is_enabled"
type="checkbox"
value="true"
class="w-4 h-4 border border-gray-300 rounded bg-gray-50 focus:ring-3 focus:ring-blue-300 dark:bg-gray-700 dark:border-gray-600 dark:focus:ring-blue-600 dark:ring-offset-gray-800"
if data.NotificationService != nil && data.NotificationService.IsEnabled {
checked
}
/>
</div>
<div class="ml-3 text-sm">
<label for="is_enabled" class="font-medium text-gray-900 dark:text-white">Enable this notification service</label>
<p class="text-xs text-gray-500 dark:text-gray-400">Check this box to make the service active.</p>
</div>
</div>
<div class="hidden common-fields">
<button type="submit" class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800">
if data.IsNew {
Add Service
} else {
Save Changes
}
</button>
</div>
</form>
</div>
<!-- Help Notice -->
<div class="mt-8 p-4 bg-gray-50 border border-gray-200 rounded-lg dark:bg-gray-800 dark:border-gray-700">
<div class="flex">
<div class="flex-shrink-0">
<i class="fas fa-info-circle text-blue-400 dark:text-blue-400"></i>
</div>
<div class="ml-3">
<p class="text-sm text-blue-700 dark:text-blue-400">
Configure your notification service to receive alerts for job events. Different notification types have different configuration options.
</p>
</div>
</div>
</div>
</div>
</div>
<!-- Theme-specific background handled by Tailwind classes -->
}
}
@@ -0,0 +1,45 @@
package form
// FormScripts contains JavaScript specific to the notification form page.
templ FormScripts() {
<script>
// Toggle notification fields based on selection
document.addEventListener('DOMContentLoaded', function() {
const typeSelector = document.getElementById('notification_type');
// Ensure typeSelector exists before adding listener
if (!typeSelector) {
console.warn("Notification type selector not found.");
return;
}
const allFields = document.querySelectorAll('.notification-fields');
const commonFields = document.querySelectorAll('.common-fields');
function toggleFields() {
// Hide all specific fields first
allFields.forEach(field => field.classList.add('hidden'));
// Show/hide common fields based on selection
const selectedType = typeSelector.value;
if (selectedType) {
// Show common fields (name, description, is_enabled, submit)
commonFields.forEach(field => field.classList.remove('hidden'));
// Show the selected type's specific fields
const fieldsToShow = document.getElementById(`${selectedType}_fields`);
if (fieldsToShow) {
fieldsToShow.classList.remove('hidden');
}
} else {
// Hide common fields if no type selected
commonFields.forEach(field => field.classList.add('hidden'));
}
}
typeSelector.addEventListener('change', toggleFields);
// Initialize form state on load (if editing or if a type is pre-selected)
toggleFields();
});
</script>
}
@@ -0,0 +1,88 @@
package utils
import (
"strings"
)
// GetNotificationFormTitle returns the title for the notification form page.
func GetNotificationFormTitle(isNew bool) string {
if isNew {
return "Add Notification Service"
}
return "Edit Notification Service"
}
// Contains checks if a string slice contains a specific string.
func Contains(slice []string, item string) bool {
for _, a := range slice {
if a == item {
return true
}
}
return false
}
// BoolToString converts a boolean to its string representation "true" or "false".
// Useful for setting HTML attributes that expect string values.
func BoolToString(b bool) string {
if b {
return "true"
}
return "false"
}
// IsEventTriggerSelected checks if a specific event trigger should be pre-selected.
// It now accepts the anonymous struct type defined in types.NotificationFormData.
// Defaults to checking 'job_complete' and 'job_error' when creating a new service.
func IsEventTriggerSelected(service *struct {
ID uint
Name string
Description string
Type string
IsEnabled bool
EventTriggers []string
RetryPolicy string
WebhookURL string
Method string
Headers string
PayloadTemplate string
SecretKey string
PushbulletAPIKey string
PushbulletDeviceID string
PushbulletTitleTemplate string
PushbulletBodyTemplate string
NtfyServer string
NtfyTopic string
NtfyPriority string
NtfyUsername string
NtfyPassword string
NtfyTitleTemplate string
NtfyMessageTemplate string
GotifyURL string
GotifyToken string
GotifyPriority string
GotifyTitleTemplate string
GotifyMessageTemplate string
PushoverAPIToken string
PushoverUserKey string
PushoverDevice string
PushoverPriority string
PushoverSound string
PushoverTitleTemplate string
PushoverMessageTemplate string
}, event string, isNew bool) bool {
if !isNew && service != nil {
// Use the Contains helper function
return Contains(service.EventTriggers, event)
}
// Default for new services: check complete and error
return isNew && (event == "job_complete" || event == "job_error")
}
// FormatEventTriggerName converts event trigger keys to human-readable names.
func FormatEventTriggerName(event string) string {
// Replace underscores with spaces and capitalize words
name := strings.ReplaceAll(event, "_", " ")
name = strings.Title(name) // Use strings.Title for capitalization
return name
}
+207
View File
@@ -0,0 +1,207 @@
package list
import (
"context"
"fmt"
"github.com/starfleetcptn/gomft/components"
"github.com/starfleetcptn/gomft/components/notifications/dialog"
"github.com/starfleetcptn/gomft/components/notifications/types"
)
// List renders the notification services list page.
templ List(ctx context.Context, data types.SettingsNotificationsData) {
@components.LayoutWithContext("Notification Services", ctx) {
<!-- Status and Error Messages (Handled by shared toast component in layout) -->
<div id="notifications-container" class="notifications-page bg-gray-50 dark:bg-gray-900 min-h-screen">
<div class="pb-8 w-full">
<!-- Success Message (hidden, used for HTMX responses/toast trigger) -->
if data.SuccessMessage != "" {
<div class="hidden success-message">{ data.SuccessMessage }</div>
}
<!-- Error Message (hidden, used for HTMX responses/toast trigger) -->
if data.ErrorMessage != "" {
<div class="hidden error-message">{ data.ErrorMessage }</div>
}
<div class="mb-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
<i class="fas fa-bell w-6 h-6 mr-2 text-blue-500 dark:text-blue-400"></i>
Notification Services
</h1>
<a href="/admin/settings/notifications/new" class="flex items-center justify-center text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg px-5 py-2.5 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800">
<i class="fas fa-plus w-4 h-4 mr-2"></i>
Add Notification Service
</a>
</div>
<!-- List of Notification Services -->
if len(data.NotificationServices) == 0 {
<div class="text-center py-8 bg-white dark:bg-gray-800 shadow-md rounded-lg">
<div class="inline-flex items-center justify-center w-16 h-16 rounded-full bg-blue-100 dark:bg-blue-900 mb-4">
<i class="fas fa-bell text-2xl text-blue-600 dark:text-blue-400"></i>
</div>
<h3 class="mb-2 text-lg font-semibold text-gray-900 dark:text-white">No notification services configured</h3>
<p class="text-gray-500 dark:text-gray-400 mb-4">Add a notification service to receive alerts for job events.</p>
<a href="/admin/settings/notifications/new" class="inline-flex items-center px-3 py-2 text-sm font-medium text-center text-white bg-blue-700 rounded-lg hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800">
<i class="fas fa-plus w-4 h-4 mr-2"></i>
Add First Notification Service
</a>
</div>
} else {
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 overflow-hidden">
<ul class="divide-y divide-gray-200 dark:divide-gray-700">
for _, service := range data.NotificationServices {
<li>
<div class="block hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
<div class="px-4 py-4 sm:px-6">
<div class="flex items-center justify-between">
<div class="flex items-center">
if service.Type == "email" {
<div class="w-10 h-10 rounded-full bg-blue-100 flex items-center justify-center text-blue-600 dark:bg-blue-900 dark:text-blue-400 mr-3">
<i class="fas fa-envelope"></i>
</div>
} else if service.Type == "webhook" {
<div class="w-10 h-10 rounded-full bg-green-100 flex items-center justify-center text-green-600 dark:bg-green-900 dark:text-green-400 mr-3">
<i class="fas fa-code"></i>
</div>
} else { // Default icon
<div class="w-10 h-10 rounded-full bg-gray-100 flex items-center justify-center text-gray-600 dark:bg-gray-700 dark:text-gray-400 mr-3">
<i class="fas fa-bell"></i>
</div>
}
<div>
<p class="text-sm font-medium text-blue-600 dark:text-blue-400 truncate">
{ service.Name }
</p>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
{ service.Description }
</p>
</div>
</div>
<div class="ml-2 flex-shrink-0 flex space-x-2">
<a
href={ templ.SafeURL(fmt.Sprintf("/admin/settings/notifications/%d/edit", service.ID)) }
class="text-gray-500 bg-white focus:outline-none hover:bg-gray-100 focus:ring-4 focus:ring-gray-200 rounded-lg text-sm p-2 mr-1 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white dark:focus:ring-gray-700"
>
<i class="fas fa-edit"></i>
</a>
<!-- Add notification delete dialog -->
@dialog.NotificationDialog(
fmt.Sprintf("delete-notification-dialog-%d", service.ID),
"Delete Notification Service",
fmt.Sprintf("Are you sure you want to delete the notification service '%s'? This cannot be undone.", service.Name),
"text-white bg-red-700 hover:bg-red-800 focus:ring-4 focus:ring-red-300 font-medium rounded-lg text-sm px-5 py-2.5 dark:bg-red-600 dark:hover:bg-red-700 focus:outline-none dark:focus:ring-red-800",
"Delete",
"delete",
service.ID,
service.Name,
)
<button
type="button"
onclick={ templ.ComponentScript{Call: fmt.Sprintf("showModal('delete-notification-dialog-%d')", service.ID)} }
class="text-red-500 bg-white focus:outline-none hover:bg-gray-100 focus:ring-4 focus:ring-gray-200 rounded-lg text-sm p-2 dark:bg-gray-800 dark:text-red-400 dark:hover:bg-gray-700 dark:hover:text-white dark:focus:ring-gray-700"
>
<i class="fas fa-trash-alt"></i>
</button>
</div>
</div>
<div class="mt-3 sm:flex sm:justify-between">
<div class="sm:flex flex-col md:flex-row gap-2 md:gap-6">
<div class="flex items-center">
<span
class={ "px-2 py-1 text-xs font-medium rounded-full",
templ.KV("bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300", service.IsEnabled),
templ.KV("bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300", !service.IsEnabled) }
>
if service.IsEnabled {
Active
} else {
Disabled
}
</span>
<span class="ml-2 px-2 py-1 text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300 rounded-full">
{ service.Type }
</span>
if len(service.EventTriggers) > 0 && service.Type == "webhook" {
<span class="ml-2 px-2 py-1 text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300 rounded-full">
{ fmt.Sprintf("%d triggers", len(service.EventTriggers)) }
</span>
}
if service.SuccessCount > 0 || service.FailureCount > 0 {
<span class="ml-2 px-2 py-1 text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300 rounded-full">
{ fmt.Sprintf("%d/%d", service.SuccessCount, service.SuccessCount + service.FailureCount) }
</span>
}
</div>
</div>
if service.Type == "webhook" {
<div class="mt-2 md:mt-0 flex items-center space-x-4">
<div class="text-xs">
<span class="text-gray-500 dark:text-gray-400">Events:</span>
<span class="ml-1 text-gray-900 dark:text-gray-300">
if len(service.EventTriggers) == 0 {
None
} else {
for i, trigger := range service.EventTriggers {
if i > 0 {
<span>, </span>
}
{ trigger }
}
}
</span>
</div>
<div class="text-xs">
<span class="text-gray-500 dark:text-gray-400">Retry:</span>
<span class="ml-1 text-gray-900 dark:text-gray-300">
if service.RetryPolicy == "" {
Default
} else {
{ service.RetryPolicy }
}
</span>
</div>
</div>
} else {
<div class="mt-2 md:mt-0 flex items-center text-sm text-gray-500 dark:text-gray-400">
<i class="far fa-clock w-4 h-4 mr-1.5 text-gray-400 dark:text-gray-500"></i>
<p>
Last sent:
if service.SuccessCount > 0 {
"Recently"
} else {
"Never"
}
</p>
</div>
}
</div>
</div>
</div>
</li>
}
</ul>
</div>
}
<!-- Help Notice Placeholder -->
<div class="mt-8 p-4 bg-gray-50 border border-gray-200 rounded-lg dark:bg-gray-800 dark:border-gray-700">
<div class="flex">
<div class="flex-shrink-0">
<i class="fas fa-info-circle text-blue-400 dark:text-blue-400"></i>
</div>
<div class="ml-3">
<p class="text-sm text-blue-700 dark:text-blue-400">
Notification services allow the system to send alerts for job events such as completion, errors, or when jobs start.
</p>
</div>
</div>
</div>
</div>
</div>
@dialog.DialogScripts()
}
// Script call removed for now
}
+221
View File
@@ -0,0 +1,221 @@
package list
// ListScripts contains JavaScript specific to the notification list page.
templ ListScripts() {
<script>
// Notification system (showToast function is now in shared/toast/toast_js.templ)
// Track all HTMX events for debugging
document.addEventListener('htmx:beforeRequest', function(event) {
// Check if this is a DELETE request for a notification service
const path = event.detail.path;
const method = event.detail.verb;
console.log(`Request path: ${path}, method: ${method}`);
// Pattern match for notification service deletions (e.g., /admin/settings/notifications/123)
if (path && method === 'DELETE' && path.match(/^\/admin\/settings\/notifications\/\d+$/)) {
console.log("Detected notification service deletion request via URL pattern");
// This is definitely a delete request - store this information
window.isServiceDeleteRequest = true;
}
});
document.addEventListener('htmx:afterRequest', function(event) {
// Check for notification service deletion multiple ways
const isDeleteRequest =
// Check global flag from the triggerServiceDelete function
window.currentlyDeletingService ||
// Check flag from beforeRequest handler
window.isServiceDeleteRequest ||
// Check URL pattern directly from this event
(event.detail.pathInfo &&
event.detail.pathInfo.requestPath &&
event.detail.pathInfo.requestPath.match(/^\/admin\/settings\/notifications\/\d+$/) &&
event.detail.verb === 'DELETE');
console.log(`Is delete request: ${isDeleteRequest}`);
// If this is a successful delete request, show notification
if (isDeleteRequest && event.detail.successful) {
console.log("Delete request was successful");
let serviceName = "Unknown";
// Try multiple sources for service name
if (event.detail.elt && event.detail.elt.getAttribute) {
serviceName = event.detail.elt.getAttribute('data-service-name') || serviceName;
}
if (serviceName === "Unknown" && window.lastDeletedService) {
// Fallback to our stored service info
serviceName = window.lastDeletedService.name;
}
console.log(`Showing success notification for deleted service: ${serviceName}`);
// Ensure showToast is globally available
if (typeof showToast === 'function') {
showToast(`Notification service "${serviceName}" deleted successfully`, 'success');
} else {
console.error("showToast function not found!");
}
// Clear flags
window.currentlyDeletingService = false;
window.isServiceDeleteRequest = false;
window.lastDeletedService = null;
}
});
document.addEventListener('htmx:responseError', function(event) {
console.log("HTMX response error:", event.detail);
// Similar logic as success but for errors
const isDeleteRequest =
window.currentlyDeletingService ||
window.isServiceDeleteRequest ||
(event.detail.pathInfo &&
event.detail.pathInfo.requestPath &&
event.detail.pathInfo.requestPath.match(/^\/admin\/settings\/notifications\/\d+$/) &&
event.detail.verb === 'DELETE');
let errorMsg = 'An error occurred';
if (event.detail.xhr && event.detail.xhr.responseText) {
errorMsg = event.detail.xhr.responseText;
}
if (isDeleteRequest) {
console.log("Delete request failed");
let serviceName = "Unknown";
// Try multiple sources for service name
if (event.detail.elt && event.detail.elt.getAttribute) {
serviceName = event.detail.elt.getAttribute('data-service-name') || serviceName;
}
if (serviceName === "Unknown" && window.lastDeletedService) {
// Fallback to our stored service info
serviceName = window.lastDeletedService.name;
}
let specificErrorMsg = `Failed to delete notification service "${serviceName}"`;
if (event.detail.xhr && event.detail.xhr.responseText) {
// Try to provide a more specific error from the response
specificErrorMsg = `Error deleting "${serviceName}": ${event.detail.xhr.responseText}`;
}
console.log(`Showing error notification: ${specificErrorMsg}`);
if (typeof showToast === 'function') {
showToast(specificErrorMsg, 'error');
} else {
console.error("showToast function not found!");
}
// Clear flags
window.currentlyDeletingService = false;
window.isServiceDeleteRequest = false;
window.lastDeletedService = null;
} else {
// General error toast
if (typeof showToast === 'function') {
showToast(errorMsg, 'error');
} else {
console.error("showToast function not found!");
}
}
});
// Handle modal hide buttons (This might be better placed globally or in layout if modals are used elsewhere)
document.addEventListener('DOMContentLoaded', function() {
// This listener handles closing modals via data-modal-hide attribute
// It might conflict or be redundant if Flowbite's JS handles this already.
// Consider removing if Flowbite is initialized globally.
const hideButtons = document.querySelectorAll('[data-modal-hide]');
hideButtons.forEach(button => {
button.addEventListener('click', function() {
const modalId = this.getAttribute('data-modal-hide');
const modal = document.getElementById(modalId);
if (modal) {
modal.classList.add('hidden');
modal.classList.remove('flex');
}
const backdrop = document.getElementById(modalId + "-backdrop");
if (backdrop) {
backdrop.classList.add("hidden");
}
});
});
// Show any success or error messages passed via data struct as toasts
const successDiv = document.querySelector('.success-message');
if (successDiv) {
const successMsg = successDiv.textContent.trim();
if (successMsg && typeof showToast === 'function') {
showToast(successMsg, 'success');
} else if (successMsg) {
console.error("showToast function not found, cannot display success message:", successMsg);
}
}
const errorDiv = document.querySelector('.error-message');
if (errorDiv) {
const errorMsg = errorDiv.textContent.trim();
if (errorMsg && typeof showToast === 'function') {
showToast(errorMsg, 'error');
} else if (errorMsg) {
console.error("showToast function not found, cannot display error message:", errorMsg);
}
}
});
// Script for handling the service deletion trigger
function triggerServiceDelete(dialogId, serviceID, serviceName) {
// Hide the dialog first
closeModal(dialogId); // Reuse closeModal logic
// Add debugging info
console.log(`Notification service deletion triggered for: ${serviceName} (ID: ${serviceID})`);
// Store data in a way that's accessible to event handlers
window.lastDeletedService = {
id: serviceID,
name: serviceName
};
// Add custom marker to track this deletion
window.currentlyDeletingService = true;
}
// Script for closing the modal
function closeModal(id) {
const dialog = document.getElementById(id);
if (dialog) {
dialog.classList.add("hidden");
dialog.classList.remove("flex");
}
const backdrop = document.getElementById(id + "-backdrop");
if (backdrop) {
// Instead of removing, hide it to potentially reuse
backdrop.classList.add("hidden");
}
}
// Script for showing the modal
function showModal(id) {
const dialog = document.getElementById(id);
if (dialog) {
dialog.classList.remove("hidden");
dialog.classList.add("flex"); // Use flex to center content
}
const backdrop = document.getElementById(id + "-backdrop");
if (backdrop) {
backdrop.classList.remove("hidden");
}
}
</script>
}
+69
View File
@@ -0,0 +1,69 @@
package types
// SettingsNotificationsData defines the data needed for the notifications list page
type SettingsNotificationsData struct {
NotificationServices []NotificationServiceData
SuccessMessage string
ErrorMessage string
}
// NotificationServiceData defines the data for a single service in the list
type NotificationServiceData struct {
ID uint
Name string
Type string
IsEnabled bool
Config map[string]string // Keep for now, might refine later
Description string
EventTriggers []string
PayloadTemplate string
SecretKey string
RetryPolicy string
SuccessCount int
FailureCount int
}
// NotificationFormData defines the data needed for the notification add/edit form
// TODO: This will be moved from notification_form.templ later
type NotificationFormData struct {
NotificationService *struct {
ID uint
Name string
Description string
Type string
IsEnabled bool
EventTriggers []string
RetryPolicy string
WebhookURL string
Method string
Headers string
PayloadTemplate string
SecretKey string
PushbulletAPIKey string
PushbulletDeviceID string
PushbulletTitleTemplate string
PushbulletBodyTemplate string
NtfyServer string
NtfyTopic string
NtfyPriority string
NtfyUsername string
NtfyPassword string
NtfyTitleTemplate string
NtfyMessageTemplate string
GotifyURL string
GotifyToken string
GotifyPriority string
GotifyTitleTemplate string
GotifyMessageTemplate string
PushoverAPIToken string
PushoverUserKey string
PushoverDevice string
PushoverPriority string
PushoverSound string
PushoverTitleTemplate string
PushoverMessageTemplate string
}
IsNew bool
SuccessMessage string
ErrorMessage string
}
+255 -70
View File
@@ -5,53 +5,225 @@ import (
"github.com/starfleetcptn/gomft/internal/db"
)
// Dialog component for 2FA disable confirmation with Flowbite styling
templ TwoFactorDisableDialog() {
<div id="disable-2fa-dialog" tabindex="-1" aria-hidden="true" class="hidden fixed top-0 right-0 left-0 z-50 justify-center items-center w-full inset-0 overflow-y-auto overflow-x-hidden bg-gray-900/50 dark:bg-gray-900/80 backdrop-blur-sm">
<div class="relative p-4 w-full max-w-md max-h-full mx-auto">
<div class="relative bg-white rounded-lg shadow-xl dark:bg-gray-800 border border-gray-200 dark:border-gray-700">
<div class="p-5 text-center">
<div class="h-14 w-14 rounded-full bg-yellow-100 dark:bg-yellow-900 p-2 flex items-center justify-center mx-auto mb-5">
<i class="fas fa-shield-alt text-yellow-500 dark:text-yellow-300 text-xl"></i>
</div>
<h3 class="mb-5 text-xl font-semibold text-gray-900 dark:text-white">
Disable Two-Factor Authentication
</h3>
<p class="mb-6 text-gray-500 dark:text-gray-400">
Are you sure you want to disable two-factor authentication? This will make your account less secure.
</p>
<div class="mb-6">
<label for="current-password-2fa" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
<i class="fas fa-lock mr-1"></i> Current Password
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
<i class="fas fa-key text-gray-400 dark:text-gray-500"></i>
</div>
<input
type="password"
id="current-password-2fa"
name="current_password"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full pl-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="Enter your current password"
required/>
</div>
</div>
<div id="disable-2fa-result" class="mb-5"></div>
<div class="flex justify-center space-x-4">
<button
type="button"
class="text-white bg-red-600 hover:bg-red-700 focus:ring-4 focus:outline-none focus:ring-red-300 font-medium rounded-lg text-sm px-5 py-2.5 inline-flex items-center dark:bg-red-600 dark:hover:bg-red-700 dark:focus:ring-red-800"
onclick="submitDisable2FA()">
<i class="fas fa-times mr-2"></i>
Disable 2FA
</button>
<button type="button" class="py-2.5 px-5 text-sm font-medium text-gray-900 focus:outline-none bg-white rounded-lg border border-gray-200 hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-4 focus:ring-gray-100 dark:focus:ring-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700" onclick="hideDisable2FADialog()">
Cancel
</button>
</div>
</div>
</div>
</div>
</div>
}
templ Profile(ctx context.Context, user db.User) {
@LayoutWithContext("Profile", ctx) {
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div class="flex items-center justify-between mb-6">
<h1 class="text-3xl font-bold text-secondary-900 dark:text-secondary-100">
<i class="fas fa-user-circle mr-2 text-primary-600 dark:text-primary-400"></i>
<script>
// Initialize dialog functionality
document.addEventListener('DOMContentLoaded', function() {
console.log('Initializing 2FA dialog functionality');
// Global functions for dialog control
window.hideDisable2FADialog = function() {
document.getElementById('disable-2fa-dialog').classList.add('hidden');
document.getElementById('disable-2fa-dialog').classList.remove('flex');
document.getElementById('current-password-2fa').value = '';
document.getElementById('disable-2fa-result').innerHTML = '';
};
window.showDisable2FADialog = function() {
console.log('Showing 2FA disable dialog');
document.getElementById('disable-2fa-dialog').classList.remove('hidden');
document.getElementById('disable-2fa-dialog').classList.add('flex');
};
window.submitDisable2FA = function() {
const password = document.getElementById('current-password-2fa').value;
if (!password) {
document.getElementById('disable-2fa-result').innerHTML = `
<div class="p-4 text-sm text-red-800 rounded-lg bg-red-50 dark:bg-red-900 dark:text-red-400" role="alert">
<div class="flex items-center">
<i class="fas fa-exclamation-circle mr-2"></i>
<span>Current password is required</span>
</div>
</div>`;
return;
}
htmx.ajax('POST', '/profile/2fa/disable', {
target: '#disable-2fa-result',
swap: 'innerHTML',
values: { current_password: password }
});
};
// Close dialog when clicking outside
document.getElementById('disable-2fa-dialog').addEventListener('click', function(e) {
if (e.target === this) {
hideDisable2FADialog();
}
});
// Close dialog on escape key
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape' && !document.getElementById('disable-2fa-dialog').classList.contains('hidden')) {
hideDisable2FADialog();
}
});
});
</script>
@TwoFactorDisableDialog()
<div class="p-4 md:p-6 2xl:p-10">
<!-- Profile Header -->
<div class="mb-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
<i class="fas fa-user-circle w-6 h-6 mr-2 text-blue-500"></i>
Profile
</h1>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
<!-- Profile Information Card -->
<div class="card">
<div class="card-header">
<h3 class="text-lg font-medium text-secondary-900 dark:text-secondary-100">
<i class="fas fa-id-card mr-2 text-primary-500"></i>
Profile Information
</h3>
<p class="mt-1 text-sm text-secondary-500 dark:text-secondary-400">Personal details and application settings.</p>
<div class="bg-white border border-gray-200 rounded-xl shadow-sm dark:border-gray-700 dark:bg-gray-800 transition-all duration-200 hover:shadow-md">
<div class="p-5 border-b border-gray-200 dark:border-gray-700">
<div class="flex items-center space-x-3">
<div class="h-10 w-10 bg-blue-100 dark:bg-blue-900 rounded-full flex items-center justify-center">
<i class="fas fa-id-card text-blue-600 dark:text-blue-300"></i>
</div>
<div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
Profile Information
</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">Personal details and application settings</p>
</div>
</div>
</div>
<div class="card-body">
<div class="p-5">
<dl class="space-y-6">
<div class="flex flex-col sm:flex-row">
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400 sm:w-1/3 mb-1 sm:mb-0">Email</dt>
<dd class="text-sm text-secondary-900 dark:text-secondary-100 sm:w-2/3">{ user.Email }</dd>
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400 sm:w-1/3 mb-1 sm:mb-0">Email</dt>
<dd class="text-sm font-medium text-gray-900 dark:text-white sm:w-2/3">{ user.Email }</dd>
</div>
<div class="flex flex-col sm:flex-row">
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400 sm:w-1/3 mb-1 sm:mb-0">Role</dt>
<dd class="text-sm text-secondary-900 dark:text-secondary-100 sm:w-2/3">
if user.IsAdmin {
<span class="badge badge-success">
<i class="fas fa-user-shield mr-1"></i> Administrator
</span>
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400 sm:w-1/3 mb-1 sm:mb-0">Roles</dt>
<dd class="text-sm font-medium text-gray-900 dark:text-white sm:w-2/3">
<div class="flex flex-wrap gap-2">
if user.GetIsAdmin() {
<span class="bg-blue-100 text-blue-800 text-xs font-medium px-3 py-1 rounded-full dark:bg-blue-900 dark:text-blue-300 inline-flex items-center">
<i class="fas fa-user-shield w-3 h-3 mr-1.5"></i> Administrator
</span>
}
for _, role := range user.Roles {
<span class="bg-gray-100 text-gray-800 text-xs font-medium px-3 py-1 rounded-full dark:bg-gray-700 dark:text-gray-300 inline-flex items-center">
<i class="fas fa-user-tag w-3 h-3 mr-1.5"></i> { role.Name }
</span>
}
</div>
</dd>
</div>
<div class="flex flex-col sm:flex-row">
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400 sm:w-1/3 mb-1 sm:mb-0">Permissions</dt>
<dd class="text-sm font-medium text-gray-900 dark:text-white sm:w-2/3">
<div class="flex flex-wrap gap-1">
for _, role := range user.Roles {
for _, perm := range role.GetPermissions() {
<span class="bg-green-100 text-green-800 text-xs font-medium px-2 py-0.5 rounded dark:bg-green-900 dark:text-green-300">
{ perm }
</span>
}
}
</div>
</dd>
</div>
<div class="flex flex-col sm:flex-row">
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400 sm:w-1/3 mb-1 sm:mb-0">Two-Factor Authentication</dt>
<dd class="text-sm font-medium text-gray-900 dark:text-white sm:w-2/3">
if user.TwoFactorEnabled {
<div class="flex flex-col space-y-3">
<div class="flex items-center">
<span class="bg-green-100 text-green-800 text-xs font-medium px-3 py-1 rounded-full dark:bg-green-900 dark:text-green-300 inline-flex items-center">
<i class="fas fa-shield-alt w-3 h-3 mr-1.5"></i> Enabled
</span>
</div>
<div class="flex flex-wrap gap-2 mt-2">
<a href="/profile/2fa/backup-codes" class="py-2 px-3 text-xs font-medium text-gray-900 focus:outline-none bg-white rounded-lg border border-gray-200 hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-4 focus:ring-gray-100 dark:focus:ring-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700 inline-flex items-center shadow-sm">
<i class="fas fa-key w-3 h-3 mr-1.5"></i>
Manage Backup Codes
</a>
<button
type="button"
class="text-white bg-red-600 hover:bg-red-700 focus:ring-4 focus:outline-none focus:ring-red-300 font-medium rounded-lg text-xs px-3 py-2 text-center inline-flex items-center dark:bg-red-600 dark:hover:bg-red-700 dark:focus:ring-red-800 shadow-sm"
onclick="showDisable2FADialog()">
<i class="fas fa-times w-3 h-3 mr-1.5"></i>
Disable 2FA
</button>
</div>
</div>
} else {
<span class="badge badge-info">
<i class="fas fa-user mr-1"></i> Regular User
</span>
<div class="flex flex-col space-y-3">
<div class="flex items-center">
<span class="bg-yellow-100 text-yellow-800 text-xs font-medium px-3 py-1 rounded-full dark:bg-yellow-900 dark:text-yellow-300 inline-flex items-center">
<i class="fas fa-shield-alt w-3 h-3 mr-1.5"></i> Disabled
</span>
</div>
<div class="mt-2">
<a href="/profile/2fa/setup" class="text-white bg-blue-600 hover:bg-blue-700 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-xs px-3 py-2 text-center inline-flex items-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800 shadow-sm">
<i class="fas fa-lock w-3 h-3 mr-1.5"></i>
Enable 2FA
</a>
</div>
</div>
}
</dd>
</div>
<div class="flex flex-col sm:flex-row">
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400 sm:w-1/3 mb-1 sm:mb-0">Theme</dt>
<dd class="text-sm text-secondary-900 dark:text-secondary-100 sm:w-2/3">
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400 sm:w-1/3 mb-1 sm:mb-0">Theme</dt>
<dd class="text-sm font-medium text-gray-900 dark:text-white sm:w-2/3">
<form
hx-post="/profile/theme"
hx-swap="none"
class="flex items-center space-x-4">
class="flex items-center space-x-5">
<div class="flex items-center">
<input
type="radio"
@@ -61,9 +233,9 @@ templ Profile(ctx context.Context, user db.User) {
checked?={ user.Theme == "light" || user.Theme == "" }
hx-trigger="change"
hx-post="/profile/theme"
class="form-checkbox" />
<label for="theme-light" class="ml-2 block text-sm text-secondary-700 dark:text-secondary-300">
<i class="fas fa-sun mr-1"></i> Light
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600" />
<label for="theme-light" class="ml-2 text-sm font-medium text-gray-900 dark:text-gray-300">
<i class="fas fa-sun mr-1.5"></i> Light
</label>
</div>
<div class="flex items-center">
@@ -75,9 +247,9 @@ templ Profile(ctx context.Context, user db.User) {
checked?={ user.Theme == "dark" }
hx-trigger="change"
hx-post="/profile/theme"
class="form-checkbox" />
<label for="theme-dark" class="ml-2 block text-sm text-secondary-700 dark:text-secondary-300">
<i class="fas fa-moon mr-1"></i> Dark
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600" />
<label for="theme-dark" class="ml-2 text-sm font-medium text-gray-900 dark:text-gray-300">
<i class="fas fa-moon mr-1.5"></i> Dark
</label>
</div>
<div class="flex items-center">
@@ -89,9 +261,9 @@ templ Profile(ctx context.Context, user db.User) {
checked?={ user.Theme == "system" }
hx-trigger="change"
hx-post="/profile/theme"
class="form-checkbox" />
<label for="theme-system" class="ml-2 block text-sm text-secondary-700 dark:text-secondary-300">
<i class="fas fa-desktop mr-1"></i> System
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600" />
<label for="theme-system" class="ml-2 text-sm font-medium text-gray-900 dark:text-gray-300">
<i class="fas fa-desktop mr-1.5"></i> System
</label>
</div>
</form>
@@ -102,15 +274,21 @@ templ Profile(ctx context.Context, user db.User) {
</div>
<!-- Change Password Card -->
<div class="card">
<div class="card-header">
<h3 class="text-lg font-medium text-secondary-900 dark:text-secondary-100">
<i class="fas fa-key mr-2 text-primary-500"></i>
Change Password
</h3>
<p class="mt-1 text-sm text-secondary-500 dark:text-secondary-400">Update your password to keep your account secure.</p>
<div class="bg-white border border-gray-200 rounded-xl shadow-sm dark:border-gray-700 dark:bg-gray-800 transition-all duration-200 hover:shadow-md">
<div class="p-5 border-b border-gray-200 dark:border-gray-700">
<div class="flex items-center space-x-3">
<div class="h-10 w-10 bg-blue-100 dark:bg-blue-900 rounded-full flex items-center justify-center">
<i class="fas fa-key text-blue-600 dark:text-blue-300"></i>
</div>
<div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
Change Password
</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">Update your password to keep your account secure</p>
</div>
</div>
</div>
<div class="card-body">
<div class="p-5">
<form
method="POST"
action="/change-password"
@@ -119,7 +297,7 @@ templ Profile(ctx context.Context, user db.User) {
hx-swap="innerHTML"
hx-headers='{"X-Profile-Page": "true"}'
hx-indicator="#password-change-indicator"
class="space-y-4"
class="space-y-5"
x-data="{
currentPassword: '',
newPassword: '',
@@ -138,57 +316,57 @@ templ Profile(ctx context.Context, user db.User) {
<div id="password-result"></div>
<div>
<label for="current-password" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
<i class="fas fa-lock mr-1"></i> Current Password
<label for="current-password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
<i class="fas fa-lock mr-1.5"></i> Current Password
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
<div class="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
<i class="fas fa-key text-gray-400 dark:text-gray-500"></i>
</div>
<input
type="password"
id="current-password"
name="current_password"
x-model="currentPassword"
class="form-input pl-10 w-full"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full pl-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="••••••••"
required/>
</div>
</div>
<div>
<label for="new-password" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
<i class="fas fa-lock-open mr-1"></i> New Password
<label for="new-password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
<i class="fas fa-lock-open mr-1.5"></i> New Password
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
<div class="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
<i class="fas fa-key text-gray-400 dark:text-gray-500"></i>
</div>
<input
type="password"
id="new-password"
name="new_password"
x-model="newPassword"
class="form-input pl-10 w-full"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full pl-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="••••••••"
required/>
</div>
</div>
<div>
<label for="confirm-password" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
<i class="fas fa-check-double mr-1"></i> Confirm New Password
<label for="confirm-password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
<i class="fas fa-check-double mr-1.5"></i> Confirm New Password
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
<div class="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
<i class="fas fa-key text-gray-400 dark:text-gray-500"></i>
</div>
<input
type="password"
id="confirm-password"
name="confirm_password"
x-model="confirmPassword"
class="form-input pl-10 w-full"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full pl-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="••••••••"
required/>
</div>
@@ -197,13 +375,13 @@ templ Profile(ctx context.Context, user db.User) {
<div>
<button
type="submit"
class="btn-primary w-full flex justify-center py-3"
class="w-full text-white bg-blue-600 hover:bg-blue-700 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800 shadow-sm"
x-bind:disabled="!validate() || loading">
<span x-show="!loading" class="flex items-center">
<span x-show="!loading" class="flex items-center justify-center">
<i class="fas fa-save mr-2"></i>
Update Password
</span>
<span x-show="loading" class="flex items-center">
<span x-show="loading" class="flex items-center justify-center">
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
@@ -214,9 +392,11 @@ templ Profile(ctx context.Context, user db.User) {
</div>
</form>
</div>
<div class="px-6 py-4 bg-secondary-50 dark:bg-secondary-800 border-t border-secondary-200 dark:border-secondary-700">
<div class="flex items-center text-sm text-secondary-500 dark:text-secondary-400">
<i class="fas fa-shield-alt mr-2 text-primary-500"></i>
<div class="p-5 bg-gray-50 dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 rounded-b-xl">
<div class="flex items-center text-sm text-gray-600 dark:text-gray-400">
<div class="mr-3 text-blue-500 flex-shrink-0">
<i class="fas fa-shield-alt"></i>
</div>
<span>Password must be at least 8 characters with letters, numbers, and special characters</span>
</div>
</div>
@@ -224,10 +404,15 @@ templ Profile(ctx context.Context, user db.User) {
</div>
<!-- Security Notice -->
<div class="mt-8 text-center">
<div class="inline-flex items-center text-sm text-secondary-500 dark:text-secondary-400">
<i class="fas fa-shield-alt mr-2 text-primary-500"></i>
<span>All profile changes are securely logged for your protection</span>
<div class="mt-8">
<div class="p-5 text-sm text-blue-800 rounded-xl bg-blue-50 border border-blue-200 dark:bg-blue-900/50 dark:text-blue-400 dark:border-blue-800 flex items-start shadow-sm">
<div class="mr-3 text-blue-500 dark:text-blue-400 flex-shrink-0 mt-0.5">
<i class="fas fa-shield-alt"></i>
</div>
<div>
<h4 class="text-sm font-medium mb-1">Security Information</h4>
<p>All profile changes are securely logged for your protection. For security reasons, you'll be required to enter your password for sensitive changes.</p>
</div>
</div>
</div>
</div>
+584
View File
@@ -0,0 +1,584 @@
package common
import (
"fmt"
"github.com/starfleetcptn/gomft/internal/db"
)
templ NameField() {
<div class="mb-6">
<label for="name" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Configuration Name</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-tag text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" name="name" id="name" x-model="name" required
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="Transfer configuration name" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Choose a descriptive name to identify this configuration.</p>
</div>
}
templ FilePatternFields() {
<div class="space-y-6">
<div class="mb-6">
<label for="file_pattern" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">File Pattern</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-filter text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" name="file_pattern" id="file_pattern" x-model="filePattern"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="*.txt, *.csv" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Glob pattern for files to transfer. Leave empty to transfer all files.
</p>
</div>
<div class="mb-6">
<label for="output_pattern" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Output Pattern</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-file-export text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" name="output_pattern" id="output_pattern" x-model="outputPattern"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="${filename}" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Pattern for output filename. Use variables like ${`filename`}, ${`timestamp`}, ${`date`}
</p>
<div class="mt-4 p-4 bg-gray-50 rounded-lg border border-gray-200 dark:bg-gray-800 dark:border-gray-700">
<h4 class="text-sm font-medium text-gray-900 dark:text-white mb-2">Available variables:</h4>
<p class="text-sm text-gray-500 dark:text-gray-400 space-y-1">
<span class="block"> ${`filename`} - Original filename without extension (e.g., "report")</span>
<span class="block"> ${`ext`} - Original file extension (e.g., "csv")</span>
<span class="block"> ${`date:format`} - Current date using Go's time format:</span>
<span class="block pl-4">- 2006-01-02 YYYY-MM-DD</span>
<span class="block pl-4">- 20060102 YYYYMMDD</span>
<span class="block pl-4">- 2006-01-02 15:04:05 YYYY-MM-DD_HH:MM:SS</span>
<span class="block italic mt-2">Example: ${`filename`}_${`date:2006-01-02`}_${`ext`} "report_2023-03-01.csv"</span>
</p>
</div>
</div>
</div>
}
templ ArchiveOptions() {
<div class="p-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:bg-gray-800 dark:border-gray-700">
<div class="space-y-6">
<div class="flex items-center">
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" id="archive_enabled" name="archive_enabled" x-model="archiveEnabled"
class="sr-only peer" :value="archiveEnabled ? 'true' : 'false'">
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
<span class="ms-3 text-sm font-medium text-gray-900 dark:text-white">Enable archiving</span>
</label>
</div>
<div x-show="archiveEnabled" class="mt-4">
<label for="archive_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Archive Path</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-folder text-gray-400 dark:text-gray-500"></i>
</div>
<input id="archive_path" name="archive_path" type="text" x-model="archivePath"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="Path to archive files" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Files will be moved here after successful transfer
</p>
</div>
<div class="flex items-center">
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" id="delete_after_transfer" name="delete_after_transfer" x-model="deleteAfterTransfer"
class="sr-only peer" :value="deleteAfterTransfer ? 'true' : 'false'">
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-red-300 dark:peer-focus:ring-red-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-red-600"></div>
<span class="ms-3 text-sm font-medium text-gray-900 dark:text-white">Delete source files after transfer</span>
</label>
</div>
<div x-show="deleteAfterTransfer" class="mt-2">
<div class="p-4 mb-4 text-sm text-red-800 rounded-lg bg-red-50 dark:bg-red-900/30 dark:text-red-300" role="alert">
<div class="flex items-center">
<i class="fas fa-exclamation-triangle mr-2"></i>
<span>Warning: This will permanently delete the original files</span>
</div>
</div>
</div>
<div class="flex items-center">
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" id="skip_processed_files" name="skip_processed_files" x-model="skipProcessedFiles"
class="sr-only peer" :value="skipProcessedFiles ? 'true' : 'false'">
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
<span class="ms-3 text-sm font-medium text-gray-900 dark:text-white">Skip files that have already been processed</span>
</label>
</div>
<p class="ms-14 text-sm text-gray-500 dark:text-gray-400">
Files with the same hash that have been successfully processed before will be skipped
</p>
<div class="mt-6">
<label for="max_concurrent_transfers" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
Concurrent Transfers: <span x-text="maxConcurrentTransfers"></span>
</label>
<input type="range" id="max_concurrent_transfers" name="max_concurrent_transfers"
class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer dark:bg-gray-700"
min="1" max="32" step="1" x-model="maxConcurrentTransfers">
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Number of files to transfer simultaneously (higher values may improve performance but increase resource usage)
</p>
</div>
</div>
</div>
}
templ RcloneFlags(currentCommandID uint) {
<div class="mb-6">
<label for="command_id" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Rclone Command</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-terminal text-gray-400 dark:text-gray-500"></i>
</div>
@RcloneCommandOptions(currentCommandID) // Pass it down
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Select the rclone command to use for this configuration.
</p>
<!-- Flag container is now rendered directly in ConfigForm -->
<label for="rclone_flags" class="block mb-2 mt-6 text-sm font-medium text-gray-900 dark:text-white">Additional Rclone Flags</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-flag text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" name="rclone_flags" id="rclone_flags" x-model="rcloneFlags"
@change="updateCommandRequirements()"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="--transfers 4 --checkers 8" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Optional: Additional rclone flags for fine-tuning the transfer.
</p>
</div>
}
// New placeholder templ for rclone command options
templ RcloneCommandOptions(currentCommandID uint) { // Accept currentCommandID
<div
hx-get="/api/rclone/commands"
hx-trigger="load"
hx-target="this"
hx-swap="outerHTML"
hx-vals={ fmt.Sprintf(`{"commandId": %d}`, currentCommandID) }>
<!-- Loading placeholder -->
<option value="">Loading commands...</option>
</div>
}
templ SourceSelection(providers []db.StorageProvider) {
<div class="mb-6">
<label for="source_type" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Source Type</label>
<!-- Provider selection -->
<div class="mb-4">
<div class="flex items-center mb-4">
<input id="use_source_provider" name="use_source_provider" type="checkbox" x-model="useSourceProvider" value="true" class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600">
<label for="use_source_provider" class="ml-2 text-sm font-medium text-gray-900 dark:text-white">Use existing storage provider</label>
</div>
<template x-if="useSourceProvider">
<div class="mt-2 space-y-4">
<div>
<label for="source_provider_id" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Select provider</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-server text-gray-400 dark:text-gray-500"></i>
</div>
<select
id="source_provider_id"
name="source_provider_id"
x-model="sourceProviderId"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
hx-get="/api/storage-providers/options"
hx-trigger="load delay:500ms"
hx-target="this"
hx-swap="innerHTML">
<option value="">Select a provider...</option>
for _, provider := range providers {
<option value={ fmt.Sprintf("%d", provider.ID) }>{ provider.Name } ({ string(provider.Type) })</option>
}
</select>
</div>
</div>
<div class="flex items-center justify-between">
<a href="/storage-providers/new" target="_blank" class="text-blue-600 hover:underline flex items-center text-sm">
<i class="fas fa-plus mr-1"></i> Add new storage provider
</a>
<button type="button"
class="text-sm text-white bg-blue-600 hover:bg-blue-700 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg px-3 py-1.5 dark:bg-blue-500 dark:hover:bg-blue-600 dark:focus:ring-blue-800"
hx-get="/api/storage-providers/options"
hx-target="#source_provider_id"
hx-swap="innerHTML">
<i class="fas fa-sync-alt mr-1"></i> Refresh List
</button>
</div>
</div>
</template>
</div>
<!-- Manual configuration -->
<template x-if="!useSourceProvider">
<div>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-server text-gray-400 dark:text-gray-500"></i>
</div>
<select id="source_type" name="source_type" x-model="sourceType"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500">
<option value="local">Local</option>
<option value="sftp">SFTP</option>
<option value="ftp">FTP</option>
<option value="s3">S3</option>
<option value="b2">Backblaze B2</option>
<option value="wasabi">Wasabi</option>
<option value="minio">MinIO</option>
<option value="smb">SMB</option>
<option value="nextcloud">NextCloud</option>
<option value="webdav">WebDAV</option>
<option value="drive">Google Drive (BETA)</option>
<option value="gphotos">Google Photos (BETA)</option>
<option value="hetzner">Hetzner Storage Box</option>
</select>
</div>
</div>
</template>
</div>
}
templ DestinationSelection(providers []db.StorageProvider) {
<div class="mb-6">
<label for="destination_type" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Destination Type</label>
<!-- Provider selection -->
<div class="mb-4">
<div class="flex items-center mb-4">
<input id="use_destination_provider" name="use_destination_provider" type="checkbox" x-model="useDestinationProvider" value="true" class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600">
<label for="use_destination_provider" class="ml-2 text-sm font-medium text-gray-900 dark:text-white">Use existing storage provider</label>
</div>
<template x-if="useDestinationProvider">
<div class="mt-2 space-y-4">
<div>
<label for="destination_provider_id" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Select provider</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-server text-gray-400 dark:text-gray-500"></i>
</div>
<select
id="destination_provider_id"
name="destination_provider_id"
x-model="destinationProviderId"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
hx-get="/api/storage-providers/options"
hx-trigger="load delay:500ms"
hx-target="this"
hx-swap="innerHTML">
<option value="">Select a provider...</option>
for _, provider := range providers {
<option value={ fmt.Sprintf("%d", provider.ID) }>{ provider.Name } ({ string(provider.Type) })</option>
}
</select>
</div>
</div>
<div class="flex items-center justify-between">
<a href="/storage-providers/new" target="_blank" class="text-blue-600 hover:underline flex items-center text-sm">
<i class="fas fa-plus mr-1"></i> Add new storage provider
</a>
<button type="button"
class="text-sm text-white bg-blue-600 hover:bg-blue-700 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg px-3 py-1.5 dark:bg-blue-500 dark:hover:bg-blue-600 dark:focus:ring-blue-800"
hx-get="/api/storage-providers/options"
hx-target="#destination_provider_id"
hx-swap="innerHTML">
<i class="fas fa-sync-alt mr-1"></i> Refresh List
</button>
</div>
</div>
</template>
</div>
<!-- Manual configuration -->
<template x-if="!useDestinationProvider">
<div>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-server text-gray-400 dark:text-gray-500"></i>
</div>
<select id="destination_type" name="destination_type" x-model="destinationType"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500">
<option value="local">Local</option>
<option value="sftp">SFTP</option>
<option value="ftp">FTP</option>
<option value="s3">S3</option>
<option value="b2">Backblaze B2</option>
<option value="wasabi">Wasabi</option>
<option value="minio">MinIO</option>
<option value="smb">SMB</option>
<option value="nextcloud">NextCloud</option>
<option value="webdav">WebDAV</option>
<option value="drive">Google Drive (BETA)</option>
<option value="gphotos">Google Photos (BETA)</option>
<option value="hetzner">Hetzner Storage Box</option>
</select>
</div>
</div>
</template>
</div>
}
// RcloneCommandOptionsContent renders the command options organized by category
templ RcloneCommandOptionsContent(categoryMap map[string][]db.RcloneCommand, categories []string, currentCommandID uint, commandFlagsJSON string, commandFlagValuesJSON string) { // Add flag JSON strings
<select id="command_id" name="command_id" x-model="commandId"
hx-get="/api/rclone/command-flags"
hx-target="#command-flags-container"
hx-trigger="change"
hx-include="[name='command_id']"
hx-vals={ fmt.Sprintf(`{"commandFlags": %s, "commandFlagValues": %s}`, commandFlagsJSON, commandFlagValuesJSON) }
@change="updateCommandRequirements()"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500">
<option value="">Select command...</option>
for _, category := range categories {
if commands, ok := categoryMap[category]; ok && len(commands) > 0 {
<optgroup label={ category }>
for _, cmd := range commands {
if cmd.ID == currentCommandID {
<option value={ fmt.Sprintf("%d", cmd.ID) } selected>{ cmd.Name } - { cmd.Description }</option>
} else {
<option value={ fmt.Sprintf("%d", cmd.ID) }>{ cmd.Name } - { cmd.Description }</option>
}
}
</optgroup>
}
}
</select>
}
// RcloneCommandFlagsContent renders the command flags for a selected command
templ RcloneCommandFlagsContent(command *db.RcloneCommand, selectedFlagsMap map[uint]bool, selectedFlagValues map[uint]string) {
if command == nil {
<div class="p-4 text-red-500">Command not found</div>
return
}
<div class="mb-4">
<h4 class="font-medium text-gray-900 dark:text-white mb-2">{ command.Name }</h4>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">{ command.Description }</p>
if len(command.Flags) > 0 {
<div class="mb-3">
<h5 class="font-medium text-gray-900 dark:text-white mb-2">Command Flags:</h5>
<div class="space-y-3">
for _, flag := range command.Flags {
if flag.DataType == "bool" {
<!-- Boolean flag (simple checkbox) -->
<div class="flex items-start mb-4">
<input
type="checkbox"
id={ fmt.Sprintf("flag_%d", flag.ID) }
name="command_flags"
value={ fmt.Sprintf("%d", flag.ID) }
class="mt-0.5 rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:focus:ring-blue-600"
if selectedFlagsMap[flag.ID] {
checked
} />
<div class="ml-3">
<label for={ fmt.Sprintf("flag_%d", flag.ID) } class="font-medium text-gray-900 dark:text-white">
{ flag.Name } - { flag.Description }
</label>
if flag.DefaultValue != "" {
<p class="text-xs text-gray-500 dark:text-gray-400">Default: { flag.DefaultValue }</p>
}
</div>
</div>
} else {
<!-- Non-boolean flag (requires value) -->
<div class="flex items-start mb-4 w-full">
<div class="w-full">
<div class="flex items-center mb-2">
<input
type="checkbox"
id={ fmt.Sprintf("flag_enable_%d", flag.ID) }
name={ fmt.Sprintf("flag_enable_%d", flag.ID) }
class="mr-2 rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:focus:ring-blue-600"
data-input-id={ fmt.Sprintf("flag_value_%d", flag.ID) }
onclick="toggleFlagValue(this)"
if selectedFlagsMap[flag.ID] {
checked
} />
<label for={ fmt.Sprintf("flag_enable_%d", flag.ID) } class="font-medium text-gray-900 dark:text-white">
{ flag.Name } - { flag.Description }
</label>
</div>
<div class="w-full mt-2">
@renderFlagInput(flag, selectedFlagValues[flag.ID], selectedFlagsMap[flag.ID]) // Pass value and enabled status
if flag.DefaultValue != "" {
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Default: { flag.DefaultValue }</p>
}
</div>
</div>
</div>
}
}
</div>
</div>
<!-- JavaScript to handle enabling/disabling flag value inputs -->
<script>
function toggleFlagValue(checkbox) {
const inputId = checkbox.getAttribute('data-input-id');
const input = document.getElementById(inputId);
if (input) {
input.disabled = !checkbox.checked;
if (checkbox.checked) {
// Focus on the input when enabled
input.focus();
}
// Also enable/disable the hidden input for flag ID
const hiddenId = inputId.replace('flag_value_', 'flag_hidden_');
const hiddenInput = document.getElementById(hiddenId);
if (hiddenInput) {
hiddenInput.disabled = !checkbox.checked;
console.log('Toggle hidden input:', hiddenId, checkbox.checked);
}
// For debugging
console.log('Toggle flag value:', inputId, checkbox.checked);
}
}
// Initialize all flag inputs on page load
// Initialization is now handled by server-side rendering
</script>
<div class="mt-4 p-3 bg-blue-50 text-blue-800 rounded-lg border border-blue-100 dark:bg-blue-900/20 dark:text-blue-300 dark:border-blue-900 text-sm">
<div class="flex items-center mb-1">
<i class="fas fa-info-circle mr-2"></i>
<span class="font-medium">Usage Examples</span>
</div>
if command.Name == "ls" ||
command.Name == "lsd" ||
command.Name == "lsl" ||
command.Name == "lsjson" ||
command.Name == "lsf" ||
command.Name == "delete" ||
command.Name == "purge" ||
command.Name == "rmdirs" ||
command.Name == "mkdir" ||
command.Name == "touch" ||
command.Name == "md5sum" ||
command.Name == "sha1sum" ||
command.Name == "sha256sum" ||
command.Name == "size" ||
command.Name == "stat" ||
command.Name == "version" {
<code class="block mt-1 font-mono text-xs overflow-x-auto">rclone { command.Name } [flags] source:path</code>
} else {
<code class="block mt-1 font-mono text-xs overflow-x-auto">rclone { command.Name } [flags] source:path dest:path</code>
}
</div>
} else {
<div class="p-3 bg-gray-50 text-gray-600 rounded-lg border border-gray-200 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-700 text-sm">
<div class="flex items-center">
<i class="fas fa-info-circle mr-2"></i>
<span>This command doesn't have any specific flags.</span>
</div>
</div>
}
</div>
}
// Helper function to render appropriate input based on flag data type
templ renderFlagInput(flag db.RcloneCommandFlag, value string, enabled bool) {
if flag.DataType == "int" {
<input
type="number"
id={ fmt.Sprintf("flag_value_%d", flag.ID) }
name={ fmt.Sprintf("flag_value_%d", flag.ID) }
class="w-full bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 p-2 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder={ flag.DefaultValue }
value={ value }
if !enabled {
disabled
} />
<!-- Hidden input to include this flag ID when checked -->
<input
type="hidden"
name="command_flags"
value={ fmt.Sprintf("%d", flag.ID) }
if !enabled {
disabled
}
id={ fmt.Sprintf("flag_hidden_%d", flag.ID) }
data-enable-with={ fmt.Sprintf("flag_enable_%d", flag.ID) }
/>
} else if flag.DataType == "float" {
<input
type="number"
id={ fmt.Sprintf("flag_value_%d", flag.ID) }
name={ fmt.Sprintf("flag_value_%d", flag.ID) }
step="0.01"
class="w-full bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 p-2 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder={ flag.DefaultValue }
value={ value }
if !enabled {
disabled
} />
<!-- Hidden input to include this flag ID when checked -->
<input
type="hidden"
name="command_flags"
value={ fmt.Sprintf("%d", flag.ID) }
if !enabled {
disabled
}
id={ fmt.Sprintf("flag_hidden_%d", flag.ID) }
data-enable-with={ fmt.Sprintf("flag_enable_%d", flag.ID) }
/>
} else {
<!-- Default to text input for string and other types -->
<input
type="text"
id={ fmt.Sprintf("flag_value_%d", flag.ID) }
name={ fmt.Sprintf("flag_value_%d", flag.ID) }
class="w-full bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 p-2 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder={ flag.DefaultValue }
value={ value }
if !enabled {
disabled
} />
<!-- Hidden input to include this flag ID when checked -->
<input
type="hidden"
name="command_flags"
value={ fmt.Sprintf("%d", flag.ID) }
if !enabled {
disabled
}
id={ fmt.Sprintf("flag_hidden_%d", flag.ID) }
data-enable-with={ fmt.Sprintf("flag_enable_%d", flag.ID) }
/>
}
}
+97
View File
@@ -0,0 +1,97 @@
package destination
templ B2DestinationForm() {
<div class="space-y-6 mt-4">
<div class="p-4 mb-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900/30 dark:text-blue-300" role="alert">
<div class="flex items-center">
<i class="fas fa-info-circle mr-2"></i>
<span>Configure your Backblaze B2 details below. You'll need your account ID, application key, and bucket information.</span>
</div>
</div>
<div class="mb-6">
<label for="dest_bucket" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Bucket Name</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-archive text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="dest_bucket" name="dest_bucket" x-model="destBucket" x-bind:required="requiresDestination"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="my-b2-bucket" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Name of your Backblaze B2 bucket (case-sensitive)
</p>
</div>
<div class="mb-6">
<label for="dest_access_key" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Account ID</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-id-card text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="dest_access_key" name="dest_access_key" x-model="destAccessKey" x-bind:required="requiresDestination"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="0123456789abcdef0123456789" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Your Backblaze B2 Account ID
</p>
</div>
<div class="mb-6">
<label for="dest_secret_key" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Application Key</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-lock text-gray-400 dark:text-gray-500"></i>
</div>
<input type="password" id="dest_secret_key" name="dest_secret_key" x-model="destSecretKey" x-bind:required="requiresDestination"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="K0123456789abcdef0123456789abcdef0123456789" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Your Backblaze B2 Application Key
</p>
</div>
<div class="mb-6">
<label for="dest_endpoint" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Endpoint (Optional)</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-server text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="dest_endpoint" name="dest_endpoint" x-model="destEndpoint"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="s3.us-west-000.backblazeb2.com" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Custom endpoint URL (only needed for non-standard regions or private endpoints)
</p>
</div>
<div class="mb-6">
<label for="destination_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Path in Bucket</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-folder-open text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="destination_path" name="destination_path" x-model="destinationPath"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="path/to/files/" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Path prefix within the bucket (e.g., "backups/"). Leave empty to access the entire bucket.
</p>
</div>
<div class="p-4 mb-4 text-sm text-yellow-800 rounded-lg bg-yellow-50 dark:bg-yellow-900/30 dark:text-yellow-300" role="alert">
<div class="flex">
<i class="fas fa-shield-alt mr-2 flex-shrink-0"></i>
<div>
<h3 class="font-medium">Security Note</h3>
<p class="mt-1">It's recommended to create an application key with restricted permissions for this configuration. The application key should only have access to the specific B2 bucket and operations needed.</p>
</div>
</div>
</div>
</div>
}
@@ -0,0 +1,3 @@
// Package destination contains the UI destination forms for the GoMFT application.
// This file serves as a marker for the destination package to ensure it's properly recognized by Go.
package destination
@@ -0,0 +1,81 @@
package destination
templ FTPDestinationForm() {
<div class="space-y-6 mt-4">
<div>
<label for="dest_host" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">FTP Host</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-server text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="dest_host" name="dest_host" x-model="destHost" x-bind:required="requiresDestination"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="ftp.example.com" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Hostname or IP address of the FTP server</p>
</div>
<div>
<label for="dest_port" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Port</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-network-wired text-gray-400 dark:text-gray-500"></i>
</div>
<input type="number" id="dest_port" name="dest_port" x-model="destPort"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="21" min="1" max="65535"
x-init="if (!destPort || destPort === 0) destPort = 21"
x-effect="if (destinationType === 'ftp' && (destPort === 0 || destPort === 22)) destPort = 21" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">FTP port number (default: 21)</p>
</div>
<div>
<label for="dest_user" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Username</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-user text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="dest_user" name="dest_user" x-model="destUser" x-bind:required="requiresDestination"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="username" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">FTP username for authentication</p>
</div>
<div>
<label for="dest_password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Password</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-lock text-gray-400 dark:text-gray-500"></i>
</div>
<input type="password" id="dest_password" name="dest_password" x-model="destPassword" x-bind:required="requiresDestination"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="Enter password" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">FTP password for authentication</p>
</div>
<div class="flex items-center mb-6">
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" id="dest_passive_mode" name="dest_passive_mode" x-model="destPassiveMode"
class="sr-only peer" :value="destPassiveMode ? 'true' : 'false'">
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
<span class="ms-3 text-sm font-medium text-gray-900 dark:text-white">Use Passive Mode</span>
</label>
</div>
<div>
<label for="destination_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Remote Path</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-folder-open text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="destination_path" name="destination_path" x-model="destinationPath" x-bind:required="requiresDestination"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="/remote/path/to/files" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Full path to the directory on the FTP server</p>
</div>
</div>
}
@@ -0,0 +1,106 @@
package destination
templ GoogleDriveDestinationForm() {
<div class="space-y-6 mt-4">
<div class="p-4 mb-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900/30 dark:text-blue-300" role="alert">
<div class="flex items-center">
<i class="fas fa-info-circle mr-2"></i>
<span>Google Drive requires authentication after creating this configuration. You'll be prompted to authenticate once the configuration is saved.</span>
</div>
</div>
<div>
<div class="flex items-center mb-6">
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" id="use_builtin_auth_dest" name="use_builtin_auth_dest" x-model="useBuiltinAuthDest"
class="sr-only peer" :value="useBuiltinAuthDest ? 'true' : 'false'">
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
<span class="ms-3 text-sm font-medium text-gray-900 dark:text-white">Use built-in authentication</span>
</label>
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Toggle to use your own Google API credentials instead of the built-in ones.
</p>
</div>
<div x-show="!useBuiltinAuthDest">
<div class="p-4 mb-4 text-sm text-yellow-800 rounded-lg bg-yellow-50 dark:bg-yellow-900/30 dark:text-yellow-300" role="alert">
<div class="flex">
<i class="fas fa-exclamation-triangle mr-2 flex-shrink-0"></i>
<span>You'll need to create your own Google API project with Drive API enabled and create OAuth credentials. Learn how at <a href="https://developers.google.com/drive/api/quickstart/go" class="font-medium underline hover:text-yellow-700 dark:hover:text-yellow-200" target="_blank">Google API documentation</a>.</span>
</div>
</div>
<div class="mb-6">
<label for="dest_client_id" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Client ID</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-id-card text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="dest_client_id" name="dest_client_id" x-model="destClientId"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="Your Google API Client ID" x-bind:required="!useBuiltinAuthDest" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">OAuth 2.0 Client ID from Google Cloud Console</p>
</div>
<div class="mb-6">
<label for="dest_client_secret" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Client Secret</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-key text-gray-400 dark:text-gray-500"></i>
</div>
<input type="password" id="dest_client_secret" name="dest_client_secret" x-model="destClientSecret"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="Your Google API Client Secret" x-bind:required="!useBuiltinAuthDest" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">OAuth 2.0 Client Secret from Google Cloud Console</p>
</div>
</div>
<div>
<label for="destination_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Drive Path</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fab fa-google-drive text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="destination_path" name="destination_path" x-model="destinationPath"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="/path/in/drive" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Path to the directory in your Google Drive. Leave empty for the root of your Drive.
</p>
</div>
<div>
<label for="dest_drive_id" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Drive ID (Optional)</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-fingerprint text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="dest_drive_id" name="dest_drive_id" x-model="destDriveId"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="Specific Google Drive ID" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
For accessing a shared drive. Leave empty to use your personal Google Drive.
</p>
</div>
<div>
<label for="dest_team_drive" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Team Drive (Optional)</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-users text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="dest_team_drive" name="dest_team_drive" x-model="destTeamDrive"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="Team Drive ID" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
For accessing a shared team drive. Leave empty for personal drive.
</p>
</div>
</div>
}
@@ -0,0 +1,188 @@
package destination
templ GooglePhotosDestinationForm() {
<div class="space-y-6 mt-4" x-init="$watch('useBuiltinAuthDest', value => {
if(value) {
destClientId = '';
destClientSecret = '';
}
})">
<div class="p-4 mb-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900/30 dark:text-blue-300" role="alert">
<div class="flex items-center">
<i class="fas fa-info-circle mr-2"></i>
<span>Google Photos requires authentication after creating this configuration. You'll be prompted to authenticate once the configuration is saved.</span>
</div>
</div>
<div>
<div class="flex items-center mb-6">
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" id="use_builtin_auth_dest" name="use_builtin_auth_dest" x-model="useBuiltinAuthDest"
class="sr-only peer" :value="useBuiltinAuthDest ? 'true' : 'false'">
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
<span class="ms-3 text-sm font-medium text-gray-900 dark:text-white">Use rclone's built-in Google authentication (recommended)</span>
</label>
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Simple one-click authentication using rclone's shared credentials
</p>
</div>
<div x-bind:class="{ 'opacity-50': useBuiltinAuthDest }">
<div class="mb-6">
<label for="dest_client_id" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
Google Client ID
<span x-show="useBuiltinAuthDest" class="text-gray-500 dark:text-gray-400 text-xs font-normal">(Using rclone default)</span>
</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-id-card text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="dest_client_id" name="dest_client_id" x-model="destClientId"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
x-bind:disabled="useBuiltinAuthDest"
placeholder="Google Photos OAuth Client ID" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Client ID from Google Cloud Console
</p>
</div>
<div class="mb-6">
<label for="dest_client_secret" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
Google Client Secret
<span x-show="useBuiltinAuthDest" class="text-gray-500 dark:text-gray-400 text-xs font-normal">(Using rclone default)</span>
</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-key text-gray-400 dark:text-gray-500"></i>
</div>
<input type="password" id="dest_client_secret" name="dest_client_secret" x-model="destClientSecret"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
x-bind:disabled="useBuiltinAuthDest"
placeholder="Google Photos OAuth Client Secret" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Client Secret from Google Cloud Console
</p>
</div>
</div>
<div class="flex items-center mb-6">
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" id="dest_read_only" name="dest_read_only" x-model="destReadOnly"
class="sr-only peer" :value="destReadOnly ? 'true' : 'false'">
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
<span class="ms-3 text-sm font-medium text-gray-900 dark:text-white">Read-only mode</span>
</label>
</div>
<p class="ms-14 text-sm text-gray-500 dark:text-gray-400">
Only request read-only access to your photos
</p>
<div class="mb-6">
<label for="dest_start_year" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Start Year (Optional)</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-calendar text-gray-400 dark:text-gray-500"></i>
</div>
<input type="number" id="dest_start_year" name="dest_start_year" x-model="destStartYear"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="Only include photos after this year (default: 2000)" min="1900" max="2100" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Only include photos uploaded after this year
</p>
</div>
<div class="flex items-center mb-6">
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" id="dest_include_archived" name="dest_include_archived" x-model="destIncludeArchived"
class="sr-only peer" :value="destIncludeArchived ? 'true' : 'false'">
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
<span class="ms-3 text-sm font-medium text-gray-900 dark:text-white">Include archived media</span>
</label>
</div>
<p class="ms-14 text-sm text-gray-500 dark:text-gray-400">
Include archived photos and videos in media listings
</p>
<div class="mb-6">
<label for="destination_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Album Path</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-images text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="destination_path" name="destination_path" x-model="destinationPath"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="Album path (e.g., album/my-photos)" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Path within Google Photos to download files from
</p>
</div>
<div class="p-4 mb-4 text-sm text-yellow-800 rounded-lg bg-yellow-50 dark:bg-yellow-900/30 dark:text-yellow-300" role="alert">
<div class="flex">
<i class="fas fa-exclamation-triangle mr-2 flex-shrink-0"></i>
<div>
<h3 class="font-medium">Authentication Required</h3>
<p class="mt-1">After saving this configuration, you'll need to authenticate with Google Photos:</p>
<ol class="list-decimal list-inside mt-2 space-y-1">
<li>Go to the configurations page</li>
<li>Click the "Authenticate" button for this configuration</li>
<li>You'll be redirected to Google to grant access permissions</li>
<li>After granting permission, you'll be automatically redirected back to GoMFT</li>
</ol>
</div>
</div>
</div>
<div class="p-4 mb-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900/30 dark:text-blue-300" role="alert">
<div class="flex">
<i class="fas fa-info-circle mr-2 flex-shrink-0"></i>
<div>
<h3 class="font-medium">Authentication Information</h3>
<div class="mt-1">
<template x-if="useBuiltinAuthDest">
<div>
<p>You're using rclone's built-in authentication, which simplifies the setup process:</p>
<ul class="list-disc list-inside mt-1 space-y-1">
<li>No need to create your own Google API credentials</li>
<li>Authentication happens through Google's secure OAuth flow</li>
<li>Automatic redirection back to GoMFT after authentication</li>
</ul>
<p class="mt-2 text-yellow-700 dark:text-yellow-300">
<i class="fas fa-exclamation-triangle mr-1"></i>
Note: The built-in authentication uses shared credentials which have rate limits across all rclone users.
If you plan to transfer large amounts of data or run many concurrent transfers, consider creating your own credentials.
</p>
</div>
</template>
<template x-if="!useBuiltinAuthDest">
<div>
<p>To use Google Photos with your own credentials:</p>
<ol class="list-decimal list-inside mt-1 space-y-1">
<li>Go to the <a href="https://console.cloud.google.com/" target="_blank" class="font-medium underline hover:text-blue-700 dark:hover:text-blue-300">Google Cloud Console</a></li>
<li>Create a project and enable the Google Photos API</li>
<li>Create OAuth 2.0 credentials (Client ID & Secret)</li>
<li>Set authorized redirect URI to <code class="bg-blue-100 dark:bg-blue-800 px-1.5 py-0.5 rounded text-xs">http://localhost:53682/</code></li>
</ol>
</div>
</template>
</div>
</div>
</div>
</div>
<div class="p-4 mb-4 text-sm text-yellow-800 rounded-lg bg-yellow-50 dark:bg-yellow-900/30 dark:text-yellow-300" role="alert">
<div class="flex">
<i class="fas fa-exclamation-circle mr-2 flex-shrink-0"></i>
<div>
<h3 class="font-medium">Important Note About Google Photos</h3>
<p class="mt-1">When downloading from Google Photos, be aware that some original metadata may not be preserved. Google Photos processes and may compress some images upon upload.</p>
</div>
</div>
</div>
</div>
}
@@ -0,0 +1,128 @@
package destination
templ HetznerDestinationForm() {
<div class="space-y-6 mt-4">
<div class="p-4 mb-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900/30 dark:text-blue-300" role="alert">
<div class="flex items-center">
<i class="fas fa-info-circle mr-2"></i>
<span>Configure your Hetzner Storage Box details below. You'll need your server details, username, and password.</span>
</div>
</div>
<div class="mb-6">
<label for="dest_host" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Storage Box Host</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-server text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="dest_host" name="dest_host" x-model="destHost" x-bind:required="requiresDestination"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="uXXXXXX.your-storagebox.de" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Your Hetzner Storage Box hostname (e.g., uXXXXXX.your-storagebox.de)
</p>
</div>
<div class="mb-6">
<label for="dest_port" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Port</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-plug text-gray-400 dark:text-gray-500"></i>
</div>
<input type="number" id="dest_port" name="dest_port" x-model="destPort" x-bind:required="requiresDestination"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="23" min="1" max="65535" value="23" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Connection port (default: 23)
</p>
</div>
<div class="mb-6">
<label for="dest_auth_type" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Authentication Type</label>
<div class="flex">
<div class="flex items-center me-4">
<input id="dest_auth_password" type="radio" value="password" name="dest_auth_type" x-model="destAuthType"
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600">
<label for="dest_auth_password" class="ms-2 text-sm font-medium text-gray-900 dark:text-white">Password</label>
</div>
<div class="flex items-center me-4">
<input id="dest_auth_key" type="radio" value="key" name="dest_auth_type" x-model="destAuthType"
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600">
<label for="dest_auth_key" class="ms-2 text-sm font-medium text-gray-900 dark:text-white">SSH Key</label>
</div>
</div>
</div>
<div class="mb-6">
<label for="dest_user" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Username</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-user text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="dest_user" name="dest_user" x-model="destUser" x-bind:required="requiresDestination"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="uXXXXXX" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Your Hetzner Storage Box username (typically matches your Storage Box number)
</p>
</div>
<div x-show="destAuthType === 'password'" class="mb-6">
<label for="dest_password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Password</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-lock text-gray-400 dark:text-gray-500"></i>
</div>
<input type="password" id="dest_password" name="dest_password" x-model="destPassword" x-bind:required="requiresDestination && destAuthType === 'password'"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="Your password" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Your Hetzner Storage Box password
</p>
</div>
<div x-show="destAuthType === 'key'" class="mb-6">
<label for="dest_key_file" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">SSH Key File</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-key text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="dest_key_file" name="dest_key_file" x-model="destKeyFile" x-bind:required="requiresDestination && destAuthType === 'key'"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="/path/to/id_rsa" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Path to your SSH private key file (must be readable by the application)
</p>
</div>
<div class="mb-6">
<label for="destination_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Remote Path</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-folder-open text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="destination_path" name="destination_path" x-model="destinationPath" x-bind:required="requiresDestination"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="/backups" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Path on your Storage Box (e.g., /backups, /path/to/files)
</p>
</div>
<div class="p-4 mb-4 text-sm text-yellow-800 rounded-lg bg-yellow-50 dark:bg-yellow-900/30 dark:text-yellow-300" role="alert">
<div class="flex">
<i class="fas fa-shield-alt mr-2 flex-shrink-0"></i>
<div>
<h3 class="font-medium">Security Note</h3>
<p class="mt-1">For increased security, consider using SSH key authentication instead of password authentication when possible.</p>
</div>
</div>
</div>
</div>
}
@@ -0,0 +1,31 @@
package destination
templ LocalDestinationForm() {
<div class="space-y-4 mt-4">
<div>
<label for="destination_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Local Path</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-folder-open text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="destination_path" name="destination_path" x-model="destinationPath" x-bind:required="requiresDestination"
:class="{ 'border-red-500 dark:border-red-700': destPathValid === false, 'border-green-500 dark:border-green-700': destPathValid === true }"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="/path/to/files" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Full path to the local directory containing your files</p>
<div x-show="destPathValid === false" class="mt-2 text-sm text-red-600 dark:text-red-400">
<div class="flex items-center">
<i class="fas fa-exclamation-circle mr-2"></i>
<span x-text="destPathError"></span>
</div>
</div>
<div x-show="destPathValid === true" class="mt-2 text-sm text-green-600 dark:text-green-400">
<div class="flex items-center">
<i class="fas fa-check-circle mr-2"></i>
<span x-text="destPathError || 'Path is valid'"></span>
</div>
</div>
</div>
</div>
}
@@ -0,0 +1,113 @@
package destination
templ MinIODestinationForm() {
<div class="space-y-6 mt-4">
<div class="p-4 mb-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900/30 dark:text-blue-300" role="alert">
<div class="flex items-center">
<i class="fas fa-info-circle mr-2"></i>
<span>Configure your MinIO server details below. You'll need your access key, secret key, and endpoint information.</span>
</div>
</div>
<div class="mb-6">
<label for="dest_endpoint" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Endpoint URL</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-server text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="dest_endpoint" name="dest_endpoint" x-model="destEndpoint" x-bind:required="requiresDestination"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="https://minio.example.com" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Full URL to your MinIO server including protocol (http:// or https://)
</p>
</div>
<div class="mb-6">
<label for="dest_bucket" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Bucket Name</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-archive text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="dest_bucket" name="dest_bucket" x-model="destBucket" x-bind:required="requiresDestination"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="my-bucket" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Name of your MinIO bucket (case-sensitive)
</p>
</div>
<div class="mb-6">
<label for="dest_region" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Region (Optional)</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-globe-americas text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="dest_region" name="dest_region" x-model="destRegion"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="us-east-1" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Optional: Specify the region if your MinIO setup requires it.
</p>
</div>
<div class="mb-6">
<label for="dest_access_key" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Access Key</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-key text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="dest_access_key" name="dest_access_key" x-model="destAccessKey" x-bind:required="requiresDestination"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="minioadmin" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Your MinIO Access Key for authentication
</p>
</div>
<div class="mb-6">
<label for="dest_secret_key" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Secret Key</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-lock text-gray-400 dark:text-gray-500"></i>
</div>
<input type="password" id="dest_secret_key" name="dest_secret_key" x-model="destSecretKey" x-bind:required="requiresDestination"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="minioadmin" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Your MinIO Secret Key for authentication
</p>
</div>
<div class="mb-6">
<label for="destination_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Path in Bucket</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-folder-open text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="destination_path" name="destination_path" x-model="destinationPath"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="path/to/files/" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Path prefix within the bucket (e.g., "backups/"). Leave empty to access the entire bucket.
</p>
</div>
<div class="p-4 mb-4 text-sm text-yellow-800 rounded-lg bg-yellow-50 dark:bg-yellow-900/30 dark:text-yellow-300" role="alert">
<div class="flex">
<i class="fas fa-exclamation-triangle mr-2 flex-shrink-0"></i>
<div>
<h3 class="font-medium">Configuration Note</h3>
<p class="mt-1">Make sure your MinIO server is properly configured with TLS if you're using HTTPS, and that the server is accessible from this machine.</p>
</div>
</div>
</div>
</div>
}
@@ -0,0 +1,82 @@
package destination
templ NextCloudDestinationForm() {
<div class="space-y-6 mt-4">
<div class="p-4 mb-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900/30 dark:text-blue-300" role="alert">
<div class="flex items-center">
<i class="fas fa-info-circle mr-2"></i>
<span>Configure your NextCloud connection details below. You'll need your server URL, username, and password.</span>
</div>
</div>
<div class="mb-6">
<label for="dest_host" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">NextCloud URL</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-cloud text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="dest_host" name="dest_host" x-model="destHost" x-bind:required="requiresDestination"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="https://nextcloud.example.com" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Full URL to your NextCloud server including protocol (https://)
</p>
</div>
<div class="mb-6">
<label for="dest_user" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Username</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-user text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="dest_user" name="dest_user" x-model="destUser" x-bind:required="requiresDestination"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="username" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Your NextCloud username
</p>
</div>
<div class="mb-6">
<label for="dest_password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Password</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-lock text-gray-400 dark:text-gray-500"></i>
</div>
<input type="password" id="dest_password" name="dest_password" x-model="destPassword" x-bind:required="requiresDestination"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="password" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Your NextCloud account password
</p>
</div>
<div class="mb-6">
<label for="destination_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Remote Path</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-folder-open text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="destination_path" name="destination_path" x-model="destinationPath" x-bind:required="requiresDestination"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="/path/to/files" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Path to your files in NextCloud
</p>
</div>
<div class="p-4 mb-4 text-sm text-yellow-800 rounded-lg bg-yellow-50 dark:bg-yellow-900/30 dark:text-yellow-300" role="alert">
<div class="flex">
<i class="fas fa-lightbulb mr-2 flex-shrink-0"></i>
<div>
<h3 class="font-medium">Connection Tip</h3>
<p class="mt-1">If you have two-factor authentication enabled on your NextCloud account, you'll need to create an app password in your NextCloud security settings.</p>
</div>
</div>
</div>
</div>
}
+97
View File
@@ -0,0 +1,97 @@
package destination
templ S3DestinationForm() {
<div class="space-y-6 mt-4">
<div class="p-4 mb-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900/30 dark:text-blue-300" role="alert">
<div class="flex items-center">
<i class="fas fa-info-circle mr-2"></i>
<span>Configure your AWS S3 bucket details below. You'll need your access key, secret key, and bucket information.</span>
</div>
</div>
<div class="mb-6">
<label for="dest_region" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">AWS Region</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-globe text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="dest_region" name="dest_region" x-model="destRegion" x-bind:required="requiresDestination"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="us-east-1" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
AWS Region where your S3 bucket is located (e.g., us-east-1, eu-west-1)
</p>
</div>
<div class="mb-6">
<label for="dest_bucket" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Bucket Name</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-archive text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="dest_bucket" name="dest_bucket" x-model="destBucket" x-bind:required="requiresDestination"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="my-bucket" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Name of your S3 bucket (case-sensitive)
</p>
</div>
<div class="mb-6">
<label for="dest_access_key" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Access Key ID</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-key text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="dest_access_key" name="dest_access_key" x-model="destAccessKey" x-bind:required="requiresDestination"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="AKIAIOSFODNN7EXAMPLE" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Your AWS Access Key ID
</p>
</div>
<div class="mb-6">
<label for="dest_secret_key" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Secret Access Key</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-lock text-gray-400 dark:text-gray-500"></i>
</div>
<input type="password" id="dest_secret_key" name="dest_secret_key" x-model="destSecretKey" x-bind:required="requiresDestination"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Your AWS Secret Access Key
</p>
</div>
<div class="mb-6">
<label for="destination_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Path in Bucket</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-folder-open text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="destination_path" name="destination_path" x-model="destinationPath"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="path/to/files/" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Path prefix within the bucket (e.g., "backups/"). Leave empty to access the entire bucket.
</p>
</div>
<div class="p-4 mb-4 text-sm text-yellow-800 rounded-lg bg-yellow-50 dark:bg-yellow-900/30 dark:text-yellow-300" role="alert">
<div class="flex">
<i class="fas fa-shield-alt mr-2 flex-shrink-0"></i>
<div>
<h3 class="font-medium">Security Note</h3>
<p class="mt-1">It's recommended to use an IAM user with restricted permissions for this configuration. The IAM user should only have access to the specific S3 bucket and operations needed.</p>
</div>
</div>
</div>
</div>
}
@@ -0,0 +1,97 @@
package destination
templ SFTPDestinationForm() {
<div class="space-y-6 mt-4">
<div>
<label for="dest_host" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">SFTP Host</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-server text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="dest_host" name="dest_host" x-model="destHost" x-bind:required="requiresDestination"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="sftp.example.com" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Hostname or IP address of the SFTP server</p>
</div>
<div>
<label for="dest_port" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Port</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-network-wired text-gray-400 dark:text-gray-500"></i>
</div>
<input type="number" id="dest_port" name="dest_port" x-model="destPort"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="22" min="1" max="65535" x-init="if (!destPort || destPort === 0) destPort = 22" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">SFTP port number (default: 22)</p>
</div>
<div>
<label for="dest_user" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Username</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-user text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="dest_user" name="dest_user" x-model="destUser" x-bind:required="requiresDestination"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="username" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">SFTP username for authentication</p>
</div>
<div>
<label class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Authentication Method</label>
<div class="flex items-center mb-4">
<input id="dest_auth_password" type="radio" value="password" name="dest_auth_type" x-model="destAuthType"
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600">
<label for="dest_auth_password" class="ms-2 text-sm font-medium text-gray-900 dark:text-white">Password</label>
</div>
<div class="flex items-center">
<input id="dest_auth_key" type="radio" value="key" name="dest_auth_type" x-model="destAuthType"
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600">
<label for="dest_auth_key" class="ms-2 text-sm font-medium text-gray-900 dark:text-white">SSH Key</label>
</div>
</div>
<div x-show="destAuthType === 'password'">
<label for="dest_password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Password</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-lock text-gray-400 dark:text-gray-500"></i>
</div>
<input type="password" id="dest_password" name="dest_password" x-model="destPassword"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="Enter password" x-bind:required="destAuthType === 'password'" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">SFTP password for authentication</p>
</div>
<div x-show="destAuthType === 'key'">
<label for="dest_key_file" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">SSH Key File</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-key text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="dest_key_file" name="dest_key_file" x-model="destKeyFile"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="/path/to/id_rsa" x-bind:required="destAuthType === 'key'" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Path to the SSH private key file</p>
</div>
<div>
<label for="destination_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Remote Path</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-folder-open text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="destination_path" name="destination_path" x-model="destinationPath" x-bind:required="requiresDestination"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="/remote/path/to/files" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Full path to the directory on the SFTP server</p>
</div>
</div>
}
+112
View File
@@ -0,0 +1,112 @@
package destination
templ SMBDestinationForm() {
<div class="space-y-6 mt-4">
<div class="p-4 mb-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900/30 dark:text-blue-300" role="alert">
<div class="flex items-center">
<i class="fas fa-info-circle mr-2"></i>
<span>Configure your SMB/CIFS share connection details below. You'll need server address, share name, and credentials.</span>
</div>
</div>
<div class="mb-6">
<label for="dest_host" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Server Address</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-server text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="dest_host" name="dest_host" x-model="destHost" x-bind:required="requiresDestination"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="192.168.1.100 or server.example.com" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
IP address or hostname of the SMB/CIFS server
</p>
</div>
<div class="mb-6">
<label for="dest_share" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Share Name</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-share-alt text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="dest_share" name="dest_share" x-model="destShare" x-bind:required="requiresDestination"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="Documents" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Name of the shared folder on the server (without leading slashes)
</p>
</div>
<div class="mb-6">
<label for="dest_domain" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Domain (Optional)</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-sitemap text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="dest_domain" name="dest_domain" x-model="destDomain"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="WORKGROUP or DOMAIN" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Windows domain name (if applicable). Default is 'WORKGROUP' for standalone servers.
</p>
</div>
<div class="mb-6">
<label for="dest_user" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Username</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-user text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="dest_user" name="dest_user" x-model="destUser" x-bind:required="requiresDestination"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="smb_username" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Username for SMB/CIFS authentication
</p>
</div>
<div class="mb-6">
<label for="dest_password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Password</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-lock text-gray-400 dark:text-gray-500"></i>
</div>
<input type="password" id="dest_password" name="dest_password" x-model="destPassword" x-bind:required="requiresDestination"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="SMB password" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Password for SMB/CIFS authentication
</p>
</div>
<div class="mb-6">
<label for="destination_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Subfolder Path (Optional)</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-folder-open text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="destination_path" name="destination_path" x-model="destinationPath"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="path/to/subfolder" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Path to a subfolder within the share (Optional). Use forward slashes.
</p>
</div>
<div class="p-4 mb-4 text-sm text-yellow-800 rounded-lg bg-yellow-50 dark:bg-yellow-900/30 dark:text-yellow-300" role="alert">
<div class="flex">
<i class="fas fa-exclamation-triangle mr-2 flex-shrink-0"></i>
<div>
<h3 class="font-medium">Connection Note</h3>
<p class="mt-1">Make sure the SMB server is accessible from this machine and that the share permissions allow the specified user to read the files.</p>
</div>
</div>
</div>
</div>
}
@@ -0,0 +1,112 @@
package destination
templ WasabiDestinationForm() {
<div class="space-y-6 mt-4">
<div class="p-4 mb-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900/30 dark:text-blue-300" role="alert">
<div class="flex items-center">
<i class="fas fa-info-circle mr-2"></i>
<span>Configure your Wasabi Cloud Storage details below. You'll need your access key, secret key, region, and bucket information.</span>
</div>
</div>
<div class="mb-6">
<label for="dest_region" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Wasabi Region</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-globe text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="dest_region" name="dest_region" x-model="destRegion" x-bind:required="requiresDestination"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="us-east-1" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Wasabi region where your bucket is located (e.g., us-east-1, us-west-1, eu-central-1)
</p>
</div>
<div class="mb-6">
<label for="dest_bucket" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Bucket Name</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-archive text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="dest_bucket" name="dest_bucket" x-model="destBucket" x-bind:required="requiresDestination"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="my-wasabi-bucket" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Name of your Wasabi bucket (case-sensitive)
</p>
</div>
<div class="mb-6">
<label for="dest_access_key" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Access Key</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-key text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="dest_access_key" name="dest_access_key" x-model="destAccessKey" x-bind:required="requiresDestination"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="ABCDEFGHIJKLMNOPQRST" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Your Wasabi Access Key
</p>
</div>
<div class="mb-6">
<label for="dest_secret_key" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Secret Key</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-lock text-gray-400 dark:text-gray-500"></i>
</div>
<input type="password" id="dest_secret_key" name="dest_secret_key" x-model="destSecretKey" x-bind:required="requiresDestination"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="abcdefghijklmnopqrstuvwxyz1234567890ABCD" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Your Wasabi Secret Key
</p>
</div>
<div class="mb-6">
<label for="dest_endpoint" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Endpoint (Optional)</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-server text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="dest_endpoint" name="dest_endpoint" x-model="destEndpoint"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="s3.wasabisys.com" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Wasabi endpoint URL (usually s3.wasabisys.com or region-specific endpoints)
</p>
</div>
<div class="mb-6">
<label for="destination_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Path in Bucket</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-folder-open text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="destination_path" name="destination_path" x-model="destinationPath"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="path/to/files/" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Path prefix within the bucket (e.g., "backups/"). Leave empty to access the entire bucket.
</p>
</div>
<div class="p-4 mb-4 text-sm text-yellow-800 rounded-lg bg-yellow-50 dark:bg-yellow-900/30 dark:text-yellow-300" role="alert">
<div class="flex">
<i class="fas fa-shield-alt mr-2 flex-shrink-0"></i>
<div>
<h3 class="font-medium">Security Note</h3>
<p class="mt-1">It's recommended to use an IAM user with restricted permissions for this configuration. The IAM user should only have access to the specific Wasabi bucket and operations needed.</p>
</div>
</div>
</div>
</div>
}
@@ -0,0 +1,82 @@
package destination
templ WebDAVDestinationForm() {
<div class="space-y-6 mt-4">
<div class="p-4 mb-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900/30 dark:text-blue-300" role="alert">
<div class="flex items-center">
<i class="fas fa-info-circle mr-2"></i>
<span>Configure your WebDAV connection details below. You'll need the server URL, username, and password.</span>
</div>
</div>
<div class="mb-6">
<label for="dest_host" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">WebDAV URL</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-globe text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="dest_host" name="dest_host" x-model="destHost" x-bind:required="requiresDestination"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="https://webdav.example.com" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Full URL to your WebDAV server including protocol (https://)
</p>
</div>
<div class="mb-6">
<label for="dest_user" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Username</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-user text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="dest_user" name="dest_user" x-model="destUser" x-bind:required="requiresDestination"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="username" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Your WebDAV username
</p>
</div>
<div class="mb-6">
<label for="dest_password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Password</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-lock text-gray-400 dark:text-gray-500"></i>
</div>
<input type="password" id="dest_password" name="dest_password" x-model="destPassword" x-bind:required="requiresDestination"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="password" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Your WebDAV account password
</p>
</div>
<div class="mb-6">
<label for="destination_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Remote Path</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-folder-open text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="destination_path" name="destination_path" x-model="destinationPath"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="/path/to/files" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Path to your files on the WebDAV server
</p>
</div>
<div class="p-4 mb-4 text-sm text-yellow-800 rounded-lg bg-yellow-50 dark:bg-yellow-900/30 dark:text-yellow-300" role="alert">
<div class="flex">
<i class="fas fa-lightbulb mr-2 flex-shrink-0"></i>
<div>
<h3 class="font-medium">Integration Note</h3>
<p class="mt-1">WebDAV is supported by many services including ownCloud, Nextcloud, Box, and many others. Make sure your WebDAV URL includes the full path to the WebDAV interface.</p>
</div>
</div>
</div>
</div>
}
+3
View File
@@ -0,0 +1,3 @@
// Package providers contains the UI providers for the GoMFT application.
// This file serves as a marker for the providers package to ensure it's properly recognized by Go.
package providers
+97
View File
@@ -0,0 +1,97 @@
package source
templ B2SourceForm() {
<div class="space-y-6 mt-4">
<div class="p-4 mb-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900/30 dark:text-blue-300" role="alert">
<div class="flex items-center">
<i class="fas fa-info-circle mr-2"></i>
<span>Configure your Backblaze B2 details below. You'll need your account ID, application key, and bucket information.</span>
</div>
</div>
<div class="mb-6">
<label for="source_bucket" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Bucket Name</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-archive text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="source_bucket" name="source_bucket" x-model="sourceBucket" required
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="my-b2-bucket" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Name of your Backblaze B2 bucket (case-sensitive)
</p>
</div>
<div class="mb-6">
<label for="source_access_key" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Account ID</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-id-card text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="source_access_key" name="source_access_key" x-model="sourceAccessKey" required
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="0123456789abcdef0123456789" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Your Backblaze B2 Account ID
</p>
</div>
<div class="mb-6">
<label for="source_secret_key" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Application Key</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-lock text-gray-400 dark:text-gray-500"></i>
</div>
<input type="password" id="source_secret_key" name="source_secret_key" x-model="sourceSecretKey" required
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="K0123456789abcdef0123456789abcdef0123456789" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Your Backblaze B2 Application Key
</p>
</div>
<div class="mb-6">
<label for="source_endpoint" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Endpoint (Optional)</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-server text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="source_endpoint" name="source_endpoint" x-model="sourceEndpoint"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="s3.us-west-000.backblazeb2.com" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Custom endpoint URL (only needed for non-standard regions or private endpoints)
</p>
</div>
<div class="mb-6">
<label for="source_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Path in Bucket</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-folder-open text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="source_path" name="source_path" x-model="sourcePath"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="path/to/files/" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Path prefix within the bucket (e.g., "backups/"). Leave empty to access the entire bucket.
</p>
</div>
<div class="p-4 mb-4 text-sm text-yellow-800 rounded-lg bg-yellow-50 dark:bg-yellow-900/30 dark:text-yellow-300" role="alert">
<div class="flex">
<i class="fas fa-shield-alt mr-2 flex-shrink-0"></i>
<div>
<h3 class="font-medium">Security Note</h3>
<p class="mt-1">It's recommended to create an application key with restricted permissions for this configuration. The application key should only have access to the specific B2 bucket and operations needed.</p>
</div>
</div>
</div>
</div>
}
+79
View File
@@ -0,0 +1,79 @@
package source
templ FTPSourceForm() {
<div class="space-y-6 mt-4">
<div>
<label for="source_host" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">FTP Host</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-server text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="source_host" name="source_host" x-model="sourceHost" required
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="ftp.example.com" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Hostname or IP address of the FTP server</p>
</div>
<div>
<label for="source_port" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Port</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-network-wired text-gray-400 dark:text-gray-500"></i>
</div>
<input type="number" id="source_port" name="source_port" x-model="sourcePort"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="21" min="1" max="65535" value="21"/>
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">FTP port number (default: 21)</p>
</div>
<div>
<label for="source_user" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Username</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-user text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="source_user" name="source_user" x-model="sourceUser" required
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="username" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">FTP username for authentication</p>
</div>
<div>
<label for="source_password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Password</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-lock text-gray-400 dark:text-gray-500"></i>
</div>
<input type="password" id="source_password" name="source_password" x-model="sourcePassword" required
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="Enter password" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">FTP password for authentication</p>
</div>
<div class="flex items-center mb-6">
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" id="source_passive_mode" name="source_passive_mode" x-model="sourcePassiveMode"
class="sr-only peer" :value="sourcePassiveMode ? 'true' : 'false'">
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
<span class="ms-3 text-sm font-medium text-gray-900 dark:text-white">Use Passive Mode</span>
</label>
</div>
<div>
<label for="source_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Remote Path</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-folder-open text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="source_path" name="source_path" x-model="sourcePath" required
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="/remote/path/to/files" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Full path to the directory on the FTP server</p>
</div>
</div>
}
+106
View File
@@ -0,0 +1,106 @@
package source
templ GoogleDriveSourceForm() {
<div class="space-y-6 mt-4">
<div class="p-4 mb-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900/30 dark:text-blue-300" role="alert">
<div class="flex items-center">
<i class="fas fa-info-circle mr-2"></i>
<span>Google Drive requires authentication after creating this configuration. You'll be prompted to authenticate once the configuration is saved.</span>
</div>
</div>
<div>
<div class="flex items-center mb-6">
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" id="use_builtin_auth_source" name="use_builtin_auth_source" x-model="useBuiltinAuthSource"
class="sr-only peer" :value="useBuiltinAuthSource ? 'true' : 'false'">
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
<span class="ms-3 text-sm font-medium text-gray-900 dark:text-white">Use built-in authentication</span>
</label>
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Toggle to use your own Google API credentials instead of the built-in ones.
</p>
</div>
<div x-show="!useBuiltinAuthSource">
<div class="p-4 mb-4 text-sm text-yellow-800 rounded-lg bg-yellow-50 dark:bg-yellow-900/30 dark:text-yellow-300" role="alert">
<div class="flex">
<i class="fas fa-exclamation-triangle mr-2 flex-shrink-0"></i>
<span>You'll need to create your own Google API project with Drive API enabled and create OAuth credentials. Learn how at <a href="https://developers.google.com/drive/api/quickstart/go" class="font-medium underline hover:text-yellow-700 dark:hover:text-yellow-200" target="_blank">Google API documentation</a>.</span>
</div>
</div>
<div class="mb-6">
<label for="source_client_id" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Client ID</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-id-card text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="source_client_id" name="source_client_id" x-model="sourceClientId"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="Your Google API Client ID" x-bind:required="!useBuiltinAuthSource" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">OAuth 2.0 Client ID from Google Cloud Console</p>
</div>
<div class="mb-6">
<label for="source_client_secret" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Client Secret</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-key text-gray-400 dark:text-gray-500"></i>
</div>
<input type="password" id="source_client_secret" name="source_client_secret" x-model="sourceClientSecret"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="Your Google API Client Secret" x-bind:required="!useBuiltinAuthSource" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">OAuth 2.0 Client Secret from Google Cloud Console</p>
</div>
</div>
<div>
<label for="source_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Drive Path</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fab fa-google-drive text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="source_path" name="source_path" x-model="sourcePath"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="/path/in/drive" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Path to the directory in your Google Drive. Leave empty for the root of your Drive.
</p>
</div>
<div>
<label for="source_drive_id" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Drive ID (Optional)</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-fingerprint text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="source_drive_id" name="source_drive_id" x-model="sourceDriveId"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="Specific Google Drive ID" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
For accessing a shared drive. Leave empty to use your personal Google Drive.
</p>
</div>
<div>
<label for="source_team_drive" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Team Drive (Optional)</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-users text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="source_team_drive" name="source_team_drive" x-model="sourceTeamDrive"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="Team Drive ID" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
For accessing a shared team drive. Leave empty for personal drive.
</p>
</div>
</div>
}
+188
View File
@@ -0,0 +1,188 @@
package source
templ GooglePhotosSourceForm() {
<div class="space-y-6 mt-4" x-init="$watch('useBuiltinAuthSource', value => {
if(value) {
sourceClientId = '';
sourceClientSecret = '';
}
})">
<div class="p-4 mb-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900/30 dark:text-blue-300" role="alert">
<div class="flex items-center">
<i class="fas fa-info-circle mr-2"></i>
<span>Google Photos requires authentication after creating this configuration. You'll be prompted to authenticate once the configuration is saved.</span>
</div>
</div>
<div>
<div class="flex items-center mb-6">
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" id="use_builtin_auth_source" name="use_builtin_auth_source" x-model="useBuiltinAuthSource"
class="sr-only peer" :value="useBuiltinAuthSource ? 'true' : 'false'">
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
<span class="ms-3 text-sm font-medium text-gray-900 dark:text-white">Use rclone's built-in Google authentication (recommended)</span>
</label>
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Simple one-click authentication using rclone's shared credentials
</p>
</div>
<div x-bind:class="{ 'opacity-50': useBuiltinAuthSource }">
<div class="mb-6">
<label for="source_client_id" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
Google Client ID
<span x-show="useBuiltinAuthSource" class="text-gray-500 dark:text-gray-400 text-xs font-normal">(Using rclone default)</span>
</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-id-card text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="source_client_id" name="source_client_id" x-model="sourceClientId"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
x-bind:disabled="useBuiltinAuthSource"
placeholder="Google Photos OAuth Client ID" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Client ID from Google Cloud Console
</p>
</div>
<div class="mb-6">
<label for="source_client_secret" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
Google Client Secret
<span x-show="useBuiltinAuthSource" class="text-gray-500 dark:text-gray-400 text-xs font-normal">(Using rclone default)</span>
</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-key text-gray-400 dark:text-gray-500"></i>
</div>
<input type="password" id="source_client_secret" name="source_client_secret" x-model="sourceClientSecret"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
x-bind:disabled="useBuiltinAuthSource"
placeholder="Google Photos OAuth Client Secret" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Client Secret from Google Cloud Console
</p>
</div>
</div>
<div class="flex items-center mb-6">
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" id="source_read_only" name="source_read_only" x-model="sourceReadOnly"
class="sr-only peer" :value="sourceReadOnly ? 'true' : 'false'">
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
<span class="ms-3 text-sm font-medium text-gray-900 dark:text-white">Read-only mode</span>
</label>
</div>
<p class="ms-14 text-sm text-gray-500 dark:text-gray-400">
Only request read-only access to your photos
</p>
<div class="mb-6">
<label for="source_start_year" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Start Year (Optional)</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-calendar text-gray-400 dark:text-gray-500"></i>
</div>
<input type="number" id="source_start_year" name="source_start_year" x-model="sourceStartYear"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="Only include photos after this year (default: 2000)" min="1900" max="2100" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Only include photos uploaded after this year
</p>
</div>
<div class="flex items-center mb-6">
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" id="source_include_archived" name="source_include_archived" x-model="sourceIncludeArchived"
class="sr-only peer" :value="sourceIncludeArchived ? 'true' : 'false'">
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
<span class="ms-3 text-sm font-medium text-gray-900 dark:text-white">Include archived media</span>
</label>
</div>
<p class="ms-14 text-sm text-gray-500 dark:text-gray-400">
Include archived photos and videos in media listings
</p>
<div class="mb-6">
<label for="source_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Album Path</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-images text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="source_path" name="source_path" x-model="sourcePath"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="Album path (e.g., album/my-photos)" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Path within Google Photos to download files from
</p>
</div>
<div class="p-4 mb-4 text-sm text-yellow-800 rounded-lg bg-yellow-50 dark:bg-yellow-900/30 dark:text-yellow-300" role="alert">
<div class="flex">
<i class="fas fa-exclamation-triangle mr-2 flex-shrink-0"></i>
<div>
<h3 class="font-medium">Authentication Required</h3>
<p class="mt-1">After saving this configuration, you'll need to authenticate with Google Photos:</p>
<ol class="list-decimal list-inside mt-2 space-y-1">
<li>Go to the configurations page</li>
<li>Click the "Authenticate" button for this configuration</li>
<li>You'll be redirected to Google to grant access permissions</li>
<li>After granting permission, you'll be automatically redirected back to GoMFT</li>
</ol>
</div>
</div>
</div>
<div class="p-4 mb-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900/30 dark:text-blue-300" role="alert">
<div class="flex">
<i class="fas fa-info-circle mr-2 flex-shrink-0"></i>
<div>
<h3 class="font-medium">Authentication Information</h3>
<div class="mt-1">
<template x-if="useBuiltinAuthSource">
<div>
<p>You're using rclone's built-in authentication, which simplifies the setup process:</p>
<ul class="list-disc list-inside mt-1 space-y-1">
<li>No need to create your own Google API credentials</li>
<li>Authentication happens through Google's secure OAuth flow</li>
<li>Automatic redirection back to GoMFT after authentication</li>
</ul>
<p class="mt-2 text-yellow-700 dark:text-yellow-300">
<i class="fas fa-exclamation-triangle mr-1"></i>
Note: The built-in authentication uses shared credentials which have rate limits across all rclone users.
If you plan to transfer large amounts of data or run many concurrent transfers, consider creating your own credentials.
</p>
</div>
</template>
<template x-if="!useBuiltinAuthSource">
<div>
<p>To use Google Photos with your own credentials:</p>
<ol class="list-decimal list-inside mt-1 space-y-1">
<li>Go to the <a href="https://console.cloud.google.com/" target="_blank" class="font-medium underline hover:text-blue-700 dark:hover:text-blue-300">Google Cloud Console</a></li>
<li>Create a project and enable the Google Photos API</li>
<li>Create OAuth 2.0 credentials (Client ID & Secret)</li>
<li>Set authorized redirect URI to <code class="bg-blue-100 dark:bg-blue-800 px-1.5 py-0.5 rounded text-xs">http://localhost:53682/</code></li>
</ol>
</div>
</template>
</div>
</div>
</div>
</div>
<div class="p-4 mb-4 text-sm text-yellow-800 rounded-lg bg-yellow-50 dark:bg-yellow-900/30 dark:text-yellow-300" role="alert">
<div class="flex">
<i class="fas fa-exclamation-circle mr-2 flex-shrink-0"></i>
<div>
<h3 class="font-medium">Important Note About Google Photos</h3>
<p class="mt-1">When downloading from Google Photos, be aware that some original metadata may not be preserved. Google Photos processes and may compress some images upon upload.</p>
</div>
</div>
</div>
</div>
}
+128
View File
@@ -0,0 +1,128 @@
package source
templ HetznerSourceForm() {
<div class="space-y-6 mt-4">
<div class="p-4 mb-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900/30 dark:text-blue-300" role="alert">
<div class="flex items-center">
<i class="fas fa-info-circle mr-2"></i>
<span>Configure your Hetzner Storage Box details below. You'll need your server details, username, and password.</span>
</div>
</div>
<div class="mb-6">
<label for="source_host" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Storage Box Host</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-server text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="source_host" name="source_host" x-model="sourceHost" required
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="uXXXXXX.your-storagebox.de" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Your Hetzner Storage Box hostname (e.g., uXXXXXX.your-storagebox.de)
</p>
</div>
<div class="mb-6">
<label for="source_port" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Port</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-plug text-gray-400 dark:text-gray-500"></i>
</div>
<input type="number" id="source_port" name="source_port" x-model="sourcePort" required
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="23" min="1" max="65535" value="23" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Connection port (default: 23)
</p>
</div>
<div class="mb-6">
<label for="source_auth_type" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Authentication Type</label>
<div class="flex">
<div class="flex items-center me-4">
<input id="source_auth_password" type="radio" value="password" name="source_auth_type" x-model="sourceAuthType"
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600">
<label for="source_auth_password" class="ms-2 text-sm font-medium text-gray-900 dark:text-white">Password</label>
</div>
<div class="flex items-center me-4">
<input id="source_auth_key" type="radio" value="key" name="source_auth_type" x-model="sourceAuthType"
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600">
<label for="source_auth_key" class="ms-2 text-sm font-medium text-gray-900 dark:text-white">SSH Key</label>
</div>
</div>
</div>
<div class="mb-6">
<label for="source_user" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Username</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-user text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="source_user" name="source_user" x-model="sourceUser" required
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="uXXXXXX" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Your Hetzner Storage Box username (typically matches your Storage Box number)
</p>
</div>
<div x-show="sourceAuthType === 'password'" class="mb-6">
<label for="source_password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Password</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-lock text-gray-400 dark:text-gray-500"></i>
</div>
<input type="password" id="source_password" name="source_password" x-model="sourcePassword" x-bind:required="sourceAuthType === 'password'"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="Your password" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Your Hetzner Storage Box password
</p>
</div>
<div x-show="sourceAuthType === 'key'" class="mb-6">
<label for="source_key_file" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">SSH Key File</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-key text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="source_key_file" name="source_key_file" x-model="sourceKeyFile" x-bind:required="sourceAuthType === 'key'"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="/path/to/id_rsa" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Path to your SSH private key file (must be readable by the application)
</p>
</div>
<div class="mb-6">
<label for="source_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Remote Path</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-folder-open text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="source_path" name="source_path" x-model="sourcePath" required
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="/backups" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Path on your Storage Box (e.g., /backups, /path/to/files)
</p>
</div>
<div class="p-4 mb-4 text-sm text-yellow-800 rounded-lg bg-yellow-50 dark:bg-yellow-900/30 dark:text-yellow-300" role="alert">
<div class="flex">
<i class="fas fa-shield-alt mr-2 flex-shrink-0"></i>
<div>
<h3 class="font-medium">Security Note</h3>
<p class="mt-1">For increased security, consider using SSH key authentication instead of password authentication when possible.</p>
</div>
</div>
</div>
</div>
}
+31
View File
@@ -0,0 +1,31 @@
package source
templ LocalSourceForm() {
<div class="space-y-4 mt-4">
<div>
<label for="source_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Local Path</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-folder-open text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="source_path" name="source_path" x-model="sourcePath" required
:class="{ 'border-red-500 dark:border-red-700': sourcePathValid === false, 'border-green-500 dark:border-green-700': sourcePathValid === true }"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="/path/to/files" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Full path to the local directory containing your files</p>
<div x-show="sourcePathValid === false" class="mt-2 text-sm text-red-600 dark:text-red-400">
<div class="flex items-center">
<i class="fas fa-exclamation-circle mr-2"></i>
<span x-text="sourcePathError"></span>
</div>
</div>
<div x-show="sourcePathValid === true" class="mt-2 text-sm text-green-600 dark:text-green-400">
<div class="flex items-center">
<i class="fas fa-check-circle mr-2"></i>
<span x-text="sourcePathError || 'Path is valid'"></span>
</div>
</div>
</div>
</div>
}
+113
View File
@@ -0,0 +1,113 @@
package source
templ MinIOSourceForm() {
<div class="space-y-6 mt-4">
<div class="p-4 mb-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900/30 dark:text-blue-300" role="alert">
<div class="flex items-center">
<i class="fas fa-info-circle mr-2"></i>
<span>Configure your MinIO server details below. You'll need your access key, secret key, and endpoint information.</span>
</div>
</div>
<div class="mb-6">
<label for="source_endpoint" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Endpoint URL</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-server text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="source_endpoint" name="source_endpoint" x-model="sourceEndpoint" required
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="https://minio.example.com" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Full URL to your MinIO server including protocol (http:// or https://)
</p>
</div>
<div class="mb-6">
<label for="source_bucket" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Bucket Name</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-archive text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="source_bucket" name="source_bucket" x-model="sourceBucket" required
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="my-bucket" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Name of your MinIO bucket (case-sensitive)
</p>
</div>
<div class="mb-6">
<label for="source_region" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Region (Optional)</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-globe-americas text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="source_region" name="source_region" x-model="sourceRegion"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="us-east-1" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Optional: Specify the region if your MinIO setup requires it.
</p>
</div>
<div class="mb-6">
<label for="source_access_key" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Access Key</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-key text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="source_access_key" name="source_access_key" x-model="sourceAccessKey" required
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="minioadmin" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Your MinIO Access Key for authentication
</p>
</div>
<div class="mb-6">
<label for="source_secret_key" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Secret Key</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-lock text-gray-400 dark:text-gray-500"></i>
</div>
<input type="password" id="source_secret_key" name="source_secret_key" x-model="sourceSecretKey" required
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="minioadmin" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Your MinIO Secret Key for authentication
</p>
</div>
<div class="mb-6">
<label for="source_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Path in Bucket</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-folder-open text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="source_path" name="source_path" x-model="sourcePath"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="path/to/files/" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Path prefix within the bucket (e.g., "backups/"). Leave empty to access the entire bucket.
</p>
</div>
<div class="p-4 mb-4 text-sm text-yellow-800 rounded-lg bg-yellow-50 dark:bg-yellow-900/30 dark:text-yellow-300" role="alert">
<div class="flex">
<i class="fas fa-exclamation-triangle mr-2 flex-shrink-0"></i>
<div>
<h3 class="font-medium">Configuration Note</h3>
<p class="mt-1">Make sure your MinIO server is properly configured with TLS if you're using HTTPS, and that the server is accessible from this machine.</p>
</div>
</div>
</div>
</div>
}
@@ -0,0 +1,82 @@
package source
templ NextCloudSourceForm() {
<div class="space-y-6 mt-4">
<div class="p-4 mb-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900/30 dark:text-blue-300" role="alert">
<div class="flex items-center">
<i class="fas fa-info-circle mr-2"></i>
<span>Configure your NextCloud connection details below. You'll need your server URL, username, and password.</span>
</div>
</div>
<div class="mb-6">
<label for="source_host" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">NextCloud URL</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-cloud text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="source_host" name="source_host" x-model="sourceHost" required
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="https://nextcloud.example.com" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Full URL to your NextCloud server including protocol (https://)
</p>
</div>
<div class="mb-6">
<label for="source_user" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Username</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-user text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="source_user" name="source_user" x-model="sourceUser" required
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="username" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Your NextCloud username
</p>
</div>
<div class="mb-6">
<label for="source_password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Password</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-lock text-gray-400 dark:text-gray-500"></i>
</div>
<input type="password" id="source_password" name="source_password" x-model="sourcePassword" required
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="password" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Your NextCloud account password
</p>
</div>
<div class="mb-6">
<label for="source_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Remote Path</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-folder-open text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="source_path" name="source_path" x-model="sourcePath" required
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="/path/to/files" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Path to your files in NextCloud
</p>
</div>
<div class="p-4 mb-4 text-sm text-yellow-800 rounded-lg bg-yellow-50 dark:bg-yellow-900/30 dark:text-yellow-300" role="alert">
<div class="flex">
<i class="fas fa-lightbulb mr-2 flex-shrink-0"></i>
<div>
<h3 class="font-medium">Connection Tip</h3>
<p class="mt-1">If you have two-factor authentication enabled on your NextCloud account, you'll need to create an app password in your NextCloud security settings.</p>
</div>
</div>
</div>
</div>
}
+97
View File
@@ -0,0 +1,97 @@
package source
templ S3SourceForm() {
<div class="space-y-6 mt-4">
<div class="p-4 mb-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900/30 dark:text-blue-300" role="alert">
<div class="flex items-center">
<i class="fas fa-info-circle mr-2"></i>
<span>Configure your AWS S3 bucket details below. You'll need your access key, secret key, and bucket information.</span>
</div>
</div>
<div class="mb-6">
<label for="source_region" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">AWS Region</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-globe text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="source_region" name="source_region" x-model="sourceRegion" required
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="us-east-1" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
AWS Region where your S3 bucket is located (e.g., us-east-1, eu-west-1)
</p>
</div>
<div class="mb-6">
<label for="source_bucket" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Bucket Name</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-archive text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="source_bucket" name="source_bucket" x-model="sourceBucket" required
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="my-bucket" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Name of your S3 bucket (case-sensitive)
</p>
</div>
<div class="mb-6">
<label for="source_access_key" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Access Key ID</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-key text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="source_access_key" name="source_access_key" x-model="sourceAccessKey" required
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="AKIAIOSFODNN7EXAMPLE" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Your AWS Access Key ID
</p>
</div>
<div class="mb-6">
<label for="source_secret_key" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Secret Access Key</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-lock text-gray-400 dark:text-gray-500"></i>
</div>
<input type="password" id="source_secret_key" name="source_secret_key" x-model="sourceSecretKey" required
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Your AWS Secret Access Key
</p>
</div>
<div class="mb-6">
<label for="source_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Path in Bucket</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-folder-open text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="source_path" name="source_path" x-model="sourcePath"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="path/to/files/" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Path prefix within the bucket (e.g., "backups/"). Leave empty to access the entire bucket.
</p>
</div>
<div class="p-4 mb-4 text-sm text-yellow-800 rounded-lg bg-yellow-50 dark:bg-yellow-900/30 dark:text-yellow-300" role="alert">
<div class="flex">
<i class="fas fa-shield-alt mr-2 flex-shrink-0"></i>
<div>
<h3 class="font-medium">Security Note</h3>
<p class="mt-1">It's recommended to use an IAM user with restricted permissions for this configuration. The IAM user should only have access to the specific S3 bucket and operations needed.</p>
</div>
</div>
</div>
</div>
}
+97
View File
@@ -0,0 +1,97 @@
package source
templ SFTPSourceForm() {
<div class="space-y-6 mt-4">
<div>
<label for="source_host" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">SFTP Host</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-server text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="source_host" name="source_host" x-model="sourceHost" required
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="sftp.example.com" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Hostname or IP address of the SFTP server</p>
</div>
<div>
<label for="source_port" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Port</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-network-wired text-gray-400 dark:text-gray-500"></i>
</div>
<input type="number" id="source_port" name="source_port" x-model="sourcePort"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="22" min="1" max="65535" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">SFTP port number (default: 22)</p>
</div>
<div>
<label for="source_user" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Username</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-user text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="source_user" name="source_user" x-model="sourceUser" required
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="username" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">SFTP username for authentication</p>
</div>
<div>
<label class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Authentication Method</label>
<div class="flex items-center mb-4">
<input id="source_auth_password" type="radio" value="password" name="source_auth_type" x-model="sourceAuthType"
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600">
<label for="source_auth_password" class="ms-2 text-sm font-medium text-gray-900 dark:text-white">Password</label>
</div>
<div class="flex items-center">
<input id="source_auth_key" type="radio" value="key" name="source_auth_type" x-model="sourceAuthType"
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600">
<label for="source_auth_key" class="ms-2 text-sm font-medium text-gray-900 dark:text-white">SSH Key</label>
</div>
</div>
<div x-show="sourceAuthType === 'password'">
<label for="source_password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Password</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-lock text-gray-400 dark:text-gray-500"></i>
</div>
<input type="password" id="source_password" name="source_password" x-model="sourcePassword"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="Enter password" x-bind:required="sourceAuthType === 'password'" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">SFTP password for authentication</p>
</div>
<div x-show="sourceAuthType === 'key'">
<label for="source_key_file" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">SSH Key File</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-key text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="source_key_file" name="source_key_file" x-model="sourceKeyFile"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="/path/to/id_rsa" x-bind:required="sourceAuthType === 'key'" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Path to the SSH private key file</p>
</div>
<div>
<label for="source_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Remote Path</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-folder-open text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="source_path" name="source_path" x-model="sourcePath" required
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="/remote/path/to/files" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Full path to the directory on the SFTP server</p>
</div>
</div>
}
+112
View File
@@ -0,0 +1,112 @@
package source
templ SMBSourceForm() {
<div class="space-y-6 mt-4">
<div class="p-4 mb-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900/30 dark:text-blue-300" role="alert">
<div class="flex items-center">
<i class="fas fa-info-circle mr-2"></i>
<span>Configure your SMB/CIFS share connection details below. You'll need server address, share name, and credentials.</span>
</div>
</div>
<div class="mb-6">
<label for="source_host" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Server Address</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-server text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="source_host" name="source_host" x-model="sourceHost" required
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="192.168.1.100 or server.example.com" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
IP address or hostname of the SMB/CIFS server
</p>
</div>
<div class="mb-6">
<label for="source_share" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Share Name</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-share-alt text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="source_share" name="source_share" x-model="sourceShare" required
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="Documents" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Name of the shared folder on the server (without leading slashes)
</p>
</div>
<div class="mb-6">
<label for="source_domain" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Domain (Optional)</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-sitemap text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="source_domain" name="source_domain" x-model="sourceDomain"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="WORKGROUP or DOMAIN" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Windows domain name (if applicable). Default is 'WORKGROUP' for standalone servers.
</p>
</div>
<div class="mb-6">
<label for="source_user" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Username</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-user text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="source_user" name="source_user" x-model="sourceUser" required
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="smb_username" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Username for SMB/CIFS authentication
</p>
</div>
<div class="mb-6">
<label for="source_password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Password</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-lock text-gray-400 dark:text-gray-500"></i>
</div>
<input type="password" id="source_password" name="source_password" x-model="sourcePassword" required
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="SMB password" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Password for SMB/CIFS authentication
</p>
</div>
<div class="mb-6">
<label for="source_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Subfolder Path (Optional)</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-folder-open text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="source_path" name="source_path" x-model="sourcePath"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="path/to/subfolder" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Path to a subfolder within the share (Optional). Use forward slashes.
</p>
</div>
<div class="p-4 mb-4 text-sm text-yellow-800 rounded-lg bg-yellow-50 dark:bg-yellow-900/30 dark:text-yellow-300" role="alert">
<div class="flex">
<i class="fas fa-exclamation-triangle mr-2 flex-shrink-0"></i>
<div>
<h3 class="font-medium">Connection Note</h3>
<p class="mt-1">Make sure the SMB server is accessible from this machine and that the share permissions allow the specified user to read the files.</p>
</div>
</div>
</div>
</div>
}
+3
View File
@@ -0,0 +1,3 @@
// Package source contains the UI source forms for the GoMFT application.
// This file serves as a marker for the source package to ensure it's properly recognized by Go.
package source
+112
View File
@@ -0,0 +1,112 @@
package source
templ WasabiSourceForm() {
<div class="space-y-6 mt-4">
<div class="p-4 mb-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900/30 dark:text-blue-300" role="alert">
<div class="flex items-center">
<i class="fas fa-info-circle mr-2"></i>
<span>Configure your Wasabi Cloud Storage details below. You'll need your access key, secret key, region, and bucket information.</span>
</div>
</div>
<div class="mb-6">
<label for="source_region" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Wasabi Region</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-globe text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="source_region" name="source_region" x-model="sourceRegion" required
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="us-east-1" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Wasabi region where your bucket is located (e.g., us-east-1, us-west-1, eu-central-1)
</p>
</div>
<div class="mb-6">
<label for="source_bucket" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Bucket Name</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-archive text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="source_bucket" name="source_bucket" x-model="sourceBucket" required
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="my-wasabi-bucket" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Name of your Wasabi bucket (case-sensitive)
</p>
</div>
<div class="mb-6">
<label for="source_access_key" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Access Key</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-key text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="source_access_key" name="source_access_key" x-model="sourceAccessKey" required
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="ABCDEFGHIJKLMNOPQRST" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Your Wasabi Access Key
</p>
</div>
<div class="mb-6">
<label for="source_secret_key" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Secret Key</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-lock text-gray-400 dark:text-gray-500"></i>
</div>
<input type="password" id="source_secret_key" name="source_secret_key" x-model="sourceSecretKey" required
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="abcdefghijklmnopqrstuvwxyz1234567890ABCD" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Your Wasabi Secret Key
</p>
</div>
<div class="mb-6">
<label for="source_endpoint" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Endpoint (Optional)</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-server text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="source_endpoint" name="source_endpoint" x-model="sourceEndpoint"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="s3.wasabisys.com" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Wasabi endpoint URL (usually s3.wasabisys.com or region-specific endpoints)
</p>
</div>
<div class="mb-6">
<label for="source_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Path in Bucket</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-folder-open text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="source_path" name="source_path" x-model="sourcePath"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="path/to/files/" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Path prefix within the bucket (e.g., "backups/"). Leave empty to access the entire bucket.
</p>
</div>
<div class="p-4 mb-4 text-sm text-yellow-800 rounded-lg bg-yellow-50 dark:bg-yellow-900/30 dark:text-yellow-300" role="alert">
<div class="flex">
<i class="fas fa-shield-alt mr-2 flex-shrink-0"></i>
<div>
<h3 class="font-medium">Security Note</h3>
<p class="mt-1">It's recommended to use an IAM user with restricted permissions for this configuration. The IAM user should only have access to the specific Wasabi bucket and operations needed.</p>
</div>
</div>
</div>
</div>
}
+82
View File
@@ -0,0 +1,82 @@
package source
templ WebDAVSourceForm() {
<div class="space-y-6 mt-4">
<div class="p-4 mb-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900/30 dark:text-blue-300" role="alert">
<div class="flex items-center">
<i class="fas fa-info-circle mr-2"></i>
<span>Configure your WebDAV connection details below. You'll need the server URL, username, and password.</span>
</div>
</div>
<div class="mb-6">
<label for="source_host" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">WebDAV URL</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-globe text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="source_host" name="source_host" x-model="sourceHost" required
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="https://webdav.example.com" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Full URL to your WebDAV server including protocol (https://)
</p>
</div>
<div class="mb-6">
<label for="source_user" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Username</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-user text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="source_user" name="source_user" x-model="sourceUser" required
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="username" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Your WebDAV username
</p>
</div>
<div class="mb-6">
<label for="source_password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Password</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-lock text-gray-400 dark:text-gray-500"></i>
</div>
<input type="password" id="source_password" name="source_password" x-model="sourcePassword" required
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="password" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Your WebDAV account password
</p>
</div>
<div class="mb-6">
<label for="source_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Remote Path</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-folder-open text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="source_path" name="source_path" x-model="sourcePath"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="/path/to/files" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Path to your files on the WebDAV server
</p>
</div>
<div class="p-4 mb-4 text-sm text-yellow-800 rounded-lg bg-yellow-50 dark:bg-yellow-900/30 dark:text-yellow-300" role="alert">
<div class="flex">
<i class="fas fa-lightbulb mr-2 flex-shrink-0"></i>
<div>
<h3 class="font-medium">Integration Note</h3>
<p class="mt-1">WebDAV is supported by many services including ownCloud, Nextcloud, Box, and many others. Make sure your WebDAV URL includes the full path to the WebDAV interface.</p>
</div>
</div>
</div>
</div>
}
+13
View File
@@ -0,0 +1,13 @@
package components
type RcloneImportPreview struct {
Remotes []RcloneRemotePreview
Error string
}
type RcloneRemotePreview struct {
Name string
Type string
Fields map[string]string
Import bool // Should import
}
+152 -82
View File
@@ -6,98 +6,168 @@ import (
templ Register(ctx context.Context, errorMessage string) {
@LayoutWithContext("Register", ctx) {
<div class="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div class="max-w-md w-full space-y-8">
<div>
<h2 class="mt-6 text-center text-3xl font-extrabold text-gray-900">
Create a new user account
</h2>
<p class="mt-2 text-center text-sm text-gray-600">
Complete the form below to create a new user
</p>
</div>
<div class="p-4 md:p-6 2xl:p-10">
<!-- Page Header -->
<div class="mb-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
<i class="fas fa-user-plus w-6 h-6 mr-2 text-blue-500"></i>
Create Account
</h1>
</div>
<p class="text-sm text-gray-500 dark:text-gray-400 mb-6">
Complete the form below to create a new user account
</p>
<!-- Registration Card -->
<div class="max-w-md mx-auto">
if errorMessage != "" {
<div class="mt-4">
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative" role="alert">
<span class="block sm:inline">{ errorMessage }</span>
<div class="p-4 mb-6 text-sm text-red-800 rounded-lg bg-red-50 dark:bg-red-900/50 dark:text-red-400 border border-red-200 dark:border-red-800" role="alert">
<div class="flex items-center">
<i class="fas fa-exclamation-circle flex-shrink-0 mr-2"></i>
<span>{ errorMessage }</span>
</div>
</div>
}
<form class="mt-8 space-y-6" hx-post="/register" hx-target="body"
x-data="{
email: '',
password: '',
confirmPassword: '',
loading: false,
validate() {
return this.email &&
this.password &&
this.confirmPassword &&
this.password === this.confirmPassword;
}
}"
@htmx:before-request="loading = true"
@htmx:after-request="loading = false">
<input type="hidden" name="remember" value="true" />
<div class="rounded-md shadow-sm -space-y-px">
<div>
<label for="email" class="sr-only">Email address</label>
<input
id="email"
name="email"
type="email"
required
x-model="email"
class="form-input rounded-t-md"
placeholder="Email address"/>
</div>
<div>
<label for="password" class="sr-only">Password</label>
<input
id="password"
name="password"
type="password"
required
x-model="password"
class="form-input"
placeholder="Password"/>
</div>
<div>
<label for="confirm-password" class="sr-only">Confirm Password</label>
<input
id="confirm-password"
name="confirm_password"
type="password"
required
x-model="confirmPassword"
class="form-input rounded-b-md"
placeholder="Confirm Password"/>
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800">
<div class="p-4 border-b border-gray-200 dark:border-gray-700">
<div class="flex items-center space-x-3">
<div class="h-10 w-10 bg-blue-100 dark:bg-blue-900 rounded-full flex items-center justify-center">
<i class="fas fa-user-plus text-blue-600 dark:text-blue-300"></i>
</div>
<div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
Registration
</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">Create your account credentials</p>
</div>
</div>
</div>
<div class="p-6">
<form
hx-post="/register"
hx-target="body"
class="space-y-4"
x-data="{
email: '',
password: '',
confirmPassword: '',
loading: false,
validate() {
return this.email &&
this.password &&
this.confirmPassword &&
this.password === this.confirmPassword;
}
}"
@htmx:before-request="loading = true"
@htmx:after-request="loading = false">
<div>
<label for="email" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
Email Address
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
<i class="fas fa-envelope text-gray-400 dark:text-gray-500"></i>
</div>
<input
type="email"
id="email"
name="email"
x-model="email"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full pl-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="your.email@example.com"
required
/>
</div>
</div>
<div>
<label for="password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
Password
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
<i class="fas fa-lock text-gray-400 dark:text-gray-500"></i>
</div>
<input
type="password"
id="password"
name="password"
x-model="password"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full pl-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="••••••••"
required
/>
</div>
</div>
<div>
<label for="confirm-password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
Confirm Password
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
<i class="fas fa-lock text-gray-400 dark:text-gray-500"></i>
</div>
<input
type="password"
id="confirm-password"
name="confirm_password"
x-model="confirmPassword"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full pl-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="••••••••"
required
/>
</div>
<template x-if="confirmPassword && password !== confirmPassword">
<p class="mt-2 text-sm text-red-600 dark:text-red-500">
<i class="fas fa-exclamation-circle mr-1"></i>
Passwords do not match
</p>
</template>
</div>
<button
type="submit"
class="w-full text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800"
x-bind:disabled="!validate() || loading">
<span x-show="!loading" class="flex items-center justify-center">
<i class="fas fa-user-plus mr-2"></i>
Create Account
</span>
<span x-show="loading" class="flex items-center justify-center">
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Processing...
</span>
</button>
</form>
</div>
<div class="p-4 bg-gray-50 dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 rounded-b-lg">
<div class="text-center">
<p class="text-sm text-gray-600 dark:text-gray-400">
Already have an account?
<a href="/login" class="font-medium text-blue-600 dark:text-blue-500 hover:underline">
Sign in
</a>
</p>
</div>
</div>
</div>
</div>
<!-- Help Notice -->
<div class="mt-6 max-w-md mx-auto">
<div class="p-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900/50 dark:text-blue-400 border border-blue-200 dark:border-blue-800 flex items-start">
<i class="fas fa-info-circle flex-shrink-0 mr-3 mt-0.5"></i>
<div>
<button
type="submit"
class="btn-primary w-full flex justify-center"
x-bind:disabled="!validate() || loading">
<span x-show="!loading">Create User</span>
<span x-show="loading" class="flex items-center">
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Processing...
</span>
</button>
<h4 class="text-sm font-medium mb-1">Account Information</h4>
<p>Creating an account will allow you to manage file transfers and configurations. Your email address will only be used for authentication and important notifications related to your transfers.</p>
</div>
</form>
<div class="text-center">
<p class="text-sm text-gray-600">
Already have an account?
<a href="/login" class="font-medium text-blue-600 hover:text-blue-500">Sign in</a>
</p>
</div>
</div>
</div>
+773
View File
@@ -0,0 +1,773 @@
package components
import (
"context"
"fmt"
)
// NotificationService defines a notification service configuration
type NotificationService struct {
ID uint
Name string
Type string // "email", "webhook", etc.
IsEnabled bool
Config map[string]string
Description string
EventTriggers []string
PayloadTemplate string
SecretKey string
RetryPolicy string
SuccessCount int
FailureCount int
}
// SettingsNotificationsData contains data for the notifications page in settings
type SettingsNotificationsData struct {
NotificationServices []NotificationService
ErrorMessage string
SuccessMessage string
}
// SettingsData contains data for the settings page
type SettingsData struct {
NotificationServices []NotificationService
ErrorMessage string
SuccessMessage string
}
templ Settings(ctx context.Context, data SettingsData) {
@LayoutWithContext("Application Settings", ctx) {
<div id="settings-container" style="min-height: 100vh;" class="bg-gray-50 dark:bg-gray-900">
<div class="pb-8 w-full">
<div class="mb-6">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
<i class="fas fa-cog w-6 h-6 mr-2 text-blue-500"></i>
Application Settings
</h1>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
Configure global settings for your GoMFT instance.
</p>
</div>
<!-- Success Message -->
if data.SuccessMessage != "" {
<div class="p-4 mb-6 text-sm text-green-800 rounded-lg bg-green-50 dark:bg-green-900 dark:text-green-400" role="alert">
<div class="flex items-center">
<i class="fas fa-check-circle mr-2"></i>
<span>{ data.SuccessMessage }</span>
</div>
</div>
}
<!-- Error Message -->
if data.ErrorMessage != "" {
<div class="p-4 mb-6 text-sm text-red-800 rounded-lg bg-red-50 dark:bg-red-900 dark:text-red-400" role="alert">
<div class="flex items-center">
<i class="fas fa-exclamation-circle mr-2"></i>
<span>{ data.ErrorMessage }</span>
</div>
</div>
}
<!-- Tabs -->
<div class="mb-4 border-b border-gray-200 dark:border-gray-700">
<ul class="flex flex-wrap -mb-px text-sm font-medium text-center" id="settingsTabs" role="tablist">
<li class="mr-2" role="presentation">
<button class="inline-block p-4 border-b-2 rounded-t-lg border-blue-600 text-blue-600 dark:text-blue-500 dark:border-blue-500" id="notifications-tab" data-tabs-target="#notifications" type="button" role="tab" aria-controls="notifications" aria-selected="true">
<i class="fas fa-bell mr-2"></i>Notifications
</button>
</li>
<li class="mr-2" role="presentation">
<button class="inline-block p-4 border-b-2 border-transparent rounded-t-lg hover:border-gray-300 hover:text-gray-600 dark:hover:text-gray-300" id="general-tab" data-tabs-target="#general" type="button" role="tab" aria-controls="general" aria-selected="false" disabled>
<i class="fas fa-sliders-h mr-2"></i>General (Coming Soon)
</button>
</li>
<li role="presentation">
<button class="inline-block p-4 border-b-2 border-transparent rounded-t-lg hover:border-gray-300 hover:text-gray-600 dark:hover:text-gray-300" id="security-tab" data-tabs-target="#security" type="button" role="tab" aria-controls="security" aria-selected="false" disabled>
<i class="fas fa-shield-alt mr-2"></i>Security (Coming Soon)
</button>
</li>
</ul>
</div>
<!-- Tab Content -->
<div id="settingsTabContent">
<!-- Notifications Tab -->
<div class="block p-4 rounded-lg bg-white dark:bg-gray-800" id="notifications" role="tabpanel" aria-labelledby="notifications-tab">
<div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Notification Services</h2>
</div>
<!-- Add Notification Service Form -->
<div class="mb-6 p-6 border border-gray-200 rounded-lg shadow-sm dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Add Notification Service
</h3>
<form id="add-notification-form" hx-post="/admin/settings/notifications" hx-target="body">
<div class="mb-6">
<label for="notification_type" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Notification Type</label>
<select id="notification_type" name="type" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500">
<option value="">Select a type</option>
<option value="webhook">Webhook</option>
<option value="email" disabled>Email (Coming Soon)</option>
</select>
</div>
<div class="mb-6 hidden common-fields">
<label for="notification_name" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Name</label>
<input type="text" id="notification_name" name="name" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="My Notification Service" required/>
</div>
<div class="mb-6 hidden common-fields">
<label for="notification_description" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Description</label>
<textarea id="notification_description" name="description" rows="3" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="Description for this notification service"></textarea>
</div>
<!-- Dynamic fields based on notification type -->
<div id="email_fields" class="hidden notification-fields">
<div class="mb-6">
<label for="smtp_host" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">SMTP Host</label>
<input type="text" id="smtp_host" name="smtp_host" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="smtp.example.com"/>
</div>
<div class="mb-6">
<label for="smtp_port" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">SMTP Port</label>
<input type="number" id="smtp_port" name="smtp_port" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="587"/>
</div>
<div class="mb-6">
<label for="smtp_username" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">SMTP Username</label>
<input type="text" id="smtp_username" name="smtp_username" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="user@example.com"/>
</div>
<div class="mb-6">
<label for="smtp_password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">SMTP Password</label>
<input type="password" id="smtp_password" name="smtp_password" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"/>
</div>
<div class="mb-6">
<label for="from_email" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">From Email</label>
<input type="email" id="from_email" name="from_email" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="notifications@example.com"/>
</div>
</div>
<div id="webhook_fields" class="hidden notification-fields">
<div class="mb-6">
<label for="webhook_url" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Webhook URL</label>
<input type="url" id="webhook_url" name="webhook_url" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="https://api.example.com/webhook"/>
</div>
<div class="mb-6">
<label for="method" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">HTTP Method</label>
<select id="method" name="method" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500">
<option value="POST">POST</option>
<option value="PUT">PUT</option>
</select>
</div>
<div class="mb-6">
<label for="headers" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Headers (JSON)</label>
<textarea id="headers" name="headers" rows="3" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder='{"Content-Type": "application/json", "Authorization": "Bearer token"}'></textarea>
</div>
<div class="mb-6">
<label for="payload_template" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Payload Template (JSON)</label>
<textarea
id="payload_template"
name="payload_template"
rows="5"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder='{
"event": "{{job.event}}",
"job": {
"id": "{{job.id}}",
"name": "{{job.name}}",
"status": "{{job.status}}",
"message": "{{job.message}}",
"started_at": "{{job.started_at}}",
"completed_at": "{{job.completed_at}}",
"duration_seconds": {{job.duration_seconds}},
"config_id": "{{job.config_id}}",
"config_name": "{{job.config_name}}",
"transfer_bytes": {{job.transfer_bytes}},
"file_count": {{job.file_count}}
},
"instance": {
"id": "{{instance.id}}",
"name": "{{instance.name}}",
"version": "{{instance.version}}",
"environment": "{{instance.environment}}"
},
"timestamp": "{{timestamp}}",
"notification_id": "{{notification.id}}"
}'
></textarea>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Use { `variable` } placeholders for dynamic values. Available variables: job.*, instance.*, timestamp, notification.*</p>
</div>
<div class="mb-6">
<label class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Event Triggers</label>
<div class="space-y-2">
<div class="flex items-center">
<input id="trigger_job_start" name="trigger_job_start" type="checkbox" class="w-4 h-4 border border-gray-300 rounded bg-gray-50 focus:ring-3 focus:ring-blue-300 dark:bg-gray-700 dark:border-gray-600 dark:focus:ring-blue-600 dark:ring-offset-gray-800" checked/>
<label for="trigger_job_start" class="ml-2 text-sm font-medium text-gray-900 dark:text-white">Job Start</label>
</div>
<div class="flex items-center">
<input id="trigger_job_complete" name="trigger_job_complete" type="checkbox" class="w-4 h-4 border border-gray-300 rounded bg-gray-50 focus:ring-3 focus:ring-blue-300 dark:bg-gray-700 dark:border-gray-600 dark:focus:ring-blue-600 dark:ring-offset-gray-800" checked/>
<label for="trigger_job_complete" class="ml-2 text-sm font-medium text-gray-900 dark:text-white">Job Complete</label>
</div>
<div class="flex items-center">
<input id="trigger_job_error" name="trigger_job_error" type="checkbox" class="w-4 h-4 border border-gray-300 rounded bg-gray-50 focus:ring-3 focus:ring-blue-300 dark:bg-gray-700 dark:border-gray-600 dark:focus:ring-blue-600 dark:ring-offset-gray-800" checked/>
<label for="trigger_job_error" class="ml-2 text-sm font-medium text-gray-900 dark:text-white">Job Error</label>
</div>
</div>
</div>
<div class="mb-6">
<label for="secret_key" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Secret Key (for signature verification)</label>
<input type="text" id="secret_key" name="secret_key" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="Optional signature verification key"/>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">If provided, all webhooks will include an X-GoMFT-Signature header</p>
</div>
<div class="mb-6">
<label for="retry_policy" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Retry Policy</label>
<select id="retry_policy" name="retry_policy" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500">
<option value="none">No retries</option>
<option value="simple" selected>Simple (3 retries)</option>
<option value="exponential">Exponential backoff</option>
</select>
</div>
<!-- Test notification button -->
<div class="mb-6 p-4 bg-gray-50 border border-gray-200 rounded-lg dark:bg-gray-700 dark:border-gray-600">
<div class="flex items-center justify-between mb-2">
<h4 class="text-base font-medium text-gray-900 dark:text-white">Test Configuration</h4>
<button
type="button"
id="test-webhook-btn"
hx-post="/admin/settings/notifications/test"
hx-trigger="click"
hx-target="#test-notification-result"
hx-swap="outerHTML"
class="px-3 py-2 text-xs font-medium text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 rounded-lg dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800"
>
<i class="fas fa-paper-plane mr-1"></i>
Send Test Notification
</button>
</div>
<p class="text-sm text-gray-500 dark:text-gray-400">
Send a test notification to verify your configuration works correctly before saving.
</p>
<div id="test-notification-result" class="mt-3 hidden">
<!-- Result will be shown here -->
</div>
</div>
</div>
<div class="flex items-start mb-6 hidden common-fields">
<div class="flex items-center h-5">
<input id="is_enabled" name="is_enabled" type="checkbox" class="w-4 h-4 border border-gray-300 rounded bg-gray-50 focus:ring-3 focus:ring-blue-300 dark:bg-gray-700 dark:border-gray-600 dark:focus:ring-blue-600 dark:ring-offset-gray-800" checked/>
</div>
<div class="ml-3 text-sm">
<label for="is_enabled" class="font-medium text-gray-900 dark:text-white">Enable this notification service</label>
</div>
</div>
<div class="hidden common-fields">
<button type="submit" class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800">Add Service</button>
</div>
</form>
</div>
if len(data.NotificationServices) == 0 {
<div class="text-center py-8">
<div class="inline-flex items-center justify-center w-16 h-16 rounded-full bg-blue-100 dark:bg-blue-900 mb-4">
<i class="fas fa-bell text-2xl text-blue-600 dark:text-blue-400"></i>
</div>
<h3 class="mb-2 text-lg font-semibold text-gray-900 dark:text-white">No notification services configured</h3>
<p class="text-gray-500 dark:text-gray-400 mb-4">Use the form above to add your first notification service.</p>
</div>
} else {
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 overflow-hidden">
<ul class="divide-y divide-gray-200 dark:divide-gray-700">
for _, service := range data.NotificationServices {
<li>
<div class="block hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
<div class="px-4 py-4 sm:px-6">
<div class="flex items-center justify-between">
<div class="flex items-center">
if service.Type == "email" {
<div class="w-10 h-10 rounded-full bg-blue-100 flex items-center justify-center text-blue-600 dark:bg-blue-900 dark:text-blue-400 mr-3">
<i class="fas fa-envelope"></i>
</div>
} else if service.Type == "webhook" {
<div class="w-10 h-10 rounded-full bg-green-100 flex items-center justify-center text-green-600 dark:bg-green-900 dark:text-green-400 mr-3">
<i class="fas fa-code"></i>
</div>
} else {
<div class="w-10 h-10 rounded-full bg-gray-100 flex items-center justify-center text-gray-600 dark:bg-gray-700 dark:text-gray-400 mr-3">
<i class="fas fa-bell"></i>
</div>
}
<div>
<p class="text-sm font-medium text-blue-600 dark:text-blue-400 truncate">
{ service.Name }
</p>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
{ service.Description }
</p>
</div>
</div>
<div class="ml-2 flex-shrink-0 flex space-x-2">
<button
type="button"
class="text-gray-500 bg-white focus:outline-none hover:bg-gray-100 focus:ring-4 focus:ring-gray-200 rounded-lg text-sm p-2 mr-1 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white dark:focus:ring-gray-700"
data-modal-target="edit-notification-modal"
data-modal-toggle="edit-notification-modal"
data-service-id={ fmt.Sprint(service.ID) }
>
<i class="fas fa-edit"></i>
</button>
<button
type="button"
class="text-red-500 bg-white focus:outline-none hover:bg-gray-100 focus:ring-4 focus:ring-gray-200 rounded-lg text-sm p-2 dark:bg-gray-800 dark:text-red-400 dark:hover:bg-gray-700 dark:hover:text-white dark:focus:ring-gray-700"
hx-delete={ "/admin/settings/notifications/" + fmt.Sprint(service.ID) }
hx-confirm="Are you sure you want to delete this notification service? This cannot be undone."
hx-target="body"
>
<i class="fas fa-trash-alt"></i>
</button>
</div>
</div>
<div class="mt-3 sm:flex sm:justify-between">
<div class="sm:flex flex-col md:flex-row gap-2 md:gap-6">
<div class="flex items-center">
<span
class={ "px-2 py-1 text-xs font-medium rounded-full",
templ.KV("bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300", service.IsEnabled),
templ.KV("bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300", !service.IsEnabled) }
>
if service.IsEnabled {
Active
} else {
Disabled
}
</span>
<span class="ml-2 px-2 py-1 text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300 rounded-full">
{ service.Type }
</span>
if len(service.EventTriggers) > 0 && service.Type == "webhook" {
<span class="ml-2 px-2 py-1 text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300 rounded-full">
{ fmt.Sprintf("%d triggers", len(service.EventTriggers)) }
</span>
}
if service.SuccessCount > 0 || service.FailureCount > 0 {
<span class="ml-2 px-2 py-1 text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300 rounded-full">
{ fmt.Sprintf("%d/%d", service.SuccessCount, service.SuccessCount + service.FailureCount) }
</span>
}
</div>
</div>
if service.Type == "webhook" {
<div class="mt-2 md:mt-0 flex items-center space-x-4">
<div class="text-xs">
<span class="text-gray-500 dark:text-gray-400">Events:</span>
<span class="ml-1 text-gray-900 dark:text-gray-300">
if len(service.EventTriggers) == 0 {
None
} else {
for i, trigger := range service.EventTriggers {
if i > 0 {
<span>, </span>
}
{ trigger }
}
}
</span>
</div>
<div class="text-xs">
<span class="text-gray-500 dark:text-gray-400">Retry:</span>
<span class="ml-1 text-gray-900 dark:text-gray-300">
if service.RetryPolicy == "" {
Default
} else {
{ service.RetryPolicy }
}
</span>
</div>
</div>
} else {
<div class="mt-2 md:mt-0 flex items-center text-sm text-gray-500 dark:text-gray-400">
<i class="far fa-clock w-4 h-4 mr-1.5 text-gray-400 dark:text-gray-500"></i>
<p>
Last sent:
if service.SuccessCount > 0 {
"Recently"
} else {
"Never"
}
</p>
</div>
}
</div>
</div>
</div>
</li>
}
</ul>
</div>
}
</div>
<!-- General Tab -->
<div class="hidden p-4 rounded-lg bg-white dark:bg-gray-800" id="general" role="tabpanel" aria-labelledby="general-tab">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">General Settings</h2>
<form class="space-y-6" action="/settings/general" method="POST">
<div class="grid gap-6 mb-6 md:grid-cols-2">
<div>
<label for="app_name" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Application Name</label>
<input type="text" id="app_name" name="app_name" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" value="GoMFT"/>
</div>
<div>
<label for="base_url" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Base URL</label>
<input type="url" id="base_url" name="base_url" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" value="http://localhost:8080"/>
</div>
</div>
<button type="submit" class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800">Save Settings</button>
</form>
</div>
<!-- Security Tab -->
<div class="hidden p-4 rounded-lg bg-white dark:bg-gray-800" id="security" role="tabpanel" aria-labelledby="security-tab">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Security Settings</h2>
<!-- Authentication Sources -->
<div class="mb-8">
<div class="flex justify-between items-center mb-4">
<h3 class="text-lg font-medium text-gray-900 dark:text-white">Authentication Sources</h3>
<button type="button" data-modal-target="add-auth-source-modal" data-modal-toggle="add-auth-source-modal" class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-4 py-2 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800">
<i class="fas fa-plus mr-2"></i>Add Source
</button>
</div>
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 overflow-hidden">
<div class="p-4 border-b border-gray-200 dark:border-gray-700">
<p class="text-sm text-gray-500 dark:text-gray-400">
Configure external authentication providers to allow users to log in using existing accounts.
</p>
</div>
<!-- Authentication Sources List -->
<ul class="divide-y divide-gray-200 dark:divide-gray-700">
<!-- Local Authentication (always present) -->
<li>
<div class="px-4 py-4 sm:px-6">
<div class="flex items-center justify-between">
<div class="flex items-center">
<div class="w-10 h-10 rounded-full bg-gray-100 flex items-center justify-center text-gray-600 dark:bg-gray-700 dark:text-gray-400 mr-3">
<i class="fas fa-key"></i>
</div>
<div>
<p class="text-sm font-medium text-blue-600 dark:text-blue-400">
Local Authentication
</p>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
Default username and password authentication
</p>
</div>
</div>
<div class="ml-2 flex-shrink-0 flex">
<span class="px-2 py-1 text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300 rounded-full">
Active
</span>
</div>
</div>
</div>
</li>
<!-- Authentik SSO (if configured) -->
<!-- This would be populated from backend data in a real implementation -->
<li>
<div class="px-4 py-4 sm:px-6">
<div class="flex items-center justify-between">
<div class="flex items-center">
<div class="w-10 h-10 rounded-full bg-purple-100 flex items-center justify-center text-purple-600 dark:bg-purple-900 dark:text-purple-400 mr-3">
<i class="fas fa-passport"></i>
</div>
<div>
<p class="text-sm font-medium text-blue-600 dark:text-blue-400">
Authentik SSO
</p>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
Single Sign-On with Authentik Identity Provider
</p>
</div>
</div>
<div class="ml-2 flex items-center space-x-2">
<span class="px-2 py-1 text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300 rounded-full">
Active
</span>
<button
type="button"
class="text-gray-500 bg-white focus:outline-none hover:bg-gray-100 focus:ring-4 focus:ring-gray-200 rounded-lg text-sm p-2 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white dark:focus:ring-gray-700"
data-modal-target="edit-auth-source-modal"
data-modal-toggle="edit-auth-source-modal"
>
<i class="fas fa-edit"></i>
</button>
<button
type="button"
class="text-red-500 bg-white focus:outline-none hover:bg-gray-100 focus:ring-4 focus:ring-gray-200 rounded-lg text-sm p-2 dark:bg-gray-800 dark:text-red-400 dark:hover:bg-gray-700 dark:hover:text-white dark:focus:ring-gray-700"
hx-delete="/admin/settings/auth-sources/1"
hx-confirm="Are you sure you want to delete this authentication source?"
hx-target="body"
>
<i class="fas fa-trash-alt"></i>
</button>
</div>
</div>
<div class="mt-2 sm:flex sm:justify-between">
<div class="sm:flex gap-4">
<div class="flex items-center text-xs">
<span class="text-gray-500 dark:text-gray-400">Client ID:</span>
<span class="ml-1 text-gray-900 dark:text-gray-300">gomft_app</span>
</div>
<div class="flex items-center text-xs">
<span class="text-gray-500 dark:text-gray-400">Provider URL:</span>
<span class="ml-1 text-gray-900 dark:text-gray-300">https://auth.example.com</span>
</div>
</div>
</div>
</div>
</li>
</ul>
</div>
</div>
<form class="space-y-6" hx-post="/admin/settings/security" hx-target="body">
<div class="grid gap-6 mb-6 md:grid-cols-2">
<div>
<label for="session_timeout" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Session Timeout (minutes)</label>
<input type="number" id="session_timeout" name="session_timeout" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" value="60" min="5" max="1440"/>
</div>
<div>
<label for="password_policy" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Password Policy</label>
<select id="password_policy" name="password_policy" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500">
<option value="standard">Standard (8+ chars, 1 uppercase, 1 number)</option>
<option value="strong">Strong (12+ chars, uppercase, number, symbol)</option>
<option value="very_strong">Very Strong (16+ chars, uppercase, number, symbol)</option>
</select>
</div>
</div>
<div class="flex items-start mb-6">
<div class="flex items-center h-5">
<input id="enable_2fa" name="enable_2fa" type="checkbox" class="w-4 h-4 border border-gray-300 rounded bg-gray-50 focus:ring-3 focus:ring-blue-300 dark:bg-gray-700 dark:border-gray-600 dark:focus:ring-blue-600 dark:ring-offset-gray-800" checked/>
</div>
<div class="ml-3 text-sm">
<label for="enable_2fa" class="font-medium text-gray-900 dark:text-white">Enable Two-Factor Authentication</label>
<p class="text-xs font-normal text-gray-500 dark:text-gray-400">Require 2FA for all admin users</p>
</div>
</div>
<button type="submit" class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800">Save Settings</button>
</form>
</div>
</div>
</div>
</div>
}
@toggleNotificationFields()
@addAuthSourceModals()
@setupTabSwitching()
}
// Add new templ component for auth source modals
templ addAuthSourceModals() {
<!-- Add Authentication Source Modal -->
<div id="add-auth-source-modal" tabindex="-1" aria-hidden="true" class="fixed top-0 left-0 right-0 z-50 hidden w-full p-4 overflow-x-hidden overflow-y-auto md:inset-0 h-[calc(100%-1rem)] max-h-full">
<div class="relative w-full max-w-2xl max-h-full">
<div class="relative bg-white rounded-lg shadow dark:bg-gray-800">
<div class="flex items-start justify-between p-4 border-b rounded-t dark:border-gray-700">
<h3 class="text-xl font-semibold text-gray-900 dark:text-white">
Add Authentication Source
</h3>
<button type="button" class="text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm w-8 h-8 ml-auto inline-flex justify-center items-center dark:hover:bg-gray-700 dark:hover:text-white" data-modal-hide="add-auth-source-modal">
<i class="fas fa-times"></i>
<span class="sr-only">Close modal</span>
</button>
</div>
<form hx-post="/admin/settings/auth-sources" hx-target="body">
<div class="p-6 space-y-6">
<div class="mb-6">
<label for="auth_type" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Authentication Type</label>
<select id="auth_type" name="type" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500">
<option value="">Select a type</option>
<option value="authentik">Authentik SSO</option>
<option value="oauth2">Generic OAuth 2.0</option>
<option value="oidc">OpenID Connect</option>
<option value="saml" disabled>SAML 2.0 (Coming Soon)</option>
</select>
</div>
<div id="authentik_fields" class="auth-type-fields">
<div class="mb-6">
<label for="name" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Name</label>
<input type="text" id="name" name="name" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="Authentik SSO" required/>
</div>
<div class="mb-6">
<label for="provider_url" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Authentik Provider URL</label>
<input type="url" id="provider_url" name="provider_url" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="https://auth.example.com" required/>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">The base URL of your Authentik instance</p>
</div>
<div class="grid gap-6 mb-6 md:grid-cols-2">
<div>
<label for="client_id" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Client ID</label>
<input type="text" id="client_id" name="client_id" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="gomft_app" required/>
</div>
<div>
<label for="client_secret" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Client Secret</label>
<input type="password" id="client_secret" name="client_secret" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" required/>
</div>
</div>
<div class="mb-6">
<label for="redirect_url" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Redirect URL</label>
<input type="text" id="redirect_url" name="redirect_url" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" value="http://localhost:8080/auth/callback" disabled/>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Configure this URL in your Authentik provider settings</p>
</div>
<div class="mb-6">
<label for="scopes" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Scopes</label>
<input type="text" id="scopes" name="scopes" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" value="openid email profile" required/>
</div>
<div class="mb-6">
<label class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">User Attribute Mapping</label>
<div class="grid gap-6 mb-6 md:grid-cols-2">
<div>
<label for="username_attribute" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Username Attribute</label>
<input type="text" id="username_attribute" name="username_attribute" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" value="preferred_username" required/>
</div>
<div>
<label for="email_attribute" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Email Attribute</label>
<input type="text" id="email_attribute" name="email_attribute" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" value="email" required/>
</div>
</div>
<div class="grid gap-6 mb-6 md:grid-cols-2">
<div>
<label for="name_attribute" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Name Attribute</label>
<input type="text" id="name_attribute" name="name_attribute" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" value="name" required/>
</div>
<div>
<label for="groups_attribute" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Groups Attribute</label>
<input type="text" id="groups_attribute" name="groups_attribute" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" value="groups" required/>
</div>
</div>
</div>
<div class="flex items-start mb-6">
<div class="flex items-center h-5">
<input id="is_enabled" name="is_enabled" type="checkbox" class="w-4 h-4 border border-gray-300 rounded bg-gray-50 focus:ring-3 focus:ring-blue-300 dark:bg-gray-700 dark:border-gray-600 dark:focus:ring-blue-600 dark:ring-offset-gray-800" checked/>
</div>
<div class="ml-3 text-sm">
<label for="is_enabled" class="font-medium text-gray-900 dark:text-white">Enable this authentication source</label>
</div>
</div>
<div class="p-4 mb-6 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900/50 dark:text-blue-400 border border-blue-200 dark:border-blue-800 flex items-start">
<i class="fas fa-info-circle flex-shrink-0 mr-3 mt-0.5"></i>
<div>
<p>To configure Authentik for this application:</p>
<ol class="list-decimal ml-5 mt-2">
<li>Create a new OAuth2/OIDC Provider in Authentik</li>
<li>Set the redirect URI to the value shown above</li>
<li>Obtain the Client ID and Secret from Authentik</li>
<li>Configure group mappings if needed for permission control</li>
</ol>
</div>
</div>
</div>
</div>
<div class="flex items-center p-6 space-x-2 border-t border-gray-200 rounded-b dark:border-gray-700">
<button type="submit" class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800">Save</button>
<button type="button" class="text-gray-500 bg-white hover:bg-gray-100 focus:ring-4 focus:outline-none focus:ring-blue-300 rounded-lg border border-gray-200 text-sm font-medium px-5 py-2.5 hover:text-gray-900 focus:z-10 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-500 dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-gray-700" data-modal-hide="add-auth-source-modal">Cancel</button>
</div>
</form>
</div>
</div>
</div>
<!-- Edit Authentication Source Modal would be similar to the Add modal -->
}
// Script to toggle notification fields based on selection
script toggleNotificationFields() {
document.addEventListener('DOMContentLoaded', function() {
const typeSelector = document.getElementById('notification_type');
const allFields = document.querySelectorAll('.notification-fields');
const commonFields = document.querySelectorAll('.common-fields');
typeSelector.addEventListener('change', function() {
// Hide all fields first
allFields.forEach(field => field.classList.add('hidden'));
// Show/hide common fields based on selection
const selectedType = this.value;
if (selectedType) {
// Show common fields (name, description)
commonFields.forEach(field => field.classList.remove('hidden'));
// Show the selected type's specific fields
const fieldsToShow = document.getElementById(`${selectedType}_fields`);
if (fieldsToShow) {
fieldsToShow.classList.remove('hidden');
}
} else {
// Hide common fields if no type selected
commonFields.forEach(field => field.classList.add('hidden'));
}
});
});
}
// Add script to toggle authentication source fields based on type selection
script toggleAuthSourceFields() {
document.addEventListener('DOMContentLoaded', function() {
const typeSelector = document.getElementById('auth_type');
const authFields = document.querySelectorAll('.auth-type-fields');
if (typeSelector) {
typeSelector.addEventListener('change', function() {
// Hide all auth type fields first
authFields.forEach(field => field.classList.add('hidden'));
// Show the selected type's fields
const selectedType = this.value;
if (selectedType) {
const fieldsToShow = document.getElementById(`${selectedType}_fields`);
if (fieldsToShow) {
fieldsToShow.classList.remove('hidden');
}
}
});
}
});
}
// Add script to handle tab switching
script setupTabSwitching() {
document.addEventListener('DOMContentLoaded', function() {
const tabButtons = document.querySelectorAll('[role="tab"]');
const tabPanels = document.querySelectorAll('[role="tabpanel"]');
function switchTab(targetId) {
// Hide all tab panels
tabPanels.forEach(panel => {
panel.classList.add('hidden');
});
// Show the target panel
const targetPanel = document.querySelector(targetId);
if (targetPanel) {
targetPanel.classList.remove('hidden');
targetPanel.classList.add('block');
}
// Update tab button styles
tabButtons.forEach(button => {
// Get the target from the button
const buttonTarget = button.getAttribute('data-tabs-target');
if (buttonTarget === targetId) {
// Active tab
button.classList.add('border-blue-600', 'text-blue-600', 'dark:text-blue-500', 'dark:border-blue-500');
button.classList.remove('border-transparent', 'hover:border-gray-300', 'hover:text-gray-600', 'dark:hover:text-gray-300');
button.setAttribute('aria-selected', 'true');
} else {
// Inactive tab
button.classList.remove('border-blue-600', 'text-blue-600', 'dark:text-blue-500', 'dark:border-blue-500');
button.classList.add('border-transparent', 'hover:border-gray-300', 'hover:text-gray-600', 'dark:hover:text-gray-300');
button.setAttribute('aria-selected', 'false');
}
});
}
// Add click event listeners to tab buttons
tabButtons.forEach(button => {
button.addEventListener('click', function(e) {
e.preventDefault();
const targetId = this.getAttribute('data-tabs-target');
switchTab(targetId);
});
});
// Initialize the tabs (select the one with aria-selected="true")
const initialTab = document.querySelector('[role="tab"][aria-selected="true"]');
if (initialTab) {
const targetId = initialTab.getAttribute('data-tabs-target');
switchTab(targetId);
}
});
}
+5
View File
@@ -0,0 +1,5 @@
package toast
templ Container() {
<div id="toast-container" class="fixed top-5 right-5 z-50 flex flex-col gap-2"></div>
}
+114
View File
@@ -0,0 +1,114 @@
package toast
templ ShowToastJS() {
<script>
// Notification system
// Global tracking of shown messages to prevent duplicates
window.shownToastMessages = window.shownToastMessages || [];
function showToast(message, type) {
// Check if this exact message has been shown in the last 500ms
const messageKey = `${message}-${type}`;
if (window.shownToastMessages.includes(messageKey)) {
console.log(`Preventing duplicate toast: ${messageKey}`);
return;
}
// Add to shown messages
window.shownToastMessages.push(messageKey);
// Remove from tracking after 500ms to allow the same message later if needed
setTimeout(() => {
const index = window.shownToastMessages.indexOf(messageKey);
if (index > -1) {
window.shownToastMessages.splice(index, 1);
}
}, 500);
const toastContainer = document.getElementById('toast-container');
if (!toastContainer) {
console.error("Toast container not found!");
return;
}
// Create toast element
const toast = document.createElement('div');
toast.id = 'toast-' + type + '-' + Date.now();
toast.className = 'flex items-center w-full max-w-xs p-4 mb-4 rounded-lg shadow text-gray-500 bg-white dark:text-gray-400 dark:bg-gray-800 transform translate-y-16 opacity-0 transition-all duration-300 ease-out';
toast.role = 'alert';
// Set toast content based on type
let iconClass, bgColorClass, textColorClass;
if (type === 'success') {
iconClass = 'text-green-500 bg-green-100 dark:bg-green-800 dark:text-green-200';
bgColorClass = 'text-green-500 dark:text-green-200';
textColorClass = 'text-green-500 dark:text-green-200';
} else if (type === 'error') {
iconClass = 'text-red-500 bg-red-100 dark:bg-red-800 dark:text-red-200';
bgColorClass = 'text-red-500 dark:text-red-200';
textColorClass = 'text-red-500 dark:text-red-200';
} else { // Default to info
iconClass = 'text-blue-500 bg-blue-100 dark:bg-blue-800 dark:text-blue-200';
bgColorClass = 'text-blue-500 dark:text-blue-200';
textColorClass = 'text-blue-500 dark:text-blue-200';
}
// Create icon div
const iconDiv = document.createElement('div');
iconDiv.className = `inline-flex items-center justify-center flex-shrink-0 w-8 h-8 rounded-lg ${iconClass}`;
iconDiv.innerHTML = type === 'success'
? '<i class="fas fa-check"></i>'
: type === 'error'
? '<i class="fas fa-exclamation-circle"></i>'
: '<i class="fas fa-info-circle"></i>';
// Create message div and set text content safely
const messageDiv = document.createElement('div');
messageDiv.className = 'ml-3 text-sm font-normal';
messageDiv.textContent = message; // Use textContent for safety
// Create close button
const closeButton = document.createElement('button'); // Keep this declaration
closeButton.type = 'button';
closeButton.className = 'ml-auto -mx-1.5 -my-1.5 bg-white text-gray-400 hover:text-gray-900 rounded-lg focus:ring-2 focus:ring-gray-300 p-1.5 hover:bg-gray-100 inline-flex h-8 w-8 dark:text-gray-500 dark:hover:text-white dark:bg-gray-800 dark:hover:bg-gray-700';
closeButton.setAttribute('data-dismiss-target', `#${toast.id}`);
closeButton.setAttribute('aria-label', 'Close');
closeButton.innerHTML = `
<span class="sr-only">Close</span>
<i class="fas fa-times"></i>
`;
// Append elements to the toast
toast.appendChild(iconDiv);
toast.appendChild(messageDiv);
toast.appendChild(closeButton);
// Add toast to container
toastContainer.appendChild(toast);
// Trigger animation after a small delay
setTimeout(() => {
toast.classList.remove('translate-y-16', 'opacity-0');
toast.classList.add('translate-y-0', 'opacity-100');
}, 10);
// Add event listener to the close button we created earlier
closeButton.addEventListener('click', function() { // Use the existing closeButton variable
// Animate out before removing
toast.classList.add('opacity-0', 'translate-y-4');
setTimeout(() => {
toast.remove();
}, 300);
});
// Auto-remove toast after 5 seconds
setTimeout(() => {
toast.classList.add('opacity-0', 'translate-y-4');
setTimeout(() => {
toast.remove();
}, 300);
}, 5000);
}
</script>
}
+954
View File
@@ -0,0 +1,954 @@
package components
import (
"context"
"fmt"
"github.com/starfleetcptn/gomft/internal/db"
)
type StorageProviderFormData struct {
Provider *db.StorageProvider
IsEdit bool
Error string
}
// getTitle returns the appropriate title based on whether we're editing or creating
func getTitle(isEdit bool) string {
if isEdit {
return "Edit Storage Provider"
}
return "New Storage Provider"
}
// Main template for Storage Provider form
templ StorageProviderForm(ctx context.Context, data StorageProviderFormData) {
@LayoutWithContext(getTitle(data.IsEdit), ctx) {
<div class="min-h-screen bg-gray-50 dark:bg-gray-900 py-8">
<div class="max-w-3xl mx-auto">
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
<i class="fas fa-server w-6 h-6 mr-2 text-blue-500 dark:text-blue-400"></i>
if data.IsEdit {
Edit Storage Provider
} else {
New Storage Provider
}
</h1>
<a href="/storage-providers" class="text-blue-600 dark:text-blue-400 hover:underline flex items-center">
<i class="fas fa-arrow-left mr-1.5"></i>
Back to Providers
</a>
</div>
if data.Error != "" {
<div class="mb-4 p-4 text-sm text-red-800 rounded-lg bg-red-50 dark:bg-gray-800 dark:text-red-400">
<div class="flex items-center">
<i class="fas fa-exclamation-circle mr-2"></i>
<span>{ data.Error }</span>
</div>
</div>
}
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-6 mb-6">
if data.IsEdit {
<form action={ templ.SafeURL(fmt.Sprintf("/storage-providers/%d", data.Provider.ID)) } method="POST">
<input type="hidden" name="_method" value="PUT" />
@formFields(data)
</form>
} else {
<form action="/storage-providers" method="POST">
@formFields(data)
</form>
}
</div>
</div>
</div>
<!-- Include the provider form scripts -->
@providerFormScript()
}
}
// Form fields template
templ formFields(data StorageProviderFormData) {
<!-- Basic Information -->
<div class="mb-6">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Basic Information</h2>
<!-- Provider Name -->
<div class="mb-4">
<label for="name" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Provider Name <span class="text-red-500">*</span></label>
<input type="text" id="name" name="name" value={ data.Provider.Name } class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="Storage Provider Name" required />
</div>
<!-- Provider Type -->
<div>
<label for="type" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Provider Type <span class="text-red-500">*</span></label>
<select id="type" name="type" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" required onchange="toggleProviderFields()">
<option value="" disabled
if data.Provider.Type == "" {
selected="selected"
}
>
Select provider type
</option>
<!-- Server-based providers -->
<optgroup label="Server-based">
<option value="sftp"
if data.Provider.Type == db.ProviderTypeSFTP {
selected="selected"
}
>SFTP</option>
<option value="hetzner"
if data.Provider.Type == db.ProviderTypeHetzner {
selected="selected"
}
>Hetzner Storage Box</option>
<option value="ftp"
if data.Provider.Type == db.ProviderTypeFTP {
selected="selected"
}
>FTP</option>
<option value="smb"
if data.Provider.Type == db.ProviderTypeSMB {
selected="selected"
}
>SMB/CIFS</option>
</optgroup>
<!-- Object Storage -->
<optgroup label="Object Storage">
<option value="s3"
if data.Provider.Type == db.ProviderTypeS3 {
selected="selected"
}
>Amazon S3</option>
<option value="wasabi"
if data.Provider.Type == "wasabi" {
selected="selected"
}
>Wasabi</option>
<option value="minio"
if data.Provider.Type == "minio" {
selected="selected"
}
>MinIO</option>
<option value="b2"
if data.Provider.Type == "b2" {
selected="selected"
}
>Backblaze B2</option>
</optgroup>
<!-- Web-based storage -->
<optgroup label="Web Storage">
<option value="webdav"
if data.Provider.Type == "webdav" {
selected="selected"
}
>WebDAV</option>
<option value="nextcloud"
if data.Provider.Type == "nextcloud" {
selected="selected"
}
>Nextcloud</option>
</optgroup>
<!-- Cloud providers -->
<optgroup label="Cloud Storage">
<option value="onedrive"
if data.Provider.Type == db.ProviderTypeOneDrive {
selected="selected"
}
>OneDrive</option>
<option value="drive"
if data.Provider.Type == db.ProviderTypeGoogleDrive {
selected="selected"
}
>Google Drive</option>
<option value="gphotos"
if data.Provider.Type == db.ProviderTypeGooglePhoto {
selected="selected"
}
>Google Photos</option>
</optgroup>
<!-- Local -->
<optgroup label="Local">
<option value="local"
if data.Provider.Type == db.ProviderTypeLocal {
selected="selected"
}
>Local Filesystem</option>
</optgroup>
</select>
</div>
</div>
<!-- Connection Details - SFTP/FTP/SMB -->
<div id="sftp-ftp-fields" class="mb-6 provider-fields hidden">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Connection Details</h2>
<!-- Host -->
<div class="mb-4">
<label for="host" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Host <span class="text-red-500">*</span></label>
<input type="text" id="host" name="host" value={ data.Provider.Host } class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="e.g., sftp.example.com or 192.168.1.10" />
</div>
<!-- Port -->
<div id="port-field" class="mb-4">
<label for="port" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Port</label>
<input type="number" id="port" name="port" value={ fmt.Sprint(data.Provider.Port) } class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="22" />
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Leave empty for default (SFTP: 22, FTP: 21, SMB: 445, WebDAV: 80/443)</p>
</div>
<!-- Username -->
<div class="mb-4">
<label for="username" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Username <span class="text-red-500">*</span></label>
<input type="text" id="username" name="username" value={ data.Provider.Username } class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="Your login username" />
</div>
<!-- Password -->
<div class="mb-4">
<label for="password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Password</label>
<input type="password" id="password" name="password" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="Your login password" />
if data.IsEdit {
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Leave empty to keep the current password</p>
}
</div>
<!-- Key File (SFTP only) -->
<div id="key-file-field" class="mb-4 hidden">
<label for="keyFile" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Key File Path</label>
<input type="text" id="keyFile" name="keyFile" value={ data.Provider.KeyFile } class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="e.g., /home/user/.ssh/id_rsa" />
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Absolute path to private key file (if using key-based authentication)</p>
</div>
<!-- Domain (SMB only) -->
<div id="domain-field" class="mb-4 hidden">
<label for="domain" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Domain</label>
<input type="text" id="domain" name="domain" value={ data.Provider.Domain } class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="e.g., WORKGROUP or domain.local" />
</div>
<!-- Passive Mode (FTP only) -->
<div id="passive-mode-field" class="mb-4 hidden">
<div class="flex items-center">
<input id="passiveMode" name="passiveMode" type="checkbox" value="true" class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"
if data.Provider.PassiveMode != nil && *data.Provider.PassiveMode {
checked="checked"
}
/>
<label for="passiveMode" class="ml-2 text-sm font-medium text-gray-900 dark:text-white">Use Passive Mode</label>
</div>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Recommended for most FTP connections through firewalls</p>
</div>
</div>
<!-- S3 Fields -->
<div id="s3-fields" class="mb-6 provider-fields hidden">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">S3 Connection Details</h2>
<!-- Endpoint -->
<div class="mb-4">
<label for="endpoint" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
Endpoint
<span id="endpoint-required" class="text-red-500">*</span>
</label>
<input type="text" id="endpoint" name="endpoint" value={ data.Provider.Endpoint }
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="e.g., s3.amazonaws.com, s3.us-west-1.wasabisys.com" />
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
Custom endpoint URL (only needed for non-standard regions or non-AWS S3-compatible services). Optional for B2.
</p>
</div>
<!-- Region -->
<div class="mb-4">
<label for="region" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
Region
<span id="region-required" class="text-red-500">*</span>
</label>
<input type="text" id="region" name="region" value={ data.Provider.Region }
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="e.g., us-east-1, eu-central-1" />
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
AWS Region where your S3 bucket is located (e.g., us-east-1, eu-west-1). Optional for B2 and Wasabi.
</p>
</div>
<!-- Bucket -->
<div class="mb-4">
<label for="bucket" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Bucket <span class="text-red-500">*</span></label>
<input type="text" id="bucket" name="bucket" value={ data.Provider.Bucket }
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="Your bucket name" />
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Name of your S3 bucket (case-sensitive)
</p>
</div>
<!-- Access Key -->
<div class="mb-4">
<label for="accessKey" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Access Key <span class="text-red-500">*</span></label>
<input type="text" id="accessKey" name="accessKey" value={ data.Provider.AccessKey }
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="Your access key/key ID" />
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Your AWS Access Key ID
</p>
</div>
<!-- Secret Key -->
<div class="mb-4">
<label for="secretKey" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Secret Key <span class="text-red-500">*</span></label>
<input type="password" id="secretKey" name="secretKey"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="Your secret access key" />
if data.IsEdit {
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Leave empty to keep the current secret key</p>
}
</div>
</div>
<!-- Cloud Storage Fields (OneDrive, Google Drive, Google Photos) -->
<div id="cloud-fields" class="mb-6 provider-fields hidden">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Cloud Storage Details</h2>
<!-- Client ID -->
<div class="mb-4">
<label for="clientID" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Client ID <span class="text-red-500">*</span></label>
<input type="text" id="clientID" name="clientID" value={ data.Provider.ClientID } class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="OAuth client ID from developer console" />
</div>
<!-- Client Secret -->
<div class="mb-4">
<label for="clientSecret" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Client Secret <span class="text-red-500">*</span></label>
<input type="password" id="clientSecret" name="clientSecret" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="OAuth client secret from developer console" />
if data.IsEdit {
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Leave empty to keep the current client secret</p>
}
</div>
<!-- Google Drive - Drive ID -->
<div id="drive-id-field" class="mb-4 hidden">
<label for="driveID" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Drive ID</label>
<input type="text" id="driveID" name="driveID" value={ data.Provider.DriveID } class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="ID of shared/team drive (from Drive URL)" />
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Only required for shared drives</p>
</div>
<!-- Google Drive - Team Drive -->
<div id="team-drive-field" class="mb-4 hidden">
<label for="teamDrive" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Team Drive</label>
<input type="text" id="teamDrive" name="teamDrive" value={ data.Provider.TeamDrive } class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="Team drive identifier" />
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Only required for team drives</p>
</div>
<!-- Google Photos - Read Only -->
<div id="readonly-field" class="mb-4 hidden">
<div class="flex items-center">
<input id="readOnly" name="readOnly" type="checkbox" value="true" class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"
if data.Provider.ReadOnly != nil && *data.Provider.ReadOnly {
checked="checked"
}
/>
<label for="readOnly" class="ml-2 text-sm font-medium text-gray-900 dark:text-white">Read Only Mode</label>
</div>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Google Photos has limited write capabilities</p>
</div>
</div>
<!-- Local File System Fields -->
<div id="local-fields" class="mb-6 provider-fields hidden">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Local File System</h2>
<div class="mb-4">
<label for="localPath" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Base Path <span class="text-red-500">*</span></label>
<input type="text" id="localPath" name="localPath" value={ data.Provider.Host } class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="e.g., /data/files or C:\transfer\data" />
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Absolute path on the server's file system</p>
</div>
</div>
<!-- Submit Buttons -->
<div class="flex items-center justify-between mt-8">
<a href="/storage-providers" class="text-gray-500 bg-gray-50 hover:bg-gray-100 focus:ring-4 focus:outline-none focus:ring-gray-200 rounded-lg border border-gray-200 text-sm font-medium px-5 py-2.5 hover:text-gray-900 focus:z-10 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-500 dark:hover:text-white dark:hover:bg-gray-600 dark:focus:ring-gray-600">
Cancel
</a>
<div class="flex space-x-2">
<button type="submit" name="test" value="true" class="text-white bg-blue-600 hover:bg-blue-700 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm w-full sm:w-auto px-5 py-2.5 text-center dark:bg-blue-500 dark:hover:bg-blue-600 dark:focus:ring-blue-700">
Save & Test
</button>
<button type="submit" class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm w-full sm:w-auto px-5 py-2.5 text-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800">
Save Provider
</button>
</div>
</div>
<!-- Hidden fields for S3 form data to ensure it gets submitted correctly -->
<input type="hidden" id="hidden_endpoint" name="endpoint" value={ data.Provider.Endpoint } />
<input type="hidden" id="hidden_region" name="region" value={ data.Provider.Region } />
<input type="hidden" id="hidden_bucket" name="bucket" value={ data.Provider.Bucket } />
<input type="hidden" id="hidden_accessKey" name="accessKey" value={ data.Provider.AccessKey } />
<input type="hidden" id="hidden_secretKey" name="secretKey" />
<!-- Hidden fields for Google Drive and Google Photos -->
<input type="hidden" id="hidden_clientID" name="clientID" value={ data.Provider.ClientID } />
<input type="hidden" id="hidden_clientSecret" name="clientSecret" />
}
// JavaScript helper for toggling provider fields
templ providerFormScript() {
<script>
// Set current active fields on page load
document.addEventListener('DOMContentLoaded', function() {
toggleProviderFields();
// Add event listener to track if user manually changes the port
const portField = document.getElementById('port');
portField.addEventListener('input', function() {
// Mark the field as user-modified
this.dataset.userModified = 'true';
});
// Add event listeners for S3 fields
document.getElementById('endpoint').addEventListener('input', function() {
document.getElementById('hidden_endpoint').value = this.value;
});
document.getElementById('region').addEventListener('input', function() {
document.getElementById('hidden_region').value = this.value;
});
document.getElementById('bucket').addEventListener('input', function() {
document.getElementById('hidden_bucket').value = this.value;
});
document.getElementById('accessKey').addEventListener('input', function() {
document.getElementById('hidden_accessKey').value = this.value;
});
document.getElementById('secretKey').addEventListener('input', function() {
document.getElementById('hidden_secretKey').value = this.value;
});
// Add event listeners for Google Drive/Photos fields
document.getElementById('clientID').addEventListener('input', function() {
document.getElementById('hidden_clientID').value = this.value;
});
document.getElementById('clientSecret').addEventListener('input', function() {
document.getElementById('hidden_clientSecret').value = this.value;
});
// Add form submit listener to ensure all hidden fields are populated
const forms = document.querySelectorAll('form');
forms.forEach(form => {
form.addEventListener('submit', function(e) {
// Find active provider type
const providerType = document.getElementById('type').value;
// If S3-compatible, update hidden fields
if (['s3', 'wasabi', 'minio', 'b2'].includes(providerType)) {
document.getElementById('hidden_endpoint').value = document.getElementById('endpoint').value;
document.getElementById('hidden_region').value = document.getElementById('region').value;
document.getElementById('hidden_bucket').value = document.getElementById('bucket').value;
document.getElementById('hidden_accessKey').value = document.getElementById('accessKey').value;
// Make sure secretKey is always copied to the hidden field
// This is especially important for B2 which uses this as Application Key
const secretKeyValue = document.getElementById('secretKey').value;
document.getElementById('hidden_secretKey').value = secretKeyValue;
// For validation - ensure we have appropriate fields for each provider type
if (providerType === 'b2') {
// B2 does not require region or endpoint
if (!document.getElementById('hidden_bucket').value) {
alert('Bucket name is required');
e.preventDefault();
return false;
}
if (!document.getElementById('hidden_accessKey').value) {
alert('Account ID is required');
e.preventDefault();
return false;
}
if (!secretKeyValue && !document.getElementById('hidden_secretKey').value) {
alert('Application Key is required');
e.preventDefault();
return false;
}
} else if (providerType === 'wasabi') {
// Wasabi does not require region
if (!document.getElementById('hidden_endpoint').value) {
alert('Endpoint is required for Wasabi');
e.preventDefault();
return false;
}
if (!document.getElementById('hidden_bucket').value) {
alert('Bucket name is required');
e.preventDefault();
return false;
}
if (!document.getElementById('hidden_accessKey').value) {
alert('Access key is required');
e.preventDefault();
return false;
}
if (!secretKeyValue && !document.getElementById('hidden_secretKey').value) {
alert('Secret key is required');
e.preventDefault();
return false;
}
}
console.log('Submitting S3-compatible form with:', {
provider: providerType,
endpoint: document.getElementById('hidden_endpoint').value,
region: document.getElementById('hidden_region').value,
bucket: document.getElementById('hidden_bucket').value,
accessKey: document.getElementById('hidden_accessKey').value,
secretKey: document.getElementById('hidden_secretKey').value ? '[PRESENT]' : '[EMPTY]',
secretKeyLength: document.getElementById('hidden_secretKey').value.length
});
}
// If Google Drive or Google Photos, update hidden fields
if (['drive', 'gphotos', 'onedrive'].includes(providerType)) {
document.getElementById('hidden_clientID').value = document.getElementById('clientID').value;
// Make sure clientSecret is always copied to the hidden field
const clientSecretValue = document.getElementById('clientSecret').value;
document.getElementById('hidden_clientSecret').value = clientSecretValue;
// Validate required fields
if (!document.getElementById('hidden_clientID').value) {
alert('Client ID is required');
e.preventDefault();
return false;
}
console.log('Submitting cloud storage form with:', {
provider: providerType,
clientID: document.getElementById('hidden_clientID').value,
clientSecret: document.getElementById('hidden_clientSecret').value ? '[PRESENT]' : '[EMPTY]',
clientSecretLength: document.getElementById('hidden_clientSecret').value.length
});
}
// Debug WebDAV form submission
if (['webdav', 'nextcloud'].includes(providerType)) {
console.log('Submitting WebDAV form with:', {
provider: providerType,
host: document.getElementById('host').value,
username: document.getElementById('username').value,
password: document.getElementById('password').value ? '[PRESENT]' : '[EMPTY]',
passwordLength: document.getElementById('password').value.length
});
}
});
});
});
function toggleProviderFields() {
const provider = document.getElementById('type').value;
// Hide all provider fields first
const providerFieldsets = document.querySelectorAll('.provider-fields');
providerFieldsets.forEach(fieldset => {
fieldset.classList.add('hidden');
});
// Auto-populate port based on provider type
const portField = document.getElementById('port');
const isPortEmpty = portField.value === '';
const userModified = portField.dataset.userModified === 'true';
// Only set default port if field is empty or hasn't been modified by user
if (isPortEmpty || !userModified) {
if (provider === 'sftp') {
portField.value = '22';
} else if (provider === 'hetzner') {
portField.value = '23';
} else if (provider === 'ftp') {
portField.value = '21';
} else if (provider === 'smb') {
portField.value = '445';
} else if (provider === 'webdav' || provider === 'nextcloud') {
portField.value = '443';
} else {
portField.value = '';
}
// Reset user modified flag if we're setting it programmatically
portField.dataset.userModified = 'false';
}
// Show the appropriate fields based on the selected provider type
console.log("Provider type selected:", provider);
// Server-based connection fields (SFTP, FTP, Hetzner)
if (['sftp', 'ftp', 'hetzner'].includes(provider)) {
document.getElementById('sftp-ftp-fields').classList.remove('hidden');
// SFTP/Hetzner-specific fields
if (provider === 'sftp' || provider === 'hetzner') {
document.getElementById('key-file-field').classList.remove('hidden');
// Update placeholders for Hetzner
if (provider === 'hetzner') {
// Update host field
const hostField = document.getElementById('host');
if (hostField) {
hostField.placeholder = "uXXXXXX.your-storagebox.de";
// Update host label and description
const hostLabel = document.querySelector('label[for="host"]');
if (hostLabel) {
hostLabel.textContent = "Storage Box Host";
}
const hostDescription = hostField.nextElementSibling;
if (hostDescription?.tagName === 'P') {
hostDescription.textContent = "Your Hetzner Storage Box hostname (e.g., uXXXXXX.your-storagebox.de)";
}
}
// Update username field
const usernameField = document.getElementById('username');
if (usernameField) {
usernameField.placeholder = "uXXXXXX";
// Update username description
const usernameDescription = usernameField.nextElementSibling;
if (usernameDescription?.tagName === 'P') {
usernameDescription.textContent = "Your Hetzner Storage Box username (typically matches your Storage Box number)";
}
}
// Update key file field
const keyFileField = document.getElementById('keyFile');
if (keyFileField) {
keyFileField.placeholder = "/path/to/id_rsa";
// Update key file description
const keyFileDescription = keyFileField.nextElementSibling;
if (keyFileDescription?.tagName === 'P') {
keyFileDescription.textContent = "Path to your SSH private key file for Hetzner Storage Box authentication";
}
}
// Update port field description
const portField = document.getElementById('port');
if (portField) {
const portDescription = portField.nextElementSibling;
if (portDescription?.tagName === 'P') {
portDescription.textContent = "Connection port for Hetzner Storage Box (default: 23)";
}
}
}
}
// FTP-specific fields
if (provider === 'ftp') {
document.getElementById('passive-mode-field').classList.remove('hidden');
}
}
// WebDAV-based fields
if (['webdav', 'nextcloud'].includes(provider)) {
document.getElementById('sftp-ftp-fields').classList.remove('hidden');
// Hide fields that WebDAV doesn't use
document.getElementById('port-field').classList.add('hidden');
// Update placeholders for WebDAV
const hostField = document.getElementById('host');
if (hostField) {
hostField.placeholder = "https://webdav.example.com";
}
// Update label for host field
const hostLabel = document.querySelector('label[for="host"]');
if (hostLabel) {
hostLabel.textContent = "WebDAV URL";
}
// Update description for host field
const hostDescription = hostField?.nextElementSibling;
if (hostDescription?.tagName === 'P') {
hostDescription.textContent = provider === 'webdav' ?
"Full URL to your WebDAV server including protocol (https://)" :
"Full URL to your Nextcloud WebDAV endpoint (e.g., https://nextcloud.example.com/remote.php/dav/files/username/)";
}
// Update username field
const usernameField = document.getElementById('username');
if (usernameField) {
usernameField.placeholder = provider === 'nextcloud' ? "nextcloud_username" : "webdav_username";
// Update username description
const usernameDescription = usernameField.nextElementSibling;
if (usernameDescription?.tagName === 'P') {
usernameDescription.textContent = provider === 'nextcloud' ?
"Your Nextcloud username for authentication" :
"Your WebDAV username for authentication";
}
}
// Update password field
const passwordField = document.getElementById('password');
if (passwordField) {
passwordField.name = "password"; // Ensure name is set correctly
// Update password description
const passwordDescription = passwordField.nextElementSibling;
if (passwordDescription?.tagName === 'P') {
// Check if we're in edit mode
const editMode = passwordDescription.textContent.includes("Leave empty to keep");
if (editMode) {
// Edit mode - tell user they can leave password empty to keep current one
passwordDescription.textContent = provider === 'nextcloud' ?
"Leave empty to keep the current Nextcloud password" :
"Leave empty to keep the current WebDAV password";
} else {
// New provider - show regular password help text
passwordDescription.textContent = provider === 'nextcloud' ?
"Your Nextcloud password or app-specific password" :
"Your WebDAV password for authentication";
}
}
}
if (document.getElementById('key-file-field')) {
document.getElementById('key-file-field').classList.add('hidden');
}
if (document.getElementById('domain-field')) {
document.getElementById('domain-field').classList.add('hidden');
}
} else {
// Reset placeholders for other providers
const hostField = document.getElementById('host');
if (hostField && ['sftp', 'ftp', 'smb'].includes(provider)) {
hostField.placeholder = provider === 'sftp' ? "sftp.example.com" :
provider === 'ftp' ? "ftp.example.com" :
"192.168.1.10";
}
// Reset label for host field
const hostLabel = document.querySelector('label[for="host"]');
if (hostLabel) {
hostLabel.textContent = "Host";
}
// Reset username field
const usernameField = document.getElementById('username');
if (usernameField) {
usernameField.placeholder = provider === 'sftp' ? "sftp_username" :
provider === 'ftp' ? "ftp_username" :
provider === 'smb' ? "smb_username" : "username";
// Reset username description
const usernameDescription = usernameField.nextElementSibling;
if (usernameDescription?.tagName === 'P') {
usernameDescription.textContent = "Your login username";
}
}
// Reset password field
const passwordField = document.getElementById('password');
if (passwordField) {
// Reset password description
const passwordDescription = passwordField.nextElementSibling;
if (passwordDescription?.tagName === 'P') {
// Check if we're in edit mode
const editMode = passwordDescription.textContent.includes("Leave empty to keep");
if (editMode) {
// Edit mode - tell user they can leave password empty to keep current one
if (provider === 'sftp') {
passwordDescription.textContent = "Leave empty to keep the current SFTP password";
} else if (provider === 'ftp') {
passwordDescription.textContent = "Leave empty to keep the current FTP password";
} else if (provider === 'smb') {
passwordDescription.textContent = "Leave empty to keep the current SMB password";
} else if (provider === 'hetzner') {
passwordDescription.textContent = "Leave empty to keep the current Hetzner Storage Box password";
} else {
passwordDescription.textContent = "Leave empty to keep the current password";
}
} else {
// New provider - show regular password help text
if (provider === 'sftp') {
passwordDescription.textContent = "Your SFTP server password";
} else if (provider === 'ftp') {
passwordDescription.textContent = "Your FTP server password";
} else if (provider === 'smb') {
passwordDescription.textContent = "Your SMB/CIFS share password";
} else if (provider === 'hetzner') {
passwordDescription.textContent = "Your Hetzner Storage Box password";
} else {
passwordDescription.textContent = "Your login password";
}
}
}
}
}
// SMB-specific fields
if (provider === 'smb') {
document.getElementById('sftp-ftp-fields').classList.remove('hidden');
document.getElementById('domain-field').classList.remove('hidden');
}
// S3-compatible storage
if (['s3', 'wasabi', 'minio', 'b2'].includes(provider)) {
document.getElementById('s3-fields').classList.remove('hidden');
// Set region and endpoint requirements based on provider type
const regionRequired = document.getElementById('region-required');
const endpointRequired = document.getElementById('endpoint-required');
const regionInput = document.getElementById('region');
const endpointInput = document.getElementById('endpoint');
// Update Secret Key field description based on provider type
const secretKeyField = document.getElementById('secretKey');
if (secretKeyField) {
const secretKeyDescription = secretKeyField.nextElementSibling;
if (secretKeyDescription?.tagName === 'P') {
// Check if in edit mode
const editMode = secretKeyDescription.textContent.includes("Leave empty to keep");
if (editMode) {
// Edit mode - provider specific text
if (provider === 'b2') {
secretKeyDescription.textContent = "Leave empty to keep the current B2 Application Key";
} else if (provider === 'wasabi') {
secretKeyDescription.textContent = "Leave empty to keep the current Wasabi Secret Key";
} else if (provider === 'minio') {
secretKeyDescription.textContent = "Leave empty to keep the current MinIO Secret Key";
} else {
secretKeyDescription.textContent = "Leave empty to keep the current AWS Secret Access Key";
}
} else {
// New provider - provider specific text
if (provider === 'b2') {
secretKeyDescription.textContent = "Your Backblaze B2 Application Key";
} else if (provider === 'wasabi') {
secretKeyDescription.textContent = "Your Wasabi Secret Key";
} else if (provider === 'minio') {
secretKeyDescription.textContent = "Your MinIO Secret Key";
} else {
secretKeyDescription.textContent = "Your AWS Secret Access Key";
}
}
}
}
// For B2: endpoint and region are optional
if (provider === 'b2') {
regionRequired.style.display = 'none';
endpointRequired.style.display = 'none';
regionInput.removeAttribute('required');
endpointInput.removeAttribute('required');
}
// For Wasabi: region is optional
else if (provider === 'wasabi') {
regionRequired.style.display = 'none';
endpointRequired.style.display = 'inline';
regionInput.removeAttribute('required');
endpointInput.setAttribute('required', 'required');
}
// For S3 and MinIO: both are required
else {
regionRequired.style.display = 'inline';
endpointRequired.style.display = 'inline';
regionInput.setAttribute('required', 'required');
endpointInput.setAttribute('required', 'required');
}
}
// Google services
if (['drive', 'gphotos'].includes(provider)) {
document.getElementById('cloud-fields').classList.remove('hidden');
document.getElementById('built-in-auth-field').classList.remove('hidden');
// Update client secret field description
const clientSecretField = document.getElementById('clientSecret');
if (clientSecretField) {
const clientSecretDescription = clientSecretField.nextElementSibling;
if (clientSecretDescription?.tagName === 'P') {
// Check if in edit mode
const editMode = clientSecretDescription.textContent.includes("Leave empty to keep");
if (editMode) {
// Edit mode - provider specific text
if (provider === 'drive') {
clientSecretDescription.textContent = "Leave empty to keep the current Google Drive client secret";
} else if (provider === 'gphotos') {
clientSecretDescription.textContent = "Leave empty to keep the current Google Photos client secret";
}
}
}
}
// Google Drive specific fields
if (provider === 'drive') {
document.getElementById('drive-id-field').classList.remove('hidden');
document.getElementById('team-drive-field').classList.remove('hidden');
}
// Google Photos specific fields
if (provider === 'gphotos') {
document.getElementById('gphotos-options-field').classList.remove('hidden');
}
}
// OneDrive
if (provider === 'onedrive') {
document.getElementById('cloud-fields').classList.remove('hidden');
// Update client secret field description
const clientSecretField = document.getElementById('clientSecret');
if (clientSecretField) {
const clientSecretDescription = clientSecretField.nextElementSibling;
if (clientSecretDescription?.tagName === 'P') {
// Check if in edit mode
const editMode = clientSecretDescription.textContent.includes("Leave empty to keep");
if (editMode) {
clientSecretDescription.textContent = "Leave empty to keep the current OneDrive client secret";
} else {
clientSecretDescription.textContent = "Your Microsoft Azure OAuth client secret";
}
}
}
}
// Local filesystem fields
if (provider === 'local') {
document.getElementById('local-fields').classList.remove('hidden');
}
}
</script>
}
// Test result dialog
templ ConnectionTestResult(success bool, message string, errorCode string) {
<div class="text-center">
if success {
<i class="fas fa-check-circle text-green-500 text-5xl mb-4"></i>
<h3 class="mb-2 text-lg font-semibold text-green-500 dark:text-green-400">Connection Successful</h3>
} else {
<i class="fas fa-times-circle text-red-500 text-5xl mb-4"></i>
<h3 class="mb-2 text-lg font-semibold text-red-500 dark:text-red-400">Connection Failed</h3>
}
<p class="text-gray-500 dark:text-gray-400 mb-4">
{ message }
</p>
if errorCode != "" {
<div class="text-sm bg-gray-100 dark:bg-gray-800 p-2 rounded">
<p class="text-gray-700 dark:text-gray-300">Error code: { errorCode }</p>
</div>
}
</div>
}
@@ -0,0 +1,169 @@
package components
import (
"context"
)
// StorageProviderGDriveHeadlessAuthData contains data needed for rendering the headless auth page for storage providers
type StorageProviderGDriveHeadlessAuthData struct {
AuthCommand string
ProviderID string
}
// StorageProviderGDriveHeadlessAuth renders the headless authentication page for Google Drive/Photos for storage providers
templ StorageProviderGDriveHeadlessAuth(ctx context.Context, data StorageProviderGDriveHeadlessAuthData) {
// Force the layout to display as authenticated content
@LayoutWithContext("Google Authentication - Headless Mode", ctx) {
<style>
/* Ensure proper styling for the headless auth page */
body.dark .auth-page {
background-color: #111827 !important;
}
</style>
<div id="auth-container" class="auth-page w-full pb-8 bg-gray-50 dark:bg-gray-900" style="min-height: 100vh; background-color: rgb(249, 250, 251);">
<div class="max-w-4xl mx-auto">
<div class="mb-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
<i class="fas fa-key w-6 h-6 mr-2 text-blue-500 dark:text-blue-400"></i>
Headless Google Authentication
</h1>
<a href="/storage-providers" class="flex items-center justify-center text-gray-700 bg-gray-100 hover:bg-gray-200 focus:ring-4 focus:ring-gray-300 font-medium rounded-lg px-5 py-2.5 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600 focus:outline-none dark:focus:ring-gray-700">
<i class="fas fa-arrow-left w-4 h-4 mr-2"></i>
Back to Storage Providers
</a>
</div>
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 p-6">
<div class="bg-blue-50 dark:bg-blue-900/30 border-l-4 border-blue-500 p-4 mb-6">
<div class="flex">
<div class="flex-shrink-0 mt-0.5">
<i class="fas fa-info-circle h-5 w-5 text-blue-500"></i>
</div>
<div class="ml-3">
<p class="text-sm text-blue-700 dark:text-blue-300">
You need to authenticate with Google using a web browser. Since you're running GoMFT behind a reverse proxy or in a headless environment, you'll need to complete authentication on a machine with a web browser.
</p>
</div>
</div>
</div>
<div class="mb-8">
<h2 class="text-lg font-medium mb-3 text-gray-900 dark:text-white">Step 1: Run the following command on a machine with a web browser</h2>
<div class="relative mb-4">
<pre id="auth-command-text" class="bg-gray-50 dark:bg-gray-900 rounded-md p-4 overflow-x-auto text-sm font-mono">{ data.AuthCommand }</pre>
<button id="copy-command" class="absolute top-2 right-2 bg-gray-200 dark:bg-gray-700 p-1.5 rounded hover:bg-gray-300 dark:hover:bg-gray-600" title="Copy to clipboard">
<i class="fas fa-copy h-5 w-5 text-gray-700 dark:text-gray-300"></i>
</button>
</div>
<div>
<h3 class="text-md font-medium mb-2 text-gray-900 dark:text-white">What this command does:</h3>
<ul class="list-disc ml-6 text-sm text-gray-700 dark:text-gray-300 space-y-1">
<li>Opens a browser window on the machine where you run it</li>
<li>Allows you to authenticate with Google</li>
<li>Generates an authentication token</li>
</ul>
</div>
</div>
<div class="mb-6">
<h2 class="text-lg font-medium mb-3 text-gray-900 dark:text-white">Step 2: Paste the authentication token below</h2>
<p class="text-sm text-gray-700 dark:text-gray-300 mb-4">
After completing authentication in the browser, you'll receive a token. Copy and paste that token here:
</p>
<form action="/storage-providers/gdrive-headless-token" method="POST" class="space-y-4">
<input type="hidden" name="provider_id" value={ data.ProviderID } />
<div>
<label for="auth_token" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Authentication Token</label>
<textarea
id="auth_token"
name="auth_token"
rows="5"
class="mt-1 block w-full rounded-md border border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white dark:placeholder-gray-400"
placeholder="Paste your authentication token here..."
required
></textarea>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">The token will look like a long JSON string containing access credentials.</p>
</div>
<div class="flex items-center justify-end mt-6">
<a href="/storage-providers" class="mr-4 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-gray-500 dark:hover:text-gray-400">
Cancel
</a>
<button
type="submit"
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
Submit Token
</button>
</div>
</form>
</div>
</div>
<!-- Help Section -->
<div class="bg-gray-50 dark:bg-gray-800 rounded-lg shadow-sm mt-8 p-4 border border-gray-200 dark:border-gray-700">
<div class="flex items-start">
<div class="flex items-center h-5">
<i class="fas fa-info-circle w-4 h-4 text-blue-500 dark:text-blue-400 mr-2"></i>
</div>
<div class="ml-2 text-sm">
<p class="text-gray-700 dark:text-gray-300">This authentication process is necessary for GoMFT to access your Google Drive or Google Photos account.</p>
<p class="mt-1 text-gray-600 dark:text-gray-400">The token is only used for authentication and is stored securely. You'll only need to complete this process once for each storage provider.</p>
</div>
</div>
</div>
</div>
</div>
<script>
// Set dark background color if in dark mode
document.addEventListener('DOMContentLoaded', function() {
if (document.documentElement.classList.contains('dark')) {
document.getElementById('auth-container').style.backgroundColor = '#111827';
}
// Add event listener for theme changes
const themeToggle = document.getElementById('theme-toggle');
if (themeToggle) {
themeToggle.addEventListener('click', function() {
setTimeout(function() {
const isDark = document.documentElement.classList.contains('dark');
document.getElementById('auth-container').style.backgroundColor = isDark ? '#111827' : 'rgb(249, 250, 251)';
}, 50);
});
}
// Store the actual command text
const actualCommand = document.getElementById('auth-command-text').textContent.trim();
// Add click handler to copy button
document.getElementById('copy-command').addEventListener('click', function() {
// Copy the actual command text, not the template variable
navigator.clipboard.writeText(actualCommand).then(function() {
// Show a success message
const button = document.getElementById('copy-command');
const originalTitle = button.getAttribute('title');
button.setAttribute('title', 'Copied!');
// Also show visual feedback
button.classList.add('bg-green-200', 'dark:bg-green-700');
button.classList.remove('bg-gray-200', 'dark:bg-gray-700');
setTimeout(function() {
button.setAttribute('title', originalTitle);
button.classList.remove('bg-green-200', 'dark:bg-green-700');
button.classList.add('bg-gray-200', 'dark:bg-gray-700');
}, 2000);
}).catch(function(err) {
console.error('Failed to copy text: ', err);
alert('Failed to copy command. Please select and copy it manually.');
});
});
});
</script>
}
}
+728
View File
@@ -0,0 +1,728 @@
package components
import (
"context"
"fmt"
"github.com/starfleetcptn/gomft/components/shared/toast"
"github.com/starfleetcptn/gomft/internal/db"
)
type StorageProvidersData struct {
Providers []db.StorageProvider
Error string
Status string
}
// Main template for Storage Providers page
templ StorageProviders(ctx context.Context, data StorageProvidersData) {
@LayoutWithContext("Storage Providers", ctx) {
@toast.Container()
@toast.ShowToastJS()
<script>
// Handle test provider button clicks
window.testProvider = function(button) {
const providerId = button.getAttribute('data-provider-id');
const providerName = button.getAttribute('data-provider-name') || `Provider #${providerId}`;
showToast(`Testing connection to "${providerName}"...`, 'info');
button.addEventListener('htmx:afterRequest', function(event) {
if (event.detail.successful) {
showToast(`Connection to "${providerName}" successful!`, 'success');
} else {
let errorMsg = `Failed to connect to "${providerName}"`;
if (event.detail.xhr && event.detail.xhr.responseText) {
try {
const error = JSON.parse(event.detail.xhr.responseText);
errorMsg = error.error ? `Connection error: ${error.error}` : `Connection error: ${event.detail.xhr.responseText}`;
} catch (e) {
errorMsg = `Connection error: ${event.detail.xhr.responseText}`;
}
}
showToast(errorMsg, 'error');
}
}, { once: true });
};
// --- Duplicate Provider HTMX Event Handling (configs.templ style) ---
// Track all HTMX events for duplicate provider
document.addEventListener('htmx:beforeRequest', function(event) {
const path = event.detail.path;
const method = event.detail.verb;
if (path && method === 'POST' && path.match(/^\/storage-providers\/\d+\/duplicate$/)) {
window.isProviderDuplicateRequest = true;
// Store the provider name for toast
const providerId = path.match(/^\/storage-providers\/(\d+)\/duplicate$/)[1];
const btn = document.querySelector(`button[hx-post="/storage-providers/${providerId}/duplicate"]`);
if (btn) {
window.duplicatingProviderName = btn.getAttribute('data-provider-name') || `Provider #${providerId}`;
}
}
});
document.addEventListener('htmx:afterRequest', function(event) {
// Handle delete provider events (existing logic)
if (event.detail.pathInfo && event.detail.pathInfo.requestPath &&
event.detail.pathInfo.requestPath.match(/^\/storage-providers\/\d+$/) &&
event.detail.verb === 'DELETE') {
const providerName = event.detail.elt.getAttribute('data-provider-name') || 'Provider';
if (event.detail.successful) {
showToast(`Provider "${providerName}" deleted successfully`, 'success');
} else {
let errorMsg = `Failed to delete provider "${providerName}"`;
if (event.detail.xhr && event.detail.xhr.responseText) {
try {
const error = JSON.parse(event.detail.xhr.responseText);
errorMsg = error.error ? error.error : `Error: ${event.detail.xhr.responseText}`;
} catch (e) {
errorMsg = `Error: ${event.detail.xhr.responseText}`;
}
}
showToast(errorMsg, 'error');
}
}
// Handle duplicate provider success
const isDuplicateRequest = window.isProviderDuplicateRequest &&
event.detail.pathInfo &&
event.detail.pathInfo.requestPath &&
event.detail.pathInfo.requestPath.match(/^\/storage-providers\/\d+\/duplicate$/);
if (isDuplicateRequest && event.detail.successful) {
const providerName = window.duplicatingProviderName || 'provider';
showToast(`Provider "${providerName}" duplicated successfully`, 'success');
window.isProviderDuplicateRequest = false;
window.duplicatingProviderName = null;
}
});
document.addEventListener('htmx:responseError', function(event) {
// Handle any HTMX response error
let errorMsg = "An error occurred";
let entityName = "Operation";
// Get more context about the operation that failed
if (event.detail.elt && event.detail.elt.getAttribute('data-provider-name')) {
entityName = event.detail.elt.getAttribute('data-provider-name');
}
// Handle duplicate provider errors
const isDuplicateRequest = window.isProviderDuplicateRequest &&
event.detail.pathInfo &&
event.detail.pathInfo.requestPath &&
event.detail.pathInfo.requestPath.match(/^\/storage-providers\/\d+\/duplicate$/);
if (isDuplicateRequest) {
const providerName = window.duplicatingProviderName || 'provider';
errorMsg = `Failed to duplicate provider "${providerName}"`;
if (event.detail.xhr && event.detail.xhr.responseText) {
try {
const error = JSON.parse(event.detail.xhr.responseText);
errorMsg = error.error ? error.error : errorMsg;
} catch (e) {
if (event.detail.xhr.responseText.trim()) {
errorMsg = event.detail.xhr.responseText;
}
}
}
showToast(errorMsg, 'error');
window.isProviderDuplicateRequest = false;
window.duplicatingProviderName = null;
}
// Handle other HTMX errors
else if (event.detail.xhr && event.detail.xhr.responseText) {
try {
const error = JSON.parse(event.detail.xhr.responseText);
errorMsg = error.error ? error.error : `Error during ${entityName} operation`;
} catch (e) {
if (event.detail.xhr.responseText.trim()) {
errorMsg = event.detail.xhr.responseText;
} else {
errorMsg = `Error during ${entityName} operation`;
}
}
showToast(errorMsg, 'error');
}
// Update the visual error alert for all error types
const errorAlert = document.getElementById('htmx-error-alert');
const errorMessageSpan = document.getElementById('htmx-error-message');
if (errorAlert && errorMessageSpan) {
errorMessageSpan.textContent = errorMsg;
errorAlert.classList.remove('hidden');
// Auto-hide after 10 seconds
setTimeout(() => {
errorAlert.classList.add('hidden');
}, 10000);
}
});
// Set a flag before duplicate request to show toast after reload
document.addEventListener('click', function(e) {
const btn = e.target.closest('button[data-provider-id][hx-post*="/duplicate"]');
if (btn) {
localStorage.setItem('showProviderDuplicateToast', '1');
}
});
document.addEventListener('DOMContentLoaded', function() {
// Display error from data.Error if present
const errorElement = document.getElementById('provider-error-message');
if (errorElement && errorElement.textContent.trim()) {
showToast(errorElement.textContent.trim(), 'error');
}
// Display status messages programmatically
const statusElement = document.getElementById('provider-status-message');
if (statusElement && statusElement.textContent.trim()) {
const status = statusElement.textContent.trim();
let message = '';
let type = 'info';
// Convert status to appropriate toast message
switch(status) {
case 'created':
message = 'Provider created successfully';
type = 'success';
break;
case 'updated':
message = 'Provider updated successfully';
type = 'success';
break;
case 'deleted':
message = 'Provider deleted successfully';
type = 'success';
break;
case 'duplicated':
message = 'Provider duplicated successfully';
type = 'success';
break;
default:
if (status) {
message = `Provider ${status}`;
type = 'info';
}
}
if (message) {
showToast(message, type);
}
}
// Show toasts based on localStorage or URL parameters
if (localStorage.getItem('showProviderDuplicateToast')) {
showToast('Provider duplicated successfully', 'success');
localStorage.removeItem('showProviderDuplicateToast');
}
// Process URL parameters for toast notifications
const urlParams = new URLSearchParams(window.location.search);
// Handle combined status and test_status parameters
if (urlParams.get('status') === 'created') {
const testStatus = urlParams.get('test_status');
const errorMsg = urlParams.get('error');
if (testStatus === 'success') {
showToast('Provider created and tested successfully', 'success');
} else if (testStatus === 'failed' && errorMsg) {
showToast(`Provider created but test failed: ${errorMsg}`, 'error');
} else {
showToast('Provider created successfully', 'success');
}
} else if (urlParams.get('status') === 'updated') {
const testStatus = urlParams.get('test_status');
const errorMsg = urlParams.get('error');
if (testStatus === 'success') {
showToast('Provider updated and tested successfully', 'success');
} else if (testStatus === 'failed' && errorMsg) {
showToast(`Provider updated but test failed: ${errorMsg}`, 'error');
} else {
showToast('Provider updated successfully', 'success');
}
} else if (urlParams.get('error')) {
showToast(urlParams.get('error'), 'error');
}
});
</script>
<!-- Error message holder (hidden, used to pass server-side errors to JS) -->
if data.Error != "" {
<div id="provider-error-message" class="hidden">{ data.Error }</div>
}
<!-- Status message holder (hidden, used to pass server-side status to JS) -->
if data.Status != "" {
<div id="provider-status-message" class="hidden">{ data.Status }</div>
}
<div id="providers-container" style="min-height: 100vh; background-color: rgb(249, 250, 251);" class="providers-page bg-gray-50 dark:bg-gray-900">
<!-- Providers list follows; import button is above -->
<div class="pb-8 w-full">
<!-- Display error alert if data.Error is not empty -->
if data.Error != "" {
<div class="mb-4 p-4 text-sm text-red-800 rounded-lg bg-red-50 dark:bg-gray-800 dark:text-red-400" role="alert">
<div class="flex items-center">
<i class="fas fa-exclamation-circle flex-shrink-0 mr-2"></i>
<span>{ data.Error }</span>
</div>
</div>
}
<!-- Dynamic error alert container for HTMX errors -->
// <div id="htmx-error-alert" class="mb-4 p-4 text-sm text-red-800 rounded-lg bg-red-50 dark:bg-gray-800 dark:text-red-400 hidden" role="alert">
// <div class="flex items-center">
// <i class="fas fa-exclamation-circle flex-shrink-0 mr-2"></i>
// <span id="htmx-error-message"></span>
// <button type="button" class="ml-auto -mx-1.5 -my-1.5 bg-red-50 text-red-500 rounded-lg focus:ring-2 focus:ring-red-400 p-1.5 hover:bg-red-200 inline-flex items-center justify-center h-8 w-8 dark:bg-gray-800 dark:text-red-400 dark:hover:bg-gray-700" onclick="document.getElementById('htmx-error-alert').classList.add('hidden')">
// <span class="sr-only">Dismiss</span>
// <i class="fas fa-times"></i>
// </button>
// </div>
// </div>
<div class="mb-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
<i class="fas fa-server w-6 h-6 mr-2 text-blue-500 dark:text-blue-400"></i>
Storage Providers
</h1>
<!-- Import rclone config and New Provider links side by side -->
<div class="flex gap-x-4">
<a href="/storage-providers/import" class="bg-blue-600 hover:bg-blue-700 text-white font-semibold px-4 py-2 rounded-lg shadow flex items-center">
<i class="fas fa-file-import mr-2"></i>
Import rclone config
</a>
<a href="/storage-providers/new" class="flex items-center justify-center text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg px-5 py-2.5 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800">
<i class="fas fa-plus w-4 h-4 mr-2"></i>
New Provider
</a>
</div>
</div>
<div class="mt-6">
if len(data.Providers) == 0 {
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 p-8 flex flex-col items-center justify-center text-center">
<div class="inline-flex h-16 w-16 flex-shrink-0 items-center justify-center rounded-full bg-gray-100 mb-4 dark:bg-gray-700">
<i class="fas fa-server text-gray-400 dark:text-gray-500 text-3xl"></i>
</div>
<h3 class="mb-2 text-lg font-semibold text-gray-900 dark:text-white">No storage providers</h3>
<p class="text-gray-500 dark:text-gray-400 mb-4">Get started by creating a new storage provider.</p>
<a href="/storage-providers/new" class="inline-flex items-center px-3 py-2 text-sm font-medium text-center text-white bg-blue-700 rounded-lg hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800">
<i class="fas fa-plus w-4 h-4 mr-2"></i>
Create First Provider
</a>
</div>
} else {
@StorageProviders_ProvidersList(data.Providers)
}
</div>
<!-- Help Section -->
<div class="bg-gray-50 dark:bg-gray-800 rounded-lg shadow-sm mt-8 p-4 border border-gray-200 dark:border-gray-700">
<div class="flex items-start mb-2">
<div class="flex items-center h-5">
<i class="fas fa-info-circle w-4 h-4 text-blue-500 dark:text-blue-400 mr-2"></i>
</div>
<div class="ml-2 text-sm">
<p class="text-gray-700 dark:text-gray-300">Storage providers define connection details to different storage systems.</p>
</div>
</div>
<div class="flex items-start mt-4">
<div class="flex items-center h-5">
<i class="fas fa-shield-alt w-4 h-4 text-blue-500 dark:text-blue-400 mr-2"></i>
</div>
<div class="ml-2 text-sm">
<p class="text-gray-700 dark:text-gray-300">Your credentials are encrypted for security. You can test connections before using them in transfers.</p>
<p class="mt-1 text-gray-600 dark:text-gray-400">Google Drive and Google Photos providers require authentication. Click the "Authenticate" button to complete setup.</p>
</div>
</div>
<div class="flex items-start mt-4">
<div class="flex items-center h-5">
<i class="fas fa-link w-4 h-4 text-blue-500 dark:text-blue-400 mr-2"></i>
</div>
<div class="ml-2 text-sm">
<p class="text-gray-700 dark:text-gray-300">Providers can be used in multiple transfer configurations. Deleting a provider will affect any transfer that uses it.</p>
</div>
</div>
</div>
</div>
</div>
<script src="/static/js/storage-providers.js"></script>
<script>
// Function to initialize all auth dropdowns
function initAllAuthDropdowns() {
// Get all dropdown buttons
const dropdownButtons = document.querySelectorAll('[id^="auth-dropdown-button-"]');
dropdownButtons.forEach(button => {
const providerId = button.getAttribute('data-provider-id');
const menu = document.getElementById(`auth-dropdown-menu-${providerId}`);
if (button && menu) {
// Add click listener
button.addEventListener('click', function(e) {
e.preventDefault();
e.stopPropagation();
// Position dropdown based on available space
const buttonRect = button.getBoundingClientRect();
const spaceRight = window.innerWidth - buttonRect.right;
const spaceLeft = buttonRect.left;
// Check if there's more space on the left or right side
if (spaceLeft > spaceRight) {
menu.classList.add('right-0');
menu.classList.remove('left-0');
} else {
menu.classList.add('left-0');
menu.classList.remove('right-0');
}
// Toggle visibility
menu.classList.toggle('hidden');
console.log(`Toggled dropdown for provider ${providerId}`);
});
console.log(`Initialized dropdown for provider ${providerId}`);
} else {
console.error(`Could not find dropdown elements for provider ${providerId}`);
}
});
// Close dropdowns when clicking elsewhere
document.addEventListener('click', function(e) {
dropdownButtons.forEach(button => {
const providerId = button.getAttribute('data-provider-id');
const menu = document.getElementById(`auth-dropdown-menu-${providerId}`);
if (menu && !button.contains(e.target) && !menu.contains(e.target)) {
menu.classList.add('hidden');
}
});
});
}
// Initialize dropdowns when DOM is loaded
document.addEventListener('DOMContentLoaded', function() {
// Check for status messages based on URL parameters
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.get('status') === 'gdrive_auth_success') {
showToast("Google Drive authentication completed successfully", 'success');
}
// Initialize all authentication dropdowns
initAllAuthDropdowns();
});
</script>
}
}
// Partial for just the providers-list div
// Usage: @StorageProviders_ProvidersList(providers)
templ StorageProviders_ProvidersList(providers []db.StorageProvider) {
<div id="providers-list" class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 overflow-hidden">
<ul class="divide-y divide-gray-200 dark:divide-gray-700">
for _, provider := range providers {
<li>
<div class="block hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
<div class="px-4 py-4 sm:px-6">
<div class="flex items-center justify-between">
<div class="flex items-center">
<p class="text-sm font-medium text-blue-600 dark:text-blue-400 truncate">
{ provider.Name }
</p>
<span class="ml-2 bg-blue-100 text-blue-800 text-xs font-medium mr-2 px-2.5 py-0.5 rounded-full dark:bg-blue-900 dark:text-blue-300">
{ string(provider.Type) }
</span>
</div>
<div class="ml-2 flex-shrink-0 flex space-x-2">
<!-- Test Connection Button -->
<button
type="button"
hx-post={ fmt.Sprintf("/storage-providers/%d/test", provider.ID) }
hx-swap="none"
data-provider-id={ fmt.Sprint(provider.ID) }
data-provider-name={ provider.Name }
onclick="window.testProvider(this)"
class="test-provider-btn text-blue-700 bg-blue-100 hover:bg-blue-200 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-3 py-1.5 text-center inline-flex items-center dark:bg-blue-700 dark:text-blue-300 dark:hover:bg-blue-600 dark:focus:ring-blue-800">
<i class="fas fa-plug w-3.5 h-3.5 mr-1.5"></i>
Test
</button>
<!-- Duplicate Button -->
<button
type="button"
hx-post={ fmt.Sprintf("/storage-providers/%d/duplicate", provider.ID) }
hx-swap="outerHTML"
hx-target="#providers-list"
data-provider-id={ fmt.Sprint(provider.ID) }
data-provider-name={ provider.Name }
class="text-indigo-700 bg-indigo-100 hover:bg-indigo-200 focus:ring-4 focus:outline-none focus:ring-indigo-300 font-medium rounded-lg text-sm px-3 py-1.5 text-center inline-flex items-center dark:bg-indigo-700 dark:text-indigo-300 dark:hover:bg-indigo-600 dark:focus:ring-indigo-800">
<i class="fas fa-clone w-3.5 h-3.5 mr-1.5"></i>
Duplicate
</button>
<!-- Edit Button -->
<a href={ templ.SafeURL(fmt.Sprintf("/storage-providers/%d", provider.ID)) } class="text-gray-700 bg-gray-100 hover:bg-gray-200 focus:ring-4 focus:outline-none focus:ring-gray-300 font-medium rounded-lg text-sm px-3 py-1.5 text-center inline-flex items-center dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600 dark:focus:ring-gray-700">
<i class="fas fa-edit w-3.5 h-3.5 mr-1.5"></i>
Edit
</a>
<!-- Delete Button -->
@StorageProviderDialog(
fmt.Sprintf("delete-provider-dialog-%d", provider.ID),
"Delete Provider",
fmt.Sprintf("Are you sure you want to delete the provider '%s'? This cannot be undone.", provider.Name),
"text-white bg-red-600 hover:bg-red-800 focus:ring-4 focus:outline-none focus:ring-red-300 dark:focus:ring-red-800 font-medium rounded-lg text-sm inline-flex items-center px-5 py-2.5 text-center",
"Delete",
"delete",
provider.ID,
provider.Name,
)
<button
type="button"
onclick={ showProviderModal(fmt.Sprintf("delete-provider-dialog-%d", provider.ID)) }
class="text-white bg-red-700 hover:bg-red-800 focus:ring-4 focus:outline-none focus:ring-red-300 font-medium rounded-lg text-sm px-3 py-1.5 text-center inline-flex items-center dark:bg-red-600 dark:hover:bg-red-700 dark:focus:ring-red-800">
<i class="fas fa-trash-alt w-3.5 h-3.5 mr-1.5"></i>
Delete
</button>
</div>
</div>
<div class="mt-3 sm:flex sm:justify-between">
<div class="sm:flex flex-col md:flex-row gap-2 md:gap-6">
<!-- Show different details based on provider type -->
if provider.Type == "local" {
<p class="flex items-center text-sm text-gray-500 dark:text-gray-400">
<i class="fas fa-folder w-4 h-4 mr-1.5 text-gray-400 dark:text-gray-500"></i>
Path: { provider.Host }
</p>
} else if provider.Type == "s3" || provider.Type == "wasabi" || provider.Type == "minio" {
<p class="flex items-center text-sm text-gray-500 dark:text-gray-400">
<i class="fas fa-server w-4 h-4 mr-1.5 text-gray-400 dark:text-gray-500"></i>
Endpoint: { provider.Host }
</p>
<p class="mt-2 md:mt-0 flex items-center text-sm text-gray-500 dark:text-gray-400">
<i class="fas fa-box w-4 h-4 mr-1.5 text-gray-400 dark:text-gray-500"></i>
Bucket: { provider.Bucket }
</p>
if provider.Region != "" {
<p class="mt-2 md:mt-0 flex items-center text-sm text-gray-500 dark:text-gray-400">
<i class="fas fa-globe w-4 h-4 mr-1.5 text-gray-400 dark:text-gray-500"></i>
Region: { provider.Region }
</p>
}
} else if provider.Type == "drive" || provider.Type == "gphotos" {
<p class="flex items-center text-sm text-gray-500 dark:text-gray-400">
<i class="fab fa-google-drive w-4 h-4 mr-1.5 text-gray-400 dark:text-gray-500"></i>
Google
if provider.Type == "drive" {
Drive
} else {
Photos
}
if provider.DriveID != "" {
: { provider.DriveID }
}
</p>
if provider.Authenticated != nil && *provider.Authenticated {
<span class="mt-2 md:mt-0 bg-green-100 text-green-800 text-xs font-medium mr-2 px-2.5 py-0.5 rounded-full dark:bg-green-900 dark:text-green-300">
<i class="fas fa-check-circle w-3 h-3 mr-1 inline"></i>
Authenticated
</span>
} else {
<div class="flex flex-col md:flex-row items-start md:items-center mt-2 md:mt-0">
<!-- Google Authentication Dropdown -->
<div class="relative inline-block text-left mt-2 md:mt-0">
<button
id={ fmt.Sprintf("auth-dropdown-button-%d", provider.ID) }
data-provider-id={ fmt.Sprintf("%d", provider.ID) }
type="button"
class="text-yellow-700 bg-yellow-100 hover:bg-yellow-200 focus:ring-4 focus:outline-none focus:ring-yellow-300 font-medium rounded-lg text-sm px-3 py-1.5 text-center inline-flex items-center dark:bg-yellow-900 dark:text-yellow-300 dark:hover:bg-yellow-800 dark:focus:ring-yellow-800"
aria-expanded="false"
aria-haspopup="true">
<i class="fas fa-key w-3.5 h-3.5 mr-1.5"></i>
Authenticate with Google
<i class="fas fa-chevron-down w-3.5 h-3.5 ml-1.5"></i>
</button>
<div id={ fmt.Sprintf("auth-dropdown-menu-%d", provider.ID) } class="origin-top-right absolute left-0 mt-2 w-56 rounded-md shadow-lg bg-white dark:bg-gray-700 ring-1 ring-black ring-opacity-5 focus:outline-none z-50 hidden" style="max-height: 200px; overflow-y: auto;" role="menu" aria-orientation="vertical" aria-labelledby={ fmt.Sprintf("auth-dropdown-button-%d", provider.ID) }>
<div class="py-1" role="none">
<a href={ templ.SafeURL(fmt.Sprintf("/storage-providers/%d/gdrive-auth", provider.ID)) } class="text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 group flex items-center px-4 py-2 text-sm" role="menuitem">
<i class="fas fa-globe w-4 h-4 mr-3 text-gray-500 dark:text-gray-400"></i>
Standard Authentication
</a>
<a href={ templ.SafeURL(fmt.Sprintf("/storage-providers/%d/gdrive-headless-auth", provider.ID)) } class="text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 group flex items-center px-4 py-2 text-sm" role="menuitem">
<i class="fas fa-terminal w-4 h-4 mr-3 text-gray-500 dark:text-gray-400"></i>
Headless Authentication
</a>
</div>
</div>
</div>
<!-- Hidden fallback links - only shown when JavaScript is disabled -->
<noscript>
<div class="flex flex-col text-xs text-gray-500 dark:text-gray-400 mt-1 ml-1">
<a href={ templ.SafeURL(fmt.Sprintf("/storage-providers/%d/gdrive-auth", provider.ID)) } class="hover:underline hover:text-blue-500">
Direct Standard Auth
</a>
<a href={ templ.SafeURL(fmt.Sprintf("/storage-providers/%d/gdrive-headless-auth", provider.ID)) } class="hover:underline hover:text-blue-500">
Direct Headless Auth
</a>
</div>
</noscript>
</div>
}
} else if provider.Type == "webdav" || provider.Type == "nextcloud" {
<p class="flex items-center text-sm text-gray-500 dark:text-gray-400">
<i class="fas fa-cloud w-4 h-4 mr-1.5 text-gray-400 dark:text-gray-500"></i>
Server: { provider.Host }
</p>
if provider.Username != "" {
<p class="mt-2 md:mt-0 flex items-center text-sm text-gray-500 dark:text-gray-400">
<i class="fas fa-user w-4 h-4 mr-1.5 text-gray-400 dark:text-gray-500"></i>
User: { provider.Username }
</p>
}
} else if provider.Type == "sftp" || provider.Type == "hetzner" {
<p class="flex items-center text-sm text-gray-500 dark:text-gray-400">
<i class="fas fa-server w-4 h-4 mr-1.5 text-gray-400 dark:text-gray-500"></i>
Host: { provider.Host }
if provider.Port > 0 {
:{ fmt.Sprint(provider.Port) }
}
</p>
if provider.Username != "" {
<p class="mt-2 md:mt-0 flex items-center text-sm text-gray-500 dark:text-gray-400">
<i class="fas fa-user w-4 h-4 mr-1.5 text-gray-400 dark:text-gray-500"></i>
User: { provider.Username }
</p>
}
} else if provider.Type == "b2" {
<p class="flex items-center text-sm text-gray-500 dark:text-gray-400">
<i class="fas fa-cloud w-4 h-4 mr-1.5 text-gray-400 dark:text-gray-500"></i>
Backblaze B2
</p>
if provider.Bucket != "" {
<p class="mt-2 md:mt-0 flex items-center text-sm text-gray-500 dark:text-gray-400">
<i class="fas fa-box w-4 h-4 mr-1.5 text-gray-400 dark:text-gray-500"></i>
Bucket: { provider.Bucket }
</p>
}
} else {
<!-- Default display for other provider types -->
<p class="flex items-center text-sm text-gray-500 dark:text-gray-400">
<i class="fas fa-server w-4 h-4 mr-1.5 text-gray-400 dark:text-gray-500"></i>
Host: { provider.Host }
if provider.Port > 0 {
:{ fmt.Sprint(provider.Port) }
}
</p>
}
</div>
<div class="mt-2 md:mt-0 flex items-center text-sm text-gray-500 dark:text-gray-400">
<i class="far fa-clock w-4 h-4 mr-1.5 text-gray-400 dark:text-gray-500"></i>
<p>
Updated: { provider.UpdatedAt.Format("2006-01-02 15:04:05") }
</p>
</div>
</div>
</div>
<!-- Authentication Notice (More Visible) -->
if (provider.Type == "drive" || provider.Type == "gphotos") && (provider.Authenticated == nil || !*provider.Authenticated) {
<div class="mt-3 flex items-center justify-between bg-yellow-50 dark:bg-yellow-900/30 rounded-lg p-3 border border-yellow-200 dark:border-yellow-800">
<div class="flex items-center">
<i class="fas fa-exclamation-triangle text-yellow-500 w-5 h-5 mr-2"></i>
<span class="text-sm text-yellow-700 dark:text-yellow-300">
Authentication required for Google
if provider.Type == "drive" {
Drive
} else {
Photos
}
</span>
</div>
<div class="flex gap-2">
<a href={ templ.SafeURL(fmt.Sprintf("/storage-providers/%d/gdrive-auth", provider.ID)) } class="text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 px-3 py-1.5 rounded-lg">
<i class="fas fa-globe w-3.5 h-3.5 mr-1.5"></i>
Standard Auth
</a>
<a href={ templ.SafeURL(fmt.Sprintf("/storage-providers/%d/gdrive-headless-auth", provider.ID)) } class="text-sm font-medium text-white bg-green-600 hover:bg-green-700 px-3 py-1.5 rounded-lg">
<i class="fas fa-terminal w-3.5 h-3.5 mr-1.5"></i>
Headless Auth
</a>
</div>
</div>
}
</div>
</li>
}
</ul>
</div>
}
// Dialog component for confirmation dialogs for storage providers
// Usage: @StorageProviderDialog(id, title, message, confirmClass, confirmText, action, providerID, providerName)
templ StorageProviderDialog(id string, title string, message string, confirmClass string, confirmText string, action string, providerID uint, providerName string) {
<div id={ id } tabindex="-1" aria-hidden="true" class="hidden fixed top-0 right-0 left-0 z-50 justify-center items-center w-full md:inset-0 h-[calc(100%-1rem)] max-h-full">
<!-- Backdrop -->
<div id={ fmt.Sprintf("%s-backdrop", id) } class="fixed inset-0 bg-gray-900/50 dark:bg-gray-900/80 backdrop-blur-sm"></div>
<!-- Modal content -->
<div class="relative p-4 w-full max-w-md max-h-full mx-auto">
<div class="relative bg-white rounded-lg shadow dark:bg-gray-700">
<div class="p-6 text-center">
<i class="fas fa-trash-alt text-red-400 text-3xl mb-4"></i>
<h3 class="mb-5 text-lg font-normal text-gray-500 dark:text-gray-400">{ message }</h3>
<button
type="button"
class={ confirmClass }
hx-delete={ fmt.Sprintf("/storage-providers/%d", providerID) }
hx-target="closest li"
hx-swap="delete"
data-provider-name={ providerName }
data-provider-id={ fmt.Sprint(providerID) }
id={ fmt.Sprintf("delete-provider-btn-%d", providerID) }
onclick={ triggerProviderDelete(id, providerID, providerName) }>
{ confirmText }
</button>
<button type="button" onclick={ closeProviderModal(id) } class="text-gray-500 bg-white hover:bg-gray-100 focus:ring-4 focus:outline-none focus:ring-gray-200 rounded-lg border border-gray-200 text-sm font-medium px-5 py-2.5 hover:text-gray-900 focus:z-10 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-500 dark:hover:text-white dark:hover:bg-gray-600 dark:focus:ring-gray-600">
Cancel
</button>
</div>
</div>
</div>
</div>
}
script closeProviderModal(id string) {
const modal = document.getElementById(id);
const backdrop = document.getElementById(id + '-backdrop');
if (modal) {
modal.classList.add('hidden');
modal.classList.remove('flex');
}
if (backdrop) {
backdrop.remove();
}
document.body.style.overflow = '';
}
script showProviderModal(id string) {
const modal = document.getElementById(id);
if (modal) {
modal.classList.remove('hidden');
modal.classList.add('flex');
document.body.style.overflow = 'hidden';
}
}
script triggerProviderDelete(dialogId string, providerID uint, providerName string) {
// Hide the dialog
document.getElementById(dialogId).classList.add("hidden");
document.getElementById(dialogId).classList.remove("flex");
// Store data for event handlers
window.lastDeletedProvider = {
id: providerID,
name: providerName
};
window.currentlyDeletingProvider = true;
}

Some files were not shown because too many files have changed in this diff Show More