mirror of
https://github.com/StarFleetCPTN/GoMFT.git
synced 2026-09-16 11:30:49 +02:00
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.
This commit is contained in:
+234
-268
@@ -16,6 +16,9 @@ type ConfigFormData struct {
|
||||
InitialCommand *db.RcloneCommand
|
||||
SelectedFlagsMap map[uint]bool
|
||||
SelectedFlagValues map[uint]string
|
||||
// Add source and destination providers
|
||||
SourceProviders []db.StorageProvider
|
||||
DestinationProviders []db.StorageProvider
|
||||
}
|
||||
|
||||
func getConfigFormTitle(isNew bool) string {
|
||||
@@ -92,6 +95,13 @@ func getInitialData(config *db.TransferConfig) string {
|
||||
useBuiltinAuthSource := true
|
||||
useBuiltinAuthDest := true
|
||||
|
||||
// Provider configuration
|
||||
useSourceProvider := false
|
||||
sourceProviderId := uint(0)
|
||||
|
||||
useDestinationProvider := false
|
||||
destinationProviderId := uint(0)
|
||||
|
||||
// If editing an existing config, populate with those values
|
||||
if config != nil {
|
||||
name = config.Name
|
||||
@@ -183,6 +193,17 @@ func getInitialData(config *db.TransferConfig) string {
|
||||
} else if destClientId != "" || destClientSecret != "" {
|
||||
useBuiltinAuthDest = false
|
||||
}
|
||||
|
||||
// Provider reference fields
|
||||
if config.IsUsingSourceProviderReference() {
|
||||
useSourceProvider = true
|
||||
sourceProviderId = *config.SourceProviderID
|
||||
}
|
||||
|
||||
if config.IsUsingDestinationProviderReference() {
|
||||
useDestinationProvider = true
|
||||
destinationProviderId = *config.DestinationProviderID
|
||||
}
|
||||
}
|
||||
|
||||
// Return the JSON-formatted string with all the data, add new path validation states
|
||||
@@ -212,6 +233,9 @@ func getInitialData(config *db.TransferConfig) string {
|
||||
sourceStartYear: %d,
|
||||
sourceIncludeArchived: %v,
|
||||
|
||||
useSourceProvider: %v,
|
||||
sourceProviderId: %d,
|
||||
|
||||
filePattern: '%s',
|
||||
outputPattern: '%s',
|
||||
|
||||
@@ -239,6 +263,9 @@ func getInitialData(config *db.TransferConfig) string {
|
||||
destStartYear: %d,
|
||||
destIncludeArchived: %v,
|
||||
|
||||
useDestinationProvider: %v,
|
||||
destinationProviderId: %d,
|
||||
|
||||
useBuiltinAuthSource: %v,
|
||||
useBuiltinAuthDest: %v,
|
||||
|
||||
@@ -347,11 +374,13 @@ func getInitialData(config *db.TransferConfig) string {
|
||||
sourceBucket, sourceRegion, sourceAccessKey, sourceSecretKey, sourceEndpoint, sourceShare, sourceDomain, sourcePassiveMode,
|
||||
sourceClientId, sourceClientSecret, sourceDriveId, sourceTeamDrive,
|
||||
sourceReadOnly, sourceStartYear, sourceIncludeArchived,
|
||||
useSourceProvider, sourceProviderId,
|
||||
filePattern, outputPattern,
|
||||
destinationType, destinationPath, destHost, destPort, destUser, destPassword, destKeyFile, destAuthType,
|
||||
destBucket, destRegion, destAccessKey, destSecretKey, destEndpoint, destShare, destDomain, destPassiveMode,
|
||||
destClientId, destClientSecret, destDriveId, destTeamDrive,
|
||||
destReadOnly, destStartYear, destIncludeArchived,
|
||||
useDestinationProvider, destinationProviderId,
|
||||
useBuiltinAuthSource, useBuiltinAuthDest,
|
||||
archivePath, archiveEnabled, deleteAfterTransfer, skipProcessedFiles, maxConcurrentTransfers, rcloneFlags,
|
||||
commandId, commandFlags)
|
||||
@@ -422,6 +451,10 @@ templ ConfigForm(ctx context.Context, data ConfigFormData) {
|
||||
maxConcurrentTransfers = 1;
|
||||
}
|
||||
|
||||
// Initialize provider selection states
|
||||
useSourceProvider = useSourceProvider || false;
|
||||
useDestinationProvider = useDestinationProvider || false;
|
||||
|
||||
// Initialize command requirements
|
||||
updateCommandRequirements();
|
||||
})"
|
||||
@@ -512,8 +545,9 @@ templ ConfigForm(ctx context.Context, data ConfigFormData) {
|
||||
</h3>
|
||||
|
||||
<!-- Source selection -->
|
||||
@common.SourceSelection()
|
||||
@common.SourceSelection(data.SourceProviders)
|
||||
|
||||
<!-- Test Source Connection button -->
|
||||
<div class="mt-4">
|
||||
<button type="button"
|
||||
class="text-white bg-green-600 hover:bg-green-700 focus:ring-4 focus:ring-green-300 font-medium rounded-lg text-sm px-4 py-2 text-center dark:bg-green-500 dark:hover:bg-green-600 dark:focus:ring-green-800"
|
||||
@@ -525,60 +559,102 @@ templ ConfigForm(ctx context.Context, data ConfigFormData) {
|
||||
<i class="fas fa-plug mr-1"></i> Test Source
|
||||
<span id="source-test-spinner" class="htmx-indicator ml-2"><i class="fas fa-spinner fa-spin"></i></span>
|
||||
</button>
|
||||
<!-- Removed target div, result shown via toast -->
|
||||
</div>
|
||||
|
||||
<!-- Source type specific forms -->
|
||||
<template x-if="sourceType === 'local'">
|
||||
@source.LocalSourceForm()
|
||||
<!-- Source type specific forms - only show when not using a provider -->
|
||||
<template x-if="!useSourceProvider">
|
||||
<div class="mt-4">
|
||||
<template x-if="sourceType === 'local'">
|
||||
@source.LocalSourceForm()
|
||||
</template>
|
||||
|
||||
<template x-if="sourceType === 'sftp'">
|
||||
@source.SFTPSourceForm()
|
||||
</template>
|
||||
|
||||
<template x-if="sourceType === 'ftp'">
|
||||
@source.FTPSourceForm()
|
||||
</template>
|
||||
|
||||
<template x-if="sourceType === 's3'">
|
||||
@source.S3SourceForm()
|
||||
</template>
|
||||
|
||||
<template x-if="sourceType === 'b2'">
|
||||
@source.B2SourceForm()
|
||||
</template>
|
||||
|
||||
<template x-if="sourceType === 'wasabi'">
|
||||
@source.WasabiSourceForm()
|
||||
</template>
|
||||
|
||||
<template x-if="sourceType === 'minio'">
|
||||
@source.MinIOSourceForm()
|
||||
</template>
|
||||
|
||||
<template x-if="sourceType === 'smb'">
|
||||
@source.SMBSourceForm()
|
||||
</template>
|
||||
|
||||
<template x-if="sourceType === 'webdav'">
|
||||
@source.WebDAVSourceForm()
|
||||
</template>
|
||||
|
||||
<template x-if="sourceType === 'nextcloud'">
|
||||
@source.NextCloudSourceForm()
|
||||
</template>
|
||||
|
||||
<template x-if="sourceType === 'gdrive'">
|
||||
@source.GoogleDriveSourceForm()
|
||||
</template>
|
||||
|
||||
<template x-if="sourceType === 'gphotos'">
|
||||
@source.GooglePhotosSourceForm()
|
||||
</template>
|
||||
|
||||
<template x-if="sourceType === 'hetzner'">
|
||||
@source.HetznerSourceForm()
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="sourceType === 'sftp'">
|
||||
@source.SFTPSourceForm()
|
||||
</template>
|
||||
|
||||
<template x-if="sourceType === 'ftp'">
|
||||
@source.FTPSourceForm()
|
||||
</template>
|
||||
|
||||
<template x-if="sourceType === 's3'">
|
||||
@source.S3SourceForm()
|
||||
</template>
|
||||
|
||||
<template x-if="sourceType === 'b2'">
|
||||
@source.B2SourceForm()
|
||||
<!-- Provider usage notice -->
|
||||
<template x-if="useSourceProvider">
|
||||
<div class="mt-4 p-4 bg-blue-50 border border-blue-100 rounded-lg dark:bg-blue-900/20 dark:border-blue-800">
|
||||
<div class="flex">
|
||||
<i class="fas fa-info-circle text-blue-500 dark:text-blue-400 mt-0.5 mr-2"></i>
|
||||
<div>
|
||||
<p class="text-sm text-blue-800 dark:text-blue-300">
|
||||
Using storage provider configuration. Customize source path options below if needed.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="sourceType === 'wasabi'">
|
||||
@source.WasabiSourceForm()
|
||||
</template>
|
||||
|
||||
<template x-if="sourceType === 'minio'">
|
||||
@source.MinIOSourceForm()
|
||||
</template>
|
||||
|
||||
<template x-if="sourceType === 'smb'">
|
||||
@source.SMBSourceForm()
|
||||
</template>
|
||||
|
||||
<template x-if="sourceType === 'webdav'">
|
||||
@source.WebDAVSourceForm()
|
||||
</template>
|
||||
|
||||
<template x-if="sourceType === 'nextcloud'">
|
||||
@source.NextCloudSourceForm()
|
||||
</template>
|
||||
|
||||
<template x-if="sourceType === 'gdrive'">
|
||||
@source.GoogleDriveSourceForm()
|
||||
</template>
|
||||
|
||||
<template x-if="sourceType === 'gphotos'">
|
||||
@source.GooglePhotosSourceForm()
|
||||
</template>
|
||||
|
||||
<template x-if="sourceType === 'hetzner'">
|
||||
@source.HetznerSourceForm()
|
||||
<!-- Always show source path field with provider or without provider -->
|
||||
<template x-if="useSourceProvider && sourceProviderId > 0">
|
||||
<div class="mt-4">
|
||||
<label for="source_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Source 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
|
||||
type="text"
|
||||
id="source_path"
|
||||
name="source_path"
|
||||
x-model="sourcePath"
|
||||
@blur="checkPath(sourcePath, '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 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/source"
|
||||
/>
|
||||
<template x-if="sourcePathValid === false">
|
||||
<p class="mt-2 text-sm text-red-600 dark:text-red-500" x-text="sourcePathError"></p>
|
||||
</template>
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Specify source path within the provider</p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
@@ -592,15 +668,16 @@ templ ConfigForm(ctx context.Context, data ConfigFormData) {
|
||||
@common.FilePatternFields()
|
||||
</div>
|
||||
|
||||
<!-- Destination Configuration Section (only shown if required) -->
|
||||
<!-- Destination Configuration Section -->
|
||||
<div x-show="requiresDestination" x-transition class="p-5 bg-white border border-gray-200 rounded-lg shadow-sm dark:bg-gray-800 dark:border-gray-700">
|
||||
<h3 class="mb-4 text-xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||
<i class="fas fa-download mr-2 text-blue-500 dark:text-blue-400"></i>Destination Configuration
|
||||
</h3>
|
||||
|
||||
<!-- Destination selection -->
|
||||
@common.DestinationSelection()
|
||||
@common.DestinationSelection(data.DestinationProviders)
|
||||
|
||||
<!-- Test Destination button -->
|
||||
<div class="mt-4">
|
||||
<button type="button"
|
||||
class="text-white bg-green-600 hover:bg-green-700 focus:ring-4 focus:ring-green-300 font-medium rounded-lg text-sm px-4 py-2 text-center dark:bg-green-500 dark:hover:bg-green-600 dark:focus:ring-green-800"
|
||||
@@ -612,60 +689,102 @@ templ ConfigForm(ctx context.Context, data ConfigFormData) {
|
||||
<i class="fas fa-plug mr-1"></i> Test Destination
|
||||
<span id="dest-test-spinner" class="htmx-indicator ml-2"><i class="fas fa-spinner fa-spin"></i></span>
|
||||
</button>
|
||||
<!-- Removed target div, result shown via toast -->
|
||||
</div>
|
||||
|
||||
<!-- Destination type specific forms -->
|
||||
<template x-if="destinationType === 'local'">
|
||||
@destination.LocalDestinationForm()
|
||||
</template>
|
||||
|
||||
<template x-if="destinationType === 'sftp'">
|
||||
@destination.SFTPDestinationForm()
|
||||
</template>
|
||||
<!-- Destination type specific forms - only show when not using a provider -->
|
||||
<template x-if="!useDestinationProvider">
|
||||
<div class="mt-4">
|
||||
<template x-if="destinationType === 'local'">
|
||||
@destination.LocalDestinationForm()
|
||||
</template>
|
||||
|
||||
<template x-if="destinationType === 'sftp'">
|
||||
@destination.SFTPDestinationForm()
|
||||
</template>
|
||||
|
||||
<template x-if="destinationType === 'ftp'">
|
||||
@destination.FTPDestinationForm()
|
||||
</template>
|
||||
|
||||
<template x-if="destinationType === 's3'">
|
||||
@destination.S3DestinationForm()
|
||||
</template>
|
||||
|
||||
<template x-if="destinationType === 'b2'">
|
||||
@destination.B2DestinationForm()
|
||||
</template>
|
||||
|
||||
<template x-if="destinationType === 'wasabi'">
|
||||
@destination.WasabiDestinationForm()
|
||||
</template>
|
||||
|
||||
<template x-if="destinationType === 'minio'">
|
||||
@destination.MinIODestinationForm()
|
||||
</template>
|
||||
|
||||
<template x-if="destinationType === 'smb'">
|
||||
@destination.SMBDestinationForm()
|
||||
</template>
|
||||
<template x-if="destinationType === 'ftp'">
|
||||
@destination.FTPDestinationForm()
|
||||
</template>
|
||||
|
||||
<template x-if="destinationType === 's3'">
|
||||
@destination.S3DestinationForm()
|
||||
</template>
|
||||
|
||||
<template x-if="destinationType === 'b2'">
|
||||
@destination.B2DestinationForm()
|
||||
</template>
|
||||
|
||||
<template x-if="destinationType === 'wasabi'">
|
||||
@destination.WasabiDestinationForm()
|
||||
</template>
|
||||
|
||||
<template x-if="destinationType === 'minio'">
|
||||
@destination.MinIODestinationForm()
|
||||
</template>
|
||||
|
||||
<template x-if="destinationType === 'smb'">
|
||||
@destination.SMBDestinationForm()
|
||||
</template>
|
||||
|
||||
<template x-if="destinationType === 'nextcloud'">
|
||||
@destination.NextCloudDestinationForm()
|
||||
</template>
|
||||
<template x-if="destinationType === 'nextcloud'">
|
||||
@destination.NextCloudDestinationForm()
|
||||
</template>
|
||||
|
||||
<template x-if="destinationType === 'webdav'">
|
||||
@destination.WebDAVDestinationForm()
|
||||
</template>
|
||||
<template x-if="destinationType === 'webdav'">
|
||||
@destination.WebDAVDestinationForm()
|
||||
</template>
|
||||
|
||||
<template x-if="destinationType === 'gdrive'">
|
||||
@destination.GoogleDriveDestinationForm()
|
||||
<template x-if="destinationType === 'gdrive'">
|
||||
@destination.GoogleDriveDestinationForm()
|
||||
</template>
|
||||
|
||||
<template x-if="destinationType === 'gphotos'">
|
||||
@destination.GooglePhotosDestinationForm()
|
||||
</template>
|
||||
|
||||
<template x-if="destinationType === 'hetzner'">
|
||||
@destination.HetznerDestinationForm()
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="destinationType === 'gphotos'">
|
||||
@destination.GooglePhotosDestinationForm()
|
||||
<!-- Provider usage notice -->
|
||||
<template x-if="useDestinationProvider">
|
||||
<div class="mt-4 p-4 bg-blue-50 border border-blue-100 rounded-lg dark:bg-blue-900/20 dark:border-blue-800">
|
||||
<div class="flex">
|
||||
<i class="fas fa-info-circle text-blue-500 dark:text-blue-400 mt-0.5 mr-2"></i>
|
||||
<div>
|
||||
<p class="text-sm text-blue-800 dark:text-blue-300">
|
||||
Using storage provider configuration. Customize destination path options below if needed.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="destinationType === 'hetzner'">
|
||||
@destination.HetznerDestinationForm()
|
||||
|
||||
<!-- Always show destination path field with provider or without provider -->
|
||||
<template x-if="useDestinationProvider && destinationProviderId > 0">
|
||||
<div class="mt-4">
|
||||
<label for="destination_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Destination 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
|
||||
type="text"
|
||||
id="destination_path"
|
||||
name="destination_path"
|
||||
x-model="destinationPath"
|
||||
@blur="checkPath(destinationPath, 'dest')"
|
||||
class="bg-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/destination"
|
||||
/>
|
||||
<template x-if="destPathValid === false">
|
||||
<p class="mt-2 text-sm text-red-600 dark:text-red-500" x-text="destPathError"></p>
|
||||
</template>
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Specify destination path within the provider</p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
@@ -747,191 +866,38 @@ templ formvalidation() {
|
||||
hasErrors = true;
|
||||
}
|
||||
|
||||
// Source path validation
|
||||
// Note: Most source and destination forms already have HTML5 validation
|
||||
// with the required attribute, but we do additional JS validation here
|
||||
// to provide a better user experience with a centralized error display
|
||||
const sourcePath = document.getElementById('source_path')?.value;
|
||||
if (!sourcePath || sourcePath.trim() === '') {
|
||||
errors.push('Source path is required');
|
||||
hasErrors = true;
|
||||
}
|
||||
// Get form data to check provider usage
|
||||
const useSourceProvider = document.getElementById('use_source_provider')?.checked || false;
|
||||
const useDestinationProvider = document.getElementById('use_destination_provider')?.checked || false;
|
||||
|
||||
// Get source type
|
||||
const sourceType = document.querySelector('input[name="source_type"]').value;
|
||||
|
||||
// For remote source, validate credentials
|
||||
if (sourceType !== 'local') {
|
||||
// Host validation for remote sources
|
||||
const sourceHost = document.getElementById('source_host')?.value;
|
||||
if (!sourceHost || sourceHost.trim() === '') {
|
||||
errors.push('Source host is required for remote connections');
|
||||
// Validate source provider selection if using provider
|
||||
if (useSourceProvider) {
|
||||
const sourceProviderId = document.getElementById('source_provider_id').value;
|
||||
if (!sourceProviderId || sourceProviderId === '') {
|
||||
errors.push('Source provider selection is required');
|
||||
hasErrors = true;
|
||||
}
|
||||
|
||||
// Port validation
|
||||
const sourcePort = document.getElementById('source_port')?.value;
|
||||
if (!sourcePort || isNaN(parseInt(sourcePort)) || parseInt(sourcePort) <= 0 || parseInt(sourcePort) > 65535) {
|
||||
errors.push('Source port must be a valid port number (1-65535)');
|
||||
}
|
||||
|
||||
// Validate destination provider selection if using provider
|
||||
if (useDestinationProvider) {
|
||||
const destProviderId = document.getElementById('destination_provider_id').value;
|
||||
if (!destProviderId || destProviderId === '') {
|
||||
errors.push('Destination provider selection is required');
|
||||
hasErrors = true;
|
||||
}
|
||||
|
||||
// Username validation for SFTP/FTP/etc.
|
||||
if (['sftp', 'ftp', 'hetzner'].includes(sourceType)) {
|
||||
const sourceUser = document.getElementById('source_username')?.value;
|
||||
if (!sourceUser || sourceUser.trim() === '') {
|
||||
errors.push('Source username is required');
|
||||
hasErrors = true;
|
||||
}
|
||||
|
||||
// Check auth type
|
||||
const sourceAuthType = document.querySelector('input[name="source_auth_type"]:checked')?.value;
|
||||
|
||||
// Password validation if using password auth
|
||||
if (sourceAuthType === 'password') {
|
||||
const sourcePassword = document.getElementById('source_password')?.value;
|
||||
if (!sourcePassword || sourcePassword.trim() === '') {
|
||||
errors.push('Source password is required when using password authentication');
|
||||
hasErrors = true;
|
||||
}
|
||||
} else if (sourceAuthType === 'key') {
|
||||
// Key file validation if using key auth
|
||||
const sourceKeyFile = document.getElementById('source_key_file')?.value;
|
||||
if (!sourceKeyFile || sourceKeyFile.trim() === '') {
|
||||
errors.push('Source key file path is required when using key authentication');
|
||||
hasErrors = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// S3/B2/Wasabi specific validations
|
||||
if (['s3', 'b2', 'wasabi', 'minio'].includes(sourceType)) {
|
||||
const sourceAccessKey = document.getElementById('source_access_key')?.value;
|
||||
const sourceSecretKey = document.getElementById('source_secret_key')?.value;
|
||||
|
||||
if (!sourceAccessKey || sourceAccessKey.trim() === '') {
|
||||
errors.push('Source access key is required');
|
||||
hasErrors = true;
|
||||
}
|
||||
|
||||
if (!sourceSecretKey || sourceSecretKey.trim() === '') {
|
||||
errors.push('Source secret key is required');
|
||||
hasErrors = true;
|
||||
}
|
||||
|
||||
// Bucket validation
|
||||
const sourceBucket = document.getElementById('source_bucket')?.value;
|
||||
if (!sourceBucket || sourceBucket.trim() === '') {
|
||||
errors.push('Source bucket is required');
|
||||
hasErrors = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate destination if it's a required field
|
||||
const requiresDestination = document.querySelector('form').__x.$data.requiresDestination;
|
||||
if (requiresDestination) {
|
||||
// Destination path validation
|
||||
const destPath = document.getElementById('destination_path')?.value;
|
||||
if (!destPath || destPath.trim() === '') {
|
||||
errors.push('Destination path is required');
|
||||
hasErrors = true;
|
||||
}
|
||||
|
||||
// Get destination type
|
||||
const destType = document.querySelector('input[name="destination_type"]').value;
|
||||
|
||||
// For remote destination, validate credentials
|
||||
if (destType !== 'local') {
|
||||
// Host validation for remote destinations
|
||||
const destHost = document.getElementById('destination_host')?.value;
|
||||
if (!destHost || destHost.trim() === '') {
|
||||
errors.push('Destination host is required for remote connections');
|
||||
hasErrors = true;
|
||||
}
|
||||
|
||||
// Port validation
|
||||
const destPort = document.getElementById('destination_port')?.value;
|
||||
if (!destPort || isNaN(parseInt(destPort)) || parseInt(destPort) <= 0 || parseInt(destPort) > 65535) {
|
||||
errors.push('Destination port must be a valid port number (1-65535)');
|
||||
hasErrors = true;
|
||||
}
|
||||
|
||||
// Username validation for SFTP/FTP/etc.
|
||||
if (['sftp', 'ftp', 'hetzner'].includes(destType)) {
|
||||
const destUser = document.getElementById('destination_username')?.value;
|
||||
if (!destUser || destUser.trim() === '') {
|
||||
errors.push('Destination username is required');
|
||||
hasErrors = true;
|
||||
}
|
||||
|
||||
// Check auth type
|
||||
const destAuthType = document.querySelector('input[name="destination_auth_type"]:checked')?.value;
|
||||
|
||||
// Password validation if using password auth
|
||||
if (destAuthType === 'password') {
|
||||
const destPassword = document.getElementById('destination_password')?.value;
|
||||
if (!destPassword || destPassword.trim() === '') {
|
||||
errors.push('Destination password is required when using password authentication');
|
||||
hasErrors = true;
|
||||
}
|
||||
} else if (destAuthType === 'key') {
|
||||
// Key file validation if using key auth
|
||||
const destKeyFile = document.getElementById('destination_key_file')?.value;
|
||||
if (!destKeyFile || destKeyFile.trim() === '') {
|
||||
errors.push('Destination key file path is required when using key authentication');
|
||||
hasErrors = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// S3/B2/Wasabi specific validations
|
||||
if (['s3', 'b2', 'wasabi', 'minio'].includes(destType)) {
|
||||
const destAccessKey = document.getElementById('destination_access_key')?.value;
|
||||
const destSecretKey = document.getElementById('destination_secret_key')?.value;
|
||||
|
||||
if (!destAccessKey || destAccessKey.trim() === '') {
|
||||
errors.push('Destination access key is required');
|
||||
hasErrors = true;
|
||||
}
|
||||
|
||||
if (!destSecretKey || destSecretKey.trim() === '') {
|
||||
errors.push('Destination secret key is required');
|
||||
hasErrors = true;
|
||||
}
|
||||
|
||||
// Bucket validation
|
||||
const destBucket = document.getElementById('destination_bucket')?.value;
|
||||
if (!destBucket || destBucket.trim() === '') {
|
||||
errors.push('Destination bucket is required');
|
||||
hasErrors = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for concurrent transfers
|
||||
const maxTransfers = document.getElementById('max_concurrent_transfers')?.value;
|
||||
if (maxTransfers && (isNaN(parseInt(maxTransfers)) || parseInt(maxTransfers) < 1)) {
|
||||
errors.push('Maximum concurrent transfers must be at least 1');
|
||||
hasErrors = true;
|
||||
}
|
||||
|
||||
// If errors exist, prevent form submission and display errors
|
||||
// Display errors if any
|
||||
if (hasErrors) {
|
||||
evt.preventDefault();
|
||||
|
||||
// Display errors
|
||||
errors.forEach(error => {
|
||||
formErrors.classList.remove('hidden');
|
||||
errors.forEach(function(error) {
|
||||
const li = document.createElement('li');
|
||||
li.textContent = error;
|
||||
errorList.appendChild(li);
|
||||
});
|
||||
|
||||
formErrors.classList.remove('hidden');
|
||||
|
||||
// Scroll to the errors
|
||||
formErrors.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
evt.preventDefault(); // Prevent form submission
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -161,6 +161,10 @@ templ LayoutWithContext(title string, ctx context.Context) {
|
||||
<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
|
||||
@@ -265,6 +269,10 @@ templ LayoutWithContext(title string, ctx context.Context) {
|
||||
<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
|
||||
|
||||
@@ -182,57 +182,167 @@ templ RcloneCommandOptions(currentCommandID uint) { // Accept currentCommandID
|
||||
</div>
|
||||
}
|
||||
|
||||
templ SourceSelection() {
|
||||
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>
|
||||
<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>
|
||||
|
||||
<!-- 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>
|
||||
<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="gdrive">Google Drive (BETA)</option>
|
||||
<option value="gphotos">Google Photos (BETA)</option>
|
||||
<option value="hetzner">Hetzner Storage Box</option>
|
||||
</select>
|
||||
|
||||
<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="gdrive">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() {
|
||||
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>
|
||||
<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>
|
||||
|
||||
<!-- 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>
|
||||
<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="gdrive">Google Drive (BETA)</option>
|
||||
<option value="gphotos">Google Photos (BETA)</option>
|
||||
<option value="hetzner">Hetzner Storage Box</option>
|
||||
</select>
|
||||
|
||||
<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="gdrive">Google Drive (BETA)</option>
|
||||
<option value="gphotos">Google Photos (BETA)</option>
|
||||
<option value="hetzner">Hetzner Storage Box</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,404 @@
|
||||
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>
|
||||
<option value="sftp"
|
||||
if data.Provider.Type == db.ProviderTypeSFTP {
|
||||
selected="selected"
|
||||
}
|
||||
>SFTP</option>
|
||||
<option value="s3"
|
||||
if data.Provider.Type == db.ProviderTypeS3 {
|
||||
selected="selected"
|
||||
}
|
||||
>S3</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>
|
||||
<option value="onedrive"
|
||||
if data.Provider.Type == db.ProviderTypeOneDrive {
|
||||
selected="selected"
|
||||
}
|
||||
>OneDrive</option>
|
||||
<option value="gdrive"
|
||||
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>
|
||||
<option value="local"
|
||||
if data.Provider.Type == db.ProviderTypeLocal {
|
||||
selected="selected"
|
||||
}
|
||||
>Local Filesystem</option>
|
||||
<option value="hetzner"
|
||||
if data.Provider.Type == db.ProviderTypeHetzner {
|
||||
selected="selected"
|
||||
}
|
||||
>Hetzner Storage Box</option>
|
||||
</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="sftp.example.com" />
|
||||
</div>
|
||||
|
||||
<!-- Port -->
|
||||
<div 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)</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="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="••••••••" />
|
||||
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="/path/to/key.pem" />
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">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="WORKGROUP" />
|
||||
</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 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="s3.amazonaws.com" />
|
||||
</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 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="us-east-1" />
|
||||
</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="my-bucket" />
|
||||
</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="AKIAXXXXXXXXXXXXXXXX" />
|
||||
</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="••••••••••••••••••••••••••••••••" />
|
||||
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="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="••••••••••••••••••••••••••••••••" />
|
||||
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="For shared/team drives" />
|
||||
<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="/path/to/directory" />
|
||||
<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>
|
||||
}
|
||||
|
||||
// JavaScript helper for toggling provider fields
|
||||
templ providerFormScript() {
|
||||
<script>
|
||||
// Set current active fields on page load
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
toggleProviderFields();
|
||||
});
|
||||
|
||||
function toggleProviderFields() {
|
||||
// Hide all provider fields first
|
||||
const providerFieldsets = document.querySelectorAll('.provider-fields');
|
||||
providerFieldsets.forEach(fieldset => {
|
||||
fieldset.classList.add('hidden');
|
||||
});
|
||||
|
||||
// Show the appropriate fields based on the selected provider type
|
||||
const providerType = document.getElementById('type').value;
|
||||
console.log("Provider type selected:", providerType);
|
||||
|
||||
// SFTP/FTP/SMB fields
|
||||
if (['sftp', 'ftp', 'smb'].includes(providerType)) {
|
||||
document.getElementById('sftp-ftp-fields').classList.remove('hidden');
|
||||
|
||||
// SFTP-specific fields
|
||||
if (providerType === 'sftp') {
|
||||
document.getElementById('key-file-field').classList.remove('hidden');
|
||||
}
|
||||
|
||||
// SMB-specific fields
|
||||
if (providerType === 'smb') {
|
||||
document.getElementById('domain-field').classList.remove('hidden');
|
||||
}
|
||||
|
||||
// FTP-specific fields
|
||||
if (providerType === 'ftp') {
|
||||
document.getElementById('passive-mode-field').classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
// S3 fields
|
||||
if (providerType === 's3' || providerType === 'hetzner') {
|
||||
document.getElementById('s3-fields').classList.remove('hidden');
|
||||
}
|
||||
|
||||
// Cloud storage fields
|
||||
if (['onedrive', 'gdrive', 'gphotos'].includes(providerType)) {
|
||||
document.getElementById('cloud-fields').classList.remove('hidden');
|
||||
|
||||
// Google Drive specific fields
|
||||
if (providerType === 'gdrive') {
|
||||
document.getElementById('drive-id-field').classList.remove('hidden');
|
||||
document.getElementById('team-drive-field').classList.remove('hidden');
|
||||
}
|
||||
|
||||
// Google Photos specific fields
|
||||
if (providerType === 'gphotos') {
|
||||
document.getElementById('readonly-field').classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
// Local filesystem fields
|
||||
if (providerType === '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,568 @@
|
||||
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');
|
||||
}
|
||||
|
||||
// Keep other status-based toasts if needed
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
if (urlParams.get('status') === 'created') {
|
||||
showToast('Provider created successfully', 'success');
|
||||
}
|
||||
if (urlParams.get('status') === 'updated') {
|
||||
showToast('Provider updated successfully', 'success');
|
||||
}
|
||||
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">
|
||||
<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>
|
||||
<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 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>
|
||||
</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>
|
||||
}
|
||||
}
|
||||
|
||||
// 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 == "google_drive" || provider.Type == "google_photo" {
|
||||
<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 == "google_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">
|
||||
Authenticated
|
||||
</span>
|
||||
} else {
|
||||
<span class="mt-2 md:mt-0 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">
|
||||
Not Authenticated
|
||||
</span>
|
||||
}
|
||||
} 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>
|
||||
</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;
|
||||
}
|
||||
Reference in New Issue
Block a user