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:
StarFleetCPTN
2025-04-16 17:18:53 -07:00
parent 88d0ac815a
commit 31871bd16e
59 changed files with 15842 additions and 594 deletions
+234 -268
View File
@@ -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
}
}
});
+8
View File
@@ -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
+150 -40
View File
@@ -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>
}
+404
View File
@@ -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>
}
+568
View File
@@ -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;
}