Files
GoMFT/components/config_form.templ
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

973 lines
37 KiB
Templ

package components
import (
"fmt"
"github.com/starfleetcptn/gomft/internal/db"
"context"
"github.com/starfleetcptn/gomft/components/providers/source"
"github.com/starfleetcptn/gomft/components/providers/destination"
"github.com/starfleetcptn/gomft/components/providers/common"
)
type ConfigFormData struct {
Config *db.TransferConfig
IsNew bool
// Fields for pre-rendering flags on edit
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 {
if isNew {
return "New Configuration"
}
return "Edit Configuration"
}
func getInitialData(config *db.TransferConfig) string {
// Default values for a new configuration
name := ""
sourceType := "local"
sourcePath := ""
sourceHost := ""
sourcePort := 0 // Initialize to 0 to trigger default setting
sourceUser := ""
sourcePassword := ""
sourceKeyFile := ""
sourceAuthType := "password"
sourceBucket := ""
sourceRegion := ""
sourceAccessKey := ""
sourceSecretKey := ""
sourceEndpoint := ""
sourceShare := ""
sourceDomain := ""
sourcePassiveMode := false
sourceClientId := ""
sourceClientSecret := ""
sourceDriveId := ""
sourceTeamDrive := ""
// Google Photos source fields
sourceReadOnly := false
sourceStartYear := 2025
sourceIncludeArchived := false
filePattern := ""
outputPattern := "${filename}"
destinationType := "local"
destinationPath := ""
destHost := ""
destPort := 0 // Initialize to 0 to trigger default setting
destUser := ""
destPassword := ""
destKeyFile := ""
destAuthType := "password"
destBucket := ""
destRegion := ""
destAccessKey := ""
destSecretKey := ""
destEndpoint := ""
destShare := ""
destDomain := ""
destPassiveMode := false
destClientId := ""
destClientSecret := ""
destDriveId := ""
destTeamDrive := ""
// Google Photos destination fields
destReadOnly := false
destStartYear := 2025
destIncludeArchived := false
archivePath := ""
archiveEnabled := false
deleteAfterTransfer := false
skipProcessedFiles := true
maxConcurrentTransfers := 4
rcloneFlags := ""
commandId := uint(1) // Default to 'copy' command
commandFlags := ""
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
sourceType = config.SourceType
sourcePath = config.SourcePath
sourceHost = config.SourceHost
sourcePort = config.SourcePort
sourceUser = config.SourceUser
sourcePassword = config.SourcePassword
sourceKeyFile = config.SourceKeyFile
if sourceKeyFile != "" && sourcePassword == "" {
sourceAuthType = "key"
}
sourceBucket = config.SourceBucket
sourceRegion = config.SourceRegion
sourceAccessKey = config.SourceAccessKey
sourceSecretKey = config.SourceSecretKey
sourceEndpoint = config.SourceEndpoint
sourceShare = config.SourceShare
sourceDomain = config.SourceDomain
sourcePassiveMode = config.GetSourcePassiveMode()
sourceClientId = config.SourceClientID
sourceClientSecret = config.SourceClientSecret
sourceDriveId = config.SourceDriveID
sourceTeamDrive = config.SourceTeamDrive
// Google Photos source fields
if config.SourceReadOnly != nil {
sourceReadOnly = *config.SourceReadOnly
}
// sourceStartYear = config.SourceStartYear
if config.SourceIncludeArchived != nil {
sourceIncludeArchived = *config.SourceIncludeArchived
}
filePattern = config.FilePattern
outputPattern = config.OutputPattern
destinationType = config.DestinationType
destinationPath = config.DestinationPath
destHost = config.DestHost
destPort = config.DestPort
destUser = config.DestUser
destPassword = config.DestPassword
destKeyFile = config.DestKeyFile
if destKeyFile != "" && destPassword == "" {
destAuthType = "key"
}
destBucket = config.DestBucket
destRegion = config.DestRegion
destAccessKey = config.DestAccessKey
destSecretKey = config.DestSecretKey
destEndpoint = config.DestEndpoint
destShare = config.DestShare
destDomain = config.DestDomain
destPassiveMode = config.GetDestPassiveMode()
destClientId = config.DestClientID
destClientSecret = config.DestClientSecret
destDriveId = config.DestDriveID
destTeamDrive = config.DestTeamDrive
// Google Photos destination fields
if config.DestReadOnly != nil {
destReadOnly = *config.DestReadOnly
}
// destStartYear = config.DestStartYear
if config.DestIncludeArchived != nil {
destIncludeArchived = *config.DestIncludeArchived
}
archivePath = config.ArchivePath
archiveEnabled = config.GetArchiveEnabled()
deleteAfterTransfer = config.GetDeleteAfterTransfer()
skipProcessedFiles = config.GetSkipProcessedFiles()
maxConcurrentTransfers = config.MaxConcurrentTransfers
if maxConcurrentTransfers <= 0 {
maxConcurrentTransfers = 1 // Ensure at least 1 concurrent transfer
}
rcloneFlags = config.RcloneFlags
commandId = config.CommandID
commandFlags = config.CommandFlags
if config.UseBuiltinAuthSource != nil {
useBuiltinAuthSource = *config.UseBuiltinAuthSource
} else if sourceClientId != "" || sourceClientSecret != "" {
useBuiltinAuthSource = false
}
if config.UseBuiltinAuthDest != nil {
useBuiltinAuthDest = *config.UseBuiltinAuthDest
} 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
return fmt.Sprintf(`{
name: '%s',
sourceType: '%s',
sourcePath: '%s',
sourceHost: '%s',
sourcePort: %d,
sourceUser: '%s',
sourcePassword: '%s',
sourceKeyFile: '%s',
sourceAuthType: '%s',
sourceBucket: '%s',
sourceRegion: '%s',
sourceAccessKey: '%s',
sourceSecretKey: '%s',
sourceEndpoint: '%s',
sourceShare: '%s',
sourceDomain: '%s',
sourcePassiveMode: %v,
sourceClientId: '%s',
sourceClientSecret: '%s',
sourceDriveId: '%s',
sourceTeamDrive: '%s',
sourceReadOnly: %v,
sourceStartYear: %d,
sourceIncludeArchived: %v,
useSourceProvider: %v,
sourceProviderId: %d,
filePattern: '%s',
outputPattern: '%s',
destinationType: '%s',
destinationPath: '%s',
destHost: '%s',
destPort: %d,
destUser: '%s',
destPassword: '%s',
destKeyFile: '%s',
destAuthType: '%s',
destBucket: '%s',
destRegion: '%s',
destAccessKey: '%s',
destSecretKey: '%s',
destEndpoint: '%s',
destShare: '%s',
destDomain: '%s',
destPassiveMode: %v,
destClientId: '%s',
destClientSecret: '%s',
destDriveId: '%s',
destTeamDrive: '%s',
destReadOnly: %v,
destStartYear: %d,
destIncludeArchived: %v,
useDestinationProvider: %v,
destinationProviderId: %d,
useBuiltinAuthSource: %v,
useBuiltinAuthDest: %v,
archivePath: '%s',
archiveEnabled: %v,
deleteAfterTransfer: %v,
skipProcessedFiles: %v,
maxConcurrentTransfers: %d,
rcloneFlags: '%s',
commandId: %d,
commandFlags: '%s',
// Path validation states
sourcePathValid: null,
sourcePathError: '',
destPathValid: null,
destPathError: '',
// Command configuration
requiresDestination: true,
// Methods for path validation
checkPath(path, type) {
if (!path) {
this[type + 'PathValid'] = false;
this[type + 'PathError'] = 'Path cannot be empty';
return;
}
fetch('/check-path?path=' + encodeURIComponent(path))
.then(response => response.json())
.then(data => {
this[type + 'PathValid'] = data.valid;
this[type + 'PathError'] = data.error || '';
})
.catch(error => {
this[type + 'PathValid'] = false;
this[type + 'PathError'] = 'Error checking path: ' + error.message;
});
},
// Method to check if destination is required based on command type
updateCommandRequirements() {
// List of commands that don't require destination
const listingCommands = ['ls', 'lsd', 'lsl', 'lsf', 'lsjson', 'listremotes'];
const infoCommands = ['md5sum', 'sha1sum', 'size', 'version'];
const dirCommands = ['mkdir', 'rmdir', 'rmdirs'];
const destructiveCommands = ['delete', 'purge'];
const specialSinglePathCommands = ['obscure'];
// Get the command name from the command ID
// This will need coordination with your backend to ensure the IDs match the commands
let commandName = '';
switch(parseInt(this.commandId)) {
// Correct mapping based on internal/db/migrations/009_add_rclone_tables.go
case 1: commandName = 'copy'; break;
case 2: commandName = 'sync'; break;
case 3: commandName = 'bisync'; break;
case 4: commandName = 'move'; break;
case 5: commandName = 'delete'; break;
case 6: commandName = 'purge'; break;
case 7: commandName = 'mkdir'; break;
case 8: commandName = 'rmdir'; break;
case 9: commandName = 'rmdirs'; break;
case 10: commandName = 'check'; break;
case 11: commandName = 'ls'; break;
case 12: commandName = 'lsd'; break;
case 13: commandName = 'lsl'; break;
case 14: commandName = 'lsf'; break;
case 15: commandName = 'lsjson'; break;
case 16: commandName = 'md5sum'; break;
case 17: commandName = 'sha1sum'; break;
case 18: commandName = 'size'; break;
case 19: commandName = 'version'; break;
case 20: commandName = 'cleanup'; break;
case 21: commandName = 'dedupe'; break;
case 22: commandName = 'copyto'; break;
case 23: commandName = 'moveto'; break;
case 24: commandName = 'listremotes'; break;
case 25: commandName = 'obscure'; break;
case 26: commandName = 'cryptcheck'; break;
default: commandName = 'copy'; // Default to copy if ID is unknown
}
console.log('Command ID:', this.commandId, 'Command Name:', commandName);
// Check if the command requires a destination
if (
listingCommands.includes(commandName) ||
infoCommands.includes(commandName) ||
(dirCommands.includes(commandName) && !this.rcloneFlags.includes('--dst')) ||
destructiveCommands.includes(commandName) ||
specialSinglePathCommands.includes(commandName) ||
commandName === 'version' ||
commandName === 'listremotes'
) {
this.requiresDestination = false;
console.log('Destination not required for command:', commandName);
} else {
this.requiresDestination = true;
console.log('Destination required for command:', commandName);
}
}
}`,
name, sourceType, sourcePath, sourceHost, sourcePort, sourceUser, sourcePassword, sourceKeyFile, sourceAuthType,
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)
}
templ ConfigForm(ctx context.Context, data ConfigFormData) {
@LayoutWithContext(getConfigFormTitle(data.IsNew), ctx) {
<!-- Main Content -->
<section class="py-8 px-4">
<div class="mx-auto max-w-3xl">
<div class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-sm p-6 md:p-8">
<!-- Form Header -->
<div class="mb-8 text-center">
<div class="flex justify-center mb-4">
<span class="inline-flex items-center justify-center w-16 h-16 rounded-full bg-blue-50 dark:bg-blue-900">
<i class="fas fa-cog text-blue-600 dark:text-blue-300 text-2xl"></i>
</span>
</div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">
{ getConfigFormTitle(data.IsNew) }
</h1>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
Configure your file transfer settings using the form below
</p>
</div>
<!-- Main Form -->
<form
id="config-form"
class="space-y-6"
if data.IsNew {
hx-post="/configs"
} else {
hx-post={ fmt.Sprintf("/configs/%d", data.Config.ID) }
}
hx-target="body"
hx-redirect="/configs"
hx-boost="true"
x-data={ getInitialData(data.Config) }
x-init="$nextTick(() => {
// Ensure initial form state displays correctly on load
sourceType = sourceType || 'local';
destinationType = destinationType || 'local';
// Set default ports based on connection type
if (sourcePort === 0 || !sourcePort) {
if (sourceType === 'sftp') {
sourcePort = 22;
} else if (sourceType === 'ftp') {
sourcePort = 21;
} else if (sourceType === 'hetzner') {
sourcePort = 23;
}
}
if (destPort === 0 || !destPort) {
if (destinationType === 'sftp') {
destPort = 22;
} else if (destinationType === 'ftp') {
destPort = 21;
} else if (destinationType === 'hetzner') {
destPort = 23;
}
}
// Ensure maxConcurrentTransfers is at least 1
if (!maxConcurrentTransfers || maxConcurrentTransfers < 1) {
maxConcurrentTransfers = 1;
}
// Initialize provider selection states
useSourceProvider = useSourceProvider || false;
useDestinationProvider = useDestinationProvider || false;
// Initialize command requirements
updateCommandRequirements();
// Initialize hidden input fields with their values to ensure they're included in form submission
document.getElementById('hidden_name').value = name;
document.getElementById('hidden_source_path').value = sourcePath;
document.getElementById('hidden_destination_path').value = destinationPath;
// Initialize S3 source fields if applicable
if (sourceType === 's3' || sourceType === 'b2' || sourceType === 'wasabi' || sourceType === 'minio') {
document.getElementById('hidden_source_access_key').value = sourceAccessKey;
document.getElementById('hidden_source_secret_key').value = sourceSecretKey;
document.getElementById('hidden_source_endpoint').value = sourceEndpoint;
document.getElementById('hidden_source_bucket').value = sourceBucket;
document.getElementById('hidden_source_region').value = sourceRegion;
}
// Initialize S3 destination fields if applicable
if (destinationType === 's3' || destinationType === 'b2' || destinationType === 'wasabi' || destinationType === 'minio') {
document.getElementById('hidden_dest_access_key').value = destAccessKey;
document.getElementById('hidden_dest_secret_key').value = destSecretKey;
document.getElementById('hidden_dest_endpoint').value = destEndpoint;
document.getElementById('hidden_dest_bucket').value = destBucket;
document.getElementById('hidden_dest_region').value = destRegion;
}
})"
x-effect="if (sourceType === 'sftp' && (sourcePort === 0 || sourcePort === 21 || sourcePort === 23)) {
sourcePort = 22;
console.log('Updating source port to 22 for SFTP');
} else if (sourceType === 'ftp' && (sourcePort === 0 || sourcePort === 22 || sourcePort === 23)) {
sourcePort = 21;
console.log('Updating source port to 21 for FTP');
} else if (sourceType === 'hetzner' && (sourcePort === 0 || sourcePort === 22 || sourcePort === 21)) {
sourcePort = 23;
console.log('Updating source port to 23 for Hetzner');
} else if (destinationType === 'sftp' && (destPort === 0 || destPort === 21 || destPort === 23)) {
destPort = 22;
console.log('Updating destination port to 22 for SFTP');
} else if (destinationType === 'ftp' && (destPort === 0 || destPort === 22 || destPort === 23)) {
destPort = 21;
console.log('Updating destination port to 21 for FTP');
} else if (destinationType === 'hetzner' && (destPort === 0 || destPort === 22 || destPort === 21)) {
destPort = 23;
console.log('Updating destination port to 23 for Hetzner');
}"
@formvalidation
@submit="
// Basic fields
document.getElementById('hidden_name').value = name;
document.getElementById('hidden_source_path').value = sourcePath;
document.getElementById('hidden_destination_path').value = destinationPath;
// S3 source fields
if (sourceType === 's3' || sourceType === 'b2' || sourceType === 'wasabi' || sourceType === 'minio') {
document.getElementById('hidden_source_access_key').value = sourceAccessKey;
document.getElementById('hidden_source_secret_key').value = sourceSecretKey;
document.getElementById('hidden_source_endpoint').value = sourceEndpoint;
document.getElementById('hidden_source_bucket').value = sourceBucket;
document.getElementById('hidden_source_region').value = sourceRegion;
}
// S3 destination fields
if (destinationType === 's3' || destinationType === 'b2' || destinationType === 'wasabi' || destinationType === 'minio') {
document.getElementById('hidden_dest_access_key').value = destAccessKey;
document.getElementById('hidden_dest_secret_key').value = destSecretKey;
document.getElementById('hidden_dest_endpoint').value = destEndpoint;
document.getElementById('hidden_dest_bucket').value = destBucket;
document.getElementById('hidden_dest_region').value = destRegion;
}
"
>
<!-- Hidden fields to ensure values are submitted with the form -->
<input type="hidden" id="hidden_name" name="name" />
<input type="hidden" id="hidden_source_path" name="source_path" />
<input type="hidden" id="hidden_destination_path" name="destination_path" />
<!-- Hidden fields for S3-compatible providers -->
<input type="hidden" id="hidden_source_access_key" name="source_access_key" />
<input type="hidden" id="hidden_source_secret_key" name="source_secret_key" />
<input type="hidden" id="hidden_source_endpoint" name="source_endpoint" />
<input type="hidden" id="hidden_source_bucket" name="source_bucket" />
<input type="hidden" id="hidden_source_region" name="source_region" />
<input type="hidden" id="hidden_dest_access_key" name="dest_access_key" />
<input type="hidden" id="hidden_dest_secret_key" name="dest_secret_key" />
<input type="hidden" id="hidden_dest_endpoint" name="dest_endpoint" />
<input type="hidden" id="hidden_dest_bucket" name="dest_bucket" />
<input type="hidden" id="hidden_dest_region" name="dest_region" />
<!-- Form Error Container -->
<div id="form-errors" class="hidden p-4 mb-6 text-sm text-red-800 rounded-lg bg-red-50 dark:bg-red-800/20 dark:text-red-400 border border-red-200 dark:border-red-900" role="alert">
<div class="flex items-center mb-2">
<i class="fas fa-exclamation-circle text-red-600 dark:text-red-500 mr-2"></i>
<h3 class="text-base font-medium text-red-800 dark:text-red-400">Please correct the following errors:</h3>
</div>
<ul id="error-list" class="ml-5 list-disc space-y-1"></ul>
</div>
<!-- Configuration Details Section -->
<div class="p-4 mb-4 bg-blue-50 border border-blue-100 rounded-lg dark:bg-blue-900/20 dark:border-blue-900">
<div class="flex items-center mb-2">
<i class="fas fa-info-circle text-blue-600 dark:text-blue-400 mr-2"></i>
<h3 class="text-lg font-medium text-blue-600 dark:text-blue-400">Configuration Details</h3>
</div>
<p class="text-sm text-blue-700 dark:text-blue-300">
Give your configuration a descriptive name and set up the source and destination locations.
</p>
</div>
<!-- Name field -->
<div class="mb-2">
<label for="name" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
Configuration Name <span class="text-red-500">*</span>
</label>
<input
type="text"
id="name"
name="name"
x-model="name"
@input="document.getElementById('hidden_name').value = name"
required
aria-required="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 p-2.5 dark: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 Transfer Configuration"
/>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Choose a descriptive name to identify this configuration.</p>
</div>
<!-- Rclone Command Configuration Section -->
<div 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-terminal mr-2 text-blue-500 dark:text-blue-400"></i>Command Configuration
</h3>
<!-- Additonal Rclone Flags -->
@common.RcloneFlags(data.Config.CommandID) // Pass current command ID
<!-- Container for flags, pre-rendered on edit, loaded via HTMX on new/change -->
<div id="command-flags-container" class="mt-4">
if !data.IsNew && data.InitialCommand != nil {
// Pre-render flags if editing and command data is available
@common.RcloneCommandFlagsContent(data.InitialCommand, data.SelectedFlagsMap, data.SelectedFlagValues)
}
</div>
</div>
<!-- Source Configuration Section -->
<div 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-upload mr-2 text-blue-500 dark:text-blue-400"></i>Source Configuration
</h3>
<!-- Source selection -->
@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"
hx-post="/configs/test-connection"
hx-include="closest form"
hx-vals='{"providerType": "source"}'
hx-swap="none"
hx-indicator="#source-test-spinner">
<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>
</div>
<!-- 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>
<!-- 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>
<!-- 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"
@input="document.getElementById('hidden_source_path').value = 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>
<!-- File Pattern Section -->
<div 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-filter mr-2 text-blue-500 dark:text-blue-400"></i>File Patterns
</h3>
<!-- File pattern fields -->
@common.FilePatternFields()
</div>
<!-- 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(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"
hx-post="/configs/test-connection"
hx-include="closest form"
hx-vals='{"providerType": "destination"}'
hx-swap="none"
hx-indicator="#dest-test-spinner">
<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>
</div>
<!-- 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 === 'nextcloud'">
@destination.NextCloudDestinationForm()
</template>
<template x-if="destinationType === 'webdav'">
@destination.WebDAVDestinationForm()
</template>
<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>
<!-- 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>
<!-- 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"
@input="document.getElementById('hidden_destination_path').value = 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>
<!-- Advanced Options Section -->
<div 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-cogs mr-2 text-blue-500 dark:text-blue-400"></i>Advanced Options
</h3>
<!-- Archive options -->
<div class="mb-6">
<h4 class="text-lg font-medium text-gray-900 dark:text-white mb-4">Archive Settings</h4>
@common.ArchiveOptions()
</div>
</div>
<!-- Form Actions -->
<div class="flex items-center justify-between pt-6 border-t border-gray-200 dark:border-gray-700">
<a href="/configs"
class="text-white bg-gray-500 hover:bg-gray-600 focus:ring-4 focus:ring-gray-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-gray-600 dark:hover:bg-gray-700 dark:focus:ring-gray-800">
<i class="fas fa-arrow-left mr-2"></i>Cancel
</a>
<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 text-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800">
<span class="flex items-center">
<i class="fas fa-save mr-2"></i>
if data.IsNew {
Create Configuration
} else {
Save Changes
}
</span>
</button>
</div>
</form>
</div>
<!-- Help Card -->
<div class="mt-4 p-4 bg-gray-50 border border-gray-200 rounded-lg shadow-sm dark:bg-gray-800 dark:border-gray-700">
<div class="flex">
<div class="flex-shrink-0">
<i class="fas fa-lightbulb text-yellow-400 text-xl"></i>
</div>
<div class="ml-4">
<h5 class="text-sm font-medium text-gray-900 dark:text-white">Configuration Tips</h5>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
Configure your file transfer settings carefully for optimal performance. Use file patterns to filter which files are transferred, and consider using archive options to keep track of processed files.
</p>
</div>
</div>
</div>
</div>
</section>
}
}
templ formvalidation() {
<script>
document.addEventListener('htmx:beforeRequest', function(evt) {
if (evt.detail.elt.id === 'config-form') {
const formErrors = document.getElementById('form-errors');
const errorList = document.getElementById('error-list');
let errors = [];
let hasErrors = false;
// Clear previous errors
errorList.innerHTML = '';
formErrors.classList.add('hidden');
// Validate name
const name = document.getElementById('name').value;
if (!name || name.trim() === '') {
errors.push('Configuration name 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;
// 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;
}
}
// 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;
}
}
// Display errors if any
if (hasErrors) {
formErrors.classList.remove('hidden');
errors.forEach(function(error) {
const li = document.createElement('li');
li.textContent = error;
errorList.appendChild(li);
});
evt.preventDefault(); // Prevent form submission
}
}
});
</script>
}