mirror of
https://github.com/StarFleetCPTN/GoMFT.git
synced 2026-09-08 23:50:48 +02:00
954 lines
42 KiB
Templ
954 lines
42 KiB
Templ
package components
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"github.com/starfleetcptn/gomft/internal/db"
|
|
)
|
|
|
|
type StorageProviderFormData struct {
|
|
Provider *db.StorageProvider
|
|
IsEdit bool
|
|
Error string
|
|
}
|
|
|
|
// getTitle returns the appropriate title based on whether we're editing or creating
|
|
func getTitle(isEdit bool) string {
|
|
if isEdit {
|
|
return "Edit Storage Provider"
|
|
}
|
|
return "New Storage Provider"
|
|
}
|
|
|
|
// Main template for Storage Provider form
|
|
templ StorageProviderForm(ctx context.Context, data StorageProviderFormData) {
|
|
@LayoutWithContext(getTitle(data.IsEdit), ctx) {
|
|
<div class="min-h-screen bg-gray-50 dark:bg-gray-900 py-8">
|
|
<div class="max-w-3xl mx-auto">
|
|
<div class="flex items-center justify-between mb-6">
|
|
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
|
|
<i class="fas fa-server w-6 h-6 mr-2 text-blue-500 dark:text-blue-400"></i>
|
|
if data.IsEdit {
|
|
Edit Storage Provider
|
|
} else {
|
|
New Storage Provider
|
|
}
|
|
</h1>
|
|
<a href="/storage-providers" class="text-blue-600 dark:text-blue-400 hover:underline flex items-center">
|
|
<i class="fas fa-arrow-left mr-1.5"></i>
|
|
Back to Providers
|
|
</a>
|
|
</div>
|
|
|
|
if data.Error != "" {
|
|
<div class="mb-4 p-4 text-sm text-red-800 rounded-lg bg-red-50 dark:bg-gray-800 dark:text-red-400">
|
|
<div class="flex items-center">
|
|
<i class="fas fa-exclamation-circle mr-2"></i>
|
|
<span>{ data.Error }</span>
|
|
</div>
|
|
</div>
|
|
}
|
|
|
|
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-6 mb-6">
|
|
if data.IsEdit {
|
|
<form action={ templ.SafeURL(fmt.Sprintf("/storage-providers/%d", data.Provider.ID)) } method="POST">
|
|
<input type="hidden" name="_method" value="PUT" />
|
|
@formFields(data)
|
|
</form>
|
|
} else {
|
|
<form action="/storage-providers" method="POST">
|
|
@formFields(data)
|
|
</form>
|
|
}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Include the provider form scripts -->
|
|
@providerFormScript()
|
|
}
|
|
}
|
|
|
|
// Form fields template
|
|
templ formFields(data StorageProviderFormData) {
|
|
<!-- Basic Information -->
|
|
<div class="mb-6">
|
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Basic Information</h2>
|
|
|
|
<!-- Provider Name -->
|
|
<div class="mb-4">
|
|
<label for="name" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Provider Name <span class="text-red-500">*</span></label>
|
|
<input type="text" id="name" name="name" value={ data.Provider.Name } class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="Storage Provider Name" required />
|
|
</div>
|
|
|
|
<!-- Provider Type -->
|
|
<div>
|
|
<label for="type" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Provider Type <span class="text-red-500">*</span></label>
|
|
<select id="type" name="type" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" required onchange="toggleProviderFields()">
|
|
<option value="" disabled
|
|
if data.Provider.Type == "" {
|
|
selected="selected"
|
|
}
|
|
>
|
|
Select provider type
|
|
</option>
|
|
<!-- Server-based providers -->
|
|
<optgroup label="Server-based">
|
|
<option value="sftp"
|
|
if data.Provider.Type == db.ProviderTypeSFTP {
|
|
selected="selected"
|
|
}
|
|
>SFTP</option>
|
|
<option value="hetzner"
|
|
if data.Provider.Type == db.ProviderTypeHetzner {
|
|
selected="selected"
|
|
}
|
|
>Hetzner Storage Box</option>
|
|
<option value="ftp"
|
|
if data.Provider.Type == db.ProviderTypeFTP {
|
|
selected="selected"
|
|
}
|
|
>FTP</option>
|
|
<option value="smb"
|
|
if data.Provider.Type == db.ProviderTypeSMB {
|
|
selected="selected"
|
|
}
|
|
>SMB/CIFS</option>
|
|
</optgroup>
|
|
|
|
<!-- Object Storage -->
|
|
<optgroup label="Object Storage">
|
|
<option value="s3"
|
|
if data.Provider.Type == db.ProviderTypeS3 {
|
|
selected="selected"
|
|
}
|
|
>Amazon S3</option>
|
|
<option value="wasabi"
|
|
if data.Provider.Type == "wasabi" {
|
|
selected="selected"
|
|
}
|
|
>Wasabi</option>
|
|
<option value="minio"
|
|
if data.Provider.Type == "minio" {
|
|
selected="selected"
|
|
}
|
|
>MinIO</option>
|
|
<option value="b2"
|
|
if data.Provider.Type == "b2" {
|
|
selected="selected"
|
|
}
|
|
>Backblaze B2</option>
|
|
</optgroup>
|
|
|
|
<!-- Web-based storage -->
|
|
<optgroup label="Web Storage">
|
|
<option value="webdav"
|
|
if data.Provider.Type == "webdav" {
|
|
selected="selected"
|
|
}
|
|
>WebDAV</option>
|
|
<option value="nextcloud"
|
|
if data.Provider.Type == "nextcloud" {
|
|
selected="selected"
|
|
}
|
|
>Nextcloud</option>
|
|
</optgroup>
|
|
|
|
<!-- Cloud providers -->
|
|
<optgroup label="Cloud Storage">
|
|
<option value="onedrive"
|
|
if data.Provider.Type == db.ProviderTypeOneDrive {
|
|
selected="selected"
|
|
}
|
|
>OneDrive</option>
|
|
<option value="drive"
|
|
if data.Provider.Type == db.ProviderTypeGoogleDrive {
|
|
selected="selected"
|
|
}
|
|
>Google Drive</option>
|
|
<option value="gphotos"
|
|
if data.Provider.Type == db.ProviderTypeGooglePhoto {
|
|
selected="selected"
|
|
}
|
|
>Google Photos</option>
|
|
</optgroup>
|
|
|
|
<!-- Local -->
|
|
<optgroup label="Local">
|
|
<option value="local"
|
|
if data.Provider.Type == db.ProviderTypeLocal {
|
|
selected="selected"
|
|
}
|
|
>Local Filesystem</option>
|
|
</optgroup>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Connection Details - SFTP/FTP/SMB -->
|
|
<div id="sftp-ftp-fields" class="mb-6 provider-fields hidden">
|
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Connection Details</h2>
|
|
|
|
<!-- Host -->
|
|
<div class="mb-4">
|
|
<label for="host" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Host <span class="text-red-500">*</span></label>
|
|
<input type="text" id="host" name="host" value={ data.Provider.Host } class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="e.g., sftp.example.com or 192.168.1.10" />
|
|
</div>
|
|
|
|
<!-- Port -->
|
|
<div id="port-field" class="mb-4">
|
|
<label for="port" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Port</label>
|
|
<input type="number" id="port" name="port" value={ fmt.Sprint(data.Provider.Port) } class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="22" />
|
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Leave empty for default (SFTP: 22, FTP: 21, SMB: 445, WebDAV: 80/443)</p>
|
|
</div>
|
|
|
|
<!-- Username -->
|
|
<div class="mb-4">
|
|
<label for="username" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Username <span class="text-red-500">*</span></label>
|
|
<input type="text" id="username" name="username" value={ data.Provider.Username } class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="Your login username" />
|
|
</div>
|
|
|
|
<!-- Password -->
|
|
<div class="mb-4">
|
|
<label for="password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Password</label>
|
|
<input type="password" id="password" name="password" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="Your login password" />
|
|
if data.IsEdit {
|
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Leave empty to keep the current password</p>
|
|
}
|
|
</div>
|
|
|
|
<!-- Key File (SFTP only) -->
|
|
<div id="key-file-field" class="mb-4 hidden">
|
|
<label for="keyFile" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Key File Path</label>
|
|
<input type="text" id="keyFile" name="keyFile" value={ data.Provider.KeyFile } class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="e.g., /home/user/.ssh/id_rsa" />
|
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Absolute path to private key file (if using key-based authentication)</p>
|
|
</div>
|
|
|
|
<!-- Domain (SMB only) -->
|
|
<div id="domain-field" class="mb-4 hidden">
|
|
<label for="domain" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Domain</label>
|
|
<input type="text" id="domain" name="domain" value={ data.Provider.Domain } class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="e.g., WORKGROUP or domain.local" />
|
|
</div>
|
|
|
|
<!-- Passive Mode (FTP only) -->
|
|
<div id="passive-mode-field" class="mb-4 hidden">
|
|
<div class="flex items-center">
|
|
<input id="passiveMode" name="passiveMode" type="checkbox" value="true" class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"
|
|
if data.Provider.PassiveMode != nil && *data.Provider.PassiveMode {
|
|
checked="checked"
|
|
}
|
|
/>
|
|
<label for="passiveMode" class="ml-2 text-sm font-medium text-gray-900 dark:text-white">Use Passive Mode</label>
|
|
</div>
|
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Recommended for most FTP connections through firewalls</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- S3 Fields -->
|
|
<div id="s3-fields" class="mb-6 provider-fields hidden">
|
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">S3 Connection Details</h2>
|
|
|
|
<!-- Endpoint -->
|
|
<div class="mb-4">
|
|
<label for="endpoint" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
|
|
Endpoint
|
|
<span id="endpoint-required" class="text-red-500">*</span>
|
|
</label>
|
|
<input type="text" id="endpoint" name="endpoint" value={ data.Provider.Endpoint }
|
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
|
placeholder="e.g., s3.amazonaws.com, s3.us-west-1.wasabisys.com" />
|
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
|
Custom endpoint URL (only needed for non-standard regions or non-AWS S3-compatible services). Optional for B2.
|
|
</p>
|
|
</div>
|
|
|
|
<!-- Region -->
|
|
<div class="mb-4">
|
|
<label for="region" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
|
|
Region
|
|
<span id="region-required" class="text-red-500">*</span>
|
|
</label>
|
|
<input type="text" id="region" name="region" value={ data.Provider.Region }
|
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
|
placeholder="e.g., us-east-1, eu-central-1" />
|
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
|
AWS Region where your S3 bucket is located (e.g., us-east-1, eu-west-1). Optional for B2 and Wasabi.
|
|
</p>
|
|
</div>
|
|
|
|
<!-- Bucket -->
|
|
<div class="mb-4">
|
|
<label for="bucket" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Bucket <span class="text-red-500">*</span></label>
|
|
<input type="text" id="bucket" name="bucket" value={ data.Provider.Bucket }
|
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
|
placeholder="Your bucket name" />
|
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
|
Name of your S3 bucket (case-sensitive)
|
|
</p>
|
|
</div>
|
|
|
|
<!-- Access Key -->
|
|
<div class="mb-4">
|
|
<label for="accessKey" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Access Key <span class="text-red-500">*</span></label>
|
|
<input type="text" id="accessKey" name="accessKey" value={ data.Provider.AccessKey }
|
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
|
placeholder="Your access key/key ID" />
|
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
|
Your AWS Access Key ID
|
|
</p>
|
|
</div>
|
|
|
|
<!-- Secret Key -->
|
|
<div class="mb-4">
|
|
<label for="secretKey" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Secret Key <span class="text-red-500">*</span></label>
|
|
<input type="password" id="secretKey" name="secretKey"
|
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
|
placeholder="Your secret access key" />
|
|
if data.IsEdit {
|
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Leave empty to keep the current secret key</p>
|
|
}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Cloud Storage Fields (OneDrive, Google Drive, Google Photos) -->
|
|
<div id="cloud-fields" class="mb-6 provider-fields hidden">
|
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Cloud Storage Details</h2>
|
|
|
|
<!-- Client ID -->
|
|
<div class="mb-4">
|
|
<label for="clientID" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Client ID <span class="text-red-500">*</span></label>
|
|
<input type="text" id="clientID" name="clientID" value={ data.Provider.ClientID } class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="OAuth client ID from developer console" />
|
|
</div>
|
|
|
|
<!-- Client Secret -->
|
|
<div class="mb-4">
|
|
<label for="clientSecret" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Client Secret <span class="text-red-500">*</span></label>
|
|
<input type="password" id="clientSecret" name="clientSecret" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="OAuth client secret from developer console" />
|
|
if data.IsEdit {
|
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Leave empty to keep the current client secret</p>
|
|
}
|
|
</div>
|
|
|
|
<!-- Google Drive - Drive ID -->
|
|
<div id="drive-id-field" class="mb-4 hidden">
|
|
<label for="driveID" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Drive ID</label>
|
|
<input type="text" id="driveID" name="driveID" value={ data.Provider.DriveID } class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="ID of shared/team drive (from Drive URL)" />
|
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Only required for shared drives</p>
|
|
</div>
|
|
|
|
<!-- Google Drive - Team Drive -->
|
|
<div id="team-drive-field" class="mb-4 hidden">
|
|
<label for="teamDrive" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Team Drive</label>
|
|
<input type="text" id="teamDrive" name="teamDrive" value={ data.Provider.TeamDrive } class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="Team drive identifier" />
|
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Only required for team drives</p>
|
|
</div>
|
|
|
|
<!-- Google Photos - Read Only -->
|
|
<div id="readonly-field" class="mb-4 hidden">
|
|
<div class="flex items-center">
|
|
<input id="readOnly" name="readOnly" type="checkbox" value="true" class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"
|
|
if data.Provider.ReadOnly != nil && *data.Provider.ReadOnly {
|
|
checked="checked"
|
|
}
|
|
/>
|
|
<label for="readOnly" class="ml-2 text-sm font-medium text-gray-900 dark:text-white">Read Only Mode</label>
|
|
</div>
|
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Google Photos has limited write capabilities</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Local File System Fields -->
|
|
<div id="local-fields" class="mb-6 provider-fields hidden">
|
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Local File System</h2>
|
|
|
|
<div class="mb-4">
|
|
<label for="localPath" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Base Path <span class="text-red-500">*</span></label>
|
|
<input type="text" id="localPath" name="localPath" value={ data.Provider.Host } class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="e.g., /data/files or C:\transfer\data" />
|
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Absolute path on the server's file system</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Submit Buttons -->
|
|
<div class="flex items-center justify-between mt-8">
|
|
<a href="/storage-providers" class="text-gray-500 bg-gray-50 hover:bg-gray-100 focus:ring-4 focus:outline-none focus:ring-gray-200 rounded-lg border border-gray-200 text-sm font-medium px-5 py-2.5 hover:text-gray-900 focus:z-10 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-500 dark:hover:text-white dark:hover:bg-gray-600 dark:focus:ring-gray-600">
|
|
Cancel
|
|
</a>
|
|
<div class="flex space-x-2">
|
|
<button type="submit" name="test" value="true" class="text-white bg-blue-600 hover:bg-blue-700 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm w-full sm:w-auto px-5 py-2.5 text-center dark:bg-blue-500 dark:hover:bg-blue-600 dark:focus:ring-blue-700">
|
|
Save & Test
|
|
</button>
|
|
<button type="submit" class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm w-full sm:w-auto px-5 py-2.5 text-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800">
|
|
Save Provider
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Hidden fields for S3 form data to ensure it gets submitted correctly -->
|
|
<input type="hidden" id="hidden_endpoint" name="endpoint" value={ data.Provider.Endpoint } />
|
|
<input type="hidden" id="hidden_region" name="region" value={ data.Provider.Region } />
|
|
<input type="hidden" id="hidden_bucket" name="bucket" value={ data.Provider.Bucket } />
|
|
<input type="hidden" id="hidden_accessKey" name="accessKey" value={ data.Provider.AccessKey } />
|
|
<input type="hidden" id="hidden_secretKey" name="secretKey" />
|
|
|
|
<!-- Hidden fields for Google Drive and Google Photos -->
|
|
<input type="hidden" id="hidden_clientID" name="clientID" value={ data.Provider.ClientID } />
|
|
<input type="hidden" id="hidden_clientSecret" name="clientSecret" />
|
|
}
|
|
|
|
// JavaScript helper for toggling provider fields
|
|
templ providerFormScript() {
|
|
<script>
|
|
// Set current active fields on page load
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
toggleProviderFields();
|
|
|
|
// Add event listener to track if user manually changes the port
|
|
const portField = document.getElementById('port');
|
|
portField.addEventListener('input', function() {
|
|
// Mark the field as user-modified
|
|
this.dataset.userModified = 'true';
|
|
});
|
|
|
|
// Add event listeners for S3 fields
|
|
document.getElementById('endpoint').addEventListener('input', function() {
|
|
document.getElementById('hidden_endpoint').value = this.value;
|
|
});
|
|
|
|
document.getElementById('region').addEventListener('input', function() {
|
|
document.getElementById('hidden_region').value = this.value;
|
|
});
|
|
|
|
document.getElementById('bucket').addEventListener('input', function() {
|
|
document.getElementById('hidden_bucket').value = this.value;
|
|
});
|
|
|
|
document.getElementById('accessKey').addEventListener('input', function() {
|
|
document.getElementById('hidden_accessKey').value = this.value;
|
|
});
|
|
|
|
document.getElementById('secretKey').addEventListener('input', function() {
|
|
document.getElementById('hidden_secretKey').value = this.value;
|
|
});
|
|
|
|
// Add event listeners for Google Drive/Photos fields
|
|
document.getElementById('clientID').addEventListener('input', function() {
|
|
document.getElementById('hidden_clientID').value = this.value;
|
|
});
|
|
|
|
document.getElementById('clientSecret').addEventListener('input', function() {
|
|
document.getElementById('hidden_clientSecret').value = this.value;
|
|
});
|
|
|
|
// Add form submit listener to ensure all hidden fields are populated
|
|
const forms = document.querySelectorAll('form');
|
|
forms.forEach(form => {
|
|
form.addEventListener('submit', function(e) {
|
|
// Find active provider type
|
|
const providerType = document.getElementById('type').value;
|
|
|
|
// If S3-compatible, update hidden fields
|
|
if (['s3', 'wasabi', 'minio', 'b2'].includes(providerType)) {
|
|
document.getElementById('hidden_endpoint').value = document.getElementById('endpoint').value;
|
|
document.getElementById('hidden_region').value = document.getElementById('region').value;
|
|
document.getElementById('hidden_bucket').value = document.getElementById('bucket').value;
|
|
document.getElementById('hidden_accessKey').value = document.getElementById('accessKey').value;
|
|
|
|
// Make sure secretKey is always copied to the hidden field
|
|
// This is especially important for B2 which uses this as Application Key
|
|
const secretKeyValue = document.getElementById('secretKey').value;
|
|
document.getElementById('hidden_secretKey').value = secretKeyValue;
|
|
|
|
// For validation - ensure we have appropriate fields for each provider type
|
|
if (providerType === 'b2') {
|
|
// B2 does not require region or endpoint
|
|
if (!document.getElementById('hidden_bucket').value) {
|
|
alert('Bucket name is required');
|
|
e.preventDefault();
|
|
return false;
|
|
}
|
|
if (!document.getElementById('hidden_accessKey').value) {
|
|
alert('Account ID is required');
|
|
e.preventDefault();
|
|
return false;
|
|
}
|
|
if (!secretKeyValue && !document.getElementById('hidden_secretKey').value) {
|
|
alert('Application Key is required');
|
|
e.preventDefault();
|
|
return false;
|
|
}
|
|
} else if (providerType === 'wasabi') {
|
|
// Wasabi does not require region
|
|
if (!document.getElementById('hidden_endpoint').value) {
|
|
alert('Endpoint is required for Wasabi');
|
|
e.preventDefault();
|
|
return false;
|
|
}
|
|
if (!document.getElementById('hidden_bucket').value) {
|
|
alert('Bucket name is required');
|
|
e.preventDefault();
|
|
return false;
|
|
}
|
|
if (!document.getElementById('hidden_accessKey').value) {
|
|
alert('Access key is required');
|
|
e.preventDefault();
|
|
return false;
|
|
}
|
|
if (!secretKeyValue && !document.getElementById('hidden_secretKey').value) {
|
|
alert('Secret key is required');
|
|
e.preventDefault();
|
|
return false;
|
|
}
|
|
}
|
|
|
|
console.log('Submitting S3-compatible form with:', {
|
|
provider: providerType,
|
|
endpoint: document.getElementById('hidden_endpoint').value,
|
|
region: document.getElementById('hidden_region').value,
|
|
bucket: document.getElementById('hidden_bucket').value,
|
|
accessKey: document.getElementById('hidden_accessKey').value,
|
|
secretKey: document.getElementById('hidden_secretKey').value ? '[PRESENT]' : '[EMPTY]',
|
|
secretKeyLength: document.getElementById('hidden_secretKey').value.length
|
|
});
|
|
}
|
|
|
|
// If Google Drive or Google Photos, update hidden fields
|
|
if (['drive', 'gphotos', 'onedrive'].includes(providerType)) {
|
|
document.getElementById('hidden_clientID').value = document.getElementById('clientID').value;
|
|
|
|
// Make sure clientSecret is always copied to the hidden field
|
|
const clientSecretValue = document.getElementById('clientSecret').value;
|
|
document.getElementById('hidden_clientSecret').value = clientSecretValue;
|
|
|
|
// Validate required fields
|
|
if (!document.getElementById('hidden_clientID').value) {
|
|
alert('Client ID is required');
|
|
e.preventDefault();
|
|
return false;
|
|
}
|
|
|
|
console.log('Submitting cloud storage form with:', {
|
|
provider: providerType,
|
|
clientID: document.getElementById('hidden_clientID').value,
|
|
clientSecret: document.getElementById('hidden_clientSecret').value ? '[PRESENT]' : '[EMPTY]',
|
|
clientSecretLength: document.getElementById('hidden_clientSecret').value.length
|
|
});
|
|
}
|
|
|
|
// Debug WebDAV form submission
|
|
if (['webdav', 'nextcloud'].includes(providerType)) {
|
|
console.log('Submitting WebDAV form with:', {
|
|
provider: providerType,
|
|
host: document.getElementById('host').value,
|
|
username: document.getElementById('username').value,
|
|
password: document.getElementById('password').value ? '[PRESENT]' : '[EMPTY]',
|
|
passwordLength: document.getElementById('password').value.length
|
|
});
|
|
}
|
|
});
|
|
});
|
|
});
|
|
|
|
function toggleProviderFields() {
|
|
const provider = document.getElementById('type').value;
|
|
|
|
// Hide all provider fields first
|
|
const providerFieldsets = document.querySelectorAll('.provider-fields');
|
|
providerFieldsets.forEach(fieldset => {
|
|
fieldset.classList.add('hidden');
|
|
});
|
|
|
|
// Auto-populate port based on provider type
|
|
const portField = document.getElementById('port');
|
|
const isPortEmpty = portField.value === '';
|
|
const userModified = portField.dataset.userModified === 'true';
|
|
|
|
// Only set default port if field is empty or hasn't been modified by user
|
|
if (isPortEmpty || !userModified) {
|
|
if (provider === 'sftp') {
|
|
portField.value = '22';
|
|
} else if (provider === 'hetzner') {
|
|
portField.value = '23';
|
|
} else if (provider === 'ftp') {
|
|
portField.value = '21';
|
|
} else if (provider === 'smb') {
|
|
portField.value = '445';
|
|
} else if (provider === 'webdav' || provider === 'nextcloud') {
|
|
portField.value = '443';
|
|
} else {
|
|
portField.value = '';
|
|
}
|
|
// Reset user modified flag if we're setting it programmatically
|
|
portField.dataset.userModified = 'false';
|
|
}
|
|
|
|
// Show the appropriate fields based on the selected provider type
|
|
console.log("Provider type selected:", provider);
|
|
|
|
// Server-based connection fields (SFTP, FTP, Hetzner)
|
|
if (['sftp', 'ftp', 'hetzner'].includes(provider)) {
|
|
document.getElementById('sftp-ftp-fields').classList.remove('hidden');
|
|
|
|
// SFTP/Hetzner-specific fields
|
|
if (provider === 'sftp' || provider === 'hetzner') {
|
|
document.getElementById('key-file-field').classList.remove('hidden');
|
|
|
|
// Update placeholders for Hetzner
|
|
if (provider === 'hetzner') {
|
|
// Update host field
|
|
const hostField = document.getElementById('host');
|
|
if (hostField) {
|
|
hostField.placeholder = "uXXXXXX.your-storagebox.de";
|
|
|
|
// Update host label and description
|
|
const hostLabel = document.querySelector('label[for="host"]');
|
|
if (hostLabel) {
|
|
hostLabel.textContent = "Storage Box Host";
|
|
}
|
|
|
|
const hostDescription = hostField.nextElementSibling;
|
|
if (hostDescription?.tagName === 'P') {
|
|
hostDescription.textContent = "Your Hetzner Storage Box hostname (e.g., uXXXXXX.your-storagebox.de)";
|
|
}
|
|
}
|
|
|
|
// Update username field
|
|
const usernameField = document.getElementById('username');
|
|
if (usernameField) {
|
|
usernameField.placeholder = "uXXXXXX";
|
|
|
|
// Update username description
|
|
const usernameDescription = usernameField.nextElementSibling;
|
|
if (usernameDescription?.tagName === 'P') {
|
|
usernameDescription.textContent = "Your Hetzner Storage Box username (typically matches your Storage Box number)";
|
|
}
|
|
}
|
|
|
|
// Update key file field
|
|
const keyFileField = document.getElementById('keyFile');
|
|
if (keyFileField) {
|
|
keyFileField.placeholder = "/path/to/id_rsa";
|
|
|
|
// Update key file description
|
|
const keyFileDescription = keyFileField.nextElementSibling;
|
|
if (keyFileDescription?.tagName === 'P') {
|
|
keyFileDescription.textContent = "Path to your SSH private key file for Hetzner Storage Box authentication";
|
|
}
|
|
}
|
|
|
|
// Update port field description
|
|
const portField = document.getElementById('port');
|
|
if (portField) {
|
|
const portDescription = portField.nextElementSibling;
|
|
if (portDescription?.tagName === 'P') {
|
|
portDescription.textContent = "Connection port for Hetzner Storage Box (default: 23)";
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// FTP-specific fields
|
|
if (provider === 'ftp') {
|
|
document.getElementById('passive-mode-field').classList.remove('hidden');
|
|
}
|
|
}
|
|
|
|
// WebDAV-based fields
|
|
if (['webdav', 'nextcloud'].includes(provider)) {
|
|
document.getElementById('sftp-ftp-fields').classList.remove('hidden');
|
|
// Hide fields that WebDAV doesn't use
|
|
document.getElementById('port-field').classList.add('hidden');
|
|
|
|
// Update placeholders for WebDAV
|
|
const hostField = document.getElementById('host');
|
|
if (hostField) {
|
|
hostField.placeholder = "https://webdav.example.com";
|
|
}
|
|
|
|
// Update label for host field
|
|
const hostLabel = document.querySelector('label[for="host"]');
|
|
if (hostLabel) {
|
|
hostLabel.textContent = "WebDAV URL";
|
|
}
|
|
|
|
// Update description for host field
|
|
const hostDescription = hostField?.nextElementSibling;
|
|
if (hostDescription?.tagName === 'P') {
|
|
hostDescription.textContent = provider === 'webdav' ?
|
|
"Full URL to your WebDAV server including protocol (https://)" :
|
|
"Full URL to your Nextcloud WebDAV endpoint (e.g., https://nextcloud.example.com/remote.php/dav/files/username/)";
|
|
}
|
|
|
|
// Update username field
|
|
const usernameField = document.getElementById('username');
|
|
if (usernameField) {
|
|
usernameField.placeholder = provider === 'nextcloud' ? "nextcloud_username" : "webdav_username";
|
|
|
|
// Update username description
|
|
const usernameDescription = usernameField.nextElementSibling;
|
|
if (usernameDescription?.tagName === 'P') {
|
|
usernameDescription.textContent = provider === 'nextcloud' ?
|
|
"Your Nextcloud username for authentication" :
|
|
"Your WebDAV username for authentication";
|
|
}
|
|
}
|
|
|
|
// Update password field
|
|
const passwordField = document.getElementById('password');
|
|
if (passwordField) {
|
|
passwordField.name = "password"; // Ensure name is set correctly
|
|
|
|
// Update password description
|
|
const passwordDescription = passwordField.nextElementSibling;
|
|
if (passwordDescription?.tagName === 'P') {
|
|
// Check if we're in edit mode
|
|
const editMode = passwordDescription.textContent.includes("Leave empty to keep");
|
|
|
|
if (editMode) {
|
|
// Edit mode - tell user they can leave password empty to keep current one
|
|
passwordDescription.textContent = provider === 'nextcloud' ?
|
|
"Leave empty to keep the current Nextcloud password" :
|
|
"Leave empty to keep the current WebDAV password";
|
|
} else {
|
|
// New provider - show regular password help text
|
|
passwordDescription.textContent = provider === 'nextcloud' ?
|
|
"Your Nextcloud password or app-specific password" :
|
|
"Your WebDAV password for authentication";
|
|
}
|
|
}
|
|
}
|
|
|
|
if (document.getElementById('key-file-field')) {
|
|
document.getElementById('key-file-field').classList.add('hidden');
|
|
}
|
|
if (document.getElementById('domain-field')) {
|
|
document.getElementById('domain-field').classList.add('hidden');
|
|
}
|
|
} else {
|
|
// Reset placeholders for other providers
|
|
const hostField = document.getElementById('host');
|
|
if (hostField && ['sftp', 'ftp', 'smb'].includes(provider)) {
|
|
hostField.placeholder = provider === 'sftp' ? "sftp.example.com" :
|
|
provider === 'ftp' ? "ftp.example.com" :
|
|
"192.168.1.10";
|
|
}
|
|
|
|
// Reset label for host field
|
|
const hostLabel = document.querySelector('label[for="host"]');
|
|
if (hostLabel) {
|
|
hostLabel.textContent = "Host";
|
|
}
|
|
|
|
// Reset username field
|
|
const usernameField = document.getElementById('username');
|
|
if (usernameField) {
|
|
usernameField.placeholder = provider === 'sftp' ? "sftp_username" :
|
|
provider === 'ftp' ? "ftp_username" :
|
|
provider === 'smb' ? "smb_username" : "username";
|
|
|
|
// Reset username description
|
|
const usernameDescription = usernameField.nextElementSibling;
|
|
if (usernameDescription?.tagName === 'P') {
|
|
usernameDescription.textContent = "Your login username";
|
|
}
|
|
}
|
|
|
|
// Reset password field
|
|
const passwordField = document.getElementById('password');
|
|
if (passwordField) {
|
|
// Reset password description
|
|
const passwordDescription = passwordField.nextElementSibling;
|
|
if (passwordDescription?.tagName === 'P') {
|
|
// Check if we're in edit mode
|
|
const editMode = passwordDescription.textContent.includes("Leave empty to keep");
|
|
|
|
if (editMode) {
|
|
// Edit mode - tell user they can leave password empty to keep current one
|
|
if (provider === 'sftp') {
|
|
passwordDescription.textContent = "Leave empty to keep the current SFTP password";
|
|
} else if (provider === 'ftp') {
|
|
passwordDescription.textContent = "Leave empty to keep the current FTP password";
|
|
} else if (provider === 'smb') {
|
|
passwordDescription.textContent = "Leave empty to keep the current SMB password";
|
|
} else if (provider === 'hetzner') {
|
|
passwordDescription.textContent = "Leave empty to keep the current Hetzner Storage Box password";
|
|
} else {
|
|
passwordDescription.textContent = "Leave empty to keep the current password";
|
|
}
|
|
} else {
|
|
// New provider - show regular password help text
|
|
if (provider === 'sftp') {
|
|
passwordDescription.textContent = "Your SFTP server password";
|
|
} else if (provider === 'ftp') {
|
|
passwordDescription.textContent = "Your FTP server password";
|
|
} else if (provider === 'smb') {
|
|
passwordDescription.textContent = "Your SMB/CIFS share password";
|
|
} else if (provider === 'hetzner') {
|
|
passwordDescription.textContent = "Your Hetzner Storage Box password";
|
|
} else {
|
|
passwordDescription.textContent = "Your login password";
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// SMB-specific fields
|
|
if (provider === 'smb') {
|
|
document.getElementById('sftp-ftp-fields').classList.remove('hidden');
|
|
document.getElementById('domain-field').classList.remove('hidden');
|
|
}
|
|
|
|
// S3-compatible storage
|
|
if (['s3', 'wasabi', 'minio', 'b2'].includes(provider)) {
|
|
document.getElementById('s3-fields').classList.remove('hidden');
|
|
|
|
// Set region and endpoint requirements based on provider type
|
|
const regionRequired = document.getElementById('region-required');
|
|
const endpointRequired = document.getElementById('endpoint-required');
|
|
const regionInput = document.getElementById('region');
|
|
const endpointInput = document.getElementById('endpoint');
|
|
|
|
// Update Secret Key field description based on provider type
|
|
const secretKeyField = document.getElementById('secretKey');
|
|
if (secretKeyField) {
|
|
const secretKeyDescription = secretKeyField.nextElementSibling;
|
|
if (secretKeyDescription?.tagName === 'P') {
|
|
// Check if in edit mode
|
|
const editMode = secretKeyDescription.textContent.includes("Leave empty to keep");
|
|
|
|
if (editMode) {
|
|
// Edit mode - provider specific text
|
|
if (provider === 'b2') {
|
|
secretKeyDescription.textContent = "Leave empty to keep the current B2 Application Key";
|
|
} else if (provider === 'wasabi') {
|
|
secretKeyDescription.textContent = "Leave empty to keep the current Wasabi Secret Key";
|
|
} else if (provider === 'minio') {
|
|
secretKeyDescription.textContent = "Leave empty to keep the current MinIO Secret Key";
|
|
} else {
|
|
secretKeyDescription.textContent = "Leave empty to keep the current AWS Secret Access Key";
|
|
}
|
|
} else {
|
|
// New provider - provider specific text
|
|
if (provider === 'b2') {
|
|
secretKeyDescription.textContent = "Your Backblaze B2 Application Key";
|
|
} else if (provider === 'wasabi') {
|
|
secretKeyDescription.textContent = "Your Wasabi Secret Key";
|
|
} else if (provider === 'minio') {
|
|
secretKeyDescription.textContent = "Your MinIO Secret Key";
|
|
} else {
|
|
secretKeyDescription.textContent = "Your AWS Secret Access Key";
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// For B2: endpoint and region are optional
|
|
if (provider === 'b2') {
|
|
regionRequired.style.display = 'none';
|
|
endpointRequired.style.display = 'none';
|
|
regionInput.removeAttribute('required');
|
|
endpointInput.removeAttribute('required');
|
|
}
|
|
// For Wasabi: region is optional
|
|
else if (provider === 'wasabi') {
|
|
regionRequired.style.display = 'none';
|
|
endpointRequired.style.display = 'inline';
|
|
regionInput.removeAttribute('required');
|
|
endpointInput.setAttribute('required', 'required');
|
|
}
|
|
// For S3 and MinIO: both are required
|
|
else {
|
|
regionRequired.style.display = 'inline';
|
|
endpointRequired.style.display = 'inline';
|
|
regionInput.setAttribute('required', 'required');
|
|
endpointInput.setAttribute('required', 'required');
|
|
}
|
|
}
|
|
|
|
// Google services
|
|
if (['drive', 'gphotos'].includes(provider)) {
|
|
document.getElementById('cloud-fields').classList.remove('hidden');
|
|
document.getElementById('built-in-auth-field').classList.remove('hidden');
|
|
|
|
// Update client secret field description
|
|
const clientSecretField = document.getElementById('clientSecret');
|
|
if (clientSecretField) {
|
|
const clientSecretDescription = clientSecretField.nextElementSibling;
|
|
if (clientSecretDescription?.tagName === 'P') {
|
|
// Check if in edit mode
|
|
const editMode = clientSecretDescription.textContent.includes("Leave empty to keep");
|
|
|
|
if (editMode) {
|
|
// Edit mode - provider specific text
|
|
if (provider === 'drive') {
|
|
clientSecretDescription.textContent = "Leave empty to keep the current Google Drive client secret";
|
|
} else if (provider === 'gphotos') {
|
|
clientSecretDescription.textContent = "Leave empty to keep the current Google Photos client secret";
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Google Drive specific fields
|
|
if (provider === 'drive') {
|
|
document.getElementById('drive-id-field').classList.remove('hidden');
|
|
document.getElementById('team-drive-field').classList.remove('hidden');
|
|
}
|
|
|
|
// Google Photos specific fields
|
|
if (provider === 'gphotos') {
|
|
document.getElementById('gphotos-options-field').classList.remove('hidden');
|
|
}
|
|
}
|
|
|
|
// OneDrive
|
|
if (provider === 'onedrive') {
|
|
document.getElementById('cloud-fields').classList.remove('hidden');
|
|
|
|
// Update client secret field description
|
|
const clientSecretField = document.getElementById('clientSecret');
|
|
if (clientSecretField) {
|
|
const clientSecretDescription = clientSecretField.nextElementSibling;
|
|
if (clientSecretDescription?.tagName === 'P') {
|
|
// Check if in edit mode
|
|
const editMode = clientSecretDescription.textContent.includes("Leave empty to keep");
|
|
|
|
if (editMode) {
|
|
clientSecretDescription.textContent = "Leave empty to keep the current OneDrive client secret";
|
|
} else {
|
|
clientSecretDescription.textContent = "Your Microsoft Azure OAuth client secret";
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Local filesystem fields
|
|
if (provider === 'local') {
|
|
document.getElementById('local-fields').classList.remove('hidden');
|
|
}
|
|
}
|
|
</script>
|
|
}
|
|
|
|
// Test result dialog
|
|
templ ConnectionTestResult(success bool, message string, errorCode string) {
|
|
<div class="text-center">
|
|
if success {
|
|
<i class="fas fa-check-circle text-green-500 text-5xl mb-4"></i>
|
|
<h3 class="mb-2 text-lg font-semibold text-green-500 dark:text-green-400">Connection Successful</h3>
|
|
} else {
|
|
<i class="fas fa-times-circle text-red-500 text-5xl mb-4"></i>
|
|
<h3 class="mb-2 text-lg font-semibold text-red-500 dark:text-red-400">Connection Failed</h3>
|
|
}
|
|
|
|
<p class="text-gray-500 dark:text-gray-400 mb-4">
|
|
{ message }
|
|
</p>
|
|
|
|
if errorCode != "" {
|
|
<div class="text-sm bg-gray-100 dark:bg-gray-800 p-2 rounded">
|
|
<p class="text-gray-700 dark:text-gray-300">Error code: { errorCode }</p>
|
|
</div>
|
|
}
|
|
</div>
|
|
} |