mirror of
https://github.com/StarFleetCPTN/GoMFT.git
synced 2026-09-11 17:10:44 +02:00
Compare commits
121
Commits
v0.1.16
...
development
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ba40483e0 | ||
|
|
4b5929331f | ||
|
|
d19f13f137 | ||
|
|
25609dfe6f | ||
|
|
b9acf47530 | ||
|
|
495b844c00 | ||
|
|
98e20ea843 | ||
|
|
4c5accaae4 | ||
|
|
6e8fe9ad72 | ||
|
|
d9cb1cac03 | ||
|
|
5da298ef0b | ||
|
|
23db71c4a8 | ||
|
|
b819162732 | ||
|
|
dc665b9fa4 | ||
|
|
776c4971a6 | ||
|
|
130a3b0d1c | ||
|
|
31871bd16e | ||
|
|
37d507ade6 | ||
|
|
88d0ac815a | ||
|
|
ecb9ec874c | ||
|
|
ee97a7f9be | ||
|
|
5f4db03982 | ||
|
|
4c8d122488 | ||
|
|
05d4861dbb | ||
|
|
3d103cb6f6 | ||
|
|
2ada2b2ea6 | ||
|
|
7e630f9337 | ||
|
|
a59da16b4c | ||
|
|
adccd4314b | ||
|
|
4d570e502b | ||
|
|
d948849915 | ||
|
|
834f066704 | ||
|
|
0aa68bc8e7 | ||
|
|
e9a005b658 | ||
|
|
82f73cfe60 | ||
|
|
032e188a24 | ||
|
|
2dbf8de089 | ||
|
|
1c0459683b | ||
|
|
856a8403b3 | ||
|
|
dc6209dbc1 | ||
|
|
4b540faec4 | ||
|
|
2f6c95dd51 | ||
|
|
beb77d3fe9 | ||
|
|
152f137cad | ||
|
|
1358cb0e6d | ||
|
|
ca54b450de | ||
|
|
2221e6a29c | ||
|
|
db44823ecf | ||
|
|
66623693df | ||
|
|
e2ab2eb603 | ||
|
|
87bdfc5e91 | ||
|
|
78fe589fcf | ||
|
|
bcd09af2d6 | ||
|
|
de97ad6b50 | ||
|
|
417841b3d2 | ||
|
|
11f8631929 | ||
|
|
d650247817 | ||
|
|
46df27e8c6 | ||
|
|
bb75bc9400 | ||
|
|
8acdfc216d | ||
|
|
16e7b7e6e5 | ||
|
|
0f46b20fc4 | ||
|
|
3654161245 | ||
|
|
d691fc837e | ||
|
|
b9af8fc051 | ||
|
|
e14d90e37e | ||
|
|
71ec298db5 | ||
|
|
53cf4bc3c5 | ||
|
|
0f9a4cffd2 | ||
|
|
105d223be4 | ||
|
|
7a54a83c8d | ||
|
|
c52267bbb6 | ||
|
|
e93556e537 | ||
|
|
391cfbc50c | ||
|
|
608da9ce1c | ||
|
|
b35174f857 | ||
|
|
ae18cd1a12 | ||
|
|
68dd9fc173 | ||
|
|
f95720758e | ||
|
|
d4c07fdc8e | ||
|
|
28fa65df9d | ||
|
|
c52edb75df | ||
|
|
11fe75dd57 | ||
|
|
ec107ca0ce | ||
|
|
7172d4da90 | ||
|
|
badcbfdb17 | ||
|
|
748d5c0939 | ||
|
|
dd7426a01f | ||
|
|
fde60b7d68 | ||
|
|
297e5ca92b | ||
|
|
405b2e605e | ||
|
|
1f94641dee | ||
|
|
9249b84ddd | ||
|
|
a7cd021ff2 | ||
|
|
8424e97cf8 | ||
|
|
f128eff8de | ||
|
|
db1a0ad5f5 | ||
|
|
8e1321d251 | ||
|
|
ef0829aaa4 | ||
|
|
4d393e17ab | ||
|
|
d35f7bfe53 | ||
|
|
b91b0e8796 | ||
|
|
233f1779d8 | ||
|
|
0cebfcf1f5 | ||
|
|
fb3f834c36 | ||
|
|
30d0725331 | ||
|
|
3140d9e143 | ||
|
|
db6259f8a3 | ||
|
|
49a586db53 | ||
|
|
aa2149e2cf | ||
|
|
8dac5b390c | ||
|
|
faef3c2df9 | ||
|
|
874c7ad69e | ||
|
|
7de4f57fc3 | ||
|
|
9a49d87777 | ||
|
|
73362793ad | ||
|
|
d6bd471eb0 | ||
|
|
1a1df435de | ||
|
|
e031e67b3c | ||
|
|
402072540f | ||
|
|
be19a5efde |
@@ -7,7 +7,7 @@ args_bin = []
|
|||||||
bin = "./tmp/main"
|
bin = "./tmp/main"
|
||||||
cmd = "templ generate && go build -o ./tmp/main ."
|
cmd = "templ generate && go build -o ./tmp/main ."
|
||||||
delay = 1000
|
delay = 1000
|
||||||
exclude_dir = ["assets", "tmp", "vendor", "testdata", "node_modules"]
|
exclude_dir = ["assets", "tmp", "vendor", "testdata", "node_modules", "docs"]
|
||||||
exclude_file = []
|
exclude_file = []
|
||||||
exclude_regex = ["_test.go", "_templ.go"]
|
exclude_regex = ["_test.go", "_templ.go"]
|
||||||
exclude_unchanged = false
|
exclude_unchanged = false
|
||||||
|
|||||||
@@ -0,0 +1,224 @@
|
|||||||
|
name: Development Build
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- 'development'
|
||||||
|
- 'feature/**'
|
||||||
|
pull_request:
|
||||||
|
branches: [ development, feature/** ]
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
environment:
|
||||||
|
description: 'Environment to deploy to'
|
||||||
|
required: true
|
||||||
|
default: 'development'
|
||||||
|
type: choice
|
||||||
|
options:
|
||||||
|
- development
|
||||||
|
- staging
|
||||||
|
|
||||||
|
env:
|
||||||
|
# Use github.repository as the default image name
|
||||||
|
IMAGE_NAME: ${{ github.repository }}
|
||||||
|
REGISTRY: ghcr.io
|
||||||
|
DOCKERHUB_IMAGE: starfleetcptn/gomft
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: Build Development Binaries
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- name: Install Node.js dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Build frontend assets
|
||||||
|
run: |
|
||||||
|
# Build JavaScript and CSS assets
|
||||||
|
node build.js
|
||||||
|
|
||||||
|
# Ensure the dist directory exists
|
||||||
|
mkdir -p static/dist
|
||||||
|
|
||||||
|
# Verify the build output
|
||||||
|
ls -la static/dist
|
||||||
|
|
||||||
|
- name: Set up Go
|
||||||
|
uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version: '1.24.x'
|
||||||
|
cache: true
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
go mod download
|
||||||
|
# Install templ compiler for template generation
|
||||||
|
go install github.com/a-h/templ/cmd/templ@latest
|
||||||
|
|
||||||
|
- name: Generate template files
|
||||||
|
run: templ generate
|
||||||
|
|
||||||
|
- name: Set Version
|
||||||
|
id: version
|
||||||
|
run: |
|
||||||
|
# For development builds, use branch name or PR number with commit hash
|
||||||
|
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
|
||||||
|
VERSION="pr-${{ github.event.pull_request.number }}-$(git rev-parse --short HEAD)"
|
||||||
|
else
|
||||||
|
BRANCH=${GITHUB_REF#refs/heads/}
|
||||||
|
VERSION="${BRANCH//\//-}-$(git rev-parse --short HEAD)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "VERSION=$VERSION" >> $GITHUB_ENV
|
||||||
|
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
# Also set build timestamp for versioning
|
||||||
|
echo "BUILD_TIME=$(date -u +'%Y-%m-%d_%H:%M:%S')" >> $GITHUB_ENV
|
||||||
|
echo "COMMIT=$(git rev-parse --short HEAD)" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- name: Build for Linux (amd64)
|
||||||
|
run: |
|
||||||
|
mkdir -p dist
|
||||||
|
|
||||||
|
# Define common ldflags with version information
|
||||||
|
LDFLAGS="-X github.com/starfleetcptn/gomft/components.AppVersion=$VERSION -X main.Version=$VERSION -X main.BuildTime=$BUILD_TIME -X main.Commit=$COMMIT -X github.com/starfleetcptn/gomft/components.BuildTime=$BUILD_TIME -X github.com/starfleetcptn/gomft/components.Commit=$COMMIT"
|
||||||
|
|
||||||
|
# Only build for Linux amd64 for development builds
|
||||||
|
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -ldflags="$LDFLAGS" -o dist/gomft-$VERSION-linux-amd64 .
|
||||||
|
|
||||||
|
# Create checksums
|
||||||
|
cd dist
|
||||||
|
sha256sum * > SHA256SUMS.txt
|
||||||
|
|
||||||
|
- name: Upload artifacts
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: dev-binary
|
||||||
|
path: dist/
|
||||||
|
retention-days: 7 # Keep development builds for 7 days
|
||||||
|
|
||||||
|
docker:
|
||||||
|
name: Build and Push Development Docker Image
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: build
|
||||||
|
# Set the permissions needed for the GitHub token to push to GHCR
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
# Set up Node.js for frontend build
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
# Build frontend assets
|
||||||
|
- name: Build frontend assets
|
||||||
|
run: |
|
||||||
|
node build.js
|
||||||
|
ls -la static/dist/
|
||||||
|
|
||||||
|
# Set version information
|
||||||
|
- name: Set Version
|
||||||
|
id: version
|
||||||
|
run: |
|
||||||
|
# For development builds, use branch name or PR number with commit hash
|
||||||
|
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
|
||||||
|
VERSION="pr-${{ github.event.pull_request.number }}-$(git rev-parse --short HEAD)"
|
||||||
|
else
|
||||||
|
BRANCH=${GITHUB_REF#refs/heads/}
|
||||||
|
VERSION="${BRANCH//\//-}-$(git rev-parse --short HEAD)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "VERSION=$VERSION" >> $GITHUB_ENV
|
||||||
|
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
# Also set build timestamp for versioning
|
||||||
|
echo "BUILD_TIME=$(date -u +'%Y-%m-%d_%H:%M:%S')" >> $GITHUB_ENV
|
||||||
|
echo "COMMIT=$(git rev-parse --short HEAD)" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
# Set up Docker Buildx for efficient builds
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
# Login to GitHub Container Registry
|
||||||
|
- name: Log in to GitHub Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
# Login to DockerHub
|
||||||
|
- name: Log in to DockerHub
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
|
# Extract metadata for GitHub Container Registry
|
||||||
|
- name: Extract GitHub Container Registry metadata
|
||||||
|
id: meta-ghcr
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||||
|
tags: |
|
||||||
|
type=ref,event=branch
|
||||||
|
type=ref,event=pr
|
||||||
|
type=sha,format=short
|
||||||
|
type=raw,value=dev-latest
|
||||||
|
|
||||||
|
# Extract metadata for DockerHub
|
||||||
|
- name: Extract DockerHub metadata
|
||||||
|
id: meta-dockerhub
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ${{ env.DOCKERHUB_IMAGE }}
|
||||||
|
tags: |
|
||||||
|
type=ref,event=branch
|
||||||
|
type=ref,event=pr
|
||||||
|
type=sha,format=short
|
||||||
|
type=raw,value=dev-latest
|
||||||
|
|
||||||
|
# Build and push Docker image to both registries
|
||||||
|
- name: Build and push
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
${{ steps.meta-ghcr.outputs.tags }}
|
||||||
|
${{ steps.meta-dockerhub.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta-ghcr.outputs.labels }}
|
||||||
|
platforms: linux/amd64
|
||||||
|
build-args: |
|
||||||
|
VERSION=${{ env.VERSION }}
|
||||||
|
BUILD_TIME=${{ env.BUILD_TIME }}
|
||||||
|
COMMIT=${{ env.COMMIT }}
|
||||||
|
UID=1000
|
||||||
|
GID=1000
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
@@ -29,6 +29,23 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
fetch-depth: 0 # Needed to get all tags for versioning
|
fetch-depth: 0 # Needed to get all tags for versioning
|
||||||
|
|
||||||
|
# Set up Node.js for frontend build
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
# Build frontend assets
|
||||||
|
- name: Build frontend assets
|
||||||
|
run: |
|
||||||
|
node build.js
|
||||||
|
ls -la static/dist/
|
||||||
|
|
||||||
# Set version information
|
# Set version information
|
||||||
- name: Set Version
|
- name: Set Version
|
||||||
id: version
|
id: version
|
||||||
@@ -79,6 +96,7 @@ jobs:
|
|||||||
push: ${{ github.event_name != 'pull_request' }}
|
push: ${{ github.event_name != 'pull_request' }}
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||||
build-args: |
|
build-args: |
|
||||||
VERSION=${{ env.VERSION }}
|
VERSION=${{ env.VERSION }}
|
||||||
BUILD_TIME=${{ env.BUILD_TIME }}
|
BUILD_TIME=${{ env.BUILD_TIME }}
|
||||||
|
|||||||
@@ -30,6 +30,23 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
fetch-depth: 0 # Needed to get all tags for versioning
|
fetch-depth: 0 # Needed to get all tags for versioning
|
||||||
|
|
||||||
|
# Set up Node.js for frontend build
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
# Build frontend assets
|
||||||
|
- name: Build frontend assets
|
||||||
|
run: |
|
||||||
|
node build.js
|
||||||
|
ls -la static/dist/
|
||||||
|
|
||||||
# Set version information
|
# Set version information
|
||||||
- name: Set Version
|
- name: Set Version
|
||||||
id: version
|
id: version
|
||||||
@@ -80,6 +97,7 @@ jobs:
|
|||||||
push: ${{ github.event_name != 'pull_request' }}
|
push: ${{ github.event_name != 'pull_request' }}
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||||
build-args: |
|
build-args: |
|
||||||
VERSION=${{ env.VERSION }}
|
VERSION=${{ env.VERSION }}
|
||||||
BUILD_TIME=${{ env.BUILD_TIME }}
|
BUILD_TIME=${{ env.BUILD_TIME }}
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
name: Deploy Docusaurus to GitHub Pages
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
paths: ['docs/**']
|
||||||
|
# Allows you to run this workflow manually from the Actions tab
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pages: write
|
||||||
|
id-token: write
|
||||||
|
|
||||||
|
# Allow only one concurrent deployment
|
||||||
|
concurrency:
|
||||||
|
group: "pages"
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: Deploy Docusaurus
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: ./docs
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 18
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: ./docs/package-lock.json
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Copy screenshots to static directory
|
||||||
|
run: |
|
||||||
|
npm run prepare-screenshots
|
||||||
|
# Ensure the images referenced in the docs are definitely available
|
||||||
|
mkdir -p static/img
|
||||||
|
cp -v ../screenshots/dashboard.gomft.png static/img/
|
||||||
|
cp -v ../screenshots/transfer.config.gomft.png static/img/
|
||||||
|
ls -la static/img/dashboard.gomft.png static/img/transfer.config.gomft.png
|
||||||
|
|
||||||
|
- name: Fix Markdown image paths
|
||||||
|
run: npm run fix-image-paths
|
||||||
|
|
||||||
|
- name: Verify screenshots exist
|
||||||
|
run: |
|
||||||
|
echo "Checking if screenshots were copied correctly..."
|
||||||
|
if [ -f "static/img/dashboard.gomft.png" ]; then
|
||||||
|
echo "✅ Found dashboard.gomft.png in static/img/"
|
||||||
|
else
|
||||||
|
echo "❌ Missing dashboard.gomft.png in static/img/"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ -f "static/img/transfer.config.gomft.png" ]; then
|
||||||
|
echo "✅ Found transfer.config.gomft.png in static/img/"
|
||||||
|
else
|
||||||
|
echo "❌ Missing transfer.config.gomft.png in static/img/"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
- name: Setup Pages
|
||||||
|
uses: actions/configure-pages@v4
|
||||||
|
|
||||||
|
- name: Upload artifact
|
||||||
|
uses: actions/upload-pages-artifact@v3
|
||||||
|
with:
|
||||||
|
path: ./docs/build
|
||||||
|
|
||||||
|
- name: Deploy to GitHub Pages
|
||||||
|
id: deployment
|
||||||
|
uses: actions/deploy-pages@v4
|
||||||
@@ -27,6 +27,26 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- name: Install Node.js dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Build frontend assets
|
||||||
|
run: |
|
||||||
|
# Build JavaScript and CSS assets
|
||||||
|
node build.js
|
||||||
|
|
||||||
|
# Ensure the dist directory exists
|
||||||
|
mkdir -p static/dist
|
||||||
|
|
||||||
|
# Verify the build output
|
||||||
|
ls -la static/dist
|
||||||
|
|
||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
@@ -66,7 +86,7 @@ jobs:
|
|||||||
mkdir -p dist
|
mkdir -p dist
|
||||||
|
|
||||||
# Define common ldflags with version information
|
# Define common ldflags with version information
|
||||||
LDFLAGS="-X github.com/starfleetcptn/gomft/components.AppVersion=$VERSION -X main.Version=$VERSION -X main.BuildTime=$BUILD_TIME -X main.Commit=$COMMIT"
|
LDFLAGS="-X github.com/starfleetcptn/gomft/components.AppVersion=$VERSION -X main.Version=$VERSION -X main.BuildTime=$BUILD_TIME -X main.Commit=$COMMIT -X github.com/starfleetcptn/gomft/components.BuildTime=$BUILD_TIME -X github.com/starfleetcptn/gomft/components.Commit=$COMMIT"
|
||||||
|
|
||||||
# Linux builds
|
# Linux builds
|
||||||
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -ldflags="$LDFLAGS" -o dist/gomft-$VERSION-linux-amd64 .
|
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -ldflags="$LDFLAGS" -o dist/gomft-$VERSION-linux-amd64 .
|
||||||
|
|||||||
+37
-14
@@ -43,20 +43,7 @@ node_modules/
|
|||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
# Ignore all Go files in the components directory
|
# Ignore all Go files in the components directory
|
||||||
components/*.go
|
*_templ.go
|
||||||
!components/components.go
|
|
||||||
|
|
||||||
components/providers/*.go
|
|
||||||
!components/providers/providers.go
|
|
||||||
|
|
||||||
components/providers/source/*.go
|
|
||||||
!components/providers/source/source.go
|
|
||||||
|
|
||||||
components/providers/destination/*.go
|
|
||||||
!components/providers/destination/destination.go
|
|
||||||
|
|
||||||
components/providers/common/*.go
|
|
||||||
!components/providers/common/common.go
|
|
||||||
|
|
||||||
# Ignore the data directory
|
# Ignore the data directory
|
||||||
data/
|
data/
|
||||||
@@ -74,11 +61,47 @@ configs/
|
|||||||
# Ignore the backups directory
|
# Ignore the backups directory
|
||||||
backups/
|
backups/
|
||||||
|
|
||||||
|
# Ignore the tests directory
|
||||||
|
test-results/
|
||||||
|
playwright-report/
|
||||||
# Ignore Dirs
|
# Ignore Dirs
|
||||||
/source/
|
/source/
|
||||||
/destination/
|
/destination/
|
||||||
/archive/
|
/archive/
|
||||||
|
|
||||||
|
# Ignore the dist directory
|
||||||
|
static/dist/
|
||||||
|
|
||||||
# Ignore binaries
|
# Ignore binaries
|
||||||
gomft
|
gomft
|
||||||
|
|
||||||
|
# Docusaurus files
|
||||||
|
docs/.docusaurus/
|
||||||
|
docs/.cache-loader/
|
||||||
|
docs/build/
|
||||||
|
docs/build-searchindex/
|
||||||
|
docs/static/search/
|
||||||
|
docs/static/js/
|
||||||
|
docs/node_modules/
|
||||||
|
docs/.env*
|
||||||
|
docs/*.log
|
||||||
|
|
||||||
|
# Added by Claude Task Master
|
||||||
|
logs
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
dev-debug.log
|
||||||
|
# Environment variables
|
||||||
|
# Editor directories and files
|
||||||
|
.idea
|
||||||
|
.vscode
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
|
# OS specific
|
||||||
|
# Task files
|
||||||
|
tasks.json
|
||||||
|
tasks/
|
||||||
+71
-11
@@ -1,3 +1,32 @@
|
|||||||
|
# Build frontend assets
|
||||||
|
FROM node:20-alpine AS frontend-builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy all files needed for the build first
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
COPY build.js ./
|
||||||
|
COPY static/ ./static/
|
||||||
|
COPY tailwind.config.js ./
|
||||||
|
|
||||||
|
# Debug: Show the contents of build.js
|
||||||
|
RUN echo "Contents of build.js:" && cat build.js
|
||||||
|
|
||||||
|
# Remove the build script from postinstall
|
||||||
|
RUN sed -i 's/"postinstall": "npm run build",//' package.json
|
||||||
|
|
||||||
|
# Install dependencies and build with verbose output
|
||||||
|
RUN npm ci && \
|
||||||
|
echo "Building frontend assets..." && \
|
||||||
|
node build.js && \
|
||||||
|
echo "Build complete. Contents of dist:" && \
|
||||||
|
ls -la static/dist/ && \
|
||||||
|
echo "Sample of app.js:" && \
|
||||||
|
head -n 10 static/dist/app.js && \
|
||||||
|
echo "Sample of app.css:" && \
|
||||||
|
head -n 10 static/dist/app.css
|
||||||
|
|
||||||
|
# Go build stage
|
||||||
FROM golang:1.24-alpine AS builder
|
FROM golang:1.24-alpine AS builder
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
@@ -6,6 +35,10 @@ WORKDIR /app
|
|||||||
ARG VERSION=dev
|
ARG VERSION=dev
|
||||||
ARG BUILD_TIME=unknown
|
ARG BUILD_TIME=unknown
|
||||||
ARG COMMIT=unknown
|
ARG COMMIT=unknown
|
||||||
|
# Architecture-related build arguments
|
||||||
|
ARG TARGETOS=linux
|
||||||
|
ARG TARGETARCH=amd64
|
||||||
|
ARG TARGETVARIANT=""
|
||||||
|
|
||||||
# Install build dependencies
|
# Install build dependencies
|
||||||
RUN apk add --no-cache git build-base
|
RUN apk add --no-cache git build-base
|
||||||
@@ -17,22 +50,47 @@ RUN go install github.com/a-h/templ/cmd/templ@latest
|
|||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
|
|
||||||
# Copy the rest of the source code
|
# Create static directory structure
|
||||||
|
RUN mkdir -p /app/static/dist
|
||||||
|
|
||||||
|
# Copy built frontend assets from frontend-builder BEFORE copying Go source
|
||||||
|
COPY --from=frontend-builder /app/static/dist/ /app/static/dist/
|
||||||
|
|
||||||
|
# Copy the rest of the static files
|
||||||
|
COPY static/ /app/static/
|
||||||
|
|
||||||
|
# Verify static files are in place before Go build
|
||||||
|
RUN echo "Verifying static files before Go build:" && \
|
||||||
|
ls -la /app/static/dist/
|
||||||
|
|
||||||
|
# Now copy the rest of the source code
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Generate template files from .templ files
|
# Generate template files from .templ files
|
||||||
RUN templ generate
|
RUN templ generate
|
||||||
|
|
||||||
# Compile the application with version information
|
# Compile the main application with version information
|
||||||
RUN CGO_ENABLED=0 GOOS=linux go build \
|
RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build \
|
||||||
-ldflags "-X github.com/starfleetcptn/gomft/components.AppVersion=${VERSION} -X main.Version=${VERSION} -X main.BuildTime=${BUILD_TIME} -X main.Commit=${COMMIT} -X github.com/starfleetcptn/gomft/components.BuildTime=${BUILD_TIME} -X github.com/starfleetcptn/gomft/components.Commit=${COMMIT}" \
|
-ldflags "-X github.com/starfleetcptn/gomft/components.AppVersion=${VERSION} -X main.Version=${VERSION} -X main.BuildTime=${BUILD_TIME} -X main.Commit=${COMMIT} -X github.com/starfleetcptn/gomft/components.BuildTime=${BUILD_TIME} -X github.com/starfleetcptn/gomft/components.Commit=${COMMIT}" \
|
||||||
-o /app/gomft
|
-o /app/gomft
|
||||||
|
|
||||||
# Install rclone
|
# Compile the command line tool with version information
|
||||||
|
RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build \
|
||||||
|
-ldflags "-X main.Version=${VERSION} -X main.BuildTime=${BUILD_TIME} -X main.Commit=${COMMIT}" \
|
||||||
|
-o /app/gomftctl ./cmd/gomftctl
|
||||||
|
|
||||||
|
# Install rclone with appropriate architecture
|
||||||
RUN apk add --no-cache curl unzip && \
|
RUN apk add --no-cache curl unzip && \
|
||||||
curl -O https://downloads.rclone.org/rclone-current-linux-amd64.zip && \
|
if [ "$TARGETARCH" = "arm64" ]; then \
|
||||||
unzip rclone-current-linux-amd64.zip && \
|
RCLONE_ARCH="arm64"; \
|
||||||
cd rclone-*-linux-amd64 && \
|
elif [ "$TARGETARCH" = "arm" ]; then \
|
||||||
|
RCLONE_ARCH="arm-v7"; \
|
||||||
|
else \
|
||||||
|
RCLONE_ARCH="amd64"; \
|
||||||
|
fi && \
|
||||||
|
curl -O https://downloads.rclone.org/rclone-current-linux-${RCLONE_ARCH}.zip && \
|
||||||
|
unzip rclone-current-linux-${RCLONE_ARCH}.zip && \
|
||||||
|
cd rclone-*-linux-${RCLONE_ARCH} && \
|
||||||
cp rclone /usr/local/bin/ && \
|
cp rclone /usr/local/bin/ && \
|
||||||
chmod 755 /usr/local/bin/rclone && \
|
chmod 755 /usr/local/bin/rclone && \
|
||||||
cd .. && \
|
cd .. && \
|
||||||
@@ -45,6 +103,8 @@ FROM alpine:3.19
|
|||||||
ARG UID=1000
|
ARG UID=1000
|
||||||
ARG GID=1000
|
ARG GID=1000
|
||||||
ARG USERNAME=gomft
|
ARG USERNAME=gomft
|
||||||
|
ARG TARGETOS
|
||||||
|
ARG TARGETARCH
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -57,12 +117,12 @@ RUN apk add --no-cache ca-certificates tzdata sqlite bash shadow su-exec \
|
|||||||
RUN addgroup -g ${GID} ${USERNAME} && \
|
RUN addgroup -g ${GID} ${USERNAME} && \
|
||||||
adduser -D -u ${UID} -G ${USERNAME} -s /bin/sh ${USERNAME}
|
adduser -D -u ${UID} -G ${USERNAME} -s /bin/sh ${USERNAME}
|
||||||
|
|
||||||
# Copy the binary from the builder stage
|
# Copy the binaries from the builder stage
|
||||||
COPY --from=builder /app/gomft /app/
|
COPY --from=builder /app/gomft /app/
|
||||||
|
COPY --from=builder /app/gomftctl /app/
|
||||||
COPY --from=builder /usr/local/bin/rclone /usr/local/bin/rclone
|
COPY --from=builder /usr/local/bin/rclone /usr/local/bin/rclone
|
||||||
|
|
||||||
# Copy static files and configurations
|
# Copy components
|
||||||
COPY static/ /app/static/
|
|
||||||
COPY components/ /app/components/
|
COPY components/ /app/components/
|
||||||
|
|
||||||
# Copy entrypoint script
|
# Copy entrypoint script
|
||||||
@@ -76,7 +136,7 @@ RUN mkdir -p /app/data /app/backups
|
|||||||
RUN touch /app/.env && chmod 644 /app/.env && chown ${USERNAME}:${USERNAME} /app/.env
|
RUN touch /app/.env && chmod 644 /app/.env && chown ${USERNAME}:${USERNAME} /app/.env
|
||||||
|
|
||||||
# Set executable permissions
|
# Set executable permissions
|
||||||
RUN chmod +x /app/gomft
|
RUN chmod +x /app/gomft /app/gomftctl
|
||||||
|
|
||||||
# Set ownership of application files
|
# Set ownership of application files
|
||||||
RUN chown -R ${USERNAME}:${USERNAME} /app
|
RUN chown -R ${USERNAME}:${USERNAME} /app
|
||||||
|
|||||||
@@ -6,6 +6,29 @@
|
|||||||
|
|
||||||
GoMFT is a web-based managed file transfer application built with Go, leveraging rclone for robust file transfer capabilities. It provides a user-friendly interface for configuring, scheduling, and monitoring file transfers across various storage providers.
|
GoMFT is a web-based managed file transfer application built with Go, leveraging rclone for robust file transfer capabilities. It provides a user-friendly interface for configuring, scheduling, and monitoring file transfers across various storage providers.
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://discord.gg/f9dwtM3j">
|
||||||
|
<img src="https://img.shields.io/discord/1351354052654403675?color=7289da&logo=discord&logoColor=white&label=Discord" alt="Join our Discord server!" />
|
||||||
|
</a>
|
||||||
|
<a href="https://starfleetcptn.github.io/GoMFT/">
|
||||||
|
<img src="https://img.shields.io/badge/docs-online-blue.svg" alt="Documentation" />
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
## 📚 Documentation
|
||||||
|
|
||||||
|
Comprehensive documentation is available at [https://starfleetcptn.github.io/GoMFT/](https://starfleetcptn.github.io/GoMFT/).
|
||||||
|
|
||||||
|
The documentation includes:
|
||||||
|
- [Getting Started Guide](https://starfleetcptn.github.io/GoMFT/docs/getting-started)
|
||||||
|
- [Installation Instructions](https://starfleetcptn.github.io/GoMFT/docs/installation)
|
||||||
|
- [Configuration Reference](https://starfleetcptn.github.io/GoMFT/docs/configuration)
|
||||||
|
- [User Guide](https://starfleetcptn.github.io/GoMFT/docs/user-guide)
|
||||||
|
- [Storage Provider Setup](https://starfleetcptn.github.io/GoMFT/docs/storage-providers)
|
||||||
|
- [Advanced Features](https://starfleetcptn.github.io/GoMFT/docs/advanced)
|
||||||
|
- [Troubleshooting](https://starfleetcptn.github.io/GoMFT/docs/troubleshooting)
|
||||||
|
- [API Reference](https://starfleetcptn.github.io/GoMFT/docs/api)
|
||||||
|
|
||||||
> [!WARNING]
|
> [!WARNING]
|
||||||
> This application is actively under development. As such, any aspect of the application—including configurations, data structures, and database fields—may change rapidly and without prior notice. Please review all release notes thoroughly before updating.
|
> This application is actively under development. As such, any aspect of the application—including configurations, data structures, and database fields—may change rapidly and without prior notice. Please review all release notes thoroughly before updating.
|
||||||
|
|
||||||
@@ -44,14 +67,20 @@ GoMFT is a web-based managed file transfer application built with Go, leveraging
|
|||||||
- SFTP
|
- SFTP
|
||||||
- FTP
|
- FTP
|
||||||
- SMB/CIFS shares
|
- SMB/CIFS shares
|
||||||
|
- Hetzner Storage Box
|
||||||
|
- Backblaze B2
|
||||||
|
- Wasabi
|
||||||
- Local filesystem
|
- Local filesystem
|
||||||
- And more via rclone
|
- And more via rclone
|
||||||
- **Webhook Notifications**: Receive real-time notifications of job events:
|
- **Multiple Notification Services**: Get job status updates through various notification channels:
|
||||||
- Configurable webhook URLs
|
- Email notifications with configurable SMTP settings
|
||||||
- HMAC-SHA256 authentication with secrets
|
- Webhooks with authentication for custom integrations
|
||||||
- Custom HTTP headers
|
- Pushbullet notifications with optional device targeting
|
||||||
- Selectable events (job success, job failure)
|
- Ntfy.sh (both public and self-hosted) for simple push notifications
|
||||||
- Detailed JSON payload with job information
|
- Gotify server integration for self-hosted notifications
|
||||||
|
- Pushover notifications with customizable sounds and priorities
|
||||||
|
- Configurable message templates for all notification types
|
||||||
|
- Event-based triggers (job start, completion, errors)
|
||||||
- **Scheduled Transfers**: Configure transfers using cron expressions with flexible scheduling options
|
- **Scheduled Transfers**: Configure transfers using cron expressions with flexible scheduling options
|
||||||
- **Transfer Monitoring**: Real-time status updates and detailed transfer logs with bytes and files transferred statistics
|
- **Transfer Monitoring**: Real-time status updates and detailed transfer logs with bytes and files transferred statistics
|
||||||
- **File Metadata Tracking**: Complete history and status of all transferred files with detailed information:
|
- **File Metadata Tracking**: Complete history and status of all transferred files with detailed information:
|
||||||
@@ -71,12 +100,21 @@ GoMFT is a web-based managed file transfer application built with Go, leveraging
|
|||||||
- Optimized for both high-volume small files and large file transfers
|
- Optimized for both high-volume small files and large file transfers
|
||||||
- Maximizes bandwidth utilization for cloud storage providers
|
- Maximizes bandwidth utilization for cloud storage providers
|
||||||
- **Web Interface**: User-friendly interface for managing transfers, built with Templ components
|
- **Web Interface**: User-friendly interface for managing transfers, built with Templ components
|
||||||
|
- **Command Line Tools**: Administrative tasks can be performed via the `gomftctl` CLI tool
|
||||||
- **File Pattern Matching**: Support for file patterns to filter files during transfers
|
- **File Pattern Matching**: Support for file patterns to filter files during transfers
|
||||||
- **File Output Patterns**: Dynamic naming of destination files using patterns with date variables
|
- **File Output Patterns**: Dynamic naming of destination files using patterns with date variables
|
||||||
- **Archive Function**: Option to archive transferred files for backup and compliance
|
- **Archive Function**: Option to archive transferred files for backup and compliance
|
||||||
- **Transfer Configurations**: Full control over source and destination connection parameters
|
- **Transfer Configurations**: Full control over source and destination connection parameters
|
||||||
- **Job Management**: Create, edit, and monitor transfer jobs with scheduling
|
- **Job Management**: Create, edit, and monitor transfer jobs with scheduling
|
||||||
- **Security**: Role-based access control with admin-managed user accounts and secure password management
|
- **Security**: Role-based access control with admin-managed user accounts and secure password management
|
||||||
|
- **Authentication Providers**: Flexible authentication options:
|
||||||
|
- Built-in email/password authentication
|
||||||
|
- Authentik integration for enterprise SSO
|
||||||
|
- OpenID Connect (OIDC) support for standard identity providers
|
||||||
|
- OAuth2 integration for popular providers (Google, GitHub, etc.)
|
||||||
|
- Multiple provider support with fallback options
|
||||||
|
- Automatic user provisioning from external providers
|
||||||
|
- Role mapping from external identity providers
|
||||||
- **Password Recovery**: Self-service password reset via email with secure token-based authentication
|
- **Password Recovery**: Self-service password reset via email with secure token-based authentication
|
||||||
- **User Profile Management**: Personal settings including theme preferences
|
- **User Profile Management**: Personal settings including theme preferences
|
||||||
- **Modern UI**: Built with Templ, HTMX and Tailwind CSS for a responsive experience
|
- **Modern UI**: Built with Templ, HTMX and Tailwind CSS for a responsive experience
|
||||||
@@ -106,11 +144,18 @@ cd gomft
|
|||||||
2. Install dependencies:
|
2. Install dependencies:
|
||||||
```bash
|
```bash
|
||||||
go mod download
|
go mod download
|
||||||
|
go install github.com/a-h/templ/cmd/templ@latest
|
||||||
```
|
```
|
||||||
|
|
||||||
3. Build the application:
|
3. Generate template code:
|
||||||
|
```bash
|
||||||
|
templ generate
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Build the application and CLI tools:
|
||||||
```bash
|
```bash
|
||||||
go build -o gomft
|
go build -o gomft
|
||||||
|
go build -o gomftctl ./cmd/gomftctl
|
||||||
```
|
```
|
||||||
|
|
||||||
### Docker Installation
|
### Docker Installation
|
||||||
@@ -201,6 +246,7 @@ services:
|
|||||||
- GOOGLE_CLIENT_ID=your_google_client_id
|
- GOOGLE_CLIENT_ID=your_google_client_id
|
||||||
- GOOGLE_CLIENT_SECRET=your_google_client_secret
|
- GOOGLE_CLIENT_SECRET=your_google_client_secret
|
||||||
- TOTP_ENCRYPTION_KEY=your_32_byte_encryption_key_here
|
- TOTP_ENCRYPTION_KEY=your_32_byte_encryption_key_here
|
||||||
|
- GOMFT_ENCRYPTION_KEY=your_32_byte_encryption_key_here
|
||||||
- EMAIL_ENABLED=true
|
- EMAIL_ENABLED=true
|
||||||
- EMAIL_HOST=smtp.example.com
|
- EMAIL_HOST=smtp.example.com
|
||||||
- EMAIL_PORT=587
|
- EMAIL_PORT=587
|
||||||
@@ -226,527 +272,40 @@ docker-compose up -d
|
|||||||
|
|
||||||
For more information and available tags, visit the [GoMFT Docker Hub page](https://hub.docker.com/r/starfleetcptn/gomft).
|
For more information and available tags, visit the [GoMFT Docker Hub page](https://hub.docker.com/r/starfleetcptn/gomft).
|
||||||
|
|
||||||
---
|
## Command Line Tools
|
||||||
|
|
||||||
## Configuration
|
GoMFT includes a command line tool called `gomftctl` for administrative tasks:
|
||||||
|
|
||||||
GoMFT uses an environment file located at `.env` in the root directory of the application. On first run, a default configuration will be created:
|
|
||||||
|
|
||||||
```ini
|
|
||||||
# Basic configuration
|
|
||||||
SERVER_ADDRESS=:8080
|
|
||||||
DATA_DIR=/app/data
|
|
||||||
BACKUP_DIR=/app/backups
|
|
||||||
JWT_SECRET=change_this_to_a_secure_random_string
|
|
||||||
BASE_URL=http://localhost:8080
|
|
||||||
|
|
||||||
# Google OAuth configuration (optional, for built-in authentication)
|
|
||||||
GOOGLE_CLIENT_ID=your_google_client_id
|
|
||||||
GOOGLE_CLIENT_SECRET=your_google_client_secret
|
|
||||||
|
|
||||||
# Email configuration
|
|
||||||
EMAIL_ENABLED=true
|
|
||||||
EMAIL_HOST=smtp.example.com
|
|
||||||
EMAIL_PORT=587
|
|
||||||
EMAIL_FROM_EMAIL=gomft@example.com
|
|
||||||
EMAIL_FROM_NAME=GoMFT
|
|
||||||
EMAIL_REPLY_TO=
|
|
||||||
EMAIL_ENABLE_TLS=true
|
|
||||||
EMAIL_REQUIRE_AUTH=true
|
|
||||||
EMAIL_USERNAME=smtp_username
|
|
||||||
EMAIL_PASSWORD=smtp_password
|
|
||||||
|
|
||||||
# Two-Factor Authentication configuration
|
|
||||||
TOTP_ENCRYPTION_KEY=your_32_byte_encryption_key_here
|
|
||||||
|
|
||||||
# UserID and GroupID
|
|
||||||
PUID=1000
|
|
||||||
PGID=1000
|
|
||||||
```
|
|
||||||
|
|
||||||
### Configuration Options
|
|
||||||
|
|
||||||
- `SERVER_ADDRESS`: The address and port to run the server on
|
|
||||||
- `DATA_DIR`: Directory for storing application data (database and configs)
|
|
||||||
- `BACKUP_DIR`: Directory for storing database backups
|
|
||||||
- `JWT_SECRET`: Secret key for JWT token generation
|
|
||||||
- `BASE_URL`: Base URL for generating links in emails (e.g., password reset links)
|
|
||||||
- Google OAuth configuration for built-in authentication:
|
|
||||||
- `GOOGLE_CLIENT_ID`: Your Google OAuth client ID
|
|
||||||
- `GOOGLE_CLIENT_SECRET`: Your Google OAuth client secret
|
|
||||||
- Email configuration settings for system notifications and password resets:
|
|
||||||
- `EMAIL_ENABLED`: Set to `true` to enable email functionality
|
|
||||||
- `EMAIL_HOST`: SMTP server hostname
|
|
||||||
- `EMAIL_PORT`: SMTP server port (usually 587 for TLS, 465 for SSL, or 25 for non-secure)
|
|
||||||
- `EMAIL_USERNAME`: Username for SMTP authentication
|
|
||||||
- `EMAIL_PASSWORD`: Password for SMTP authentication
|
|
||||||
- `EMAIL_FROM_EMAIL`: Email address used as sender
|
|
||||||
- `EMAIL_FROM_NAME`: Name displayed as the sender
|
|
||||||
- `EMAIL_REPLY_TO`: Optional reply-to email address
|
|
||||||
- `EMAIL_ENABLE_TLS`: Set to `true` to use TLS for secure email transmission
|
|
||||||
- `EMAIL_REQUIRE_AUTH`: Set to `true` to require authentication for SMTP connections, or `false` for servers that don't need authentication
|
|
||||||
|
|
||||||
- Two-Factor Authentication (2FA) configuration:
|
|
||||||
- `TOTP_ENCRYPTION_KEY`: Secret key used to encrypt/decrypt TOTP secrets (for 2FA)
|
|
||||||
- Should be exactly 32 bytes (characters) for optimal security
|
|
||||||
- If not set, a default development key will be used (not secure for production)
|
|
||||||
- If shorter than 32 bytes, it will be automatically padded (less secure)
|
|
||||||
- If longer than 32 bytes, it will be truncated to 32 bytes
|
|
||||||
- Example: `TOTP_ENCRYPTION_KEY=abcdefghijklmnopqrstuvwxyz123456`
|
|
||||||
|
|
||||||
### Logging Configuration
|
|
||||||
|
|
||||||
GoMFT provides configurable logging with rotation support through the following environment variables:
|
|
||||||
|
|
||||||
- `LOGS_DIR`: Directory where log files are stored (default: `./data/logs`)
|
|
||||||
- `LOG_MAX_SIZE`: Maximum size in megabytes for each log file before rotation (default: `10`)
|
|
||||||
- `LOG_MAX_BACKUPS`: Number of old log files to retain (default: `5`)
|
|
||||||
- `LOG_MAX_AGE`: Maximum number of days to retain old log files (default: `30`)
|
|
||||||
- `LOG_COMPRESS`: Whether to compress rotated log files (default: `true`)
|
|
||||||
- `LOG_LEVEL`: Controls verbosity level of logging (values: `error`, `info`, `debug`, default: `info`)
|
|
||||||
- `error`: Only show errors and critical issues
|
|
||||||
- `info`: Show errors and general operational information (default)
|
|
||||||
- `debug`: Show all messages including detailed debugging information
|
|
||||||
|
|
||||||
Log files contain detailed information about file transfers, job execution, and system operations, which can be useful for troubleshooting and auditing.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
1. Start the server:
|
|
||||||
```bash
|
```bash
|
||||||
./gomft
|
# View available commands
|
||||||
|
./gomftctl --help
|
||||||
|
|
||||||
|
# Migrate provider data
|
||||||
|
./gomftctl migrate-providers
|
||||||
|
|
||||||
|
# Rotate security keys
|
||||||
|
./gomftctl rotate-key --type jwt
|
||||||
|
|
||||||
|
# Manage users
|
||||||
|
./gomftctl user create --email admin@example.com --password secure_password --admin
|
||||||
|
./gomftctl user list
|
||||||
|
|
||||||
|
# Backup database
|
||||||
|
./gomftctl backup
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Access the web interface at `http://localhost:8080`
|
See the [Admin Tools documentation](https://starfleetcptn.github.io/GoMFT/docs/advanced/admin-tools) for more details.
|
||||||
|
|
||||||
3. Log in with the default admin account:
|
|
||||||
- Email: `admin@example.com`
|
|
||||||
- Password: `admin`
|
|
||||||
- **Important**: Change this password immediately after first login
|
|
||||||
|
|
||||||
4. Create transfer configurations:
|
|
||||||
- Navigate to "Transfer Configs" section
|
|
||||||
- Configure source and destination locations with connection details
|
|
||||||
- Set file patterns and archive options as needed
|
|
||||||
- Configure performance settings:
|
|
||||||
- Set "Concurrent Transfers" slider to optimize throughput
|
|
||||||
- Use higher values (8-16) for many small files or fast networks
|
|
||||||
- Use lower values (1-4) for large files or limited bandwidth
|
|
||||||
- Consider source/destination system capabilities when setting
|
|
||||||
|
|
||||||
5. Create jobs using your configurations:
|
|
||||||
- Navigate to "Jobs" section
|
|
||||||
- Select an existing transfer config
|
|
||||||
- Set up a schedule using cron expressions or run manually
|
|
||||||
- Enable/disable as needed
|
|
||||||
|
|
||||||
6. Monitor transfers:
|
|
||||||
- View active and completed transfers on the Dashboard
|
|
||||||
- Check detailed transfer history with performance metrics
|
|
||||||
- View job run details including any error messages
|
|
||||||
|
|
||||||
7. Configure webhook notifications:
|
|
||||||
- Enable webhooks in job settings to receive notifications
|
|
||||||
- Provide a valid webhook URL where notifications will be sent
|
|
||||||
- Optionally set a webhook secret for HMAC-SHA256 signature verification
|
|
||||||
- Configure custom HTTP headers in JSON format if needed
|
|
||||||
- Choose notification triggers (job success, job failure, or both)
|
|
||||||
- Test your webhook integration with manual job runs
|
|
||||||
|
|
||||||
8. Manage file metadata:
|
|
||||||
- Navigate to the "Files" section to view all processed files
|
|
||||||
- Use filters to quickly find files by status, job ID, or filename
|
|
||||||
- Click on any file to view detailed metadata including timestamps, size, and hash
|
|
||||||
- Use the advanced search page for complex queries with multiple criteria
|
|
||||||
- Delete file metadata records when no longer needed
|
|
||||||
- View files associated with specific jobs by navigating from the job details
|
|
||||||
|
|
||||||
9. Utilize admin tools (administrators only):
|
|
||||||
- Access the "Admin Tools" section from the navigation menu
|
|
||||||
- View system statistics and server information
|
|
||||||
- Create and manage database backups
|
|
||||||
- Browse and download system log files with the integrated log viewer
|
|
||||||
- Perform database maintenance and optimization tasks
|
|
||||||
- View webhook documentation and integration details
|
|
||||||
|
|
||||||
### User Management
|
|
||||||
|
|
||||||
GoMFT uses a role-based access control system:
|
|
||||||
|
|
||||||
- **Administrators**: Can create and manage users, access all features
|
|
||||||
- **Regular Users**: Can manage transfers and view history
|
|
||||||
|
|
||||||
User management features:
|
|
||||||
- Only administrators can create new user accounts
|
|
||||||
- User passwords are securely hashed with bcrypt
|
|
||||||
- Password history tracking prevents reuse of recent passwords
|
|
||||||
- Account lockout after multiple failed login attempts
|
|
||||||
- Self-service password reset via secure email links
|
|
||||||
- JWT-based authentication with tokens
|
|
||||||
- User theme preference settings (light/dark)
|
|
||||||
|
|
||||||
### Two-Factor Authentication (2FA) Implementation
|
|
||||||
|
|
||||||
#### Overview
|
|
||||||
This implementation adds TOTP-based (Time-based One-Time Password) two-factor authentication support to the application, compatible with standard authenticator apps like Google Authenticator, Authy, and others.
|
|
||||||
|
|
||||||
#### Features
|
|
||||||
- TOTP-based authentication (RFC 6238 compliant)
|
|
||||||
- QR code setup for easy enrollment
|
|
||||||
- Backup codes for account recovery
|
|
||||||
- Rate-limited verification attempts
|
|
||||||
- Secure secret storage
|
|
||||||
|
|
||||||
#### Database Changes
|
|
||||||
The following fields have been added to the `users` table:
|
|
||||||
- `two_factor_secret`: Stores the TOTP secret key
|
|
||||||
- `two_factor_enabled`: Boolean flag indicating if 2FA is enabled
|
|
||||||
- `backup_codes`: Stores recovery backup codes
|
|
||||||
|
|
||||||
#### Setup Process
|
|
||||||
1. Navigate to `/profile/2fa/setup`
|
|
||||||
2. Scan the displayed QR code with your authenticator app
|
|
||||||
3. Enter the verification code to confirm setup
|
|
||||||
4. Save your backup codes in a secure location
|
|
||||||
|
|
||||||
#### Login Flow
|
|
||||||
1. Enter email and password as usual
|
|
||||||
2. If 2FA is enabled:
|
|
||||||
- Enter the 6-digit code from your authenticator app
|
|
||||||
- Alternatively, use a backup code if you can't access your authenticator
|
|
||||||
|
|
||||||
#### Security Considerations
|
|
||||||
- The TOTP secrets are encrypted using AES-256-GCM
|
|
||||||
- You must set the `TOTP_ENCRYPTION_KEY` environment variable in production
|
|
||||||
- This key should be 32 bytes (characters) long and kept confidential
|
|
||||||
- Changing this key after users have set up 2FA will invalidate their existing 2FA configurations
|
|
||||||
- For high-security deployments, store this key in a secure vault and inject it at runtime
|
|
||||||
|
|
||||||
### Transfer Configuration Options
|
|
||||||
|
|
||||||
1. **Source/Destination Types**:
|
|
||||||
- Google Drive
|
|
||||||
- Google Photos
|
|
||||||
- Local filesystem
|
|
||||||
- Amazon S3
|
|
||||||
- MinIO (S3-compatible storage)
|
|
||||||
- SFTP
|
|
||||||
- FTP
|
|
||||||
- SMB/CIFS shares
|
|
||||||
- And many more via rclone
|
|
||||||
|
|
||||||
2. **Connection Options**:
|
|
||||||
- Host/server addresses
|
|
||||||
- Authentication (username/password or key files)
|
|
||||||
- OAuth2 authentication for Google services
|
|
||||||
- Port configurations
|
|
||||||
- Cloud credentials (access keys, secret keys)
|
|
||||||
- Bucket and region settings
|
|
||||||
- Custom endpoints
|
|
||||||
- Custom rclone flags
|
|
||||||
|
|
||||||
3. **Google Photos Specific Options**:
|
|
||||||
- Read-only mode for safer operations
|
|
||||||
- Start year filter for historical photos
|
|
||||||
- Include/exclude archived media
|
|
||||||
- Album path configuration
|
|
||||||
- Built-in or custom OAuth authentication
|
|
||||||
|
|
||||||
4. **Google Drive Specific Options**:
|
|
||||||
- Folder ID for specific directory access
|
|
||||||
- Team/Shared Drive ID support
|
|
||||||
- Built-in or custom OAuth authentication
|
|
||||||
- Path-based navigation
|
|
||||||
|
|
||||||
5. **File Options**:
|
|
||||||
- File patterns for filtering (e.g., `*.txt`, `data_*.csv`)
|
|
||||||
- Output patterns for dynamic naming
|
|
||||||
- Archive options for transferred files
|
|
||||||
- Skip already processed files to avoid duplicates
|
|
||||||
- Concurrent file transfers (configurable per job)
|
|
||||||
|
|
||||||
6. **Performance Options**:
|
|
||||||
- **Multi-threaded File Transfers**: Process multiple files simultaneously for higher throughput
|
|
||||||
- Configurable concurrency level (1-32 concurrent transfers)
|
|
||||||
- Per-job concurrency settings to optimize for different storage types
|
|
||||||
- Automatic transfer queue management to prevent overloading systems
|
|
||||||
- Adaptive processing based on source/destination capabilities
|
|
||||||
|
|
||||||
7. **Schedule Options**:
|
|
||||||
- Cron expressions for flexible scheduling
|
|
||||||
- Manual execution
|
|
||||||
- Enable/disable schedules
|
|
||||||
|
|
||||||
8. **Webhook Notifications**:
|
|
||||||
- **Webhook Integration**: Send notifications to external systems when jobs complete
|
|
||||||
- **Secure Authentication**: HMAC-SHA256 signature for webhook verification
|
|
||||||
- **Custom Headers**: Add custom HTTP headers to webhook requests
|
|
||||||
- **Flexible Configuration**: Configure different webhooks for different jobs
|
|
||||||
- **Event Selection**: Choose to send notifications on success, failure, or both
|
|
||||||
- **Detailed Payload**: Rich JSON payload with complete job execution details
|
|
||||||
|
|
||||||
### Email Notifications
|
|
||||||
|
|
||||||
GoMFT supports email notifications for various features:
|
|
||||||
|
|
||||||
- **Password Reset**: Users can request password reset links sent to their registered email
|
|
||||||
- **Styled Emails**: Professional HTML emails that match the application's design theme
|
|
||||||
- **Secure Tokens**: One-time use secure tokens with 15-minute expiration for enhanced security
|
|
||||||
- **Flexible Configuration**: Easily configure your SMTP server settings
|
|
||||||
- **Authentication Options**: Support for both authenticated and unauthenticated SMTP servers
|
|
||||||
- **TLS Support**: Secure communication with your SMTP server
|
|
||||||
- **Development Mode**: When emails are disabled, reset links are logged to the console
|
|
||||||
|
|
||||||
To configure email functionality:
|
|
||||||
|
|
||||||
1. Edit the `.env` file and provide your SMTP server details
|
|
||||||
2. Set `EMAIL_ENABLED=true` in the email configuration section
|
|
||||||
3. Ensure the `BASE_URL` setting is configured correctly for your deployment
|
|
||||||
|
|
||||||
### Webhook Integration
|
|
||||||
|
|
||||||
GoMFT can send webhook notifications to external systems when jobs complete. This allows integration with monitoring tools, chat applications, custom notification systems, or workflow automation platforms.
|
|
||||||
|
|
||||||
#### Webhook Payload Structure
|
|
||||||
|
|
||||||
Webhook notifications are sent as HTTP POST requests with a JSON payload containing detailed information about the job execution:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"event_type": "job_execution",
|
|
||||||
"job_id": 123,
|
|
||||||
"job_name": "Daily Backup",
|
|
||||||
"config_id": 456,
|
|
||||||
"config_name": "S3 to Local Backup",
|
|
||||||
"status": "completed",
|
|
||||||
"start_time": "2023-07-14T15:30:00Z",
|
|
||||||
"end_time": "2023-07-14T15:35:42Z",
|
|
||||||
"duration_seconds": 342,
|
|
||||||
"bytes_transferred": 1048576,
|
|
||||||
"files_transferred": 25,
|
|
||||||
"history_id": 789,
|
|
||||||
"source": {
|
|
||||||
"type": "s3",
|
|
||||||
"path": "my-bucket/data"
|
|
||||||
},
|
|
||||||
"destination": {
|
|
||||||
"type": "local",
|
|
||||||
"path": "/backups/data"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
For failed transfers, additional error information is included:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"status": "failed",
|
|
||||||
"error_message": "Permission denied accessing destination path"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Webhook Authentication
|
|
||||||
|
|
||||||
When a webhook secret is configured, GoMFT signs the payload using HMAC-SHA256 and includes the signature in the `X-Hub-Signature-256` header. To verify the webhook:
|
|
||||||
|
|
||||||
1. Compute the HMAC-SHA256 of the raw request body using your shared secret
|
|
||||||
2. Compare it with the value in the `X-Hub-Signature-256` header
|
|
||||||
3. Process the webhook only if the signatures match
|
|
||||||
|
|
||||||
This ensures that webhook requests are authentic and haven't been tampered with.
|
|
||||||
|
|
||||||
### Admin Tools
|
|
||||||
|
|
||||||
GoMFT provides a comprehensive set of administrative tools for system management and monitoring:
|
|
||||||
|
|
||||||
#### Log Viewer
|
|
||||||
|
|
||||||
The Admin Tools panel includes an integrated log viewer with the following features:
|
|
||||||
|
|
||||||
- **Log File Browser**: View a list of all available log files in the system
|
|
||||||
- **Real-time Log Viewing**: View log file contents directly in the web interface
|
|
||||||
- **Refresh Function**: Update the log list and content with the latest information
|
|
||||||
- **User-friendly Interface**: Clean, readable presentation with custom scrolling
|
|
||||||
- **Dark Mode Support**: Consistent theming with the rest of the application
|
|
||||||
- **Navigation**: Easily switch between different log files
|
|
||||||
|
|
||||||
This log viewer allows administrators to:
|
|
||||||
- Monitor system activity and diagnose issues without requiring server access
|
|
||||||
- View application logs, scheduler logs, and transfer logs in one place
|
|
||||||
- Track down errors and warning messages in real-time
|
|
||||||
|
|
||||||
#### Database Management
|
|
||||||
|
|
||||||
The Admin Tools interface also includes database management capabilities:
|
|
||||||
- Create and manage database backups
|
|
||||||
- Restore from previous backups
|
|
||||||
- Download backups for safekeeping
|
|
||||||
- View system statistics
|
|
||||||
- Optimize the database with maintenance tools
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Development
|
|
||||||
|
|
||||||
### Project Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
.
|
|
||||||
├── components/ # Templ components for UI
|
|
||||||
├── internal/
|
|
||||||
│ ├── api/ # REST API handlers
|
|
||||||
│ ├── auth/ # Authentication/authorization
|
|
||||||
│ ├── config/ # Configuration management
|
|
||||||
│ ├── db/ # Database models and operations
|
|
||||||
│ ├── email/ # Email service for notifications and password resets
|
|
||||||
│ ├── scheduler/ # Job scheduling and execution
|
|
||||||
│ └── web/ # Web interface handlers
|
|
||||||
├── static/ # Static assets
|
|
||||||
│ ├── css/
|
|
||||||
│ └── js/
|
|
||||||
└── main.go # Application entry point
|
|
||||||
```
|
|
||||||
|
|
||||||
### Technology Stack
|
|
||||||
|
|
||||||
- **Backend**: Go with Gin web framework
|
|
||||||
- **Frontend**: Templ for Go HTML components
|
|
||||||
- **UI Enhancement**: HTMX for dynamic interactions
|
|
||||||
- **Styling**: Tailwind CSS
|
|
||||||
- **Authentication**: JWT (JSON Web Tokens)
|
|
||||||
- **Database**: GORM with SQLite
|
|
||||||
- **File Transfer**: rclone
|
|
||||||
- **Deployment**: Docker containerization and traditional installation
|
|
||||||
|
|
||||||
### Building from Source
|
|
||||||
|
|
||||||
1. Install development dependencies:
|
|
||||||
```bash
|
|
||||||
go install github.com/cosmtrek/air@latest # Hot reload for development
|
|
||||||
go install github.com/a-h/templ/cmd/templ@latest # Templ template compiler
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Generate template code:
|
|
||||||
```bash
|
|
||||||
templ generate
|
|
||||||
```
|
|
||||||
|
|
||||||
3. Run in development mode:
|
|
||||||
```bash
|
|
||||||
air
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
|
Contributions are welcome! Please see our [Contributing Guide](https://starfleetcptn.github.io/GoMFT/docs/contributing) for details on how to get started.
|
||||||
|
|
||||||
1. Fork the repository
|
1. Fork the repository
|
||||||
2. Create a feature branch
|
2. Create a feature branch
|
||||||
3. Commit your changes
|
3. Make your changes
|
||||||
4. Push to the branch
|
4. Submit a pull request
|
||||||
5. Create a Pull Request
|
|
||||||
|
|
||||||
---
|
We also welcome documentation improvements. The documentation source is available in the `docs/` directory.
|
||||||
|
|
||||||
## Directory Structure
|
|
||||||
|
|
||||||
GoMFT uses the following directory structure:
|
|
||||||
|
|
||||||
- `/app/data`: Main application data directory
|
|
||||||
- Contains the SQLite database (`gomft.db`)
|
|
||||||
- Contains rclone configurations in `/app/data/configs`
|
|
||||||
- Contains log files in `/app/data/logs`
|
|
||||||
- `/app/backups`: Database backup directory
|
|
||||||
|
|
||||||
When using Docker, you should mount volumes to these locations:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
volumes:
|
|
||||||
- /host/path/data:/app/data # For all application data
|
|
||||||
- /host/path/backups:/app/backups # For database backups
|
|
||||||
```
|
|
||||||
|
|
||||||
These paths can be customized using the environment variables `DATA_DIR`, `BACKUP_DIR`, and `LOGS_DIR`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Security Considerations
|
|
||||||
|
|
||||||
### Running as a Non-Root User
|
|
||||||
|
|
||||||
By default, Docker containers run as the root user, which can pose security risks. GoMFT supports running as a non-root user, which is recommended for production environments.
|
|
||||||
|
|
||||||
#### Benefits of Running as Non-Root
|
|
||||||
|
|
||||||
- **Improved Security**: Limits the potential damage if the container is compromised
|
|
||||||
- **Better File Permissions**: Files created by the container will match your host user permissions
|
|
||||||
- **Compliance**: Many security policies and best practices require containers to run as non-root
|
|
||||||
|
|
||||||
#### Methods to Run as Non-Root
|
|
||||||
|
|
||||||
1. **Using PUID/PGID environment variables (recommended)**:
|
|
||||||
```bash
|
|
||||||
# Using current user's ID
|
|
||||||
docker run -e PUID=$(id -u) -e PGID=$(id -g) starfleetcptn/gomft:latest
|
|
||||||
|
|
||||||
# Or in docker-compose.yml
|
|
||||||
environment:
|
|
||||||
- PUID=1000
|
|
||||||
- PGID=1000
|
|
||||||
```
|
|
||||||
This is the most flexible method as it allows changing the user at runtime without rebuilding the image.
|
|
||||||
|
|
||||||
2. **Using the `--user` flag with Docker run**:
|
|
||||||
```bash
|
|
||||||
docker run --user $(id -u):$(id -g) starfleetcptn/gomft:latest
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Using Docker Compose with environment variables for `user` directive**:
|
|
||||||
```yaml
|
|
||||||
services:
|
|
||||||
gomft:
|
|
||||||
image: starfleetcptn/gomft:latest
|
|
||||||
user: "${UID:-1000}:${GID:-1000}"
|
|
||||||
```
|
|
||||||
|
|
||||||
4. **Building a custom image with specified UID/GID**:
|
|
||||||
```yaml
|
|
||||||
services:
|
|
||||||
gomft:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
args:
|
|
||||||
UID: ${UID:-1000}
|
|
||||||
GID: ${GID:-1000}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Environment Variables for User Management
|
|
||||||
|
|
||||||
| Variable | Description | Default |
|
|
||||||
|----------|-------------|---------|
|
|
||||||
| `PUID` | User ID to run as | Built-in user ID (1000) |
|
|
||||||
| `PGID` | Group ID to run as | Built-in group ID (1000) |
|
|
||||||
| `USERNAME` | Username to use | `gomft` |
|
|
||||||
|
|
||||||
These environment variables allow you to change the user/group IDs at runtime without rebuilding the image.
|
|
||||||
|
|
||||||
#### Volume Permissions
|
|
||||||
|
|
||||||
When mounting volumes, ensure that the directories on the host have appropriate permissions for the container user:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Create directories with correct ownership
|
|
||||||
mkdir -p data backups
|
|
||||||
chown -R $(id -u):$(id -g) data backups
|
|
||||||
|
|
||||||
# Or adjust permissions to allow the container user to write
|
|
||||||
mkdir -p data backups
|
|
||||||
chmod -R 777 data backups # Less secure, but easier for testing
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import * as esbuild from 'esbuild';
|
||||||
|
import path from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import { execSync } from 'child_process';
|
||||||
|
import fs from 'fs';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
|
||||||
|
const isWatch = process.argv.includes('--watch');
|
||||||
|
|
||||||
|
// Ensure the dist directory exists
|
||||||
|
const distDir = path.join(__dirname, 'static', 'dist');
|
||||||
|
if (!fs.existsSync(distDir)) {
|
||||||
|
fs.mkdirSync(distDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy Font Awesome files
|
||||||
|
const fontAwesomeSrcDir = path.join(__dirname, 'node_modules', '@fortawesome', 'fontawesome-free');
|
||||||
|
const fontAwesomeDestDir = path.join(distDir, 'fontawesome');
|
||||||
|
|
||||||
|
// Copy CSS files
|
||||||
|
const cssFiles = [
|
||||||
|
'css/all.min.css',
|
||||||
|
'css/fontawesome.min.css',
|
||||||
|
'css/solid.min.css',
|
||||||
|
'css/regular.min.css',
|
||||||
|
'css/brands.min.css'
|
||||||
|
];
|
||||||
|
|
||||||
|
cssFiles.forEach(file => {
|
||||||
|
const srcFile = path.join(fontAwesomeSrcDir, file);
|
||||||
|
const destFile = path.join(fontAwesomeDestDir, file);
|
||||||
|
const destDir = path.dirname(destFile);
|
||||||
|
|
||||||
|
if (!fs.existsSync(destDir)) {
|
||||||
|
fs.mkdirSync(destDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fs.existsSync(srcFile)) {
|
||||||
|
fs.copyFileSync(srcFile, destFile);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Copy webfonts
|
||||||
|
const webfontsSrcDir = path.join(fontAwesomeSrcDir, 'webfonts');
|
||||||
|
const webfontsDestDir = path.join(fontAwesomeDestDir, 'webfonts');
|
||||||
|
|
||||||
|
if (!fs.existsSync(webfontsDestDir)) {
|
||||||
|
fs.mkdirSync(webfontsDestDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.readdirSync(webfontsSrcDir).forEach(file => {
|
||||||
|
fs.copyFileSync(
|
||||||
|
path.join(webfontsSrcDir, file),
|
||||||
|
path.join(webfontsDestDir, file)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const commonConfig = {
|
||||||
|
sourcemap: true,
|
||||||
|
minify: true,
|
||||||
|
bundle: true,
|
||||||
|
platform: 'browser',
|
||||||
|
target: ['es2020'],
|
||||||
|
};
|
||||||
|
|
||||||
|
async function buildTailwind() {
|
||||||
|
console.log('Building Tailwind CSS...');
|
||||||
|
execSync('npx tailwindcss -i ./static/css/app.css -o ./static/dist/app.css --minify');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function build() {
|
||||||
|
try {
|
||||||
|
// Build vendor JavaScript bundle (CDN dependencies)
|
||||||
|
await esbuild.build({
|
||||||
|
...commonConfig,
|
||||||
|
entryPoints: ['static/js/vendor.js'],
|
||||||
|
outfile: 'static/dist/vendor.js',
|
||||||
|
format: 'iife',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build application JavaScript
|
||||||
|
await esbuild.build({
|
||||||
|
...commonConfig,
|
||||||
|
entryPoints: ['static/js/app.js'],
|
||||||
|
outfile: 'static/dist/app.js',
|
||||||
|
format: 'iife',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build initialization JavaScript
|
||||||
|
await esbuild.build({
|
||||||
|
...commonConfig,
|
||||||
|
entryPoints: ['static/js/init.js'],
|
||||||
|
outfile: 'static/dist/init.js',
|
||||||
|
format: 'iife',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build CSS with Tailwind
|
||||||
|
await buildTailwind();
|
||||||
|
|
||||||
|
console.log('Build completed successfully!');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Build failed:', error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isWatch) {
|
||||||
|
// Watch mode
|
||||||
|
console.log('Starting watch mode...');
|
||||||
|
const ctx = await esbuild.context(commonConfig);
|
||||||
|
await ctx.watch();
|
||||||
|
|
||||||
|
// Watch Tailwind CSS
|
||||||
|
execSync('npx tailwindcss -i ./static/css/app.css -o ./static/dist/app.css --watch');
|
||||||
|
|
||||||
|
console.log('Watching for changes...');
|
||||||
|
} else {
|
||||||
|
// Single build
|
||||||
|
build();
|
||||||
|
}
|
||||||
@@ -0,0 +1,772 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/starfleetcptn/gomft/internal/config"
|
||||||
|
"github.com/starfleetcptn/gomft/internal/db"
|
||||||
|
"github.com/starfleetcptn/gomft/internal/encryption"
|
||||||
|
"github.com/starfleetcptn/gomft/internal/encryption/keyrotation"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// Create root command
|
||||||
|
rootCmd := &cobra.Command{
|
||||||
|
Use: "gomftctl",
|
||||||
|
Short: "GoMFT Control Tool - Command line utilities for GoMFT",
|
||||||
|
Long: `GoMFT Control Tool (gomftctl) provides command line utilities for managing
|
||||||
|
your GoMFT installation, including database migrations, security key rotation,
|
||||||
|
and other administrative functions.`,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add commands
|
||||||
|
rootCmd.AddCommand(createMigrateCmd())
|
||||||
|
rootCmd.AddCommand(createKeyRotationCmd())
|
||||||
|
rootCmd.AddCommand(createVersionCmd())
|
||||||
|
rootCmd.AddCommand(createBackupCmd())
|
||||||
|
rootCmd.AddCommand(createUserCmd())
|
||||||
|
rootCmd.AddCommand(createEncryptionKeyRotationCmd())
|
||||||
|
|
||||||
|
// Execute the root command
|
||||||
|
if err := rootCmd.Execute(); err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// createMigrateCmd creates the migrate command for provider data migration
|
||||||
|
func createMigrateCmd() *cobra.Command {
|
||||||
|
var dryRun, validationOnly, force, debugMode, autoFill bool
|
||||||
|
var backupDir string
|
||||||
|
|
||||||
|
migrateCmd := &cobra.Command{
|
||||||
|
Use: "migrate-providers",
|
||||||
|
Short: "Migrate provider data to the new storage provider model",
|
||||||
|
Long: `Migrate provider data extracts unique provider configurations from existing
|
||||||
|
transfer configs and creates dedicated storage provider records.
|
||||||
|
|
||||||
|
This command should be run when upgrading from older versions of GoMFT that
|
||||||
|
stored provider configuration directly in transfer configs.`,
|
||||||
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
// Load configuration
|
||||||
|
cfg, err := config.Load()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to load configuration: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set backup directory if not provided
|
||||||
|
if backupDir == "" {
|
||||||
|
backupDir = cfg.BackupDir
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize database
|
||||||
|
dbPath := filepath.Join(cfg.DataDir, "gomft.db")
|
||||||
|
database, err := db.Initialize(dbPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to initialize database: %v", err)
|
||||||
|
}
|
||||||
|
defer database.Close()
|
||||||
|
|
||||||
|
// Create migration options
|
||||||
|
options := db.MigrateProviderDataOptions{
|
||||||
|
DryRun: dryRun,
|
||||||
|
ValidationOnly: validationOnly,
|
||||||
|
Force: force,
|
||||||
|
BackupDir: backupDir,
|
||||||
|
DebugMode: debugMode,
|
||||||
|
AutoFill: autoFill,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run migration
|
||||||
|
fmt.Println("Starting provider data migration...")
|
||||||
|
stats, err := database.MigrateProviderData(options)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println("\nMigration failed with error:")
|
||||||
|
fmt.Printf("Error: %v\n", err)
|
||||||
|
|
||||||
|
// Add more detailed error information
|
||||||
|
fmt.Println("\nDetailed error information:")
|
||||||
|
fmt.Println("===========================")
|
||||||
|
|
||||||
|
// Unwrap nested errors if possible
|
||||||
|
var currentErr error = err
|
||||||
|
depth := 1
|
||||||
|
for currentErr != nil {
|
||||||
|
fmt.Printf("%d. %v\n", depth, currentErr)
|
||||||
|
if unwrapped, ok := currentErr.(interface{ Unwrap() error }); ok {
|
||||||
|
currentErr = unwrapped.Unwrap()
|
||||||
|
depth++
|
||||||
|
} else {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Print database connection information (without sensitive details)
|
||||||
|
fmt.Println("\nDatabase information:")
|
||||||
|
fmt.Printf("- Database path: %s\n", dbPath)
|
||||||
|
fmt.Printf("- Migration options: dryRun=%v, validationOnly=%v, force=%v\n",
|
||||||
|
options.DryRun, options.ValidationOnly, options.Force)
|
||||||
|
|
||||||
|
log.Fatalf("Migration failed. See details above.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Print report
|
||||||
|
fmt.Println(db.FormatMigrationReport(stats))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add flags
|
||||||
|
migrateCmd.Flags().BoolVar(&dryRun, "dry-run", false, "Simulate migration without making changes")
|
||||||
|
migrateCmd.Flags().BoolVar(&validationOnly, "validate-only", false, "Only validate if migration is possible without making changes")
|
||||||
|
migrateCmd.Flags().BoolVar(&force, "force", false, "Force migration even if validation fails")
|
||||||
|
migrateCmd.Flags().StringVar(&backupDir, "backup-dir", "", "Directory to store backup data (defaults to config backup_dir)")
|
||||||
|
migrateCmd.Flags().BoolVar(&debugMode, "debug", false, "Enable debug mode with more detailed error messages")
|
||||||
|
migrateCmd.Flags().BoolVar(&autoFill, "auto-fill", false, "Automatically fill missing required fields with placeholder values")
|
||||||
|
|
||||||
|
return migrateCmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// createKeyRotationCmd creates the key rotation command
|
||||||
|
func createKeyRotationCmd() *cobra.Command {
|
||||||
|
var keyType string
|
||||||
|
var writeToEnv bool
|
||||||
|
|
||||||
|
keyRotationCmd := &cobra.Command{
|
||||||
|
Use: "rotate-key",
|
||||||
|
Short: "Rotate security keys used by GoMFT",
|
||||||
|
Long: `Rotate security keys generates new cryptographic keys for GoMFT.
|
||||||
|
|
||||||
|
Available key types:
|
||||||
|
- jwt: JSON Web Token signing key
|
||||||
|
- totp: TOTP encryption key
|
||||||
|
- encryption: General encryption key used for sensitive data
|
||||||
|
|
||||||
|
This command will generate a new key and provide instructions for updating
|
||||||
|
your configuration. The application must be restarted for changes to take effect.`,
|
||||||
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
// Validate key type
|
||||||
|
validTypes := map[string]string{
|
||||||
|
"jwt": "JWT_SECRET",
|
||||||
|
"totp": "TOTP_ENCRYPTION_KEY",
|
||||||
|
"encryption": "GOMFT_ENCRYPTION_KEY",
|
||||||
|
}
|
||||||
|
|
||||||
|
envVar, valid := validTypes[keyType]
|
||||||
|
if !valid {
|
||||||
|
log.Fatalf("Invalid key type: %s. Valid types are: jwt, totp, encryption", keyType)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load configuration
|
||||||
|
_, err := config.Load()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to load configuration: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate a new key
|
||||||
|
newKey, err := generateSecureKey()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to generate secure key: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Generated new %s key: %s\n\n", keyType, newKey)
|
||||||
|
|
||||||
|
if writeToEnv {
|
||||||
|
// Read current .env file
|
||||||
|
envPath := ".env"
|
||||||
|
envContent, err := os.ReadFile(envPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to read .env file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update .env file with new key
|
||||||
|
updatedEnv, updated := updateEnvVar(string(envContent), envVar, newKey)
|
||||||
|
if !updated {
|
||||||
|
// If the variable wasn't found, append it
|
||||||
|
updatedEnv = updatedEnv + fmt.Sprintf("\n%s=%s\n", envVar, newKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write updated content back to .env file
|
||||||
|
if err := os.WriteFile(envPath, []byte(updatedEnv), 0644); err != nil {
|
||||||
|
log.Fatalf("Failed to write updated .env file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Updated %s in .env file\n", envVar)
|
||||||
|
fmt.Println("Please restart the GoMFT application for changes to take effect.")
|
||||||
|
} else {
|
||||||
|
// Print instructions for manual update
|
||||||
|
fmt.Println("To use this key, update your .env file with:")
|
||||||
|
fmt.Printf("%s=%s\n\n", envVar, newKey)
|
||||||
|
fmt.Println("Then restart the GoMFT application for changes to take effect.")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add flags
|
||||||
|
keyRotationCmd.Flags().StringVar(&keyType, "type", "", "Type of key to rotate (jwt, totp, encryption)")
|
||||||
|
keyRotationCmd.Flags().BoolVar(&writeToEnv, "write", false, "Write the new key directly to .env file")
|
||||||
|
keyRotationCmd.MarkFlagRequired("type")
|
||||||
|
|
||||||
|
return keyRotationCmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// createVersionCmd creates the version command
|
||||||
|
func createVersionCmd() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "version",
|
||||||
|
Short: "Display version information",
|
||||||
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
// Import the version from the components package
|
||||||
|
fmt.Println("GoMFT Control Tool")
|
||||||
|
fmt.Println("Version: Same as GoMFT application")
|
||||||
|
fmt.Println("Visit https://github.com/starfleetcptn/gomft for more information")
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// createBackupCmd creates the backup command
|
||||||
|
func createBackupCmd() *cobra.Command {
|
||||||
|
var outputDir string
|
||||||
|
|
||||||
|
backupCmd := &cobra.Command{
|
||||||
|
Use: "backup",
|
||||||
|
Short: "Create a backup of the GoMFT database",
|
||||||
|
Long: `Create a backup of the GoMFT database and configuration.
|
||||||
|
The backup includes the SQLite database file and the .env configuration file.`,
|
||||||
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
// Load configuration
|
||||||
|
cfg, err := config.Load()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to load configuration: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set output directory if not provided
|
||||||
|
if outputDir == "" {
|
||||||
|
outputDir = cfg.BackupDir
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure output directory exists
|
||||||
|
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
||||||
|
log.Fatalf("Failed to create backup directory: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create timestamp for backup filename
|
||||||
|
timestamp := fmt.Sprintf("%s", filepath.Base(os.Args[0]))
|
||||||
|
|
||||||
|
// Create backup
|
||||||
|
dbPath := filepath.Join(cfg.DataDir, "gomft.db")
|
||||||
|
backupPath := filepath.Join(outputDir, fmt.Sprintf("gomft-backup-%s.db", timestamp))
|
||||||
|
|
||||||
|
// Copy database file
|
||||||
|
if err := copyFile(dbPath, backupPath); err != nil {
|
||||||
|
log.Fatalf("Failed to create database backup: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy .env file if it exists
|
||||||
|
envPath := ".env"
|
||||||
|
backupEnvPath := filepath.Join(outputDir, fmt.Sprintf("gomft-env-backup-%s.env", timestamp))
|
||||||
|
if _, err := os.Stat(envPath); err == nil {
|
||||||
|
if err := copyFile(envPath, backupEnvPath); err != nil {
|
||||||
|
log.Fatalf("Failed to backup .env file: %v", err)
|
||||||
|
}
|
||||||
|
fmt.Printf("Configuration backed up to: %s\n", backupEnvPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Database backed up to: %s\n", backupPath)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add flags
|
||||||
|
backupCmd.Flags().StringVar(&outputDir, "output-dir", "", "Directory to store backup files (defaults to config backup_dir)")
|
||||||
|
|
||||||
|
return backupCmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// createUserCmd creates the user management command
|
||||||
|
func createUserCmd() *cobra.Command {
|
||||||
|
userCmd := &cobra.Command{
|
||||||
|
Use: "user",
|
||||||
|
Short: "User management commands",
|
||||||
|
Long: `Commands for managing GoMFT users, including creating, updating, and listing users.`,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add subcommands
|
||||||
|
userCmd.AddCommand(createUserCreateCmd())
|
||||||
|
userCmd.AddCommand(createUserResetPasswordCmd())
|
||||||
|
userCmd.AddCommand(createUserListCmd())
|
||||||
|
|
||||||
|
return userCmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// createUserCreateCmd creates the user create command
|
||||||
|
func createUserCreateCmd() *cobra.Command {
|
||||||
|
var email, password string
|
||||||
|
var isAdmin bool
|
||||||
|
|
||||||
|
createCmd := &cobra.Command{
|
||||||
|
Use: "create",
|
||||||
|
Short: "Create a new user",
|
||||||
|
Long: `Create a new user with the specified email and password.`,
|
||||||
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
// Load configuration
|
||||||
|
cfg, err := config.Load()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to load configuration: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize database
|
||||||
|
dbPath := filepath.Join(cfg.DataDir, "gomft.db")
|
||||||
|
database, err := db.Initialize(dbPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to initialize database: %v", err)
|
||||||
|
}
|
||||||
|
defer database.Close()
|
||||||
|
|
||||||
|
// Create user by first generating password hash
|
||||||
|
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to hash password: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create user object
|
||||||
|
user := &db.User{
|
||||||
|
Email: email,
|
||||||
|
PasswordHash: string(hashedPassword),
|
||||||
|
LastPasswordChange: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set admin status if requested
|
||||||
|
if isAdmin {
|
||||||
|
user.SetIsAdmin(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save user to database
|
||||||
|
if err := database.CreateUser(user); err != nil {
|
||||||
|
log.Fatalf("Failed to create user: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("User created successfully:\n")
|
||||||
|
fmt.Printf(" ID: %d\n", user.ID)
|
||||||
|
fmt.Printf(" Email: %s\n", user.Email)
|
||||||
|
fmt.Printf(" Admin: %t\n", user.GetIsAdmin())
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add flags
|
||||||
|
createCmd.Flags().StringVar(&email, "email", "", "User email address")
|
||||||
|
createCmd.Flags().StringVar(&password, "password", "", "User password")
|
||||||
|
createCmd.Flags().BoolVar(&isAdmin, "admin", false, "Grant admin privileges to the user")
|
||||||
|
createCmd.MarkFlagRequired("email")
|
||||||
|
createCmd.MarkFlagRequired("password")
|
||||||
|
|
||||||
|
return createCmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// createUserResetPasswordCmd creates the user reset-password command
|
||||||
|
func createUserResetPasswordCmd() *cobra.Command {
|
||||||
|
var email, newPassword string
|
||||||
|
|
||||||
|
resetCmd := &cobra.Command{
|
||||||
|
Use: "reset-password",
|
||||||
|
Short: "Reset a user's password",
|
||||||
|
Long: `Reset the password for a user with the specified email address.`,
|
||||||
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
// Load configuration
|
||||||
|
cfg, err := config.Load()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to load configuration: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize database
|
||||||
|
dbPath := filepath.Join(cfg.DataDir, "gomft.db")
|
||||||
|
database, err := db.Initialize(dbPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to initialize database: %v", err)
|
||||||
|
}
|
||||||
|
defer database.Close()
|
||||||
|
|
||||||
|
// Find user by email
|
||||||
|
var user db.User
|
||||||
|
if err := database.Where("email = ?", email).First(&user).Error; err != nil {
|
||||||
|
log.Fatalf("Failed to find user with email %s: %v", email, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate new password hash
|
||||||
|
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to hash password: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update user password
|
||||||
|
user.PasswordHash = string(hashedPassword)
|
||||||
|
user.LastPasswordChange = time.Now()
|
||||||
|
|
||||||
|
// Save user to database
|
||||||
|
if err := database.Save(&user).Error; err != nil {
|
||||||
|
log.Fatalf("Failed to update user: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Password reset successfully for user: %s\n", email)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add flags
|
||||||
|
resetCmd.Flags().StringVar(&email, "email", "", "User email address")
|
||||||
|
resetCmd.Flags().StringVar(&newPassword, "password", "", "New password")
|
||||||
|
resetCmd.MarkFlagRequired("email")
|
||||||
|
resetCmd.MarkFlagRequired("password")
|
||||||
|
|
||||||
|
return resetCmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// createUserListCmd creates the user list command
|
||||||
|
func createUserListCmd() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "list",
|
||||||
|
Short: "List all users",
|
||||||
|
Long: `List all users in the GoMFT system.`,
|
||||||
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
// Load configuration
|
||||||
|
cfg, err := config.Load()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to load configuration: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize database
|
||||||
|
dbPath := filepath.Join(cfg.DataDir, "gomft.db")
|
||||||
|
database, err := db.Initialize(dbPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to initialize database: %v", err)
|
||||||
|
}
|
||||||
|
defer database.Close()
|
||||||
|
|
||||||
|
// Get all users
|
||||||
|
var users []db.User
|
||||||
|
if err := database.Find(&users).Error; err != nil {
|
||||||
|
log.Fatalf("Failed to get users: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Print users
|
||||||
|
fmt.Println("GoMFT Users:")
|
||||||
|
fmt.Println("ID\tEmail\tAdmin\tLast Updated")
|
||||||
|
fmt.Println("--------------------------------------------------")
|
||||||
|
for _, user := range users {
|
||||||
|
lastUpdated := "Never"
|
||||||
|
if !user.UpdatedAt.IsZero() {
|
||||||
|
lastUpdated = user.UpdatedAt.Format("2006-01-02 15:04:05")
|
||||||
|
}
|
||||||
|
fmt.Printf("%d\t%s\t%t\t%s\n", user.ID, user.Email, user.GetIsAdmin(), lastUpdated)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// createEncryptionKeyRotationCmd creates the encryption key rotation command
|
||||||
|
func createEncryptionKeyRotationCmd() *cobra.Command {
|
||||||
|
var dryRun bool
|
||||||
|
var batchSize, maxErrors int
|
||||||
|
var backupDir string
|
||||||
|
var skipBackup bool
|
||||||
|
var oldKeyEnvVar string
|
||||||
|
var modelsFlag string
|
||||||
|
|
||||||
|
rotateCmd := &cobra.Command{
|
||||||
|
Use: "rotate-encryption-key",
|
||||||
|
Short: "Rotate encryption keys for sensitive data",
|
||||||
|
Long: `Rotate encryption keys for sensitive data stored in the database.
|
||||||
|
|
||||||
|
This command will:
|
||||||
|
1. Create a backup of your database (unless --skip-backup is specified)
|
||||||
|
2. Re-encrypt all sensitive data with a new encryption key
|
||||||
|
3. Provide instructions for updating your configuration
|
||||||
|
|
||||||
|
The application must be stopped before running this command to prevent data corruption.
|
||||||
|
`,
|
||||||
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
// Load configuration
|
||||||
|
cfg, err := config.Load()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to load configuration: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set backup directory if not provided
|
||||||
|
if backupDir == "" {
|
||||||
|
backupDir = cfg.BackupDir
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create backup if needed
|
||||||
|
if !skipBackup {
|
||||||
|
dbPath := filepath.Join(cfg.DataDir, "gomft.db")
|
||||||
|
backupPath := filepath.Join(backupDir, fmt.Sprintf("gomft_backup_before_key_rotation_%s.db",
|
||||||
|
time.Now().Format("20060102_150405")))
|
||||||
|
|
||||||
|
fmt.Printf("Creating database backup at %s...\n", backupPath)
|
||||||
|
if err := copyFile(dbPath, backupPath); err != nil {
|
||||||
|
log.Fatalf("Failed to create backup: %v", err)
|
||||||
|
}
|
||||||
|
fmt.Println("Backup created successfully.")
|
||||||
|
} else {
|
||||||
|
fmt.Println("Skipping database backup as requested.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize database
|
||||||
|
dbPath := filepath.Join(cfg.DataDir, "gomft.db")
|
||||||
|
database, err := db.Initialize(dbPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to initialize database: %v", err)
|
||||||
|
}
|
||||||
|
defer database.Close()
|
||||||
|
|
||||||
|
// Setup old encryption service
|
||||||
|
if oldKeyEnvVar == "" {
|
||||||
|
oldKeyEnvVar = encryption.DefaultKeyEnvVar
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the current encryption service
|
||||||
|
oldService, err := encryption.GetGlobalEncryptionService()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to get current encryption service: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate new key
|
||||||
|
newKey, err := encryption.GenerateKey(encryption.AES256KeySize)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to generate new encryption key: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new key manager for the new key
|
||||||
|
newKeyManager := &keyManager{key: newKey}
|
||||||
|
|
||||||
|
// Setup new encryption service with the new key
|
||||||
|
newService, err := encryption.NewEncryptionService(newKeyManager)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to create new encryption service: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create rotation options
|
||||||
|
options := keyrotation.RotationOptions{
|
||||||
|
DryRun: dryRun,
|
||||||
|
BatchSize: batchSize,
|
||||||
|
MaxErrors: maxErrors,
|
||||||
|
Timeout: 24 * time.Hour,
|
||||||
|
ProgressCallback: func(modelName string, processed, total int) {
|
||||||
|
fmt.Printf("\rProcessing %s: %d/%d records (%.1f%%)",
|
||||||
|
modelName, processed, total, float64(processed)/float64(total)*100)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create rotation utility
|
||||||
|
rotationUtil, err := keyrotation.NewRotationUtility(
|
||||||
|
database.DB, // Use the underlying gorm.DB
|
||||||
|
oldService,
|
||||||
|
newService,
|
||||||
|
nil, // No auditor needed, keyrotation will use the global one
|
||||||
|
options,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to create rotation utility: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find models with encrypted fields
|
||||||
|
var models []interface{}
|
||||||
|
if modelsFlag == "auto" {
|
||||||
|
fmt.Println("Automatically detecting models with encrypted fields...")
|
||||||
|
models, err = rotationUtil.FindModelsWithEncryptedFields()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to find models with encrypted fields: %v", err)
|
||||||
|
}
|
||||||
|
if len(models) == 0 {
|
||||||
|
log.Fatalf("No models with encrypted fields found")
|
||||||
|
}
|
||||||
|
} else if modelsFlag != "" {
|
||||||
|
// TODO: Support manual model specification
|
||||||
|
log.Fatalf("Manual model specification not yet implemented, use --models=auto")
|
||||||
|
} else {
|
||||||
|
log.Fatalf("No models specified, use --models=auto to automatically detect models")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create migration plan
|
||||||
|
fmt.Println("Creating encryption migration plan...")
|
||||||
|
plan, err := rotationUtil.CreateEncryptionMigrationPlan(models)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to create migration plan: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Print plan
|
||||||
|
fmt.Println("\nEncryption Migration Plan:")
|
||||||
|
fmt.Printf("Total models: %d\n", len(plan.ModelPlans))
|
||||||
|
fmt.Printf("Total records: %d\n", plan.EstimatedRecords)
|
||||||
|
fmt.Printf("Estimated duration: %s\n", plan.EstimatedDuration.Round(time.Second))
|
||||||
|
fmt.Println("\nModels to process:")
|
||||||
|
for name, modelPlan := range plan.ModelPlans {
|
||||||
|
fmt.Printf("- %s: %d records, %d encrypted fields\n",
|
||||||
|
name, modelPlan.RecordCount, len(modelPlan.EncryptedFields))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirm if not in dry run mode
|
||||||
|
if !dryRun {
|
||||||
|
fmt.Println("\nWARNING: This operation will re-encrypt all sensitive data with a new key.")
|
||||||
|
fmt.Println("Make sure the application is stopped before proceeding.")
|
||||||
|
fmt.Print("\nDo you want to continue? [y/N]: ")
|
||||||
|
var response string
|
||||||
|
fmt.Scanln(&response)
|
||||||
|
if strings.ToLower(response) != "y" {
|
||||||
|
fmt.Println("Operation cancelled.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Perform key rotation
|
||||||
|
fmt.Println("\nStarting key rotation...")
|
||||||
|
startTime := time.Now()
|
||||||
|
stats, err := rotationUtil.RotateKeysForModels(context.Background(), models)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println("\nKey rotation failed with error:")
|
||||||
|
fmt.Printf("Error: %v\n", err)
|
||||||
|
|
||||||
|
// Add more detailed error information
|
||||||
|
fmt.Println("\nDetailed error information:")
|
||||||
|
fmt.Println("===========================")
|
||||||
|
|
||||||
|
// Unwrap nested errors if possible
|
||||||
|
var currentErr error = err
|
||||||
|
depth := 1
|
||||||
|
for currentErr != nil {
|
||||||
|
fmt.Printf("%d. %v\n", depth, currentErr)
|
||||||
|
if unwrapped, ok := currentErr.(interface{ Unwrap() error }); ok {
|
||||||
|
currentErr = unwrapped.Unwrap()
|
||||||
|
depth++
|
||||||
|
} else {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Print rotation configuration details
|
||||||
|
fmt.Println("\nRotation configuration:")
|
||||||
|
fmt.Printf("- Dry run: %v\n", dryRun)
|
||||||
|
fmt.Printf("- Batch size: %d\n", batchSize)
|
||||||
|
fmt.Printf("- Max errors: %d\n", maxErrors)
|
||||||
|
fmt.Printf("- Models: %s\n", modelsFlag)
|
||||||
|
fmt.Printf("- Old key env var: %s\n", oldKeyEnvVar)
|
||||||
|
|
||||||
|
log.Fatalf("Key rotation failed. See details above.")
|
||||||
|
}
|
||||||
|
duration := time.Since(startTime).Round(time.Second)
|
||||||
|
|
||||||
|
// Print results
|
||||||
|
fmt.Println("\nKey rotation completed successfully!")
|
||||||
|
fmt.Printf("Total records processed: %d/%d\n", stats.ProcessedRecords, stats.TotalRecords)
|
||||||
|
fmt.Printf("Failed records: %d\n", stats.FailedRecords)
|
||||||
|
fmt.Printf("Duration: %s\n", duration)
|
||||||
|
|
||||||
|
if len(stats.Errors) > 0 {
|
||||||
|
fmt.Printf("\nErrors (%d):\n", len(stats.Errors))
|
||||||
|
for i, err := range stats.Errors {
|
||||||
|
if i >= 10 {
|
||||||
|
fmt.Printf("... and %d more errors\n", len(stats.Errors)-10)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
fmt.Printf("- %s\n", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Print next steps
|
||||||
|
if !dryRun {
|
||||||
|
fmt.Println("\nNext steps:")
|
||||||
|
fmt.Println("1. Update your environment variable or .env file with the new encryption key:")
|
||||||
|
fmt.Printf(" %s=%s\n", oldKeyEnvVar, base64.StdEncoding.EncodeToString(newKey))
|
||||||
|
fmt.Println("2. Restart your GoMFT application")
|
||||||
|
fmt.Println("\nIMPORTANT: Keep a backup of both the old and new keys until you verify everything works correctly.")
|
||||||
|
} else {
|
||||||
|
fmt.Println("\nDry run completed. No changes were made to the database.")
|
||||||
|
fmt.Println("Run without --dry-run to perform the actual key rotation.")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add flags
|
||||||
|
rotateCmd.Flags().BoolVar(&dryRun, "dry-run", false, "Simulate key rotation without making changes")
|
||||||
|
rotateCmd.Flags().IntVar(&batchSize, "batch-size", 100, "Number of records to process in each batch")
|
||||||
|
rotateCmd.Flags().IntVar(&maxErrors, "max-errors", 50, "Maximum number of errors before aborting")
|
||||||
|
rotateCmd.Flags().StringVar(&backupDir, "backup-dir", "", "Directory to store backup data (defaults to config backup_dir)")
|
||||||
|
rotateCmd.Flags().BoolVar(&skipBackup, "skip-backup", false, "Skip database backup (not recommended)")
|
||||||
|
rotateCmd.Flags().StringVar(&oldKeyEnvVar, "old-key-env", "", "Environment variable containing the old encryption key (defaults to GOMFT_ENCRYPTION_KEY)")
|
||||||
|
rotateCmd.Flags().StringVar(&modelsFlag, "models", "auto", "Models to process (use 'auto' for automatic detection)")
|
||||||
|
|
||||||
|
return rotateCmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// keyManager is a simple implementation of the encryption.KeyManager interface
|
||||||
|
// that uses a fixed key for the new encryption service
|
||||||
|
type keyManager struct {
|
||||||
|
key []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func (km *keyManager) Initialize() error {
|
||||||
|
// Already initialized with the key
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (km *keyManager) GetPrimaryKey() ([]byte, error) {
|
||||||
|
return km.key, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (km *keyManager) GetEnvironmentVariableName() string {
|
||||||
|
return "TEMP_KEY_MANAGER"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (km *keyManager) StoreKeyEnvironment(key []byte) error {
|
||||||
|
// Not needed for this implementation
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper functions
|
||||||
|
|
||||||
|
// generateSecureKey creates a cryptographically secure random key encoded as base64
|
||||||
|
func generateSecureKey() (string, error) {
|
||||||
|
return config.GenerateSecureKey()
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateEnvVar updates an environment variable in the .env file content
|
||||||
|
func updateEnvVar(content, key, value string) (string, bool) {
|
||||||
|
lines := strings.Split(content, "\n")
|
||||||
|
prefix := key + "="
|
||||||
|
updated := false
|
||||||
|
|
||||||
|
for i, line := range lines {
|
||||||
|
if strings.HasPrefix(line, prefix) {
|
||||||
|
lines[i] = prefix + value
|
||||||
|
updated = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.Join(lines, "\n"), updated
|
||||||
|
}
|
||||||
|
|
||||||
|
// copyFile copies a file from src to dst
|
||||||
|
func copyFile(src, dst string) error {
|
||||||
|
srcFile, err := os.Open(src)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer srcFile.Close()
|
||||||
|
|
||||||
|
dstFile, err := os.Create(dst)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer dstFile.Close()
|
||||||
|
|
||||||
|
_, err = io.Copy(dstFile, srcFile)
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
package components
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AdminLayout renders the main admin dashboard
|
||||||
|
templ AdminLayout(ctx context.Context) {
|
||||||
|
@LayoutWithContext("Administration", ctx) {
|
||||||
|
<div class="flex flex-col md:flex-row bg-gray-50 dark:bg-gray-900 min-h-screen">
|
||||||
|
<!-- Sidebar Navigation -->
|
||||||
|
<nav class="md:w-64 md:min-h-screen bg-white border-r border-gray-200 dark:bg-gray-800 dark:border-gray-700">
|
||||||
|
<div class="p-4 overflow-y-auto">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Admin Console</h2>
|
||||||
|
<ul class="space-y-2">
|
||||||
|
<li>
|
||||||
|
<a href="/admin" class="flex items-center p-2 text-gray-900 rounded-lg dark:text-white bg-gray-100 dark:bg-gray-700 group">
|
||||||
|
<i class="fas fa-tachometer-alt w-5 h-5 text-gray-500 dark:text-gray-400 mr-3"></i>
|
||||||
|
Dashboard
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/admin/users" class="flex items-center p-2 text-gray-900 rounded-lg dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700 group">
|
||||||
|
<i class="fas fa-users w-5 h-5 text-gray-500 dark:text-gray-400 mr-3"></i>
|
||||||
|
User Management
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/admin/roles" class="flex items-center p-2 text-gray-900 rounded-lg dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700 group">
|
||||||
|
<i class="fas fa-user-shield w-5 h-5 text-gray-500 dark:text-gray-400 mr-3"></i>
|
||||||
|
Role Management
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/admin/audit" class="flex items-center p-2 text-gray-900 rounded-lg dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700 group">
|
||||||
|
<i class="fas fa-clipboard-list w-5 h-5 text-gray-500 dark:text-gray-400 mr-3"></i>
|
||||||
|
Audit Logs
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/admin/database" class="flex items-center p-2 text-gray-900 rounded-lg dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700 group">
|
||||||
|
<i class="fas fa-database w-5 h-5 text-gray-500 dark:text-gray-400 mr-3"></i>
|
||||||
|
Database Tools
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/admin/settings" class="flex items-center p-2 text-gray-900 rounded-lg dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700 group">
|
||||||
|
<i class="fas fa-cog w-5 h-5 text-gray-500 dark:text-gray-400 mr-3"></i>
|
||||||
|
System Settings
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Main Content -->
|
||||||
|
<div class="flex-1 p-8">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||||||
|
<!-- Statistics Cards -->
|
||||||
|
<div class="bg-white p-6 rounded-lg shadow-sm dark:bg-gray-800">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">Total Users</p>
|
||||||
|
<p class="text-2xl font-bold text-gray-900 dark:text-white">1,234</p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-blue-100 p-3 rounded-full dark:bg-blue-900">
|
||||||
|
<i class="fas fa-users text-blue-500 w-6 h-6 dark:text-blue-300"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white p-6 rounded-lg shadow-sm dark:bg-gray-800">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">Active Sessions</p>
|
||||||
|
<p class="text-2xl font-bold text-gray-900 dark:text-white">42</p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-green-100 p-3 rounded-full dark:bg-green-900">
|
||||||
|
<i class="fas fa-signal text-green-500 w-6 h-6 dark:text-green-300"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white p-6 rounded-lg shadow-sm dark:bg-gray-800">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">System Health</p>
|
||||||
|
<p class="text-2xl font-bold text-green-500 dark:text-green-400">98%</p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-green-100 p-3 rounded-full dark:bg-green-900">
|
||||||
|
<i class="fas fa-heartbeat text-green-500 w-6 h-6 dark:text-green-300"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recent Activity Table -->
|
||||||
|
<div class="bg-white rounded-lg shadow-sm dark:bg-gray-800">
|
||||||
|
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Recent Activity</h3>
|
||||||
|
</div>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full">
|
||||||
|
<thead class="bg-gray-50 dark:bg-gray-700">
|
||||||
|
<tr>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">User</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">Action</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">Timestamp</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
<!-- Example Rows -->
|
||||||
|
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||||
|
<td class="px-6 py-4 text-sm text-gray-900 dark:text-white">admin@example.com</td>
|
||||||
|
<td class="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">Updated user permissions</td>
|
||||||
|
<td class="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">2024-02-20 14:32:10</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,384 @@
|
|||||||
|
package components
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AuditLogEntry represents an audit log entry for display
|
||||||
|
type AuditLogEntry struct {
|
||||||
|
ID uint
|
||||||
|
Action string
|
||||||
|
EntityType string
|
||||||
|
EntityID uint
|
||||||
|
Username string
|
||||||
|
UserID uint
|
||||||
|
Timestamp time.Time
|
||||||
|
Details map[string]interface{}
|
||||||
|
DetailsSummary string
|
||||||
|
}
|
||||||
|
|
||||||
|
type AuditLogsData struct {
|
||||||
|
Logs []AuditLogEntry
|
||||||
|
TotalPages int
|
||||||
|
CurrentPage int
|
||||||
|
TotalRecords int
|
||||||
|
FilterAction string
|
||||||
|
FilterEntity string
|
||||||
|
FilterUser string
|
||||||
|
FilterDateFrom string
|
||||||
|
FilterDateTo string
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminAuditLogs renders the audit logs page
|
||||||
|
templ AdminAuditLogs(ctx context.Context, data AuditLogsData) {
|
||||||
|
@LayoutWithContext("Audit Logs", ctx) {
|
||||||
|
<div id="audit-logs-container" style="min-height: 100vh;" class="audit-logs-page bg-gray-50 dark:bg-gray-900">
|
||||||
|
<div class="pb-8 w-full">
|
||||||
|
<!-- Page Header -->
|
||||||
|
<div class="mb-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||||
|
<i class="fas fa-clipboard-list w-6 h-6 mr-2 text-blue-500 dark:text-blue-400"></i>
|
||||||
|
Audit Logs
|
||||||
|
</h1>
|
||||||
|
<a href="/admin/audit/export" class="flex items-center justify-center text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg px-5 py-2.5 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800">
|
||||||
|
<i class="fas fa-file-export w-4 h-4 mr-2"></i>
|
||||||
|
Export Logs
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Toast container for notifications -->
|
||||||
|
<div id="toast-container" class="fixed top-5 right-5 z-50 flex flex-col gap-2"></div>
|
||||||
|
|
||||||
|
<!-- Status and Error Messages -->
|
||||||
|
<div id="status-message" class="hidden mb-4 p-4 text-sm text-green-700 bg-green-100 rounded-lg dark:bg-green-200 dark:text-green-800" role="alert"></div>
|
||||||
|
<div id="error-message" class="hidden mb-4 p-4 text-sm text-red-700 bg-red-100 rounded-lg dark:bg-red-200 dark:text-red-800" role="alert">
|
||||||
|
<div class="font-medium error-title"></div>
|
||||||
|
<div class="error-details mt-1"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filters -->
|
||||||
|
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 mb-6">
|
||||||
|
<div class="p-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Filter Logs</h3>
|
||||||
|
</div>
|
||||||
|
<div class="p-4">
|
||||||
|
<form method="GET" action="/admin/audit" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4">
|
||||||
|
<!-- Action filter -->
|
||||||
|
<div>
|
||||||
|
<label for="filter-action" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Action</label>
|
||||||
|
<select id="filter-action" name="action" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500">
|
||||||
|
<option value="">All Actions</option>
|
||||||
|
if data.FilterAction == "create" {
|
||||||
|
<option value="create" selected>Create</option>
|
||||||
|
} else {
|
||||||
|
<option value="create">Create</option>
|
||||||
|
}
|
||||||
|
if data.FilterAction == "update" {
|
||||||
|
<option value="update" selected>Update</option>
|
||||||
|
} else {
|
||||||
|
<option value="update">Update</option>
|
||||||
|
}
|
||||||
|
if data.FilterAction == "delete" {
|
||||||
|
<option value="delete" selected>Delete</option>
|
||||||
|
} else {
|
||||||
|
<option value="delete">Delete</option>
|
||||||
|
}
|
||||||
|
if data.FilterAction == "login" {
|
||||||
|
<option value="login" selected>Login</option>
|
||||||
|
} else {
|
||||||
|
<option value="login">Login</option>
|
||||||
|
}
|
||||||
|
if data.FilterAction == "logout" {
|
||||||
|
<option value="logout" selected>Logout</option>
|
||||||
|
} else {
|
||||||
|
<option value="logout">Logout</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Entity filter -->
|
||||||
|
<div>
|
||||||
|
<label for="filter-entity" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Entity Type</label>
|
||||||
|
<select id="filter-entity" name="entity" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500">
|
||||||
|
<option value="">All Entities</option>
|
||||||
|
if data.FilterEntity == "user" {
|
||||||
|
<option value="user" selected>User</option>
|
||||||
|
} else {
|
||||||
|
<option value="user">User</option>
|
||||||
|
}
|
||||||
|
if data.FilterEntity == "role" {
|
||||||
|
<option value="role" selected>Role</option>
|
||||||
|
} else {
|
||||||
|
<option value="role">Role</option>
|
||||||
|
}
|
||||||
|
if data.FilterEntity == "config" {
|
||||||
|
<option value="config" selected>Config</option>
|
||||||
|
} else {
|
||||||
|
<option value="config">Config</option>
|
||||||
|
}
|
||||||
|
if data.FilterEntity == "job" {
|
||||||
|
<option value="job" selected>Job</option>
|
||||||
|
} else {
|
||||||
|
<option value="job">Job</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- User filter -->
|
||||||
|
<div>
|
||||||
|
<label for="filter-user" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Username</label>
|
||||||
|
<input type="text" id="filter-user" name="user" value={data.FilterUser} placeholder="Filter by username" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Date range -->
|
||||||
|
<div>
|
||||||
|
<label for="filter-date-from" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">From Date</label>
|
||||||
|
<input type="date" id="filter-date-from" name="date_from" value={data.FilterDateFrom} class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="filter-date-to" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">To Date</label>
|
||||||
|
<input type="date" id="filter-date-to" name="date_to" value={data.FilterDateTo} class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filter buttons -->
|
||||||
|
<div class="lg:col-span-5 flex items-center justify-end gap-2">
|
||||||
|
<a href="/admin/audit" class="px-4 py-2 text-sm font-medium text-gray-900 bg-white rounded-lg border border-gray-200 hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700">
|
||||||
|
Clear Filters
|
||||||
|
</a>
|
||||||
|
<button type="submit" class="px-4 py-2 text-sm font-medium text-white bg-blue-700 rounded-lg hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800">
|
||||||
|
Apply Filters
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Audit Logs Table -->
|
||||||
|
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800">
|
||||||
|
<div class="p-4 border-b border-gray-200 dark:border-gray-700 flex justify-between items-center">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Audit Log Entries</h3>
|
||||||
|
<span class="text-sm text-gray-600 dark:text-gray-400">Total entries: {fmt.Sprint(data.TotalRecords)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full">
|
||||||
|
<thead class="bg-gray-50 dark:bg-gray-700">
|
||||||
|
<tr>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">Timestamp</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">User</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">Action</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">Entity</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">Details</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
if len(data.Logs) == 0 {
|
||||||
|
<tr>
|
||||||
|
<td colspan="5" class="px-6 py-4 text-center text-gray-500 dark:text-gray-400">
|
||||||
|
No audit logs found matching your filters.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
} else {
|
||||||
|
for _, log := range data.Logs {
|
||||||
|
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||||
|
<td class="px-6 py-4 text-sm text-gray-900 dark:text-white">
|
||||||
|
{log.Timestamp.Format("2006-01-02 15:04:05")}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 text-sm text-gray-900 dark:text-white">
|
||||||
|
{log.Username}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 text-sm">
|
||||||
|
<span class={getActionClass(log.Action)}>
|
||||||
|
{log.Action}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 text-sm text-gray-900 dark:text-white">
|
||||||
|
{log.EntityType}
|
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
(ID: {fmt.Sprint(log.EntityID)})
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
<button
|
||||||
|
data-details={log.DetailsSummary}
|
||||||
|
data-modal-target="details-modal"
|
||||||
|
data-modal-toggle="details-modal"
|
||||||
|
class="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300 underline text-xs">
|
||||||
|
View Details
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
if data.TotalPages > 1 {
|
||||||
|
<div class="px-6 py-4 bg-gray-50 dark:bg-gray-700 border-t border-gray-200 dark:border-gray-600">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="text-sm text-gray-700 dark:text-gray-400">
|
||||||
|
Showing page {fmt.Sprint(data.CurrentPage)} of {fmt.Sprint(data.TotalPages)}
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
if data.CurrentPage > 1 {
|
||||||
|
<a href={buildPaginationURL(data, data.CurrentPage-1)} class="px-3 py-1 text-sm text-gray-500 bg-white border border-gray-300 rounded-md hover:bg-gray-100 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700">
|
||||||
|
Previous
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Page numbers -->
|
||||||
|
<div class="hidden sm:flex space-x-1">
|
||||||
|
<!-- First page -->
|
||||||
|
if data.CurrentPage > 3 {
|
||||||
|
<a href={buildPaginationURL(data, 1)} class="px-3 py-1 text-sm text-gray-500 bg-white border border-gray-300 rounded-md hover:bg-gray-100 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700">
|
||||||
|
1
|
||||||
|
</a>
|
||||||
|
if data.CurrentPage > 4 {
|
||||||
|
<span class="px-3 py-1 text-sm text-gray-500 dark:text-gray-400">...</span>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Pages around current -->
|
||||||
|
for i := max(1, data.CurrentPage-2); i <= min(data.TotalPages, data.CurrentPage+2); i++ {
|
||||||
|
if i == data.CurrentPage {
|
||||||
|
<span class="px-3 py-1 text-sm text-white bg-blue-600 border border-blue-600 rounded-md dark:bg-blue-700">
|
||||||
|
{fmt.Sprint(i)}
|
||||||
|
</span>
|
||||||
|
} else {
|
||||||
|
<a href={buildPaginationURL(data, i)} class="px-3 py-1 text-sm text-gray-500 bg-white border border-gray-300 rounded-md hover:bg-gray-100 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700">
|
||||||
|
{fmt.Sprint(i)}
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Last page -->
|
||||||
|
if data.CurrentPage < data.TotalPages-2 {
|
||||||
|
if data.CurrentPage < data.TotalPages-3 {
|
||||||
|
<span class="px-3 py-1 text-sm text-gray-500 dark:text-gray-400">...</span>
|
||||||
|
}
|
||||||
|
<a href={buildPaginationURL(data, data.TotalPages)} class="px-3 py-1 text-sm text-gray-500 bg-white border border-gray-300 rounded-md hover:bg-gray-100 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700">
|
||||||
|
{fmt.Sprint(data.TotalPages)}
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
if data.CurrentPage < data.TotalPages {
|
||||||
|
<a href={buildPaginationURL(data, data.CurrentPage+1)} class="px-3 py-1 text-sm text-gray-500 bg-white border border-gray-300 rounded-md hover:bg-gray-100 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700">
|
||||||
|
Next
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Details Modal -->
|
||||||
|
<div id="details-modal" tabindex="-1" aria-hidden="true" class="hidden overflow-y-auto overflow-x-hidden fixed top-0 right-0 left-0 z-50 justify-center items-center w-full md:inset-0 h-[calc(100%-1rem)] max-h-full bg-gray-900/50 dark:bg-gray-900/80 backdrop-blur-sm">
|
||||||
|
<div class="relative p-4 w-full max-w-2xl max-h-full mx-auto">
|
||||||
|
<div class="relative bg-white rounded-lg shadow dark:bg-gray-700">
|
||||||
|
<div class="flex items-center justify-between p-4 md:p-5 border-b rounded-t dark:border-gray-600">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
|
Audit Log Details
|
||||||
|
</h3>
|
||||||
|
<button type="button" class="text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm w-8 h-8 ms-auto inline-flex justify-center items-center dark:hover:bg-gray-600 dark:hover:text-white" data-modal-hide="details-modal">
|
||||||
|
<i class="fas fa-times"></i>
|
||||||
|
<span class="sr-only">Close modal</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="p-4 md:p-5 space-y-4">
|
||||||
|
<pre id="details-content" class="bg-gray-100 dark:bg-gray-800 p-4 rounded-lg text-sm text-gray-900 dark:text-gray-300 overflow-x-auto"></pre>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center p-4 md:p-5 border-t border-gray-200 rounded-b dark:border-gray-600">
|
||||||
|
<button data-modal-hide="details-modal" type="button" class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Modal functionality
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const modal = document.getElementById('details-modal');
|
||||||
|
const detailsContent = document.getElementById('details-content');
|
||||||
|
|
||||||
|
// Handle modal show/hide
|
||||||
|
document.querySelectorAll('[data-modal-target="details-modal"]').forEach(button => {
|
||||||
|
button.addEventListener('click', function() {
|
||||||
|
const details = this.getAttribute('data-details');
|
||||||
|
detailsContent.textContent = JSON.stringify(JSON.parse(details), null, 2);
|
||||||
|
modal.classList.remove('hidden');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('[data-modal-hide="details-modal"]').forEach(button => {
|
||||||
|
button.addEventListener('click', function() {
|
||||||
|
modal.classList.add('hidden');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close modal when clicking outside
|
||||||
|
modal.addEventListener('click', function(e) {
|
||||||
|
if (e.target === modal) {
|
||||||
|
modal.classList.add('hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper functions
|
||||||
|
func getActionClass(action string) string {
|
||||||
|
baseClass := "px-2 py-1 text-xs rounded-full "
|
||||||
|
|
||||||
|
switch action {
|
||||||
|
case "create":
|
||||||
|
return baseClass + "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300"
|
||||||
|
case "update":
|
||||||
|
return baseClass + "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300"
|
||||||
|
case "delete":
|
||||||
|
return baseClass + "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300"
|
||||||
|
case "login":
|
||||||
|
return baseClass + "bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300"
|
||||||
|
case "logout":
|
||||||
|
return baseClass + "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300"
|
||||||
|
default:
|
||||||
|
return baseClass + "bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildPaginationURL(data AuditLogsData, page int) templ.SafeURL {
|
||||||
|
url := fmt.Sprintf("/admin/audit?page=%d", page)
|
||||||
|
|
||||||
|
if data.FilterAction != "" {
|
||||||
|
url += fmt.Sprintf("&action=%s", data.FilterAction)
|
||||||
|
}
|
||||||
|
|
||||||
|
if data.FilterEntity != "" {
|
||||||
|
url += fmt.Sprintf("&entity=%s", data.FilterEntity)
|
||||||
|
}
|
||||||
|
|
||||||
|
if data.FilterUser != "" {
|
||||||
|
url += fmt.Sprintf("&user=%s", data.FilterUser)
|
||||||
|
}
|
||||||
|
|
||||||
|
if data.FilterDateFrom != "" {
|
||||||
|
url += fmt.Sprintf("&date_from=%s", data.FilterDateFrom)
|
||||||
|
}
|
||||||
|
|
||||||
|
if data.FilterDateTo != "" {
|
||||||
|
url += fmt.Sprintf("&date_to=%s", data.FilterDateTo)
|
||||||
|
}
|
||||||
|
|
||||||
|
return templ.SafeURL(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,442 @@
|
|||||||
|
package components
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BackupFile represents a database backup file
|
||||||
|
type BackupFile struct {
|
||||||
|
Name string
|
||||||
|
Size string
|
||||||
|
Created time.Time
|
||||||
|
FullPath string
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminDatabaseTools renders the database tools page with backup and restore functionality
|
||||||
|
templ AdminDatabaseTools(ctx context.Context, backups []BackupFile) {
|
||||||
|
@LayoutWithContext("Database Tools", ctx) {
|
||||||
|
<div id="database-tools-container" style="min-height: 100vh;" class="database-tools-page bg-gray-50 dark:bg-gray-900">
|
||||||
|
<div class="pb-8 w-full">
|
||||||
|
<!-- Page Header -->
|
||||||
|
<div class="mb-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||||
|
<i class="fas fa-database w-6 h-6 mr-2 text-blue-500 dark:text-blue-400"></i>
|
||||||
|
Database Tools
|
||||||
|
</h1>
|
||||||
|
<form id="backup-form" method="POST" action="/admin/database/backup-database">
|
||||||
|
<button type="submit" class="flex items-center justify-center text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg px-5 py-2.5 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800">
|
||||||
|
<i class="fas fa-download w-4 h-4 mr-2"></i>
|
||||||
|
Create Backup
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Status and Error Messages -->
|
||||||
|
<div id="toast-container" class="fixed top-5 right-5 z-50 flex flex-col gap-2"></div>
|
||||||
|
|
||||||
|
<!-- Action Cards Grid -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8">
|
||||||
|
<!-- Optimize Database Card -->
|
||||||
|
<div class="bg-white border border-gray-200 rounded-lg shadow-sm p-6 dark:bg-gray-800 dark:border-gray-700">
|
||||||
|
<div class="flex items-center mb-4">
|
||||||
|
<div class="bg-purple-100 p-3 rounded-full dark:bg-purple-900 mr-4">
|
||||||
|
<i class="fas fa-broom text-purple-500 w-6 h-6 dark:text-purple-300"></i>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Optimize Database</h3>
|
||||||
|
</div>
|
||||||
|
<p class="text-gray-600 dark:text-gray-400 mb-4">Vacuum the database to reclaim space and optimize performance.</p>
|
||||||
|
<form id="vacuum-form" method="POST" action="/admin/database/vacuum-database">
|
||||||
|
<button type="submit" class="w-full px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 focus:outline-none focus:ring-2 focus:ring-purple-500 dark:bg-purple-700 dark:hover:bg-purple-800">
|
||||||
|
<i class="fas fa-magic mr-2"></i>
|
||||||
|
Optimize Database
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Clear Job History Card -->
|
||||||
|
<div class="bg-white border border-gray-200 rounded-lg shadow-sm p-6 dark:bg-gray-800 dark:border-gray-700">
|
||||||
|
<div class="flex items-center mb-4">
|
||||||
|
<div class="bg-orange-100 p-3 rounded-full dark:bg-orange-900 mr-4">
|
||||||
|
<i class="fas fa-eraser text-orange-500 w-6 h-6 dark:text-orange-300"></i>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Clear Job History</h3>
|
||||||
|
</div>
|
||||||
|
<p class="text-gray-600 dark:text-gray-400 mb-4">Remove old job history records to free up database space.</p>
|
||||||
|
<form id="clear-job-form" method="POST" action="/admin/database/clear-job-history">
|
||||||
|
<button type="submit" class="w-full px-4 py-2 bg-orange-600 text-white rounded-lg hover:bg-orange-700 focus:outline-none focus:ring-2 focus:ring-orange-500 dark:bg-orange-700 dark:hover:bg-orange-800">
|
||||||
|
<i class="fas fa-trash-alt mr-2"></i>
|
||||||
|
Clear Job History
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Export Configuration Card -->
|
||||||
|
<div class="bg-white border border-gray-200 rounded-lg shadow-sm p-6 dark:bg-gray-800 dark:border-gray-700">
|
||||||
|
<div class="flex items-center mb-4">
|
||||||
|
<div class="bg-green-100 p-3 rounded-full dark:bg-green-900 mr-4">
|
||||||
|
<i class="fas fa-file-export text-green-500 w-6 h-6 dark:text-green-300"></i>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Export Configuration</h3>
|
||||||
|
</div>
|
||||||
|
<p class="text-gray-600 dark:text-gray-400 mb-4">Export system configurations and jobs for backup or migration.</p>
|
||||||
|
<div class="flex space-x-2">
|
||||||
|
<a href="/admin/database/export-configs" class="flex-1 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-green-500 dark:bg-green-700 dark:hover:bg-green-800 text-center">
|
||||||
|
<i class="fas fa-cogs mr-2"></i>
|
||||||
|
Export Configs
|
||||||
|
</a>
|
||||||
|
<a href="/admin/database/export-jobs" class="flex-1 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-green-500 dark:bg-green-700 dark:hover:bg-green-800 text-center">
|
||||||
|
<i class="fas fa-tasks mr-2"></i>
|
||||||
|
Export Jobs
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Database Backup List -->
|
||||||
|
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800">
|
||||||
|
<div class="p-6 border-b border-gray-200 dark:border-gray-700 flex justify-between items-center">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Database Backups</h3>
|
||||||
|
<a href="/admin/database/refresh-backups" class="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300">
|
||||||
|
<i class="fas fa-sync-alt mr-1"></i>
|
||||||
|
Refresh
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full">
|
||||||
|
<thead class="bg-gray-50 dark:bg-gray-700">
|
||||||
|
<tr>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">Backup Name</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">Size</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">Created</th>
|
||||||
|
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
if len(backups) == 0 {
|
||||||
|
<tr>
|
||||||
|
<td colspan="4" class="px-6 py-4 text-center text-gray-500 dark:text-gray-400">
|
||||||
|
No backups found. Create your first backup to get started.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
} else {
|
||||||
|
for _, backup := range backups {
|
||||||
|
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||||
|
<td class="px-6 py-4 text-sm text-gray-900 dark:text-white">{ backup.Name }</td>
|
||||||
|
<td class="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">{ backup.Size }</td>
|
||||||
|
<td class="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">{ backup.Created.Format("2006-01-02 15:04:05") }</td>
|
||||||
|
<td class="px-6 py-4 text-sm text-right">
|
||||||
|
<div class="flex items-center justify-end space-x-2">
|
||||||
|
<button type="button"
|
||||||
|
data-modal-target="confirm-restore-modal"
|
||||||
|
data-modal-toggle="confirm-restore-modal"
|
||||||
|
data-backup-name={ backup.Name }
|
||||||
|
class="text-yellow-600 hover:text-yellow-900 dark:text-yellow-400 dark:hover:text-yellow-300">
|
||||||
|
<i class="fas fa-undo-alt"></i>
|
||||||
|
</button>
|
||||||
|
<a href={ templ.SafeURL("/admin/database/download-backup/" + backup.Name) }
|
||||||
|
class="text-blue-600 hover:text-blue-900 dark:text-blue-400 dark:hover:text-blue-300">
|
||||||
|
<i class="fas fa-download"></i>
|
||||||
|
</a>
|
||||||
|
<button type="button"
|
||||||
|
data-modal-target="confirm-delete-modal"
|
||||||
|
data-modal-toggle="confirm-delete-modal"
|
||||||
|
data-backup-name={ backup.Name }
|
||||||
|
class="text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300">
|
||||||
|
<i class="fas fa-trash"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Upload Backup Section -->
|
||||||
|
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 mt-8">
|
||||||
|
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Restore from Backup File</h3>
|
||||||
|
</div>
|
||||||
|
<div class="p-6">
|
||||||
|
<form id="upload-backup-form" method="POST" action="/admin/database/restore-database" enctype="multipart/form-data">
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Upload Backup File</label>
|
||||||
|
<div class="flex items-center justify-center w-full">
|
||||||
|
<label class="flex flex-col w-full h-32 border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-lg cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||||
|
<div class="flex flex-col items-center justify-center pt-5 pb-6">
|
||||||
|
<i class="fas fa-cloud-upload-alt text-3xl text-gray-400 mb-2"></i>
|
||||||
|
<p class="mb-2 text-sm text-gray-500 dark:text-gray-400"><span class="font-semibold">Click to upload</span> or drag and drop</p>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">SQLite backup file</p>
|
||||||
|
</div>
|
||||||
|
<input type="file" name="backup_file" class="hidden" accept=".db,.sqlite,.backup" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-end">
|
||||||
|
<button type="submit" class="px-4 py-2 bg-yellow-600 text-white rounded-lg hover:bg-yellow-700 focus:outline-none focus:ring-2 focus:ring-yellow-500 dark:bg-yellow-700 dark:hover:bg-yellow-800">
|
||||||
|
<i class="fas fa-undo-alt mr-2"></i>
|
||||||
|
Restore Database
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Help Section -->
|
||||||
|
<div class="bg-gray-50 dark:bg-gray-800 rounded-lg shadow-sm mt-8 p-4 border border-gray-200 dark:border-gray-700">
|
||||||
|
<div class="flex items-start mb-2">
|
||||||
|
<div class="flex items-center h-5">
|
||||||
|
<i class="fas fa-info-circle w-4 h-4 text-blue-500 dark:text-blue-400 mr-2"></i>
|
||||||
|
</div>
|
||||||
|
<div class="ml-2 text-sm">
|
||||||
|
<h4 class="font-medium text-gray-900 dark:text-white">Important Safety Information</h4>
|
||||||
|
<ul class="mt-2 text-gray-600 dark:text-gray-400 space-y-1 list-disc list-inside">
|
||||||
|
<li>Always create a backup before making significant changes</li>
|
||||||
|
<li>Restoring a backup will overwrite all current data</li>
|
||||||
|
<li>Database optimization may take time for large databases</li>
|
||||||
|
<li>Clear job history operations cannot be undone</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Restore Confirmation Modal -->
|
||||||
|
<div id="confirm-restore-modal" tabindex="-1" aria-hidden="true" class="hidden overflow-y-auto overflow-x-hidden fixed top-0 right-0 left-0 z-50 justify-center items-center w-full md:inset-0 h-[calc(100%-1rem)] max-h-full bg-gray-900/50 dark:bg-gray-900/80 backdrop-blur-sm">
|
||||||
|
<div class="relative p-4 w-full max-w-md max-h-full mx-auto">
|
||||||
|
<div class="relative bg-white rounded-lg shadow dark:bg-gray-700">
|
||||||
|
<div class="p-6 text-center">
|
||||||
|
<i class="fas fa-exclamation-triangle text-yellow-400 text-3xl mb-4"></i>
|
||||||
|
<h3 class="mb-5 text-lg font-normal text-gray-500 dark:text-gray-400">Are you sure you want to restore this backup? This will overwrite your current database.</h3>
|
||||||
|
<form method="POST" id="restore-form" action="/admin/database/restore-database">
|
||||||
|
<input type="hidden" name="filename" id="restore-filename" value="" />
|
||||||
|
<button type="submit" class="text-white bg-yellow-600 hover:bg-yellow-800 focus:ring-4 focus:outline-none focus:ring-yellow-300 dark:focus:ring-yellow-800 font-medium rounded-lg text-sm inline-flex items-center px-5 py-2.5 text-center mr-2">
|
||||||
|
Yes, restore it
|
||||||
|
</button>
|
||||||
|
<button type="button" data-modal-hide="confirm-restore-modal" class="text-gray-500 bg-white hover:bg-gray-100 focus:ring-4 focus:outline-none focus:ring-gray-200 rounded-lg border border-gray-200 text-sm font-medium px-5 py-2.5 hover:text-gray-900 focus:z-10 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-500 dark:hover:text-white dark:hover:bg-gray-600 dark:focus:ring-gray-600">
|
||||||
|
No, cancel
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Delete Confirmation Modal -->
|
||||||
|
<div id="confirm-delete-modal" tabindex="-1" aria-hidden="true" class="hidden overflow-y-auto overflow-x-hidden fixed top-0 right-0 left-0 z-50 justify-center items-center w-full md:inset-0 h-[calc(100%-1rem)] max-h-full bg-gray-900/50 dark:bg-gray-900/80 backdrop-blur-sm">
|
||||||
|
<div class="relative p-4 w-full max-w-md max-h-full mx-auto">
|
||||||
|
<div class="relative bg-white rounded-lg shadow dark:bg-gray-700">
|
||||||
|
<div class="p-6 text-center">
|
||||||
|
<i class="fas fa-trash-alt text-red-400 text-3xl mb-4"></i>
|
||||||
|
<h3 class="mb-5 text-lg font-normal text-gray-500 dark:text-gray-400">Are you sure you want to delete this backup file?</h3>
|
||||||
|
<form method="POST" id="delete-form" action="">
|
||||||
|
<button type="submit" class="text-white bg-red-600 hover:bg-red-800 focus:ring-4 focus:outline-none focus:ring-red-300 dark:focus:ring-red-800 font-medium rounded-lg text-sm inline-flex items-center px-5 py-2.5 text-center mr-2">
|
||||||
|
Yes, delete it
|
||||||
|
</button>
|
||||||
|
<button type="button" data-modal-hide="confirm-delete-modal" class="text-gray-500 bg-white hover:bg-gray-100 focus:ring-4 focus:outline-none focus:ring-gray-200 rounded-lg border border-gray-200 text-sm font-medium px-5 py-2.5 hover:text-gray-900 focus:z-10 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-500 dark:hover:text-white dark:hover:bg-gray-600 dark:focus:ring-gray-600">
|
||||||
|
No, cancel
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Handle restore modal
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
// Parse URL parameters for error and status messages
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
const errorParam = urlParams.get('error');
|
||||||
|
const detailsParam = urlParams.get('details');
|
||||||
|
const statusParam = urlParams.get('status');
|
||||||
|
|
||||||
|
// Function to create and show a toast
|
||||||
|
function showToast(message, type) {
|
||||||
|
const toastContainer = document.getElementById('toast-container');
|
||||||
|
|
||||||
|
// Create toast element
|
||||||
|
const toast = document.createElement('div');
|
||||||
|
toast.id = 'toast-' + type + '-' + Date.now();
|
||||||
|
toast.className = 'flex items-center w-full max-w-xs p-4 mb-4 rounded-lg shadow text-gray-500 bg-white dark:text-gray-400 dark:bg-gray-800 transform translate-y-16 opacity-0 transition-all duration-300 ease-out';
|
||||||
|
toast.role = 'alert';
|
||||||
|
|
||||||
|
// Set toast content based on type
|
||||||
|
let iconClass, bgColorClass, textColorClass;
|
||||||
|
|
||||||
|
if (type === 'success') {
|
||||||
|
iconClass = 'text-green-500 bg-green-100 dark:bg-green-800 dark:text-green-200';
|
||||||
|
bgColorClass = 'text-green-500 dark:text-green-200';
|
||||||
|
textColorClass = 'text-green-500 dark:text-green-200';
|
||||||
|
} else if (type === 'error') {
|
||||||
|
iconClass = 'text-red-500 bg-red-100 dark:bg-red-800 dark:text-red-200';
|
||||||
|
bgColorClass = 'text-red-500 dark:text-red-200';
|
||||||
|
textColorClass = 'text-red-500 dark:text-red-200';
|
||||||
|
} else {
|
||||||
|
iconClass = 'text-blue-500 bg-blue-100 dark:bg-blue-800 dark:text-blue-200';
|
||||||
|
bgColorClass = 'text-blue-500 dark:text-blue-200';
|
||||||
|
textColorClass = 'text-blue-500 dark:text-blue-200';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set inner HTML with appropriate icon and message
|
||||||
|
toast.innerHTML = `
|
||||||
|
<div class="inline-flex items-center justify-center flex-shrink-0 w-8 h-8 rounded-lg ${iconClass}">
|
||||||
|
${type === 'success'
|
||||||
|
? '<i class="fas fa-check"></i>'
|
||||||
|
: type === 'error'
|
||||||
|
? '<i class="fas fa-exclamation-circle"></i>'
|
||||||
|
: '<i class="fas fa-info-circle"></i>'}
|
||||||
|
</div>
|
||||||
|
<div class="ml-3 text-sm font-normal">${message}</div>
|
||||||
|
<button type="button" class="ml-auto -mx-1.5 -my-1.5 bg-white text-gray-400 hover:text-gray-900 rounded-lg focus:ring-2 focus:ring-gray-300 p-1.5 hover:bg-gray-100 inline-flex h-8 w-8 dark:text-gray-500 dark:hover:text-white dark:bg-gray-800 dark:hover:bg-gray-700" data-dismiss-target="#${toast.id}" aria-label="Close">
|
||||||
|
<span class="sr-only">Close</span>
|
||||||
|
<i class="fas fa-times"></i>
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Add toast to container
|
||||||
|
toastContainer.appendChild(toast);
|
||||||
|
|
||||||
|
// Trigger animation after a small delay to ensure the DOM has updated
|
||||||
|
setTimeout(() => {
|
||||||
|
toast.classList.remove('translate-y-16', 'opacity-0');
|
||||||
|
toast.classList.add('translate-y-0', 'opacity-100');
|
||||||
|
}, 10);
|
||||||
|
|
||||||
|
// Add event listener to close button
|
||||||
|
const closeButton = toast.querySelector('button[data-dismiss-target]');
|
||||||
|
closeButton.addEventListener('click', function() {
|
||||||
|
// Animate out before removing
|
||||||
|
toast.classList.add('opacity-0', 'translate-y-4');
|
||||||
|
setTimeout(() => {
|
||||||
|
toast.remove();
|
||||||
|
}, 300);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto-remove toast after 5 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
toast.classList.add('opacity-0', 'translate-y-4');
|
||||||
|
setTimeout(() => {
|
||||||
|
toast.remove();
|
||||||
|
}, 300);
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display error message if present
|
||||||
|
if (errorParam) {
|
||||||
|
const errorMessage = decodeURIComponent(errorParam).replace(/\+/g, ' ');
|
||||||
|
let details = '';
|
||||||
|
|
||||||
|
if (detailsParam) {
|
||||||
|
details = decodeURIComponent(detailsParam).replace(/\+/g, ' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullMessage = details ? `${errorMessage}: ${details}` : errorMessage;
|
||||||
|
showToast(fullMessage, 'error');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display status message if present
|
||||||
|
if (statusParam) {
|
||||||
|
const statusMessage = decodeURIComponent(statusParam).replace(/\+/g, ' ');
|
||||||
|
showToast(statusMessage, 'success');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display modals when buttons are clicked
|
||||||
|
const restoreButtons = document.querySelectorAll('[data-modal-target="confirm-restore-modal"]');
|
||||||
|
restoreButtons.forEach(button => {
|
||||||
|
button.addEventListener('click', function() {
|
||||||
|
const backupName = this.getAttribute('data-backup-name');
|
||||||
|
document.getElementById('restore-filename').value = backupName;
|
||||||
|
document.getElementById('restore-form').action = `/admin/database/restore-database/${backupName}`;
|
||||||
|
|
||||||
|
// Show the modal
|
||||||
|
const modal = document.getElementById('confirm-restore-modal');
|
||||||
|
modal.classList.remove('hidden');
|
||||||
|
modal.classList.add('flex');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle delete modal
|
||||||
|
const deleteButtons = document.querySelectorAll('[data-modal-target="confirm-delete-modal"]');
|
||||||
|
deleteButtons.forEach(button => {
|
||||||
|
button.addEventListener('click', function() {
|
||||||
|
const backupName = this.getAttribute('data-backup-name');
|
||||||
|
document.getElementById('delete-form').action = `/admin/database/delete-backup/${backupName}`;
|
||||||
|
|
||||||
|
// Show the modal
|
||||||
|
const modal = document.getElementById('confirm-delete-modal');
|
||||||
|
modal.classList.remove('hidden');
|
||||||
|
modal.classList.add('flex');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle modal hide buttons
|
||||||
|
const hideButtons = document.querySelectorAll('[data-modal-hide]');
|
||||||
|
hideButtons.forEach(button => {
|
||||||
|
button.addEventListener('click', function() {
|
||||||
|
const modalId = this.getAttribute('data-modal-hide');
|
||||||
|
const modal = document.getElementById(modalId);
|
||||||
|
modal.classList.add('hidden');
|
||||||
|
modal.classList.remove('flex');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// File upload preview
|
||||||
|
const fileInput = document.querySelector('input[type="file"]');
|
||||||
|
if (fileInput) {
|
||||||
|
fileInput.addEventListener('change', function(e) {
|
||||||
|
const fileName = e.target.files[0]?.name || 'No file selected';
|
||||||
|
const fileLabel = this.parentElement.querySelector('p.mb-2');
|
||||||
|
if (fileLabel) {
|
||||||
|
fileLabel.textContent = fileName;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add form submission events
|
||||||
|
const backupForm = document.getElementById('backup-form');
|
||||||
|
if (backupForm) {
|
||||||
|
backupForm.addEventListener('submit', function(e) {
|
||||||
|
console.log('Backup form submitted');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const vacuumForm = document.getElementById('vacuum-form');
|
||||||
|
if (vacuumForm) {
|
||||||
|
vacuumForm.addEventListener('submit', function(e) {
|
||||||
|
console.log('Vacuum form submitted');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearJobForm = document.getElementById('clear-job-form');
|
||||||
|
if (clearJobForm) {
|
||||||
|
clearJobForm.addEventListener('submit', function(e) {
|
||||||
|
console.log('Clear job form submitted');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set dark background color if in dark mode
|
||||||
|
if (document.documentElement.classList.contains('dark')) {
|
||||||
|
document.getElementById('database-tools-container').style.backgroundColor = '#111827';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add event listener for theme changes
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const themeToggle = document.getElementById('theme-toggle');
|
||||||
|
if (themeToggle) {
|
||||||
|
themeToggle.addEventListener('click', function() {
|
||||||
|
setTimeout(function() {
|
||||||
|
const isDark = document.documentElement.classList.contains('dark');
|
||||||
|
document.getElementById('database-tools-container').style.backgroundColor = isDark ? '#111827' : 'rgb(249, 250, 251)';
|
||||||
|
}, 50);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,538 @@
|
|||||||
|
package components
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LogEntry represents a log entry for display
|
||||||
|
type LogEntry struct {
|
||||||
|
Timestamp time.Time
|
||||||
|
Level string
|
||||||
|
Message string
|
||||||
|
Source string
|
||||||
|
Details map[string]interface{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogViewerData represents the data for the log viewer component
|
||||||
|
type LogViewerData struct {
|
||||||
|
Logs []LogEntry
|
||||||
|
CurrentFilter string
|
||||||
|
LogFilePath string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to get the appropriate CSS class for log levels
|
||||||
|
func getLogLevelClass(level string) string {
|
||||||
|
baseClass := "px-4 py-2 text-sm font-medium whitespace-nowrap "
|
||||||
|
|
||||||
|
switch level {
|
||||||
|
case "debug":
|
||||||
|
return baseClass + "text-purple-500 dark:text-purple-400"
|
||||||
|
case "info":
|
||||||
|
return baseClass + "text-blue-500 dark:text-blue-400"
|
||||||
|
case "warn":
|
||||||
|
return baseClass + "text-yellow-500 dark:text-yellow-400"
|
||||||
|
case "error":
|
||||||
|
return baseClass + "text-red-500 dark:text-red-400"
|
||||||
|
case "fatal":
|
||||||
|
return baseClass + "text-red-700 dark:text-red-600 font-bold"
|
||||||
|
default:
|
||||||
|
return baseClass + "text-gray-500 dark:text-gray-400"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminLogs renders the log viewer page
|
||||||
|
templ AdminLogs(ctx context.Context, data LogViewerData) {
|
||||||
|
@LayoutWithContext("Log Viewer", ctx) {
|
||||||
|
<div class="log-viewer-page">
|
||||||
|
<!-- Page Header -->
|
||||||
|
<div class="mb-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||||
|
<i class="fas fa-stream w-6 h-6 mr-2 text-blue-500 dark:text-blue-400"></i> Log Viewer
|
||||||
|
</h1>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button id="pause-logs" class="flex items-center justify-center text-white bg-yellow-500 hover:bg-yellow-600 focus:ring-4 focus:ring-yellow-300 font-medium rounded-lg px-4 py-2 dark:bg-yellow-600 dark:hover:bg-yellow-700 focus:outline-none dark:focus:ring-yellow-800">
|
||||||
|
<i class="fas fa-pause w-4 h-4 mr-2"></i> Pause
|
||||||
|
</button>
|
||||||
|
<button id="resume-logs" class="hidden flex items-center justify-center text-white bg-green-500 hover:bg-green-600 focus:ring-4 focus:ring-green-300 font-medium rounded-lg px-4 py-2 dark:bg-green-600 dark:hover:bg-green-700 focus:outline-none dark:focus:ring-green-800">
|
||||||
|
<i class="fas fa-play w-4 h-4 mr-2"></i> Resume
|
||||||
|
</button>
|
||||||
|
<button id="clear-logs" class="flex items-center justify-center text-white bg-red-500 hover:bg-red-600 focus:ring-4 focus:ring-red-300 font-medium rounded-lg px-4 py-2 dark:bg-red-600 dark:hover:bg-red-700 focus:outline-none dark:focus:ring-red-800">
|
||||||
|
<i class="fas fa-trash w-4 h-4 mr-2"></i> Clear
|
||||||
|
</button>
|
||||||
|
<button id="download-logs" class="flex items-center justify-center text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg px-4 py-2 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800">
|
||||||
|
<i class="fas fa-download w-4 h-4 mr-2"></i> Download
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Log Information -->
|
||||||
|
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 mb-4 p-4">
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
<p><i class="fas fa-info-circle mr-2 text-blue-500 dark:text-blue-400"></i> Viewing logs from: <span class="font-mono">{ data.LogFilePath }</span></p>
|
||||||
|
<p><i class="fas fa-circle text-green-500 dark:text-green-400 mr-2"></i> Real-time log streaming is active, logs are automatically captured and displayed</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filters -->
|
||||||
|
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 mb-6">
|
||||||
|
<div class="p-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Filter Logs</h3>
|
||||||
|
</div>
|
||||||
|
<div class="p-4">
|
||||||
|
<div class="flex flex-wrap gap-4">
|
||||||
|
<!-- Log Level Filter -->
|
||||||
|
<div class="flex-1 min-w-[200px]">
|
||||||
|
<label for="filter-level" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Log Level</label>
|
||||||
|
<select id="filter-level" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500">
|
||||||
|
<option value="">All Levels</option>
|
||||||
|
<option value="debug">Debug</option>
|
||||||
|
<option value="info">Info</option>
|
||||||
|
<option value="warn">Warning</option>
|
||||||
|
<option value="error">Error</option>
|
||||||
|
<option value="fatal">Fatal</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Source Filter -->
|
||||||
|
<div class="flex-1 min-w-[200px]">
|
||||||
|
<label for="filter-source" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Source</label>
|
||||||
|
<select id="filter-source" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500">
|
||||||
|
<option value="">All Sources</option>
|
||||||
|
<option value="api">API</option>
|
||||||
|
<option value="web">Web</option>
|
||||||
|
<option value="scheduler">Scheduler</option>
|
||||||
|
<option value="auth">Authentication</option>
|
||||||
|
<option value="database">Database</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Search Filter -->
|
||||||
|
<div class="flex-1 min-w-[200px]">
|
||||||
|
<label for="filter-search" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Search</label>
|
||||||
|
<input type="text" id="filter-search" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="Search logs...">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Log Table -->
|
||||||
|
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 overflow-hidden">
|
||||||
|
<div class="p-4 border-b border-gray-200 dark:border-gray-700 flex justify-between items-center">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Live Logs</h3>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<span id="connection-status" class="flex items-center text-sm text-green-500 dark:text-green-400">
|
||||||
|
<span class="inline-block w-2 h-2 bg-green-500 dark:bg-green-400 rounded-full mr-2"></span>
|
||||||
|
Connected
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="overflow-x-auto" style="max-height: 60vh; overflow-y: auto;">
|
||||||
|
<table class="w-full">
|
||||||
|
<thead class="bg-gray-50 dark:bg-gray-700 sticky top-0 z-10">
|
||||||
|
<tr>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">Timestamp</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase w-[100px]">Level</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase w-[120px]">Source</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">Message</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="log-entries" class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
<!-- Log entries will be inserted here dynamically -->
|
||||||
|
if len(data.Logs) == 0 {
|
||||||
|
<tr>
|
||||||
|
<td colspan="4" class="px-4 py-6 text-center text-gray-500 dark:text-gray-400">Waiting for logs...</td>
|
||||||
|
</tr>
|
||||||
|
} else {
|
||||||
|
for _, log := range data.Logs {
|
||||||
|
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||||
|
<td class="px-4 py-2 text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">{ log.Timestamp.Format("2006-01-02 15:04:05.000") }</td>
|
||||||
|
<td class={ getLogLevelClass(log.Level) }>{ log.Level }</td>
|
||||||
|
<td class="px-4 py-2 text-sm text-gray-500 dark:text-gray-400">{ log.Source }</td>
|
||||||
|
<td class="px-4 py-2 text-sm text-gray-900 dark:text-white font-mono">{ log.Message }</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const logEntries = document.getElementById('log-entries');
|
||||||
|
const pauseButton = document.getElementById('pause-logs');
|
||||||
|
const resumeButton = document.getElementById('resume-logs');
|
||||||
|
const clearButton = document.getElementById('clear-logs');
|
||||||
|
const downloadButton = document.getElementById('download-logs');
|
||||||
|
const connectionStatus = document.getElementById('connection-status');
|
||||||
|
const filterLevel = document.getElementById('filter-level');
|
||||||
|
const filterSource = document.getElementById('filter-source');
|
||||||
|
const filterSearch = document.getElementById('filter-search');
|
||||||
|
|
||||||
|
let isPaused = false;
|
||||||
|
let logs = [];
|
||||||
|
let filteredLogs = [];
|
||||||
|
let ws;
|
||||||
|
let knownSources = new Set();
|
||||||
|
let reconnectTimer = null;
|
||||||
|
let pingInterval = null;
|
||||||
|
|
||||||
|
// Connect to WebSocket
|
||||||
|
function connectWebSocket() {
|
||||||
|
// Clear any existing reconnect timer
|
||||||
|
if (reconnectTimer) {
|
||||||
|
clearTimeout(reconnectTimer);
|
||||||
|
reconnectTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear any existing ping interval
|
||||||
|
if (pingInterval) {
|
||||||
|
clearInterval(pingInterval);
|
||||||
|
pingInterval = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
const wsUrl = protocol + '//' + window.location.host + '/admin/logs/ws';
|
||||||
|
|
||||||
|
console.log("Connecting to WebSocket:", wsUrl);
|
||||||
|
connectionStatus.innerHTML = '<span class="inline-block w-2 h-2 bg-yellow-500 dark:bg-yellow-400 rounded-full mr-2"></span>Connecting...';
|
||||||
|
connectionStatus.className = 'flex items-center text-sm text-yellow-500 dark:text-yellow-400';
|
||||||
|
|
||||||
|
try {
|
||||||
|
ws = new WebSocket(wsUrl);
|
||||||
|
|
||||||
|
ws.onopen = function() {
|
||||||
|
console.log("WebSocket connection established");
|
||||||
|
connectionStatus.innerHTML = '<span class="inline-block w-2 h-2 bg-green-500 dark:bg-green-400 rounded-full mr-2"></span>Connected';
|
||||||
|
connectionStatus.className = 'flex items-center text-sm text-green-500 dark:text-green-400';
|
||||||
|
|
||||||
|
// Set up ping interval to keep connection alive
|
||||||
|
pingInterval = setInterval(function() {
|
||||||
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||||
|
console.debug("Sending ping to server");
|
||||||
|
// Send a simple ping message
|
||||||
|
ws.send(JSON.stringify({type: "ping"}));
|
||||||
|
}
|
||||||
|
}, 30000); // 30 seconds
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = function(event) {
|
||||||
|
console.log("WebSocket connection closed", event);
|
||||||
|
connectionStatus.innerHTML = '<span class="inline-block w-2 h-2 bg-red-500 dark:bg-red-400 rounded-full mr-2"></span>Disconnected';
|
||||||
|
connectionStatus.className = 'flex items-center text-sm text-red-500 dark:text-red-400';
|
||||||
|
|
||||||
|
// Clear the ping interval
|
||||||
|
if (pingInterval) {
|
||||||
|
clearInterval(pingInterval);
|
||||||
|
pingInterval = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attempt to reconnect after 5 seconds
|
||||||
|
console.log("Scheduling reconnect in 5 seconds...");
|
||||||
|
reconnectTimer = setTimeout(connectWebSocket, 5000);
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = function(error) {
|
||||||
|
console.error("WebSocket error:", error);
|
||||||
|
connectionStatus.innerHTML = '<span class="inline-block w-2 h-2 bg-red-500 dark:bg-red-400 rounded-full mr-2"></span>Error';
|
||||||
|
connectionStatus.className = 'flex items-center text-sm text-red-500 dark:text-red-400';
|
||||||
|
|
||||||
|
// Don't set up reconnect here, let onclose handle it
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onmessage = function(event) {
|
||||||
|
// Debug log the received data
|
||||||
|
console.debug("Raw log entry received:", event.data);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const logEntry = JSON.parse(event.data);
|
||||||
|
|
||||||
|
// Debug log the parsed entry
|
||||||
|
console.debug("Parsed log entry:", logEntry);
|
||||||
|
|
||||||
|
// Extract source and add to known sources for filtering
|
||||||
|
const source = logEntry.Source || logEntry.source || '';
|
||||||
|
if (source && !knownSources.has(source)) {
|
||||||
|
knownSources.add(source);
|
||||||
|
updateSourceFilter();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle potential log prefixes in the message
|
||||||
|
const message = logEntry.Message || logEntry.message || '';
|
||||||
|
if (message.startsWith("DEBUG:")) {
|
||||||
|
logEntry.Level = "debug";
|
||||||
|
logEntry.Message = message.substring(7).trim();
|
||||||
|
} else if (message.startsWith("INFO:")) {
|
||||||
|
logEntry.Level = "info";
|
||||||
|
logEntry.Message = message.substring(6).trim();
|
||||||
|
} else if (message.startsWith("ERROR:")) {
|
||||||
|
logEntry.Level = "error";
|
||||||
|
logEntry.Message = message.substring(7).trim();
|
||||||
|
} else if (message.startsWith("WARN:")) {
|
||||||
|
logEntry.Level = "warn";
|
||||||
|
logEntry.Message = message.substring(6).trim();
|
||||||
|
} else if (message.startsWith("WARNING:")) {
|
||||||
|
logEntry.Level = "warn";
|
||||||
|
logEntry.Message = message.substring(9).trim();
|
||||||
|
} else if (message.startsWith("FATAL:")) {
|
||||||
|
logEntry.Level = "fatal";
|
||||||
|
logEntry.Message = message.substring(7).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add to logs array
|
||||||
|
logs.push(logEntry);
|
||||||
|
|
||||||
|
// Apply filters and update display if not paused
|
||||||
|
if (!isPaused) {
|
||||||
|
applyFilters();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error processing log entry:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error creating WebSocket:", error);
|
||||||
|
connectionStatus.innerHTML = '<span class="inline-block w-2 h-2 bg-red-500 dark:bg-red-400 rounded-full mr-2"></span>Connection Failed';
|
||||||
|
connectionStatus.className = 'flex items-center text-sm text-red-500 dark:text-red-400';
|
||||||
|
|
||||||
|
// Retry connection after 5 seconds
|
||||||
|
reconnectTimer = setTimeout(connectWebSocket, 5000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the source filter dropdown with dynamically discovered sources
|
||||||
|
function updateSourceFilter() {
|
||||||
|
// Remember the current selection
|
||||||
|
const currentValue = filterSource.value;
|
||||||
|
|
||||||
|
// Clear existing options except the first "All Sources" option
|
||||||
|
while (filterSource.options.length > 1) {
|
||||||
|
filterSource.remove(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add sorted sources to dropdown
|
||||||
|
Array.from(knownSources).sort().forEach(source => {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = source.toLowerCase();
|
||||||
|
option.textContent = source;
|
||||||
|
filterSource.appendChild(option);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Restore previous selection if it still exists
|
||||||
|
if (currentValue) {
|
||||||
|
for (let i = 0; i < filterSource.options.length; i++) {
|
||||||
|
if (filterSource.options[i].value === currentValue) {
|
||||||
|
filterSource.selectedIndex = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply filters to logs
|
||||||
|
function applyFilters() {
|
||||||
|
const levelFilter = filterLevel.value.toLowerCase();
|
||||||
|
const sourceFilter = filterSource.value.toLowerCase();
|
||||||
|
const searchFilter = filterSearch.value.toLowerCase();
|
||||||
|
|
||||||
|
filteredLogs = logs.filter(log => {
|
||||||
|
// Handle capitalized properties from the server
|
||||||
|
const level = (log.Level || log.level || '').toLowerCase();
|
||||||
|
const source = (log.Source || log.source || '').toLowerCase();
|
||||||
|
const message = (log.Message || log.message || '').toLowerCase();
|
||||||
|
|
||||||
|
return (levelFilter === '' || level === levelFilter) &&
|
||||||
|
(sourceFilter === '' || source === sourceFilter) &&
|
||||||
|
(searchFilter === '' || message.includes(searchFilter));
|
||||||
|
});
|
||||||
|
|
||||||
|
renderLogs();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render logs to the table
|
||||||
|
function renderLogs() {
|
||||||
|
// Clear existing logs
|
||||||
|
logEntries.innerHTML = '';
|
||||||
|
|
||||||
|
if (filteredLogs.length === 0) {
|
||||||
|
const emptyRow = document.createElement('tr');
|
||||||
|
emptyRow.innerHTML = `<td colspan="4" class="px-4 py-6 text-center text-gray-500 dark:text-gray-400">No logs found</td>`;
|
||||||
|
logEntries.appendChild(emptyRow);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add filtered logs
|
||||||
|
filteredLogs.forEach(log => {
|
||||||
|
// Handle capitalized property names from the server
|
||||||
|
const timestamp = log.Timestamp || log.timestamp;
|
||||||
|
const level = log.Level || log.level || 'unknown';
|
||||||
|
const source = log.Source || log.source || 'unknown';
|
||||||
|
const message = log.Message || log.message || '';
|
||||||
|
|
||||||
|
let formattedTime;
|
||||||
|
try {
|
||||||
|
// Convert to date object
|
||||||
|
const date = new Date(timestamp);
|
||||||
|
|
||||||
|
// Format in local time with milliseconds
|
||||||
|
const options = {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
hour12: false
|
||||||
|
};
|
||||||
|
|
||||||
|
// Format main part of the timestamp
|
||||||
|
formattedTime = date.toLocaleString(undefined, options);
|
||||||
|
|
||||||
|
// Add milliseconds
|
||||||
|
const ms = String(date.getMilliseconds()).padStart(3, '0');
|
||||||
|
formattedTime += "." + ms;
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Error formatting timestamp:", e);
|
||||||
|
formattedTime = String(timestamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = document.createElement('tr');
|
||||||
|
row.className = 'hover:bg-gray-50 dark:hover:bg-gray-700';
|
||||||
|
|
||||||
|
let levelClass = 'px-4 py-2 text-sm font-medium whitespace-nowrap ';
|
||||||
|
|
||||||
|
switch(level.toLowerCase()) {
|
||||||
|
case 'debug':
|
||||||
|
levelClass += 'text-purple-500 dark:text-purple-400';
|
||||||
|
break;
|
||||||
|
case 'info':
|
||||||
|
levelClass += 'text-blue-500 dark:text-blue-400';
|
||||||
|
break;
|
||||||
|
case 'warn':
|
||||||
|
levelClass += 'text-yellow-500 dark:text-yellow-400';
|
||||||
|
break;
|
||||||
|
case 'error':
|
||||||
|
levelClass += 'text-red-500 dark:text-red-400';
|
||||||
|
break;
|
||||||
|
case 'fatal':
|
||||||
|
levelClass += 'text-red-700 dark:text-red-600 font-bold';
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
levelClass += 'text-gray-500 dark:text-gray-400';
|
||||||
|
}
|
||||||
|
|
||||||
|
row.innerHTML =
|
||||||
|
'<td class="px-4 py-2 text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">' + formattedTime + '</td>' +
|
||||||
|
'<td class="' + levelClass + '">' + level + '</td>' +
|
||||||
|
'<td class="px-4 py-2 text-sm text-gray-500 dark:text-gray-400">' + source + '</td>' +
|
||||||
|
'<td class="px-4 py-2 text-sm text-gray-900 dark:text-white font-mono">' + message + '</td>';
|
||||||
|
|
||||||
|
logEntries.appendChild(row);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto-scroll to bottom unless user has scrolled up
|
||||||
|
const container = logEntries.parentElement;
|
||||||
|
if (container.scrollTop + container.clientHeight >= container.scrollHeight - 100) {
|
||||||
|
container.scrollTop = container.scrollHeight;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pause button click
|
||||||
|
pauseButton.addEventListener('click', function() {
|
||||||
|
isPaused = true;
|
||||||
|
pauseButton.classList.add('hidden');
|
||||||
|
resumeButton.classList.remove('hidden');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Resume button click
|
||||||
|
resumeButton.addEventListener('click', function() {
|
||||||
|
isPaused = false;
|
||||||
|
resumeButton.classList.add('hidden');
|
||||||
|
pauseButton.classList.remove('hidden');
|
||||||
|
applyFilters(); // Re-apply filters and update
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear button click
|
||||||
|
clearButton.addEventListener('click', function() {
|
||||||
|
logs = [];
|
||||||
|
applyFilters();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Download button click
|
||||||
|
downloadButton.addEventListener('click', function() {
|
||||||
|
// Create CSV from logs
|
||||||
|
let csv = 'Timestamp,Level,Source,Message\n';
|
||||||
|
|
||||||
|
logs.forEach(log => {
|
||||||
|
// Handle capitalized property names from the server
|
||||||
|
const timestamp = log.Timestamp || log.timestamp;
|
||||||
|
const level = log.Level || log.level || 'unknown';
|
||||||
|
const source = log.Source || log.source || 'unknown';
|
||||||
|
const message = log.Message || log.message || '';
|
||||||
|
|
||||||
|
let formattedTime;
|
||||||
|
try {
|
||||||
|
// Convert to date object
|
||||||
|
const date = new Date(timestamp);
|
||||||
|
|
||||||
|
// Format in local time with milliseconds
|
||||||
|
const options = {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
hour12: false
|
||||||
|
};
|
||||||
|
|
||||||
|
// Format main part of the timestamp
|
||||||
|
formattedTime = date.toLocaleString(undefined, options);
|
||||||
|
|
||||||
|
// Add milliseconds
|
||||||
|
const ms = String(date.getMilliseconds()).padStart(3, '0');
|
||||||
|
formattedTime += "." + ms;
|
||||||
|
} catch (e) {
|
||||||
|
formattedTime = String(timestamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Properly escape CSV fields
|
||||||
|
let escapedMessage = '';
|
||||||
|
if (message) {
|
||||||
|
escapedMessage = message.split('"').join('""');
|
||||||
|
}
|
||||||
|
|
||||||
|
csv += '"' + formattedTime + '","' + level + '","' + source + '","' + escapedMessage + '"\n';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create and trigger download
|
||||||
|
const blob = new Blob([csv], { type: 'text/csv' });
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
const date = new Date().toISOString().replace(/[:.]/g, '-').substring(0, 19);
|
||||||
|
|
||||||
|
a.setAttribute('href', url);
|
||||||
|
a.setAttribute('download', 'gomft-logs-' + date + '.csv');
|
||||||
|
a.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filter change handlers
|
||||||
|
filterLevel.addEventListener('change', applyFilters);
|
||||||
|
filterSource.addEventListener('change', applyFilters);
|
||||||
|
|
||||||
|
// Debounce search input
|
||||||
|
let searchTimeout;
|
||||||
|
filterSearch.addEventListener('input', function() {
|
||||||
|
clearTimeout(searchTimeout);
|
||||||
|
searchTimeout = setTimeout(applyFilters, 300);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initial connection
|
||||||
|
connectWebSocket();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
package components
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type RoleFormData struct {
|
||||||
|
Role *Role
|
||||||
|
IsNew bool
|
||||||
|
ErrorMessage string
|
||||||
|
AllPermissions []string
|
||||||
|
}
|
||||||
|
|
||||||
|
templ AdminRoleForm(ctx context.Context, data RoleFormData) {
|
||||||
|
@LayoutWithContext(getFormTitle(data.IsNew), ctx) {
|
||||||
|
<div class="mb-6">
|
||||||
|
<nav class="flex" aria-label="Breadcrumb">
|
||||||
|
<ol class="inline-flex items-center space-x-1 md:space-x-3">
|
||||||
|
<li class="inline-flex items-center">
|
||||||
|
<a href="/admin" class="inline-flex items-center text-sm font-medium text-gray-700 hover:text-blue-600 dark:text-gray-400 dark:hover:text-white">
|
||||||
|
<i class="fas fa-shield-alt w-4 h-4 mr-2"></i>
|
||||||
|
Admin
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<i class="fas fa-chevron-right w-3 h-3 text-gray-400 mx-1"></i>
|
||||||
|
<a href="/admin/roles" class="ml-1 text-sm font-medium text-gray-700 hover:text-blue-600 md:ml-2 dark:text-gray-400 dark:hover:text-white">Roles</a>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li aria-current="page">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<i class="fas fa-chevron-right w-3 h-3 text-gray-400 mx-1"></i>
|
||||||
|
<span class="ml-1 text-sm font-medium text-gray-500 md:ml-2 dark:text-gray-400">
|
||||||
|
if data.IsNew {
|
||||||
|
New Role
|
||||||
|
} else {
|
||||||
|
Edit Role
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
if data.ErrorMessage != "" {
|
||||||
|
<div class="p-4 mb-6 text-sm text-red-800 rounded-lg bg-red-50 dark:bg-red-900/50 dark:text-red-400" role="alert">
|
||||||
|
<span class="font-medium">Error!</span> { data.ErrorMessage }
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="bg-white rounded-lg shadow-sm dark:bg-gray-800">
|
||||||
|
<div class="p-6 space-y-6">
|
||||||
|
<form method="POST" action={ getFormAction(data.IsNew, data.Role) }>
|
||||||
|
if !data.IsNew {
|
||||||
|
<input type="hidden" name="_method" value="PUT"/>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Role Name -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<label for="name" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Role Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="name"
|
||||||
|
name="name"
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white"
|
||||||
|
placeholder="Enter role name"
|
||||||
|
required
|
||||||
|
if !data.IsNew {
|
||||||
|
value={ data.Role.Name }
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Role Description -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<label for="description" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Description</label>
|
||||||
|
<textarea
|
||||||
|
id="description"
|
||||||
|
name="description"
|
||||||
|
rows="3"
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white"
|
||||||
|
placeholder="Enter role description"
|
||||||
|
>
|
||||||
|
if !data.IsNew {
|
||||||
|
{ data.Role.Description }
|
||||||
|
}
|
||||||
|
</textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Permissions -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<label class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Permissions</label>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
for _, perm := range data.AllPermissions {
|
||||||
|
<div class="flex items-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id={ "perm_" + perm }
|
||||||
|
name="permissions[]"
|
||||||
|
value={ perm }
|
||||||
|
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"
|
||||||
|
if !data.IsNew && hasPermission(data.Role, perm) {
|
||||||
|
checked
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<label for={ "perm_" + perm } class="ml-2 text-sm font-medium text-gray-900 dark:text-gray-300">
|
||||||
|
{ formatPermissionLabel(perm) }
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Form Actions -->
|
||||||
|
<div class="flex items-center justify-end space-x-3">
|
||||||
|
<a href="/admin/roles" class="text-gray-500 bg-white hover:bg-gray-100 focus:ring-4 focus:outline-none focus:ring-blue-300 rounded-lg border border-gray-200 text-sm font-medium px-5 py-2.5 hover:text-gray-900 focus:z-10 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-500 dark:hover:text-white dark:hover:bg-gray-600 dark:focus:ring-gray-600">
|
||||||
|
Cancel
|
||||||
|
</a>
|
||||||
|
<button type="submit" class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800">
|
||||||
|
if data.IsNew {
|
||||||
|
Create Role
|
||||||
|
} else {
|
||||||
|
Update Role
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper functions
|
||||||
|
func getFormTitle(isNew bool) string {
|
||||||
|
if isNew {
|
||||||
|
return "New Role"
|
||||||
|
}
|
||||||
|
return "Edit Role"
|
||||||
|
}
|
||||||
|
|
||||||
|
func getFormAction(isNew bool, role *Role) templ.SafeURL {
|
||||||
|
if isNew {
|
||||||
|
return "/admin/roles"
|
||||||
|
}
|
||||||
|
return templ.SafeURL("/admin/roles/" + fmt.Sprint(role.ID))
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasPermission(role *Role, permission string) bool {
|
||||||
|
if role == nil || role.Permissions == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, p := range role.Permissions {
|
||||||
|
if p == permission {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatPermissionLabel(permission string) string {
|
||||||
|
// Convert permission strings like "users.create" to "Create Users"
|
||||||
|
parts := strings.Split(permission, ".")
|
||||||
|
if len(parts) != 2 {
|
||||||
|
return permission
|
||||||
|
}
|
||||||
|
|
||||||
|
resource := strings.Title(parts[0])
|
||||||
|
action := strings.Title(parts[1])
|
||||||
|
|
||||||
|
return action + " " + resource
|
||||||
|
}
|
||||||
@@ -0,0 +1,316 @@
|
|||||||
|
package components
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Role struct {
|
||||||
|
ID uint
|
||||||
|
Name string
|
||||||
|
Description string
|
||||||
|
Permissions []string
|
||||||
|
}
|
||||||
|
|
||||||
|
type RolesData struct {
|
||||||
|
Roles []Role
|
||||||
|
Error string
|
||||||
|
}
|
||||||
|
|
||||||
|
templ AdminRoles(ctx context.Context, data RolesData) {
|
||||||
|
@LayoutWithContext("Role Management", ctx) {
|
||||||
|
<!-- Toast container for status messages -->
|
||||||
|
<div id="toast-container" class="fixed top-5 right-5 z-50 flex flex-col gap-2"></div>
|
||||||
|
|
||||||
|
<!-- Roles Management Content -->
|
||||||
|
<div class="mb-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||||
|
<i class="fas fa-user-shield w-6 h-6 mr-2 text-blue-500 dark:text-blue-400"></i>
|
||||||
|
Role Management
|
||||||
|
</h1>
|
||||||
|
<a href="/admin/roles/new" class="flex items-center justify-center text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg px-5 py-2.5 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800">
|
||||||
|
<i class="fas fa-plus w-4 h-4 mr-2"></i>
|
||||||
|
New Role
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Roles Table -->
|
||||||
|
<div class="bg-white rounded-lg shadow-sm dark:bg-gray-800">
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full">
|
||||||
|
<thead class="bg-gray-50 dark:bg-gray-700">
|
||||||
|
<tr>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">Role Name</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">Description</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">Permissions</th>
|
||||||
|
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
if len(data.Roles) == 0 {
|
||||||
|
<tr>
|
||||||
|
<td colspan="4" class="px-6 py-4 text-center text-gray-500 dark:text-gray-400">
|
||||||
|
No roles found. Create your first role to get started.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
} else {
|
||||||
|
for _, role := range data.Roles {
|
||||||
|
<!-- Dialog for this role -->
|
||||||
|
@RoleDialog(
|
||||||
|
fmt.Sprintf("delete-role-dialog-%d", role.ID),
|
||||||
|
"Delete Role",
|
||||||
|
fmt.Sprintf("Are you sure you want to delete role \"%s\"? This action cannot be undone.", role.Name),
|
||||||
|
"bg-red-600 hover:bg-red-700 dark:bg-red-600 dark:hover:bg-red-700",
|
||||||
|
"Delete Role",
|
||||||
|
"delete",
|
||||||
|
role.ID,
|
||||||
|
role.Name,
|
||||||
|
)
|
||||||
|
|
||||||
|
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||||
|
<td class="px-6 py-4 text-sm text-gray-900 dark:text-white">{ role.Name }</td>
|
||||||
|
<td class="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">{ role.Description }</td>
|
||||||
|
<td class="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
<div class="flex flex-wrap gap-1">
|
||||||
|
for _, perm := range role.Permissions {
|
||||||
|
<span class="px-2 py-1 text-xs rounded-full bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300">
|
||||||
|
{ perm }
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 text-sm text-right">
|
||||||
|
<div class="flex items-center justify-end space-x-2">
|
||||||
|
<a href={ templ.SafeURL("/admin/roles/" + fmt.Sprint(role.ID)) } class="text-blue-600 hover:text-blue-900 dark:text-blue-400 dark:hover:text-blue-300">
|
||||||
|
<i class="fas fa-edit"></i>
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
data-role-id={ fmt.Sprint(role.ID) }
|
||||||
|
data-role-name={ role.Name }
|
||||||
|
onclick={ showModal(fmt.Sprintf("delete-role-dialog-%d", role.ID)) }
|
||||||
|
class="text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300">
|
||||||
|
<i class="fas fa-trash"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Function to create and show a toast
|
||||||
|
function showToast(message, type) {
|
||||||
|
const toastContainer = document.getElementById('toast-container');
|
||||||
|
|
||||||
|
// Create toast element
|
||||||
|
const toast = document.createElement('div');
|
||||||
|
toast.id = 'toast-' + type + '-' + Date.now();
|
||||||
|
toast.className = 'flex items-center w-full max-w-xs p-4 mb-4 rounded-lg shadow text-gray-500 bg-white dark:text-gray-400 dark:bg-gray-800 transform translate-y-16 opacity-0 transition-all duration-300 ease-out';
|
||||||
|
toast.role = 'alert';
|
||||||
|
|
||||||
|
// Set toast content based on type
|
||||||
|
let iconClass, bgColorClass, textColorClass;
|
||||||
|
|
||||||
|
if (type === 'success') {
|
||||||
|
iconClass = 'text-green-500 bg-green-100 dark:bg-green-800 dark:text-green-200';
|
||||||
|
bgColorClass = 'text-green-500 dark:text-green-200';
|
||||||
|
textColorClass = 'text-green-500 dark:text-green-200';
|
||||||
|
} else if (type === 'error') {
|
||||||
|
iconClass = 'text-red-500 bg-red-100 dark:bg-red-800 dark:text-red-200';
|
||||||
|
bgColorClass = 'text-red-500 dark:text-red-200';
|
||||||
|
textColorClass = 'text-red-500 dark:text-red-200';
|
||||||
|
} else {
|
||||||
|
iconClass = 'text-blue-500 bg-blue-100 dark:bg-blue-800 dark:text-blue-200';
|
||||||
|
bgColorClass = 'text-blue-500 dark:text-blue-200';
|
||||||
|
textColorClass = 'text-blue-500 dark:text-blue-200';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set inner HTML with appropriate icon and message
|
||||||
|
toast.innerHTML = `
|
||||||
|
<div class="inline-flex items-center justify-center flex-shrink-0 w-8 h-8 rounded-lg ${iconClass}">
|
||||||
|
${type === 'success'
|
||||||
|
? '<i class="fas fa-check"></i>'
|
||||||
|
: type === 'error'
|
||||||
|
? '<i class="fas fa-exclamation-circle"></i>'
|
||||||
|
: '<i class="fas fa-info-circle"></i>'}
|
||||||
|
</div>
|
||||||
|
<div class="ml-3 text-sm font-normal">${message}</div>
|
||||||
|
<button type="button" class="ml-auto -mx-1.5 -my-1.5 bg-white text-gray-400 hover:text-gray-900 rounded-lg focus:ring-2 focus:ring-gray-300 p-1.5 hover:bg-gray-100 inline-flex h-8 w-8 dark:text-gray-500 dark:hover:text-white dark:bg-gray-800 dark:hover:bg-gray-700" data-dismiss-target="#${toast.id}" aria-label="Close">
|
||||||
|
<span class="sr-only">Close</span>
|
||||||
|
<i class="fas fa-times"></i>
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Add toast to container
|
||||||
|
toastContainer.appendChild(toast);
|
||||||
|
|
||||||
|
// Trigger animation after a small delay to ensure the DOM has updated
|
||||||
|
setTimeout(() => {
|
||||||
|
toast.classList.remove('translate-y-16', 'opacity-0');
|
||||||
|
toast.classList.add('translate-y-0', 'opacity-100');
|
||||||
|
}, 10);
|
||||||
|
|
||||||
|
// Add event listener to close button
|
||||||
|
const closeButton = toast.querySelector('button[data-dismiss-target]');
|
||||||
|
closeButton.addEventListener('click', function() {
|
||||||
|
// Animate out before removing
|
||||||
|
toast.classList.add('opacity-0', 'translate-y-4');
|
||||||
|
setTimeout(() => {
|
||||||
|
toast.remove();
|
||||||
|
}, 300);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto-remove toast after 5 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
toast.classList.add('opacity-0', 'translate-y-4');
|
||||||
|
setTimeout(() => {
|
||||||
|
toast.remove();
|
||||||
|
}, 300);
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle modal hide buttons
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const hideButtons = document.querySelectorAll('[data-modal-hide]');
|
||||||
|
hideButtons.forEach(button => {
|
||||||
|
button.addEventListener('click', function() {
|
||||||
|
const modalId = this.getAttribute('data-modal-hide');
|
||||||
|
const modal = document.getElementById(modalId);
|
||||||
|
modal.classList.add('hidden');
|
||||||
|
modal.classList.remove('flex');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check for URL parameters after page load to show success/error messages
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
const successMsg = urlParams.get('success');
|
||||||
|
const errorMsg = urlParams.get('error');
|
||||||
|
|
||||||
|
// Show success toast if parameter exists
|
||||||
|
if (successMsg) {
|
||||||
|
showToast(decodeURIComponent(successMsg), 'success');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show error toast if parameter exists
|
||||||
|
if (errorMsg) {
|
||||||
|
showToast(decodeURIComponent(errorMsg), 'error');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show error from the data if it exists
|
||||||
|
if (document.querySelector('[data-error-message]')) {
|
||||||
|
const errorMsg = document.querySelector('[data-error-message]').getAttribute('data-error-message');
|
||||||
|
if (errorMsg) {
|
||||||
|
showToast(errorMsg, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up window variables to track deletions
|
||||||
|
window.lastDeletedRole = null;
|
||||||
|
window.isRoleDeletingRequest = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle role deletion
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const confirmDeleteButtons = document.querySelectorAll('[id^="delete-role-btn-"]');
|
||||||
|
confirmDeleteButtons.forEach(button => {
|
||||||
|
button.addEventListener('click', function() {
|
||||||
|
const roleId = this.getAttribute('data-role-id');
|
||||||
|
const roleName = this.getAttribute('data-role-name');
|
||||||
|
|
||||||
|
// Store deletion info for event tracking
|
||||||
|
window.lastDeletedRole = {
|
||||||
|
id: roleId,
|
||||||
|
name: roleName
|
||||||
|
};
|
||||||
|
|
||||||
|
// Send DELETE request
|
||||||
|
fetch(`/admin/roles/${roleId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then(response => {
|
||||||
|
if (response.ok) {
|
||||||
|
// Show success toast
|
||||||
|
showToast(`Role "${roleName}" deleted successfully`, 'success');
|
||||||
|
// Reload page on success after a short delay
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.reload();
|
||||||
|
}, 1000);
|
||||||
|
} else {
|
||||||
|
// Handle error
|
||||||
|
return response.json().then(data => {
|
||||||
|
showToast(`Error: ${data.error || 'Failed to delete role'}`, 'error');
|
||||||
|
}).catch(() => {
|
||||||
|
showToast(`Error deleting role "${roleName}"`, 'error');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error:', error);
|
||||||
|
showToast(`Error: ${error.message || 'Failed to connect to server'}`, 'error');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Store error message if it exists -->
|
||||||
|
if data.Error != "" {
|
||||||
|
<div data-error-message={ data.Error } class="hidden"></div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RoleDialog for confirmation actions
|
||||||
|
templ RoleDialog(id string, title string, message string, confirmClass string, confirmText string, action string, roleID uint, roleName string) {
|
||||||
|
<div id={ id } tabindex="-1" aria-hidden="true" class="hidden fixed top-0 right-0 left-0 z-50 justify-center items-center w-full md:inset-0 h-[calc(100%-1rem)] max-h-full">
|
||||||
|
<!-- Backdrop -->
|
||||||
|
<div id={ fmt.Sprintf("%s-backdrop", id) } class="fixed inset-0 bg-gray-900/50 dark:bg-gray-900/80 backdrop-blur-sm"></div>
|
||||||
|
<!-- Modal content -->
|
||||||
|
<div class="relative p-4 w-full max-w-md max-h-full mx-auto">
|
||||||
|
<div class="relative bg-white rounded-lg shadow dark:bg-gray-700">
|
||||||
|
<div class="p-6 text-center">
|
||||||
|
if action == "delete" {
|
||||||
|
<i class="fas fa-trash-alt text-red-400 text-3xl mb-4"></i>
|
||||||
|
} else {
|
||||||
|
<i class="fas fa-exclamation-triangle text-yellow-400 text-3xl mb-4"></i>
|
||||||
|
}
|
||||||
|
<h3 class="mb-5 text-lg font-normal text-gray-500 dark:text-gray-400">{ message }</h3>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-white font-medium rounded-lg text-sm px-5 py-2.5 text-center me-2 bg-red-600 hover:bg-red-700 focus:ring-4 focus:outline-none focus:ring-red-300 dark:bg-red-500 dark:hover:bg-red-600 dark:focus:ring-red-800"
|
||||||
|
id={ fmt.Sprintf("delete-role-btn-%d", roleID) }
|
||||||
|
data-role-id={ fmt.Sprint(roleID) }
|
||||||
|
data-role-name={ roleName }
|
||||||
|
onclick={ triggerRoleDelete(id, roleID, roleName) }>
|
||||||
|
{ confirmText }
|
||||||
|
</button>
|
||||||
|
<button type="button" onclick={ closeModal(id) } class="text-gray-500 bg-white hover:bg-gray-100 focus:ring-4 focus:outline-none focus:ring-gray-200 rounded-lg border border-gray-200 text-sm font-medium px-5 py-2.5 hover:text-gray-900 focus:z-10 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-500 dark:hover:text-white dark:hover:bg-gray-600 dark:focus:ring-gray-600">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
script triggerRoleDelete(dialogId string, roleID uint, roleName string) {
|
||||||
|
// Hide the dialog
|
||||||
|
document.getElementById(dialogId).classList.add("hidden");
|
||||||
|
document.getElementById(dialogId).classList.remove("flex");
|
||||||
|
|
||||||
|
// Store data in a way that's accessible to event handlers
|
||||||
|
window.lastDeletedRole = {
|
||||||
|
id: roleID,
|
||||||
|
name: roleName
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add custom marker to track this deletion
|
||||||
|
window.isRoleDeletingRequest = true;
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,655 @@
|
|||||||
|
package components
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"encoding/json"
|
||||||
|
"context"
|
||||||
|
"github.com/starfleetcptn/gomft/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
templ AuthProviderForm(ctx context.Context, provider *db.AuthProvider, isNew bool) {
|
||||||
|
@LayoutWithContext(getPageTitle(isNew), ctx) {
|
||||||
|
<div id="auth-provider-form-container" style="min-height: 100vh; background-color: rgb(249, 250, 251);" class="auth-provider-form-page bg-gray-50 dark:bg-gray-900">
|
||||||
|
<div class="p-4 pb-8 w-full">
|
||||||
|
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4 mb-6">
|
||||||
|
<div>
|
||||||
|
if isNew {
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||||
|
<i class="fas fa-user-shield w-6 h-6 mr-2 text-blue-500 dark:text-blue-400"></i>
|
||||||
|
New Authentication Provider
|
||||||
|
</h1>
|
||||||
|
<p class="text-gray-500 dark:text-gray-400">Configure a new external authentication source</p>
|
||||||
|
} else {
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||||
|
<i class="fas fa-user-shield w-6 h-6 mr-2 text-blue-500 dark:text-blue-400"></i>
|
||||||
|
Edit Authentication Provider
|
||||||
|
</h1>
|
||||||
|
<p class="text-gray-500 dark:text-gray-400">Update an existing external authentication source</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<a href="/admin/settings/auth-providers" class="text-gray-700 bg-gray-100 hover:bg-gray-200 focus:ring-4 focus:outline-none focus:ring-gray-300 font-medium rounded-lg text-sm px-4 py-2 text-center inline-flex items-center dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600 dark:focus:ring-gray-700">
|
||||||
|
<i class="fas fa-arrow-left w-4 h-4 mr-2"></i> Back to Providers
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 p-6">
|
||||||
|
if isNew {
|
||||||
|
<form method="POST" action="/admin/settings/auth-providers" class="space-y-6">
|
||||||
|
@formContent(provider, isNew)
|
||||||
|
</form>
|
||||||
|
} else {
|
||||||
|
<form method="POST" action={ templ.SafeURL(fmt.Sprintf("/admin/settings/auth-providers/%d", provider.ID)) } class="space-y-6">
|
||||||
|
<input type="hidden" name="_method" value="PUT" />
|
||||||
|
@formContent(provider, isNew)
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Help Section -->
|
||||||
|
<div class="bg-gray-50 dark:bg-gray-800 rounded-lg shadow-sm mt-8 p-4 border border-gray-200 dark:border-gray-700">
|
||||||
|
<div class="flex items-start mb-2">
|
||||||
|
<div class="flex items-center h-5">
|
||||||
|
<i class="fas fa-info-circle w-4 h-4 text-blue-500 dark:text-blue-400 mr-2"></i>
|
||||||
|
</div>
|
||||||
|
<div class="ml-2 text-sm">
|
||||||
|
<p class="text-gray-700 dark:text-gray-300">Authentication providers allow users to sign in using external identity providers.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-start mt-4">
|
||||||
|
<div class="flex items-center h-5">
|
||||||
|
<i class="fas fa-key w-4 h-4 text-blue-500 dark:text-blue-400 mr-2"></i>
|
||||||
|
</div>
|
||||||
|
<div class="ml-2 text-sm">
|
||||||
|
<p class="text-gray-700 dark:text-gray-300">Make sure to enter the correct callback URL in your external provider's configuration.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-start mt-4">
|
||||||
|
<div class="flex items-center h-5">
|
||||||
|
<i class="fas fa-user-check w-4 h-4 text-indigo-500 dark:text-indigo-400 mr-2"></i>
|
||||||
|
</div>
|
||||||
|
<div class="ml-2 text-sm">
|
||||||
|
<p class="text-gray-700 dark:text-gray-300">Configure attribute mappings to match the fields in your identity provider's user data.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function updateFormFields() {
|
||||||
|
// Hide all provider-specific sections first
|
||||||
|
document.querySelectorAll('.provider-specific-section').forEach(section => {
|
||||||
|
section.style.display = 'none';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Show the relevant section based on selected provider type
|
||||||
|
const providerType = document.getElementById('type').value;
|
||||||
|
|
||||||
|
if (providerType === 'authentik') {
|
||||||
|
document.getElementById('authentikSection').style.display = 'block';
|
||||||
|
} else if (providerType === 'oidc') {
|
||||||
|
document.getElementById('oidcSection').style.display = 'block';
|
||||||
|
} else if (providerType === 'saml') {
|
||||||
|
document.getElementById('samlSection').style.display = 'block';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function testProviderConnection() {
|
||||||
|
const providerId = window.location.pathname.split('/').slice(-2)[0];
|
||||||
|
|
||||||
|
const testBtn = event.target;
|
||||||
|
const originalText = testBtn.innerHTML;
|
||||||
|
testBtn.disabled = true;
|
||||||
|
testBtn.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i> Testing...';
|
||||||
|
|
||||||
|
fetch(`/admin/settings/auth-providers/${providerId}/test`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.error) {
|
||||||
|
showToast('error', data.error);
|
||||||
|
} else if (data.success) {
|
||||||
|
showToast('success', data.message || 'Connection test successful');
|
||||||
|
} else {
|
||||||
|
showToast('error', 'Connection test failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
testBtn.disabled = false;
|
||||||
|
testBtn.innerHTML = originalText;
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error:', error);
|
||||||
|
showToast('error', 'Connection test failed');
|
||||||
|
|
||||||
|
testBtn.disabled = false;
|
||||||
|
testBtn.innerHTML = originalText;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle icon URL preview
|
||||||
|
function updateIconPreview() {
|
||||||
|
const iconUrl = document.getElementById('icon_url').value;
|
||||||
|
const previewImg = document.getElementById('preview-img');
|
||||||
|
const previewPlaceholder = document.getElementById('preview-placeholder');
|
||||||
|
|
||||||
|
if (iconUrl && iconUrl.trim() !== '') {
|
||||||
|
// Create image element if it doesn't exist
|
||||||
|
if (!previewImg) {
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.id = 'preview-img';
|
||||||
|
img.className = 'max-w-full max-h-full';
|
||||||
|
img.onerror = function() {
|
||||||
|
// If image fails to load, show placeholder
|
||||||
|
this.style.display = 'none';
|
||||||
|
if (previewPlaceholder) {
|
||||||
|
previewPlaceholder.style.display = 'block';
|
||||||
|
} else {
|
||||||
|
const icon = document.createElement('i');
|
||||||
|
icon.id = 'preview-placeholder';
|
||||||
|
icon.className = 'fas fa-exclamation-circle text-red-500 text-xl';
|
||||||
|
document.getElementById('icon-preview').appendChild(icon);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
img.onload = function() {
|
||||||
|
// If image loads successfully, hide placeholder
|
||||||
|
this.style.display = 'block';
|
||||||
|
if (previewPlaceholder) {
|
||||||
|
previewPlaceholder.style.display = 'none';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.getElementById('icon-preview').appendChild(img);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update image source
|
||||||
|
if (previewImg) {
|
||||||
|
previewImg.src = iconUrl;
|
||||||
|
previewImg.style.display = 'block';
|
||||||
|
if (previewPlaceholder) {
|
||||||
|
previewPlaceholder.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// If no URL, show placeholder
|
||||||
|
if (previewImg) {
|
||||||
|
previewImg.style.display = 'none';
|
||||||
|
}
|
||||||
|
if (previewPlaceholder) {
|
||||||
|
previewPlaceholder.style.display = 'block';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize the form on page load
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
updateFormFields();
|
||||||
|
|
||||||
|
// Set dark background color if in dark mode
|
||||||
|
if (document.documentElement.classList.contains('dark')) {
|
||||||
|
document.getElementById('auth-provider-form-container').style.backgroundColor = '#111827';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add event listener for theme changes
|
||||||
|
const themeToggle = document.getElementById('theme-toggle');
|
||||||
|
if (themeToggle) {
|
||||||
|
themeToggle.addEventListener('click', function() {
|
||||||
|
setTimeout(function() {
|
||||||
|
const isDark = document.documentElement.classList.contains('dark');
|
||||||
|
document.getElementById('auth-provider-form-container').style.backgroundColor = isDark ? '#111827' : 'rgb(249, 250, 251)';
|
||||||
|
}, 50);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add event listener for icon URL changes
|
||||||
|
const iconUrlInput = document.getElementById('icon_url');
|
||||||
|
if (iconUrlInput) {
|
||||||
|
iconUrlInput.addEventListener('input', updateIconPreview);
|
||||||
|
// Initial preview
|
||||||
|
updateIconPreview();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getPageTitle(isNew bool) string {
|
||||||
|
if isNew {
|
||||||
|
return "New Authentication Provider"
|
||||||
|
}
|
||||||
|
return "Edit Authentication Provider"
|
||||||
|
}
|
||||||
|
|
||||||
|
templ formContent(provider *db.AuthProvider, isNew bool) {
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
|
||||||
|
<!-- Name -->
|
||||||
|
<div>
|
||||||
|
<label for="name" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Name <span class="text-red-600">*</span></label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="name"
|
||||||
|
name="name"
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
placeholder="e.g. Authentik SSO"
|
||||||
|
required
|
||||||
|
value={ getValue(provider, "name") }
|
||||||
|
/>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">A descriptive name for this authentication provider</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Provider Type -->
|
||||||
|
<div>
|
||||||
|
<label for="type" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Provider Type <span class="text-red-600">*</span></label>
|
||||||
|
<select id="type" name="type" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" required onchange="updateFormFields()">
|
||||||
|
<option value="">Select a provider type</option>
|
||||||
|
if provider != nil && provider.Type == db.ProviderTypeAuthentik {
|
||||||
|
<option value="authentik" selected>Authentik</option>
|
||||||
|
} else {
|
||||||
|
<option value="authentik">Authentik</option>
|
||||||
|
}
|
||||||
|
if provider != nil && provider.Type == db.ProviderTypeOIDC {
|
||||||
|
<option value="oidc" selected>OpenID Connect (OIDC)</option>
|
||||||
|
} else {
|
||||||
|
<option value="oidc">OpenID Connect (OIDC)</option>
|
||||||
|
}
|
||||||
|
if provider != nil && provider.Type == db.ProviderTypeSAML {
|
||||||
|
<option value="saml" selected>SAML 2.0</option>
|
||||||
|
} else {
|
||||||
|
<option value="saml">SAML 2.0</option>
|
||||||
|
}
|
||||||
|
if provider != nil && provider.Type == db.ProviderTypeOAuth2 {
|
||||||
|
<option value="oauth2" selected>OAuth 2.0</option>
|
||||||
|
} else {
|
||||||
|
<option value="oauth2">OAuth 2.0</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">The type of external authentication service</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Enabled -->
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center">
|
||||||
|
if provider == nil || provider.GetEnabled() {
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="enabled"
|
||||||
|
name="enabled"
|
||||||
|
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"
|
||||||
|
checked
|
||||||
|
/>
|
||||||
|
} else {
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="enabled"
|
||||||
|
name="enabled"
|
||||||
|
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
<label for="enabled" class="ml-2 block text-sm font-medium text-gray-700 dark:text-gray-300">Enabled</label>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Whether this authentication provider is active and available for login</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Description -->
|
||||||
|
<div>
|
||||||
|
<label for="description" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Description</label>
|
||||||
|
<textarea
|
||||||
|
id="description"
|
||||||
|
name="description"
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
rows="3"
|
||||||
|
placeholder="Optional description of this provider"
|
||||||
|
>
|
||||||
|
if provider != nil {
|
||||||
|
{ provider.Description }
|
||||||
|
}
|
||||||
|
</textarea>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Additional information about this authentication provider</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Icon URL -->
|
||||||
|
<div class="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||||
|
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-4">Provider Icon</h3>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div>
|
||||||
|
<label for="icon_url" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Icon URL</label>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
id="icon_url"
|
||||||
|
name="icon_url"
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
placeholder="https://example.com/icon.svg"
|
||||||
|
value={ provider.IconURL }
|
||||||
|
/>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">URL to the provider's icon image (SVG recommended)</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Icon Preview</label>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div id="icon-preview" class="border border-gray-300 dark:border-gray-700 rounded-lg p-4 flex items-center justify-center w-20 h-20 bg-white dark:bg-gray-800">
|
||||||
|
if provider != nil && provider.IconURL != "" {
|
||||||
|
<img src={ provider.IconURL } class="max-w-full max-h-full" id="preview-img" />
|
||||||
|
} else {
|
||||||
|
<i class="fas fa-image text-gray-400 text-xl" id="preview-placeholder"></i>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="ml-4">
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">Preview of the icon that will be displayed on the login button.</p>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">If no URL is provided, a default icon will be used based on the provider type.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||||
|
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-4">Connection Settings</h3>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<!-- Provider URL -->
|
||||||
|
<div>
|
||||||
|
<label for="provider_url" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Provider URL <span class="text-red-600">*</span></label>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
id="provider_url"
|
||||||
|
name="provider_url"
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
placeholder="https://authentik.example.com"
|
||||||
|
required
|
||||||
|
value={ getValue(provider, "provider_url") }
|
||||||
|
/>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">The base URL of the authentication provider</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Client ID -->
|
||||||
|
<div>
|
||||||
|
<label for="client_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Client ID <span class="text-red-600">*</span></label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="client_id"
|
||||||
|
name="client_id"
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
required
|
||||||
|
value={ getValue(provider, "client_id") }
|
||||||
|
/>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">The client identifier assigned by the authentication provider</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Client Secret -->
|
||||||
|
<div>
|
||||||
|
<label for="client_secret" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
Client Secret
|
||||||
|
if !isNew {
|
||||||
|
<span class="text-gray-500 text-xs font-normal ml-2">(leave empty to keep current)</span>
|
||||||
|
} else {
|
||||||
|
<span class="text-red-600">*</span>
|
||||||
|
}
|
||||||
|
</label>
|
||||||
|
if isNew {
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="client_secret"
|
||||||
|
name="client_secret"
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
} else {
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="client_secret"
|
||||||
|
name="client_secret"
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">The client secret for authentication with the provider</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Redirect URL -->
|
||||||
|
<div>
|
||||||
|
<label for="redirect_url" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Redirect URL <span class="text-red-600">*</span></label>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
id="redirect_url"
|
||||||
|
name="redirect_url"
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
placeholder="https://your-app.example.com/auth/callback"
|
||||||
|
required
|
||||||
|
value={ getRedirectURL(provider) }
|
||||||
|
/>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">The callback URL that will handle the authentication response</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Scopes -->
|
||||||
|
<div>
|
||||||
|
<label for="scopes" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Scopes</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="scopes"
|
||||||
|
name="scopes"
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
placeholder="openid profile email"
|
||||||
|
value={ getValue(provider, "scopes") }
|
||||||
|
/>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Space-separated list of scopes to request from the provider</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="border-t border-gray-200 dark:border-gray-700 pt-6 provider-specific-section" id="authentikSection">
|
||||||
|
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-4">Authentik Settings</h3>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<!-- Authentik Tenant ID -->
|
||||||
|
<div>
|
||||||
|
<label for="authentik_tenant" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Tenant ID</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="authentik_tenant"
|
||||||
|
name="authentik_tenant"
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
placeholder="default"
|
||||||
|
value={ getConfigValue(provider, "tenant_id") }
|
||||||
|
/>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Authentik tenant ID (optional, defaults to 'default')</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="border-t border-gray-200 dark:border-gray-700 pt-6 provider-specific-section" id="oidcSection">
|
||||||
|
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-4">OpenID Connect Settings</h3>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<!-- OIDC Discovery URL -->
|
||||||
|
<div>
|
||||||
|
<label for="oidc_discovery_url" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Discovery URL</label>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
id="oidc_discovery_url"
|
||||||
|
name="oidc_discovery_url"
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
placeholder="https://provider/.well-known/openid-configuration"
|
||||||
|
value={ getConfigValue(provider, "discovery_url") }
|
||||||
|
/>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">URL to the OIDC discovery document</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="border-t border-gray-200 dark:border-gray-700 pt-6 provider-specific-section" id="samlSection">
|
||||||
|
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-4">SAML Settings</h3>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-6">
|
||||||
|
<!-- SAML Metadata URL -->
|
||||||
|
<div>
|
||||||
|
<label for="saml_metadata_url" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Metadata URL</label>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
id="saml_metadata_url"
|
||||||
|
name="saml_metadata_url"
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
placeholder="https://provider/metadata.xml"
|
||||||
|
value={ getConfigValue(provider, "metadata_url") }
|
||||||
|
/>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">URL to the SAML metadata XML</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||||
|
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-4">User Attribute Mapping</h3>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<!-- Username Attribute -->
|
||||||
|
<div>
|
||||||
|
<label for="attr_username" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Username Attribute</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="attr_username"
|
||||||
|
name="attr_username"
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
placeholder="preferred_username"
|
||||||
|
value={ getAttributeValue(provider, "username") }
|
||||||
|
/>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">The attribute to use as the username</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Email Attribute -->
|
||||||
|
<div>
|
||||||
|
<label for="attr_email" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Email Attribute</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="attr_email"
|
||||||
|
name="attr_email"
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
placeholder="email"
|
||||||
|
value={ getAttributeValue(provider, "email") }
|
||||||
|
/>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">The attribute to use as the email address</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Display Name Attribute -->
|
||||||
|
<div>
|
||||||
|
<label for="attr_name" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Display Name Attribute</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="attr_name"
|
||||||
|
name="attr_name"
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
placeholder="name"
|
||||||
|
value={ getAttributeValue(provider, "name") }
|
||||||
|
/>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">The attribute to use as the display name</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Groups Attribute -->
|
||||||
|
<div>
|
||||||
|
<label for="attr_groups" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Groups Attribute</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="attr_groups"
|
||||||
|
name="attr_groups"
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
placeholder="groups"
|
||||||
|
value={ getAttributeValue(provider, "groups") }
|
||||||
|
/>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">The attribute that contains user groups</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-4 mt-6">
|
||||||
|
<a href="/admin/settings/auth-providers" class="text-gray-700 bg-gray-100 hover:bg-gray-200 focus:ring-4 focus:outline-none focus:ring-gray-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center inline-flex items-center dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600 dark:focus:ring-gray-700">
|
||||||
|
Cancel
|
||||||
|
</a>
|
||||||
|
if !isNew {
|
||||||
|
<button type="button" class="text-white bg-blue-600 hover:bg-blue-700 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center inline-flex items-center dark:bg-blue-500 dark:hover:bg-blue-600 dark:focus:ring-blue-700" onclick="testProviderConnection()">
|
||||||
|
<i class="fas fa-check-circle w-4 h-4 mr-2"></i>
|
||||||
|
Test Connection
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
<button type="submit" class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center inline-flex items-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800">
|
||||||
|
if isNew {
|
||||||
|
<i class="fas fa-plus w-4 h-4 mr-2"></i>
|
||||||
|
Create Provider
|
||||||
|
} else {
|
||||||
|
<i class="fas fa-save w-4 h-4 mr-2"></i>
|
||||||
|
Save Changes
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
func getValue(provider *db.AuthProvider, field string) string {
|
||||||
|
if provider == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
switch field {
|
||||||
|
case "name":
|
||||||
|
return provider.Name
|
||||||
|
case "provider_url":
|
||||||
|
return provider.ProviderURL
|
||||||
|
case "client_id":
|
||||||
|
return provider.ClientID
|
||||||
|
case "redirect_url":
|
||||||
|
return provider.RedirectURL
|
||||||
|
case "scopes":
|
||||||
|
return provider.Scopes
|
||||||
|
case "icon_url":
|
||||||
|
return provider.IconURL
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getRedirectURL(provider *db.AuthProvider) string {
|
||||||
|
if provider == nil || provider.RedirectURL == "" {
|
||||||
|
return fmt.Sprintf("https://%s/auth/callback", "your-app-domain.com")
|
||||||
|
}
|
||||||
|
return provider.RedirectURL
|
||||||
|
}
|
||||||
|
|
||||||
|
func getConfigValue(provider *db.AuthProvider, key string) string {
|
||||||
|
if provider == nil || provider.Config == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var config map[string]interface{}
|
||||||
|
if err := json.Unmarshal([]byte(provider.Config), &config); err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if value, ok := config[key]; ok {
|
||||||
|
if strValue, ok := value.(string); ok {
|
||||||
|
return strValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func getAttributeValue(provider *db.AuthProvider, key string) string {
|
||||||
|
if provider == nil || provider.AttributeMapping == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var mapping map[string]string
|
||||||
|
if err := json.Unmarshal([]byte(provider.AttributeMapping), &mapping); err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if value, ok := mapping[key]; ok {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,380 @@
|
|||||||
|
package components
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
"github.com/starfleetcptn/gomft/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
// formatTime formats a time.Time value as a human-readable string
|
||||||
|
func formatTime(t time.Time) string {
|
||||||
|
return t.Format("Jan 02, 2006 15:04")
|
||||||
|
}
|
||||||
|
|
||||||
|
templ AuthProviders(ctx context.Context, providers []db.AuthProvider) {
|
||||||
|
@LayoutWithContext("Authentication Providers", ctx) {
|
||||||
|
<!-- Toast container for notifications -->
|
||||||
|
<div id="toast-container" class="fixed top-5 right-5 z-50 flex flex-col gap-2"></div>
|
||||||
|
|
||||||
|
<div id="auth-providers-container" style="min-height: 100vh; background-color: rgb(249, 250, 251);" class="auth-providers-page bg-gray-50 dark:bg-gray-900">
|
||||||
|
<div class="p-4 pb-8 w-full">
|
||||||
|
<div class="mb-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||||
|
<i class="fas fa-user-shield w-6 h-6 mr-2 text-blue-500 dark:text-blue-400"></i>
|
||||||
|
Authentication Providers
|
||||||
|
</h1>
|
||||||
|
<p class="text-gray-500 dark:text-gray-400">Manage external authentication sources like Authentik, OIDC, SAML, etc.</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<a href="/admin/settings/auth-providers/new" class="flex items-center justify-center text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg px-5 py-2.5 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800">
|
||||||
|
<i class="fas fa-plus w-4 h-4 mr-2"></i>
|
||||||
|
New Provider
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6">
|
||||||
|
if len(providers) > 0 {
|
||||||
|
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 overflow-hidden">
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full text-sm text-left rtl:text-right">
|
||||||
|
<thead class="text-xs uppercase bg-gray-100 dark:bg-gray-700">
|
||||||
|
<tr>
|
||||||
|
<th scope="col" class="px-6 py-3">Name</th>
|
||||||
|
<th scope="col" class="px-6 py-3">Icon</th>
|
||||||
|
<th scope="col" class="px-6 py-3">Type</th>
|
||||||
|
<th scope="col" class="px-6 py-3">Status</th>
|
||||||
|
<th scope="col" class="px-6 py-3">Provider URL</th>
|
||||||
|
<th scope="col" class="px-6 py-3">Last Used</th>
|
||||||
|
<th scope="col" class="px-6 py-3">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
for _, provider := range providers {
|
||||||
|
<tr class="border-b border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50">
|
||||||
|
<td class="px-6 py-4 font-medium text-gray-900 dark:text-white whitespace-nowrap">
|
||||||
|
{ provider.Name }
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
<div class="flex items-center justify-center">
|
||||||
|
if provider.IconURL != "" {
|
||||||
|
<img src={ provider.IconURL } class="w-6 h-6" alt={ provider.Name + " icon" } />
|
||||||
|
} else {
|
||||||
|
if provider.Type == db.ProviderTypeAuthentik {
|
||||||
|
<img src="/static/img/authentik.svg" class="w-6 h-6" alt="Authentik" />
|
||||||
|
} else if provider.Type == db.ProviderTypeOIDC {
|
||||||
|
<img src="/static/img/oidc.svg" class="w-6 h-6" alt="OIDC" />
|
||||||
|
} else if provider.Type == db.ProviderTypeSAML {
|
||||||
|
<img src="/static/img/saml.svg" class="w-6 h-6" alt="SAML" />
|
||||||
|
} else if provider.Type == db.ProviderTypeOAuth2 {
|
||||||
|
<img src="/static/img/oauth2.svg" class="w-6 h-6" alt="OAuth2" />
|
||||||
|
} else {
|
||||||
|
<i class="fas fa-user-shield text-blue-500 text-lg"></i>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 uppercase">
|
||||||
|
{ string(provider.Type) }
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
if provider.GetEnabled() {
|
||||||
|
<span class="px-2 py-1 text-xs rounded-full bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
|
||||||
|
Active
|
||||||
|
</span>
|
||||||
|
} else {
|
||||||
|
<span class="px-2 py-1 text-xs rounded-full bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200">
|
||||||
|
Disabled
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 max-w-[200px] truncate">
|
||||||
|
{ provider.ProviderURL }
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
if provider.LastUsed.Valid {
|
||||||
|
{ formatTime(provider.LastUsed.Time) }
|
||||||
|
} else {
|
||||||
|
<span class="text-gray-400 dark:text-gray-500">Never</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<a href={ templ.SafeURL("/admin/settings/auth-providers/" + fmt.Sprint(provider.ID) + "/edit") }
|
||||||
|
class="text-gray-700 bg-gray-100 hover:bg-gray-200 focus:ring-4 focus:outline-none focus:ring-gray-300 font-medium rounded-lg text-sm px-3 py-1.5 text-center inline-flex items-center dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600 dark:focus:ring-gray-700"
|
||||||
|
title="Edit">
|
||||||
|
<i class="fas fa-edit w-3.5 h-3.5 mr-1.5"></i>
|
||||||
|
Edit
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-provider-id={ fmt.Sprint(provider.ID) }
|
||||||
|
data-provider-name={ provider.Name }
|
||||||
|
class="delete-provider-btn text-white bg-red-700 hover:bg-red-800 focus:ring-4 focus:outline-none focus:ring-red-300 font-medium rounded-lg text-sm px-3 py-1.5 text-center inline-flex items-center dark:bg-red-600 dark:hover:bg-red-700 dark:focus:ring-red-800"
|
||||||
|
title="Delete">
|
||||||
|
<i class="fas fa-trash-alt w-3.5 h-3.5 mr-1.5"></i>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
data-provider-id={ fmt.Sprint(provider.ID) }
|
||||||
|
onclick="testProvider(this)"
|
||||||
|
class="text-blue-700 bg-blue-100 hover:bg-blue-200 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-3 py-1.5 text-center inline-flex items-center dark:bg-blue-700 dark:text-blue-300 dark:hover:bg-blue-600 dark:focus:ring-blue-800"
|
||||||
|
title="Test Connection">
|
||||||
|
<i class="fas fa-check-circle w-3.5 h-3.5 mr-1.5"></i>
|
||||||
|
Test
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
} else {
|
||||||
|
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 p-8 flex flex-col items-center justify-center text-center">
|
||||||
|
<div class="inline-flex h-16 w-16 flex-shrink-0 items-center justify-center rounded-full bg-gray-100 mb-4 dark:bg-gray-700">
|
||||||
|
<i class="fas fa-user-shield text-gray-400 dark:text-gray-500 text-3xl"></i>
|
||||||
|
</div>
|
||||||
|
<h3 class="mb-2 text-lg font-semibold text-gray-900 dark:text-white">No Authentication Providers</h3>
|
||||||
|
<p class="text-gray-500 dark:text-gray-400 mb-4">
|
||||||
|
You haven't set up any external authentication providers yet.
|
||||||
|
</p>
|
||||||
|
<a href="/admin/settings/auth-providers/new" class="inline-flex items-center px-3 py-2 text-sm font-medium text-center text-white bg-blue-700 rounded-lg hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800">
|
||||||
|
<i class="fas fa-plus w-4 h-4 mr-2"></i>
|
||||||
|
Add First Provider
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Help Section -->
|
||||||
|
<div class="bg-gray-50 dark:bg-gray-800 rounded-lg shadow-sm mt-8 p-4 border border-gray-200 dark:border-gray-700">
|
||||||
|
<div class="flex items-start mb-2">
|
||||||
|
<div class="flex items-center h-5">
|
||||||
|
<i class="fas fa-info-circle w-4 h-4 text-blue-500 dark:text-blue-400 mr-2"></i>
|
||||||
|
</div>
|
||||||
|
<div class="ml-2 text-sm">
|
||||||
|
<p class="text-gray-700 dark:text-gray-300">Authentication providers allow users to sign in using external identity providers.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-start mt-4">
|
||||||
|
<div class="flex items-center h-5">
|
||||||
|
<i class="fas fa-key w-4 h-4 text-blue-500 dark:text-blue-400 mr-2"></i>
|
||||||
|
</div>
|
||||||
|
<div class="ml-2 text-sm">
|
||||||
|
<p class="text-gray-700 dark:text-gray-300">Make sure to configure callback URLs in your provider's settings.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-start mt-4">
|
||||||
|
<div class="flex items-center h-5">
|
||||||
|
<i class="fas fa-user-check w-4 h-4 text-indigo-500 dark:text-indigo-400 mr-2"></i>
|
||||||
|
</div>
|
||||||
|
<div class="ml-2 text-sm">
|
||||||
|
<p class="text-gray-700 dark:text-gray-300">Test your connections to ensure proper communication with external authentication systems.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Delete Provider Modal using Flowbite style -->
|
||||||
|
<div id="deleteProviderModal" tabindex="-1" aria-hidden="true" class="hidden overflow-y-auto overflow-x-hidden fixed top-0 right-0 left-0 z-50 justify-center items-center w-full md:inset-0 h-[calc(100%-1rem)] max-h-full bg-gray-900/50 dark:bg-gray-900/80 backdrop-blur-sm">
|
||||||
|
<div class="relative p-4 w-full max-w-md max-h-full mx-auto">
|
||||||
|
<div class="relative bg-white rounded-lg shadow dark:bg-gray-700">
|
||||||
|
<div class="p-6 text-center">
|
||||||
|
<i class="fas fa-trash-alt text-red-400 text-3xl mb-4"></i>
|
||||||
|
<h3 class="mb-5 text-lg font-normal text-gray-500 dark:text-gray-400">Are you sure you want to delete the provider <strong id="providerNameConfirm"></strong>?</h3>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
id="confirmDeleteBtn"
|
||||||
|
class="text-white bg-red-600 hover:bg-red-800 focus:ring-4 focus:outline-none focus:ring-red-300 dark:focus:ring-red-800 font-medium rounded-lg text-sm inline-flex items-center px-5 py-2.5 text-center">
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
<button type="button" onclick="closeDeleteModal()" class="text-gray-500 bg-white hover:bg-gray-100 focus:ring-4 focus:outline-none focus:ring-gray-200 rounded-lg border border-gray-200 text-sm font-medium px-5 py-2.5 hover:text-gray-900 focus:z-10 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-500 dark:hover:text-white dark:hover:bg-gray-600 dark:focus:ring-gray-600">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Toast notification function
|
||||||
|
function showToast(type, message) {
|
||||||
|
const toastContainer = document.getElementById('toast-container');
|
||||||
|
|
||||||
|
// Create toast element
|
||||||
|
const toast = document.createElement('div');
|
||||||
|
toast.id = 'toast-' + type + '-' + Date.now();
|
||||||
|
toast.className = 'flex items-center w-full max-w-xs p-4 mb-4 rounded-lg shadow text-gray-500 bg-white dark:text-gray-400 dark:bg-gray-800 transform translate-y-16 opacity-0 transition-all duration-300 ease-out';
|
||||||
|
toast.role = 'alert';
|
||||||
|
|
||||||
|
// Set toast content based on type
|
||||||
|
let iconClass, bgColorClass, textColorClass;
|
||||||
|
|
||||||
|
if (type === 'success') {
|
||||||
|
iconClass = 'text-green-500 bg-green-100 dark:bg-green-800 dark:text-green-200';
|
||||||
|
bgColorClass = 'text-green-500 dark:text-green-200';
|
||||||
|
textColorClass = 'text-green-500 dark:text-green-200';
|
||||||
|
} else if (type === 'error') {
|
||||||
|
iconClass = 'text-red-500 bg-red-100 dark:bg-red-800 dark:text-red-200';
|
||||||
|
bgColorClass = 'text-red-500 dark:text-red-200';
|
||||||
|
textColorClass = 'text-red-500 dark:text-red-200';
|
||||||
|
} else {
|
||||||
|
iconClass = 'text-blue-500 bg-blue-100 dark:bg-blue-800 dark:text-blue-200';
|
||||||
|
bgColorClass = 'text-blue-500 dark:text-blue-200';
|
||||||
|
textColorClass = 'text-blue-500 dark:text-blue-200';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set inner HTML with appropriate icon and message
|
||||||
|
toast.innerHTML = `
|
||||||
|
<div class="inline-flex items-center justify-center flex-shrink-0 w-8 h-8 rounded-lg ${iconClass}">
|
||||||
|
${type === 'success'
|
||||||
|
? '<i class="fas fa-check"></i>'
|
||||||
|
: type === 'error'
|
||||||
|
? '<i class="fas fa-exclamation-circle"></i>'
|
||||||
|
: '<i class="fas fa-info-circle"></i>'}
|
||||||
|
</div>
|
||||||
|
<div class="ml-3 text-sm font-normal">${message}</div>
|
||||||
|
<button type="button" class="ml-auto -mx-1.5 -my-1.5 bg-white text-gray-400 hover:text-gray-900 rounded-lg focus:ring-2 focus:ring-gray-300 p-1.5 hover:bg-gray-100 inline-flex h-8 w-8 dark:text-gray-500 dark:hover:text-white dark:bg-gray-800 dark:hover:bg-gray-700" data-dismiss-target="#${toast.id}" aria-label="Close">
|
||||||
|
<span class="sr-only">Close</span>
|
||||||
|
<i class="fas fa-times"></i>
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Add toast to container
|
||||||
|
toastContainer.appendChild(toast);
|
||||||
|
|
||||||
|
// Trigger animation after a small delay to ensure the DOM has updated
|
||||||
|
setTimeout(() => {
|
||||||
|
toast.classList.remove('translate-y-16', 'opacity-0');
|
||||||
|
toast.classList.add('translate-y-0', 'opacity-100');
|
||||||
|
}, 10);
|
||||||
|
|
||||||
|
// Add event listener to close button
|
||||||
|
const closeButton = toast.querySelector('button[data-dismiss-target]');
|
||||||
|
closeButton.addEventListener('click', function() {
|
||||||
|
// Animate out before removing
|
||||||
|
toast.classList.add('opacity-0', 'translate-y-4');
|
||||||
|
setTimeout(() => {
|
||||||
|
toast.remove();
|
||||||
|
}, 300);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto-remove toast after 5 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
toast.classList.add('opacity-0', 'translate-y-4');
|
||||||
|
setTimeout(() => {
|
||||||
|
toast.remove();
|
||||||
|
}, 300);
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmDeleteProvider(providerId, providerName) {
|
||||||
|
document.getElementById('providerNameConfirm').textContent = providerName;
|
||||||
|
document.getElementById('confirmDeleteBtn').onclick = () => deleteProvider(providerId);
|
||||||
|
|
||||||
|
// Show the modal
|
||||||
|
document.getElementById('deleteProviderModal').classList.remove('hidden');
|
||||||
|
document.getElementById('deleteProviderModal').classList.add('flex');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDeleteModal() {
|
||||||
|
document.getElementById('deleteProviderModal').classList.add('hidden');
|
||||||
|
document.getElementById('deleteProviderModal').classList.remove('flex');
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteProvider(providerId) {
|
||||||
|
fetch(`/admin/settings/auth-providers/${providerId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.error) {
|
||||||
|
showToast('error', data.error);
|
||||||
|
} else {
|
||||||
|
showToast('success', 'Authentication provider deleted successfully');
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.reload();
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
closeDeleteModal();
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error:', error);
|
||||||
|
showToast('error', 'Failed to delete authentication provider');
|
||||||
|
closeDeleteModal();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function testProvider(btn) {
|
||||||
|
const providerId = btn.getAttribute('data-provider-id');
|
||||||
|
const originalText = btn.innerHTML;
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.innerHTML = '<i class="fas fa-spinner fa-spin w-3.5 h-3.5 mr-1.5"></i> Testing...';
|
||||||
|
|
||||||
|
fetch(`/admin/settings/auth-providers/${providerId}/test`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.error) {
|
||||||
|
showToast('error', data.error);
|
||||||
|
} else if (data.success) {
|
||||||
|
showToast('success', data.message || 'Connection test successful');
|
||||||
|
} else {
|
||||||
|
showToast('error', 'Connection test failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = originalText;
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error:', error);
|
||||||
|
showToast('error', 'Connection test failed');
|
||||||
|
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = originalText;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set dark background color if in dark mode
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
if (document.documentElement.classList.contains('dark')) {
|
||||||
|
document.getElementById('auth-providers-container').style.backgroundColor = '#111827';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add event listener for theme changes
|
||||||
|
const themeToggle = document.getElementById('theme-toggle');
|
||||||
|
if (themeToggle) {
|
||||||
|
themeToggle.addEventListener('click', function() {
|
||||||
|
setTimeout(function() {
|
||||||
|
const isDark = document.documentElement.classList.contains('dark');
|
||||||
|
document.getElementById('auth-providers-container').style.backgroundColor = isDark ? '#111827' : 'rgb(249, 250, 251)';
|
||||||
|
}, 50);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up event listeners for delete buttons
|
||||||
|
document.querySelectorAll('.delete-provider-btn').forEach(button => {
|
||||||
|
button.addEventListener('click', function() {
|
||||||
|
const providerId = this.getAttribute('data-provider-id');
|
||||||
|
const providerName = this.getAttribute('data-provider-name');
|
||||||
|
confirmDeleteProvider(providerId, providerName);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package components
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"github.com/starfleetcptn/gomft/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
// getProviderIcon returns the appropriate icon for a provider
|
||||||
|
func getProviderIcon(provider db.AuthProvider) templ.Component {
|
||||||
|
// If provider has a custom icon URL, use it
|
||||||
|
if provider.IconURL != "" {
|
||||||
|
return templ.Raw(fmt.Sprintf(`<img src="%s" class="w-5 h-5" alt="%s icon" />`, provider.IconURL, provider.Name))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise fall back to default icons based on type
|
||||||
|
switch provider.Type {
|
||||||
|
case db.ProviderTypeAuthentik:
|
||||||
|
return templ.Raw(`<img src="/static/img/authentik.svg" class="w-5 h-5" alt="Authentik" />`)
|
||||||
|
case db.ProviderTypeOIDC:
|
||||||
|
return templ.Raw(`<img src="/static/img/oidc.svg" class="w-5 h-5" alt="OIDC" />`)
|
||||||
|
case db.ProviderTypeSAML:
|
||||||
|
return templ.Raw(`<img src="/static/img/saml.svg" class="w-5 h-5" alt="SAML" />`)
|
||||||
|
case db.ProviderTypeOAuth2:
|
||||||
|
return templ.Raw(`<img src="/static/img/oauth2.svg" class="w-5 h-5" alt="OAuth2" />`)
|
||||||
|
default:
|
||||||
|
return templ.Raw(`<i class="fas fa-user-shield text-blue-500"></i>`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
templ AuthProviderButtons(providers []db.AuthProvider) {
|
||||||
|
if len(providers) == 0 {
|
||||||
|
<div class="text-sm text-gray-500 dark:text-gray-400 italic">
|
||||||
|
No external authentication providers available
|
||||||
|
</div>
|
||||||
|
} else {
|
||||||
|
<div class="space-y-2 w-full">
|
||||||
|
for _, provider := range providers {
|
||||||
|
if provider.GetEnabled() {
|
||||||
|
<a
|
||||||
|
href={ templ.SafeURL(fmt.Sprintf("/auth/provider/%d", provider.ID)) }
|
||||||
|
class="w-full inline-flex items-center justify-center px-4 py-2.5 bg-gray-100 border border-gray-300 rounded-lg font-medium text-gray-700 hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 dark:bg-gray-700 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-600"
|
||||||
|
>
|
||||||
|
<span class="flex-shrink-0 w-5 h-5 mr-2.5">
|
||||||
|
@getProviderIcon(provider)
|
||||||
|
</span>
|
||||||
|
<span>{ provider.Name }</span>
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,651 @@
|
|||||||
|
package components
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"github.com/starfleetcptn/gomft/internal/db"
|
||||||
|
"time"
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"strconv"
|
||||||
|
"sort"
|
||||||
|
)
|
||||||
|
|
||||||
|
// JobCalendarData contains the data for the calendar view
|
||||||
|
type JobCalendarData struct {
|
||||||
|
Jobs []db.Job
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateCalendarEvents converts jobs to calendar events in JSON format
|
||||||
|
func generateCalendarEvents(jobs []db.Job) string {
|
||||||
|
type CalendarEvent struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Start string `json:"start"`
|
||||||
|
End string `json:"end,omitempty"`
|
||||||
|
AllDay bool `json:"allDay,omitempty"`
|
||||||
|
URL string `json:"url,omitempty"`
|
||||||
|
ClassName string `json:"className,omitempty"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
JobID uint `json:"jobId"`
|
||||||
|
JobName string `json:"jobName"`
|
||||||
|
RunTimes []string `json:"runTimes,omitempty"` // Store additional run times for this day
|
||||||
|
RunCount int `json:"runCount,omitempty"` // Count of runs on this day
|
||||||
|
Schedule string `json:"schedule,omitempty"` // Store the schedule for tooltip
|
||||||
|
}
|
||||||
|
|
||||||
|
var events []CalendarEvent
|
||||||
|
|
||||||
|
// Set the range for future occurrences - 2 months seems to be a good balance
|
||||||
|
now := time.Now()
|
||||||
|
twoMonthsLater := now.AddDate(0, 2, 0)
|
||||||
|
|
||||||
|
// Map to track events by job ID and date to consolidate multiple occurrences
|
||||||
|
eventsByJobAndDay := make(map[string][]time.Time)
|
||||||
|
|
||||||
|
// Store job information for easy access
|
||||||
|
jobInfo := make(map[uint]struct {
|
||||||
|
Name string
|
||||||
|
Enabled bool
|
||||||
|
Schedule string
|
||||||
|
})
|
||||||
|
|
||||||
|
// First, gather all runs and group them by job ID and day
|
||||||
|
for _, job := range jobs {
|
||||||
|
// Skip jobs with no next run time
|
||||||
|
if job.NextRun == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store job information
|
||||||
|
jobName := job.Name
|
||||||
|
if jobName == "" {
|
||||||
|
jobName = job.Config.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
jobInfo[job.ID] = struct {
|
||||||
|
Name string
|
||||||
|
Enabled bool
|
||||||
|
Schedule string
|
||||||
|
}{
|
||||||
|
Name: jobName,
|
||||||
|
Enabled: job.GetEnabled(),
|
||||||
|
Schedule: job.Schedule,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the first occurrence based on NextRun
|
||||||
|
nextRun := *job.NextRun
|
||||||
|
|
||||||
|
// Skip past events that are more than a day old
|
||||||
|
oneDayAgo := now.AddDate(0, 0, -1)
|
||||||
|
if nextRun.Before(oneDayAgo) {
|
||||||
|
// For past events, if we have LastRun, use that instead
|
||||||
|
if job.LastRun != nil {
|
||||||
|
nextRun = *job.LastRun
|
||||||
|
// Still skip if it's too old
|
||||||
|
if nextRun.Before(oneDayAgo) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add initial run to the map
|
||||||
|
dateKey := fmt.Sprintf("%d-%s", job.ID, nextRun.Format("2006-01-02"))
|
||||||
|
eventsByJobAndDay[dateKey] = append(eventsByJobAndDay[dateKey], nextRun)
|
||||||
|
|
||||||
|
// Try to determine future occurrences based on the cron schedule
|
||||||
|
var interval time.Duration
|
||||||
|
schedule := strings.ToLower(job.Schedule)
|
||||||
|
|
||||||
|
// Determine interval based on schedule
|
||||||
|
switch {
|
||||||
|
case strings.Contains(schedule, "every minute") || strings.Contains(schedule, "* * * * *"):
|
||||||
|
interval = 1 * time.Minute
|
||||||
|
case strings.Contains(schedule, "every 5 minutes") || strings.Contains(schedule, "*/5 * * * *"):
|
||||||
|
interval = 5 * time.Minute
|
||||||
|
case strings.Contains(schedule, "every 10 minutes") || strings.Contains(schedule, "*/10 * * * *"):
|
||||||
|
interval = 10 * time.Minute
|
||||||
|
case strings.Contains(schedule, "every 15 minutes") || strings.Contains(schedule, "*/15 * * * *"):
|
||||||
|
interval = 15 * time.Minute
|
||||||
|
case strings.Contains(schedule, "every 30 minutes") || strings.Contains(schedule, "*/30 * * * *"):
|
||||||
|
interval = 30 * time.Minute
|
||||||
|
case strings.Contains(schedule, "hourly") || strings.Contains(schedule, "0 * * * *"):
|
||||||
|
interval = 1 * time.Hour
|
||||||
|
case strings.Contains(schedule, "every 2 hours") || strings.Contains(schedule, "0 */2 * * *"):
|
||||||
|
interval = 2 * time.Hour
|
||||||
|
case strings.Contains(schedule, "every 3 hours") || strings.Contains(schedule, "0 */3 * * *"):
|
||||||
|
interval = 3 * time.Hour
|
||||||
|
case strings.Contains(schedule, "every 4 hours") || strings.Contains(schedule, "0 */4 * * *"):
|
||||||
|
interval = 4 * time.Hour
|
||||||
|
case strings.Contains(schedule, "every 6 hours") || strings.Contains(schedule, "0 */6 * * *"):
|
||||||
|
interval = 6 * time.Hour
|
||||||
|
case strings.Contains(schedule, "every 12 hours") || strings.Contains(schedule, "0 */12 * * *"):
|
||||||
|
interval = 12 * time.Hour
|
||||||
|
case strings.Contains(schedule, "daily") || strings.Contains(schedule, "0 0 * * *"):
|
||||||
|
interval = 24 * time.Hour
|
||||||
|
case strings.Contains(schedule, "weekly") || strings.Contains(schedule, "0 0 * * 0"):
|
||||||
|
interval = 7 * 24 * time.Hour
|
||||||
|
case strings.Contains(schedule, "monthly") || strings.Contains(schedule, "0 0 1 * *"):
|
||||||
|
// Approximate as 30 days
|
||||||
|
interval = 30 * 24 * time.Hour
|
||||||
|
default:
|
||||||
|
// For other schedules, try a simple cron expression check
|
||||||
|
if strings.Contains(schedule, "* * * * *") {
|
||||||
|
// Every minute
|
||||||
|
interval = 1 * time.Minute
|
||||||
|
} else if strings.Contains(schedule, "*/") {
|
||||||
|
// Likely a recurring job with specific interval
|
||||||
|
interval = 1 * time.Hour // Default to hourly as a safe guess
|
||||||
|
} else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate future occurrences
|
||||||
|
// Limit the number of runs we'll capture per day
|
||||||
|
maxRunsPerDay := 20
|
||||||
|
|
||||||
|
// Generate future occurrences up to our limits
|
||||||
|
currentTime := nextRun.Add(interval)
|
||||||
|
|
||||||
|
for currentTime.Before(twoMonthsLater) {
|
||||||
|
dateKey := fmt.Sprintf("%d-%s", job.ID, currentTime.Format("2006-01-02"))
|
||||||
|
|
||||||
|
// Check if we already have too many runs for this day
|
||||||
|
if len(eventsByJobAndDay[dateKey]) < maxRunsPerDay {
|
||||||
|
eventsByJobAndDay[dateKey] = append(eventsByJobAndDay[dateKey], currentTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
currentTime = currentTime.Add(interval)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now convert the map to calendar events, consolidating runs on the same day
|
||||||
|
for dateKey, runTimes := range eventsByJobAndDay {
|
||||||
|
// Parse job ID from date key
|
||||||
|
parts := strings.Split(dateKey, "-")
|
||||||
|
jobIDStr := parts[0]
|
||||||
|
|
||||||
|
jobID, _ := strconv.ParseUint(jobIDStr, 10, 32)
|
||||||
|
|
||||||
|
// Get the job info
|
||||||
|
job, exists := jobInfo[uint(jobID)]
|
||||||
|
if !exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort run times chronologically
|
||||||
|
sort.Slice(runTimes, func(i, j int) bool {
|
||||||
|
return runTimes[i].Before(runTimes[j])
|
||||||
|
})
|
||||||
|
|
||||||
|
// Use the first run time as the event time
|
||||||
|
firstRunTime := runTimes[0]
|
||||||
|
|
||||||
|
// Format run times for display in tooltip
|
||||||
|
formattedTimes := make([]string, 0, len(runTimes))
|
||||||
|
for i, rt := range runTimes {
|
||||||
|
// Limit to showing max 10 times in tooltip
|
||||||
|
if i >= 10 {
|
||||||
|
formattedTimes = append(formattedTimes, fmt.Sprintf("... and %d more", len(runTimes)-10))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
formattedTimes = append(formattedTimes, rt.Format("15:04:05"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set class based on job enabled status
|
||||||
|
className := ""
|
||||||
|
if job.Enabled {
|
||||||
|
className = "bg-blue-200 border-blue-600 text-blue-800 dark:bg-blue-800 dark:border-blue-500 dark:text-blue-100"
|
||||||
|
} else {
|
||||||
|
className = "bg-gray-200 border-gray-400 text-gray-700 dark:bg-gray-700 dark:border-gray-500 dark:text-gray-300"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a single event for this job on this day
|
||||||
|
title := job.Name
|
||||||
|
if len(runTimes) > 1 {
|
||||||
|
title = fmt.Sprintf("%s (%d runs)", job.Name, len(runTimes))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create event
|
||||||
|
events = append(events, CalendarEvent{
|
||||||
|
ID: fmt.Sprintf("job-%d-%s", jobID, firstRunTime.Format("20060102")),
|
||||||
|
Title: title,
|
||||||
|
Start: firstRunTime.Format(time.RFC3339),
|
||||||
|
AllDay: false,
|
||||||
|
URL: fmt.Sprintf("/jobs/%d", jobID),
|
||||||
|
ClassName: className,
|
||||||
|
Description: fmt.Sprintf("Schedule: %s", job.Schedule),
|
||||||
|
Enabled: job.Enabled,
|
||||||
|
JobID: uint(jobID),
|
||||||
|
JobName: job.Name,
|
||||||
|
RunTimes: formattedTimes,
|
||||||
|
RunCount: len(runTimes),
|
||||||
|
Schedule: job.Schedule,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debug info
|
||||||
|
fmt.Printf("Generated %d consolidated calendar events\n", len(events))
|
||||||
|
|
||||||
|
eventsJSON, err := json.Marshal(events)
|
||||||
|
if err != nil {
|
||||||
|
return "[]" // Return empty array if marshaling fails
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(eventsJSON)
|
||||||
|
}
|
||||||
|
|
||||||
|
// JobCalendar displays scheduled jobs in a calendar view
|
||||||
|
templ JobCalendar(ctx context.Context, data JobCalendarData) {
|
||||||
|
@LayoutWithContext("Transfer Calendar", ctx) {
|
||||||
|
<div class="p-6">
|
||||||
|
<div class="w-full">
|
||||||
|
<div class="flex flex-col md:flex-row justify-between items-start md:items-center mb-6">
|
||||||
|
<div class="flex items-center mb-4 md:mb-0">
|
||||||
|
<i class="fas fa-calendar-alt text-blue-500 mr-2"></i>
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Transfer Calendar</h1>
|
||||||
|
</div>
|
||||||
|
<a href="/jobs/new" class="text-white bg-blue-600 hover:bg-blue-700 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 dark:bg-blue-600 dark:hover:bg-blue-700 flex items-center">
|
||||||
|
<i class="fas fa-plus mr-2"></i>
|
||||||
|
New Job
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-6">
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<button id="showAll" class="flex items-center text-sm px-4 py-2 rounded-lg bg-blue-100 text-blue-800 hover:bg-blue-200 dark:bg-blue-800 dark:text-blue-100 dark:hover:bg-blue-700">
|
||||||
|
<i class="fas fa-calendar-check mr-2"></i>All Jobs
|
||||||
|
</button>
|
||||||
|
<button id="showActive" class="flex items-center text-sm px-4 py-2 rounded-lg bg-gray-100 text-gray-800 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-100 dark:hover:bg-gray-600">
|
||||||
|
<i class="fas fa-toggle-on mr-2"></i>Active Only
|
||||||
|
</button>
|
||||||
|
<button id="showInactive" class="flex items-center text-sm px-4 py-2 rounded-lg bg-gray-100 text-gray-800 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-100 dark:hover:bg-gray-600">
|
||||||
|
<i class="fas fa-toggle-off mr-2"></i>Inactive Only
|
||||||
|
</button>
|
||||||
|
<button id="showNext" class="flex items-center text-sm px-4 py-2 rounded-lg bg-gray-100 text-gray-800 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-100 dark:hover:bg-gray-600">
|
||||||
|
<i class="fas fa-step-forward mr-2"></i>Next Occurrences Only
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Hidden div to store calendar event data -->
|
||||||
|
<div id="calendar-data" style="display: none;">{ generateCalendarEvents(data.Jobs) }</div>
|
||||||
|
|
||||||
|
<!-- Loading indicator -->
|
||||||
|
<div id="calendar-loading" class="flex items-center justify-center p-8">
|
||||||
|
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 dark:border-blue-400"></div>
|
||||||
|
<span class="ml-3 text-gray-600 dark:text-gray-400">Loading calendar...</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="calendar" class="w-full bg-white dark:bg-gray-800 rounded-lg shadow-md overflow-hidden hidden"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Info section -->
|
||||||
|
<div class="bg-white dark:bg-gray-800 p-6 rounded-lg shadow-md mb-8 mt-6 mx-6">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">About the Calendar View</h2>
|
||||||
|
<p class="text-gray-700 dark:text-gray-300 mb-4">
|
||||||
|
This calendar displays your scheduled transfer jobs for the next 2 months. Click on any event to view or edit the job details.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="bg-blue-50 dark:bg-blue-900 p-4 rounded-lg mb-4">
|
||||||
|
<p class="text-blue-700 dark:text-blue-300 text-sm">
|
||||||
|
<i class="fas fa-info-circle mr-2"></i>
|
||||||
|
Jobs with multiple runs on the same day are consolidated into a single event. Hover over any event to see all scheduled run times for that day.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 class="text-lg font-medium text-gray-900 dark:text-white mt-4 mb-2">Filter Options</h3>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-3 mb-4">
|
||||||
|
<div class="flex items-start">
|
||||||
|
<i class="fas fa-calendar-check mt-1 text-blue-500 dark:text-blue-400 mr-2"></i>
|
||||||
|
<div>
|
||||||
|
<p class="font-medium text-gray-800 dark:text-gray-200">All Jobs</p>
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400">Shows all scheduled occurrences in the selected timeframe.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-start">
|
||||||
|
<i class="fas fa-toggle-on mt-1 text-blue-500 dark:text-blue-400 mr-2"></i>
|
||||||
|
<div>
|
||||||
|
<p class="font-medium text-gray-800 dark:text-gray-200">Active Only</p>
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400">Shows only enabled jobs that will actually run.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-start">
|
||||||
|
<i class="fas fa-toggle-off mt-1 text-blue-500 dark:text-blue-400 mr-2"></i>
|
||||||
|
<div>
|
||||||
|
<p class="font-medium text-gray-800 dark:text-gray-200">Inactive Only</p>
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400">Shows disabled jobs that won't run unless re-enabled.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-start">
|
||||||
|
<i class="fas fa-step-forward mt-1 text-blue-500 dark:text-blue-400 mr-2"></i>
|
||||||
|
<div>
|
||||||
|
<p class="font-medium text-gray-800 dark:text-gray-200">Next Occurrences Only</p>
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400">Shows only the next upcoming occurrence of each job.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 class="text-lg font-medium text-gray-900 dark:text-white mt-4 mb-2">Legend</h3>
|
||||||
|
<div class="flex flex-wrap gap-4">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="w-4 h-4 rounded-full bg-blue-500 mr-2"></div>
|
||||||
|
<span class="text-gray-700 dark:text-gray-300">Active Jobs</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="w-4 h-4 rounded-full bg-gray-500 mr-2"></div>
|
||||||
|
<span class="text-gray-700 dark:text-gray-300">Inactive Jobs</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Local assets are now loaded through vendor.js bundle -->
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Function to initialize the calendar
|
||||||
|
function initializeCalendar() {
|
||||||
|
// Get the event data from the hidden div
|
||||||
|
try {
|
||||||
|
// Show loading indicator
|
||||||
|
const loadingIndicator = document.getElementById('calendar-loading');
|
||||||
|
const calendarElement = document.getElementById('calendar');
|
||||||
|
|
||||||
|
// Parse the data in a non-blocking way
|
||||||
|
try {
|
||||||
|
const eventsData = JSON.parse(document.getElementById('calendar-data').textContent);
|
||||||
|
|
||||||
|
// Check if FullCalendar is available
|
||||||
|
if (typeof FullCalendar === 'undefined') {
|
||||||
|
console.error('FullCalendar is not loaded yet. Waiting...');
|
||||||
|
setTimeout(initializeCalendar, 100);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize calendar with events data
|
||||||
|
const calendar = new FullCalendar.Calendar(calendarElement, {
|
||||||
|
initialView: 'dayGridMonth',
|
||||||
|
plugins: [
|
||||||
|
FullCalendar.dayGridPlugin,
|
||||||
|
FullCalendar.timeGridPlugin,
|
||||||
|
FullCalendar.listPlugin,
|
||||||
|
FullCalendar.interactionPlugin
|
||||||
|
],
|
||||||
|
headerToolbar: {
|
||||||
|
left: 'prev,next today',
|
||||||
|
center: 'title',
|
||||||
|
right: 'dayGridMonth,timeGridWeek,listWeek'
|
||||||
|
},
|
||||||
|
events: eventsData,
|
||||||
|
eventTimeFormat: {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: true
|
||||||
|
},
|
||||||
|
eventDidMount: function(info) {
|
||||||
|
// Log the event data for debugging
|
||||||
|
console.log('Event mounted:', info.event.title, 'Enabled:', info.event.extendedProps.enabled);
|
||||||
|
|
||||||
|
// Add tooltips to events
|
||||||
|
if (info.event.extendedProps) {
|
||||||
|
// Only add tooltips to visible events to improve performance
|
||||||
|
if (info.view.type === 'dayGridMonth' &&
|
||||||
|
info.event.start >= calendar.view.activeStart &&
|
||||||
|
info.event.start <= calendar.view.activeEnd) {
|
||||||
|
|
||||||
|
// Create enhanced tooltip content with run times
|
||||||
|
let tooltipContent = `<div class="p-2">`;
|
||||||
|
|
||||||
|
tooltipContent += `<div class="font-bold mb-1">${info.event.title}</div>`;
|
||||||
|
tooltipContent += `<div class="text-sm mb-2">Schedule: ${info.event.extendedProps.schedule || 'Unknown'}</div>`;
|
||||||
|
|
||||||
|
// Show the run times if available
|
||||||
|
if (info.event.extendedProps.runTimes && info.event.extendedProps.runTimes.length > 0) {
|
||||||
|
tooltipContent += `<div class="font-bold text-xs mt-1">Run Times:</div>`;
|
||||||
|
tooltipContent += `<div class="text-xs">`;
|
||||||
|
|
||||||
|
// Show the run times in a list
|
||||||
|
info.event.extendedProps.runTimes.forEach(time => {
|
||||||
|
tooltipContent += `<div>${time}</div>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
tooltipContent += `</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
tooltipContent += `<div class="text-xs mt-2">Click to view/edit job</div>`;
|
||||||
|
tooltipContent += `</div>`;
|
||||||
|
|
||||||
|
tippy(info.el, {
|
||||||
|
content: tooltipContent,
|
||||||
|
allowHTML: true,
|
||||||
|
placement: 'top',
|
||||||
|
arrow: true,
|
||||||
|
interactive: true,
|
||||||
|
maxWidth: 300,
|
||||||
|
theme: document.documentElement.classList.contains('dark') ? 'dark' : 'light'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
eventClick: function(info) {
|
||||||
|
// Use the URL property to navigate to job details
|
||||||
|
if (info.event.url) {
|
||||||
|
window.location.href = info.event.url;
|
||||||
|
return false; // Prevents the default action
|
||||||
|
}
|
||||||
|
},
|
||||||
|
eventWillUnmount: function(info) {
|
||||||
|
// Cleanup any tooltips to prevent memory leaks
|
||||||
|
if (info.el._tippy) {
|
||||||
|
info.el._tippy.destroy();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
themeSystem: 'standard',
|
||||||
|
loading: function(isLoading) {
|
||||||
|
if (!isLoading) {
|
||||||
|
// Hide loading indicator and show calendar
|
||||||
|
loadingIndicator.classList.add('hidden');
|
||||||
|
calendarElement.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
calendar.render();
|
||||||
|
|
||||||
|
// Handle filter buttons with optimized filtering
|
||||||
|
document.getElementById('showAll').addEventListener('click', function() {
|
||||||
|
updateActiveButton(this);
|
||||||
|
// Show all events
|
||||||
|
calendar.getEvents().forEach(event => {
|
||||||
|
event.setProp('display', '');
|
||||||
|
});
|
||||||
|
calendar.render(); // Re-render to apply changes
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('showActive').addEventListener('click', function() {
|
||||||
|
updateActiveButton(this);
|
||||||
|
// Show only active events
|
||||||
|
calendar.getEvents().forEach(event => {
|
||||||
|
const isEnabled = event.extendedProps.enabled;
|
||||||
|
event.setProp('display', isEnabled ? '' : 'none');
|
||||||
|
});
|
||||||
|
calendar.render(); // Re-render to apply changes
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('showInactive').addEventListener('click', function() {
|
||||||
|
updateActiveButton(this);
|
||||||
|
// Show only inactive events
|
||||||
|
calendar.getEvents().forEach(event => {
|
||||||
|
const isEnabled = event.extendedProps.enabled;
|
||||||
|
event.setProp('display', !isEnabled ? '' : 'none');
|
||||||
|
});
|
||||||
|
calendar.render(); // Re-render to apply changes
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('showNext').addEventListener('click', function() {
|
||||||
|
updateActiveButton(this);
|
||||||
|
|
||||||
|
console.log("Next Occurrences Only filter clicked");
|
||||||
|
|
||||||
|
// First reset all events to make sure none are hidden
|
||||||
|
calendar.getEvents().forEach(event => {
|
||||||
|
event.setProp('display', 'none');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get a map of all jobs
|
||||||
|
const jobIDs = new Set();
|
||||||
|
calendar.getEvents().forEach(event => {
|
||||||
|
if (event.extendedProps && event.extendedProps.jobId) {
|
||||||
|
jobIDs.add(event.extendedProps.jobId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("Found jobs:", Array.from(jobIDs));
|
||||||
|
|
||||||
|
// For each job, find the next occurrence and show it
|
||||||
|
const now = new Date();
|
||||||
|
jobIDs.forEach(jobId => {
|
||||||
|
// Get all events for this job
|
||||||
|
const jobEvents = calendar.getEvents().filter(event =>
|
||||||
|
event.extendedProps &&
|
||||||
|
event.extendedProps.jobId === jobId
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`Job ${jobId}: Found ${jobEvents.length} events`);
|
||||||
|
|
||||||
|
// Find future events
|
||||||
|
const futureEvents = jobEvents.filter(event =>
|
||||||
|
new Date(event.start) >= now
|
||||||
|
).sort((a, b) =>
|
||||||
|
new Date(a.start) - new Date(b.start)
|
||||||
|
);
|
||||||
|
|
||||||
|
// If we have future events, show the earliest one
|
||||||
|
if (futureEvents.length > 0) {
|
||||||
|
console.log(`Job ${jobId}: Next occurrence at ${futureEvents[0].start}`);
|
||||||
|
futureEvents[0].setProp('display', '');
|
||||||
|
} else {
|
||||||
|
// If no future events, find most recent past event
|
||||||
|
const pastEvents = jobEvents.filter(event =>
|
||||||
|
new Date(event.start) < now
|
||||||
|
).sort((a, b) =>
|
||||||
|
new Date(b.start) - new Date(a.start)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (pastEvents.length > 0) {
|
||||||
|
console.log(`Job ${jobId}: Most recent occurrence at ${pastEvents[0].start}`);
|
||||||
|
pastEvents[0].setProp('display', '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
calendar.render(); // Re-render to apply changes
|
||||||
|
});
|
||||||
|
|
||||||
|
function updateActiveButton(activeBtn) {
|
||||||
|
// Reset all buttons
|
||||||
|
document.querySelectorAll('#showAll, #showActive, #showInactive, #showNext').forEach(btn => {
|
||||||
|
btn.classList.remove('bg-blue-100', 'text-blue-800', 'dark:bg-blue-800', 'dark:text-blue-100');
|
||||||
|
btn.classList.add('bg-gray-100', 'text-gray-800', 'dark:bg-gray-700', 'dark:text-gray-100');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set active button
|
||||||
|
activeBtn.classList.remove('bg-gray-100', 'text-gray-800', 'dark:bg-gray-700', 'dark:text-gray-100');
|
||||||
|
activeBtn.classList.add('bg-blue-100', 'text-blue-800', 'dark:bg-blue-800', 'dark:text-blue-100');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle theme changes
|
||||||
|
const themeToggle = document.getElementById('theme-toggle');
|
||||||
|
if (themeToggle) {
|
||||||
|
themeToggle.addEventListener('click', function() {
|
||||||
|
setTimeout(function() {
|
||||||
|
// Update tooltips theme
|
||||||
|
document.querySelectorAll('[data-tippy-root]').forEach(tooltip => {
|
||||||
|
tooltip.className = document.documentElement.classList.contains('dark')
|
||||||
|
? 'tippy-box dark-theme'
|
||||||
|
: 'tippy-box light-theme';
|
||||||
|
});
|
||||||
|
}, 100);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error initializing calendar:", error);
|
||||||
|
loadingIndicator.innerHTML = '<div class="text-red-500"><i class="fas fa-exclamation-triangle mr-2"></i>Error loading calendar data</div>';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error during initial calendar setup:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for vendor.js to load before initializing calendar
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
setTimeout(initializeCalendar, 100);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.fc-event {
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 2px 4px;
|
||||||
|
border-left-width: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Style consolidated events */
|
||||||
|
.fc-event-title {
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.85em;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tooltip styles */
|
||||||
|
.tippy-box {
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tippy-box[data-theme~='dark'] {
|
||||||
|
background-color: #1f2937;
|
||||||
|
color: #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tippy-box[data-theme~='light'] {
|
||||||
|
background-color: #ffffff;
|
||||||
|
color: #1f2937;
|
||||||
|
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.fc {
|
||||||
|
--fc-page-bg-color: #1f2937;
|
||||||
|
--fc-border-color: #374151;
|
||||||
|
--fc-neutral-bg-color: #374151;
|
||||||
|
--fc-neutral-text-color: #e5e7eb;
|
||||||
|
--fc-today-bg-color: rgba(59, 130, 246, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fc-day-today {
|
||||||
|
background-color: rgba(59, 130, 246, 0.15) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fc-col-header-cell {
|
||||||
|
background-color: #111827;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fc-scrollgrid-sync-inner {
|
||||||
|
color: #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fc-daygrid-day-number {
|
||||||
|
color: #e5e7eb;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
}
|
||||||
|
}
|
||||||
+695
-167
@@ -7,12 +7,18 @@ import (
|
|||||||
"github.com/starfleetcptn/gomft/components/providers/source"
|
"github.com/starfleetcptn/gomft/components/providers/source"
|
||||||
"github.com/starfleetcptn/gomft/components/providers/destination"
|
"github.com/starfleetcptn/gomft/components/providers/destination"
|
||||||
"github.com/starfleetcptn/gomft/components/providers/common"
|
"github.com/starfleetcptn/gomft/components/providers/common"
|
||||||
"strconv"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type ConfigFormData struct {
|
type ConfigFormData struct {
|
||||||
Config *db.TransferConfig
|
Config *db.TransferConfig
|
||||||
IsNew bool
|
IsNew bool
|
||||||
|
// Fields for pre-rendering flags on edit
|
||||||
|
InitialCommand *db.RcloneCommand
|
||||||
|
SelectedFlagsMap map[uint]bool
|
||||||
|
SelectedFlagValues map[uint]string
|
||||||
|
// Add source and destination providers
|
||||||
|
SourceProviders []db.StorageProvider
|
||||||
|
DestinationProviders []db.StorageProvider
|
||||||
}
|
}
|
||||||
|
|
||||||
func getConfigFormTitle(isNew bool) string {
|
func getConfigFormTitle(isNew bool) string {
|
||||||
@@ -28,7 +34,7 @@ func getInitialData(config *db.TransferConfig) string {
|
|||||||
sourceType := "local"
|
sourceType := "local"
|
||||||
sourcePath := ""
|
sourcePath := ""
|
||||||
sourceHost := ""
|
sourceHost := ""
|
||||||
sourcePort := 22
|
sourcePort := 0 // Initialize to 0 to trigger default setting
|
||||||
sourceUser := ""
|
sourceUser := ""
|
||||||
sourcePassword := ""
|
sourcePassword := ""
|
||||||
sourceKeyFile := ""
|
sourceKeyFile := ""
|
||||||
@@ -47,7 +53,7 @@ func getInitialData(config *db.TransferConfig) string {
|
|||||||
sourceTeamDrive := ""
|
sourceTeamDrive := ""
|
||||||
// Google Photos source fields
|
// Google Photos source fields
|
||||||
sourceReadOnly := false
|
sourceReadOnly := false
|
||||||
sourceStartYear, _ := strconv.Atoi(getCurrentYear())
|
sourceStartYear := 2025
|
||||||
sourceIncludeArchived := false
|
sourceIncludeArchived := false
|
||||||
|
|
||||||
filePattern := ""
|
filePattern := ""
|
||||||
@@ -56,7 +62,7 @@ func getInitialData(config *db.TransferConfig) string {
|
|||||||
destinationType := "local"
|
destinationType := "local"
|
||||||
destinationPath := ""
|
destinationPath := ""
|
||||||
destHost := ""
|
destHost := ""
|
||||||
destPort := 22
|
destPort := 0 // Initialize to 0 to trigger default setting
|
||||||
destUser := ""
|
destUser := ""
|
||||||
destPassword := ""
|
destPassword := ""
|
||||||
destKeyFile := ""
|
destKeyFile := ""
|
||||||
@@ -75,7 +81,7 @@ func getInitialData(config *db.TransferConfig) string {
|
|||||||
destTeamDrive := ""
|
destTeamDrive := ""
|
||||||
// Google Photos destination fields
|
// Google Photos destination fields
|
||||||
destReadOnly := false
|
destReadOnly := false
|
||||||
destStartYear, _ := strconv.Atoi(getCurrentYear()) // Default to current year int
|
destStartYear := 2025
|
||||||
destIncludeArchived := false
|
destIncludeArchived := false
|
||||||
|
|
||||||
archivePath := ""
|
archivePath := ""
|
||||||
@@ -84,9 +90,18 @@ func getInitialData(config *db.TransferConfig) string {
|
|||||||
skipProcessedFiles := true
|
skipProcessedFiles := true
|
||||||
maxConcurrentTransfers := 4
|
maxConcurrentTransfers := 4
|
||||||
rcloneFlags := ""
|
rcloneFlags := ""
|
||||||
|
commandId := uint(1) // Default to 'copy' command
|
||||||
|
commandFlags := ""
|
||||||
useBuiltinAuthSource := true
|
useBuiltinAuthSource := true
|
||||||
useBuiltinAuthDest := 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 editing an existing config, populate with those values
|
||||||
if config != nil {
|
if config != nil {
|
||||||
name = config.Name
|
name = config.Name
|
||||||
@@ -117,7 +132,7 @@ func getInitialData(config *db.TransferConfig) string {
|
|||||||
if config.SourceReadOnly != nil {
|
if config.SourceReadOnly != nil {
|
||||||
sourceReadOnly = *config.SourceReadOnly
|
sourceReadOnly = *config.SourceReadOnly
|
||||||
}
|
}
|
||||||
sourceStartYear = config.SourceStartYear
|
// sourceStartYear = config.SourceStartYear
|
||||||
if config.SourceIncludeArchived != nil {
|
if config.SourceIncludeArchived != nil {
|
||||||
sourceIncludeArchived = *config.SourceIncludeArchived
|
sourceIncludeArchived = *config.SourceIncludeArchived
|
||||||
}
|
}
|
||||||
@@ -152,7 +167,7 @@ func getInitialData(config *db.TransferConfig) string {
|
|||||||
if config.DestReadOnly != nil {
|
if config.DestReadOnly != nil {
|
||||||
destReadOnly = *config.DestReadOnly
|
destReadOnly = *config.DestReadOnly
|
||||||
}
|
}
|
||||||
destStartYear = config.DestStartYear
|
// destStartYear = config.DestStartYear
|
||||||
if config.DestIncludeArchived != nil {
|
if config.DestIncludeArchived != nil {
|
||||||
destIncludeArchived = *config.DestIncludeArchived
|
destIncludeArchived = *config.DestIncludeArchived
|
||||||
}
|
}
|
||||||
@@ -162,7 +177,12 @@ func getInitialData(config *db.TransferConfig) string {
|
|||||||
deleteAfterTransfer = config.GetDeleteAfterTransfer()
|
deleteAfterTransfer = config.GetDeleteAfterTransfer()
|
||||||
skipProcessedFiles = config.GetSkipProcessedFiles()
|
skipProcessedFiles = config.GetSkipProcessedFiles()
|
||||||
maxConcurrentTransfers = config.MaxConcurrentTransfers
|
maxConcurrentTransfers = config.MaxConcurrentTransfers
|
||||||
|
if maxConcurrentTransfers <= 0 {
|
||||||
|
maxConcurrentTransfers = 1 // Ensure at least 1 concurrent transfer
|
||||||
|
}
|
||||||
rcloneFlags = config.RcloneFlags
|
rcloneFlags = config.RcloneFlags
|
||||||
|
commandId = config.CommandID
|
||||||
|
commandFlags = config.CommandFlags
|
||||||
if config.UseBuiltinAuthSource != nil {
|
if config.UseBuiltinAuthSource != nil {
|
||||||
useBuiltinAuthSource = *config.UseBuiltinAuthSource
|
useBuiltinAuthSource = *config.UseBuiltinAuthSource
|
||||||
} else if sourceClientId != "" || sourceClientSecret != "" {
|
} else if sourceClientId != "" || sourceClientSecret != "" {
|
||||||
@@ -173,6 +193,17 @@ func getInitialData(config *db.TransferConfig) string {
|
|||||||
} else if destClientId != "" || destClientSecret != "" {
|
} else if destClientId != "" || destClientSecret != "" {
|
||||||
useBuiltinAuthDest = false
|
useBuiltinAuthDest = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Provider reference fields
|
||||||
|
if config.IsUsingSourceProviderReference() {
|
||||||
|
useSourceProvider = true
|
||||||
|
sourceProviderId = *config.SourceProviderID
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.IsUsingDestinationProviderReference() {
|
||||||
|
useDestinationProvider = true
|
||||||
|
destinationProviderId = *config.DestinationProviderID
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return the JSON-formatted string with all the data, add new path validation states
|
// Return the JSON-formatted string with all the data, add new path validation states
|
||||||
@@ -202,6 +233,9 @@ func getInitialData(config *db.TransferConfig) string {
|
|||||||
sourceStartYear: %d,
|
sourceStartYear: %d,
|
||||||
sourceIncludeArchived: %v,
|
sourceIncludeArchived: %v,
|
||||||
|
|
||||||
|
useSourceProvider: %v,
|
||||||
|
sourceProviderId: %d,
|
||||||
|
|
||||||
filePattern: '%s',
|
filePattern: '%s',
|
||||||
outputPattern: '%s',
|
outputPattern: '%s',
|
||||||
|
|
||||||
@@ -229,6 +263,9 @@ func getInitialData(config *db.TransferConfig) string {
|
|||||||
destStartYear: %d,
|
destStartYear: %d,
|
||||||
destIncludeArchived: %v,
|
destIncludeArchived: %v,
|
||||||
|
|
||||||
|
useDestinationProvider: %v,
|
||||||
|
destinationProviderId: %d,
|
||||||
|
|
||||||
useBuiltinAuthSource: %v,
|
useBuiltinAuthSource: %v,
|
||||||
useBuiltinAuthDest: %v,
|
useBuiltinAuthDest: %v,
|
||||||
|
|
||||||
@@ -238,6 +275,8 @@ func getInitialData(config *db.TransferConfig) string {
|
|||||||
skipProcessedFiles: %v,
|
skipProcessedFiles: %v,
|
||||||
maxConcurrentTransfers: %d,
|
maxConcurrentTransfers: %d,
|
||||||
rcloneFlags: '%s',
|
rcloneFlags: '%s',
|
||||||
|
commandId: %d,
|
||||||
|
commandFlags: '%s',
|
||||||
|
|
||||||
// Path validation states
|
// Path validation states
|
||||||
sourcePathValid: null,
|
sourcePathValid: null,
|
||||||
@@ -245,6 +284,9 @@ func getInitialData(config *db.TransferConfig) string {
|
|||||||
destPathValid: null,
|
destPathValid: null,
|
||||||
destPathError: '',
|
destPathError: '',
|
||||||
|
|
||||||
|
// Command configuration
|
||||||
|
requiresDestination: true,
|
||||||
|
|
||||||
// Methods for path validation
|
// Methods for path validation
|
||||||
checkPath(path, type) {
|
checkPath(path, type) {
|
||||||
if (!path) {
|
if (!path) {
|
||||||
@@ -263,183 +305,669 @@ func getInitialData(config *db.TransferConfig) string {
|
|||||||
this[type + 'PathValid'] = false;
|
this[type + 'PathValid'] = false;
|
||||||
this[type + 'PathError'] = 'Error checking path: ' + error.message;
|
this[type + 'PathError'] = 'Error checking path: ' + error.message;
|
||||||
});
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Method to check if destination is required based on command type
|
||||||
|
updateCommandRequirements() {
|
||||||
|
// List of commands that don't require destination
|
||||||
|
const listingCommands = ['ls', 'lsd', 'lsl', 'lsf', 'lsjson', 'listremotes'];
|
||||||
|
const infoCommands = ['md5sum', 'sha1sum', 'size', 'version'];
|
||||||
|
const dirCommands = ['mkdir', 'rmdir', 'rmdirs'];
|
||||||
|
const destructiveCommands = ['delete', 'purge'];
|
||||||
|
const specialSinglePathCommands = ['obscure'];
|
||||||
|
|
||||||
|
// Get the command name from the command ID
|
||||||
|
// This will need coordination with your backend to ensure the IDs match the commands
|
||||||
|
let commandName = '';
|
||||||
|
switch(parseInt(this.commandId)) {
|
||||||
|
// Correct mapping based on internal/db/migrations/009_add_rclone_tables.go
|
||||||
|
case 1: commandName = 'copy'; break;
|
||||||
|
case 2: commandName = 'sync'; break;
|
||||||
|
case 3: commandName = 'bisync'; break;
|
||||||
|
case 4: commandName = 'move'; break;
|
||||||
|
case 5: commandName = 'delete'; break;
|
||||||
|
case 6: commandName = 'purge'; break;
|
||||||
|
case 7: commandName = 'mkdir'; break;
|
||||||
|
case 8: commandName = 'rmdir'; break;
|
||||||
|
case 9: commandName = 'rmdirs'; break;
|
||||||
|
case 10: commandName = 'check'; break;
|
||||||
|
case 11: commandName = 'ls'; break;
|
||||||
|
case 12: commandName = 'lsd'; break;
|
||||||
|
case 13: commandName = 'lsl'; break;
|
||||||
|
case 14: commandName = 'lsf'; break;
|
||||||
|
case 15: commandName = 'lsjson'; break;
|
||||||
|
case 16: commandName = 'md5sum'; break;
|
||||||
|
case 17: commandName = 'sha1sum'; break;
|
||||||
|
case 18: commandName = 'size'; break;
|
||||||
|
case 19: commandName = 'version'; break;
|
||||||
|
case 20: commandName = 'cleanup'; break;
|
||||||
|
case 21: commandName = 'dedupe'; break;
|
||||||
|
case 22: commandName = 'copyto'; break;
|
||||||
|
case 23: commandName = 'moveto'; break;
|
||||||
|
case 24: commandName = 'listremotes'; break;
|
||||||
|
case 25: commandName = 'obscure'; break;
|
||||||
|
case 26: commandName = 'cryptcheck'; break;
|
||||||
|
default: commandName = 'copy'; // Default to copy if ID is unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Command ID:', this.commandId, 'Command Name:', commandName);
|
||||||
|
|
||||||
|
// Check if the command requires a destination
|
||||||
|
if (
|
||||||
|
listingCommands.includes(commandName) ||
|
||||||
|
infoCommands.includes(commandName) ||
|
||||||
|
(dirCommands.includes(commandName) && !this.rcloneFlags.includes('--dst')) ||
|
||||||
|
destructiveCommands.includes(commandName) ||
|
||||||
|
specialSinglePathCommands.includes(commandName) ||
|
||||||
|
commandName === 'version' ||
|
||||||
|
commandName === 'listremotes'
|
||||||
|
) {
|
||||||
|
this.requiresDestination = false;
|
||||||
|
console.log('Destination not required for command:', commandName);
|
||||||
|
} else {
|
||||||
|
this.requiresDestination = true;
|
||||||
|
console.log('Destination required for command:', commandName);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}`,
|
}`,
|
||||||
name, sourceType, sourcePath, sourceHost, sourcePort, sourceUser, sourcePassword, sourceKeyFile, sourceAuthType,
|
name, sourceType, sourcePath, sourceHost, sourcePort, sourceUser, sourcePassword, sourceKeyFile, sourceAuthType,
|
||||||
sourceBucket, sourceRegion, sourceAccessKey, sourceSecretKey, sourceEndpoint, sourceShare, sourceDomain, sourcePassiveMode,
|
sourceBucket, sourceRegion, sourceAccessKey, sourceSecretKey, sourceEndpoint, sourceShare, sourceDomain, sourcePassiveMode,
|
||||||
sourceClientId, sourceClientSecret, sourceDriveId, sourceTeamDrive,
|
sourceClientId, sourceClientSecret, sourceDriveId, sourceTeamDrive,
|
||||||
sourceReadOnly, sourceStartYear, sourceIncludeArchived,
|
sourceReadOnly, sourceStartYear, sourceIncludeArchived,
|
||||||
|
useSourceProvider, sourceProviderId,
|
||||||
filePattern, outputPattern,
|
filePattern, outputPattern,
|
||||||
destinationType, destinationPath, destHost, destPort, destUser, destPassword, destKeyFile, destAuthType,
|
destinationType, destinationPath, destHost, destPort, destUser, destPassword, destKeyFile, destAuthType,
|
||||||
destBucket, destRegion, destAccessKey, destSecretKey, destEndpoint, destShare, destDomain, destPassiveMode,
|
destBucket, destRegion, destAccessKey, destSecretKey, destEndpoint, destShare, destDomain, destPassiveMode,
|
||||||
destClientId, destClientSecret, destDriveId, destTeamDrive,
|
destClientId, destClientSecret, destDriveId, destTeamDrive,
|
||||||
destReadOnly, destStartYear, destIncludeArchived,
|
destReadOnly, destStartYear, destIncludeArchived,
|
||||||
|
useDestinationProvider, destinationProviderId,
|
||||||
useBuiltinAuthSource, useBuiltinAuthDest,
|
useBuiltinAuthSource, useBuiltinAuthDest,
|
||||||
archivePath, archiveEnabled, deleteAfterTransfer, skipProcessedFiles, maxConcurrentTransfers, rcloneFlags)
|
archivePath, archiveEnabled, deleteAfterTransfer, skipProcessedFiles, maxConcurrentTransfers, rcloneFlags,
|
||||||
|
commandId, commandFlags)
|
||||||
}
|
}
|
||||||
|
|
||||||
templ ConfigForm(ctx context.Context, data ConfigFormData) {
|
templ ConfigForm(ctx context.Context, data ConfigFormData) {
|
||||||
@LayoutWithContext(getConfigFormTitle(data.IsNew), ctx) {
|
@LayoutWithContext(getConfigFormTitle(data.IsNew), ctx) {
|
||||||
<div class="flex flex-col items-center justify-center min-h-screen bg-secondary-50 dark:bg-secondary-900 py-12 px-4 sm:px-6 lg:px-8">
|
<!-- Main Content -->
|
||||||
<div class="max-w-3xl w-full bg-white dark:bg-secondary-800 rounded-lg shadow-md p-8 border border-secondary-200 dark:border-secondary-700">
|
<section class="py-8 px-4">
|
||||||
<div class="flex justify-center mb-6">
|
<div class="mx-auto max-w-3xl">
|
||||||
<div class="p-4 bg-primary-100 dark:bg-primary-900 rounded-full">
|
<div class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-sm p-6 md:p-8">
|
||||||
<i class="fas fa-cog text-primary-500 dark:text-primary-400 text-2xl"></i>
|
<!-- Form Header -->
|
||||||
|
<div class="mb-8 text-center">
|
||||||
|
<div class="flex justify-center mb-4">
|
||||||
|
<span class="inline-flex items-center justify-center w-16 h-16 rounded-full bg-blue-50 dark:bg-blue-900">
|
||||||
|
<i class="fas fa-cog text-blue-600 dark:text-blue-300 text-2xl"></i>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">
|
||||||
|
{ getConfigFormTitle(data.IsNew) }
|
||||||
|
</h1>
|
||||||
|
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Configure your file transfer settings using the form below
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main Form -->
|
||||||
|
<form
|
||||||
|
id="config-form"
|
||||||
|
class="space-y-6"
|
||||||
|
if data.IsNew {
|
||||||
|
hx-post="/configs"
|
||||||
|
} else {
|
||||||
|
hx-post={ fmt.Sprintf("/configs/%d", data.Config.ID) }
|
||||||
|
}
|
||||||
|
hx-target="body"
|
||||||
|
hx-redirect="/configs"
|
||||||
|
hx-boost="true"
|
||||||
|
x-data={ getInitialData(data.Config) }
|
||||||
|
x-init="$nextTick(() => {
|
||||||
|
// Ensure initial form state displays correctly on load
|
||||||
|
sourceType = sourceType || 'local';
|
||||||
|
destinationType = destinationType || 'local';
|
||||||
|
|
||||||
|
// Set default ports based on connection type
|
||||||
|
if (sourcePort === 0 || !sourcePort) {
|
||||||
|
if (sourceType === 'sftp') {
|
||||||
|
sourcePort = 22;
|
||||||
|
} else if (sourceType === 'ftp') {
|
||||||
|
sourcePort = 21;
|
||||||
|
} else if (sourceType === 'hetzner') {
|
||||||
|
sourcePort = 23;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (destPort === 0 || !destPort) {
|
||||||
|
if (destinationType === 'sftp') {
|
||||||
|
destPort = 22;
|
||||||
|
} else if (destinationType === 'ftp') {
|
||||||
|
destPort = 21;
|
||||||
|
} else if (destinationType === 'hetzner') {
|
||||||
|
destPort = 23;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure maxConcurrentTransfers is at least 1
|
||||||
|
if (!maxConcurrentTransfers || maxConcurrentTransfers < 1) {
|
||||||
|
maxConcurrentTransfers = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize provider selection states
|
||||||
|
useSourceProvider = useSourceProvider || false;
|
||||||
|
useDestinationProvider = useDestinationProvider || false;
|
||||||
|
|
||||||
|
// Initialize command requirements
|
||||||
|
updateCommandRequirements();
|
||||||
|
|
||||||
|
// Initialize hidden input fields with their values to ensure they're included in form submission
|
||||||
|
document.getElementById('hidden_name').value = name;
|
||||||
|
document.getElementById('hidden_source_path').value = sourcePath;
|
||||||
|
document.getElementById('hidden_destination_path').value = destinationPath;
|
||||||
|
|
||||||
|
// Initialize S3 source fields if applicable
|
||||||
|
if (sourceType === 's3' || sourceType === 'b2' || sourceType === 'wasabi' || sourceType === 'minio') {
|
||||||
|
document.getElementById('hidden_source_access_key').value = sourceAccessKey;
|
||||||
|
document.getElementById('hidden_source_secret_key').value = sourceSecretKey;
|
||||||
|
document.getElementById('hidden_source_endpoint').value = sourceEndpoint;
|
||||||
|
document.getElementById('hidden_source_bucket').value = sourceBucket;
|
||||||
|
document.getElementById('hidden_source_region').value = sourceRegion;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize S3 destination fields if applicable
|
||||||
|
if (destinationType === 's3' || destinationType === 'b2' || destinationType === 'wasabi' || destinationType === 'minio') {
|
||||||
|
document.getElementById('hidden_dest_access_key').value = destAccessKey;
|
||||||
|
document.getElementById('hidden_dest_secret_key').value = destSecretKey;
|
||||||
|
document.getElementById('hidden_dest_endpoint').value = destEndpoint;
|
||||||
|
document.getElementById('hidden_dest_bucket').value = destBucket;
|
||||||
|
document.getElementById('hidden_dest_region').value = destRegion;
|
||||||
|
}
|
||||||
|
})"
|
||||||
|
x-effect="if (sourceType === 'sftp' && (sourcePort === 0 || sourcePort === 21 || sourcePort === 23)) {
|
||||||
|
sourcePort = 22;
|
||||||
|
console.log('Updating source port to 22 for SFTP');
|
||||||
|
} else if (sourceType === 'ftp' && (sourcePort === 0 || sourcePort === 22 || sourcePort === 23)) {
|
||||||
|
sourcePort = 21;
|
||||||
|
console.log('Updating source port to 21 for FTP');
|
||||||
|
} else if (sourceType === 'hetzner' && (sourcePort === 0 || sourcePort === 22 || sourcePort === 21)) {
|
||||||
|
sourcePort = 23;
|
||||||
|
console.log('Updating source port to 23 for Hetzner');
|
||||||
|
} else if (destinationType === 'sftp' && (destPort === 0 || destPort === 21 || destPort === 23)) {
|
||||||
|
destPort = 22;
|
||||||
|
console.log('Updating destination port to 22 for SFTP');
|
||||||
|
} else if (destinationType === 'ftp' && (destPort === 0 || destPort === 22 || destPort === 23)) {
|
||||||
|
destPort = 21;
|
||||||
|
console.log('Updating destination port to 21 for FTP');
|
||||||
|
} else if (destinationType === 'hetzner' && (destPort === 0 || destPort === 22 || destPort === 21)) {
|
||||||
|
destPort = 23;
|
||||||
|
console.log('Updating destination port to 23 for Hetzner');
|
||||||
|
}"
|
||||||
|
@formvalidation
|
||||||
|
@submit="
|
||||||
|
// Basic fields
|
||||||
|
document.getElementById('hidden_name').value = name;
|
||||||
|
document.getElementById('hidden_source_path').value = sourcePath;
|
||||||
|
document.getElementById('hidden_destination_path').value = destinationPath;
|
||||||
|
|
||||||
|
// S3 source fields
|
||||||
|
if (sourceType === 's3' || sourceType === 'b2' || sourceType === 'wasabi' || sourceType === 'minio') {
|
||||||
|
document.getElementById('hidden_source_access_key').value = sourceAccessKey;
|
||||||
|
document.getElementById('hidden_source_secret_key').value = sourceSecretKey;
|
||||||
|
document.getElementById('hidden_source_endpoint').value = sourceEndpoint;
|
||||||
|
document.getElementById('hidden_source_bucket').value = sourceBucket;
|
||||||
|
document.getElementById('hidden_source_region').value = sourceRegion;
|
||||||
|
}
|
||||||
|
|
||||||
|
// S3 destination fields
|
||||||
|
if (destinationType === 's3' || destinationType === 'b2' || destinationType === 'wasabi' || destinationType === 'minio') {
|
||||||
|
document.getElementById('hidden_dest_access_key').value = destAccessKey;
|
||||||
|
document.getElementById('hidden_dest_secret_key').value = destSecretKey;
|
||||||
|
document.getElementById('hidden_dest_endpoint').value = destEndpoint;
|
||||||
|
document.getElementById('hidden_dest_bucket').value = destBucket;
|
||||||
|
document.getElementById('hidden_dest_region').value = destRegion;
|
||||||
|
}
|
||||||
|
"
|
||||||
|
>
|
||||||
|
|
||||||
|
<!-- Hidden fields to ensure values are submitted with the form -->
|
||||||
|
<input type="hidden" id="hidden_name" name="name" />
|
||||||
|
<input type="hidden" id="hidden_source_path" name="source_path" />
|
||||||
|
<input type="hidden" id="hidden_destination_path" name="destination_path" />
|
||||||
|
|
||||||
|
<!-- Hidden fields for S3-compatible providers -->
|
||||||
|
<input type="hidden" id="hidden_source_access_key" name="source_access_key" />
|
||||||
|
<input type="hidden" id="hidden_source_secret_key" name="source_secret_key" />
|
||||||
|
<input type="hidden" id="hidden_source_endpoint" name="source_endpoint" />
|
||||||
|
<input type="hidden" id="hidden_source_bucket" name="source_bucket" />
|
||||||
|
<input type="hidden" id="hidden_source_region" name="source_region" />
|
||||||
|
|
||||||
|
<input type="hidden" id="hidden_dest_access_key" name="dest_access_key" />
|
||||||
|
<input type="hidden" id="hidden_dest_secret_key" name="dest_secret_key" />
|
||||||
|
<input type="hidden" id="hidden_dest_endpoint" name="dest_endpoint" />
|
||||||
|
<input type="hidden" id="hidden_dest_bucket" name="dest_bucket" />
|
||||||
|
<input type="hidden" id="hidden_dest_region" name="dest_region" />
|
||||||
|
|
||||||
|
<!-- Form Error Container -->
|
||||||
|
<div id="form-errors" class="hidden p-4 mb-6 text-sm text-red-800 rounded-lg bg-red-50 dark:bg-red-800/20 dark:text-red-400 border border-red-200 dark:border-red-900" role="alert">
|
||||||
|
<div class="flex items-center mb-2">
|
||||||
|
<i class="fas fa-exclamation-circle text-red-600 dark:text-red-500 mr-2"></i>
|
||||||
|
<h3 class="text-base font-medium text-red-800 dark:text-red-400">Please correct the following errors:</h3>
|
||||||
|
</div>
|
||||||
|
<ul id="error-list" class="ml-5 list-disc space-y-1"></ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Configuration Details Section -->
|
||||||
|
<div class="p-4 mb-4 bg-blue-50 border border-blue-100 rounded-lg dark:bg-blue-900/20 dark:border-blue-900">
|
||||||
|
<div class="flex items-center mb-2">
|
||||||
|
<i class="fas fa-info-circle text-blue-600 dark:text-blue-400 mr-2"></i>
|
||||||
|
<h3 class="text-lg font-medium text-blue-600 dark:text-blue-400">Configuration Details</h3>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-blue-700 dark:text-blue-300">
|
||||||
|
Give your configuration a descriptive name and set up the source and destination locations.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Name field -->
|
||||||
|
<div class="mb-2">
|
||||||
|
<label for="name" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
|
||||||
|
Configuration Name <span class="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="name"
|
||||||
|
name="name"
|
||||||
|
x-model="name"
|
||||||
|
@input="document.getElementById('hidden_name').value = name"
|
||||||
|
required
|
||||||
|
aria-required="true"
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
placeholder="My Transfer Configuration"
|
||||||
|
/>
|
||||||
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Choose a descriptive name to identify this configuration.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Rclone Command Configuration Section -->
|
||||||
|
<div class="p-5 bg-white border border-gray-200 rounded-lg shadow-sm dark:bg-gray-800 dark:border-gray-700">
|
||||||
|
<h3 class="mb-4 text-xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||||
|
<i class="fas fa-terminal mr-2 text-blue-500 dark:text-blue-400"></i>Command Configuration
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<!-- Additonal Rclone Flags -->
|
||||||
|
@common.RcloneFlags(data.Config.CommandID) // Pass current command ID
|
||||||
|
|
||||||
|
<!-- Container for flags, pre-rendered on edit, loaded via HTMX on new/change -->
|
||||||
|
<div id="command-flags-container" class="mt-4">
|
||||||
|
if !data.IsNew && data.InitialCommand != nil {
|
||||||
|
// Pre-render flags if editing and command data is available
|
||||||
|
@common.RcloneCommandFlagsContent(data.InitialCommand, data.SelectedFlagsMap, data.SelectedFlagValues)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Source Configuration Section -->
|
||||||
|
<div class="p-5 bg-white border border-gray-200 rounded-lg shadow-sm dark:bg-gray-800 dark:border-gray-700">
|
||||||
|
<h3 class="mb-4 text-xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||||
|
<i class="fas fa-upload mr-2 text-blue-500 dark:text-blue-400"></i>Source Configuration
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<!-- Source selection -->
|
||||||
|
@common.SourceSelection(data.SourceProviders)
|
||||||
|
|
||||||
|
<!-- Test Source Connection button -->
|
||||||
|
<div class="mt-4">
|
||||||
|
<button type="button"
|
||||||
|
class="text-white bg-green-600 hover:bg-green-700 focus:ring-4 focus:ring-green-300 font-medium rounded-lg text-sm px-4 py-2 text-center dark:bg-green-500 dark:hover:bg-green-600 dark:focus:ring-green-800"
|
||||||
|
hx-post="/configs/test-connection"
|
||||||
|
hx-include="closest form"
|
||||||
|
hx-vals='{"providerType": "source"}'
|
||||||
|
hx-swap="none"
|
||||||
|
hx-indicator="#source-test-spinner">
|
||||||
|
<i class="fas fa-plug mr-1"></i> Test Source
|
||||||
|
<span id="source-test-spinner" class="htmx-indicator ml-2"><i class="fas fa-spinner fa-spin"></i></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Source type specific forms - only show when not using a provider -->
|
||||||
|
<template x-if="!useSourceProvider">
|
||||||
|
<div class="mt-4">
|
||||||
|
<template x-if="sourceType === 'local'">
|
||||||
|
@source.LocalSourceForm()
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template x-if="sourceType === 'sftp'">
|
||||||
|
@source.SFTPSourceForm()
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template x-if="sourceType === 'ftp'">
|
||||||
|
@source.FTPSourceForm()
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template x-if="sourceType === 's3'">
|
||||||
|
@source.S3SourceForm()
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template x-if="sourceType === 'b2'">
|
||||||
|
@source.B2SourceForm()
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template x-if="sourceType === 'wasabi'">
|
||||||
|
@source.WasabiSourceForm()
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template x-if="sourceType === 'minio'">
|
||||||
|
@source.MinIOSourceForm()
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template x-if="sourceType === 'smb'">
|
||||||
|
@source.SMBSourceForm()
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template x-if="sourceType === 'webdav'">
|
||||||
|
@source.WebDAVSourceForm()
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template x-if="sourceType === 'nextcloud'">
|
||||||
|
@source.NextCloudSourceForm()
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template x-if="sourceType === 'gdrive'">
|
||||||
|
@source.GoogleDriveSourceForm()
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template x-if="sourceType === 'gphotos'">
|
||||||
|
@source.GooglePhotosSourceForm()
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template x-if="sourceType === 'hetzner'">
|
||||||
|
@source.HetznerSourceForm()
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Provider usage notice -->
|
||||||
|
<template x-if="useSourceProvider">
|
||||||
|
<div class="mt-4 p-4 bg-blue-50 border border-blue-100 rounded-lg dark:bg-blue-900/20 dark:border-blue-800">
|
||||||
|
<div class="flex">
|
||||||
|
<i class="fas fa-info-circle text-blue-500 dark:text-blue-400 mt-0.5 mr-2"></i>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-blue-800 dark:text-blue-300">
|
||||||
|
Using storage provider configuration. Customize source path options below if needed.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Always show source path field with provider or without provider -->
|
||||||
|
<template x-if="useSourceProvider && sourceProviderId > 0">
|
||||||
|
<div class="mt-4">
|
||||||
|
<label for="source_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Source Path</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
|
<i class="fas fa-folder text-gray-400 dark:text-gray-500"></i>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="source_path"
|
||||||
|
name="source_path"
|
||||||
|
x-model="sourcePath"
|
||||||
|
@input="document.getElementById('hidden_source_path').value = sourcePath"
|
||||||
|
@blur="checkPath(sourcePath, 'source')"
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
placeholder="/path/to/source"
|
||||||
|
/>
|
||||||
|
<template x-if="sourcePathValid === false">
|
||||||
|
<p class="mt-2 text-sm text-red-600 dark:text-red-500" x-text="sourcePathError"></p>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Specify source path within the provider</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- File Pattern Section -->
|
||||||
|
<div class="p-5 bg-white border border-gray-200 rounded-lg shadow-sm dark:bg-gray-800 dark:border-gray-700">
|
||||||
|
<h3 class="mb-4 text-xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||||
|
<i class="fas fa-filter mr-2 text-blue-500 dark:text-blue-400"></i>File Patterns
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<!-- File pattern fields -->
|
||||||
|
@common.FilePatternFields()
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Destination Configuration Section -->
|
||||||
|
<div x-show="requiresDestination" x-transition class="p-5 bg-white border border-gray-200 rounded-lg shadow-sm dark:bg-gray-800 dark:border-gray-700">
|
||||||
|
<h3 class="mb-4 text-xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||||
|
<i class="fas fa-download mr-2 text-blue-500 dark:text-blue-400"></i>Destination Configuration
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<!-- Destination selection -->
|
||||||
|
@common.DestinationSelection(data.DestinationProviders)
|
||||||
|
|
||||||
|
<!-- Test Destination button -->
|
||||||
|
<div class="mt-4">
|
||||||
|
<button type="button"
|
||||||
|
class="text-white bg-green-600 hover:bg-green-700 focus:ring-4 focus:ring-green-300 font-medium rounded-lg text-sm px-4 py-2 text-center dark:bg-green-500 dark:hover:bg-green-600 dark:focus:ring-green-800"
|
||||||
|
hx-post="/configs/test-connection"
|
||||||
|
hx-include="closest form"
|
||||||
|
hx-vals='{"providerType": "destination"}'
|
||||||
|
hx-swap="none"
|
||||||
|
hx-indicator="#dest-test-spinner">
|
||||||
|
<i class="fas fa-plug mr-1"></i> Test Destination
|
||||||
|
<span id="dest-test-spinner" class="htmx-indicator ml-2"><i class="fas fa-spinner fa-spin"></i></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Destination type specific forms - only show when not using a provider -->
|
||||||
|
<template x-if="!useDestinationProvider">
|
||||||
|
<div class="mt-4">
|
||||||
|
<template x-if="destinationType === 'local'">
|
||||||
|
@destination.LocalDestinationForm()
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template x-if="destinationType === 'sftp'">
|
||||||
|
@destination.SFTPDestinationForm()
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template x-if="destinationType === 'ftp'">
|
||||||
|
@destination.FTPDestinationForm()
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template x-if="destinationType === 's3'">
|
||||||
|
@destination.S3DestinationForm()
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template x-if="destinationType === 'b2'">
|
||||||
|
@destination.B2DestinationForm()
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template x-if="destinationType === 'wasabi'">
|
||||||
|
@destination.WasabiDestinationForm()
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template x-if="destinationType === 'minio'">
|
||||||
|
@destination.MinIODestinationForm()
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template x-if="destinationType === 'smb'">
|
||||||
|
@destination.SMBDestinationForm()
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template x-if="destinationType === 'nextcloud'">
|
||||||
|
@destination.NextCloudDestinationForm()
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template x-if="destinationType === 'webdav'">
|
||||||
|
@destination.WebDAVDestinationForm()
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template x-if="destinationType === 'gdrive'">
|
||||||
|
@destination.GoogleDriveDestinationForm()
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template x-if="destinationType === 'gphotos'">
|
||||||
|
@destination.GooglePhotosDestinationForm()
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template x-if="destinationType === 'hetzner'">
|
||||||
|
@destination.HetznerDestinationForm()
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Provider usage notice -->
|
||||||
|
<template x-if="useDestinationProvider">
|
||||||
|
<div class="mt-4 p-4 bg-blue-50 border border-blue-100 rounded-lg dark:bg-blue-900/20 dark:border-blue-800">
|
||||||
|
<div class="flex">
|
||||||
|
<i class="fas fa-info-circle text-blue-500 dark:text-blue-400 mt-0.5 mr-2"></i>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-blue-800 dark:text-blue-300">
|
||||||
|
Using storage provider configuration. Customize destination path options below if needed.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Always show destination path field with provider or without provider -->
|
||||||
|
<template x-if="useDestinationProvider && destinationProviderId > 0">
|
||||||
|
<div class="mt-4">
|
||||||
|
<label for="destination_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Destination Path</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
|
<i class="fas fa-folder text-gray-400 dark:text-gray-500"></i>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="destination_path"
|
||||||
|
name="destination_path"
|
||||||
|
x-model="destinationPath"
|
||||||
|
@input="document.getElementById('hidden_destination_path').value = destinationPath"
|
||||||
|
@blur="checkPath(destinationPath, 'dest')"
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
placeholder="/path/to/destination"
|
||||||
|
/>
|
||||||
|
<template x-if="destPathValid === false">
|
||||||
|
<p class="mt-2 text-sm text-red-600 dark:text-red-500" x-text="destPathError"></p>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Specify destination path within the provider</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Advanced Options Section -->
|
||||||
|
<div class="p-5 bg-white border border-gray-200 rounded-lg shadow-sm dark:bg-gray-800 dark:border-gray-700">
|
||||||
|
<h3 class="mb-4 text-xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||||
|
<i class="fas fa-cogs mr-2 text-blue-500 dark:text-blue-400"></i>Advanced Options
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<!-- Archive options -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<h4 class="text-lg font-medium text-gray-900 dark:text-white mb-4">Archive Settings</h4>
|
||||||
|
@common.ArchiveOptions()
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Form Actions -->
|
||||||
|
<div class="flex items-center justify-between pt-6 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<a href="/configs"
|
||||||
|
class="text-white bg-gray-500 hover:bg-gray-600 focus:ring-4 focus:ring-gray-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-gray-600 dark:hover:bg-gray-700 dark:focus:ring-gray-800">
|
||||||
|
<i class="fas fa-arrow-left mr-2"></i>Cancel
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800">
|
||||||
|
<span class="flex items-center">
|
||||||
|
<i class="fas fa-save mr-2"></i>
|
||||||
|
if data.IsNew {
|
||||||
|
Create Configuration
|
||||||
|
} else {
|
||||||
|
Save Changes
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Help Card -->
|
||||||
|
<div class="mt-4 p-4 bg-gray-50 border border-gray-200 rounded-lg shadow-sm dark:bg-gray-800 dark:border-gray-700">
|
||||||
|
<div class="flex">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<i class="fas fa-lightbulb text-yellow-400 text-xl"></i>
|
||||||
|
</div>
|
||||||
|
<div class="ml-4">
|
||||||
|
<h5 class="text-sm font-medium text-gray-900 dark:text-white">Configuration Tips</h5>
|
||||||
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Configure your file transfer settings carefully for optimal performance. Use file patterns to filter which files are transferred, and consider using archive options to keep track of processed files.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
<h1 class="mt-2 text-center text-3xl font-extrabold text-secondary-900 dark:text-secondary-100">
|
|
||||||
{ getConfigFormTitle(data.IsNew) }
|
|
||||||
</h1>
|
|
||||||
<p class="mt-2 text-center text-sm text-secondary-600 dark:text-secondary-400">
|
|
||||||
Set up your file transfer configuration
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<form
|
|
||||||
class="space-y-8"
|
|
||||||
if data.IsNew {
|
|
||||||
hx-post="/configs"
|
|
||||||
} else {
|
|
||||||
hx-post={ fmt.Sprintf("/configs/%d", data.Config.ID) }
|
|
||||||
}
|
|
||||||
hx-target="body"
|
|
||||||
hx-redirect="/configs"
|
|
||||||
hx-boost="true"
|
|
||||||
x-data={ getInitialData(data.Config) }
|
|
||||||
x-init="$nextTick(() => {
|
|
||||||
// Ensure initial form state displays correctly on load
|
|
||||||
sourceType = sourceType || 'local';
|
|
||||||
destinationType = destinationType || 'local';
|
|
||||||
sourcePort = sourcePort || 22;
|
|
||||||
destPort = destPort || 22;
|
|
||||||
})"
|
|
||||||
>
|
|
||||||
|
|
||||||
<!-- Name field -->
|
|
||||||
@common.NameField()
|
|
||||||
|
|
||||||
<!-- Source section -->
|
|
||||||
@common.SourceSelection()
|
|
||||||
|
|
||||||
<!-- Source type specific forms -->
|
|
||||||
<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 === '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>
|
|
||||||
|
|
||||||
<!-- File pattern fields -->
|
|
||||||
@common.FilePatternFields()
|
|
||||||
|
|
||||||
<!-- Destination section -->
|
|
||||||
@common.DestinationSelection()
|
|
||||||
|
|
||||||
<!-- Destination type specific forms -->
|
|
||||||
<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 === 'minio'">
|
|
||||||
@destination.MinIODestinationForm()
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template x-if="destinationType === 'smb'">
|
|
||||||
@destination.SMBDestinationForm()
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template x-if="destinationType === 'nextcloud'">
|
|
||||||
@destination.NextCloudDestinationForm()
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template x-if="destinationType === 'webdav'">
|
|
||||||
@destination.WebDAVDestinationForm()
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template x-if="destinationType === 'gdrive'">
|
|
||||||
@destination.GoogleDriveDestinationForm()
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template x-if="destinationType === 'gphotos'">
|
|
||||||
@destination.GooglePhotosDestinationForm()
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- Archive options -->
|
|
||||||
@common.ArchiveOptions()
|
|
||||||
|
|
||||||
<!-- Rclone flags -->
|
|
||||||
@common.RcloneFlags()
|
|
||||||
|
|
||||||
<!-- Form actions -->
|
|
||||||
<div class="flex justify-end mt-8">
|
|
||||||
<a href="/configs"
|
|
||||||
class="btn-secondary mr-4">
|
|
||||||
Cancel
|
|
||||||
</a>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="btn-primary">
|
|
||||||
<span class="flex items-center">
|
|
||||||
<i class="fas fa-save mr-2"></i>
|
|
||||||
if data.IsNew {
|
|
||||||
Create Configuration
|
|
||||||
} else {
|
|
||||||
Save Changes
|
|
||||||
}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-6 text-center text-xs text-secondary-500 dark:text-secondary-400">
|
|
||||||
<p>Configure your file transfer settings carefully for optimal performance</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
templ formvalidation() {
|
||||||
|
<script>
|
||||||
|
document.addEventListener('htmx:beforeRequest', function(evt) {
|
||||||
|
if (evt.detail.elt.id === 'config-form') {
|
||||||
|
const formErrors = document.getElementById('form-errors');
|
||||||
|
const errorList = document.getElementById('error-list');
|
||||||
|
let errors = [];
|
||||||
|
let hasErrors = false;
|
||||||
|
|
||||||
|
// Clear previous errors
|
||||||
|
errorList.innerHTML = '';
|
||||||
|
formErrors.classList.add('hidden');
|
||||||
|
|
||||||
|
// Validate name
|
||||||
|
const name = document.getElementById('name').value;
|
||||||
|
if (!name || name.trim() === '') {
|
||||||
|
errors.push('Configuration name is required');
|
||||||
|
hasErrors = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get form data to check provider usage
|
||||||
|
const useSourceProvider = document.getElementById('use_source_provider')?.checked || false;
|
||||||
|
const useDestinationProvider = document.getElementById('use_destination_provider')?.checked || false;
|
||||||
|
|
||||||
|
// Validate source provider selection if using provider
|
||||||
|
if (useSourceProvider) {
|
||||||
|
const sourceProviderId = document.getElementById('source_provider_id').value;
|
||||||
|
if (!sourceProviderId || sourceProviderId === '') {
|
||||||
|
errors.push('Source provider selection is required');
|
||||||
|
hasErrors = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate destination provider selection if using provider
|
||||||
|
if (useDestinationProvider) {
|
||||||
|
const destProviderId = document.getElementById('destination_provider_id').value;
|
||||||
|
if (!destProviderId || destProviderId === '') {
|
||||||
|
errors.push('Destination provider selection is required');
|
||||||
|
hasErrors = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display errors if any
|
||||||
|
if (hasErrors) {
|
||||||
|
formErrors.classList.remove('hidden');
|
||||||
|
errors.forEach(function(error) {
|
||||||
|
const li = document.createElement('li');
|
||||||
|
li.textContent = error;
|
||||||
|
errorList.appendChild(li);
|
||||||
|
});
|
||||||
|
|
||||||
|
evt.preventDefault(); // Prevent form submission
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
}
|
||||||
+291
-124
@@ -6,55 +6,68 @@ import (
|
|||||||
"github.com/starfleetcptn/gomft/internal/db"
|
"github.com/starfleetcptn/gomft/internal/db"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Dialog component for confirmation dialogs
|
// Dialog component for confirmation dialogs using Flowbite modal
|
||||||
templ ConfigDialog(id string, title string, message string, confirmClass string, confirmText string, action string, configID uint, configName string) {
|
templ ConfigDialog(id string, title string, message string, confirmClass string, confirmText string, action string, configID uint, configName string) {
|
||||||
<div id={ id } class="hidden fixed inset-0 bg-secondary-900/50 dark:bg-secondary-900/80 backdrop-blur-sm z-50 flex items-center justify-center">
|
<div 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">
|
||||||
<div class="bg-white dark:bg-secondary-800 rounded-lg shadow-xl max-w-md w-full mx-4 overflow-hidden">
|
<!-- Backdrop -->
|
||||||
<div class="px-6 pt-5 pb-3 text-center">
|
<div id={ fmt.Sprintf("%s-backdrop", id) } class="fixed inset-0 bg-gray-900/50 dark:bg-gray-900/80 backdrop-blur-sm"></div>
|
||||||
<div class="flex justify-center mb-2">
|
<!-- Modal content -->
|
||||||
<i class="fas fa-exclamation-triangle text-yellow-400 text-3xl"></i>
|
<div class="relative p-4 w-full max-w-md max-h-full mx-auto">
|
||||||
|
<div class="relative bg-white rounded-lg shadow dark:bg-gray-700">
|
||||||
|
<div class="p-6 text-center">
|
||||||
|
if action == "delete" {
|
||||||
|
<i class="fas fa-trash-alt text-red-400 text-3xl mb-4"></i>
|
||||||
|
} else {
|
||||||
|
<i class="fas fa-exclamation-circle text-yellow-400 text-3xl mb-4"></i>
|
||||||
|
}
|
||||||
|
<h3 class="mb-5 text-lg font-normal text-gray-500 dark:text-gray-400">{ message }</h3>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class={ confirmClass }
|
||||||
|
hx-delete={ fmt.Sprintf("/configs/%d", configID) }
|
||||||
|
hx-target="closest li"
|
||||||
|
hx-swap="delete"
|
||||||
|
data-config-name={ configName }
|
||||||
|
data-config-id={ fmt.Sprint(configID) }
|
||||||
|
id={ fmt.Sprintf("delete-config-btn-%d", configID) }
|
||||||
|
onclick={ triggerConfigDelete(id, configID, configName) }>
|
||||||
|
{ confirmText }
|
||||||
|
</button>
|
||||||
|
<button type="button" onclick={ closeModal(id) } class="text-gray-500 bg-white hover:bg-gray-100 focus:ring-4 focus:outline-none focus:ring-gray-200 rounded-lg border border-gray-200 text-sm font-medium px-5 py-2.5 hover:text-gray-900 focus:z-10 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-500 dark:hover:text-white dark:hover:bg-gray-600 dark:focus:ring-gray-600">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<h3 class="text-xl font-medium text-secondary-900 dark:text-secondary-100">
|
|
||||||
{ title }
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<div class="px-6 py-4 text-center">
|
|
||||||
<p class="text-secondary-700 dark:text-secondary-300">
|
|
||||||
{ message }
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="px-6 py-4 flex justify-end space-x-3">
|
|
||||||
<button type="button" class="btn-secondary" onclick={ hideConfigDialog(id) }>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class={ confirmClass }
|
|
||||||
hx-delete={ fmt.Sprintf("/configs/%d", configID) }
|
|
||||||
hx-target="closest li"
|
|
||||||
hx-swap="delete"
|
|
||||||
data-config-name={ configName }
|
|
||||||
data-config-id={ fmt.Sprint(configID) }
|
|
||||||
id={ fmt.Sprintf("delete-config-btn-%d", configID) }
|
|
||||||
onclick={ triggerConfigDelete(id, configID, configName) }>
|
|
||||||
{ confirmText }
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
script hideConfigDialog(id string) {
|
script closeModal(id string) {
|
||||||
document.getElementById(id).classList.add("hidden");
|
const modal = document.getElementById(id);
|
||||||
|
const backdrop = document.getElementById(id + '-backdrop');
|
||||||
|
if (modal) {
|
||||||
|
modal.classList.add('hidden');
|
||||||
|
modal.classList.remove('flex');
|
||||||
|
}
|
||||||
|
if (backdrop) {
|
||||||
|
backdrop.remove();
|
||||||
|
}
|
||||||
|
document.body.style.overflow = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
script showConfigDialog(id string) {
|
script showModal(id string) {
|
||||||
document.getElementById(id).classList.remove("hidden");
|
const modal = document.getElementById(id);
|
||||||
|
if (modal) {
|
||||||
|
modal.classList.remove('hidden');
|
||||||
|
modal.classList.add('flex');
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
script triggerConfigDelete(dialogId string, configID uint, configName string) {
|
script triggerConfigDelete(dialogId string, configID uint, configName string) {
|
||||||
// Hide the dialog
|
// Hide the dialog
|
||||||
document.getElementById(dialogId).classList.add("hidden");
|
document.getElementById(dialogId).classList.add("hidden");
|
||||||
|
document.getElementById(dialogId).classList.remove("flex");
|
||||||
|
|
||||||
// Store data in a way that's accessible to event handlers
|
// Store data in a way that's accessible to event handlers
|
||||||
window.lastDeletedConfig = {
|
window.lastDeletedConfig = {
|
||||||
@@ -75,38 +88,82 @@ type ConfigsData struct {
|
|||||||
|
|
||||||
templ Configs(ctx context.Context, data ConfigsData) {
|
templ Configs(ctx context.Context, data ConfigsData) {
|
||||||
@LayoutWithContext("Transfer Configurations", ctx) {
|
@LayoutWithContext("Transfer Configurations", ctx) {
|
||||||
|
<!-- Status and Error Messages -->
|
||||||
|
<div id="toast-container" class="fixed top-5 right-5 z-50 flex flex-col gap-2"></div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// Debug notification system
|
// Debug notification system
|
||||||
console.log("Configs template loaded, setting up notification system");
|
console.log("Configs template loaded, setting up notification system");
|
||||||
|
|
||||||
// Create a global notyf instance if it doesn't exist yet
|
// Function to create and show a toast
|
||||||
if (!window.notyf) {
|
function showToast(message, type) {
|
||||||
window.notyf = new Notyf({
|
const toastContainer = document.getElementById('toast-container');
|
||||||
duration: 3000,
|
|
||||||
position: {
|
// Create toast element
|
||||||
x: 'right',
|
const toast = document.createElement('div');
|
||||||
y: 'top',
|
toast.id = 'toast-' + type + '-' + Date.now();
|
||||||
},
|
toast.className = 'flex items-center w-full max-w-xs p-4 mb-4 rounded-lg shadow text-gray-500 bg-white dark:text-gray-400 dark:bg-gray-800 transform translate-y-16 opacity-0 transition-all duration-300 ease-out';
|
||||||
types: [
|
toast.role = 'alert';
|
||||||
{
|
|
||||||
type: 'success',
|
// Set toast content based on type
|
||||||
background: '#38c172',
|
let iconClass, bgColorClass, textColorClass;
|
||||||
icon: {
|
|
||||||
className: 'fas fa-check-circle',
|
if (type === 'success') {
|
||||||
tagName: 'i'
|
iconClass = 'text-green-500 bg-green-100 dark:bg-green-800 dark:text-green-200';
|
||||||
}
|
bgColorClass = 'text-green-500 dark:text-green-200';
|
||||||
},
|
textColorClass = 'text-green-500 dark:text-green-200';
|
||||||
{
|
} else if (type === 'error') {
|
||||||
type: 'error',
|
iconClass = 'text-red-500 bg-red-100 dark:bg-red-800 dark:text-red-200';
|
||||||
background: '#e3342f',
|
bgColorClass = 'text-red-500 dark:text-red-200';
|
||||||
icon: {
|
textColorClass = 'text-red-500 dark:text-red-200';
|
||||||
className: 'fas fa-exclamation-circle',
|
} else {
|
||||||
tagName: 'i'
|
iconClass = 'text-blue-500 bg-blue-100 dark:bg-blue-800 dark:text-blue-200';
|
||||||
}
|
bgColorClass = 'text-blue-500 dark:text-blue-200';
|
||||||
}
|
textColorClass = 'text-blue-500 dark:text-blue-200';
|
||||||
]
|
}
|
||||||
|
|
||||||
|
// Set inner HTML with appropriate icon and message
|
||||||
|
toast.innerHTML = `
|
||||||
|
<div class="inline-flex items-center justify-center flex-shrink-0 w-8 h-8 rounded-lg ${iconClass}">
|
||||||
|
${type === 'success'
|
||||||
|
? '<i class="fas fa-check"></i>'
|
||||||
|
: type === 'error'
|
||||||
|
? '<i class="fas fa-exclamation-circle"></i>'
|
||||||
|
: '<i class="fas fa-info-circle"></i>'}
|
||||||
|
</div>
|
||||||
|
<div class="ml-3 text-sm font-normal">${message}</div>
|
||||||
|
<button type="button" class="ml-auto -mx-1.5 -my-1.5 bg-white text-gray-400 hover:text-gray-900 rounded-lg focus:ring-2 focus:ring-gray-300 p-1.5 hover:bg-gray-100 inline-flex h-8 w-8 dark:text-gray-500 dark:hover:text-white dark:bg-gray-800 dark:hover:bg-gray-700" data-dismiss-target="#${toast.id}" aria-label="Close">
|
||||||
|
<span class="sr-only">Close</span>
|
||||||
|
<i class="fas fa-times"></i>
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Add toast to container
|
||||||
|
toastContainer.appendChild(toast);
|
||||||
|
|
||||||
|
// Trigger animation after a small delay to ensure the DOM has updated
|
||||||
|
setTimeout(() => {
|
||||||
|
toast.classList.remove('translate-y-16', 'opacity-0');
|
||||||
|
toast.classList.add('translate-y-0', 'opacity-100');
|
||||||
|
}, 10);
|
||||||
|
|
||||||
|
// Add event listener to close button
|
||||||
|
const closeButton = toast.querySelector('button[data-dismiss-target]');
|
||||||
|
closeButton.addEventListener('click', function() {
|
||||||
|
// Animate out before removing
|
||||||
|
toast.classList.add('opacity-0', 'translate-y-4');
|
||||||
|
setTimeout(() => {
|
||||||
|
toast.remove();
|
||||||
|
}, 300);
|
||||||
});
|
});
|
||||||
console.log("Notyf initialized:", window.notyf);
|
|
||||||
|
// Auto-remove toast after 5 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
toast.classList.add('opacity-0', 'translate-y-4');
|
||||||
|
setTimeout(() => {
|
||||||
|
toast.remove();
|
||||||
|
}, 300);
|
||||||
|
}, 5000);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show status messages based on URL parameters
|
// Show status messages based on URL parameters
|
||||||
@@ -122,12 +179,23 @@ templ Configs(ctx context.Context, data ConfigsData) {
|
|||||||
if (errorDetails) {
|
if (errorDetails) {
|
||||||
message += ": " + errorDetails;
|
message += ": " + errorDetails;
|
||||||
}
|
}
|
||||||
window.notyf.error(message);
|
showToast(message, 'error');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status === 'gdrive_auth_success') {
|
if (status === 'gdrive_auth_success') {
|
||||||
window.notyf.success("Google Drive authentication completed successfully");
|
showToast("Google Drive authentication completed successfully", 'success');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle modal hide buttons
|
||||||
|
const hideButtons = document.querySelectorAll('[data-modal-hide]');
|
||||||
|
hideButtons.forEach(button => {
|
||||||
|
button.addEventListener('click', function() {
|
||||||
|
const modalId = this.getAttribute('data-modal-hide');
|
||||||
|
const modal = document.getElementById(modalId);
|
||||||
|
modal.classList.add('hidden');
|
||||||
|
modal.classList.remove('flex');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Track all HTMX events for debugging
|
// Track all HTMX events for debugging
|
||||||
@@ -143,6 +211,18 @@ templ Configs(ctx context.Context, data ConfigsData) {
|
|||||||
// This is definitely a delete request - store this information
|
// This is definitely a delete request - store this information
|
||||||
window.isConfigDeleteRequest = true;
|
window.isConfigDeleteRequest = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if this is a duplication request
|
||||||
|
if (path && method === 'POST' && path.match(/^\/configs\/\d+\/duplicate$/)) {
|
||||||
|
window.isConfigDuplicateRequest = true;
|
||||||
|
|
||||||
|
// Store the config ID for reference
|
||||||
|
const configId = path.match(/^\/configs\/(\d+)\/duplicate$/)[1];
|
||||||
|
const configButton = document.querySelector(`button[hx-post="/configs/${configId}/duplicate"]`);
|
||||||
|
if (configButton) {
|
||||||
|
window.duplicatingConfigName = configButton.closest('li').querySelector('.text-blue-600').textContent.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Track HTMX after-request events for config deletion
|
// Track HTMX after-request events for config deletion
|
||||||
@@ -175,13 +255,29 @@ templ Configs(ctx context.Context, data ConfigsData) {
|
|||||||
configName = window.lastDeletedConfig.name;
|
configName = window.lastDeletedConfig.name;
|
||||||
}
|
}
|
||||||
|
|
||||||
window.notyf.success(`Configuration "${configName}" deleted successfully`);
|
showToast(`Configuration "${configName}" deleted successfully`, 'success');
|
||||||
|
|
||||||
// Clear flags
|
// Clear flags
|
||||||
window.currentlyDeletingConfig = false;
|
window.currentlyDeletingConfig = false;
|
||||||
window.isConfigDeleteRequest = false;
|
window.isConfigDeleteRequest = false;
|
||||||
window.lastDeletedConfig = null;
|
window.lastDeletedConfig = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if this is a duplication request
|
||||||
|
const isDuplicateRequest = window.isConfigDuplicateRequest &&
|
||||||
|
event.detail.pathInfo &&
|
||||||
|
event.detail.pathInfo.requestPath &&
|
||||||
|
event.detail.pathInfo.requestPath.match(/^\/configs\/\d+\/duplicate$/);
|
||||||
|
|
||||||
|
// If this is a successful duplication request, show notification
|
||||||
|
if (isDuplicateRequest && event.detail.successful) {
|
||||||
|
const configName = window.duplicatingConfigName || "configuration";
|
||||||
|
showToast(`Configuration "${configName}" duplicated successfully`, 'success');
|
||||||
|
|
||||||
|
// Clear flags
|
||||||
|
window.isConfigDuplicateRequest = false;
|
||||||
|
window.duplicatingConfigName = null;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Track HTMX error events for config deletion
|
// Track HTMX error events for config deletion
|
||||||
@@ -218,92 +314,129 @@ templ Configs(ctx context.Context, data ConfigsData) {
|
|||||||
errorMsg = `Error: ${error.error}`;
|
errorMsg = `Error: ${error.error}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
window.notyf.error(errorMsg);
|
showToast(errorMsg, 'error');
|
||||||
|
|
||||||
// Clear flags
|
// Clear flags
|
||||||
window.currentlyDeletingConfig = false;
|
window.currentlyDeletingConfig = false;
|
||||||
window.isConfigDeleteRequest = false;
|
window.isConfigDeleteRequest = false;
|
||||||
window.lastDeletedConfig = null;
|
window.lastDeletedConfig = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if this is a duplication request
|
||||||
|
const isDuplicateRequest = window.isConfigDuplicateRequest &&
|
||||||
|
event.detail.pathInfo &&
|
||||||
|
event.detail.pathInfo.requestPath &&
|
||||||
|
event.detail.pathInfo.requestPath.match(/^\/configs\/\d+\/duplicate$/);
|
||||||
|
|
||||||
|
if (isDuplicateRequest) {
|
||||||
|
const configName = window.duplicatingConfigName || "configuration";
|
||||||
|
|
||||||
|
let errorMsg = `Failed to duplicate configuration "${configName}"`;
|
||||||
|
|
||||||
|
if (event.detail.xhr && event.detail.xhr.responseText) {
|
||||||
|
try {
|
||||||
|
const error = JSON.parse(event.detail.xhr.responseText);
|
||||||
|
errorMsg = `Error: ${error.error}`;
|
||||||
|
} catch (e) {
|
||||||
|
// If not JSON, use the response text directly
|
||||||
|
if (event.detail.xhr.responseText.trim()) {
|
||||||
|
errorMsg = event.detail.xhr.responseText;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
showToast(errorMsg, 'error');
|
||||||
|
|
||||||
|
// Clear flags
|
||||||
|
window.isConfigDuplicateRequest = false;
|
||||||
|
window.duplicatingConfigName = null;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="py-6">
|
<div id="configs-container" style="min-height: 100vh; background-color: rgb(249, 250, 251);" class="configs-page bg-gray-50 dark:bg-gray-900">
|
||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
<div class="pb-8 w-full">
|
||||||
<div class="flex justify-between items-center mb-8">
|
<div class="mb-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||||
<h1 class="text-3xl font-bold text-secondary-900 dark:text-secondary-100">
|
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||||
<i class="fas fa-cogs mr-2 text-primary-600 dark:text-primary-400"></i>
|
<i class="fas fa-exchange-alt w-6 h-6 mr-2 text-blue-500 dark:text-blue-400"></i>
|
||||||
Transfer Configurations
|
Transfer Configurations
|
||||||
</h1>
|
</h1>
|
||||||
<a href="/configs/new" class="btn-primary">
|
<a href="/configs/new" class="flex items-center justify-center text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg px-5 py-2.5 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800">
|
||||||
<i class="fas fa-plus mr-2"></i>
|
<i class="fas fa-plus w-4 h-4 mr-2"></i>
|
||||||
New Configuration
|
New Configuration
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-6">
|
<div class="mt-6">
|
||||||
if len(data.Configs) == 0 {
|
if len(data.Configs) == 0 {
|
||||||
<div class="card p-12 flex flex-col items-center justify-center text-center">
|
<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-block p-4 rounded-full bg-secondary-100 dark:bg-secondary-700 mb-4">
|
<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-folder-open text-secondary-400 dark:text-secondary-500 text-3xl"></i>
|
<i class="fas fa-exchange-alt text-gray-400 dark:text-gray-500 text-3xl"></i>
|
||||||
</div>
|
|
||||||
<h3 class="mt-2 text-lg font-medium text-secondary-900 dark:text-secondary-100">No configurations</h3>
|
|
||||||
<p class="mt-1 text-sm text-secondary-500 dark:text-secondary-400">Get started by creating a new transfer configuration.</p>
|
|
||||||
<div class="mt-6">
|
|
||||||
<a href="/configs/new" class="btn-primary">
|
|
||||||
<i class="fas fa-plus mr-2"></i>
|
|
||||||
Create First Configuration
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
|
<h3 class="mb-2 text-lg font-semibold text-gray-900 dark:text-white">No configurations</h3>
|
||||||
|
<p class="text-gray-500 dark:text-gray-400 mb-4">Get started by creating a new transfer configuration.</p>
|
||||||
|
<a href="/configs/new" class="inline-flex items-center px-3 py-2 text-sm font-medium text-center text-white bg-blue-700 rounded-lg hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800">
|
||||||
|
<i class="fas fa-plus w-4 h-4 mr-2"></i>
|
||||||
|
Create First Configuration
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
} else {
|
} else {
|
||||||
<div class="card overflow-hidden">
|
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 overflow-hidden">
|
||||||
<ul role="list" class="divide-y divide-secondary-200 dark:divide-secondary-700">
|
<ul class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
for _, config := range data.Configs {
|
for _, config := range data.Configs {
|
||||||
<li>
|
<li>
|
||||||
<div class="block hover:bg-secondary-50 dark:hover:bg-secondary-750 transition-colors">
|
<div class="block hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
|
||||||
<div class="px-4 py-4 sm:px-6">
|
<div class="px-4 py-4 sm:px-6">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<p class="text-sm font-medium text-primary-600 dark:text-primary-400 truncate">
|
<p class="text-sm font-medium text-blue-600 dark:text-blue-400 truncate">
|
||||||
{ config.Name }
|
{ config.Name }
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<!-- Google Drive Authentication Badge -->
|
<!-- Google Drive Authentication Badge -->
|
||||||
if (config.DestinationType == "gdrive" || config.SourceType == "gdrive" || config.SourceType == "gphotos" || config.DestinationType == "gphotos") && !config.GetGoogleAuthenticated() {
|
if (config.DestinationType == "drive" || config.SourceType == "drive" || config.SourceType == "gphotos" || config.DestinationType == "gphotos") && !config.GetGoogleAuthenticated() {
|
||||||
<span class="ml-2 px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-yellow-100 text-yellow-800 dark:bg-yellow-800 dark:text-yellow-100">
|
<span class="ml-2 bg-yellow-100 text-yellow-800 text-xs font-medium mr-2 px-2.5 py-0.5 rounded-full dark:bg-yellow-900 dark:text-yellow-300">
|
||||||
<i class="fas fa-exclamation-triangle mr-1 flex items-center"></i>
|
<i class="fas fa-exclamation-triangle w-3 h-3 mr-1 inline"></i>
|
||||||
Authentication Required
|
Authentication Required
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
|
|
||||||
<!-- Google Drive Authentication Status Indicator -->
|
<!-- Google Drive Authentication Status Indicator -->
|
||||||
if (config.DestinationType == "gdrive" || config.SourceType == "gdrive" || config.SourceType == "gphotos" || config.DestinationType == "gphotos") && config.GetGoogleAuthenticated() {
|
if (config.DestinationType == "drive" || config.SourceType == "drive" || config.SourceType == "gphotos" || config.DestinationType == "gphotos") && config.GetGoogleAuthenticated() {
|
||||||
<span class="ml-2 px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800 dark:bg-green-800 dark:text-green-100">
|
<span class="ml-2 bg-green-100 text-green-800 text-xs font-medium mr-2 px-2.5 py-0.5 rounded-full dark:bg-green-900 dark:text-green-300">
|
||||||
<i class="fas fa-check-circle mr-1 flex items-center"></i>
|
<i class="fas fa-check-circle w-3 h-3 mr-1 inline"></i>
|
||||||
Authenticated
|
Authenticated
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="ml-2 flex-shrink-0 flex space-x-2">
|
<div class="ml-2 flex-shrink-0 flex space-x-2">
|
||||||
<!-- Google Drive Authentication Button -->
|
<!-- Google Drive Authentication Button -->
|
||||||
if (config.DestinationType == "gdrive" || config.SourceType == "gdrive" || config.SourceType == "gphotos" || config.DestinationType == "gphotos") && !config.GetGoogleAuthenticated() {
|
if (config.DestinationType == "drive" || config.SourceType == "drive" || config.SourceType == "gphotos" || config.DestinationType == "gphotos") && !config.GetGoogleAuthenticated() {
|
||||||
<a href={ templ.SafeURL(fmt.Sprintf("/configs/%d/gdrive-auth", config.ID)) } class="btn-warning btn-sm">
|
<a href={ templ.SafeURL(fmt.Sprintf("/configs/%d/gdrive-auth", config.ID)) } class="text-yellow-700 bg-yellow-100 hover:bg-yellow-200 focus:ring-4 focus:outline-none focus:ring-yellow-300 font-medium rounded-lg text-sm px-3 py-1.5 text-center inline-flex items-center dark:bg-yellow-900 dark:text-yellow-300 dark:hover:bg-yellow-800 dark:focus:ring-yellow-800">
|
||||||
<i class="fab fa-google mr-1"></i>
|
<i class="fas fa-key w-3.5 h-3.5 mr-1.5"></i>
|
||||||
Authenticate
|
Authenticate
|
||||||
</a>
|
</a>
|
||||||
}
|
}
|
||||||
|
|
||||||
<a href={ templ.SafeURL(fmt.Sprintf("/configs/%d", config.ID)) } class="btn-secondary btn-sm">
|
<a href={ templ.SafeURL(fmt.Sprintf("/configs/%d", config.ID)) } class="text-gray-700 bg-gray-100 hover:bg-gray-200 focus:ring-4 focus:outline-none focus:ring-gray-300 font-medium rounded-lg text-sm px-3 py-1.5 text-center inline-flex items-center dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600 dark:focus:ring-gray-700">
|
||||||
<i class="fas fa-edit mr-1"></i>
|
<i class="fas fa-edit w-3.5 h-3.5 mr-1.5"></i>
|
||||||
Edit
|
Edit
|
||||||
</a>
|
</a>
|
||||||
|
<!-- Duplicate config button -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
hx-post={ fmt.Sprintf("/configs/%d/duplicate", config.ID) }
|
||||||
|
hx-target="body"
|
||||||
|
class="text-indigo-700 bg-indigo-100 hover:bg-indigo-200 focus:ring-4 focus:outline-none focus:ring-indigo-300 font-medium rounded-lg text-sm px-3 py-1.5 text-center inline-flex items-center dark:bg-indigo-700 dark:text-indigo-300 dark:hover:bg-indigo-600 dark:focus:ring-indigo-800">
|
||||||
|
<i class="fas fa-clone w-3.5 h-3.5 mr-1.5"></i>
|
||||||
|
Duplicate
|
||||||
|
</button>
|
||||||
<!-- Add delete dialog for each configuration -->
|
<!-- Add delete dialog for each configuration -->
|
||||||
@ConfigDialog(
|
@ConfigDialog(
|
||||||
fmt.Sprintf("delete-config-dialog-%d", config.ID),
|
fmt.Sprintf("delete-config-dialog-%d", config.ID),
|
||||||
"Delete Configuration",
|
"Delete Configuration",
|
||||||
fmt.Sprintf("Are you sure you want to delete the configuration '%s'? This cannot be undone.", config.Name),
|
fmt.Sprintf("Are you sure you want to delete the configuration '%s'? This cannot be undone.", config.Name),
|
||||||
"btn-danger",
|
"text-white bg-red-600 hover:bg-red-800 focus:ring-4 focus:outline-none focus:ring-red-300 dark:focus:ring-red-800 font-medium rounded-lg text-sm inline-flex items-center px-5 py-2.5 text-center",
|
||||||
"Delete",
|
"Delete",
|
||||||
"delete",
|
"delete",
|
||||||
config.ID,
|
config.ID,
|
||||||
@@ -311,26 +444,26 @@ templ Configs(ctx context.Context, data ConfigsData) {
|
|||||||
)
|
)
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={ showConfigDialog(fmt.Sprintf("delete-config-dialog-%d", config.ID)) }
|
onclick={ showModal(fmt.Sprintf("delete-config-dialog-%d", config.ID)) }
|
||||||
class="btn-danger btn-sm">
|
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 mr-1"></i>
|
<i class="fas fa-trash-alt w-3.5 h-3.5 mr-1.5"></i>
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-2 sm:flex sm:justify-between">
|
<div class="mt-3 sm:flex sm:justify-between">
|
||||||
<div class="sm:flex">
|
<div class="sm:flex flex-col md:flex-row gap-2 md:gap-6">
|
||||||
<p class="flex items-center text-sm text-secondary-500 dark:text-secondary-400">
|
<p class="flex items-center text-sm text-gray-500 dark:text-gray-400">
|
||||||
<i class="fas fa-upload flex-shrink-0 mr-1.5 h-5 w-5 text-secondary-400 dark:text-secondary-500"></i>
|
<i class="fas fa-upload w-4 h-4 mr-1.5 text-gray-400 dark:text-gray-500"></i>
|
||||||
Source: { config.SourceType }: { config.SourcePath }
|
Source: { config.SourceType }: { config.SourcePath }
|
||||||
</p>
|
</p>
|
||||||
<p class="mt-2 flex items-center text-sm text-secondary-500 dark:text-secondary-400 sm:mt-0 sm:ml-6">
|
<p class="mt-2 md:mt-0 flex items-center text-sm text-gray-500 dark:text-gray-400">
|
||||||
<i class="fas fa-download flex-shrink-0 mr-1.5 h-5 w-5 text-secondary-400 dark:text-secondary-500"></i>
|
<i class="fas fa-download w-4 h-4 mr-1.5 text-gray-400 dark:text-gray-500"></i>
|
||||||
Destination: { config.DestinationType }: { config.DestinationPath }
|
Destination: { config.DestinationType }: { config.DestinationPath }
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-2 flex items-center text-sm text-secondary-500 dark:text-secondary-400 sm:mt-0">
|
<div class="mt-2 md:mt-0 flex items-center text-sm text-gray-500 dark:text-gray-400">
|
||||||
<i class="fas fa-calendar-alt flex-shrink-0 mr-1.5 h-5 w-5 text-secondary-400 dark:text-secondary-500"></i>
|
<i class="far fa-clock w-4 h-4 mr-1.5 text-gray-400 dark:text-gray-500"></i>
|
||||||
<p>
|
<p>
|
||||||
Updated: { config.UpdatedAt.Format("2006-01-02 15:04:05") }
|
Updated: { config.UpdatedAt.Format("2006-01-02 15:04:05") }
|
||||||
</p>
|
</p>
|
||||||
@@ -345,22 +478,56 @@ templ Configs(ctx context.Context, data ConfigsData) {
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Help Notice -->
|
<!-- Help Section -->
|
||||||
<div class="mt-8 text-center">
|
<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">
|
||||||
<p class="text-sm text-secondary-500 dark:text-secondary-400">
|
<div class="flex items-start mb-2">
|
||||||
<i class="fas fa-info-circle mr-1 text-primary-500"></i>
|
<div class="flex items-center h-5">
|
||||||
Configurations define how files are transferred between systems
|
<i class="fas fa-info-circle w-4 h-4 text-blue-500 dark:text-blue-400 mr-2"></i>
|
||||||
</p>
|
</div>
|
||||||
</div>
|
<div class="ml-2 text-sm">
|
||||||
|
<p class="text-gray-700 dark:text-gray-300">Configurations define how files are transferred between systems</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Google Drive Auth Help -->
|
<div class="flex items-start mt-4">
|
||||||
<div class="mt-4 text-center">
|
<div class="flex items-center h-5">
|
||||||
<p class="text-sm text-secondary-500 dark:text-secondary-400">
|
<i class="fab fa-google-drive w-4 h-4 text-blue-500 dark:text-blue-400 mr-2"></i>
|
||||||
<i class="fab fa-google mr-1 text-blue-500 inline-flex items-center"></i>
|
</div>
|
||||||
Google Drive and Google Photos configurations require authentication. Click the "Authenticate" button to complete setup.
|
<div class="ml-2 text-sm">
|
||||||
</p>
|
<p class="text-gray-700 dark:text-gray-300">Google Drive and Google Photos configurations require authentication. Click the "Authenticate" button to complete setup.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-start mt-4">
|
||||||
|
<div class="flex items-center h-5">
|
||||||
|
<i class="fas fa-clone w-4 h-4 text-indigo-500 dark:text-indigo-400 mr-2"></i>
|
||||||
|
</div>
|
||||||
|
<div class="ml-2 text-sm">
|
||||||
|
<p class="text-gray-700 dark:text-gray-300">When duplicating configurations, you may need to re-enter sensitive credentials for security reasons. Make sure to edit duplicated configurations to add any required credentials.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Set dark background color if in dark mode
|
||||||
|
if (document.documentElement.classList.contains('dark')) {
|
||||||
|
document.getElementById('configs-container').style.backgroundColor = '#111827';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add event listener for theme changes
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const themeToggle = document.getElementById('theme-toggle');
|
||||||
|
if (themeToggle) {
|
||||||
|
themeToggle.addEventListener('click', function() {
|
||||||
|
setTimeout(function() {
|
||||||
|
const isDark = document.documentElement.classList.contains('dark');
|
||||||
|
document.getElementById('configs-container').style.backgroundColor = isDark ? '#111827' : 'rgb(249, 250, 251)';
|
||||||
|
}, 50);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+268
-91
@@ -4,7 +4,9 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/starfleetcptn/gomft/internal/db"
|
"github.com/starfleetcptn/gomft/internal/db"
|
||||||
|
"os/exec"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
type DashboardData struct {
|
type DashboardData struct {
|
||||||
@@ -13,19 +15,104 @@ type DashboardData struct {
|
|||||||
CompletedToday int
|
CompletedToday int
|
||||||
FailedTransfers int
|
FailedTransfers int
|
||||||
Configs map[uint]db.TransferConfig
|
Configs map[uint]db.TransferConfig
|
||||||
|
RcloneVersion string
|
||||||
|
LatestVersion string
|
||||||
|
CurrentVersion string
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRcloneVersion executes the rclone --version command and returns the version string
|
||||||
|
func GetRcloneVersion() string {
|
||||||
|
cmd := exec.Command("rclone", "--version")
|
||||||
|
output, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return "Unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the version from output (typically first line contains "rclone v1.XX.X")
|
||||||
|
outputStr := string(output)
|
||||||
|
lines := strings.Split(outputStr, "\n")
|
||||||
|
if len(lines) > 0 {
|
||||||
|
parts := strings.Split(lines[0], " ")
|
||||||
|
for _, part := range parts {
|
||||||
|
if strings.HasPrefix(part, "v") {
|
||||||
|
return part
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "Unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
// isNewerVersionAvailable checks if the latest version is newer than the current version
|
||||||
|
func isNewerVersionAvailable(current, latest string) bool {
|
||||||
|
// If either version is empty, we can't do a comparison
|
||||||
|
if current == "" || latest == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Special case for DEV versions - always show update available
|
||||||
|
if current == "DEV" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strip 'v' prefix if present for comparison
|
||||||
|
if strings.HasPrefix(current, "v") {
|
||||||
|
current = current[1:]
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(latest, "v") {
|
||||||
|
latest = latest[1:]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split versions into components
|
||||||
|
currentParts := strings.Split(current, ".")
|
||||||
|
latestParts := strings.Split(latest, ".")
|
||||||
|
|
||||||
|
// Handle non-semver format in either version
|
||||||
|
if len(currentParts) < 2 || len(latestParts) < 2 {
|
||||||
|
// If format doesn't match semver pattern, do string comparison
|
||||||
|
return current != latest && latest != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare major, minor, patch versions
|
||||||
|
for i := 0; i < len(currentParts) && i < len(latestParts); i++ {
|
||||||
|
// Parse to integers
|
||||||
|
currentNum, err1 := strconv.Atoi(currentParts[i])
|
||||||
|
latestNum, err2 := strconv.Atoi(latestParts[i])
|
||||||
|
|
||||||
|
// If either can't be parsed, do string comparison
|
||||||
|
if err1 != nil || err2 != nil {
|
||||||
|
if currentParts[i] < latestParts[i] {
|
||||||
|
return true
|
||||||
|
} else if currentParts[i] > latestParts[i] {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare numbers
|
||||||
|
if latestNum > currentNum {
|
||||||
|
return true
|
||||||
|
} else if latestNum < currentNum {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If all components are equal but latest has more components, it's newer
|
||||||
|
return len(latestParts) > len(currentParts)
|
||||||
}
|
}
|
||||||
|
|
||||||
templ Dashboard(ctx context.Context, data DashboardData) {
|
templ Dashboard(ctx context.Context, data DashboardData) {
|
||||||
@LayoutWithContext("Dashboard", ctx) {
|
@LayoutWithContext("Dashboard", ctx) {
|
||||||
<div class="py-6">
|
<div id="dashboard-container" style="min-height: 100vh;" class="bg-gray-50 dark:bg-gray-900">
|
||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
<div class="pb-8 w-full">
|
||||||
<div class="flex items-center justify-between mb-8">
|
<!-- Page Header -->
|
||||||
<h1 class="text-3xl font-bold text-secondary-900 dark:text-secondary-100">
|
<div class="mb-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||||
<i class="fas fa-tachometer-alt mr-2 text-primary-600 dark:text-primary-400"></i>
|
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||||
|
<i class="fas fa-chart-pie w-6 h-6 mr-2 text-blue-500 dark:text-blue-400"></i>
|
||||||
Dashboard
|
Dashboard
|
||||||
</h1>
|
</h1>
|
||||||
<div class="text-sm text-secondary-500 dark:text-secondary-400">
|
<div class="bg-white dark:bg-gray-800 p-2 px-4 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
|
||||||
<span id="current-date" class="font-medium"></span>
|
<span id="current-date" class="font-medium text-gray-700 dark:text-gray-300"></span>
|
||||||
<script>
|
<script>
|
||||||
document.getElementById('current-date').textContent = new Date().toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
|
document.getElementById('current-date').textContent = new Date().toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
|
||||||
</script>
|
</script>
|
||||||
@@ -33,111 +120,131 @@ templ Dashboard(ctx context.Context, data DashboardData) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Stats Overview Cards -->
|
<!-- Stats Overview Cards -->
|
||||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 mb-6">
|
||||||
<div class="card overflow-hidden">
|
<!-- Active Transfers -->
|
||||||
<div class="p-6 flex items-center">
|
<div class="p-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 sm:p-6">
|
||||||
<div class="rounded-full bg-blue-100 dark:bg-blue-900 p-3 mr-4">
|
<div class="flex items-center justify-between mb-4">
|
||||||
<i class="fas fa-exchange-alt text-blue-600 dark:text-blue-300 text-xl"></i>
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Active Transfers</h3>
|
||||||
|
<div class="inline-flex items-center justify-center w-10 h-10 text-blue-600 bg-blue-100 rounded-lg dark:text-blue-300 dark:bg-blue-900">
|
||||||
|
<i class="fas fa-exchange-alt w-6 h-6 flex items-center justify-center"></i>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
</div>
|
||||||
<p class="text-sm font-medium text-secondary-500 dark:text-secondary-400">Active Transfers</p>
|
<div class="flex items-center">
|
||||||
<p class="text-2xl font-bold text-secondary-900 dark:text-secondary-100">{ strconv.Itoa(data.ActiveTransfers) }</p>
|
<div class="flex-shrink-0">
|
||||||
|
<span class="text-4xl font-bold leading-none text-gray-900 dark:text-white">{ strconv.Itoa(data.ActiveTransfers) }</span>
|
||||||
|
</div>
|
||||||
|
<div class="ml-3 flex items-baseline text-sm font-medium">
|
||||||
|
<span class="text-gray-500 dark:text-gray-400">Currently in progress</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card overflow-hidden">
|
<!-- Completed Today -->
|
||||||
<div class="p-6 flex items-center">
|
<div class="p-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 sm:p-6">
|
||||||
<div class="rounded-full bg-green-100 dark:bg-green-900 p-3 mr-4">
|
<div class="flex items-center justify-between mb-4">
|
||||||
<i class="fas fa-check-circle text-green-600 dark:text-green-300 text-xl"></i>
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Completed Today</h3>
|
||||||
|
<div class="inline-flex items-center justify-center w-10 h-10 text-green-600 bg-green-100 rounded-lg dark:text-green-300 dark:bg-green-900">
|
||||||
|
<i class="fas fa-check-circle w-6 h-6 flex items-center justify-center"></i>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
</div>
|
||||||
<p class="text-sm font-medium text-secondary-500 dark:text-secondary-400">Completed Today</p>
|
<div class="flex items-center">
|
||||||
<p class="text-2xl font-bold text-secondary-900 dark:text-secondary-100">{ strconv.Itoa(data.CompletedToday) }</p>
|
<div class="flex-shrink-0">
|
||||||
|
<span class="text-4xl font-bold leading-none text-gray-900 dark:text-white">{ strconv.Itoa(data.CompletedToday) }</span>
|
||||||
|
</div>
|
||||||
|
<div class="ml-3 flex items-baseline text-sm font-medium">
|
||||||
|
<span class="text-gray-500 dark:text-gray-400">Successfully completed</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card overflow-hidden">
|
<!-- Failed Transfers -->
|
||||||
<div class="p-6 flex items-center">
|
<div class="p-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 sm:p-6">
|
||||||
<div class="rounded-full bg-red-100 dark:bg-red-900 p-3 mr-4">
|
<div class="flex items-center justify-between mb-4">
|
||||||
<i class="fas fa-exclamation-circle text-red-600 dark:text-red-300 text-xl"></i>
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Failed Transfers</h3>
|
||||||
|
<div class="inline-flex items-center justify-center w-10 h-10 text-red-600 bg-red-100 rounded-lg dark:text-red-300 dark:bg-red-900">
|
||||||
|
<i class="fas fa-exclamation-circle w-6 h-6 flex items-center justify-center"></i>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
</div>
|
||||||
<p class="text-sm font-medium text-secondary-500 dark:text-secondary-400">Failed Transfers</p>
|
<div class="flex items-center">
|
||||||
<p class="text-2xl font-bold text-secondary-900 dark:text-secondary-100">{ strconv.Itoa(data.FailedTransfers) }</p>
|
<div class="flex-shrink-0">
|
||||||
|
<span class="text-4xl font-bold leading-none text-gray-900 dark:text-white">{ strconv.Itoa(data.FailedTransfers) }</span>
|
||||||
|
</div>
|
||||||
|
<div class="ml-3 flex items-baseline text-sm font-medium">
|
||||||
|
<span class="text-gray-500 dark:text-gray-400">Require attention</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
<!-- Main Content Area -->
|
||||||
<div class="card">
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-4">
|
||||||
<div class="card-header">
|
<!-- Recent Jobs Card -->
|
||||||
<h3 class="text-lg font-medium text-secondary-900 dark:text-secondary-100">
|
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800">
|
||||||
<i class="fas fa-history mr-2 text-primary-500"></i>
|
<div class="flex items-center justify-between px-4 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white flex items-center">
|
||||||
|
<i class="fas fa-history w-6 h-6 mr-2 text-blue-500 dark:text-blue-400 flex-shrink-0"></i>
|
||||||
Recent Jobs
|
Recent Jobs
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="p-4">
|
||||||
if len(data.RecentJobs) == 0 {
|
if len(data.RecentJobs) == 0 {
|
||||||
<div class="py-8 text-center">
|
<!-- Empty state with Flowbite styling -->
|
||||||
<div class="inline-block p-4 rounded-full bg-secondary-100 dark:bg-secondary-800 mb-4">
|
<div class="flex flex-col items-center justify-center py-8 px-4 text-center">
|
||||||
<i class="fas fa-inbox text-secondary-400 text-3xl"></i>
|
<div class="inline-flex h-16 w-16 flex-shrink-0 items-center justify-center rounded-full bg-gray-100 mb-4 dark:bg-gray-700">
|
||||||
|
<i class="fas fa-clipboard-list text-gray-500 dark:text-gray-400 text-3xl"></i>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-secondary-500 dark:text-secondary-400">No recent jobs found</p>
|
<h3 class="mb-2 text-lg font-semibold text-gray-900 dark:text-white">No recent jobs found</h3>
|
||||||
<a href="/jobs/new" class="mt-4 inline-flex items-center text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300">
|
<p class="text-gray-500 dark:text-gray-400 mb-4">Get started by creating your first job.</p>
|
||||||
<span>Create your first job</span>
|
<a href="/jobs/new" class="inline-flex items-center px-3 py-2 text-sm font-medium text-center text-white bg-blue-700 rounded-lg hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800">
|
||||||
<i class="fas fa-arrow-right ml-1"></i>
|
<i class="fas fa-plus w-4 h-4 mr-2"></i>
|
||||||
|
Create your first job
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
} else {
|
} else {
|
||||||
<div class="flow-root">
|
<div class="flow-root">
|
||||||
<ul role="list" class="-my-5 divide-y divide-secondary-200 dark:divide-secondary-700">
|
<ul class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
for _, job := range data.RecentJobs {
|
for _, job := range data.RecentJobs {
|
||||||
<li class="py-4 hover:bg-secondary-50 dark:hover:bg-secondary-800 px-4 rounded-lg transition-colors">
|
<li class="py-3 sm:py-4 hover:bg-gray-50 dark:hover:bg-gray-700 px-3 rounded-lg transition-colors">
|
||||||
<div class="flex items-center space-x-4">
|
<div class="flex items-center">
|
||||||
<div class="flex-shrink-0">
|
<div class="flex-shrink-0">
|
||||||
if job.Status == "completed" {
|
if job.Status == "completed" {
|
||||||
<span class="h-10 w-10 rounded-full bg-green-100 flex items-center justify-center dark:bg-green-900">
|
<span class="inline-flex items-center justify-center h-12 w-12 rounded-full text-green-600 bg-green-100 dark:bg-green-900">
|
||||||
<i class="fas fa-check text-green-600 dark:text-green-300"></i>
|
<i class="fas fa-check-circle w-6 h-6 flex items-center justify-center"></i>
|
||||||
</span>
|
</span>
|
||||||
} else if job.Status == "failed" {
|
} else if job.Status == "failed" {
|
||||||
<span class="h-10 w-10 rounded-full bg-red-100 flex items-center justify-center dark:bg-red-900">
|
<span class="inline-flex items-center justify-center h-12 w-12 rounded-full text-red-600 bg-red-100 dark:bg-red-900">
|
||||||
<i class="fas fa-times text-red-600 dark:text-red-300"></i>
|
<i class="fas fa-exclamation-circle w-6 h-6 flex items-center justify-center"></i>
|
||||||
</span>
|
</span>
|
||||||
} else {
|
} else {
|
||||||
<span class="h-10 w-10 rounded-full bg-blue-100 flex items-center justify-center dark:bg-blue-900">
|
<span class="inline-flex items-center justify-center h-12 w-12 rounded-full text-blue-600 bg-blue-100 dark:bg-blue-900">
|
||||||
<i class="fas fa-sync-alt text-blue-600 dark:text-blue-300"></i>
|
<i class="fas fa-tasks w-6 h-6 flex items-center justify-center"></i>
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0 ms-4">
|
||||||
<p class="text-sm font-medium text-secondary-900 truncate dark:text-secondary-100">
|
<p class="text-sm font-medium text-gray-900 truncate dark:text-white">
|
||||||
{ getConfigNameForHistory(job, data.Configs) }
|
{ getConfigNameForHistory(job, data.Configs) }
|
||||||
</p>
|
</p>
|
||||||
<div class="flex items-center mt-1">
|
<div class="flex items-center mt-1">
|
||||||
<i class="fas fa-clock text-xs text-secondary-500 dark:text-secondary-400 mr-1"></i>
|
<i class="fas fa-clock w-4 h-4 text-gray-500 dark:text-gray-400 mr-1"></i>
|
||||||
<p class="text-sm text-secondary-500 truncate dark:text-secondary-400">
|
<p class="text-sm text-gray-500 truncate dark:text-gray-400">
|
||||||
Started: { job.StartTime.Format("Jan 02, 2006 15:04:05") }
|
Started: { job.StartTime.Format("Jan 02, 2006 15:04:05") }
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<a href={ templ.SafeURL(fmt.Sprintf("/job-runs/%d", job.ID)) }
|
||||||
<a href={ templ.SafeURL(fmt.Sprintf("/job-runs/%d", job.ID)) }
|
class="inline-flex items-center text-sm font-medium text-blue-600 hover:underline dark:text-blue-500">
|
||||||
class="inline-flex items-center px-3 py-1.5 border border-secondary-300 text-sm leading-5 font-medium rounded-full text-secondary-700 bg-white hover:bg-secondary-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 dark:bg-secondary-800 dark:text-secondary-200 dark:border-secondary-600 dark:hover:bg-secondary-700">
|
View
|
||||||
<i class="fas fa-eye mr-1"></i>
|
<i class="fas fa-arrow-right w-3 h-3 ms-2"></i>
|
||||||
View
|
</a>
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
}
|
}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-6">
|
<div class="pt-4 border-t border-gray-200 dark:border-gray-700 mt-4">
|
||||||
<a href="/jobs" class="w-full flex justify-center items-center px-4 py-2 border border-secondary-300 shadow-sm text-sm font-medium rounded-lg text-secondary-700 bg-white hover:bg-secondary-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 dark:bg-secondary-800 dark:text-secondary-200 dark:border-secondary-600 dark:hover:bg-secondary-700">
|
<a href="/jobs" class="inline-flex items-center justify-center w-full px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-lg hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700">
|
||||||
<i class="fas fa-list-ul mr-2"></i>
|
<i class="fas fa-list w-4 h-4 mr-2"></i>
|
||||||
View all jobs
|
View all jobs
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -145,42 +252,92 @@ templ Dashboard(ctx context.Context, data DashboardData) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<!-- Quick Actions Card -->
|
||||||
<div class="card-header">
|
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800">
|
||||||
<h3 class="text-lg font-medium text-secondary-900 dark:text-secondary-100">
|
<div class="flex items-center justify-between px-4 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
<i class="fas fa-bolt mr-2 text-primary-500"></i>
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white flex items-center">
|
||||||
|
<i class="fas fa-bolt w-6 h-6 mr-2 text-blue-500 dark:text-blue-400 flex-shrink-0"></i>
|
||||||
Quick Actions
|
Quick Actions
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body space-y-4">
|
<div class="p-4 space-y-4">
|
||||||
<a href="/configs/new" class="btn-primary text-center flex items-center justify-center">
|
<a href="/configs/new" class="inline-flex items-center justify-center w-full text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg px-5 py-2.5 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800">
|
||||||
<i class="fas fa-plus-circle mr-2"></i>
|
<i class="fas fa-plus w-5 h-5 mr-2"></i>
|
||||||
Create New Config
|
Create New Config
|
||||||
</a>
|
</a>
|
||||||
<a href="/jobs/new" class="btn-primary text-center flex items-center justify-center">
|
<a href="/jobs/new" class="inline-flex items-center justify-center w-full text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg px-5 py-2.5 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800">
|
||||||
<i class="fas fa-play-circle mr-2"></i>
|
<i class="fas fa-tasks w-5 h-5 mr-2"></i>
|
||||||
Create New Job
|
Create New Job
|
||||||
</a>
|
</a>
|
||||||
<a href="/history" class="btn-secondary text-center flex items-center justify-center">
|
<a href="/history" class="inline-flex items-center justify-center w-full text-gray-900 bg-white border border-gray-300 focus:outline-none hover:bg-gray-100 focus:ring-4 focus:ring-gray-200 font-medium rounded-lg px-5 py-2.5 dark:bg-gray-800 dark:text-white dark:border-gray-600 dark:hover:bg-gray-700 dark:hover:border-gray-600 dark:focus:ring-gray-700">
|
||||||
<i class="fas fa-history mr-2"></i>
|
<i class="fas fa-history w-5 h-5 mr-2"></i>
|
||||||
View Transfer History
|
View Transfer History
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<!-- System Status Card -->
|
<!-- System Status Card with Flowbite design -->
|
||||||
<div class="mt-6 bg-secondary-50 dark:bg-secondary-800 rounded-lg p-4 border border-secondary-200 dark:border-secondary-700">
|
<div class="bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 shadow-sm mt-6">
|
||||||
<h4 class="text-sm font-medium text-secondary-900 dark:text-secondary-100 mb-2">System Status</h4>
|
<div class="px-4 py-2 bg-gray-50 dark:bg-gray-700 rounded-t-lg border-b border-gray-200 dark:border-gray-600">
|
||||||
<div class="space-y-2">
|
<h4 class="text-sm font-medium text-gray-900 dark:text-white">System Status</h4>
|
||||||
<div class="flex items-center justify-between">
|
</div>
|
||||||
<span class="text-xs text-secondary-500 dark:text-secondary-400">Server</span>
|
<div class="px-4 py-3">
|
||||||
<span class="badge badge-success">Online</span>
|
<div class="space-y-3">
|
||||||
</div>
|
<div class="flex items-center justify-between">
|
||||||
<div class="flex items-center justify-between">
|
<span class="text-sm text-gray-700 dark:text-gray-300">Server</span>
|
||||||
<span class="text-xs text-secondary-500 dark:text-secondary-400">Scheduler</span>
|
<span class="bg-green-100 text-green-800 text-xs font-medium inline-flex items-center px-2.5 py-0.5 rounded-full dark:bg-green-900 dark:text-green-300">
|
||||||
<span class="badge badge-success">Running</span>
|
<span class="w-2 h-2 mr-1 bg-green-500 rounded-full"></span>
|
||||||
</div>
|
Online
|
||||||
<div class="flex items-center justify-between">
|
</span>
|
||||||
<span class="text-xs text-secondary-500 dark:text-secondary-400">Database</span>
|
</div>
|
||||||
<span class="badge badge-success">Connected</span>
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-sm text-gray-700 dark:text-gray-300">Scheduler</span>
|
||||||
|
<span class="bg-green-100 text-green-800 text-xs font-medium inline-flex items-center px-2.5 py-0.5 rounded-full dark:bg-green-900 dark:text-green-300">
|
||||||
|
<span class="w-2 h-2 mr-1 bg-green-500 rounded-full"></span>
|
||||||
|
Running
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-sm text-gray-700 dark:text-gray-300">Database</span>
|
||||||
|
<span class="bg-green-100 text-green-800 text-xs font-medium inline-flex items-center px-2.5 py-0.5 rounded-full dark:bg-green-900 dark:text-green-300">
|
||||||
|
<span class="w-2 h-2 mr-1 bg-green-500 rounded-full"></span>
|
||||||
|
Connected
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-sm text-gray-700 dark:text-gray-300">Rclone</span>
|
||||||
|
<span class="bg-blue-100 text-blue-800 text-xs font-medium inline-flex items-center px-2.5 py-0.5 rounded-full dark:bg-blue-900 dark:text-blue-300">
|
||||||
|
<i class="fas fa-terminal w-3 h-3 mr-1"></i>
|
||||||
|
if data.RcloneVersion != "" {
|
||||||
|
{ data.RcloneVersion }
|
||||||
|
} else {
|
||||||
|
Unknown
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-sm text-gray-700 dark:text-gray-300">Application Version</span>
|
||||||
|
if data.CurrentVersion != "" && data.LatestVersion != "" && isNewerVersionAvailable(data.CurrentVersion, data.LatestVersion) {
|
||||||
|
<div class="flex items-center">
|
||||||
|
<span class="bg-yellow-100 text-yellow-800 text-xs font-medium inline-flex items-center px-2.5 py-0.5 rounded-full dark:bg-yellow-900 dark:text-yellow-300 mr-2">
|
||||||
|
<i class="fas fa-exclamation-triangle w-3 h-3 mr-1"></i>
|
||||||
|
{ data.CurrentVersion }
|
||||||
|
</span>
|
||||||
|
<a href={ templ.SafeURL(fmt.Sprintf("https://github.com/starfleetcptn/gomft/releases/tag/%s", data.LatestVersion)) } target="_blank"
|
||||||
|
class="bg-green-100 text-green-800 text-xs font-medium inline-flex items-center px-2.5 py-0.5 rounded-full dark:bg-green-900 dark:text-green-300 hover:bg-green-200 dark:hover:bg-green-800">
|
||||||
|
<i class="fas fa-arrow-circle-up w-3 h-3 mr-1"></i>
|
||||||
|
{ data.LatestVersion } Available
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
} else {
|
||||||
|
<span class="bg-green-100 text-green-800 text-xs font-medium inline-flex items-center px-2.5 py-0.5 rounded-full dark:bg-green-900 dark:text-green-300">
|
||||||
|
<i class="fas fa-check-circle w-3 h-3 mr-1"></i>
|
||||||
|
if data.CurrentVersion != "" {
|
||||||
|
{ data.CurrentVersion }
|
||||||
|
} else {
|
||||||
|
Up to date
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -188,6 +345,26 @@ templ Dashboard(ctx context.Context, data DashboardData) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Set dark background color if in dark mode
|
||||||
|
if (document.documentElement.classList.contains('dark')) {
|
||||||
|
document.getElementById('dashboard-container').style.backgroundColor = '#111827';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add event listener for theme changes
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const themeToggle = document.getElementById('theme-toggle');
|
||||||
|
if (themeToggle) {
|
||||||
|
themeToggle.addEventListener('click', function() {
|
||||||
|
setTimeout(function() {
|
||||||
|
const isDark = document.documentElement.classList.contains('dark');
|
||||||
|
document.getElementById('dashboard-container').style.backgroundColor = isDark ? '#111827' : 'rgb(249, 250, 251)';
|
||||||
|
}, 50);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
package components
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/starfleetcptn/gomft/internal/db"
|
||||||
|
"time"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
type NotificationsData struct {
|
||||||
|
Notifications []db.UserNotification
|
||||||
|
UnreadCount int64
|
||||||
|
// Pagination fields
|
||||||
|
CurrentPage int
|
||||||
|
TotalPages int
|
||||||
|
TotalCount int
|
||||||
|
PerPage int
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatNotificationTime formats a notification time in a user-friendly way
|
||||||
|
func FormatNotificationTime(t time.Time) string {
|
||||||
|
now := time.Now()
|
||||||
|
diff := now.Sub(t)
|
||||||
|
|
||||||
|
if diff < time.Minute {
|
||||||
|
return "just now"
|
||||||
|
} else if diff < time.Hour {
|
||||||
|
minutes := int(diff.Minutes())
|
||||||
|
if minutes == 1 {
|
||||||
|
return "1 minute ago"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d minutes ago", minutes)
|
||||||
|
} else if diff < 24*time.Hour {
|
||||||
|
hours := int(diff.Hours())
|
||||||
|
if hours == 1 {
|
||||||
|
return "1 hour ago"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d hours ago", hours)
|
||||||
|
} else if diff < 48*time.Hour {
|
||||||
|
return "yesterday"
|
||||||
|
} else {
|
||||||
|
days := int(diff.Hours() / 24)
|
||||||
|
if days < 7 {
|
||||||
|
return fmt.Sprintf("%d days ago", days)
|
||||||
|
} else {
|
||||||
|
return t.Format("Jan 2")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetNotificationIcon returns the appropriate icon class based on notification type
|
||||||
|
func GetNotificationIcon(notificationType db.NotificationType) string {
|
||||||
|
switch notificationType {
|
||||||
|
case db.NotificationJobStart:
|
||||||
|
return "fas fa-play text-blue-600 dark:text-blue-300"
|
||||||
|
case db.NotificationJobComplete:
|
||||||
|
return "fas fa-check-circle text-green-600 dark:text-green-300"
|
||||||
|
case db.NotificationJobFail:
|
||||||
|
return "fas fa-exclamation-circle text-red-600 dark:text-red-300"
|
||||||
|
case db.NotificationConfigUpdate:
|
||||||
|
return "fas fa-cog text-blue-600 dark:text-blue-300"
|
||||||
|
case db.NotificationSystemAlert:
|
||||||
|
return "fas fa-bell text-yellow-600 dark:text-yellow-300"
|
||||||
|
default:
|
||||||
|
return "fas fa-info-circle text-blue-600 dark:text-blue-300"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetNotificationBgColor returns the appropriate background color class based on notification type
|
||||||
|
func GetNotificationBgColor(notificationType db.NotificationType) string {
|
||||||
|
switch notificationType {
|
||||||
|
case db.NotificationJobStart:
|
||||||
|
return "bg-blue-100 dark:bg-blue-900"
|
||||||
|
case db.NotificationJobComplete:
|
||||||
|
return "bg-green-100 dark:bg-green-900"
|
||||||
|
case db.NotificationJobFail:
|
||||||
|
return "bg-red-100 dark:bg-red-900"
|
||||||
|
case db.NotificationConfigUpdate:
|
||||||
|
return "bg-blue-100 dark:bg-blue-900"
|
||||||
|
case db.NotificationSystemAlert:
|
||||||
|
return "bg-yellow-100 dark:bg-yellow-900"
|
||||||
|
default:
|
||||||
|
return "bg-blue-100 dark:bg-blue-900"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetNotificationBadgeColor returns the appropriate badge color class based on notification type
|
||||||
|
func GetNotificationBadgeColor(notificationType db.NotificationType) string {
|
||||||
|
switch notificationType {
|
||||||
|
case db.NotificationJobStart:
|
||||||
|
return "bg-primary-700"
|
||||||
|
case db.NotificationJobComplete:
|
||||||
|
return "bg-green-600"
|
||||||
|
case db.NotificationJobFail:
|
||||||
|
return "bg-red-600"
|
||||||
|
case db.NotificationConfigUpdate:
|
||||||
|
return "bg-blue-500"
|
||||||
|
case db.NotificationSystemAlert:
|
||||||
|
return "bg-yellow-500"
|
||||||
|
default:
|
||||||
|
return "bg-primary-700"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetNotificationBadgeIcon returns the appropriate badge icon class based on notification type
|
||||||
|
func GetNotificationBadgeIcon(notificationType db.NotificationType) string {
|
||||||
|
switch notificationType {
|
||||||
|
case db.NotificationJobStart:
|
||||||
|
return "fas fa-play"
|
||||||
|
case db.NotificationJobComplete:
|
||||||
|
return "fas fa-check"
|
||||||
|
case db.NotificationJobFail:
|
||||||
|
return "fas fa-times"
|
||||||
|
case db.NotificationConfigUpdate:
|
||||||
|
return "fas fa-wrench"
|
||||||
|
case db.NotificationSystemAlert:
|
||||||
|
return "fas fa-exclamation"
|
||||||
|
default:
|
||||||
|
return "fas fa-info"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
templ NotificationDropdown(data NotificationsData) {
|
||||||
|
<div
|
||||||
|
class="block py-2 px-4 text-base font-medium text-center text-gray-700 bg-gray-50 dark:bg-gray-600 dark:text-gray-300">
|
||||||
|
Notifications
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
if len(data.Notifications) == 0 {
|
||||||
|
<div class="py-4 px-4 text-center text-gray-500 dark:text-gray-400">
|
||||||
|
<i class="fas fa-bell-slash text-2xl mb-2"></i>
|
||||||
|
<p>No notifications</p>
|
||||||
|
</div>
|
||||||
|
} else {
|
||||||
|
for _, notification := range data.Notifications {
|
||||||
|
<a
|
||||||
|
href={ templ.SafeURL(notification.Link) }
|
||||||
|
class={ "flex py-3 px-4 border-b hover:bg-gray-100 dark:hover:bg-gray-600 dark:border-gray-600", templ.KV("bg-blue-50 dark:bg-blue-900/20", !notification.IsRead) }>
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<div class={ "w-11 h-11 rounded-full flex items-center justify-center", GetNotificationBgColor(notification.Type) }>
|
||||||
|
<i class={ GetNotificationIcon(notification.Type) }></i>
|
||||||
|
</div>
|
||||||
|
// <div class={ "flex absolute justify-center items-center ml-6 -mt-5 w-5 h-5 rounded-full border border-white dark:border-gray-700", GetNotificationBadgeColor(notification.Type) }>
|
||||||
|
// <i class={ "text-white text-xs", GetNotificationBadgeIcon(notification.Type) }></i>
|
||||||
|
// </div>
|
||||||
|
</div>
|
||||||
|
<div class="pl-3 w-full">
|
||||||
|
<div class="text-gray-500 font-normal text-sm mb-1.5 dark:text-gray-400">
|
||||||
|
<span class="font-semibold text-gray-900 dark:text-white">{ notification.Title }:</span> { notification.Message }
|
||||||
|
</div>
|
||||||
|
<div class="text-xs font-medium text-primary-600 dark:text-primary-500">
|
||||||
|
{ FormatNotificationTime(notification.CreatedAt) }
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="flex">
|
||||||
|
<a href="/notifications" class="block w-1/2 py-2 text-md font-medium text-center text-gray-900 bg-gray-50 hover:bg-gray-100 dark:bg-gray-600 dark:text-white dark:hover:bg-gray-500">
|
||||||
|
<div class="inline-flex items-center">
|
||||||
|
<i class="far fa-eye mr-2"></i>
|
||||||
|
View all
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
<a href="/notifications/mark-all-read"
|
||||||
|
hx-post="/notifications/mark-all-read"
|
||||||
|
hx-swap="none"
|
||||||
|
hx-target="#notification-count"
|
||||||
|
class="block w-1/2 py-2 text-md font-medium text-center text-gray-900 bg-gray-50 hover:bg-gray-100 dark:bg-gray-600 dark:text-white dark:hover:bg-gray-500 border-l border-gray-200 dark:border-gray-700">
|
||||||
|
<div class="inline-flex items-center">
|
||||||
|
<i class="fas fa-check-double mr-2"></i>
|
||||||
|
Mark all read
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ NotificationCount(count int64) {
|
||||||
|
if count > 0 {
|
||||||
|
<div class="absolute inline-flex items-center justify-center w-5 h-5 text-xs font-bold text-white bg-red-500 border border-white dark:border-gray-800 rounded-full -translate-y-1/2 translate-x-1/2" style="top: 0; right: 0;" id="notification-count">
|
||||||
|
if count > 99 {
|
||||||
|
99+
|
||||||
|
} else {
|
||||||
|
{ fmt.Sprintf("%d", count) }
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
} else {
|
||||||
|
<!-- Empty element to ensure proper swap when count is zero -->
|
||||||
|
<span class="hidden"></span>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,315 @@
|
|||||||
|
package components
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Note: To implement pagination, update the NotificationsData struct in notifications.templ
|
||||||
|
// to include these fields:
|
||||||
|
// CurrentPage int
|
||||||
|
// TotalPages int
|
||||||
|
// TotalCount int
|
||||||
|
// PerPage int
|
||||||
|
|
||||||
|
templ NotificationsPage(ctx context.Context, data NotificationsData) {
|
||||||
|
@LayoutWithContext("Notifications", ctx) {
|
||||||
|
<div class="py-6 px-4 mx-auto max-w-7xl lg:px-8">
|
||||||
|
<!-- Page Header -->
|
||||||
|
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between mb-8">
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||||
|
<i class="fas fa-bell w-6 h-6 mr-2 text-blue-500 dark:text-blue-400"></i>
|
||||||
|
Notifications
|
||||||
|
</h1>
|
||||||
|
<div class="text-sm text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-gray-800 rounded-lg px-4 py-2 inline-flex items-center">
|
||||||
|
<i class="fas fa-info-circle mr-2"></i>
|
||||||
|
<span class="font-medium">Total: { fmt.Sprint(data.TotalCount) } notifications</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Controls and Filters -->
|
||||||
|
<div class="mb-6 p-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:bg-gray-800 dark:border-gray-700">
|
||||||
|
<div class="flex flex-col sm:flex-row sm:items-center gap-4">
|
||||||
|
<div class="ml-auto">
|
||||||
|
<label class="inline-flex items-center text-sm text-gray-700 dark:text-gray-300">
|
||||||
|
Show
|
||||||
|
<select
|
||||||
|
id="perPage"
|
||||||
|
name="perPage"
|
||||||
|
class="mx-2 bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
hx-get={ fmt.Sprintf("/notifications?page=1&perPage=%s", "${this.value}") }
|
||||||
|
hx-target="body"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
hx-trigger="change"
|
||||||
|
>
|
||||||
|
<option value="10" selected?={ data.PerPage == 10 }>10</option>
|
||||||
|
<option value="25" selected?={ data.PerPage == 25 }>25</option>
|
||||||
|
<option value="50" selected?={ data.PerPage == 50 }>50</option>
|
||||||
|
<option value="100" selected?={ data.PerPage == 100 }>100</option>
|
||||||
|
</select>
|
||||||
|
entries
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href="/notifications/mark-all-read"
|
||||||
|
hx-post="/notifications/mark-all-read"
|
||||||
|
hx-swap="none"
|
||||||
|
hx-target="#notification-bell"
|
||||||
|
class="inline-flex items-center py-2.5 px-4 text-sm font-medium text-white bg-blue-700 rounded-lg border border-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800"
|
||||||
|
>
|
||||||
|
<i class="fas fa-check-double w-4 h-4 mr-2"></i>
|
||||||
|
Mark all as read
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main Content -->
|
||||||
|
if len(data.Notifications) == 0 {
|
||||||
|
<div class="p-8 text-center bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700">
|
||||||
|
<div class="flex justify-center mb-4">
|
||||||
|
<span class="inline-flex items-center justify-center w-16 h-16 rounded-full bg-gray-100 dark:bg-gray-700">
|
||||||
|
<i class="fas fa-bell-slash text-gray-400 dark:text-gray-500 text-3xl"></i>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-lg font-medium text-gray-900 dark:text-white">No notifications</h3>
|
||||||
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">You don't have any notifications yet.</p>
|
||||||
|
</div>
|
||||||
|
} else {
|
||||||
|
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 overflow-hidden">
|
||||||
|
<ul class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
for _, notification := range data.Notifications {
|
||||||
|
<li id={ "notification-" + fmt.Sprintf("%d", notification.ID) }
|
||||||
|
class={ templ.KV("bg-blue-50 dark:bg-blue-900/20", !notification.IsRead) }>
|
||||||
|
<div class="block transition-colors px-4 py-4 sm:px-6">
|
||||||
|
<div class="flex items-start">
|
||||||
|
<div class="flex-shrink-0 mr-4">
|
||||||
|
<div class={ "w-12 h-12 rounded-full flex items-center justify-center", GetNotificationBgColor(notification.Type) }>
|
||||||
|
<i class={ GetNotificationIcon(notification.Type) }></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="flex justify-between items-start">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-base font-medium text-gray-900 dark:text-white">{ notification.Title }</h3>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">{ notification.Message }</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
<span>{ FormatNotificationTime(notification.CreatedAt) }</span>
|
||||||
|
if !notification.IsRead {
|
||||||
|
<button
|
||||||
|
hx-post={ "/notifications/" + fmt.Sprintf("%d", notification.ID) + "/read" }
|
||||||
|
hx-target={ "#notification-" + fmt.Sprintf("%d", notification.ID) }
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
class="ml-4 text-blue-700 bg-blue-100 hover:bg-blue-200 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-3 py-1.5 text-center inline-flex items-center dark:text-blue-400 dark:bg-blue-900/30 dark:hover:bg-blue-900/40 dark:focus:ring-blue-800"
|
||||||
|
>
|
||||||
|
<i class="fas fa-check w-3.5 h-3.5 mr-1.5"></i>
|
||||||
|
Mark as read
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2">
|
||||||
|
<a
|
||||||
|
href={ templ.SafeURL(notification.Link) }
|
||||||
|
class="text-blue-700 bg-blue-100 hover:bg-blue-200 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-3 py-1.5 text-center inline-flex items-center dark:text-blue-400 dark:bg-blue-900/30 dark:hover:bg-blue-900/40 dark:focus:ring-blue-800">
|
||||||
|
<i class="fas fa-eye w-3.5 h-3.5 mr-1.5"></i>
|
||||||
|
View details
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
if data.TotalPages > 1 {
|
||||||
|
<div class="mt-6 flex flex-col sm:flex-row justify-between items-center py-4 px-4 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<!-- Mobile pagination -->
|
||||||
|
<div class="flex-1 flex justify-between gap-4 sm:hidden w-full mb-4 sm:mb-0">
|
||||||
|
if data.CurrentPage > 1 {
|
||||||
|
<a
|
||||||
|
href={ templ.SafeURL("/notifications?page=" + strconv.Itoa(data.CurrentPage-1) + "&perPage=" + strconv.Itoa(data.PerPage)) }
|
||||||
|
hx-get={ "/notifications?page=" + strconv.Itoa(data.CurrentPage-1) + "&perPage=" + strconv.Itoa(data.PerPage) }
|
||||||
|
hx-target="body"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
class="w-full inline-flex justify-center items-center px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-100 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400 dark:hover:bg-gray-700"
|
||||||
|
>
|
||||||
|
<i class="fas fa-chevron-left mr-1"></i> Previous
|
||||||
|
</a>
|
||||||
|
} else {
|
||||||
|
<span class="w-full inline-flex justify-center items-center px-4 py-2 text-sm font-medium text-gray-400 bg-gray-100 border border-gray-300 rounded-lg dark:bg-gray-800 dark:border-gray-600 dark:text-gray-600 cursor-not-allowed">
|
||||||
|
<i class="fas fa-chevron-left mr-1"></i> Previous
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
if data.CurrentPage < data.TotalPages {
|
||||||
|
<a
|
||||||
|
href={ templ.SafeURL("/notifications?page=" + strconv.Itoa(data.CurrentPage+1) + "&perPage=" + strconv.Itoa(data.PerPage)) }
|
||||||
|
hx-get={ "/notifications?page=" + strconv.Itoa(data.CurrentPage+1) + "&perPage=" + strconv.Itoa(data.PerPage) }
|
||||||
|
hx-target="body"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
class="w-full inline-flex justify-center items-center px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-100 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400 dark:hover:bg-gray-700"
|
||||||
|
>
|
||||||
|
Next <i class="fas fa-chevron-right ml-1"></i>
|
||||||
|
</a>
|
||||||
|
} else {
|
||||||
|
<span class="w-full inline-flex justify-center items-center px-4 py-2 text-sm font-medium text-gray-400 bg-gray-100 border border-gray-300 rounded-lg dark:bg-gray-800 dark:border-gray-600 dark:text-gray-600 cursor-not-allowed">
|
||||||
|
Next <i class="fas fa-chevron-right ml-1"></i>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Desktop pagination -->
|
||||||
|
<div class="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-700 dark:text-gray-300">
|
||||||
|
Showing
|
||||||
|
<span class="font-medium">{ fmt.Sprint((data.CurrentPage-1)*data.PerPage + 1) }</span>
|
||||||
|
to
|
||||||
|
<span class="font-medium">{ fmt.Sprint(minInt((data.CurrentPage)*data.PerPage, data.TotalCount)) }</span>
|
||||||
|
of
|
||||||
|
<span class="font-medium">{ fmt.Sprint(data.TotalCount) }</span>
|
||||||
|
notifications
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<nav class="isolate inline-flex -space-x-px rounded-md shadow-sm" aria-label="Pagination">
|
||||||
|
<!-- First page -->
|
||||||
|
<a
|
||||||
|
href={ templ.SafeURL("/notifications?page=1&perPage=" + strconv.Itoa(data.PerPage)) }
|
||||||
|
hx-get={ "/notifications?page=1&perPage=" + strconv.Itoa(data.PerPage) }
|
||||||
|
hx-target="body"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
class={ "relative inline-flex items-center justify-center rounded-l-lg border border-gray-300 bg-white px-3 py-2 text-sm font-medium text-gray-500 hover:bg-gray-50 focus:z-20 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400 dark:hover:bg-gray-700", templ.KV("opacity-50 pointer-events-none", data.CurrentPage == 1) }
|
||||||
|
>
|
||||||
|
<span class="sr-only">First</span>
|
||||||
|
<i class="fas fa-angle-double-left text-xs"></i>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<!-- Previous page -->
|
||||||
|
<a
|
||||||
|
href={ templ.SafeURL("/notifications?page=" + strconv.Itoa(maxInt(1, data.CurrentPage-1)) + "&perPage=" + strconv.Itoa(data.PerPage)) }
|
||||||
|
hx-get={ "/notifications?page=" + strconv.Itoa(maxInt(1, data.CurrentPage-1)) + "&perPage=" + strconv.Itoa(data.PerPage) }
|
||||||
|
hx-target="body"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
class={ "relative inline-flex items-center justify-center border border-gray-300 bg-white px-3 py-2 text-sm font-medium text-gray-500 hover:bg-gray-50 focus:z-20 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400 dark:hover:bg-gray-700", templ.KV("opacity-50 pointer-events-none", data.CurrentPage == 1) }
|
||||||
|
>
|
||||||
|
<span class="sr-only">Previous</span>
|
||||||
|
<i class="fas fa-angle-left text-xs"></i>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<!-- Page numbers -->
|
||||||
|
@notificationPageNumbers(data.CurrentPage, data.TotalPages, data.PerPage)
|
||||||
|
|
||||||
|
<!-- Next page -->
|
||||||
|
<a
|
||||||
|
href={ templ.SafeURL("/notifications?page=" + strconv.Itoa(minInt(data.TotalPages, data.CurrentPage+1)) + "&perPage=" + strconv.Itoa(data.PerPage)) }
|
||||||
|
hx-get={ "/notifications?page=" + strconv.Itoa(minInt(data.TotalPages, data.CurrentPage+1)) + "&perPage=" + strconv.Itoa(data.PerPage) }
|
||||||
|
hx-target="body"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
class={ "relative inline-flex items-center justify-center border border-gray-300 bg-white px-3 py-2 text-sm font-medium text-gray-500 hover:bg-gray-50 focus:z-20 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400 dark:hover:bg-gray-700", templ.KV("opacity-50 pointer-events-none", data.CurrentPage == data.TotalPages) }
|
||||||
|
>
|
||||||
|
<span class="sr-only">Next</span>
|
||||||
|
<i class="fas fa-angle-right text-xs"></i>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<!-- Last page -->
|
||||||
|
<a
|
||||||
|
href={ templ.SafeURL("/notifications?page=" + strconv.Itoa(data.TotalPages) + "&perPage=" + strconv.Itoa(data.PerPage)) }
|
||||||
|
hx-get={ "/notifications?page=" + strconv.Itoa(data.TotalPages) + "&perPage=" + strconv.Itoa(data.PerPage) }
|
||||||
|
hx-target="body"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
class={ "relative inline-flex items-center justify-center rounded-r-lg border border-gray-300 bg-white px-3 py-2 text-sm font-medium text-gray-500 hover:bg-gray-50 focus:z-20 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400 dark:hover:bg-gray-700", templ.KV("opacity-50 pointer-events-none", data.CurrentPage == data.TotalPages) }
|
||||||
|
>
|
||||||
|
<span class="sr-only">Last</span>
|
||||||
|
<i class="fas fa-angle-double-right text-xs"></i>
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Help Section -->
|
||||||
|
<div class="bg-gray-50 dark:bg-gray-800 rounded-lg shadow-sm mt-8 p-4 border border-gray-200 dark:border-gray-700">
|
||||||
|
<div class="flex items-start mb-2">
|
||||||
|
<div class="flex items-center h-5">
|
||||||
|
<i class="fas fa-info-circle w-4 h-4 text-blue-500 dark:text-blue-400 mr-2"></i>
|
||||||
|
</div>
|
||||||
|
<div class="ml-2 text-sm">
|
||||||
|
<p class="text-gray-700 dark:text-gray-300">Notifications keep you updated on important system events and alerts.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-start mt-4">
|
||||||
|
<div class="flex items-center h-5">
|
||||||
|
<i class="fas fa-bell w-4 h-4 text-blue-500 dark:text-blue-400 mr-2"></i>
|
||||||
|
</div>
|
||||||
|
<div class="ml-2 text-sm">
|
||||||
|
<p class="text-gray-700 dark:text-gray-300">Unread notifications are highlighted in blue. Mark them as read to clear the highlight.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-start mt-4">
|
||||||
|
<div class="flex items-center h-5">
|
||||||
|
<i class="fas fa-list-ol w-4 h-4 text-blue-500 dark:text-blue-400 mr-2"></i>
|
||||||
|
</div>
|
||||||
|
<div class="ml-2 text-sm">
|
||||||
|
<p class="text-gray-700 dark:text-gray-300">Adjust how many notifications to display per page using the dropdown menu.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function for pagination math
|
||||||
|
func minInt(a, b int) int {
|
||||||
|
if a < b {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function for pagination math
|
||||||
|
func maxInt(a, b int) int {
|
||||||
|
if a > b {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add the pageNumbers template for consistent pagination layout
|
||||||
|
templ notificationPageNumbers(currentPage int, totalPages int, perPage int) {
|
||||||
|
// Show at most 5 page numbers with the current page in the middle when possible
|
||||||
|
for i := 1; i <= totalPages; i++ {
|
||||||
|
if i == currentPage {
|
||||||
|
<span aria-current="page" class="relative z-10 inline-flex items-center bg-blue-600 px-4 py-2 text-sm font-medium text-white focus:z-20 border border-blue-600 dark:bg-blue-600">
|
||||||
|
{ fmt.Sprint(i) }
|
||||||
|
</span>
|
||||||
|
} else if i == 1 || i == totalPages || (i >= currentPage-2 && i <= currentPage+2) {
|
||||||
|
<a
|
||||||
|
href={ templ.SafeURL("/notifications?page=" + strconv.Itoa(i) + "&perPage=" + strconv.Itoa(perPage)) }
|
||||||
|
hx-get={ "/notifications?page=" + strconv.Itoa(i) + "&perPage=" + strconv.Itoa(perPage) }
|
||||||
|
hx-target="body"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
hx-indicator={ fmt.Sprintf("#page-indicator-%d", i) }
|
||||||
|
class="relative inline-flex items-center px-4 py-2 text-sm font-medium text-gray-500 hover:bg-gray-50 focus:z-20 border border-gray-300 bg-white dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400 dark:hover:bg-gray-700 relative"
|
||||||
|
>
|
||||||
|
{ fmt.Sprint(i) }
|
||||||
|
<span id={ fmt.Sprintf("page-indicator-%d", i) } class="htmx-indicator absolute top-0 right-0 w-4 h-4 -mt-1 -mr-1">
|
||||||
|
<i class="fas fa-spinner fa-spin text-xs"></i>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
} else if i == currentPage-3 || i == currentPage+3 {
|
||||||
|
<span class="relative inline-flex items-center px-4 py-2 text-sm font-medium text-gray-700 border border-gray-300 bg-white dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400">
|
||||||
|
...
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package components
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrorPage renders a generic error page with customizable title, message, and details
|
||||||
|
templ ErrorPage(ctx context.Context, code int, title, message, details string) {
|
||||||
|
@LayoutWithContext(fmt.Sprintf("Error %d - %s", code, title), ctx) {
|
||||||
|
<div class="min-h-screen flex flex-col items-center justify-center px-4 sm:px-6 lg:px-8">
|
||||||
|
<div class="w-full max-w-lg bg-white dark:bg-gray-800 shadow-lg rounded-lg overflow-hidden">
|
||||||
|
<div class="px-6 py-8">
|
||||||
|
<div class="text-center">
|
||||||
|
if code == 404 {
|
||||||
|
<div class="mx-auto flex items-center justify-center h-24 w-24 rounded-full bg-blue-100 dark:bg-blue-900">
|
||||||
|
<svg class="h-14 w-14 text-blue-600 dark:text-blue-300" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064"></path>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
} else if code == 401 {
|
||||||
|
<div class="mx-auto flex items-center justify-center h-24 w-24 rounded-full bg-yellow-100 dark:bg-yellow-900">
|
||||||
|
<svg class="h-14 w-14 text-yellow-600 dark:text-yellow-300" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
} else if code == 500 {
|
||||||
|
<div class="mx-auto flex items-center justify-center h-24 w-24 rounded-full bg-red-100 dark:bg-red-900">
|
||||||
|
<svg class="h-14 w-14 text-red-600 dark:text-red-300" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
} else {
|
||||||
|
<div class="mx-auto flex items-center justify-center h-24 w-24 rounded-full bg-gray-100 dark:bg-gray-700">
|
||||||
|
<svg class="h-14 w-14 text-gray-600 dark:text-gray-300" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<h1 class="mt-6 text-3xl font-extrabold text-gray-900 dark:text-white">
|
||||||
|
{ fmt.Sprintf("%d - %s", code, title) }
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p class="mt-3 text-lg text-gray-500 dark:text-gray-400">
|
||||||
|
{ message }
|
||||||
|
</p>
|
||||||
|
|
||||||
|
if details != "" {
|
||||||
|
<div class="mt-4 p-4 rounded-md bg-gray-50 dark:bg-gray-700 overflow-auto">
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-300 font-mono">
|
||||||
|
{ details }
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="px-6 py-4 bg-gray-50 dark:bg-gray-700 border-t border-gray-200 dark:border-gray-600 flex justify-between">
|
||||||
|
<a href="/dashboard" class="text-sm font-medium text-blue-600 dark:text-blue-400 hover:text-blue-500 dark:hover:text-blue-300">
|
||||||
|
← Go to Dashboard
|
||||||
|
</a>
|
||||||
|
<a href="/" class="text-sm font-medium text-blue-600 dark:text-blue-400 hover:text-blue-500 dark:hover:text-blue-300">
|
||||||
|
Go to Home →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NotFoundError renders a 404 Not Found error page
|
||||||
|
templ NotFoundError(ctx context.Context) {
|
||||||
|
@ErrorPage(ctx, 404, "Page Not Found", "The page you're looking for doesn't exist or has been moved.", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnauthorizedError renders a 401 Unauthorized error page
|
||||||
|
templ UnauthorizedError(ctx context.Context) {
|
||||||
|
@ErrorPage(ctx, 401, "Unauthorized", "You don't have permission to access this page.", "Please log in or contact your administrator if you believe this is a mistake.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServerError renders a 500 Internal Server Error page
|
||||||
|
templ ServerError(ctx context.Context, details string) {
|
||||||
|
@ErrorPage(ctx, 500, "Server Error", "Something went wrong on our end.", details)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenericError renders a generic error page
|
||||||
|
templ GenericError(ctx context.Context, title, message, details string) {
|
||||||
|
@ErrorPage(ctx, 0, title, message, details)
|
||||||
|
}
|
||||||
+920
-703
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,256 @@
|
|||||||
|
package details
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"github.com/starfleetcptn/gomft/components"
|
||||||
|
"github.com/starfleetcptn/gomft/components/file_metadata"
|
||||||
|
"github.com/starfleetcptn/gomft/components/file_metadata/dialog" // Import dialog package
|
||||||
|
"github.com/starfleetcptn/gomft/components/file_metadata/utils" // Import utils package
|
||||||
|
)
|
||||||
|
|
||||||
|
// FileMetadataDetails renders the details view for a file metadata, matching original structure
|
||||||
|
templ FileMetadataDetails(ctx context.Context, data file_metadata.FileMetadataDetailsData) {
|
||||||
|
@components.LayoutWithContext("File Details", ctx) {
|
||||||
|
<!-- Status and Error Messages -->
|
||||||
|
<div id="toast-container" class="fixed top-5 right-5 z-50 flex flex-col gap-2"></div>
|
||||||
|
|
||||||
|
@utils.FileMetadataJS() // Include JS for toasts, etc.
|
||||||
|
|
||||||
|
<div id="file-details-container" style="min-height: 100vh;" class="bg-gray-50 dark:bg-gray-900">
|
||||||
|
<div class="pb-8 w-full">
|
||||||
|
<div class="mb-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||||
|
<i class="fas fa-file-alt w-6 h-6 mr-2 text-blue-500"></i>
|
||||||
|
File Details: { data.File.FileName }
|
||||||
|
</h1>
|
||||||
|
<a href="/files" class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800">
|
||||||
|
<i class="fas fa-arrow-left mr-2"></i> Back to Files
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white border border-gray-200 rounded-lg shadow dark:bg-gray-800 dark:border-gray-700 w-full">
|
||||||
|
<!-- Card header -->
|
||||||
|
<div class="p-4 md:p-5 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<h5 class="text-xl font-bold leading-none text-gray-900 dark:text-white">
|
||||||
|
{ data.File.FileName }
|
||||||
|
</h5>
|
||||||
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
File ID: { strconv.FormatUint(uint64(data.File.ID), 10) }
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Card content -->
|
||||||
|
<div class="p-4 md:p-5">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<!-- File Information -->
|
||||||
|
<div>
|
||||||
|
<h6 class="text-lg font-semibold mb-4 text-gray-900 dark:text-white flex items-center">
|
||||||
|
<i class="fas fa-file-alt mr-2 text-gray-500 dark:text-gray-400"></i> File Information
|
||||||
|
</h6>
|
||||||
|
<div class="overflow-x-auto relative shadow-md sm:rounded-lg">
|
||||||
|
<table class="w-full text-sm text-left text-gray-500 dark:text-gray-400">
|
||||||
|
<tbody>
|
||||||
|
<tr class="border-b dark:border-gray-700">
|
||||||
|
<th scope="row" class="py-3 px-4 font-medium text-gray-900 whitespace-nowrap dark:text-white bg-gray-50 dark:bg-gray-800">
|
||||||
|
Filename
|
||||||
|
</th>
|
||||||
|
<td class="py-3 px-4 bg-white dark:bg-gray-800">
|
||||||
|
{ data.File.FileName }
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="border-b dark:border-gray-700">
|
||||||
|
<th scope="row" class="py-3 px-4 font-medium text-gray-900 whitespace-nowrap dark:text-white bg-gray-50 dark:bg-gray-800">
|
||||||
|
Size
|
||||||
|
</th>
|
||||||
|
<td class="py-3 px-4 bg-white dark:bg-gray-800">
|
||||||
|
{ utils.FormatFileSize(data.File.FileSize) }
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="border-b dark:border-gray-700">
|
||||||
|
<th scope="row" class="py-3 px-4 font-medium text-gray-900 whitespace-nowrap dark:text-white bg-gray-50 dark:bg-gray-800">
|
||||||
|
Hash
|
||||||
|
</th>
|
||||||
|
<td class="py-3 px-4 break-all bg-white dark:bg-gray-800">
|
||||||
|
if data.File.FileHash != "" {
|
||||||
|
<span class="font-mono">{ data.File.FileHash }</span>
|
||||||
|
} else {
|
||||||
|
<span class="text-gray-400 dark:text-gray-500 italic">Not available</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="border-b dark:border-gray-700">
|
||||||
|
<th scope="row" class="py-3 px-4 font-medium text-gray-900 whitespace-nowrap dark:text-white bg-gray-50 dark:bg-gray-800">
|
||||||
|
Status
|
||||||
|
</th>
|
||||||
|
<td class="py-3 px-4 bg-white dark:bg-gray-800">
|
||||||
|
<span class={ "text-xs font-medium px-2.5 py-0.5 rounded", utils.GetStatusBadgeClass(data.File.Status) }>
|
||||||
|
{ data.File.Status }
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="border-b dark:border-gray-700">
|
||||||
|
<th scope="row" class="py-3 px-4 font-medium text-gray-900 whitespace-nowrap dark:text-white bg-gray-50 dark:bg-gray-800">
|
||||||
|
Original Path
|
||||||
|
</th>
|
||||||
|
<td class="py-3 px-4 break-all bg-white dark:bg-gray-800">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<i class="fas fa-folder mr-2 text-yellow-500"></i>
|
||||||
|
<span>{ data.File.OriginalPath }</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="border-b dark:border-gray-700">
|
||||||
|
<th scope="row" class="py-3 px-4 font-medium text-gray-900 whitespace-nowrap dark:text-white bg-gray-50 dark:bg-gray-800">
|
||||||
|
Destination Path
|
||||||
|
</th>
|
||||||
|
<td class="py-3 px-4 break-all bg-white dark:bg-gray-800">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<i class="fas fa-folder-open mr-2 text-blue-500"></i>
|
||||||
|
<span>{ data.File.DestinationPath }</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Processing Information -->
|
||||||
|
<div>
|
||||||
|
<h6 class="text-lg font-semibold mb-4 text-gray-900 dark:text-white flex items-center">
|
||||||
|
<i class="fas fa-cogs mr-2 text-gray-500 dark:text-gray-400"></i> Processing Information
|
||||||
|
</h6>
|
||||||
|
<div class="overflow-x-auto relative shadow-md sm:rounded-lg">
|
||||||
|
<table class="w-full text-sm text-left text-gray-500 dark:text-gray-400">
|
||||||
|
<tbody>
|
||||||
|
<tr class="border-b dark:border-gray-700">
|
||||||
|
<th scope="row" class="py-3 px-4 font-medium text-gray-900 whitespace-nowrap dark:text-white bg-gray-50 dark:bg-gray-800">
|
||||||
|
Job
|
||||||
|
</th>
|
||||||
|
<td class="py-3 px-4 bg-white dark:bg-gray-800">
|
||||||
|
<a href={ templ.SafeURL(fmt.Sprintf("/files/job/%d", data.File.JobID)) } class="font-medium text-blue-600 dark:text-blue-500 hover:underline">
|
||||||
|
{ data.File.Job.Name }
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="border-b dark:border-gray-700">
|
||||||
|
<th scope="row" class="py-3 px-4 font-medium text-gray-900 whitespace-nowrap dark:text-white bg-gray-50 dark:bg-gray-800">
|
||||||
|
Processed Time
|
||||||
|
</th>
|
||||||
|
<td class="py-3 px-4 bg-white dark:bg-gray-800">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<i class="far fa-clock mr-2 text-gray-500"></i>
|
||||||
|
<span>{ data.File.ProcessedTime.Format("2006-01-02 15:04:05") }</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="border-b dark:border-gray-700">
|
||||||
|
<th scope="row" class="py-3 px-4 font-medium text-gray-900 whitespace-nowrap dark:text-white bg-gray-50 dark:bg-gray-800">
|
||||||
|
Creation Time
|
||||||
|
</th>
|
||||||
|
<td class="py-3 px-4 bg-white dark:bg-gray-800">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<i class="fas fa-calendar-plus mr-2 text-green-500"></i>
|
||||||
|
<span>{ data.File.CreationTime.Format("2006-01-02 15:04:05") }</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="border-b dark:border-gray-700">
|
||||||
|
<th scope="row" class="py-3 px-4 font-medium text-gray-900 whitespace-nowrap dark:text-white bg-gray-50 dark:bg-gray-800">
|
||||||
|
Modification Time
|
||||||
|
</th>
|
||||||
|
<td class="py-3 px-4 bg-white dark:bg-gray-800">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<i class="fas fa-calendar-alt mr-2 text-purple-500"></i>
|
||||||
|
<span>{ data.File.ModTime.Format("2006-01-02 15:04:05") }</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
if data.File.Status == "error" && data.File.ErrorMessage != "" {
|
||||||
|
<tr class="border-b dark:border-gray-700">
|
||||||
|
<th scope="row" class="py-3 px-4 font-medium text-gray-900 whitespace-nowrap dark:text-white bg-gray-50 dark:bg-gray-800">
|
||||||
|
Error
|
||||||
|
</th>
|
||||||
|
<td class="py-3 px-4 break-all bg-white dark:bg-gray-800">
|
||||||
|
<div class="flex items-start">
|
||||||
|
<i class="fas fa-exclamation-triangle mt-1 mr-2 text-red-500"></i>
|
||||||
|
<span class="text-red-600 dark:text-red-400">{ data.File.ErrorMessage }</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
<tr class="border-b dark:border-gray-700">
|
||||||
|
<th scope="row" class="py-3 px-4 font-medium text-gray-900 whitespace-nowrap dark:text-white bg-gray-50 dark:bg-gray-800">
|
||||||
|
Record Created
|
||||||
|
</th>
|
||||||
|
<td class="py-3 px-4 bg-white dark:bg-gray-800">
|
||||||
|
{ data.File.CreatedAt.Format("2006-01-02 15:04:05") }
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="border-b dark:border-gray-700">
|
||||||
|
<th scope="row" class="py-3 px-4 font-medium text-gray-900 whitespace-nowrap dark:text-white bg-gray-50 dark:bg-gray-800">
|
||||||
|
Record Updated
|
||||||
|
</th>
|
||||||
|
<td class="py-3 px-4 bg-white dark:bg-gray-800">
|
||||||
|
{ data.File.UpdatedAt.Format("2006-01-02 15:04:05") }
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Delete dialog component call -->
|
||||||
|
@dialog.FileMetadataDialog(
|
||||||
|
fmt.Sprintf("delete-file-dialog-%d", data.File.ID),
|
||||||
|
"Delete File Metadata",
|
||||||
|
fmt.Sprintf("Are you sure you want to delete the metadata for '%s'? This cannot be undone.", data.File.FileName),
|
||||||
|
"text-white bg-red-700 hover:bg-red-800 focus:ring-4 focus:ring-red-300 font-medium rounded-lg text-sm px-5 py-2.5 dark:bg-red-600 dark:hover:bg-red-700 focus:outline-none dark:focus:ring-red-800", // Use correct classes
|
||||||
|
"Delete",
|
||||||
|
"delete",
|
||||||
|
data.File.ID,
|
||||||
|
data.File.FileName,
|
||||||
|
"details", // Indicate this is from the details view
|
||||||
|
)
|
||||||
|
|
||||||
|
<!-- Action buttons -->
|
||||||
|
<div class="mt-6 flex flex-wrap justify-end gap-3">
|
||||||
|
<a href="/files" class="py-2.5 px-5 text-sm font-medium text-gray-900 focus:outline-none bg-white rounded-lg border border-gray-200 hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-4 focus:ring-gray-100 dark:focus:ring-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700">
|
||||||
|
<i class="fas fa-list mr-2"></i> Back to Files
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={ templ.ComponentScript{Call: fmt.Sprintf("showModal('delete-file-dialog-%d')", data.File.ID)} }
|
||||||
|
class="text-white bg-red-700 hover:bg-red-800 focus:ring-4 focus:ring-red-300 font-medium rounded-lg text-sm px-5 py-2.5 dark:bg-red-600 dark:hover:bg-red-700 focus:outline-none dark:focus:ring-red-800">
|
||||||
|
<i class="fas fa-trash mr-2"></i> Delete Record
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Set dark background color if in dark mode
|
||||||
|
if (document.documentElement.classList.contains('dark')) {
|
||||||
|
document.getElementById('file-details-container').style.backgroundColor = '#111827';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add event listener for theme changes
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const themeToggle = document.getElementById('theme-toggle');
|
||||||
|
if (themeToggle) {
|
||||||
|
themeToggle.addEventListener('click', function() {
|
||||||
|
setTimeout(function() {
|
||||||
|
const isDark = document.documentElement.classList.contains('dark');
|
||||||
|
document.getElementById('file-details-container').style.backgroundColor = isDark ? '#111827' : 'rgb(249, 250, 251)';
|
||||||
|
}, 50);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package dialog
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"github.com/starfleetcptn/gomft/components/file_metadata/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FileMetadataDialog renders a confirmation dialog for file metadata actions
|
||||||
|
templ FileMetadataDialog(id string, title string, message string, confirmClass string, confirmText string, action string, fileID uint, fileName string, section string) {
|
||||||
|
@utils.FileMetadataJS()
|
||||||
|
<div id={ id } tabindex="-1" aria-hidden="true" class="hidden fixed top-0 right-0 left-0 z-50 justify-center items-center w-full md:inset-0 h-[calc(100%-1rem)] max-h-full">
|
||||||
|
<!-- Backdrop -->
|
||||||
|
<div id={ fmt.Sprintf("%s-backdrop", id) } class="fixed inset-0 bg-gray-900/50 dark:bg-gray-900/80 backdrop-blur-sm"></div>
|
||||||
|
<!-- Modal content -->
|
||||||
|
<div class="relative p-4 w-full max-w-md max-h-full mx-auto">
|
||||||
|
<div class="relative bg-white rounded-lg shadow dark:bg-gray-700">
|
||||||
|
<div class="p-6 text-center">
|
||||||
|
if action == "delete" {
|
||||||
|
<i class="fas fa-trash-alt text-red-400 text-3xl mb-4"></i>
|
||||||
|
} else {
|
||||||
|
<i class="fas fa-exclamation-triangle text-yellow-400 text-3xl mb-4"></i>
|
||||||
|
}
|
||||||
|
<h3 class="mb-5 text-lg font-normal text-gray-500 dark:text-gray-400">{ message }</h3>
|
||||||
|
if section == "list" {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-white font-medium rounded-lg text-sm px-5 py-2.5 text-center me-2 bg-red-600 hover:bg-red-700 focus:ring-4 focus:outline-none focus:ring-red-300 dark:bg-red-500 dark:hover:bg-red-600 dark:focus:ring-red-800"
|
||||||
|
hx-delete={ fmt.Sprintf("/files/%d", fileID) }
|
||||||
|
hx-target={ fmt.Sprintf("#file-row-%d", fileID) }
|
||||||
|
hx-swap="delete"
|
||||||
|
data-file-name={ fileName }
|
||||||
|
data-file-id={ fmt.Sprint(fileID) }
|
||||||
|
id={ fmt.Sprintf("delete-file-btn-%d", fileID) }
|
||||||
|
onclick={ templ.ComponentScript{Call: fmt.Sprintf("triggerFileDelete('%s', %d, '%s')", id, fileID, fileName)} }>
|
||||||
|
{ confirmText }
|
||||||
|
</button>
|
||||||
|
} else {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-white font-medium rounded-lg text-sm px-5 py-2.5 text-center me-2 bg-red-600 hover:bg-red-700 focus:ring-4 focus:outline-none focus:ring-red-300 dark:bg-red-500 dark:hover:bg-red-600 dark:focus:ring-red-800"
|
||||||
|
hx-delete={ fmt.Sprintf("/files/%d", fileID) }
|
||||||
|
hx-redirect="/files"
|
||||||
|
data-file-name={ fileName }
|
||||||
|
data-file-id={ fmt.Sprint(fileID) }
|
||||||
|
id={ fmt.Sprintf("delete-file-btn-%d", fileID) }
|
||||||
|
onclick={ templ.ComponentScript{Call: fmt.Sprintf("triggerFileDelete('%s', %d, '%s')", id, fileID, fileName)} }>
|
||||||
|
{ confirmText }
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
<button type="button" onclick={ templ.ComponentScript{Call: fmt.Sprintf("closeModal('%s')", id)} } class="text-gray-500 bg-white hover:bg-gray-100 focus:ring-4 focus:outline-none focus:ring-gray-200 rounded-lg border border-gray-200 text-sm font-medium px-5 py-2.5 hover:text-gray-900 focus:z-10 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-500 dark:hover:text-white dark:hover:bg-gray-600 dark:focus:ring-gray-600">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
package list
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strconv"
|
||||||
|
"github.com/starfleetcptn/gomft/components/file_metadata"
|
||||||
|
"github.com/starfleetcptn/gomft/components/file_metadata/utils"
|
||||||
|
"github.com/starfleetcptn/gomft/components" // Import the main components package
|
||||||
|
)
|
||||||
|
|
||||||
|
// FileMetadataList renders the list of file metadata
|
||||||
|
templ FileMetadataList(ctx context.Context, data file_metadata.FileMetadataListData) {
|
||||||
|
@components.LayoutWithContext("Files", ctx) { // Call using components package
|
||||||
|
<!-- Status and Error Messages -->
|
||||||
|
<div id="toast-container" class="fixed top-5 right-5 z-50 flex flex-col gap-2"></div>
|
||||||
|
|
||||||
|
@utils.FileMetadataJS() // Use capitalized function name
|
||||||
|
|
||||||
|
<div id="list-container" style="min-height: 100vh;" class="bg-gray-50 dark:bg-gray-900">
|
||||||
|
<div class="pb-8 w-full">
|
||||||
|
<div class="mb-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||||
|
<i class="fas fa-file-alt w-6 h-6 mr-2 text-blue-500"></i>
|
||||||
|
Files
|
||||||
|
</h1>
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<a href="/files/search" class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800">
|
||||||
|
<i class="fas fa-search mr-2"></i> Advanced Search
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filter Form -->
|
||||||
|
<div class="p-4 mb-6 bg-white border border-gray-200 rounded-lg shadow dark:bg-gray-800 dark:border-gray-700 w-full">
|
||||||
|
<h5 class="mb-4 text-lg font-semibold text-gray-900 dark:text-white">Filter Files</h5>
|
||||||
|
<form
|
||||||
|
hx-get="/files/partial"
|
||||||
|
hx-target="#file-list-container"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
hx-indicator="#filter-loading"
|
||||||
|
hx-headers='{"X-HX-Request": "true"}'
|
||||||
|
class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
if data.Job == nil {
|
||||||
|
<div>
|
||||||
|
<label for="job_id" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Job</label>
|
||||||
|
<input type="text" id="job_id" name="job_id" value={ data.Filter.JobID } placeholder="Job ID" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"/>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<div>
|
||||||
|
<label for="status" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Status</label>
|
||||||
|
<select id="status" name="status" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500">
|
||||||
|
<option value="">All Statuses</option>
|
||||||
|
<option value="processed" selected?={ data.Filter.Status == "processed" }>Processed</option>
|
||||||
|
<option value="archived" selected?={ data.Filter.Status == "archived" }>Archived</option>
|
||||||
|
<option value="deleted" selected?={ data.Filter.Status == "deleted" }>Deleted</option>
|
||||||
|
<option value="archived_and_deleted" selected?={ data.Filter.Status == "archived_and_deleted" }>Archived & Deleted</option>
|
||||||
|
<option value="error" selected?={ data.Filter.Status == "error" }>Error</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="filename" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Filename</label>
|
||||||
|
<input type="text" id="filename" name="filename" value={ data.Filter.FileName } placeholder="Filename or partial match"
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" />
|
||||||
|
</div>
|
||||||
|
<div class="md:col-span-3 flex justify-end items-center">
|
||||||
|
<button type="submit" class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800">
|
||||||
|
<i class="fas fa-filter mr-2"></i> Apply Filters
|
||||||
|
</button>
|
||||||
|
<div id="filter-loading" class="htmx-indicator ml-2 flex items-center">
|
||||||
|
<i class="fas fa-circle-notch fa-spin text-blue-600"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div class="bg-white border border-gray-200 rounded-lg shadow dark:bg-gray-800 dark:border-gray-700 w-full">
|
||||||
|
<!-- Card header -->
|
||||||
|
<div class="p-4 md:p-5 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<h5 class="text-xl font-bold leading-none text-gray-900 dark:text-white">
|
||||||
|
File List
|
||||||
|
</h5>
|
||||||
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Showing { strconv.FormatInt(int64((data.Page - 1) * data.Limit + 1), 10) } to { strconv.FormatInt(int64(min(data.Page * data.Limit, int(data.TotalCount))), 10) } of { strconv.FormatInt(data.TotalCount, 10) } files
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Card content -->
|
||||||
|
<div id="file-list-container" class="p-4 md:p-5">
|
||||||
|
@FileMetadataListPartial(ctx, data, "/files/partial", "#file-list-container")
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Set dark background color if in dark mode
|
||||||
|
if (document.documentElement.classList.contains('dark')) {
|
||||||
|
document.getElementById('list-container').style.backgroundColor = '#111827';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add event listener for theme changes
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const themeToggle = document.getElementById('theme-toggle');
|
||||||
|
if (themeToggle) {
|
||||||
|
themeToggle.addEventListener('click', function() {
|
||||||
|
setTimeout(function() {
|
||||||
|
const isDark = document.documentElement.classList.contains('dark');
|
||||||
|
document.getElementById('list-container').style.backgroundColor = isDark ? '#111827' : 'rgb(249, 250, 251)';
|
||||||
|
}, 50);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
package list
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"github.com/starfleetcptn/gomft/components/file_metadata"
|
||||||
|
"github.com/starfleetcptn/gomft/components/file_metadata/dialog"
|
||||||
|
"github.com/starfleetcptn/gomft/components/file_metadata/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Helper function to generate sorting links
|
||||||
|
func sortLink(currentSortBy, currentSortDir, targetSortBy, basePath string, filter file_metadata.FileMetadataFilter, limit int) string {
|
||||||
|
nextSortDir := "asc"
|
||||||
|
if currentSortBy == targetSortBy && currentSortDir == "asc" {
|
||||||
|
nextSortDir = "desc"
|
||||||
|
}
|
||||||
|
|
||||||
|
q := url.Values{}
|
||||||
|
q.Set("page", "1")
|
||||||
|
q.Set("limit", strconv.Itoa(limit))
|
||||||
|
q.Set("sort_by", targetSortBy)
|
||||||
|
q.Set("sort_dir", nextSortDir)
|
||||||
|
if filter.Status != "" {
|
||||||
|
q.Set("status", filter.Status)
|
||||||
|
}
|
||||||
|
if filter.FileName != "" {
|
||||||
|
q.Set("filename", filter.FileName)
|
||||||
|
}
|
||||||
|
if filter.JobID != "" {
|
||||||
|
q.Set("job_id", filter.JobID)
|
||||||
|
}
|
||||||
|
if filter.Hash != "" {
|
||||||
|
q.Set("hash", filter.Hash)
|
||||||
|
}
|
||||||
|
if filter.StartDate != "" {
|
||||||
|
q.Set("start_date", filter.StartDate)
|
||||||
|
}
|
||||||
|
if filter.EndDate != "" {
|
||||||
|
q.Set("end_date", filter.EndDate)
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("%s?%s", basePath, q.Encode())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to get sort icon class
|
||||||
|
func sortIconClass(currentSortBy, currentSortDir, targetSortBy string) string {
|
||||||
|
if currentSortBy == targetSortBy {
|
||||||
|
if currentSortDir == "asc" {
|
||||||
|
return "fas fa-sort-up ml-1"
|
||||||
|
}
|
||||||
|
return "fas fa-sort-down ml-1"
|
||||||
|
}
|
||||||
|
return "fas fa-sort text-gray-400 ml-1"
|
||||||
|
}
|
||||||
|
|
||||||
|
// FileMetadataListPartial renders the list of file metadata in a table format
|
||||||
|
// Added basePath and targetContainerID parameters
|
||||||
|
templ FileMetadataListPartial(ctx context.Context, data file_metadata.FileMetadataListData, basePath string, targetContainerID string) {
|
||||||
|
|
||||||
|
<!-- Container for dynamically generated dialogs -->
|
||||||
|
<div id="dialog-container">
|
||||||
|
for _, file := range data.Files {
|
||||||
|
@dialog.FileMetadataDialog(
|
||||||
|
fmt.Sprintf("delete-file-dialog-%d", file.ID),
|
||||||
|
"Delete File Metadata",
|
||||||
|
fmt.Sprintf("Are you sure you want to delete the metadata for '%s'? This cannot be undone.", file.FileName),
|
||||||
|
"text-white bg-red-700 hover:bg-red-800 focus:ring-4 focus:ring-red-300 font-medium rounded-lg text-sm px-5 py-2.5 dark:bg-red-600 dark:hover:bg-red-700 focus:outline-none dark:focus:ring-red-800",
|
||||||
|
"Delete",
|
||||||
|
"delete",
|
||||||
|
file.ID,
|
||||||
|
file.FileName,
|
||||||
|
"list",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- File List Table -->
|
||||||
|
<div class="relative overflow-x-auto shadow-md sm:rounded-lg">
|
||||||
|
<table class="w-full text-sm text-left text-gray-500 dark:text-gray-400">
|
||||||
|
<thead class="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-700 dark:text-gray-400">
|
||||||
|
<tr>
|
||||||
|
<th scope="col" class="px-6 py-3">
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
hx-get={ sortLink(data.SortBy, data.SortDir, "id", basePath, data.Filter, data.Limit) }
|
||||||
|
hx-target={ targetContainerID }
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
class="flex items-center hover:text-blue-600 dark:hover:text-blue-400"
|
||||||
|
>
|
||||||
|
ID <i class={ sortIconClass(data.SortBy, data.SortDir, "id") }></i>
|
||||||
|
</a>
|
||||||
|
</th>
|
||||||
|
<th scope="col" class="px-6 py-3">
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
hx-get={ sortLink(data.SortBy, data.SortDir, "filename", basePath, data.Filter, data.Limit) }
|
||||||
|
hx-target={ targetContainerID }
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
class="flex items-center hover:text-blue-600 dark:hover:text-blue-400"
|
||||||
|
>
|
||||||
|
Filename <i class={ sortIconClass(data.SortBy, data.SortDir, "filename") }></i>
|
||||||
|
</a>
|
||||||
|
</th>
|
||||||
|
<th scope="col" class="px-6 py-3">
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
hx-get={ sortLink(data.SortBy, data.SortDir, "size", basePath, data.Filter, data.Limit) }
|
||||||
|
hx-target={ targetContainerID }
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
class="flex items-center hover:text-blue-600 dark:hover:text-blue-400"
|
||||||
|
>
|
||||||
|
Size <i class={ sortIconClass(data.SortBy, data.SortDir, "size") }></i>
|
||||||
|
</a>
|
||||||
|
</th>
|
||||||
|
<th scope="col" class="px-6 py-3">
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
hx-get={ sortLink(data.SortBy, data.SortDir, "processed_time", basePath, data.Filter, data.Limit) }
|
||||||
|
hx-target={ targetContainerID }
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
class="flex items-center hover:text-blue-600 dark:hover:text-blue-400"
|
||||||
|
>
|
||||||
|
Processed time <i class={ sortIconClass(data.SortBy, data.SortDir, "processed_time") }></i>
|
||||||
|
</a>
|
||||||
|
</th>
|
||||||
|
<th scope="col" class="px-6 py-3">
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
hx-get={ sortLink(data.SortBy, data.SortDir, "status", basePath, data.Filter, data.Limit) }
|
||||||
|
hx-target={ targetContainerID }
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
class="flex items-center hover:text-blue-600 dark:hover:text-blue-400"
|
||||||
|
>
|
||||||
|
Status <i class={ sortIconClass(data.SortBy, data.SortDir, "status") }></i>
|
||||||
|
</a>
|
||||||
|
</th>
|
||||||
|
if data.Job == nil {
|
||||||
|
<th scope="col" class="px-6 py-3">Job</th>
|
||||||
|
}
|
||||||
|
<th scope="col" class="px-6 py-3">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
for _, file := range data.Files {
|
||||||
|
<tr id={ fmt.Sprintf("file-row-%d", file.ID) } class="bg-white border-b dark:bg-gray-800 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600">
|
||||||
|
<td class="px-6 py-4 font-medium text-gray-900 dark:text-white">
|
||||||
|
{ strconv.FormatUint(uint64(file.ID), 10) }
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 font-medium text-gray-900 whitespace-nowrap dark:text-white">
|
||||||
|
<a href={ templ.SafeURL(fmt.Sprintf("/files/%d", file.ID)) } class="hover:underline">
|
||||||
|
{ file.FileName }
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
{ utils.FormatFileSize(file.FileSize) }
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
{ file.ProcessedTime.Format("2006-01-02 15:04:05") }
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
<span class={ "text-xs font-medium px-2.5 py-0.5 rounded", utils.GetStatusBadgeClass(file.Status) }>
|
||||||
|
{ file.Status }
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
if data.Job == nil {
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
if file.Job.ID > 0 {
|
||||||
|
<a href={ templ.SafeURL(fmt.Sprintf("/files/job/%d", file.Job.ID)) } class="font-medium text-blue-600 dark:text-blue-500 hover:underline">
|
||||||
|
{ file.Job.Name }
|
||||||
|
</a>
|
||||||
|
} else {
|
||||||
|
<span class="text-gray-400 dark:text-gray-500 italic">N/A</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
}
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
<div class="flex space-x-3">
|
||||||
|
<a href={ templ.SafeURL(fmt.Sprintf("/files/%d", file.ID)) } class="font-medium text-blue-600 dark:text-blue-500 hover:underline">
|
||||||
|
<i class="fas fa-eye"></i>
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={ templ.ComponentScript{Call: fmt.Sprintf("showModal('delete-file-dialog-%d')", file.ID)} }
|
||||||
|
data-file-id={ strconv.FormatUint(uint64(file.ID), 10) }
|
||||||
|
data-file-name={ file.FileName }
|
||||||
|
class="font-medium text-red-600 dark:text-red-500 hover:underline">
|
||||||
|
<i class="fas fa-trash"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination with HTMX (Update links to include sorting and targetContainerID) -->
|
||||||
|
if data.TotalPages > 1 {
|
||||||
|
<nav class="flex items-center flex-column flex-wrap md:flex-row justify-between p-4" aria-label="Table navigation">
|
||||||
|
<span class="text-sm font-normal text-gray-500 dark:text-gray-400 mb-4 md:mb-0">
|
||||||
|
Showing <span class="font-semibold text-gray-900 dark:text-white">{ strconv.Itoa((data.Page-1)*data.Limit+1) }-{ strconv.Itoa(func() int {
|
||||||
|
end := data.Page*data.Limit
|
||||||
|
if int64(end) > data.TotalCount {
|
||||||
|
return int(data.TotalCount)
|
||||||
|
}
|
||||||
|
return end
|
||||||
|
}()) }</span> of <span class="font-semibold text-gray-900 dark:text-white">{ strconv.FormatInt(data.TotalCount, 10) }</span>
|
||||||
|
</span>
|
||||||
|
<ul class="inline-flex -space-x-px rtl:space-x-reverse text-sm h-8">
|
||||||
|
<li>
|
||||||
|
if data.Page == 1 {
|
||||||
|
<span class="flex items-center justify-center px-3 h-8 ms-0 leading-tight text-gray-500 bg-white border border-gray-300 rounded-s-lg cursor-not-allowed dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400">
|
||||||
|
Previous
|
||||||
|
</span>
|
||||||
|
} else {
|
||||||
|
<a hx-get={ fmt.Sprintf("%s?page=%d&limit=%d&status=%s&filename=%s&job_id=%s&sort_by=%s&sort_dir=%s", basePath, data.Page - 1, data.Limit, data.Filter.Status, data.Filter.FileName, data.Filter.JobID, data.SortBy, data.SortDir) }
|
||||||
|
hx-target={ targetContainerID }
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
class="flex items-center justify-center px-3 h-8 ms-0 leading-tight text-gray-500 bg-white border border-gray-300 rounded-s-lg hover:bg-gray-100 hover:text-gray-700 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white">
|
||||||
|
Previous
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
</li>
|
||||||
|
|
||||||
|
for i := 1; i <= data.TotalPages; i++ {
|
||||||
|
if i == 1 || i == data.TotalPages || (i >= data.Page-2 && i <= data.Page+2) {
|
||||||
|
<li>
|
||||||
|
if i == data.Page {
|
||||||
|
<span aria-current="page" class="flex items-center justify-center px-3 h-8 text-blue-600 border border-gray-300 bg-blue-50 hover:bg-blue-100 hover:text-blue-700 dark:border-gray-700 dark:bg-gray-700 dark:text-white">
|
||||||
|
{ strconv.Itoa(i) }
|
||||||
|
</span>
|
||||||
|
} else {
|
||||||
|
<a hx-get={ fmt.Sprintf("%s?page=%d&limit=%d&status=%s&filename=%s&job_id=%s&sort_by=%s&sort_dir=%s", basePath, i, data.Limit, data.Filter.Status, data.Filter.FileName, data.Filter.JobID, data.SortBy, data.SortDir) }
|
||||||
|
hx-target={ targetContainerID }
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
class="flex items-center justify-center px-3 h-8 leading-tight text-gray-500 bg-white border border-gray-300 hover:bg-gray-100 hover:text-gray-700 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white">
|
||||||
|
{ strconv.Itoa(i) }
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
</li>
|
||||||
|
} else if (i == 2 && data.Page > 4) || (i == data.TotalPages-1 && data.Page < data.TotalPages-3) {
|
||||||
|
<li>
|
||||||
|
<span class="flex items-center justify-center px-3 h-8 leading-tight text-gray-500 bg-white border border-gray-300 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400">
|
||||||
|
...
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
<li>
|
||||||
|
if data.Page == data.TotalPages {
|
||||||
|
<span class="flex items-center justify-center px-3 h-8 leading-tight text-gray-500 bg-white border border-gray-300 rounded-e-lg cursor-not-allowed dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400">
|
||||||
|
Next
|
||||||
|
</span>
|
||||||
|
} else {
|
||||||
|
<a hx-get={ fmt.Sprintf("%s?page=%d&limit=%d&status=%s&filename=%s&job_id=%s&sort_by=%s&sort_dir=%s", basePath, data.Page + 1, data.Limit, data.Filter.Status, data.Filter.FileName, data.Filter.JobID, data.SortBy, data.SortDir) }
|
||||||
|
hx-target={ targetContainerID }
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
class="flex items-center justify-center px-3 h-8 leading-tight text-gray-500 bg-white border border-gray-300 rounded-e-lg hover:bg-gray-100 hover:text-gray-700 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white">
|
||||||
|
Next
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
package search
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"github.com/starfleetcptn/gomft/components/file_metadata"
|
||||||
|
"github.com/starfleetcptn/gomft/components/file_metadata/utils"
|
||||||
|
"github.com/starfleetcptn/gomft/components" // Import the main components package
|
||||||
|
)
|
||||||
|
|
||||||
|
// FileMetadataSearch renders the search interface for file metadata
|
||||||
|
templ FileMetadataSearch(ctx context.Context, data file_metadata.FileMetadataSearchData) {
|
||||||
|
@components.LayoutWithContext("Search Files", ctx) {
|
||||||
|
<!-- Status and Error Messages -->
|
||||||
|
<div id="toast-container" class="fixed top-5 right-5 z-50 flex flex-col gap-2"></div>
|
||||||
|
|
||||||
|
@utils.FileMetadataJS() // Include JS for toasts, etc.
|
||||||
|
|
||||||
|
<div id="search-container" style="min-height: 100vh;" class="bg-gray-50 dark:bg-gray-900">
|
||||||
|
<div class="pb-8 w-full">
|
||||||
|
<div class="mb-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||||
|
<i class="fas fa-search w-6 h-6 mr-2 text-blue-500"></i>
|
||||||
|
Search Files
|
||||||
|
</h1>
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<a href="/files" class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800">
|
||||||
|
<i class="fas fa-arrow-left mr-2"></i> Back to Files
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Advanced Search Form -->
|
||||||
|
<div class="p-4 mb-6 bg-white border border-gray-200 rounded-lg shadow dark:bg-gray-800 dark:border-gray-700 w-full">
|
||||||
|
<h5 class="mb-4 text-lg font-semibold text-gray-900 dark:text-white">Advanced File Search</h5>
|
||||||
|
<form
|
||||||
|
hx-get="/files/search/partial"
|
||||||
|
hx-target="#search-results-container"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
hx-indicator="#search-form-loading"
|
||||||
|
hx-headers='{"X-HX-Request": "true"}'
|
||||||
|
hx-boost="false"
|
||||||
|
class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label for="job_id" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Job ID</label>
|
||||||
|
<input type="text" id="job_id" name="job_id" value={ data.Filter.JobID } placeholder="Job ID"
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="status" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Status</label>
|
||||||
|
<select id="status" name="status" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500">
|
||||||
|
<option value="">All Statuses</option>
|
||||||
|
<option value="processed" selected?={ data.Filter.Status == "processed" }>Processed</option>
|
||||||
|
<option value="archived" selected?={ data.Filter.Status == "archived" }>Archived</option>
|
||||||
|
<option value="deleted" selected?={ data.Filter.Status == "deleted" }>Deleted</option>
|
||||||
|
<option value="archived_and_deleted" selected?={ data.Filter.Status == "archived_and_deleted" }>Archived & Deleted</option>
|
||||||
|
<option value="error" selected?={ data.Filter.Status == "error" }>Error</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="filename" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Filename</label>
|
||||||
|
<input type="text" id="filename" name="filename" value={ data.Filter.FileName } placeholder="Filename or partial match"
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="hash" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">File Hash</label>
|
||||||
|
<input type="text" id="hash" name="hash" value={ data.Filter.Hash } placeholder="MD5 hash"
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="start_date" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Processed After</label>
|
||||||
|
<input type="date" id="start_date" name="start_date" value={ data.Filter.StartDate }
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="end_date" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Processed Before</label>
|
||||||
|
<input type="date" id="end_date" name="end_date" value={ data.Filter.EndDate }
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" />
|
||||||
|
</div>
|
||||||
|
<div class="md:col-span-2 flex justify-end">
|
||||||
|
<button type="submit" class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800">
|
||||||
|
<i class="fas fa-search mr-2"></i> Search Files
|
||||||
|
</button>
|
||||||
|
<div id="search-form-loading" class="htmx-indicator ml-2 flex items-center">
|
||||||
|
<i class="fas fa-circle-notch fa-spin text-blue-600"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Results Container (Initially empty, populated by HTMX) -->
|
||||||
|
<div id="search-results-container" class="bg-white border border-gray-200 rounded-lg shadow dark:bg-gray-800 dark:border-gray-700 w-full mt-6">
|
||||||
|
<div class="p-4 md:p-5 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<h5 class="text-xl font-bold leading-none text-gray-900 dark:text-white">
|
||||||
|
Search Results
|
||||||
|
</h5>
|
||||||
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Enter search criteria above and click "Search Files".
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="p-4 md:p-5">
|
||||||
|
<!-- Content will be loaded here by HTMX -->
|
||||||
|
<div class="text-center text-gray-500 dark:text-gray-400 py-8">
|
||||||
|
No results yet.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Set dark background color if in dark mode
|
||||||
|
if (document.documentElement.classList.contains('dark')) {
|
||||||
|
document.getElementById('search-container').style.backgroundColor = '#111827';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add event listener for theme changes
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const themeToggle = document.getElementById('theme-toggle');
|
||||||
|
if (themeToggle) {
|
||||||
|
themeToggle.addEventListener('click', function() {
|
||||||
|
setTimeout(function() {
|
||||||
|
const isDark = document.documentElement.classList.contains('dark');
|
||||||
|
document.getElementById('search-container').style.backgroundColor = isDark ? '#111827' : 'rgb(249, 250, 251)';
|
||||||
|
}, 50);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package search
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"github.com/starfleetcptn/gomft/components/file_metadata"
|
||||||
|
"github.com/starfleetcptn/gomft/components/file_metadata/list"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FileMetadataSearchContent renders only the search results table and pagination
|
||||||
|
templ FileMetadataSearchContent(ctx context.Context, data file_metadata.FileMetadataSearchData) {
|
||||||
|
<!-- Search Results -->
|
||||||
|
<div id="search-results">
|
||||||
|
if len(data.Files) > 0 {
|
||||||
|
@list.FileMetadataListPartial(ctx, file_metadata.FileMetadataListData{
|
||||||
|
Files: data.Files,
|
||||||
|
Page: data.Page,
|
||||||
|
Limit: data.Limit,
|
||||||
|
TotalCount: data.TotalCount,
|
||||||
|
TotalPages: data.TotalPages,
|
||||||
|
Filter: data.Filter, // Pass filter data for pagination links
|
||||||
|
SortBy: data.SortBy,
|
||||||
|
SortDir: data.SortDir,
|
||||||
|
}, "/files/search/partial", "#search-results-container") // Pass correct base path and target ID
|
||||||
|
} else {
|
||||||
|
<div class="p-6 text-center text-gray-500 dark:text-gray-400">
|
||||||
|
<svg class="mx-auto mb-4 w-12 h-12 text-gray-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
|
||||||
|
</svg>
|
||||||
|
<p>No files found matching your search criteria.</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package file_metadata
|
||||||
|
|
||||||
|
import "github.com/starfleetcptn/gomft/internal/db"
|
||||||
|
|
||||||
|
// FileMetadataFilter represents filter parameters for file metadata queries
|
||||||
|
type FileMetadataFilter struct {
|
||||||
|
Status string
|
||||||
|
JobID string
|
||||||
|
FileName string
|
||||||
|
Hash string
|
||||||
|
StartDate string
|
||||||
|
EndDate string
|
||||||
|
}
|
||||||
|
|
||||||
|
// FileMetadataListData contains data for the file metadata list template
|
||||||
|
type FileMetadataListData struct {
|
||||||
|
Files []db.FileMetadata
|
||||||
|
TotalCount int64
|
||||||
|
Page int
|
||||||
|
Limit int
|
||||||
|
TotalPages int
|
||||||
|
Job *db.Job // Optional: if viewing files for a specific job
|
||||||
|
Filter FileMetadataFilter
|
||||||
|
SortBy string // Added for sorting
|
||||||
|
SortDir string // Added for sorting ("asc" or "desc")
|
||||||
|
}
|
||||||
|
|
||||||
|
// FileMetadataDetailsData contains data for the file metadata details template
|
||||||
|
type FileMetadataDetailsData struct {
|
||||||
|
File db.FileMetadata
|
||||||
|
}
|
||||||
|
|
||||||
|
// FileMetadataSearchData contains data for the file metadata search template
|
||||||
|
type FileMetadataSearchData struct {
|
||||||
|
Files []db.FileMetadata
|
||||||
|
TotalCount int64
|
||||||
|
Page int
|
||||||
|
Limit int
|
||||||
|
TotalPages int
|
||||||
|
Filter FileMetadataFilter
|
||||||
|
SortBy string // Added for sorting
|
||||||
|
SortDir string // Added for sorting ("asc" or "desc")
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
package utils
|
||||||
|
|
||||||
|
// FileMetadataJS provides common JavaScript functions for file metadata components,
|
||||||
|
// including HTMX event listeners for delete toasts.
|
||||||
|
templ FileMetadataJS() {
|
||||||
|
<script>
|
||||||
|
// Function to create and show a toast (Defined locally for guaranteed availability)
|
||||||
|
function showToast(message, type = 'info') {
|
||||||
|
const toastContainer = document.getElementById('toast-container');
|
||||||
|
if (!toastContainer) {
|
||||||
|
console.error("Toast container not found!"); // Keep this error log
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create toast element
|
||||||
|
const toast = document.createElement('div');
|
||||||
|
toast.id = 'toast-' + type + '-' + Date.now();
|
||||||
|
// Use classes similar to the original file_metadata.templ for consistency
|
||||||
|
toast.className = 'flex items-center w-full max-w-xs p-4 mb-4 rounded-lg shadow text-gray-500 bg-white dark:text-gray-400 dark:bg-gray-800 transform translate-y-16 opacity-0 transition-all duration-300 ease-out';
|
||||||
|
toast.role = 'alert';
|
||||||
|
|
||||||
|
// Set toast content based on type
|
||||||
|
let iconClass;
|
||||||
|
if (type === 'success') {
|
||||||
|
iconClass = 'text-green-500 bg-green-100 dark:bg-green-800 dark:text-green-200';
|
||||||
|
} else if (type === 'error') {
|
||||||
|
iconClass = 'text-red-500 bg-red-100 dark:bg-red-800 dark:text-red-200';
|
||||||
|
} else { // Default to info
|
||||||
|
iconClass = 'text-blue-500 bg-blue-100 dark:bg-blue-800 dark:text-blue-200';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set inner HTML with appropriate icon and message
|
||||||
|
toast.innerHTML = `
|
||||||
|
<div class="inline-flex items-center justify-center flex-shrink-0 w-8 h-8 rounded-lg ${iconClass}">
|
||||||
|
${type === 'success'
|
||||||
|
? '<i class="fas fa-check"></i>'
|
||||||
|
: type === 'error'
|
||||||
|
? '<i class="fas fa-exclamation-circle"></i>'
|
||||||
|
: '<i class="fas fa-info-circle"></i>'}
|
||||||
|
</div>
|
||||||
|
<div class="ml-3 text-sm font-normal">${message}</div>
|
||||||
|
<button type="button" class="ml-auto -mx-1.5 -my-1.5 bg-white text-gray-400 hover:text-gray-900 rounded-lg focus:ring-2 focus:ring-gray-300 p-1.5 hover:bg-gray-100 inline-flex h-8 w-8 dark:text-gray-500 dark:hover:text-white dark:bg-gray-800 dark:hover:bg-gray-700" data-dismiss-target="#${toast.id}" aria-label="Close">
|
||||||
|
<span class="sr-only">Close</span>
|
||||||
|
<i class="fas fa-times"></i>
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Add toast to container
|
||||||
|
toastContainer.appendChild(toast);
|
||||||
|
|
||||||
|
// Trigger animation after a small delay
|
||||||
|
setTimeout(() => {
|
||||||
|
toast.classList.remove('translate-y-16', 'opacity-0');
|
||||||
|
toast.classList.add('translate-y-0', 'opacity-100');
|
||||||
|
}, 10);
|
||||||
|
|
||||||
|
// Add event listener to close button
|
||||||
|
const closeButton = toast.querySelector('button[data-dismiss-target]');
|
||||||
|
closeButton.addEventListener('click', function() {
|
||||||
|
toast.classList.add('opacity-0', 'translate-y-4');
|
||||||
|
setTimeout(() => { toast.remove(); }, 300);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto-remove toast after 5 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
toast.classList.add('opacity-0', 'translate-y-4');
|
||||||
|
setTimeout(() => { toast.remove(); }, 300);
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- HTMX Event Listener for Delete Toasts ---
|
||||||
|
|
||||||
|
// Ensure listener is attached only once using a flag
|
||||||
|
if (!window._gomft_fileMetadataListenerAttached) {
|
||||||
|
document.body.addEventListener('htmx:afterRequest', function(event) {
|
||||||
|
const triggerElement = event.detail.elt;
|
||||||
|
|
||||||
|
// Check if the element that triggered this request was the file delete button from the dialog
|
||||||
|
if (triggerElement && triggerElement.id && triggerElement.id.startsWith('delete-file-btn-')) {
|
||||||
|
// Get the path directly from the element's hx-delete attribute
|
||||||
|
const path = triggerElement.getAttribute('hx-delete');
|
||||||
|
// Ensure requestConfig exists before accessing verb (robustness)
|
||||||
|
const method = event.detail.requestConfig ? event.detail.requestConfig.verb : null;
|
||||||
|
|
||||||
|
// Check if the method was delete and the path from the attribute matches the expected pattern
|
||||||
|
if (method === 'delete' && path && path.match(/^\/files\/\d+$/)) {
|
||||||
|
const fileName = triggerElement.getAttribute('data-file-name') || "Unknown"; // Get filename from the button
|
||||||
|
|
||||||
|
// Call the locally defined showToast function
|
||||||
|
if (event.detail.successful) {
|
||||||
|
showToast(`File "${fileName}" metadata deleted successfully`, 'success');
|
||||||
|
} else {
|
||||||
|
let errorMsg = `Failed to delete file "${fileName}" metadata`;
|
||||||
|
if (event.detail.xhr && event.detail.xhr.responseText) {
|
||||||
|
try {
|
||||||
|
const responseJson = JSON.parse(event.detail.xhr.responseText);
|
||||||
|
errorMsg = responseJson.error ? `Error: ${responseJson.error}` : `Error: ${event.detail.xhr.responseText}`;
|
||||||
|
} catch (e) {
|
||||||
|
errorMsg = `Error: ${event.detail.xhr.responseText}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
showToast(errorMsg, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set the flag to true after attaching the listener
|
||||||
|
window._gomft_fileMetadataListenerAttached = true;
|
||||||
|
console.log("[FileMetadataJS] HTMX afterRequest listener attached."); // Log attachment once
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package utils
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
// GetStatusBadgeClass returns the appropriate CSS class for a file status badge
|
||||||
|
func GetStatusBadgeClass(status string) string {
|
||||||
|
switch status {
|
||||||
|
case "processed":
|
||||||
|
return "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300"
|
||||||
|
case "archived":
|
||||||
|
return "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300"
|
||||||
|
case "deleted":
|
||||||
|
return "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300"
|
||||||
|
case "archived_and_deleted":
|
||||||
|
return "bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-300"
|
||||||
|
case "error":
|
||||||
|
return "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300"
|
||||||
|
default:
|
||||||
|
return "bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatFileSize formats a file size in bytes to a human-readable string
|
||||||
|
func FormatFileSize(size int64) string {
|
||||||
|
if size < 1024 {
|
||||||
|
return fmt.Sprintf("%d B", size)
|
||||||
|
} else if size < 1024*1024 {
|
||||||
|
return fmt.Sprintf("%.2f KB", float64(size)/1024)
|
||||||
|
} else if size < 1024*1024*1024 {
|
||||||
|
return fmt.Sprintf("%.2f MB", float64(size)/(1024*1024))
|
||||||
|
} else {
|
||||||
|
return fmt.Sprintf("%.2f GB", float64(size)/(1024*1024*1024))
|
||||||
|
}
|
||||||
|
}
|
||||||
+183
-203
@@ -6,102 +6,91 @@ import (
|
|||||||
|
|
||||||
templ ForgotPassword(ctx context.Context, errorMessage string, successMessage string) {
|
templ ForgotPassword(ctx context.Context, errorMessage string, successMessage string) {
|
||||||
@LayoutWithContext("Forgot Password", ctx) {
|
@LayoutWithContext("Forgot Password", ctx) {
|
||||||
<div class="min-h-[calc(100vh-4rem)] flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8 bg-secondary-50 dark:bg-secondary-900">
|
<div class="min-h-[calc(100vh-4rem)] flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8 bg-gray-50 dark:bg-gray-900">
|
||||||
<div class="max-w-md w-full">
|
<div class="max-w-md w-full space-y-8">
|
||||||
<div class="card overflow-hidden shadow-lg">
|
<div class="bg-white dark:bg-gray-800 shadow-lg rounded-lg p-8">
|
||||||
<div class="p-8">
|
<div class="text-center mb-8">
|
||||||
<div class="text-center mb-8">
|
<div class="mx-auto mb-4 flex h-20 w-20 items-center justify-center rounded-full bg-primary-100 dark:bg-primary-900">
|
||||||
<div class="inline-flex items-center justify-center w-20 h-20 rounded-full bg-primary-100 dark:bg-primary-900 mb-4">
|
<i class="fas fa-key text-primary-600 dark:text-primary-400 text-3xl"></i>
|
||||||
<i class="fas fa-key text-primary-600 dark:text-primary-400 text-3xl"></i>
|
|
||||||
</div>
|
|
||||||
<h2 class="text-3xl font-bold text-secondary-900 dark:text-secondary-100">Password Reset</h2>
|
|
||||||
<p class="mt-2 text-secondary-600 dark:text-secondary-400">Enter your email to receive a reset link</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
<h2 class="text-3xl font-bold text-gray-900 dark:text-white">Password Reset</h2>
|
||||||
if errorMessage != "" {
|
<p class="mt-2 text-gray-600 dark:text-gray-300">Enter your email to receive a reset link</p>
|
||||||
<div class="bg-red-100 dark:bg-red-900 border border-red-400 dark:border-red-700 text-red-700 dark:text-red-300 px-4 py-3 rounded-lg mb-6" role="alert">
|
|
||||||
<div class="flex items-center">
|
|
||||||
<i class="fas fa-exclamation-circle mr-2"></i>
|
|
||||||
<span class="block sm:inline">{ errorMessage }</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
if successMessage != "" {
|
|
||||||
<div class="bg-green-100 dark:bg-green-900 border border-green-400 dark:border-green-700 text-green-700 dark:text-green-300 px-4 py-3 rounded-lg mb-6" role="alert">
|
|
||||||
<div class="flex items-center">
|
|
||||||
<i class="fas fa-check-circle mr-2"></i>
|
|
||||||
<span class="block sm:inline">{ successMessage }</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<form
|
|
||||||
class="space-y-6"
|
|
||||||
method="POST"
|
|
||||||
action="/forgot-password"
|
|
||||||
x-data="{
|
|
||||||
email: '',
|
|
||||||
loading: false,
|
|
||||||
validate() {
|
|
||||||
return this.email && this.email.includes('@');
|
|
||||||
}
|
|
||||||
}">
|
|
||||||
<div>
|
|
||||||
<label for="email" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Email address</label>
|
|
||||||
<div class="relative">
|
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
|
||||||
<i class="fas fa-envelope text-secondary-400 dark:text-secondary-600"></i>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
id="email"
|
|
||||||
name="email"
|
|
||||||
type="email"
|
|
||||||
required
|
|
||||||
x-model="email"
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
placeholder="you@example.com"/>
|
|
||||||
</div>
|
|
||||||
<p class="mt-2 text-sm text-secondary-500 dark:text-secondary-400">
|
|
||||||
We'll send a password reset link to this email
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="btn-primary w-full flex justify-center py-3"
|
|
||||||
x-bind:disabled="!validate()"
|
|
||||||
@click="loading = true">
|
|
||||||
<span x-show="!loading" class="flex items-center">
|
|
||||||
<i class="fas fa-paper-plane mr-2"></i>
|
|
||||||
Send Reset Link
|
|
||||||
</span>
|
|
||||||
<span x-show="loading" class="flex items-center">
|
|
||||||
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
|
||||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
|
||||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
|
||||||
</svg>
|
|
||||||
Processing...
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="px-8 py-4 bg-secondary-50 dark:bg-secondary-800 border-t border-secondary-200 dark:border-secondary-700 text-center">
|
if errorMessage != "" {
|
||||||
<p class="text-sm text-secondary-600 dark:text-secondary-400">
|
<div class="mb-6 flex items-center rounded-lg bg-red-50 p-4 text-red-800 dark:bg-gray-800 dark:text-red-400" role="alert">
|
||||||
<a href="/login" class="text-primary-600 dark:text-primary-400 hover:text-primary-500 dark:hover:text-primary-300 flex items-center justify-center">
|
<i class="fas fa-exclamation-circle mr-2"></i>
|
||||||
<i class="fas fa-arrow-left mr-2"></i>
|
<span class="[&:not(:first-child)]:ms-2">{ errorMessage }</span>
|
||||||
Back to login
|
</div>
|
||||||
</a>
|
}
|
||||||
</p>
|
|
||||||
|
if successMessage != "" {
|
||||||
|
<div class="mb-6 flex items-center rounded-lg bg-green-50 p-4 text-green-800 dark:bg-gray-800 dark:text-green-400" role="alert">
|
||||||
|
<i class="fas fa-check-circle mr-2"></i>
|
||||||
|
<span class="[&:not(:first-child)]:ms-2">{ successMessage }</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<form
|
||||||
|
class="space-y-6"
|
||||||
|
method="POST"
|
||||||
|
action="/forgot-password"
|
||||||
|
x-data="{
|
||||||
|
email: '',
|
||||||
|
loading: false,
|
||||||
|
validate() {
|
||||||
|
return this.email && this.email.includes('@');
|
||||||
|
}
|
||||||
|
}">
|
||||||
|
<div>
|
||||||
|
<label for="email" class="mb-2 block text-sm font-medium text-gray-900 dark:text-white">Email address</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||||
|
<i class="fas fa-envelope text-gray-500 dark:text-gray-400"></i>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
x-model="email"
|
||||||
|
class="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 pl-10 text-gray-900 focus:border-primary-500 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400 dark:focus:border-primary-500 dark:focus:ring-primary-500"
|
||||||
|
placeholder="you@example.com"/>
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
We'll send a password reset link to this email
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="w-full rounded-lg bg-primary-600 px-5 py-3 text-center text-sm font-medium text-white hover:bg-primary-700 focus:outline-none focus:ring-4 focus:ring-primary-300 dark:bg-primary-600 dark:hover:bg-primary-700 dark:focus:ring-primary-800"
|
||||||
|
x-bind:disabled="!validate()"
|
||||||
|
@click="loading = true">
|
||||||
|
<span x-show="!loading" class="flex items-center justify-center">
|
||||||
|
<i class="fas fa-paper-plane mr-2"></i>
|
||||||
|
Send Reset Link
|
||||||
|
</span>
|
||||||
|
<span x-show="loading" class="flex items-center justify-center">
|
||||||
|
<svg class="me-3 h-5 w-5 animate-spin text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
Processing...
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="mt-6 border-t border-gray-200 px-8 py-4 text-center dark:border-gray-700">
|
||||||
|
<a href="/login" class="inline-flex items-center text-sm font-medium text-primary-600 hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300">
|
||||||
|
<i class="fas fa-arrow-left mr-2"></i>
|
||||||
|
Back to login
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Security Notice -->
|
<div class="text-center">
|
||||||
<div class="mt-8 text-center">
|
<div class="inline-flex items-center text-sm text-gray-600 dark:text-gray-400">
|
||||||
<div class="inline-flex items-center text-sm text-secondary-500 dark:text-secondary-400">
|
|
||||||
<i class="fas fa-shield-alt mr-2 text-primary-500"></i>
|
<i class="fas fa-shield-alt mr-2 text-primary-500"></i>
|
||||||
<span>Secure, encrypted connection</span>
|
<span>Secure, encrypted connection</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -114,125 +103,116 @@ templ ForgotPassword(ctx context.Context, errorMessage string, successMessage st
|
|||||||
// Reset password page for when users click the link from their email
|
// Reset password page for when users click the link from their email
|
||||||
templ ResetPassword(ctx context.Context, token string, errorMessage string) {
|
templ ResetPassword(ctx context.Context, token string, errorMessage string) {
|
||||||
@LayoutWithContext("Reset Password", ctx) {
|
@LayoutWithContext("Reset Password", ctx) {
|
||||||
<div class="min-h-[calc(100vh-4rem)] flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8 bg-secondary-50 dark:bg-secondary-900">
|
<div class="min-h-[calc(100vh-4rem)] flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8 bg-gray-50 dark:bg-gray-900">
|
||||||
<div class="max-w-md w-full">
|
<div class="max-w-md w-full space-y-8">
|
||||||
<div class="card overflow-hidden shadow-lg">
|
<div class="bg-white dark:bg-gray-800 shadow-lg rounded-lg p-8">
|
||||||
<div class="p-8">
|
<div class="text-center mb-8">
|
||||||
<div class="text-center mb-8">
|
<div class="mx-auto mb-4 flex h-20 w-20 items-center justify-center rounded-full bg-primary-100 dark:bg-primary-900">
|
||||||
<div class="inline-flex items-center justify-center w-20 h-20 rounded-full bg-primary-100 dark:bg-primary-900 mb-4">
|
<i class="fas fa-lock-open text-primary-600 dark:text-primary-400 text-3xl"></i>
|
||||||
<i class="fas fa-lock-open text-primary-600 dark:text-primary-400 text-3xl"></i>
|
|
||||||
</div>
|
|
||||||
<h2 class="text-3xl font-bold text-secondary-900 dark:text-secondary-100">Reset Password</h2>
|
|
||||||
<p class="mt-2 text-secondary-600 dark:text-secondary-400">Create a new password for your account</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
<h2 class="text-3xl font-bold text-gray-900 dark:text-white">Reset Password</h2>
|
||||||
if errorMessage != "" {
|
<p class="mt-2 text-gray-600 dark:text-gray-300">Create a new password for your account</p>
|
||||||
<div class="bg-red-100 dark:bg-red-900 border border-red-400 dark:border-red-700 text-red-700 dark:text-red-300 px-4 py-3 rounded-lg mb-6" role="alert">
|
|
||||||
<div class="flex items-center">
|
|
||||||
<i class="fas fa-exclamation-circle mr-2"></i>
|
|
||||||
<span class="block sm:inline">{ errorMessage }</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<form
|
|
||||||
class="space-y-6"
|
|
||||||
method="POST"
|
|
||||||
action="/reset-password"
|
|
||||||
x-data="{
|
|
||||||
password: '',
|
|
||||||
confirmPassword: '',
|
|
||||||
loading: false,
|
|
||||||
validate() {
|
|
||||||
return this.password &&
|
|
||||||
this.password.length >= 8 &&
|
|
||||||
this.password === this.confirmPassword;
|
|
||||||
}
|
|
||||||
}">
|
|
||||||
<input type="hidden" name="token" value={token}/>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label for="password" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">New Password</label>
|
|
||||||
<div class="relative">
|
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
|
||||||
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
id="password"
|
|
||||||
name="password"
|
|
||||||
type="password"
|
|
||||||
required
|
|
||||||
x-model="password"
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
placeholder="••••••••"/>
|
|
||||||
</div>
|
|
||||||
<p class="mt-1 text-sm text-secondary-500 dark:text-secondary-400">
|
|
||||||
Minimum 8 characters
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label for="confirm-password" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Confirm New Password</label>
|
|
||||||
<div class="relative">
|
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
|
||||||
<i class="fas fa-lock text-secondary-400 dark:text-secondary-600"></i>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
id="confirm-password"
|
|
||||||
name="confirm-password"
|
|
||||||
type="password"
|
|
||||||
required
|
|
||||||
x-model="confirmPassword"
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
placeholder="••••••••"/>
|
|
||||||
</div>
|
|
||||||
<p class="mt-1 text-sm"
|
|
||||||
x-bind:class="{'text-red-500': confirmPassword && password !== confirmPassword, 'text-secondary-500 dark:text-secondary-400': !confirmPassword || password === confirmPassword}">
|
|
||||||
<span x-show="!confirmPassword || password === confirmPassword">
|
|
||||||
Passwords must match
|
|
||||||
</span>
|
|
||||||
<span x-show="confirmPassword && password !== confirmPassword">
|
|
||||||
<i class="fas fa-exclamation-triangle mr-1"></i>
|
|
||||||
Passwords do not match
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="btn-primary w-full flex justify-center py-3"
|
|
||||||
x-bind:disabled="!validate()"
|
|
||||||
@click="loading = true">
|
|
||||||
<span x-show="!loading" class="flex items-center">
|
|
||||||
<i class="fas fa-check-circle mr-2"></i>
|
|
||||||
Reset Password
|
|
||||||
</span>
|
|
||||||
<span x-show="loading" class="flex items-center">
|
|
||||||
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
|
||||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
|
||||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
|
||||||
</svg>
|
|
||||||
Processing...
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="px-8 py-4 bg-secondary-50 dark:bg-secondary-800 border-t border-secondary-200 dark:border-secondary-700 text-center">
|
if errorMessage != "" {
|
||||||
<p class="text-sm text-secondary-600 dark:text-secondary-400">
|
<div class="mb-6 flex items-center rounded-lg bg-red-50 p-4 text-red-800 dark:bg-gray-800 dark:text-red-400" role="alert">
|
||||||
<a href="/login" class="text-primary-600 dark:text-primary-400 hover:text-primary-500 dark:hover:text-primary-300 flex items-center justify-center">
|
<i class="fas fa-exclamation-circle mr-2"></i>
|
||||||
<i class="fas fa-arrow-left mr-2"></i>
|
<span class="[&:not(:first-child)]:ms-2">{ errorMessage }</span>
|
||||||
Back to login
|
</div>
|
||||||
</a>
|
}
|
||||||
</p>
|
|
||||||
|
<form
|
||||||
|
class="space-y-6"
|
||||||
|
method="POST"
|
||||||
|
action="/reset-password"
|
||||||
|
x-data="{
|
||||||
|
password: '',
|
||||||
|
confirmPassword: '',
|
||||||
|
loading: false,
|
||||||
|
validate() {
|
||||||
|
return this.password &&
|
||||||
|
this.password.length >= 8 &&
|
||||||
|
this.password === this.confirmPassword;
|
||||||
|
}
|
||||||
|
}">
|
||||||
|
<input type="hidden" name="token" value={token}/>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="password" class="mb-2 block text-sm font-medium text-gray-900 dark:text-white">New Password</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||||
|
<i class="fas fa-key text-gray-500 dark:text-gray-400"></i>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
x-model="password"
|
||||||
|
class="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 pl-10 text-gray-900 focus:border-primary-500 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400 dark:focus:border-primary-500 dark:focus:ring-primary-500"
|
||||||
|
placeholder="••••••••"/>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Minimum 8 characters
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="confirm-password" class="mb-2 block text-sm font-medium text-gray-900 dark:text-white">Confirm New Password</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||||
|
<i class="fas fa-lock text-gray-500 dark:text-gray-400"></i>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
id="confirm-password"
|
||||||
|
name="confirm-password"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
x-model="confirmPassword"
|
||||||
|
class="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 pl-10 text-gray-900 focus:border-primary-500 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400 dark:focus:border-primary-500 dark:focus:ring-primary-500"
|
||||||
|
placeholder="••••••••"/>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-sm"
|
||||||
|
x-bind:class="{'text-red-500': confirmPassword && password !== confirmPassword, 'text-gray-500 dark:text-gray-400': !confirmPassword || password === confirmPassword}">
|
||||||
|
<span x-show="!confirmPassword || password === confirmPassword">
|
||||||
|
Passwords must match
|
||||||
|
</span>
|
||||||
|
<span x-show="confirmPassword && password !== confirmPassword">
|
||||||
|
<i class="fas fa-exclamation-triangle mr-1"></i>
|
||||||
|
Passwords do not match
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="w-full rounded-lg bg-primary-600 px-5 py-3 text-center text-sm font-medium text-white hover:bg-primary-700 focus:outline-none focus:ring-4 focus:ring-primary-300 dark:bg-primary-600 dark:hover:bg-primary-700 dark:focus:ring-primary-800"
|
||||||
|
x-bind:disabled="!validate()"
|
||||||
|
@click="loading = true">
|
||||||
|
<span x-show="!loading" class="flex items-center justify-center">
|
||||||
|
<i class="fas fa-check-circle mr-2"></i>
|
||||||
|
Reset Password
|
||||||
|
</span>
|
||||||
|
<span x-show="loading" class="flex items-center justify-center">
|
||||||
|
<svg class="me-3 h-5 w-5 animate-spin text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
Processing...
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="mt-6 border-t border-gray-200 px-8 py-4 text-center dark:border-gray-700">
|
||||||
|
<a href="/login" class="inline-flex items-center text-sm font-medium text-primary-600 hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300">
|
||||||
|
<i class="fas fa-arrow-left mr-2"></i>
|
||||||
|
Back to login
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Security Notice -->
|
<div class="text-center">
|
||||||
<div class="mt-8 text-center">
|
<div class="inline-flex items-center text-sm text-gray-600 dark:text-gray-400">
|
||||||
<div class="inline-flex items-center text-sm text-secondary-500 dark:text-secondary-400">
|
|
||||||
<i class="fas fa-shield-alt mr-2 text-primary-500"></i>
|
<i class="fas fa-shield-alt mr-2 text-primary-500"></i>
|
||||||
<span>Secure, encrypted connection</span>
|
<span>Secure, encrypted connection</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Executable
+169
@@ -0,0 +1,169 @@
|
|||||||
|
package components
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GDriveHeadlessAuthData contains data needed for rendering the headless auth page
|
||||||
|
type GDriveHeadlessAuthData struct {
|
||||||
|
AuthCommand string
|
||||||
|
ConfigID string
|
||||||
|
}
|
||||||
|
|
||||||
|
// GDriveHeadlessAuth renders the headless authentication page for Google Drive/Photos
|
||||||
|
templ GDriveHeadlessAuth(ctx context.Context, data GDriveHeadlessAuthData) {
|
||||||
|
// Force the layout to display as authenticated content
|
||||||
|
@LayoutWithContext("Google Authentication - Headless Mode", ctx) {
|
||||||
|
<style>
|
||||||
|
/* Ensure proper styling for the headless auth page */
|
||||||
|
body.dark .auth-page {
|
||||||
|
background-color: #111827 !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div id="auth-container" class="auth-page w-full pb-8 bg-gray-50 dark:bg-gray-900" style="min-height: 100vh; background-color: rgb(249, 250, 251);">
|
||||||
|
<div class="max-w-4xl mx-auto">
|
||||||
|
<div class="mb-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||||
|
<i class="fas fa-key w-6 h-6 mr-2 text-blue-500 dark:text-blue-400"></i>
|
||||||
|
Headless Google Authentication
|
||||||
|
</h1>
|
||||||
|
<a href="/configs" class="flex items-center justify-center text-gray-700 bg-gray-100 hover:bg-gray-200 focus:ring-4 focus:ring-gray-300 font-medium rounded-lg px-5 py-2.5 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600 focus:outline-none dark:focus:ring-gray-700">
|
||||||
|
<i class="fas fa-arrow-left w-4 h-4 mr-2"></i>
|
||||||
|
Back to Configurations
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 p-6">
|
||||||
|
<div class="bg-blue-50 dark:bg-blue-900/30 border-l-4 border-blue-500 p-4 mb-6">
|
||||||
|
<div class="flex">
|
||||||
|
<div class="flex-shrink-0 mt-0.5">
|
||||||
|
<i class="fas fa-info-circle h-5 w-5 text-blue-500"></i>
|
||||||
|
</div>
|
||||||
|
<div class="ml-3">
|
||||||
|
<p class="text-sm text-blue-700 dark:text-blue-300">
|
||||||
|
You need to authenticate with Google using a web browser. Since you're running GoMFT behind a reverse proxy or in a headless environment, you'll need to complete authentication on a machine with a web browser.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-8">
|
||||||
|
<h2 class="text-lg font-medium mb-3 text-gray-900 dark:text-white">Step 1: Run the following command on a machine with a web browser</h2>
|
||||||
|
<div class="relative mb-4">
|
||||||
|
<pre id="auth-command-text" class="bg-gray-50 dark:bg-gray-900 rounded-md p-4 overflow-x-auto text-sm font-mono">{ data.AuthCommand }</pre>
|
||||||
|
<button id="copy-command" class="absolute top-2 right-2 bg-gray-200 dark:bg-gray-700 p-1.5 rounded hover:bg-gray-300 dark:hover:bg-gray-600" title="Copy to clipboard">
|
||||||
|
<i class="fas fa-copy h-5 w-5 text-gray-700 dark:text-gray-300"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 class="text-md font-medium mb-2 text-gray-900 dark:text-white">What this command does:</h3>
|
||||||
|
<ul class="list-disc ml-6 text-sm text-gray-700 dark:text-gray-300 space-y-1">
|
||||||
|
<li>Opens a browser window on the machine where you run it</li>
|
||||||
|
<li>Allows you to authenticate with Google</li>
|
||||||
|
<li>Generates an authentication token</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-6">
|
||||||
|
<h2 class="text-lg font-medium mb-3 text-gray-900 dark:text-white">Step 2: Paste the authentication token below</h2>
|
||||||
|
<p class="text-sm text-gray-700 dark:text-gray-300 mb-4">
|
||||||
|
After completing authentication in the browser, you'll receive a token. Copy and paste that token here:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form action="/configs/gdrive-headless-token" method="POST" class="space-y-4">
|
||||||
|
<input type="hidden" name="config_id" value={ data.ConfigID } />
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="auth_token" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Authentication Token</label>
|
||||||
|
<textarea
|
||||||
|
id="auth_token"
|
||||||
|
name="auth_token"
|
||||||
|
rows="5"
|
||||||
|
class="mt-1 block w-full rounded-md border border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white dark:placeholder-gray-400"
|
||||||
|
placeholder="Paste your authentication token here..."
|
||||||
|
required
|
||||||
|
></textarea>
|
||||||
|
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">The token will look like a long JSON string containing access credentials.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-end mt-6">
|
||||||
|
<a href="/configs" class="mr-4 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-gray-500 dark:hover:text-gray-400">
|
||||||
|
Cancel
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
Submit Token
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Help Section -->
|
||||||
|
<div class="bg-gray-50 dark:bg-gray-800 rounded-lg shadow-sm mt-8 p-4 border border-gray-200 dark:border-gray-700">
|
||||||
|
<div class="flex items-start">
|
||||||
|
<div class="flex items-center h-5">
|
||||||
|
<i class="fas fa-info-circle w-4 h-4 text-blue-500 dark:text-blue-400 mr-2"></i>
|
||||||
|
</div>
|
||||||
|
<div class="ml-2 text-sm">
|
||||||
|
<p class="text-gray-700 dark:text-gray-300">This authentication process is necessary for GoMFT to access your Google Drive or Google Photos account.</p>
|
||||||
|
<p class="mt-1 text-gray-600 dark:text-gray-400">The token is only used for authentication and is stored securely. You'll only need to complete this process once for each configuration.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Set dark background color if in dark mode
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
if (document.documentElement.classList.contains('dark')) {
|
||||||
|
document.getElementById('auth-container').style.backgroundColor = '#111827';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add event listener for theme changes
|
||||||
|
const themeToggle = document.getElementById('theme-toggle');
|
||||||
|
if (themeToggle) {
|
||||||
|
themeToggle.addEventListener('click', function() {
|
||||||
|
setTimeout(function() {
|
||||||
|
const isDark = document.documentElement.classList.contains('dark');
|
||||||
|
document.getElementById('auth-container').style.backgroundColor = isDark ? '#111827' : 'rgb(249, 250, 251)';
|
||||||
|
}, 50);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store the actual command text
|
||||||
|
const actualCommand = document.getElementById('auth-command-text').textContent.trim();
|
||||||
|
|
||||||
|
// Add click handler to copy button
|
||||||
|
document.getElementById('copy-command').addEventListener('click', function() {
|
||||||
|
// Copy the actual command text, not the template variable
|
||||||
|
navigator.clipboard.writeText(actualCommand).then(function() {
|
||||||
|
// Show a success message
|
||||||
|
const button = document.getElementById('copy-command');
|
||||||
|
const originalTitle = button.getAttribute('title');
|
||||||
|
button.setAttribute('title', 'Copied!');
|
||||||
|
|
||||||
|
// Also show visual feedback
|
||||||
|
button.classList.add('bg-green-200', 'dark:bg-green-700');
|
||||||
|
button.classList.remove('bg-gray-200', 'dark:bg-gray-700');
|
||||||
|
|
||||||
|
setTimeout(function() {
|
||||||
|
button.setAttribute('title', originalTitle);
|
||||||
|
button.classList.remove('bg-green-200', 'dark:bg-green-700');
|
||||||
|
button.classList.add('bg-gray-200', 'dark:bg-gray-700');
|
||||||
|
}, 2000);
|
||||||
|
}).catch(function(err) {
|
||||||
|
console.error('Failed to copy text: ', err);
|
||||||
|
alert('Failed to copy command. Please select and copy it manually.');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
}
|
||||||
|
}
|
||||||
+150
-96
@@ -49,66 +49,88 @@ func getConfigNameForHistory(history db.JobHistory, configs map[uint]db.Transfer
|
|||||||
// HistoryContent renders only the content part of the history page for HTMX requests
|
// HistoryContent renders only the content part of the history page for HTMX requests
|
||||||
templ HistoryContent(ctx context.Context, data HistoryData) {
|
templ HistoryContent(ctx context.Context, data HistoryData) {
|
||||||
if len(data.History) == 0 {
|
if len(data.History) == 0 {
|
||||||
<div class="text-center py-12 bg-white dark:bg-secondary-800 rounded-lg shadow">
|
<div class="p-8 text-center bg-white dark:bg-gray-800 rounded-lg shadow">
|
||||||
<div class="inline-block p-4 rounded-full bg-secondary-100 dark:bg-secondary-700 mb-4">
|
<div class="flex justify-center mb-4">
|
||||||
<i class="fas fa-inbox text-secondary-400 dark:text-secondary-500 text-3xl"></i>
|
<span class="inline-flex items-center justify-center w-16 h-16 rounded-full bg-gray-100 dark:bg-gray-700">
|
||||||
|
<i class="fas fa-inbox text-gray-400 dark:text-gray-500 text-3xl"></i>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<h3 class="mt-2 text-lg font-medium text-secondary-900 dark:text-secondary-100">No transfer history</h3>
|
<h3 class="text-lg font-medium text-gray-900 dark:text-gray-100">No transfer history</h3>
|
||||||
if data.SearchTerm != "" {
|
if data.SearchTerm != "" {
|
||||||
<p class="mt-1 text-sm text-secondary-500 dark:text-secondary-400">No results found for "{ data.SearchTerm }". Try a different search term or <button hx-get="/history" hx-target="#history-content" hx-vals={ fmt.Sprintf(`{"pageSize": %d}`, data.PageSize) } class="text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300">clear search</button>.</p>
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
No results found for "{ data.SearchTerm }". Try a different search term or
|
||||||
|
<button
|
||||||
|
hx-get="/history"
|
||||||
|
hx-target="#history-content"
|
||||||
|
hx-vals={ fmt.Sprintf(`{"pageSize": %d}`, data.PageSize) }
|
||||||
|
class="font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
|
||||||
|
>
|
||||||
|
clear search
|
||||||
|
</button>.
|
||||||
|
</p>
|
||||||
} else if data.CurrentPage > 1 {
|
} else if data.CurrentPage > 1 {
|
||||||
<p class="mt-1 text-sm text-secondary-500 dark:text-secondary-400">No more results on this page. <button hx-get="/history" hx-target="#history-content" hx-vals={ fmt.Sprintf(`{"page": 1, "pageSize": %d}`, data.PageSize) } class="text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300">Return to first page</button>.</p>
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
No more results on this page.
|
||||||
|
<button
|
||||||
|
hx-get="/history"
|
||||||
|
hx-target="#history-content"
|
||||||
|
hx-vals={ fmt.Sprintf(`{"page": 1, "pageSize": %d}`, data.PageSize) }
|
||||||
|
class="font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
|
||||||
|
>
|
||||||
|
Return to first page
|
||||||
|
</button>.
|
||||||
|
</p>
|
||||||
} else {
|
} else {
|
||||||
<p class="mt-1 text-sm text-secondary-500 dark:text-secondary-400">Transfer history will appear here once jobs have run.</p>
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Transfer history will appear here once jobs have run.</p>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
} else {
|
} else {
|
||||||
<div class="bg-white dark:bg-secondary-800 shadow overflow-hidden rounded-lg">
|
<div class="bg-white dark:bg-gray-800 shadow rounded-lg overflow-hidden">
|
||||||
<ul role="list" class="divide-y divide-secondary-200 dark:divide-secondary-700">
|
<ul class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
for _, history := range data.History {
|
for _, history := range data.History {
|
||||||
<li class="hover:bg-secondary-50 dark:hover:bg-secondary-750 transition-colors">
|
<li class="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
|
||||||
<div class="px-4 py-4 sm:px-6">
|
<div class="px-4 py-4 sm:px-6">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<p class="text-sm font-medium text-primary-600 dark:text-primary-400 truncate">{ getConfigNameForHistory(history, data.Configs) }</p>
|
<p class="text-sm font-medium text-blue-600 dark:text-blue-400 truncate">{ getConfigNameForHistory(history, data.Configs) }</p>
|
||||||
if history.Status == "completed" {
|
if history.Status == "completed" {
|
||||||
<span class="ml-2 px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-300">
|
<span class="ml-2 px-2.5 py-0.5 inline-flex text-xs font-medium rounded-full bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-300">
|
||||||
Completed
|
Completed
|
||||||
</span>
|
</span>
|
||||||
} else if history.Status == "failed" {
|
} else if history.Status == "failed" {
|
||||||
<span class="ml-2 px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-300">
|
<span class="ml-2 px-2.5 py-0.5 inline-flex text-xs font-medium rounded-full bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-300">
|
||||||
Failed
|
Failed
|
||||||
</span>
|
</span>
|
||||||
} else {
|
} else {
|
||||||
<span class="ml-2 px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-300">
|
<span class="ml-2 px-2.5 py-0.5 inline-flex text-xs font-medium rounded-full bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-300">
|
||||||
{ history.Status }
|
{ history.Status }
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<a href={ templ.SafeURL(fmt.Sprintf("/job-runs/%d", history.ID)) }
|
<a href={ templ.SafeURL(fmt.Sprintf("/job-runs/%d", history.ID)) }
|
||||||
class="inline-flex items-center px-3 py-1.5 border border-secondary-300 dark:border-secondary-600 text-sm leading-5 font-medium rounded-full text-secondary-700 dark:text-secondary-200 bg-white dark:bg-secondary-700 hover:bg-secondary-50 dark:hover:bg-secondary-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 transition-colors">
|
class="text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600 font-medium rounded-lg text-sm px-3 py-1.5 inline-flex items-center border border-gray-300 dark:border-gray-600 focus:ring-4 focus:ring-gray-100 dark:focus:ring-gray-700 transition-colors">
|
||||||
<i class="fas fa-eye mr-1"></i>
|
<i class="fas fa-eye mr-1.5"></i>
|
||||||
View Details
|
View Details
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-2 sm:flex sm:justify-between">
|
<div class="mt-2 sm:flex sm:justify-between">
|
||||||
<div class="sm:flex">
|
<div class="sm:flex">
|
||||||
<p class="flex items-center text-sm text-secondary-500 dark:text-secondary-400">
|
<p class="flex items-center text-sm text-gray-500 dark:text-gray-400">
|
||||||
<i class="fas fa-calendar-alt flex-shrink-0 mr-1.5 h-5 w-5 text-secondary-400 dark:text-secondary-500"></i>
|
<i class="fas fa-calendar-alt mr-1.5 h-5 w-5 text-gray-400 dark:text-gray-500"></i>
|
||||||
Started: { history.StartTime.Format("Jan 02, 2006 15:04:05") }
|
Started: { history.StartTime.Format("Jan 02, 2006 15:04:05") }
|
||||||
</p>
|
</p>
|
||||||
if history.EndTime != nil {
|
if history.EndTime != nil {
|
||||||
<p class="mt-2 flex items-center text-sm text-secondary-500 dark:text-secondary-400 sm:mt-0 sm:ml-6">
|
<p class="mt-2 flex items-center text-sm text-gray-500 dark:text-gray-400 sm:mt-0 sm:ml-6">
|
||||||
<i class="fas fa-clock flex-shrink-0 mr-1.5 h-5 w-5 text-secondary-400 dark:text-secondary-500"></i>
|
<i class="fas fa-clock mr-1.5 h-5 w-5 text-gray-400 dark:text-gray-500"></i>
|
||||||
Duration: { history.EndTime.Sub(history.StartTime).String() }
|
Duration: { history.EndTime.Sub(history.StartTime).String() }
|
||||||
</p>
|
</p>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-2 flex items-center text-sm text-secondary-500 dark:text-secondary-400 sm:mt-0">
|
<div class="mt-2 flex items-center text-sm text-gray-500 dark:text-gray-400 sm:mt-0">
|
||||||
if history.BytesTransferred > 0 {
|
if history.BytesTransferred > 0 {
|
||||||
<i class="fas fa-upload flex-shrink-0 mr-1.5 h-5 w-5 text-secondary-400 dark:text-secondary-500"></i>
|
<i class="fas fa-upload mr-1.5 h-5 w-5 text-gray-400 dark:text-gray-500"></i>
|
||||||
<p>
|
<p>
|
||||||
{ formatBytes(history.BytesTransferred) } transferred
|
{ formatBytes(history.BytesTransferred) } transferred
|
||||||
</p>
|
</p>
|
||||||
@@ -116,9 +138,9 @@ templ HistoryContent(ctx context.Context, data HistoryData) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
if history.ErrorMessage != "" {
|
if history.ErrorMessage != "" {
|
||||||
<div class="mt-2 p-2 bg-red-50 dark:bg-red-900/20 rounded border border-red-200 dark:border-red-800">
|
<div class="mt-2 p-3 rounded-lg border bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800">
|
||||||
<p class="text-sm text-red-600 dark:text-red-400">
|
<p class="text-sm text-red-600 dark:text-red-400">
|
||||||
<i class="fas fa-exclamation-triangle mr-1"></i>
|
<i class="fas fa-exclamation-triangle mr-1.5"></i>
|
||||||
Error: { history.ErrorMessage }
|
Error: { history.ErrorMessage }
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -131,15 +153,17 @@ templ HistoryContent(ctx context.Context, data HistoryData) {
|
|||||||
|
|
||||||
<!-- Pagination -->
|
<!-- Pagination -->
|
||||||
if data.TotalPages > 1 {
|
if data.TotalPages > 1 {
|
||||||
<div class="mt-6 flex flex-col sm:flex-row justify-between items-center py-4 bg-secondary-50 dark:bg-secondary-800 rounded-lg">
|
<div class="mt-6 flex flex-col sm:flex-row justify-between items-center py-4 bg-white dark:bg-gray-800 rounded-lg shadow px-4">
|
||||||
<div class="flex-1 flex justify-between gap-4 sm:hidden w-full px-4 mb-4 sm:mb-0">
|
<!-- Mobile pagination -->
|
||||||
|
<div class="flex-1 flex justify-between gap-4 sm:hidden w-full mb-4 sm:mb-0">
|
||||||
if data.CurrentPage > 1 {
|
if data.CurrentPage > 1 {
|
||||||
<button
|
<button
|
||||||
hx-get="/history"
|
hx-get="/history"
|
||||||
hx-target="#history-content"
|
hx-target="#history-content"
|
||||||
hx-vals={ fmt.Sprintf(`{"page": %d, "pageSize": %d, "search": "%s"}`, data.CurrentPage-1, data.PageSize, data.SearchTerm) }
|
hx-vals={ fmt.Sprintf(`{"page": %d, "pageSize": %d, "search": "%s"}`, data.CurrentPage-1, data.PageSize, data.SearchTerm) }
|
||||||
hx-indicator="#mobile-prev-indicator"
|
hx-indicator="#mobile-prev-indicator"
|
||||||
class="flex-1 h-10 flex items-center justify-center bg-secondary-200 text-secondary-800 hover:bg-secondary-300 rounded dark:bg-secondary-700 dark:text-secondary-200 dark:hover:bg-secondary-600 relative">
|
class="w-full inline-flex justify-center items-center px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-100 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400 dark:hover:bg-gray-700 relative"
|
||||||
|
>
|
||||||
<span class="flex items-center">
|
<span class="flex items-center">
|
||||||
<i class="fas fa-chevron-left mr-1"></i> Previous
|
<i class="fas fa-chevron-left mr-1"></i> Previous
|
||||||
</span>
|
</span>
|
||||||
@@ -148,7 +172,7 @@ templ HistoryContent(ctx context.Context, data HistoryData) {
|
|||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
} else {
|
} else {
|
||||||
<span class="flex-1 h-10 flex items-center justify-center bg-secondary-100 text-secondary-300 rounded dark:bg-secondary-800 dark:text-secondary-600 cursor-not-allowed">
|
<span class="w-full inline-flex justify-center items-center px-4 py-2 text-sm font-medium text-gray-400 bg-gray-100 border border-gray-300 rounded-lg dark:bg-gray-800 dark:border-gray-600 dark:text-gray-600 cursor-not-allowed">
|
||||||
<i class="fas fa-chevron-left mr-1"></i> Previous
|
<i class="fas fa-chevron-left mr-1"></i> Previous
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
@@ -159,7 +183,8 @@ templ HistoryContent(ctx context.Context, data HistoryData) {
|
|||||||
hx-target="#history-content"
|
hx-target="#history-content"
|
||||||
hx-vals={ fmt.Sprintf(`{"page": %d, "pageSize": %d, "search": "%s"}`, data.CurrentPage+1, data.PageSize, data.SearchTerm) }
|
hx-vals={ fmt.Sprintf(`{"page": %d, "pageSize": %d, "search": "%s"}`, data.CurrentPage+1, data.PageSize, data.SearchTerm) }
|
||||||
hx-indicator="#mobile-next-indicator"
|
hx-indicator="#mobile-next-indicator"
|
||||||
class="flex-1 h-10 flex items-center justify-center bg-secondary-200 text-secondary-800 hover:bg-secondary-300 rounded dark:bg-secondary-700 dark:text-secondary-200 dark:hover:bg-secondary-600 relative">
|
class="w-full inline-flex justify-center items-center px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-100 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400 dark:hover:bg-gray-700 relative"
|
||||||
|
>
|
||||||
<span class="flex items-center">
|
<span class="flex items-center">
|
||||||
Next <i class="fas fa-chevron-right ml-1"></i>
|
Next <i class="fas fa-chevron-right ml-1"></i>
|
||||||
</span>
|
</span>
|
||||||
@@ -168,14 +193,16 @@ templ HistoryContent(ctx context.Context, data HistoryData) {
|
|||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
} else {
|
} else {
|
||||||
<span class="flex-1 h-10 flex items-center justify-center bg-secondary-100 text-secondary-300 rounded dark:bg-secondary-800 dark:text-secondary-600 cursor-not-allowed">
|
<span class="w-full inline-flex justify-center items-center px-4 py-2 text-sm font-medium text-gray-400 bg-gray-100 border border-gray-300 rounded-lg dark:bg-gray-800 dark:border-gray-600 dark:text-gray-600 cursor-not-allowed">
|
||||||
Next <i class="fas fa-chevron-right ml-1"></i>
|
Next <i class="fas fa-chevron-right ml-1"></i>
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between px-4">
|
|
||||||
|
<!-- Desktop pagination -->
|
||||||
|
<div class="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm text-secondary-700 dark:text-secondary-300">
|
<p class="text-sm text-gray-700 dark:text-gray-300">
|
||||||
Showing
|
Showing
|
||||||
<span class="font-medium">{ fmt.Sprint((data.CurrentPage-1)*data.PageSize + 1) }</span>
|
<span class="font-medium">{ fmt.Sprint((data.CurrentPage-1)*data.PageSize + 1) }</span>
|
||||||
to
|
to
|
||||||
@@ -185,23 +212,24 @@ templ HistoryContent(ctx context.Context, data HistoryData) {
|
|||||||
results
|
results
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-center">
|
<div>
|
||||||
<nav class="flex gap-2" aria-label="Pagination">
|
<nav class="isolate inline-flex -space-x-px rounded-md shadow-sm" aria-label="Pagination">
|
||||||
if data.CurrentPage > 1 {
|
if data.CurrentPage > 1 {
|
||||||
<button
|
<button
|
||||||
hx-get="/history"
|
hx-get="/history"
|
||||||
hx-target="#history-content"
|
hx-target="#history-content"
|
||||||
hx-vals={ fmt.Sprintf(`{"page": %d, "pageSize": %d, "search": "%s"}`, data.CurrentPage-1, data.PageSize, data.SearchTerm) }
|
hx-vals={ fmt.Sprintf(`{"page": %d, "pageSize": %d, "search": "%s"}`, data.CurrentPage-1, data.PageSize, data.SearchTerm) }
|
||||||
hx-indicator="#prev-indicator"
|
hx-indicator="#prev-indicator"
|
||||||
class="w-10 h-10 flex items-center justify-center bg-secondary-200 text-secondary-800 hover:bg-secondary-300 rounded dark:bg-secondary-700 dark:text-secondary-200 dark:hover:bg-secondary-600 relative">
|
class="relative inline-flex items-center justify-center rounded-l-lg border border-gray-300 bg-white px-3 py-2 text-sm font-medium text-gray-500 hover:bg-gray-50 focus:z-20 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400 dark:hover:bg-gray-700"
|
||||||
<i class="fas fa-chevron-left"></i>
|
>
|
||||||
|
<i class="fas fa-chevron-left text-xs"></i>
|
||||||
<span id="prev-indicator" class="htmx-indicator absolute top-0 right-0 w-4 h-4 -mt-1 -mr-1">
|
<span id="prev-indicator" class="htmx-indicator absolute top-0 right-0 w-4 h-4 -mt-1 -mr-1">
|
||||||
<i class="fas fa-spinner fa-spin text-xs"></i>
|
<i class="fas fa-spinner fa-spin text-xs"></i>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
} else {
|
} else {
|
||||||
<span class="w-10 h-10 flex items-center justify-center bg-secondary-100 text-secondary-300 rounded dark:bg-secondary-800 dark:text-secondary-600 cursor-not-allowed">
|
<span class="relative inline-flex items-center justify-center rounded-l-lg border border-gray-300 bg-gray-100 px-3 py-2 text-sm font-medium text-gray-400 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-600 cursor-not-allowed">
|
||||||
<i class="fas fa-chevron-left"></i>
|
<i class="fas fa-chevron-left text-xs"></i>
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,15 +242,16 @@ templ HistoryContent(ctx context.Context, data HistoryData) {
|
|||||||
hx-target="#history-content"
|
hx-target="#history-content"
|
||||||
hx-vals={ fmt.Sprintf(`{"page": %d, "pageSize": %d, "search": "%s"}`, data.CurrentPage+1, data.PageSize, data.SearchTerm) }
|
hx-vals={ fmt.Sprintf(`{"page": %d, "pageSize": %d, "search": "%s"}`, data.CurrentPage+1, data.PageSize, data.SearchTerm) }
|
||||||
hx-indicator="#next-indicator"
|
hx-indicator="#next-indicator"
|
||||||
class="w-10 h-10 flex items-center justify-center bg-secondary-200 text-secondary-800 hover:bg-secondary-300 rounded dark:bg-secondary-700 dark:text-secondary-200 dark:hover:bg-secondary-600 relative">
|
class="relative inline-flex items-center justify-center rounded-r-lg border border-gray-300 bg-white px-3 py-2 text-sm font-medium text-gray-500 hover:bg-gray-50 focus:z-20 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400 dark:hover:bg-gray-700"
|
||||||
<i class="fas fa-chevron-right"></i>
|
>
|
||||||
|
<i class="fas fa-chevron-right text-xs"></i>
|
||||||
<span id="next-indicator" class="htmx-indicator absolute top-0 right-0 w-4 h-4 -mt-1 -mr-1">
|
<span id="next-indicator" class="htmx-indicator absolute top-0 right-0 w-4 h-4 -mt-1 -mr-1">
|
||||||
<i class="fas fa-spinner fa-spin text-xs"></i>
|
<i class="fas fa-spinner fa-spin text-xs"></i>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
} else {
|
} else {
|
||||||
<span class="w-10 h-10 flex items-center justify-center bg-secondary-100 text-secondary-300 rounded dark:bg-secondary-800 dark:text-secondary-600 cursor-not-allowed">
|
<span class="relative inline-flex items-center justify-center rounded-r-lg border border-gray-300 bg-gray-100 px-3 py-2 text-sm font-medium text-gray-400 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-600 cursor-not-allowed">
|
||||||
<i class="fas fa-chevron-right"></i>
|
<i class="fas fa-chevron-right text-xs"></i>
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
</nav>
|
</nav>
|
||||||
@@ -235,42 +264,45 @@ templ HistoryContent(ctx context.Context, data HistoryData) {
|
|||||||
|
|
||||||
templ History(ctx context.Context, data HistoryData) {
|
templ History(ctx context.Context, data HistoryData) {
|
||||||
@LayoutWithContext("Transfer History", ctx) {
|
@LayoutWithContext("Transfer History", ctx) {
|
||||||
<div id="history-page" class="py-6">
|
<div id="history-container" style="min-height: 100vh;" class="bg-gray-50 dark:bg-gray-900">
|
||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
<div class="pb-8 w-full">
|
||||||
<div class="flex items-center justify-between mb-8">
|
<!-- Page Header -->
|
||||||
<h1 class="text-3xl font-bold text-secondary-900 dark:text-secondary-100">
|
<div class="mb-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||||
<i class="fas fa-history mr-2 text-primary-600 dark:text-primary-400"></i>
|
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||||
|
<i class="fas fa-history w-6 h-6 mr-2 text-blue-500"></i>
|
||||||
Transfer History
|
Transfer History
|
||||||
</h1>
|
</h1>
|
||||||
<div class="text-sm text-secondary-500 dark:text-secondary-400">
|
<div class="text-sm text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-gray-800 rounded-lg px-4 py-2 inline-flex items-center">
|
||||||
|
<i class="fas fa-info-circle mr-2"></i>
|
||||||
<span class="font-medium">Total: { fmt.Sprint(data.Total) } transfers</span>
|
<span class="font-medium">Total: { fmt.Sprint(data.Total) } transfers</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Search and Pagination Controls -->
|
<!-- Search and Pagination Controls -->
|
||||||
<div class="mt-4 flex flex-col sm:flex-row justify-between items-center bg-secondary-50 dark:bg-secondary-800 p-4 rounded-lg mb-6">
|
<div class="mb-6 p-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:bg-gray-800 dark:border-gray-700">
|
||||||
<form hx-get="/history" hx-target="#history-content" hx-indicator="#search-indicator" class="w-full sm:w-auto mb-4 sm:mb-0">
|
<form hx-get="/history" hx-target="#history-content" hx-indicator="#search-indicator" class="flex flex-col sm:flex-row gap-4 items-start sm:items-center">
|
||||||
<div class="flex">
|
<div class="w-full sm:w-auto relative flex-grow">
|
||||||
<div class="relative flex-grow">
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<i class="fas fa-search text-gray-400 dark:text-gray-500"></i>
|
||||||
<i class="fas fa-search text-secondary-400 dark:text-secondary-500"></i>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="search"
|
|
||||||
value={ data.SearchTerm }
|
|
||||||
placeholder="Search by job name or status..."
|
|
||||||
class="block w-full pl-10 pr-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md leading-5 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-secondary-100 placeholder-secondary-500 dark:placeholder-secondary-400 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
|
|
||||||
/>
|
|
||||||
<input type="hidden" name="page" value="1" />
|
|
||||||
<input type="hidden" name="pageSize" value={ fmt.Sprint(data.PageSize) } />
|
|
||||||
</div>
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="search"
|
||||||
|
value={ data.SearchTerm }
|
||||||
|
placeholder="Search by job name or status..."
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full pl-10 pr-3 py-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
/>
|
||||||
|
<input type="hidden" name="page" value="1" />
|
||||||
|
<input type="hidden" name="pageSize" value={ fmt.Sprint(data.PageSize) } />
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
class="ml-3 inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 dark:bg-primary-500 dark:hover:bg-primary-600 transition-colors"
|
class="inline-flex items-center py-2.5 px-4 text-sm font-medium text-white bg-blue-700 rounded-lg border border-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800"
|
||||||
>
|
>
|
||||||
<span>Search</span>
|
<i class="fas fa-search mr-2 -ml-1"></i>
|
||||||
<span id="search-indicator" class="htmx-indicator ml-1">
|
Search
|
||||||
|
<span id="search-indicator" class="htmx-indicator ml-2">
|
||||||
<i class="fas fa-spinner fa-spin"></i>
|
<i class="fas fa-spinner fa-spin"></i>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -280,46 +312,65 @@ templ History(ctx context.Context, data HistoryData) {
|
|||||||
hx-target="#history-content"
|
hx-target="#history-content"
|
||||||
hx-indicator="#clear-indicator"
|
hx-indicator="#clear-indicator"
|
||||||
hx-vals={ fmt.Sprintf(`{"pageSize": %d}`, data.PageSize) }
|
hx-vals={ fmt.Sprintf(`{"pageSize": %d}`, data.PageSize) }
|
||||||
class="ml-2 inline-flex items-center px-4 py-2 border border-secondary-300 dark:border-secondary-600 text-sm font-medium rounded-md text-secondary-700 dark:text-secondary-200 bg-white dark:bg-secondary-700 hover:bg-secondary-50 dark:hover:bg-secondary-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 transition-colors"
|
class="py-2.5 px-5 text-sm font-medium text-gray-900 focus:outline-none bg-white rounded-lg border border-gray-200 hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-4 focus:ring-gray-200 dark:focus:ring-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700 inline-flex items-center"
|
||||||
>
|
>
|
||||||
<i class="fas fa-times mr-1"></i>
|
<i class="fas fa-times mr-2"></i>
|
||||||
Clear
|
Clear
|
||||||
<span id="clear-indicator" class="htmx-indicator ml-1">
|
<span id="clear-indicator" class="htmx-indicator ml-2">
|
||||||
<i class="fas fa-spinner fa-spin"></i>
|
<i class="fas fa-spinner fa-spin"></i>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
<div class="ml-auto">
|
||||||
|
<label class="inline-flex items-center text-sm text-gray-700 dark:text-gray-300">
|
||||||
|
Show
|
||||||
|
<select
|
||||||
|
name="pageSize"
|
||||||
|
hx-get="/history"
|
||||||
|
hx-target="#history-content"
|
||||||
|
hx-include="[name='search']"
|
||||||
|
hx-indicator="#size-indicator"
|
||||||
|
class="mx-2 bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
>
|
||||||
|
<option value="10" selected?={ data.PageSize == 10 }>10</option>
|
||||||
|
<option value="25" selected?={ data.PageSize == 25 }>25</option>
|
||||||
|
<option value="50" selected?={ data.PageSize == 50 }>50</option>
|
||||||
|
<option value="100" selected?={ data.PageSize == 100 }>100</option>
|
||||||
|
</select>
|
||||||
|
entries
|
||||||
|
<span id="size-indicator" class="htmx-indicator ml-2">
|
||||||
|
<i class="fas fa-spinner fa-spin"></i>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div class="flex items-center">
|
|
||||||
<span class="text-sm text-secondary-700 dark:text-secondary-300">
|
|
||||||
Show
|
|
||||||
<select
|
|
||||||
name="pageSize"
|
|
||||||
hx-get="/history"
|
|
||||||
hx-target="#history-content"
|
|
||||||
hx-include="[name='search']"
|
|
||||||
hx-indicator="#size-indicator"
|
|
||||||
class="mx-1 rounded-md border-secondary-300 dark:border-secondary-600 py-1 text-base bg-white dark:bg-secondary-700 text-secondary-900 dark:text-secondary-100 focus:border-primary-500 focus:outline-none focus:ring-primary-500 sm:text-sm"
|
|
||||||
>
|
|
||||||
<option value="10" selected?={ data.PageSize == 10 }>10</option>
|
|
||||||
<option value="25" selected?={ data.PageSize == 25 }>25</option>
|
|
||||||
<option value="50" selected?={ data.PageSize == 50 }>50</option>
|
|
||||||
<option value="100" selected?={ data.PageSize == 100 }>100</option>
|
|
||||||
</select>
|
|
||||||
entries
|
|
||||||
<span id="size-indicator" class="htmx-indicator ml-1">
|
|
||||||
<i class="fas fa-spinner fa-spin"></i>
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="history-content" class="mt-6">
|
<div id="history-content" class="mt-6">
|
||||||
@HistoryContent(ctx, data)
|
@HistoryContent(ctx, data)
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Set dark background color if in dark mode
|
||||||
|
if (document.documentElement.classList.contains('dark')) {
|
||||||
|
document.getElementById('history-container').style.backgroundColor = '#111827';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add event listener for theme changes
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const themeToggle = document.getElementById('theme-toggle');
|
||||||
|
if (themeToggle) {
|
||||||
|
themeToggle.addEventListener('click', function() {
|
||||||
|
setTimeout(function() {
|
||||||
|
const isDark = document.documentElement.classList.contains('dark');
|
||||||
|
document.getElementById('history-container').style.backgroundColor = isDark ? '#111827' : 'rgb(249, 250, 251)';
|
||||||
|
}, 50);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -328,7 +379,7 @@ templ pageNumbers(currentPage int, totalPages int, pageSize int, searchTerm stri
|
|||||||
// Show at most 5 page numbers with the current page in the middle when possible
|
// Show at most 5 page numbers with the current page in the middle when possible
|
||||||
for i := 1; i <= totalPages; i++ {
|
for i := 1; i <= totalPages; i++ {
|
||||||
if i == currentPage {
|
if i == currentPage {
|
||||||
<span aria-current="page" class="w-10 h-10 flex items-center justify-center bg-primary-600 text-white rounded">
|
<span aria-current="page" class="relative z-10 inline-flex items-center bg-blue-600 px-4 py-2 text-sm font-medium text-white focus:z-20 border border-blue-600">
|
||||||
{ fmt.Sprint(i) }
|
{ fmt.Sprint(i) }
|
||||||
</span>
|
</span>
|
||||||
} else if i == 1 || i == totalPages || (i >= currentPage-2 && i <= currentPage+2) {
|
} else if i == 1 || i == totalPages || (i >= currentPage-2 && i <= currentPage+2) {
|
||||||
@@ -337,14 +388,17 @@ templ pageNumbers(currentPage int, totalPages int, pageSize int, searchTerm stri
|
|||||||
hx-target="#history-content"
|
hx-target="#history-content"
|
||||||
hx-vals={ fmt.Sprintf(`{"page": %d, "pageSize": %d, "search": "%s"}`, i, pageSize, searchTerm) }
|
hx-vals={ fmt.Sprintf(`{"page": %d, "pageSize": %d, "search": "%s"}`, i, pageSize, searchTerm) }
|
||||||
hx-indicator={ fmt.Sprintf("#page-indicator-%d", i) }
|
hx-indicator={ fmt.Sprintf("#page-indicator-%d", i) }
|
||||||
class="w-10 h-10 flex items-center justify-center bg-secondary-200 text-secondary-800 hover:bg-secondary-300 rounded dark:bg-secondary-700 dark:text-secondary-200 dark:hover:bg-secondary-600 relative">
|
class="relative inline-flex items-center px-4 py-2 text-sm font-medium text-gray-500 hover:bg-gray-50 focus:z-20 border border-gray-300 bg-white dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400 dark:hover:bg-gray-700 relative"
|
||||||
|
>
|
||||||
{ fmt.Sprint(i) }
|
{ fmt.Sprint(i) }
|
||||||
<span id={ fmt.Sprintf("page-indicator-%d", i) } class="htmx-indicator absolute top-0 right-0 w-4 h-4 -mt-1 -mr-1">
|
<span id={ fmt.Sprintf("page-indicator-%d", i) } class="htmx-indicator absolute top-0 right-0 w-4 h-4 -mt-1 -mr-1">
|
||||||
<i class="fas fa-spinner fa-spin text-xs"></i>
|
<i class="fas fa-spinner fa-spin text-xs"></i>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
} else if i == currentPage-3 || i == currentPage+3 {
|
} else if i == currentPage-3 || i == currentPage+3 {
|
||||||
<span class="w-10 h-10 flex items-center justify-center">...</span>
|
<span class="relative inline-flex items-center px-4 py-2 text-sm font-medium text-gray-700 border border-gray-300 bg-white dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400">
|
||||||
|
...
|
||||||
|
</span>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+123
-98
@@ -6,130 +6,155 @@ import (
|
|||||||
|
|
||||||
templ Home(ctx context.Context) {
|
templ Home(ctx context.Context) {
|
||||||
@LayoutWithContext("Home", ctx) {
|
@LayoutWithContext("Home", ctx) {
|
||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
<div class="p-4 md:p-6 2xl:p-10">
|
||||||
<div class="text-center">
|
<!-- Page Header -->
|
||||||
<h1 class="text-4xl font-bold text-secondary-900 dark:text-secondary-100 mb-4">
|
<div class="mb-8 text-center">
|
||||||
<i class="fas fa-exchange-alt text-primary-600 dark:text-primary-400 mr-3"></i>
|
<h1 class="text-3xl font-bold text-gray-900 dark:text-white flex items-center justify-center">
|
||||||
|
<i class="fas fa-exchange-alt w-8 h-8 mr-3 text-blue-500"></i>
|
||||||
Welcome to GoMFT
|
Welcome to GoMFT
|
||||||
</h1>
|
</h1>
|
||||||
<p class="text-xl text-secondary-600 dark:text-secondary-300 mb-12">A modern managed file transfer solution</p>
|
<p class="text-lg text-gray-500 dark:text-gray-400 mt-2">
|
||||||
|
A modern managed file transfer solution
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
if isLoggedIn(ctx) {
|
if isLoggedIn(ctx) {
|
||||||
<div class="space-y-8">
|
<!-- Logged-in User View -->
|
||||||
<!-- Feature Cards -->
|
<div class="space-y-8">
|
||||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
<!-- Feature Cards -->
|
||||||
<div class="card hover:shadow-lg transition-shadow duration-300">
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
<div class="p-6">
|
<!-- Transfer Configs Card -->
|
||||||
<div class="rounded-full bg-primary-100 dark:bg-primary-900 p-4 w-16 h-16 flex items-center justify-center mb-4 mx-auto">
|
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 hover:shadow-lg transition-shadow duration-300">
|
||||||
<i class="fas fa-cogs text-primary-600 dark:text-primary-300 text-2xl"></i>
|
<div class="p-6 text-center">
|
||||||
</div>
|
<div class="inline-flex h-14 w-14 flex-shrink-0 items-center justify-center rounded-full bg-blue-100 mb-4 dark:bg-blue-900">
|
||||||
<h2 class="text-2xl font-semibold text-secondary-900 dark:text-secondary-100 mb-4 text-center">Transfer Configs</h2>
|
<i class="fas fa-cogs text-blue-600 dark:text-blue-300 text-2xl"></i>
|
||||||
<p class="text-secondary-600 dark:text-secondary-400 mb-6 text-center">Set up and manage file transfer configurations with ease.</p>
|
|
||||||
<div class="flex justify-center">
|
|
||||||
<a href="/configs" class="btn-primary inline-flex items-center">
|
|
||||||
<span>View Configs</span>
|
|
||||||
<i class="fas fa-arrow-right ml-2"></i>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card hover:shadow-lg transition-shadow duration-300">
|
|
||||||
<div class="p-6">
|
|
||||||
<div class="rounded-full bg-blue-100 dark:bg-blue-900 p-4 w-16 h-16 flex items-center justify-center mb-4 mx-auto">
|
|
||||||
<i class="fas fa-calendar-alt text-blue-600 dark:text-blue-300 text-2xl"></i>
|
|
||||||
</div>
|
|
||||||
<h2 class="text-2xl font-semibold text-secondary-900 dark:text-secondary-100 mb-4 text-center">Schedule Jobs</h2>
|
|
||||||
<p class="text-secondary-600 dark:text-secondary-400 mb-6 text-center">Create and monitor automated file transfer jobs.</p>
|
|
||||||
<div class="flex justify-center">
|
|
||||||
<a href="/jobs" class="btn-primary inline-flex items-center">
|
|
||||||
<span>View Jobs</span>
|
|
||||||
<i class="fas fa-arrow-right ml-2"></i>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card hover:shadow-lg transition-shadow duration-300">
|
|
||||||
<div class="p-6">
|
|
||||||
<div class="rounded-full bg-green-100 dark:bg-green-900 p-4 w-16 h-16 flex items-center justify-center mb-4 mx-auto">
|
|
||||||
<i class="fas fa-history text-green-600 dark:text-green-300 text-2xl"></i>
|
|
||||||
</div>
|
|
||||||
<h2 class="text-2xl font-semibold text-secondary-900 dark:text-secondary-100 mb-4 text-center">Track History</h2>
|
|
||||||
<p class="text-secondary-600 dark:text-secondary-400 mb-6 text-center">View detailed history of all file transfer operations.</p>
|
|
||||||
<div class="flex justify-center">
|
|
||||||
<a href="/history" class="btn-primary inline-flex items-center">
|
|
||||||
<span>View History</span>
|
|
||||||
<i class="fas fa-arrow-right ml-2"></i>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-3">Transfer Configs</h2>
|
||||||
|
<p class="text-gray-500 dark:text-gray-400 mb-5">Set up and manage file transfer configurations with ease.</p>
|
||||||
|
<a href="/configs" class="inline-flex items-center px-4 py-2 text-sm font-medium text-center text-white bg-blue-700 rounded-lg hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800">
|
||||||
|
View Configs
|
||||||
|
<i class="fas fa-arrow-right ml-2"></i>
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Quick Access Section -->
|
<!-- Schedule Jobs Card -->
|
||||||
<div class="mt-12">
|
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 hover:shadow-lg transition-shadow duration-300">
|
||||||
<h2 class="text-2xl font-bold text-secondary-900 dark:text-secondary-100 mb-6">
|
<div class="p-6 text-center">
|
||||||
<i class="fas fa-bolt text-primary-600 dark:text-primary-400 mr-2"></i>
|
<div class="inline-flex h-14 w-14 flex-shrink-0 items-center justify-center rounded-full bg-indigo-100 mb-4 dark:bg-indigo-900">
|
||||||
Quick Access
|
<i class="fas fa-calendar-alt text-indigo-600 dark:text-indigo-300 text-2xl"></i>
|
||||||
</h2>
|
</div>
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-3">Schedule Jobs</h2>
|
||||||
<a href="/dashboard" class="card p-4 hover:bg-secondary-50 dark:hover:bg-secondary-800 flex items-center transition-colors">
|
<p class="text-gray-500 dark:text-gray-400 mb-5">Create and monitor automated file transfer jobs.</p>
|
||||||
<i class="fas fa-tachometer-alt text-primary-600 dark:text-primary-400 text-xl mr-3"></i>
|
<a href="/jobs" class="inline-flex items-center px-4 py-2 text-sm font-medium text-center text-white bg-blue-700 rounded-lg hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800">
|
||||||
<span class="text-secondary-900 dark:text-secondary-100">Dashboard</span>
|
View Jobs
|
||||||
|
<i class="fas fa-arrow-right ml-2"></i>
|
||||||
</a>
|
</a>
|
||||||
<a href="/configs/new" class="card p-4 hover:bg-secondary-50 dark:hover:bg-secondary-800 flex items-center transition-colors">
|
</div>
|
||||||
<i class="fas fa-plus-circle text-primary-600 dark:text-primary-400 text-xl mr-3"></i>
|
</div>
|
||||||
<span class="text-secondary-900 dark:text-secondary-100">New Config</span>
|
|
||||||
</a>
|
<!-- Track History Card -->
|
||||||
<a href="/jobs/new" class="card p-4 hover:bg-secondary-50 dark:hover:bg-secondary-800 flex items-center transition-colors">
|
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 hover:shadow-lg transition-shadow duration-300">
|
||||||
<i class="fas fa-play-circle text-primary-600 dark:text-primary-400 text-xl mr-3"></i>
|
<div class="p-6 text-center">
|
||||||
<span class="text-secondary-900 dark:text-secondary-100">New Job</span>
|
<div class="inline-flex h-14 w-14 flex-shrink-0 items-center justify-center rounded-full bg-green-100 mb-4 dark:bg-green-900">
|
||||||
</a>
|
<i class="fas fa-history text-green-600 dark:text-green-300 text-2xl"></i>
|
||||||
<a href="/profile" class="card p-4 hover:bg-secondary-50 dark:hover:bg-secondary-800 flex items-center transition-colors">
|
</div>
|
||||||
<i class="fas fa-user-circle text-primary-600 dark:text-primary-400 text-xl mr-3"></i>
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-3">Track History</h2>
|
||||||
<span class="text-secondary-900 dark:text-secondary-100">Profile</span>
|
<p class="text-gray-500 dark:text-gray-400 mb-5">View detailed history of all file transfer operations.</p>
|
||||||
|
<a href="/history" class="inline-flex items-center px-4 py-2 text-sm font-medium text-center text-white bg-blue-700 rounded-lg hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800">
|
||||||
|
View History
|
||||||
|
<i class="fas fa-arrow-right ml-2"></i>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
} else {
|
|
||||||
<div class="max-w-md mx-auto card p-8 hover:shadow-lg transition-shadow duration-300">
|
<!-- Quick Access Section -->
|
||||||
<div class="inline-flex items-center justify-center w-64 h-64 mb-4">
|
<div class="mt-10">
|
||||||
<img src="/static/img/logo.svg" alt="GoMFT Logo" class="w-full h-full"/>
|
<div class="flex items-center mb-4">
|
||||||
|
<i class="fas fa-bolt text-blue-500 w-6 h-6 mr-2"></i>
|
||||||
|
<h2 class="text-xl font-bold text-gray-900 dark:text-white">Quick Access</h2>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<a href="/dashboard" class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 p-4 flex items-center transition-colors hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||||
|
<div class="inline-flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full bg-blue-100 mr-3 dark:bg-blue-900">
|
||||||
|
<i class="fas fa-tachometer-alt text-blue-600 dark:text-blue-300"></i>
|
||||||
|
</div>
|
||||||
|
<span class="font-medium text-gray-900 dark:text-white">Dashboard</span>
|
||||||
|
</a>
|
||||||
|
<a href="/configs/new" class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 p-4 flex items-center transition-colors hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||||
|
<div class="inline-flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full bg-blue-100 mr-3 dark:bg-blue-900">
|
||||||
|
<i class="fas fa-plus-circle text-blue-600 dark:text-blue-300"></i>
|
||||||
|
</div>
|
||||||
|
<span class="font-medium text-gray-900 dark:text-white">New Config</span>
|
||||||
|
</a>
|
||||||
|
<a href="/jobs/new" class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 p-4 flex items-center transition-colors hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||||
|
<div class="inline-flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full bg-blue-100 mr-3 dark:bg-blue-900">
|
||||||
|
<i class="fas fa-play-circle text-blue-600 dark:text-blue-300"></i>
|
||||||
|
</div>
|
||||||
|
<span class="font-medium text-gray-900 dark:text-white">New Job</span>
|
||||||
|
</a>
|
||||||
|
<a href="/profile" class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 p-4 flex items-center transition-colors hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||||
|
<div class="inline-flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full bg-blue-100 mr-3 dark:bg-blue-900">
|
||||||
|
<i class="fas fa-user-circle text-blue-600 dark:text-blue-300"></i>
|
||||||
|
</div>
|
||||||
|
<span class="font-medium text-gray-900 dark:text-white">Profile</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
} else {
|
||||||
|
<!-- Login Card for Non-Logged-In Users -->
|
||||||
|
<div class="max-w-md mx-auto">
|
||||||
|
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800">
|
||||||
|
<div class="p-6 text-center">
|
||||||
|
<div class="inline-flex items-center justify-center w-32 h-32 mb-6">
|
||||||
|
<img src="/static/img/logo.svg" alt="GoMFT Logo" class="w-full h-full"/>
|
||||||
|
</div>
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-3">Get Started</h2>
|
||||||
|
<p class="text-gray-500 dark:text-gray-400 mb-6">Log in to access the file transfer management system.</p>
|
||||||
|
<a href="/login" class="inline-flex items-center px-5 py-2.5 text-sm font-medium text-center text-white bg-blue-700 rounded-lg hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800">
|
||||||
|
<i class="fas fa-lock mr-2"></i>
|
||||||
|
Log In
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<h2 class="text-2xl font-semibold text-secondary-900 dark:text-secondary-100 mb-4">Get Started</h2>
|
|
||||||
<p class="text-secondary-600 dark:text-secondary-400 mb-6">Log in to access the file transfer management system.</p>
|
|
||||||
<a href="/login" class="btn-primary inline-flex items-center px-8 py-3">
|
|
||||||
<i class="fas fa-lock mr-2"></i>
|
|
||||||
<span>Log In</span>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<!-- Features Preview -->
|
<!-- Features Preview -->
|
||||||
<div class="mt-12 pt-8 border-t border-secondary-200 dark:border-secondary-700">
|
<div class="p-6 bg-gray-50 dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 rounded-b-lg">
|
||||||
<h3 class="text-lg font-medium text-secondary-900 dark:text-secondary-100 mb-4">Key Features</h3>
|
<h3 class="text-base font-medium text-gray-900 dark:text-white mb-4">Key Features</h3>
|
||||||
<ul class="space-y-3 text-left">
|
<ul class="space-y-3">
|
||||||
<li class="flex items-start">
|
<li class="flex items-start">
|
||||||
<i class="fas fa-check-circle text-green-500 mt-1 mr-2"></i>
|
<i class="fas fa-check-circle text-green-500 mt-0.5 mr-2 flex-shrink-0"></i>
|
||||||
<span class="text-secondary-600 dark:text-secondary-400">Secure file transfers with encryption</span>
|
<span class="text-gray-500 dark:text-gray-400">Secure file transfers with encryption</span>
|
||||||
</li>
|
</li>
|
||||||
<li class="flex items-start">
|
<li class="flex items-start">
|
||||||
<i class="fas fa-check-circle text-green-500 mt-1 mr-2"></i>
|
<i class="fas fa-check-circle text-green-500 mt-0.5 mr-2 flex-shrink-0"></i>
|
||||||
<span class="text-secondary-600 dark:text-secondary-400">Automated scheduling and monitoring</span>
|
<span class="text-gray-500 dark:text-gray-400">Automated scheduling and monitoring</span>
|
||||||
</li>
|
</li>
|
||||||
<li class="flex items-start">
|
<li class="flex items-start">
|
||||||
<i class="fas fa-check-circle text-green-500 mt-1 mr-2"></i>
|
<i class="fas fa-check-circle text-green-500 mt-0.5 mr-2 flex-shrink-0"></i>
|
||||||
<span class="text-secondary-600 dark:text-secondary-400">Comprehensive transfer history tracking</span>
|
<span class="text-gray-500 dark:text-gray-400">Comprehensive transfer history tracking</span>
|
||||||
</li>
|
</li>
|
||||||
<li class="flex items-start">
|
<li class="flex items-start">
|
||||||
<i class="fas fa-check-circle text-green-500 mt-1 mr-2"></i>
|
<i class="fas fa-check-circle text-green-500 mt-0.5 mr-2 flex-shrink-0"></i>
|
||||||
<span class="text-secondary-600 dark:text-secondary-400">User-friendly interface with dark mode support</span>
|
<span class="text-gray-500 dark:text-gray-400">User-friendly interface with dark mode support</span>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
|
||||||
</div>
|
<!-- Help Notice -->
|
||||||
|
<div class="mt-6">
|
||||||
|
<div class="p-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900/50 dark:text-blue-400 border border-blue-200 dark:border-blue-800 flex items-start">
|
||||||
|
<i class="fas fa-info-circle flex-shrink-0 mr-3 mt-0.5"></i>
|
||||||
|
<div>
|
||||||
|
<h4 class="text-sm font-medium mb-1">About GoMFT</h4>
|
||||||
|
<p>GoMFT is a modern, secure file transfer solution that allows you to effortlessly move files between different systems and storage providers with robust tracking and monitoring.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+1688
-526
File diff suppressed because it is too large
Load Diff
+150
-156
@@ -22,169 +22,163 @@ templ JobRunDetails(ctx context.Context, data JobRunDetailsData) {
|
|||||||
// JobRunDetailsContent is the same as JobRunDetails but without the layout wrapper
|
// JobRunDetailsContent is the same as JobRunDetails but without the layout wrapper
|
||||||
// This is used for testing
|
// This is used for testing
|
||||||
templ JobRunDetailsContent(ctx context.Context, data JobRunDetailsData) {
|
templ JobRunDetailsContent(ctx context.Context, data JobRunDetailsData) {
|
||||||
<div class="py-6">
|
<div class="py-6 px-4 mx-auto max-w-7xl lg:px-8">
|
||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
<div class="mb-6">
|
||||||
<div class="mb-6">
|
<a href="/dashboard" class="text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300 inline-flex items-center">
|
||||||
<a href="/dashboard" class="text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300">
|
<i class="fas fa-arrow-left mr-2"></i> Back to Dashboard
|
||||||
<i class="fas fa-arrow-left mr-1"></i> Back to Dashboard
|
</a>
|
||||||
</a>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center justify-between mb-8">
|
<div class="flex items-center justify-between mb-8">
|
||||||
<h1 class="text-3xl font-bold text-secondary-900 dark:text-secondary-100">
|
<h1 class="text-3xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||||
<i class="fas fa-file-alt mr-2 text-primary-600 dark:text-primary-400"></i>
|
<i class="fas fa-file-alt mr-3 text-blue-600 dark:text-blue-400"></i>
|
||||||
Job Run Details
|
Job Run Details
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Job Run Information Card -->
|
<!-- Job Run Information Card -->
|
||||||
<div class="bg-white dark:bg-secondary-800 shadow overflow-hidden rounded-lg mb-8">
|
<div class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-sm mb-8">
|
||||||
<div class="px-4 py-5 sm:px-6 border-b border-secondary-200 dark:border-secondary-700">
|
<div class="px-4 py-5 sm:px-6 border-b border-gray-200 dark:border-gray-700">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||||
<h3 class="text-lg leading-6 font-medium text-secondary-900 dark:text-secondary-100">{ data.Job.Name }</h3>
|
<h3 class="text-lg font-medium text-gray-900 dark:text-white">{ data.Job.Name }</h3>
|
||||||
if data.JobHistory.Status == "completed" {
|
if data.JobHistory.Status == "completed" {
|
||||||
<span class="px-3 py-1 inline-flex text-sm leading-5 font-semibold rounded-full bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-300">
|
<span class="px-3 py-1 text-sm font-medium rounded-full bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-300 inline-flex items-center">
|
||||||
<i class="fas fa-check mr-1"></i> Completed
|
<i class="fas fa-check mr-2"></i> Completed
|
||||||
</span>
|
</span>
|
||||||
} else if data.JobHistory.Status == "failed" {
|
} else if data.JobHistory.Status == "failed" {
|
||||||
<span class="px-3 py-1 inline-flex text-sm leading-5 font-semibold rounded-full bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-300">
|
<span class="px-3 py-1 text-sm font-medium rounded-full bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-300 inline-flex items-center">
|
||||||
<i class="fas fa-times mr-1"></i> Failed
|
<i class="fas fa-times mr-2"></i> Failed
|
||||||
</span>
|
</span>
|
||||||
} else {
|
} else {
|
||||||
<span class="px-3 py-1 inline-flex text-sm leading-5 font-semibold rounded-full bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-300">
|
<span class="px-3 py-1 text-sm font-medium rounded-full bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-300 inline-flex items-center">
|
||||||
<i class="fas fa-spinner fa-spin mr-1"></i> Running
|
<i class="fas fa-spinner fa-spin mr-2"></i> Running
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Config: { data.Config.Name }</p>
|
||||||
|
</div>
|
||||||
|
<div class="px-4 py-5 sm:p-6">
|
||||||
|
<dl class="grid grid-cols-1 gap-x-6 gap-y-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
<div class="sm:col-span-1">
|
||||||
|
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400 flex items-center mb-1">
|
||||||
|
<i class="fas fa-calendar-alt mr-2 text-gray-400 dark:text-gray-500"></i> Start Time
|
||||||
|
</dt>
|
||||||
|
<dd class="text-sm text-gray-900 dark:text-white">
|
||||||
|
{ data.JobHistory.StartTime.Format("Jan 02, 2006 15:04:05") }
|
||||||
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-1 text-sm text-secondary-500 dark:text-secondary-400">Config: { data.Config.Name }</p>
|
<div class="sm:col-span-1">
|
||||||
</div>
|
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400 flex items-center mb-1">
|
||||||
<div class="px-4 py-5 sm:p-6">
|
<i class="fas fa-calendar-check mr-2 text-gray-400 dark:text-gray-500"></i> End Time
|
||||||
<dl class="grid grid-cols-1 gap-x-4 gap-y-6 sm:grid-cols-2 lg:grid-cols-3">
|
</dt>
|
||||||
<div class="sm:col-span-1">
|
<dd class="text-sm text-gray-900 dark:text-white">
|
||||||
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">
|
if data.JobHistory.EndTime != nil {
|
||||||
<i class="fas fa-calendar-alt mr-1"></i> Start Time
|
{ data.JobHistory.EndTime.Format("Jan 02, 2006 15:04:05") }
|
||||||
</dt>
|
} else {
|
||||||
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100">
|
<span class="italic text-gray-500 dark:text-gray-400">In progress</span>
|
||||||
{ data.JobHistory.StartTime.Format("Jan 02, 2006 15:04:05") }
|
}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
|
||||||
<div class="sm:col-span-1">
|
|
||||||
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">
|
|
||||||
<i class="fas fa-calendar-check mr-1"></i> End Time
|
|
||||||
</dt>
|
|
||||||
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100">
|
|
||||||
if data.JobHistory.EndTime != nil {
|
|
||||||
{ data.JobHistory.EndTime.Format("Jan 02, 2006 15:04:05") }
|
|
||||||
} else {
|
|
||||||
<span class="italic text-secondary-500">In progress</span>
|
|
||||||
}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
<div class="sm:col-span-1">
|
|
||||||
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">
|
|
||||||
<i class="fas fa-clock mr-1"></i> Duration
|
|
||||||
</dt>
|
|
||||||
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100">
|
|
||||||
if data.JobHistory.EndTime != nil {
|
|
||||||
{ data.JobHistory.EndTime.Sub(data.JobHistory.StartTime).String() }
|
|
||||||
} else {
|
|
||||||
<span class="italic text-secondary-500">In progress</span>
|
|
||||||
}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
<div class="sm:col-span-1">
|
|
||||||
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">
|
|
||||||
<i class="fas fa-upload mr-1"></i> Data Transferred
|
|
||||||
</dt>
|
|
||||||
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100">
|
|
||||||
{ formatBytes(data.JobHistory.BytesTransferred) }
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
<div class="sm:col-span-1">
|
|
||||||
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">
|
|
||||||
<i class="fas fa-file mr-1"></i> Files Transferred
|
|
||||||
</dt>
|
|
||||||
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100">
|
|
||||||
{ fmt.Sprintf("%d files", data.JobHistory.FilesTransferred) }
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
<div class="sm:col-span-1">
|
|
||||||
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">
|
|
||||||
<i class="fas fa-calendar-day mr-1"></i> Job Schedule
|
|
||||||
</dt>
|
|
||||||
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100">
|
|
||||||
{ data.Job.Schedule }
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Transfer Configuration Details -->
|
|
||||||
<div class="bg-white dark:bg-secondary-800 shadow overflow-hidden rounded-lg mb-8">
|
|
||||||
<div class="px-4 py-5 sm:px-6 border-b border-secondary-200 dark:border-secondary-700">
|
|
||||||
<h3 class="text-lg leading-6 font-medium text-secondary-900 dark:text-secondary-100">
|
|
||||||
<i class="fas fa-cog mr-2 text-primary-600 dark:text-primary-400"></i>
|
|
||||||
Transfer Configuration
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<div class="px-4 py-5 sm:p-6">
|
|
||||||
<dl class="grid grid-cols-1 gap-x-4 gap-y-6 sm:grid-cols-2">
|
|
||||||
<div class="sm:col-span-1">
|
|
||||||
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">Source Type</dt>
|
|
||||||
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100">
|
|
||||||
<span class="px-2 py-1 text-xs font-medium rounded bg-secondary-100 dark:bg-secondary-700">{ data.Config.SourceType }</span>
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
<div class="sm:col-span-1">
|
|
||||||
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">Destination Type</dt>
|
|
||||||
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100">
|
|
||||||
<span class="px-2 py-1 text-xs font-medium rounded bg-secondary-100 dark:bg-secondary-700">{ data.Config.DestinationType }</span>
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
<div class="sm:col-span-1">
|
|
||||||
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">Source Path</dt>
|
|
||||||
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100 font-mono bg-secondary-50 dark:bg-secondary-900 p-2 rounded">{ data.Config.SourcePath }</dd>
|
|
||||||
</div>
|
|
||||||
<div class="sm:col-span-1">
|
|
||||||
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">Destination Path</dt>
|
|
||||||
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100 font-mono bg-secondary-50 dark:bg-secondary-900 p-2 rounded">{ data.Config.DestinationPath }</dd>
|
|
||||||
</div>
|
|
||||||
<div class="sm:col-span-1">
|
|
||||||
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">File Pattern</dt>
|
|
||||||
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100 font-mono">{ data.Config.FilePattern }</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Error Information (if any) -->
|
|
||||||
if data.JobHistory.Status == "failed" && data.JobHistory.ErrorMessage != "" {
|
|
||||||
<div class="bg-white dark:bg-secondary-800 shadow overflow-hidden rounded-lg mb-8 border-l-4 border-red-500">
|
|
||||||
<div class="px-4 py-5 sm:px-6 border-b border-secondary-200 dark:border-secondary-700">
|
|
||||||
<h3 class="text-lg leading-6 font-medium text-red-600 dark:text-red-400">
|
|
||||||
<i class="fas fa-exclamation-triangle mr-2"></i>
|
|
||||||
Error Information
|
|
||||||
</h3>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="px-4 py-5 sm:p-6">
|
<div class="sm:col-span-1">
|
||||||
<div class="bg-red-50 dark:bg-red-900/20 p-4 rounded-lg">
|
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400 flex items-center mb-1">
|
||||||
<pre class="text-sm text-red-800 dark:text-red-300 whitespace-pre-wrap font-mono">{ data.JobHistory.ErrorMessage }</pre>
|
<i class="fas fa-clock mr-2 text-gray-400 dark:text-gray-500"></i> Duration
|
||||||
</div>
|
</dt>
|
||||||
|
<dd class="text-sm text-gray-900 dark:text-white">
|
||||||
|
if data.JobHistory.EndTime != nil {
|
||||||
|
{ data.JobHistory.EndTime.Sub(data.JobHistory.StartTime).String() }
|
||||||
|
} else {
|
||||||
|
<span class="italic text-gray-500 dark:text-gray-400">In progress</span>
|
||||||
|
}
|
||||||
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="sm:col-span-1">
|
||||||
}
|
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400 flex items-center mb-1">
|
||||||
|
<i class="fas fa-upload mr-2 text-gray-400 dark:text-gray-500"></i> Data Transferred
|
||||||
<!-- Action Buttons -->
|
</dt>
|
||||||
<div class="flex flex-col sm:flex-row gap-4 mt-8">
|
<dd class="text-sm text-gray-900 dark:text-white">
|
||||||
<a href="/jobs" class="btn-secondary text-center flex items-center justify-center">
|
{ formatBytes(data.JobHistory.BytesTransferred) }
|
||||||
<i class="fas fa-list-ul mr-2"></i> View All Jobs
|
</dd>
|
||||||
</a>
|
</div>
|
||||||
<a href={ templ.SafeURL(fmt.Sprintf("/jobs/%d", data.Job.ID)) } class="btn-primary text-center flex items-center justify-center">
|
<div class="sm:col-span-1">
|
||||||
<i class="fas fa-edit mr-2"></i> Edit Job
|
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400 flex items-center mb-1">
|
||||||
</a>
|
<i class="fas fa-file mr-2 text-gray-400 dark:text-gray-500"></i> Files Transferred
|
||||||
|
</dt>
|
||||||
|
<dd class="text-sm text-gray-900 dark:text-white">
|
||||||
|
{ fmt.Sprintf("%d files", data.JobHistory.FilesTransferred) }
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div class="sm:col-span-1">
|
||||||
|
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400 flex items-center mb-1">
|
||||||
|
<i class="fas fa-calendar-day mr-2 text-gray-400 dark:text-gray-500"></i> Job Schedule
|
||||||
|
</dt>
|
||||||
|
<dd class="text-sm text-gray-900 dark:text-white">
|
||||||
|
{ data.Job.Schedule }
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Transfer Configuration Details -->
|
||||||
|
<div class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-sm mb-8">
|
||||||
|
<div class="px-4 py-5 sm:px-6 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<h3 class="text-lg font-medium text-gray-900 dark:text-white flex items-center">
|
||||||
|
<i class="fas fa-cog mr-3 text-blue-600 dark:text-blue-400"></i>
|
||||||
|
Transfer Configuration
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div class="px-4 py-5 sm:p-6">
|
||||||
|
<dl class="grid grid-cols-1 gap-x-6 gap-y-6 sm:grid-cols-2">
|
||||||
|
<div class="sm:col-span-1">
|
||||||
|
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">Source Type</dt>
|
||||||
|
<dd class="text-sm text-gray-900 dark:text-white">
|
||||||
|
<span class="px-2.5 py-0.5 text-xs font-medium rounded bg-gray-100 dark:bg-gray-700">{ data.Config.SourceType }</span>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div class="sm:col-span-1">
|
||||||
|
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">Destination Type</dt>
|
||||||
|
<dd class="text-sm text-gray-900 dark:text-white">
|
||||||
|
<span class="px-2.5 py-0.5 text-xs font-medium rounded bg-gray-100 dark:bg-gray-700">{ data.Config.DestinationType }</span>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div class="sm:col-span-1">
|
||||||
|
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">Source Path</dt>
|
||||||
|
<dd class="text-sm text-gray-900 dark:text-white font-mono bg-gray-50 dark:bg-gray-900 p-2 rounded-lg border border-gray-200 dark:border-gray-700">{ data.Config.SourcePath }</dd>
|
||||||
|
</div>
|
||||||
|
<div class="sm:col-span-1">
|
||||||
|
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">Destination Path</dt>
|
||||||
|
<dd class="text-sm text-gray-900 dark:text-white font-mono bg-gray-50 dark:bg-gray-900 p-2 rounded-lg border border-gray-200 dark:border-gray-700">{ data.Config.DestinationPath }</dd>
|
||||||
|
</div>
|
||||||
|
<div class="sm:col-span-1">
|
||||||
|
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">File Pattern</dt>
|
||||||
|
<dd class="text-sm text-gray-900 dark:text-white font-mono">{ data.Config.FilePattern }</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error Information (if any) -->
|
||||||
|
if data.JobHistory.Status == "failed" && data.JobHistory.ErrorMessage != "" {
|
||||||
|
<div class="p-4 mb-8 text-red-800 border-l-4 border-red-300 bg-red-50 dark:bg-red-900/20 dark:text-red-400 dark:border-red-800 rounded-lg">
|
||||||
|
<div class="flex items-center mb-2">
|
||||||
|
<i class="fas fa-exclamation-triangle flex-shrink-0 mr-2 text-red-600 dark:text-red-500"></i>
|
||||||
|
<h3 class="text-lg font-medium">Error Information</h3>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2">
|
||||||
|
<pre class="text-sm whitespace-pre-wrap font-mono p-3 bg-white dark:bg-gray-900 rounded-lg border border-red-200 dark:border-red-800">{ data.JobHistory.ErrorMessage }</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Action Buttons -->
|
||||||
|
<div class="flex flex-col sm:flex-row gap-4 mt-8">
|
||||||
|
<a href="/jobs" class="text-gray-900 bg-white border border-gray-300 focus:outline-none hover:bg-gray-100 focus:ring-4 focus:ring-gray-200 font-medium rounded-lg text-sm px-5 py-2.5 dark:bg-gray-800 dark:text-white dark:border-gray-600 dark:hover:bg-gray-700 dark:hover:border-gray-600 dark:focus:ring-gray-700 inline-flex items-center justify-center">
|
||||||
|
<i class="fas fa-list-ul mr-2"></i> View All Jobs
|
||||||
|
</a>
|
||||||
|
<a href={ templ.SafeURL(fmt.Sprintf("/jobs/%d", data.Job.ID)) } class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800 inline-flex items-center justify-center">
|
||||||
|
<i class="fas fa-edit mr-2"></i> Edit Job
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+273
-187
@@ -6,55 +6,46 @@ import (
|
|||||||
"github.com/starfleetcptn/gomft/internal/db"
|
"github.com/starfleetcptn/gomft/internal/db"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Dialog component for confirmation dialogs - copied from admin_tools.templ
|
// Dialog component for confirmation dialogs using Flowbite modal
|
||||||
templ JobDialog(id string, title string, message string, confirmClass string, confirmText string, action string, jobID uint, jobName string) {
|
templ JobDialog(id string, title string, message string, confirmClass string, confirmText string, action string, jobID uint, jobName string) {
|
||||||
<div id={ id } class="hidden fixed inset-0 bg-secondary-900/50 dark:bg-secondary-900/80 backdrop-blur-sm z-50 flex items-center justify-center">
|
<div 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">
|
||||||
<div class="bg-white dark:bg-secondary-800 rounded-lg shadow-xl max-w-md w-full mx-4 overflow-hidden">
|
<!-- Backdrop -->
|
||||||
<div class="px-6 pt-5 pb-3 text-center">
|
<div id={ fmt.Sprintf("%s-backdrop", id) } class="fixed inset-0 bg-gray-900/50 dark:bg-gray-900/80 backdrop-blur-sm"></div>
|
||||||
<div class="flex justify-center mb-2">
|
<!-- Modal content -->
|
||||||
<i class="fas fa-exclamation-triangle text-yellow-400 text-3xl"></i>
|
<div class="relative p-4 w-full max-w-md max-h-full mx-auto">
|
||||||
|
<div class="relative bg-white rounded-lg shadow dark:bg-gray-700">
|
||||||
|
<div class="p-6 text-center">
|
||||||
|
if action == "delete" {
|
||||||
|
<i class="fas fa-trash-alt text-red-400 text-3xl mb-4"></i>
|
||||||
|
} else {
|
||||||
|
<i class="fas fa-exclamation-triangle text-yellow-400 text-3xl mb-4"></i>
|
||||||
|
}
|
||||||
|
<h3 class="mb-5 text-lg font-normal text-gray-500 dark:text-gray-400">{ message }</h3>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class={ confirmClass }
|
||||||
|
hx-delete={ fmt.Sprintf("/jobs/%d", jobID) }
|
||||||
|
hx-target="closest li"
|
||||||
|
hx-swap="delete"
|
||||||
|
data-job-name={ jobName }
|
||||||
|
data-job-id={ fmt.Sprint(jobID) }
|
||||||
|
id={ fmt.Sprintf("delete-btn-%d", jobID) }
|
||||||
|
onclick={ triggerJobDelete(id, jobID, jobName) }>
|
||||||
|
{ confirmText }
|
||||||
|
</button>
|
||||||
|
<button type="button" onclick={ closeModal(id) } class="text-gray-500 bg-white hover:bg-gray-100 focus:ring-4 focus:outline-none focus:ring-gray-200 rounded-lg border border-gray-200 text-sm font-medium px-5 py-2.5 hover:text-gray-900 focus:z-10 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-500 dark:hover:text-white dark:hover:bg-gray-600 dark:focus:ring-gray-600">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<h3 class="text-xl font-medium text-secondary-900 dark:text-secondary-100">
|
|
||||||
{ title }
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<div class="px-6 py-4 text-center">
|
|
||||||
<p class="text-secondary-700 dark:text-secondary-300">
|
|
||||||
{ message }
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="px-6 py-4 flex justify-end space-x-3">
|
|
||||||
<button type="button" class="btn-secondary" onclick={ hideJobDialog(id) }>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class={ confirmClass }
|
|
||||||
hx-delete={ fmt.Sprintf("/jobs/%d", jobID) }
|
|
||||||
hx-target="closest li"
|
|
||||||
hx-swap="delete"
|
|
||||||
data-job-name={ jobName }
|
|
||||||
data-job-id={ fmt.Sprint(jobID) }
|
|
||||||
id={ fmt.Sprintf("delete-btn-%d", jobID) }
|
|
||||||
onclick={ triggerJobDelete(id, jobID, jobName) }>
|
|
||||||
{ confirmText }
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
script hideJobDialog(id string) {
|
|
||||||
document.getElementById(id).classList.add("hidden");
|
|
||||||
}
|
|
||||||
|
|
||||||
script showJobDialog(id string) {
|
|
||||||
document.getElementById(id).classList.remove("hidden");
|
|
||||||
}
|
|
||||||
|
|
||||||
script triggerJobDelete(dialogId string, jobID uint, jobName string) {
|
script triggerJobDelete(dialogId string, jobID uint, jobName string) {
|
||||||
// Hide the dialog
|
// Hide the dialog
|
||||||
document.getElementById(dialogId).classList.add("hidden");
|
document.getElementById(dialogId).classList.add("hidden");
|
||||||
|
document.getElementById(dialogId).classList.remove("flex");
|
||||||
|
|
||||||
// Add debugging info
|
// Add debugging info
|
||||||
console.log(`Job deletion triggered for: ${jobName} (ID: ${jobID})`);
|
console.log(`Job deletion triggered for: ${jobName} (ID: ${jobID})`);
|
||||||
@@ -75,38 +66,84 @@ type JobsData struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
templ Jobs(ctx context.Context, data JobsData) {
|
templ Jobs(ctx context.Context, data JobsData) {
|
||||||
@LayoutWithContext("Transfer Jobs", ctx) {
|
@LayoutWithContext("Scheduled Jobs", ctx) {
|
||||||
|
<!-- Status and Error Messages -->
|
||||||
|
<div id="toast-container" class="fixed top-5 right-5 z-50 flex flex-col gap-2"></div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// Debug notification system
|
// Debug notification system
|
||||||
console.log("Jobs template loaded, setting up notification system");
|
console.log("Jobs template loaded, setting up notification system");
|
||||||
|
|
||||||
// Create a global notyf instance immediately
|
// Function to create and show a toast
|
||||||
window.notyf = new Notyf({
|
function showToast(message, type) {
|
||||||
duration: 3000,
|
const toastContainer = document.getElementById('toast-container');
|
||||||
position: {
|
|
||||||
x: 'right',
|
// Create toast element
|
||||||
y: 'top',
|
const toast = document.createElement('div');
|
||||||
},
|
toast.id = 'toast-' + type + '-' + Date.now();
|
||||||
types: [
|
toast.className = 'flex items-center w-full max-w-xs p-4 mb-4 rounded-lg shadow text-gray-500 bg-white dark:text-gray-400 dark:bg-gray-800 transform translate-y-16 opacity-0 transition-all duration-300 ease-out';
|
||||||
{
|
toast.role = 'alert';
|
||||||
type: 'success',
|
|
||||||
background: '#38c172',
|
// Set toast content based on type
|
||||||
icon: {
|
let iconClass, bgColorClass, textColorClass;
|
||||||
className: 'fas fa-check-circle',
|
|
||||||
tagName: 'i'
|
if (type === 'success') {
|
||||||
}
|
iconClass = 'text-green-500 bg-green-100 dark:bg-green-800 dark:text-green-200';
|
||||||
},
|
bgColorClass = 'text-green-500 dark:text-green-200';
|
||||||
{
|
textColorClass = 'text-green-500 dark:text-green-200';
|
||||||
type: 'error',
|
} else if (type === 'error') {
|
||||||
background: '#e3342f',
|
iconClass = 'text-red-500 bg-red-100 dark:bg-red-800 dark:text-red-200';
|
||||||
icon: {
|
bgColorClass = 'text-red-500 dark:text-red-200';
|
||||||
className: 'fas fa-exclamation-circle',
|
textColorClass = 'text-red-500 dark:text-red-200';
|
||||||
tagName: 'i'
|
} else {
|
||||||
}
|
iconClass = 'text-blue-500 bg-blue-100 dark:bg-blue-800 dark:text-blue-200';
|
||||||
}
|
bgColorClass = 'text-blue-500 dark:text-blue-200';
|
||||||
]
|
textColorClass = 'text-blue-500 dark:text-blue-200';
|
||||||
});
|
}
|
||||||
console.log("Notyf initialized:", window.notyf);
|
|
||||||
|
// Set inner HTML with appropriate icon and message
|
||||||
|
toast.innerHTML = `
|
||||||
|
<div class="inline-flex items-center justify-center flex-shrink-0 w-8 h-8 rounded-lg ${iconClass}">
|
||||||
|
${type === 'success'
|
||||||
|
? '<i class="fas fa-check"></i>'
|
||||||
|
: type === 'error'
|
||||||
|
? '<i class="fas fa-exclamation-circle"></i>'
|
||||||
|
: '<i class="fas fa-info-circle"></i>'}
|
||||||
|
</div>
|
||||||
|
<div class="ml-3 text-sm font-normal">${message}</div>
|
||||||
|
<button type="button" class="ml-auto -mx-1.5 -my-1.5 bg-white text-gray-400 hover:text-gray-900 rounded-lg focus:ring-2 focus:ring-gray-300 p-1.5 hover:bg-gray-100 inline-flex h-8 w-8 dark:text-gray-500 dark:hover:text-white dark:bg-gray-800 dark:hover:bg-gray-700" data-dismiss-target="#${toast.id}" aria-label="Close">
|
||||||
|
<span class="sr-only">Close</span>
|
||||||
|
<i class="fas fa-times"></i>
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Add toast to container
|
||||||
|
toastContainer.appendChild(toast);
|
||||||
|
|
||||||
|
// Trigger animation after a small delay to ensure the DOM has updated
|
||||||
|
setTimeout(() => {
|
||||||
|
toast.classList.remove('translate-y-16', 'opacity-0');
|
||||||
|
toast.classList.add('translate-y-0', 'opacity-100');
|
||||||
|
}, 10);
|
||||||
|
|
||||||
|
// Add event listener to close button
|
||||||
|
const closeButton = toast.querySelector('button[data-dismiss-target]');
|
||||||
|
closeButton.addEventListener('click', function() {
|
||||||
|
// Animate out before removing
|
||||||
|
toast.classList.add('opacity-0', 'translate-y-4');
|
||||||
|
setTimeout(() => {
|
||||||
|
toast.remove();
|
||||||
|
}, 300);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto-remove toast after 5 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
toast.classList.add('opacity-0', 'translate-y-4');
|
||||||
|
setTimeout(() => {
|
||||||
|
toast.remove();
|
||||||
|
}, 300);
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
// Global function to handle job running
|
// Global function to handle job running
|
||||||
window.runJob = function(button) {
|
window.runJob = function(button) {
|
||||||
@@ -126,22 +163,33 @@ templ Jobs(ctx context.Context, data JobsData) {
|
|||||||
displayName = event.detail.headers['HX-Job-Name'];
|
displayName = event.detail.headers['HX-Job-Name'];
|
||||||
}
|
}
|
||||||
console.log(`Showing success notification for job: ${displayName}`);
|
console.log(`Showing success notification for job: ${displayName}`);
|
||||||
window.notyf.success(`Job "${displayName}" started successfully!`);
|
showToast(`Job "${displayName}" started successfully!`, 'success');
|
||||||
} else {
|
} else {
|
||||||
let errorMsg = `Failed to start job "${jobName}"`;
|
let errorMsg = `Failed to start job "${jobName}"`;
|
||||||
if (event.detail.xhr && event.detail.xhr.responseText) {
|
if (event.detail.xhr && event.detail.xhr.responseText) {
|
||||||
errorMsg = `Error: ${event.detail.xhr.responseText}`;
|
errorMsg = `Error: ${event.detail.xhr.responseText}`;
|
||||||
}
|
}
|
||||||
console.log(`Showing error notification: ${errorMsg}`);
|
console.log(`Showing error notification: ${errorMsg}`);
|
||||||
window.notyf.error(errorMsg);
|
showToast(errorMsg, 'error');
|
||||||
}
|
}
|
||||||
}, { once: true });
|
}, { once: true });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Handle modal hide buttons
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const hideButtons = document.querySelectorAll('[data-modal-hide]');
|
||||||
|
hideButtons.forEach(button => {
|
||||||
|
button.addEventListener('click', function() {
|
||||||
|
const modalId = this.getAttribute('data-modal-hide');
|
||||||
|
const modal = document.getElementById(modalId);
|
||||||
|
modal.classList.add('hidden');
|
||||||
|
modal.classList.remove('flex');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// Track all HTMX events for debugging
|
// Track all HTMX events for debugging
|
||||||
document.addEventListener('htmx:beforeRequest', function(event) {
|
document.addEventListener('htmx:beforeRequest', function(event) {
|
||||||
console.log("HTMX before request:", event.detail);
|
|
||||||
|
|
||||||
// Check if this is a DELETE request by examining the URL and method
|
// Check if this is a DELETE request by examining the URL and method
|
||||||
const path = event.detail.path;
|
const path = event.detail.path;
|
||||||
const method = event.detail.verb;
|
const method = event.detail.verb;
|
||||||
@@ -159,8 +207,6 @@ templ Jobs(ctx context.Context, data JobsData) {
|
|||||||
|
|
||||||
// Track HTMX after-request events for job deletion
|
// Track HTMX after-request events for job deletion
|
||||||
document.addEventListener('htmx:afterRequest', function(event) {
|
document.addEventListener('htmx:afterRequest', function(event) {
|
||||||
console.log("HTMX after request:", event.detail);
|
|
||||||
|
|
||||||
// Check for job deletion multiple ways
|
// Check for job deletion multiple ways
|
||||||
const isDeleteRequest =
|
const isDeleteRequest =
|
||||||
// Check global flag from the triggerJobDelete function
|
// Check global flag from the triggerJobDelete function
|
||||||
@@ -191,7 +237,7 @@ templ Jobs(ctx context.Context, data JobsData) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
console.log(`Showing success notification for deleted job: ${jobName}`);
|
console.log(`Showing success notification for deleted job: ${jobName}`);
|
||||||
window.notyf.success(`Job "${jobName}" deleted successfully`);
|
showToast(`Job "${jobName}" deleted successfully`, 'success');
|
||||||
|
|
||||||
// Clear flags
|
// Clear flags
|
||||||
window.currentlyDeletingJob = false;
|
window.currentlyDeletingJob = false;
|
||||||
@@ -234,7 +280,7 @@ templ Jobs(ctx context.Context, data JobsData) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
console.log(`Showing error notification: ${errorMsg}`);
|
console.log(`Showing error notification: ${errorMsg}`);
|
||||||
window.notyf.error(errorMsg);
|
showToast(errorMsg, 'error');
|
||||||
|
|
||||||
// Clear flags
|
// Clear flags
|
||||||
window.currentlyDeletingJob = false;
|
window.currentlyDeletingJob = false;
|
||||||
@@ -244,147 +290,187 @@ templ Jobs(ctx context.Context, data JobsData) {
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="py-6">
|
<div id="jobs-container" style="min-height: 100vh; background-color: rgb(249, 250, 251);" class="jobs-page bg-gray-50 dark:bg-gray-900">
|
||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
<div class="pb-8 w-full">
|
||||||
<div class="flex justify-between items-center mb-8">
|
<div class="mb-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||||
<h1 class="text-3xl font-bold text-secondary-900 dark:text-secondary-100">
|
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||||
<i class="fas fa-exchange-alt mr-2 text-primary-600 dark:text-primary-400"></i>
|
<i class="fas fa-calendar-alt w-6 h-6 mr-2 text-blue-500 dark:text-blue-400"></i>
|
||||||
Transfer Jobs
|
Scheduled Jobs
|
||||||
</h1>
|
</h1>
|
||||||
<a href="/jobs/new" class="btn-primary">
|
<a href="/jobs/new" class="flex items-center justify-center text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg px-5 py-2.5 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800">
|
||||||
<i class="fas fa-plus mr-2"></i>
|
<i class="fas fa-plus w-4 h-4 mr-2"></i>
|
||||||
New Job
|
New Job
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-6">
|
<div class="mt-6">
|
||||||
if len(data.Jobs) == 0 {
|
if len(data.Jobs) == 0 {
|
||||||
<div class="card p-12 flex flex-col items-center justify-center text-center">
|
<div class="bg-white dark:bg-gray-800 shadow-md rounded-lg p-12 flex flex-col items-center justify-center text-center">
|
||||||
<div class="inline-block p-4 rounded-full bg-secondary-100 dark:bg-secondary-700 mb-4">
|
<div class="inline-block p-4 rounded-full bg-gray-100 dark:bg-gray-700 mb-4">
|
||||||
<i class="fas fa-tasks text-secondary-400 dark:text-secondary-500 text-3xl"></i>
|
<i class="fas fa-list text-3xl text-gray-400 dark:text-gray-500"></i>
|
||||||
</div>
|
</div>
|
||||||
<h3 class="mt-2 text-lg font-medium text-secondary-900 dark:text-secondary-100">No jobs</h3>
|
<h3 class="mt-2 text-lg font-medium text-gray-900 dark:text-white">No jobs</h3>
|
||||||
<p class="mt-1 text-sm text-secondary-500 dark:text-secondary-400">Get started by creating a new transfer job.</p>
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Get started by creating a new transfer job.</p>
|
||||||
<div class="mt-6">
|
<div class="mt-6">
|
||||||
<a href="/jobs/new" class="btn-primary">
|
<a href="/jobs/new" class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800">
|
||||||
<i class="fas fa-plus mr-2"></i>
|
<i class="fas fa-plus mr-2"></i>
|
||||||
New Job
|
New Job
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
} else {
|
} else {
|
||||||
<div class="card overflow-hidden">
|
<div class="bg-white dark:bg-gray-800 shadow-md sm:rounded-lg overflow-hidden">
|
||||||
<ul role="list" class="divide-y divide-secondary-200 dark:divide-secondary-700">
|
<div class="relative overflow-x-auto">
|
||||||
for _, job := range data.Jobs {
|
<ul role="list" class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
<li>
|
for _, job := range data.Jobs {
|
||||||
<div class="block hover:bg-secondary-50 dark:hover:bg-secondary-750 transition-colors">
|
<li>
|
||||||
<div class="px-4 py-4 sm:px-6">
|
<div class="block hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
|
||||||
<div class="flex items-center justify-between">
|
<div class="px-4 py-4 sm:px-6">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center justify-between">
|
||||||
<p class="text-sm font-medium text-primary-600 dark:text-primary-400 truncate">
|
<div class="flex items-center">
|
||||||
if job.Name != "" {
|
<p class="text-sm font-medium text-blue-600 dark:text-blue-400 truncate">
|
||||||
{ job.Name }
|
if job.Name != "" {
|
||||||
} else {
|
{ job.Name }
|
||||||
{ job.Config.Name }
|
|
||||||
}
|
|
||||||
</p>
|
|
||||||
if job.GetEnabled() {
|
|
||||||
<span class="ml-2 px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-300">
|
|
||||||
Active
|
|
||||||
</span>
|
|
||||||
} else {
|
|
||||||
<span class="ml-2 px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-secondary-100 dark:bg-secondary-700 text-secondary-800 dark:text-secondary-300">
|
|
||||||
Inactive
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
<div class="ml-2 flex-shrink-0 flex space-x-2">
|
|
||||||
<button
|
|
||||||
hx-post={ fmt.Sprintf("/jobs/%d/run", job.ID) }
|
|
||||||
hx-swap="none"
|
|
||||||
class="btn-primary btn-sm"
|
|
||||||
data-job-id={ fmt.Sprint(job.ID) }
|
|
||||||
data-job-name={ job.Name }
|
|
||||||
onclick="window.runJob(this)">
|
|
||||||
<i class="fas fa-play mr-1"></i>
|
|
||||||
Run Now
|
|
||||||
</button>
|
|
||||||
<a href={ templ.SafeURL(fmt.Sprintf("/jobs/%d", job.ID)) } class="btn-secondary btn-sm">
|
|
||||||
<i class="fas fa-edit mr-1"></i>
|
|
||||||
Edit
|
|
||||||
</a>
|
|
||||||
<!-- Add delete dialog for each job -->
|
|
||||||
@JobDialog(
|
|
||||||
fmt.Sprintf("delete-job-dialog-%d", job.ID),
|
|
||||||
"Delete Job",
|
|
||||||
fmt.Sprintf("Are you sure you want to delete the job '%s'? This cannot be undone.", determineJobName(job)),
|
|
||||||
"btn-danger",
|
|
||||||
"Delete",
|
|
||||||
"delete",
|
|
||||||
job.ID,
|
|
||||||
determineJobName(job),
|
|
||||||
)
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={ showJobDialog(fmt.Sprintf("delete-job-dialog-%d", job.ID)) }
|
|
||||||
class="btn-danger btn-sm">
|
|
||||||
<i class="fas fa-trash-alt mr-1"></i>
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="mt-2 sm:flex sm:justify-between">
|
|
||||||
<div class="sm:flex">
|
|
||||||
<p class="flex items-center text-sm text-secondary-500 dark:text-secondary-400">
|
|
||||||
<i class="fas fa-cogs flex-shrink-0 mr-1.5 h-5 w-5 text-secondary-400 dark:text-secondary-500"></i>
|
|
||||||
Configs:
|
|
||||||
<span class="ml-1">
|
|
||||||
if count, ok := data.ConfigCount[job.ID]; ok && count > 1 {
|
|
||||||
{ fmt.Sprintf("%d configurations", count) }
|
|
||||||
} else if job.ConfigID > 0 {
|
|
||||||
{ job.Config.Name }
|
|
||||||
} else {
|
} else {
|
||||||
{ "None" }
|
{ job.Config.Name }
|
||||||
}
|
}
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
<p class="mt-2 flex items-center text-sm text-secondary-500 dark:text-secondary-400 sm:mt-0 sm:ml-6">
|
|
||||||
<i class="fas fa-calendar-alt flex-shrink-0 mr-1.5 h-5 w-5 text-secondary-400 dark:text-secondary-500"></i>
|
|
||||||
Schedule: { job.Schedule }
|
|
||||||
</p>
|
|
||||||
if job.LastRun != nil {
|
|
||||||
<p class="mt-2 flex items-center text-sm text-secondary-500 dark:text-secondary-400 sm:mt-0 sm:ml-6">
|
|
||||||
<i class="fas fa-history flex-shrink-0 mr-1.5 h-5 w-5 text-secondary-400 dark:text-secondary-500"></i>
|
|
||||||
Last Run: { job.LastRun.Format("2006-01-02 15:04:05") }
|
|
||||||
</p>
|
</p>
|
||||||
|
if job.GetEnabled() {
|
||||||
|
<span class="inline-flex items-center bg-green-100 text-green-800 text-xs font-medium mr-2 px-2.5 py-0.5 rounded-full dark:bg-green-900 dark:text-green-300 ml-2">
|
||||||
|
<i class="fas fa-circle-check text-green-500 dark:text-green-400 mr-1"></i>
|
||||||
|
Active
|
||||||
|
</span>
|
||||||
|
} else {
|
||||||
|
<span class="inline-flex items-center bg-gray-100 text-gray-800 text-xs font-medium mr-2 px-2.5 py-0.5 rounded-full dark:bg-gray-700 dark:text-gray-300 ml-2">
|
||||||
|
<i class="fas fa-circle-xmark text-gray-500 dark:text-gray-400 mr-1"></i>
|
||||||
|
Inactive
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="ml-2 flex-shrink-0 flex space-x-2">
|
||||||
|
<button
|
||||||
|
hx-post={ fmt.Sprintf("/jobs/%d/run", job.ID) }
|
||||||
|
hx-swap="none"
|
||||||
|
class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-xs px-3 py-1.5 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800"
|
||||||
|
data-job-id={ fmt.Sprint(job.ID) }
|
||||||
|
data-job-name={ job.Name }
|
||||||
|
onclick="window.runJob(this)">
|
||||||
|
<i class="fas fa-play mr-1"></i>
|
||||||
|
Run Now
|
||||||
|
</button>
|
||||||
|
<a href={ templ.SafeURL(fmt.Sprintf("/jobs/%d", job.ID)) } class="py-1.5 px-3 text-xs font-medium text-gray-900 focus:outline-none bg-white rounded-lg border border-gray-200 hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-4 focus:ring-gray-100 dark:focus:ring-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700">
|
||||||
|
<i class="fas fa-pen-to-square mr-1"></i>
|
||||||
|
Edit
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
hx-post={ fmt.Sprintf("/jobs/%d/duplicate", job.ID) }
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
hx-target="body"
|
||||||
|
class="text-indigo-700 bg-indigo-100 hover:bg-indigo-200 focus:ring-4 focus:outline-none focus:ring-indigo-300 font-medium rounded-lg text-xs px-3 py-1.5 text-center inline-flex items-center dark:bg-indigo-700 dark:text-indigo-300 dark:hover:bg-indigo-600 dark:focus:ring-indigo-800"
|
||||||
|
data-job-id={ fmt.Sprint(job.ID) }
|
||||||
|
data-job-name={ job.Name }>
|
||||||
|
<i class="fas fa-clone mr-1"></i>
|
||||||
|
Duplicate
|
||||||
|
</button>
|
||||||
|
<!-- Add delete dialog for each job -->
|
||||||
|
@JobDialog(
|
||||||
|
fmt.Sprintf("delete-job-dialog-%d", job.ID),
|
||||||
|
"Delete Job",
|
||||||
|
fmt.Sprintf("Are you sure you want to delete the job '%s'? This cannot be undone.", determineJobName(job)),
|
||||||
|
"text-white bg-red-700 hover:bg-red-800 focus:ring-4 focus:ring-red-300 font-medium rounded-lg text-sm px-5 py-2.5 dark:bg-red-600 dark:hover:bg-red-700 focus:outline-none dark:focus:ring-red-800",
|
||||||
|
"Delete",
|
||||||
|
"delete",
|
||||||
|
job.ID,
|
||||||
|
determineJobName(job),
|
||||||
|
)
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={ showModal(fmt.Sprintf("delete-job-dialog-%d", job.ID)) }
|
||||||
|
class="focus:outline-none text-white bg-red-700 hover:bg-red-800 focus:ring-4 focus:ring-red-300 font-medium rounded-lg text-xs px-3 py-1.5 dark:bg-red-600 dark:hover:bg-red-700 dark:focus:ring-red-900">
|
||||||
|
<i class="fas fa-trash mr-1"></i>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 sm:flex sm:justify-between">
|
||||||
|
<div class="sm:flex flex-wrap">
|
||||||
|
<p class="flex items-center text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
<i class="fas fa-gear text-gray-400 dark:text-gray-500 mr-1.5"></i>
|
||||||
|
Configs:
|
||||||
|
<span class="ml-1">
|
||||||
|
if count, ok := data.ConfigCount[job.ID]; ok && count > 1 {
|
||||||
|
{ fmt.Sprintf("%d configurations", count) }
|
||||||
|
} else if job.ConfigID > 0 {
|
||||||
|
{ job.Config.Name }
|
||||||
|
} else {
|
||||||
|
{ "None" }
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<p class="mt-2 flex items-center text-sm text-gray-500 dark:text-gray-400 sm:mt-0 sm:ml-6">
|
||||||
|
<i class="fas fa-calendar text-gray-400 dark:text-gray-500 mr-1.5"></i>
|
||||||
|
Schedule: { job.Schedule }
|
||||||
|
</p>
|
||||||
|
if job.LastRun != nil {
|
||||||
|
<p class="mt-2 flex items-center text-sm text-gray-500 dark:text-gray-400 sm:mt-0 sm:ml-6">
|
||||||
|
<i class="fas fa-clock text-gray-400 dark:text-gray-500 mr-1.5"></i>
|
||||||
|
Last Run: { job.LastRun.Format("2006-01-02 15:04:05") }
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
if job.NextRun != nil {
|
||||||
|
<div class="mt-2 flex items-center text-sm text-gray-500 dark:text-gray-400 sm:mt-0">
|
||||||
|
<i class="fas fa-hourglass-start text-gray-400 dark:text-gray-500 mr-1.5"></i>
|
||||||
|
<p>
|
||||||
|
Next Run: { job.NextRun.Format("2006-01-02 15:04:05") }
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
if job.NextRun != nil {
|
|
||||||
<div class="mt-2 flex items-center text-sm text-secondary-500 dark:text-secondary-400 sm:mt-0">
|
|
||||||
<i class="fas fa-clock flex-shrink-0 mr-1.5 h-5 w-5 text-secondary-400 dark:text-secondary-500"></i>
|
|
||||||
<p>
|
|
||||||
Next Run: { job.NextRun.Format("2006-01-02 15:04:05") }
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</li>
|
||||||
</li>
|
}
|
||||||
}
|
</ul>
|
||||||
</ul>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Help Notice -->
|
<!-- Help Notice -->
|
||||||
<div class="mt-8 text-center">
|
<div class="mt-8 p-4 bg-gray-50 border border-gray-200 rounded-lg dark:bg-gray-800 dark:border-gray-700">
|
||||||
<p class="text-sm text-secondary-500 dark:text-secondary-400">
|
<div class="flex">
|
||||||
<i class="fas fa-info-circle mr-1 text-primary-500"></i>
|
<div class="flex-shrink-0">
|
||||||
Transfer jobs run according to their schedule and transfer files between configured sources and destinations
|
<i class="fas fa-info-circle text-blue-400 dark:text-blue-400"></i>
|
||||||
</p>
|
</div>
|
||||||
|
<div class="ml-3">
|
||||||
|
<p class="text-sm text-blue-700 dark:text-blue-400">
|
||||||
|
Transfer jobs run according to their schedule and transfer files between configured sources and destinations
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Set dark background color if in dark mode
|
||||||
|
if (document.documentElement.classList.contains('dark')) {
|
||||||
|
document.getElementById('jobs-container').style.backgroundColor = '#111827';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add event listener for theme changes
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const themeToggle = document.getElementById('theme-toggle');
|
||||||
|
if (themeToggle) {
|
||||||
|
themeToggle.addEventListener('click', function() {
|
||||||
|
setTimeout(function() {
|
||||||
|
const isDark = document.documentElement.classList.contains('dark');
|
||||||
|
document.getElementById('jobs-container').style.backgroundColor = isDark ? '#111827' : 'rgb(249, 250, 251)';
|
||||||
|
}, 50);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+463
-406
@@ -2,19 +2,21 @@ package components
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"time"
|
"time"
|
||||||
"fmt"
|
"github.com/starfleetcptn/gomft/components/shared/toast"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AppVersion will be set at build time using ldflags
|
// AppVersion will be set at build time using ldflags
|
||||||
// Example build command:
|
// Example build command:
|
||||||
// go build -ldflags "-X github.com/starfleetcptn/gomft/components.AppVersion=1.2.3"
|
// go build -ldflags "-X github.com/starfleetcptn/gomft/components.AppVersion=1.2.3"
|
||||||
var AppVersion = "dev"
|
var AppVersion = "DEV"
|
||||||
|
|
||||||
// GetReleaseURL returns the URL to the specific GitHub release
|
// GetReleaseURL returns the URL to the specific GitHub release
|
||||||
func GetReleaseURL() templ.SafeURL {
|
func GetReleaseURL() templ.SafeURL {
|
||||||
return templ.SafeURL(fmt.Sprintf("https://github.com/starfleetcptn/gomft/releases/tag/v%s", AppVersion))
|
return templ.SafeURL(fmt.Sprintf("https://github.com/starfleetcptn/gomft/releases/tag/%s", AppVersion))
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateTemplateContext creates a new context with user information from Gin's context
|
// CreateTemplateContext creates a new context with user information from Gin's context
|
||||||
@@ -41,465 +43,520 @@ templ Layout(title string) {
|
|||||||
|
|
||||||
templ LayoutWithContext(title string, ctx context.Context) {
|
templ LayoutWithContext(title string, ctx context.Context) {
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en" class="light h-full p-0 m-0">
|
<html lang="en" class="h-full bg-gray-50 dark:bg-gray-900">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8"/>
|
<meta charset="UTF-8"/>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover"/>
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover"/>
|
||||||
<meta name="theme-color" content="#2563eb"/>
|
<meta name="theme-color" content="#2563eb"/>
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes"/>
|
<meta name="apple-mobile-web-app-capable" content="yes"/>
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"/>
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"/>
|
||||||
<link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png">
|
<link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png"/>
|
||||||
<link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png">
|
<link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png"/>
|
||||||
<link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png">
|
<link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png"/>
|
||||||
<link rel="manifest" href="/static/site.webmanifest">
|
<link rel="manifest" href="/static/site.webmanifest"/>
|
||||||
<title>{ title } - GoMFT</title>
|
<title>{ title } - GoMFT</title>
|
||||||
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
<!-- Font Awesome -->
|
||||||
<script src="https://unpkg.com/alpinejs@3.13.5/dist/cdn.min.js" defer></script>
|
<link rel="stylesheet" href="/static/dist/fontawesome/css/all.min.css"/>
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
<!-- Application assets -->
|
||||||
<script src="/static/js/app.js"></script>
|
<link rel="stylesheet" href="/static/dist/app.css"/>
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" />
|
<link rel="stylesheet" href="https://rsms.me/inter/inter.css"/>
|
||||||
<link rel="stylesheet" href="/static/css/app.css"/>
|
|
||||||
<!-- Notyf Toast Notifications -->
|
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/notyf@3/notyf.min.css" />
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/notyf@3/notyf.min.js"></script>
|
|
||||||
<script>
|
|
||||||
// Initialize Notyf and make it available globally
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
window.notyfInstance = new Notyf({
|
|
||||||
duration: 3000,
|
|
||||||
position: {
|
|
||||||
x: 'right',
|
|
||||||
y: 'bottom',
|
|
||||||
},
|
|
||||||
types: [
|
|
||||||
{
|
|
||||||
type: 'success',
|
|
||||||
className: 'notyf__toast--success',
|
|
||||||
background: '#10B981',
|
|
||||||
icon: {
|
|
||||||
className: 'fas fa-check-circle',
|
|
||||||
tagName: 'i'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'error',
|
|
||||||
className: 'notyf__toast--error',
|
|
||||||
background: '#EF4444',
|
|
||||||
icon: {
|
|
||||||
className: 'fas fa-exclamation-circle',
|
|
||||||
tagName: 'i'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
});
|
|
||||||
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
<script>
|
<script>
|
||||||
tailwind.config = {
|
// Immediate theme application
|
||||||
darkMode: 'class',
|
// This script runs before the DOM is fully loaded
|
||||||
theme: {
|
(function() {
|
||||||
extend: {
|
const isDark = localStorage.theme === 'dark' ||
|
||||||
colors: {
|
(!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||||
primary: {
|
|
||||||
50: '#f0f9ff',
|
if (isDark) {
|
||||||
100: '#e0f2fe',
|
document.documentElement.classList.add('dark');
|
||||||
200: '#bae6fd',
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
300: '#7dd3fc',
|
document.body.classList.add('dark');
|
||||||
400: '#38bdf8',
|
|
||||||
500: '#0ea5e9',
|
// Apply dark theme to containers
|
||||||
600: '#0284c7',
|
const containers = ['jobs-container', 'configs-container'];
|
||||||
700: '#0369a1',
|
containers.forEach(function(id) {
|
||||||
800: '#075985',
|
const container = document.getElementById(id);
|
||||||
900: '#0c4a6e',
|
if (container) {
|
||||||
950: '#082f49',
|
container.classList.add('dark');
|
||||||
},
|
container.style.backgroundColor = '#111827';
|
||||||
secondary: {
|
}
|
||||||
50: '#f8fafc',
|
});
|
||||||
100: '#f1f5f9',
|
});
|
||||||
200: '#e2e8f0',
|
|
||||||
300: '#cbd5e1',
|
|
||||||
400: '#94a3b8',
|
|
||||||
500: '#64748b',
|
|
||||||
600: '#475569',
|
|
||||||
700: '#334155',
|
|
||||||
800: '#1e293b',
|
|
||||||
900: '#0f172a',
|
|
||||||
950: '#020617',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
fontFamily: {
|
|
||||||
sans: ['Inter var', 'ui-sans-serif', 'system-ui', '-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'Roboto', 'Helvetica Neue', 'Arial', 'sans-serif'],
|
|
||||||
},
|
|
||||||
boxShadow: {
|
|
||||||
'custom': '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)',
|
|
||||||
'custom-lg': '0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
})();
|
||||||
</script>
|
</script>
|
||||||
<style type="text/tailwindcss">
|
<style>
|
||||||
/* Reset default browser margins and padding */
|
|
||||||
html, body {
|
html, body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
overflow-x: hidden; /* Prevent horizontal scrolling */
|
overflow-x: hidden;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer components {
|
|
||||||
.btn-primary {
|
|
||||||
@apply px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-all duration-200 shadow-md hover:shadow-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2;
|
|
||||||
}
|
|
||||||
.btn-secondary {
|
|
||||||
@apply px-4 py-2 bg-secondary-200 text-secondary-800 rounded-lg hover:bg-secondary-300 transition-all duration-200 shadow-sm hover:shadow-md focus:outline-none focus:ring-2 focus:ring-secondary-300 focus:ring-offset-2 dark:bg-secondary-700 dark:text-secondary-100 dark:hover:bg-secondary-600;
|
|
||||||
}
|
|
||||||
.btn-danger {
|
|
||||||
@apply px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-all duration-200 shadow-md hover:shadow-lg focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2;
|
|
||||||
}
|
|
||||||
.btn-warning {
|
|
||||||
@apply px-4 py-2 bg-yellow-600 text-white rounded-lg hover:bg-yellow-700 transition-all duration-200 shadow-md hover:shadow-lg focus:outline-none focus:ring-2 focus:ring-yellow-500 focus:ring-offset-2;
|
|
||||||
}
|
|
||||||
.form-input {
|
|
||||||
@apply w-full px-3 py-2 border border-secondary-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 shadow-sm transition-all duration-200 dark:bg-secondary-800 dark:border-secondary-700 dark:text-white;
|
|
||||||
}
|
|
||||||
.form-checkbox {
|
|
||||||
@apply rounded border-secondary-300 text-primary-600 shadow-sm focus:border-primary-300 focus:ring focus:ring-primary-200 focus:ring-opacity-50 dark:border-secondary-700 dark:bg-secondary-800;
|
|
||||||
}
|
|
||||||
.card {
|
|
||||||
@apply bg-white dark:bg-secondary-800 rounded-xl shadow-custom overflow-hidden border border-secondary-200 dark:border-secondary-700 transition-all duration-200 hover:shadow-custom-lg;
|
|
||||||
}
|
|
||||||
.card-header {
|
|
||||||
@apply px-6 py-4 border-b border-secondary-200 dark:border-secondary-700 bg-secondary-50 dark:bg-secondary-900;
|
|
||||||
}
|
|
||||||
.card-body {
|
|
||||||
@apply p-6;
|
|
||||||
}
|
|
||||||
.badge {
|
|
||||||
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium;
|
|
||||||
}
|
|
||||||
.badge-success {
|
|
||||||
@apply bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-100;
|
|
||||||
}
|
|
||||||
.badge-warning {
|
|
||||||
@apply bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-100;
|
|
||||||
}
|
|
||||||
.badge-danger {
|
|
||||||
@apply bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-100;
|
|
||||||
}
|
|
||||||
.badge-info {
|
|
||||||
@apply bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-100;
|
|
||||||
}
|
|
||||||
.table-container {
|
|
||||||
@apply overflow-hidden rounded-lg shadow-sm border border-secondary-200 dark:border-secondary-700;
|
|
||||||
}
|
|
||||||
.table {
|
|
||||||
@apply min-w-full divide-y divide-secondary-200 dark:divide-secondary-700;
|
|
||||||
}
|
|
||||||
.table thead {
|
|
||||||
@apply bg-secondary-50 dark:bg-secondary-900;
|
|
||||||
}
|
|
||||||
.table th {
|
|
||||||
@apply px-6 py-3 text-left text-xs font-medium text-secondary-500 uppercase tracking-wider dark:text-secondary-400;
|
|
||||||
}
|
|
||||||
.table tbody {
|
|
||||||
@apply bg-white dark:bg-secondary-800 divide-y divide-secondary-200 dark:divide-secondary-700;
|
|
||||||
}
|
|
||||||
.table td {
|
|
||||||
@apply px-6 py-4 whitespace-nowrap text-sm text-secondary-900 dark:text-secondary-100;
|
|
||||||
}
|
|
||||||
.nav-link {
|
|
||||||
@apply inline-flex items-center px-1 pt-1 text-secondary-500 hover:text-secondary-700 dark:text-secondary-400 dark:hover:text-secondary-200 border-b-2 border-transparent hover:border-primary-500 transition-all duration-200;
|
|
||||||
}
|
|
||||||
.nav-link-active {
|
|
||||||
@apply inline-flex items-center px-1 pt-1 text-primary-600 dark:text-primary-400 border-b-2 border-primary-500;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Dark mode styles */
|
|
||||||
.dark body {
|
|
||||||
@apply bg-secondary-900 text-secondary-100;
|
|
||||||
}
|
|
||||||
.dark .bg-white {
|
|
||||||
@apply bg-secondary-800;
|
|
||||||
}
|
|
||||||
.dark .text-secondary-800 {
|
|
||||||
@apply text-secondary-100;
|
|
||||||
}
|
|
||||||
.dark .text-secondary-700 {
|
|
||||||
@apply text-secondary-200;
|
|
||||||
}
|
|
||||||
.dark .text-secondary-500 {
|
|
||||||
@apply text-secondary-300;
|
|
||||||
}
|
|
||||||
.dark .hover\:text-secondary-700:hover {
|
|
||||||
@apply text-secondary-100;
|
|
||||||
}
|
|
||||||
.dark .hover\:bg-secondary-100:hover {
|
|
||||||
@apply bg-secondary-700;
|
|
||||||
}
|
|
||||||
.dark .bg-secondary-50 {
|
|
||||||
@apply bg-secondary-900;
|
|
||||||
}
|
|
||||||
.dark .bg-secondary-200 {
|
|
||||||
@apply bg-secondary-700;
|
|
||||||
}
|
|
||||||
.dark .shadow-sm {
|
|
||||||
@apply shadow-secondary-900;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Additional dark mode improvements */
|
|
||||||
.dark .bg-secondary-50 {
|
|
||||||
@apply bg-secondary-900;
|
|
||||||
}
|
|
||||||
.dark .bg-secondary-100 {
|
|
||||||
@apply bg-secondary-800;
|
|
||||||
}
|
|
||||||
.dark .border-secondary-200 {
|
|
||||||
@apply border-secondary-700;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Custom animations */
|
|
||||||
@keyframes fadeIn {
|
@keyframes fadeIn {
|
||||||
from { opacity: 0; }
|
from { opacity: 0; }
|
||||||
to { opacity: 1; }
|
to { opacity: 1; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.animate-fadeIn {
|
.animate-fadeIn {
|
||||||
animation: fadeIn 0.3s ease-in-out;
|
animation: fadeIn 0.3s ease-in-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Custom scrollbar */
|
.pb-mobile-nav {
|
||||||
::-webkit-scrollbar {
|
padding-bottom: 4rem;
|
||||||
width: 8px;
|
|
||||||
height: 8px;
|
|
||||||
}
|
}
|
||||||
::-webkit-scrollbar-track {
|
|
||||||
background: theme('colors.secondary.100');
|
@media (min-width: 640px) {
|
||||||
border-radius: 4px;
|
.pb-mobile-nav {
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.dark ::-webkit-scrollbar-track {
|
|
||||||
background: theme('colors.secondary.800');
|
/* Ensure dark mode background extends to full height */
|
||||||
|
body.dark {
|
||||||
|
background-color: #111827; /* gray-900 */
|
||||||
}
|
}
|
||||||
::-webkit-scrollbar-thumb {
|
|
||||||
background: theme('colors.secondary.300');
|
/* Force dark background on specific pages */
|
||||||
border-radius: 4px;
|
body.dark .configs-page,
|
||||||
|
body.dark .jobs-page {
|
||||||
|
background-color: #111827 !important; /* gray-900 */
|
||||||
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
.dark ::-webkit-scrollbar-thumb {
|
|
||||||
background: theme('colors.secondary.600');
|
/* Ensure dark mode fills entire page */
|
||||||
|
body.dark {
|
||||||
|
background-color: #111827 !important;
|
||||||
}
|
}
|
||||||
::-webkit-scrollbar-thumb:hover {
|
|
||||||
background: theme('colors.secondary.500');
|
/* Force container backgrounds */
|
||||||
|
#jobs-container.dark, #configs-container.dark {
|
||||||
|
background-color: #111827 !important;
|
||||||
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
.dark ::-webkit-scrollbar-thumb:hover {
|
|
||||||
background: theme('colors.secondary.500');
|
/* Override any white backgrounds in dark mode */
|
||||||
|
body.dark .bg-white {
|
||||||
|
background-color: #1f2937 !important;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
<link rel="stylesheet" href="https://rsms.me/inter/inter.css">
|
|
||||||
</head>
|
</head>
|
||||||
<body class="min-h-screen flex flex-col bg-secondary-50 dark:bg-secondary-900 h-full p-0 m-0">
|
<body class="min-h-full bg-gray-50 dark:bg-gray-900" style="min-height: 100vh; display: flex; flex-direction: column;" hx-on::after-swap="initFlowbite()">
|
||||||
if isLoggedIn(ctx) {
|
if isLoggedIn(ctx) {
|
||||||
<nav class="bg-white dark:bg-secondary-800 shadow-sm sticky top-0 z-10 w-full">
|
<!-- Application Shell -->
|
||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
<div class="flex min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||||
<div class="flex justify-between h-16">
|
<!-- Desktop Sidebar -->
|
||||||
<div class="flex">
|
<aside class="hidden md:flex md:w-64 md:flex-col md:fixed md:inset-y-0">
|
||||||
|
<div class="flex flex-col flex-grow pt-5 bg-white dark:bg-gray-800 overflow-y-auto border-r border-gray-200 dark:border-gray-700">
|
||||||
|
<div class="flex items-center flex-shrink-0 px-4">
|
||||||
<a href="/" class="flex items-center text-xl font-bold text-primary-600 dark:text-primary-400">
|
<a href="/" class="flex items-center text-xl font-bold text-primary-600 dark:text-primary-400">
|
||||||
<i class="fas fa-exchange-alt mr-2"></i>
|
<img src="/static/img/logo.png" alt="GoMFT" class="w-8 h-8 mr-2">
|
||||||
GoMFT
|
GoMFT
|
||||||
</a>
|
</a>
|
||||||
<div class="hidden sm:ml-6 sm:flex sm:space-x-8">
|
</div>
|
||||||
<a href="/dashboard" class="nav-link">
|
<nav class="flex-1 px-2 py-4 bg-white dark:bg-gray-800">
|
||||||
<i class="fas fa-tachometer-alt mr-1"></i> Dashboard
|
<div class="space-y-1">
|
||||||
|
<a href="/dashboard" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
|
||||||
|
<i class="fas fa-chart-pie mr-3 text-gray-500 dark:text-gray-400"></i>
|
||||||
|
Dashboard
|
||||||
</a>
|
</a>
|
||||||
<a href="/configs" class="nav-link">
|
<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-cogs mr-1"></i> Configs
|
<i class="fas fa-server mr-3 text-gray-500 dark:text-gray-400"></i>
|
||||||
|
Storage Providers
|
||||||
</a>
|
</a>
|
||||||
<a href="/jobs" class="nav-link">
|
<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-tasks mr-1"></i> Jobs
|
<i class="fas fa-exchange-alt mr-3 text-gray-500 dark:text-gray-400"></i>
|
||||||
|
Transfer Configurations
|
||||||
</a>
|
</a>
|
||||||
<a href="/history" class="nav-link">
|
<a href="/jobs" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
|
||||||
<i class="fas fa-history mr-1"></i> History
|
<i class="fas fa-calendar-alt mr-3 text-gray-500 dark:text-gray-400"></i>
|
||||||
|
Scheduled Jobs
|
||||||
</a>
|
</a>
|
||||||
<a href="/files" class="nav-link">
|
<a href="/calendar" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
|
||||||
<i class="fas fa-file-alt mr-1"></i> Files
|
<i class="fas fa-calendar-week mr-3 text-gray-500 dark:text-gray-400"></i>
|
||||||
|
Transfer Calendar
|
||||||
|
</a>
|
||||||
|
<a href="/history" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
|
||||||
|
<i class="fas fa-history mr-3 text-gray-500 dark:text-gray-400"></i>
|
||||||
|
Transfer History
|
||||||
|
</a>
|
||||||
|
<a href="/files" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
|
||||||
|
<i class="fas fa-file-alt mr-3 text-gray-500 dark:text-gray-400"></i>
|
||||||
|
Files
|
||||||
</a>
|
</a>
|
||||||
if isAdmin(ctx) {
|
if isAdmin(ctx) {
|
||||||
<a href="/admin/users" class="nav-link">
|
<p class="px-3 text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
<i class="fas fa-users mr-1"></i> Users
|
Administration
|
||||||
|
</p>
|
||||||
|
<a href="/admin/users" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
|
||||||
|
<i class="fas fa-users w-4 h-4 mr-2 text-gray-500 dark:text-gray-400"></i>
|
||||||
|
User Management
|
||||||
</a>
|
</a>
|
||||||
<a href="/admin/tools" class="nav-link">
|
<a href="/admin/roles" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
|
||||||
<i class="fas fa-tools mr-1"></i> Admin Tools
|
<i class="fas fa-user-shield w-4 h-4 mr-2 text-gray-500 dark:text-gray-400"></i>
|
||||||
|
Role Management
|
||||||
</a>
|
</a>
|
||||||
|
<a href="/admin/audit" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
|
||||||
|
<i class="fas fa-clipboard-list w-4 h-4 mr-2 text-gray-500 dark:text-gray-400"></i>
|
||||||
|
Audit Logs
|
||||||
|
</a>
|
||||||
|
<a href="/admin/logs" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
|
||||||
|
<i class="fas fa-stream w-4 h-4 mr-2 text-gray-500 dark:text-gray-400"></i>
|
||||||
|
Log Viewer
|
||||||
|
</a>
|
||||||
|
<a href="/admin/database" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
|
||||||
|
<i class="fas fa-database w-4 h-4 mr-2 text-gray-500 dark:text-gray-400"></i>
|
||||||
|
Database Tools
|
||||||
|
</a>
|
||||||
|
// Settings Dropdown
|
||||||
|
<button type="button" class="flex items-center w-full p-2 text-base text-gray-900 transition duration-75 rounded-lg group hover:bg-gray-100 dark:text-white dark:hover:bg-gray-700" aria-controls="dropdown-settings" data-collapse-toggle="dropdown-settings">
|
||||||
|
<i class="fas fa-cog w-4 h-4 mr-2 text-gray-500 dark:text-gray-400"></i>
|
||||||
|
<span class="flex-1 ms-3 text-left rtl:text-right whitespace-nowrap">Settings</span>
|
||||||
|
<svg class="w-3 h-3" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 10 6">
|
||||||
|
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 4 4 4-4"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<ul id="dropdown-settings" class="hidden py-2 space-y-2">
|
||||||
|
// <li>
|
||||||
|
// <a href="#" class="flex items-center w-full p-2 text-gray-900 transition duration-75 rounded-lg pl-11 group hover:bg-gray-100 dark:text-white dark:hover:bg-gray-700">General (Coming Soon)</a>
|
||||||
|
// </li>
|
||||||
|
<li>
|
||||||
|
<a href="/admin/settings/auth-providers" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
|
||||||
|
<i class="fas fa-user-lock w-4 h-4 mr-2 text-gray-500 dark:text-gray-400"></i>
|
||||||
|
Authentication Providers
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/admin/settings/notifications" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
|
||||||
|
<i class="fas fa-bell w-4 h-4 mr-2 text-gray-500 dark:text-gray-400"></i>
|
||||||
|
Notification Services
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</nav>
|
||||||
<div class="flex items-center space-x-4">
|
</div>
|
||||||
<!-- Theme Toggle -->
|
</aside>
|
||||||
<button
|
<!-- Mobile menu -->
|
||||||
id="theme-toggle"
|
<div class="md:hidden fixed top-0 z-30 w-full">
|
||||||
type="button"
|
<div class="bg-white dark:bg-gray-800 shadow-sm">
|
||||||
class="text-secondary-500 dark:text-secondary-400 hover:bg-secondary-100 dark:hover:bg-secondary-700 focus:outline-none focus:ring-2 focus:ring-primary-500 rounded-lg text-sm p-2"
|
<div class="flex items-center justify-between px-4 py-3">
|
||||||
onclick="toggleTheme()"
|
<a href="/" class="flex items-center text-primary-600 dark:text-primary-400">
|
||||||
>
|
<i class="fas fa-exchange-alt text-xl"></i>
|
||||||
<i class="fas fa-sun hidden dark:block"></i>
|
</a>
|
||||||
<i class="fas fa-moon block dark:hidden"></i>
|
<div class="flex items-center space-x-3">
|
||||||
</button>
|
<button data-drawer-target="mobile-menu" data-drawer-toggle="mobile-menu" class="p-2 text-gray-500 rounded-lg hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700">
|
||||||
|
|
||||||
<!-- Mobile menu button -->
|
|
||||||
<div class="sm:hidden flex items-center">
|
|
||||||
<button
|
|
||||||
id="mobile-menu-button"
|
|
||||||
class="text-secondary-500 dark:text-secondary-400 hover:bg-secondary-100 dark:hover:bg-secondary-700 focus:outline-none focus:ring-2 focus:ring-primary-500 rounded-lg text-sm p-2"
|
|
||||||
x-data="{}"
|
|
||||||
@click="$dispatch('toggle-mobile-menu')"
|
|
||||||
>
|
|
||||||
<i class="fas fa-bars text-lg"></i>
|
<i class="fas fa-bars text-lg"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- User Menu -->
|
|
||||||
<div x-data="{ open: false }" class="relative">
|
|
||||||
<button @click="open = !open" class="flex text-sm rounded-full focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500">
|
|
||||||
<span class="sr-only">Open user menu</span>
|
|
||||||
<div class="h-8 w-8 rounded-full bg-primary-100 text-primary-700 flex items-center justify-center dark:bg-primary-900 dark:text-primary-300">
|
|
||||||
<span class="text-sm font-medium">{ getUserInitial(ctx) }</span>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
<div x-show="open"
|
|
||||||
@click.away="open = false"
|
|
||||||
class="origin-top-right absolute right-0 mt-2 w-48 rounded-md shadow-lg py-1 bg-white ring-1 ring-black ring-opacity-5 focus:outline-none dark:bg-secondary-800 dark:ring-secondary-700 animate-fadeIn"
|
|
||||||
role="menu"
|
|
||||||
aria-orientation="vertical"
|
|
||||||
aria-labelledby="user-menu-button"
|
|
||||||
tabindex="-1">
|
|
||||||
<div class="px-4 py-2 text-xs text-secondary-500 dark:text-secondary-400 border-b border-secondary-200 dark:border-secondary-700">
|
|
||||||
Signed in as <span class="font-medium">{ getUserEmail(ctx) }</span>
|
|
||||||
</div>
|
|
||||||
<a href="/profile" class="block px-4 py-2 text-sm text-secondary-700 hover:bg-secondary-100 dark:text-secondary-200 dark:hover:bg-secondary-700" role="menuitem">
|
|
||||||
<i class="fas fa-user-circle mr-2"></i> Profile
|
|
||||||
</a>
|
|
||||||
<form method="POST" action="/logout">
|
|
||||||
<button type="submit" class="block w-full text-left px-4 py-2 text-sm text-red-600 hover:bg-secondary-100 dark:hover:bg-secondary-700" role="menuitem">
|
|
||||||
<i class="fas fa-sign-out-alt mr-2"></i> Sign out
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
<!-- Add this drawer element for mobile menu -->
|
||||||
|
<div id="mobile-menu" class="fixed top-0 left-0 z-40 h-screen p-4 overflow-y-auto transition-transform -translate-x-full bg-white w-64 dark:bg-gray-800" tabindex="-1">
|
||||||
<!-- Mobile Navigation Menu -->
|
<div class="flex items-center justify-between">
|
||||||
<div
|
<a href="/" class="flex items-center text-xl font-bold text-primary-600 dark:text-primary-400">
|
||||||
x-data="{ open: false }"
|
<!-- GoMFT Logo -->
|
||||||
x-on:toggle-mobile-menu.window="open = !open"
|
<img src="/static/img/logo.png" alt="GoMFT" class="w-8 h-8 mr-2">
|
||||||
x-show="open"
|
|
||||||
x-transition:enter="transition ease-out duration-200"
|
|
||||||
x-transition:enter-start="opacity-0 transform -translate-y-2"
|
|
||||||
x-transition:enter-end="opacity-100 transform translate-y-0"
|
|
||||||
x-transition:leave="transition ease-in duration-150"
|
|
||||||
x-transition:leave-start="opacity-100 transform translate-y-0"
|
|
||||||
x-transition:leave-end="opacity-0 transform -translate-y-2"
|
|
||||||
class="sm:hidden bg-white dark:bg-secondary-800 shadow-md w-full left-0 right-0"
|
|
||||||
>
|
|
||||||
<div class="max-w-7xl mx-auto px-2 pt-2 pb-3 space-y-1">
|
|
||||||
<a href="/dashboard" class="block px-3 py-2 rounded-md text-base font-medium text-secondary-700 dark:text-secondary-300 hover:bg-primary-50 dark:hover:bg-secondary-700 hover:text-primary-600 dark:hover:text-primary-400">
|
|
||||||
<i class="fas fa-tachometer-alt mr-2"></i> Dashboard
|
|
||||||
</a>
|
|
||||||
<a href="/configs" class="block px-3 py-2 rounded-md text-base font-medium text-secondary-700 dark:text-secondary-300 hover:bg-primary-50 dark:hover:bg-secondary-700 hover:text-primary-600 dark:hover:text-primary-400">
|
|
||||||
<i class="fas fa-cogs mr-2"></i> Configs
|
|
||||||
</a>
|
|
||||||
<a href="/jobs" class="block px-3 py-2 rounded-md text-base font-medium text-secondary-700 dark:text-secondary-300 hover:bg-primary-50 dark:hover:bg-secondary-700 hover:text-primary-600 dark:hover:text-primary-400">
|
|
||||||
<i class="fas fa-tasks mr-2"></i> Jobs
|
|
||||||
</a>
|
|
||||||
<a href="/history" class="block px-3 py-2 rounded-md text-base font-medium text-secondary-700 dark:text-secondary-300 hover:bg-primary-50 dark:hover:bg-secondary-700 hover:text-primary-600 dark:hover:text-primary-400">
|
|
||||||
<i class="fas fa-history mr-2"></i> History
|
|
||||||
</a>
|
|
||||||
<a href="/files" class="block px-3 py-2 rounded-md text-base font-medium text-secondary-700 dark:text-secondary-300 hover:bg-primary-50 dark:hover:bg-secondary-700 hover:text-primary-600 dark:hover:text-primary-400">
|
|
||||||
<i class="fas fa-file-alt mr-2"></i> Files
|
|
||||||
</a>
|
|
||||||
if isAdmin(ctx) {
|
|
||||||
<a href="/admin/users" class="block px-3 py-2 rounded-md text-base font-medium text-secondary-700 dark:text-secondary-300 hover:bg-primary-50 dark:hover:bg-secondary-700 hover:text-primary-600 dark:hover:text-primary-400">
|
|
||||||
<i class="fas fa-users mr-2"></i> Users
|
|
||||||
</a>
|
</a>
|
||||||
<a href="/admin/tools" class="block px-3 py-2 rounded-md text-base font-medium text-secondary-700 dark:text-secondary-300 hover:bg-primary-50 dark:hover:bg-secondary-700 hover:text-primary-600 dark:hover:text-primary-400">
|
<button type="button" data-drawer-hide="mobile-menu" class="text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm p-1.5 inline-flex items-center dark:hover:bg-gray-600 dark:hover:text-white">
|
||||||
<i class="fas fa-tools mr-2"></i> Admin Tools
|
<i class="fas fa-times"></i>
|
||||||
</a>
|
|
||||||
}
|
|
||||||
<a href="/profile" class="block px-3 py-2 rounded-md text-base font-medium text-secondary-700 dark:text-secondary-300 hover:bg-primary-50 dark:hover:bg-secondary-700 hover:text-primary-600 dark:hover:text-primary-400">
|
|
||||||
<i class="fas fa-user-circle mr-2"></i> Profile
|
|
||||||
</a>
|
|
||||||
<form method="POST" action="/logout">
|
|
||||||
<button type="submit" class="w-full text-left block px-3 py-2 rounded-md text-base font-medium text-red-600 hover:bg-red-50 dark:hover:bg-red-900 hover:text-red-700 dark:hover:text-red-400">
|
|
||||||
<i class="fas fa-sign-out-alt mr-2"></i> Sign out
|
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</div>
|
||||||
|
<!-- Copy the same navigation items from the desktop sidebar -->
|
||||||
|
<nav class="mt-5">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<!-- Copy the same links from the desktop sidebar -->
|
||||||
|
<a href="/dashboard" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
|
||||||
|
<i class="fas fa-chart-pie mr-3 text-gray-500 dark:text-gray-400"></i>
|
||||||
|
Dashboard
|
||||||
|
</a>
|
||||||
|
<a href="/storage-providers" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
|
||||||
|
<i class="fas fa-server mr-3 text-gray-500 dark:text-gray-400"></i>
|
||||||
|
Storage Providers
|
||||||
|
</a>
|
||||||
|
<a href="/configs" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
|
||||||
|
<i class="fas fa-exchange-alt mr-3 text-gray-500 dark:text-gray-400"></i>
|
||||||
|
Transfer Configurations
|
||||||
|
</a>
|
||||||
|
<a href="/jobs" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
|
||||||
|
<i class="fas fa-calendar-alt mr-3 text-gray-500 dark:text-gray-400"></i>
|
||||||
|
Scheduled Jobs
|
||||||
|
</a>
|
||||||
|
<a href="/calendar" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
|
||||||
|
<i class="fas fa-calendar-week mr-3 text-gray-500 dark:text-gray-400"></i>
|
||||||
|
Transfer Calendar
|
||||||
|
</a>
|
||||||
|
<a href="/history" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
|
||||||
|
<i class="fas fa-history mr-3 text-gray-500 dark:text-gray-400"></i>
|
||||||
|
Transfer History
|
||||||
|
</a>
|
||||||
|
<a href="/files" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
|
||||||
|
<i class="fas fa-file-alt mr-3 text-gray-500 dark:text-gray-400"></i>
|
||||||
|
Files
|
||||||
|
</a>
|
||||||
|
if isAdmin(ctx) {
|
||||||
|
<p class="px-3 text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
|
Administration
|
||||||
|
</p>
|
||||||
|
<a href="/admin/users" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
|
||||||
|
<i class="fas fa-users w-4 h-4 mr-2 text-gray-500 dark:text-gray-400"></i>
|
||||||
|
User Management
|
||||||
|
</a>
|
||||||
|
<a href="/admin/roles" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
|
||||||
|
<i class="fas fa-user-shield w-4 h-4 mr-2 text-gray-500 dark:text-gray-400"></i>
|
||||||
|
Role Management
|
||||||
|
</a>
|
||||||
|
<a href="/admin/audit" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
|
||||||
|
<i class="fas fa-clipboard-list w-4 h-4 mr-2 text-gray-500 dark:text-gray-400"></i>
|
||||||
|
Audit Logs
|
||||||
|
</a>
|
||||||
|
<a href="/admin/logs" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
|
||||||
|
<i class="fas fa-stream w-4 h-4 mr-2 text-gray-500 dark:text-gray-400"></i>
|
||||||
|
Log Viewer
|
||||||
|
</a>
|
||||||
|
<a href="/admin/database" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
|
||||||
|
<i class="fas fa-database w-4 h-4 mr-2 text-gray-500 dark:text-gray-400"></i>
|
||||||
|
Database Tools
|
||||||
|
</a>
|
||||||
|
// Settings Dropdown
|
||||||
|
<button type="button" class="flex items-center w-full p-2 text-base text-gray-900 transition duration-75 rounded-lg group hover:bg-gray-100 dark:text-white dark:hover:bg-gray-700" aria-controls="dropdown-settings-mobile" data-collapse-toggle="dropdown-settings-mobile">
|
||||||
|
<i class="fas fa-cog w-4 h-4 mr-2 text-gray-500 dark:text-gray-400"></i>
|
||||||
|
<span class="flex-1 ms-3 text-left rtl:text-right whitespace-nowrap">Settings</span>
|
||||||
|
<svg class="w-3 h-3" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 10 6">
|
||||||
|
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 4 4 4-4"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<ul id="dropdown-settings-mobile" class="hidden py-2 space-y-2">
|
||||||
|
// <li>
|
||||||
|
// <a href="#" class="flex items-center w-full p-2 text-gray-900 transition duration-75 rounded-lg pl-11 group hover:bg-gray-100 dark:text-white dark:hover:bg-gray-700">General (Coming Soon)</a>
|
||||||
|
// </li>
|
||||||
|
<li>
|
||||||
|
<a href="/admin/settings/auth-providers" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
|
||||||
|
<i class="fas fa-user-lock w-4 h-4 mr-2 text-gray-500 dark:text-gray-400"></i>
|
||||||
|
Authentication Providers
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/admin/settings/notifications" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
|
||||||
|
<i class="fas fa-bell w-4 h-4 mr-2 text-gray-500 dark:text-gray-400"></i>
|
||||||
|
Notification Services
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Main Content -->
|
||||||
|
<div class="md:pl-64 flex flex-col flex-1 w-full bg-gray-50 dark:bg-gray-900">
|
||||||
|
<!-- Main header -->
|
||||||
|
<header class="bg-white dark:bg-gray-800 shadow-sm">
|
||||||
|
<div class="flex items-center justify-between px-4 py-3 sm:px-6 lg:px-8">
|
||||||
|
<div class="flex items-center space-x-3">
|
||||||
|
// <h1 class="text-lg font-semibold text-gray-900 dark:text-white">{ title }</h1>
|
||||||
|
</div>
|
||||||
|
<!-- Theme toggle and user menu -->
|
||||||
|
<div class="flex items-center space-x-4">
|
||||||
|
<!-- Theme Toggle -->
|
||||||
|
<button
|
||||||
|
id="theme-toggle"
|
||||||
|
type="button"
|
||||||
|
class="p-2 text-gray-500 rounded-lg hover:text-gray-900 hover:bg-gray-100 dark:text-gray-400 dark:hover:text-white dark:hover:bg-gray-700 focus:ring-4 focus:ring-gray-300 dark:focus:ring-gray-600"
|
||||||
|
onclick="toggleTheme()"
|
||||||
|
>
|
||||||
|
<i class="fas fa-sun hidden dark:block"></i>
|
||||||
|
<i class="fas fa-moon block dark:hidden"></i>
|
||||||
|
</button>
|
||||||
|
<!-- Notification Bell -->
|
||||||
|
<div class="relative">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="p-2 text-gray-500 rounded-lg hover:text-gray-900 hover:bg-gray-100 dark:text-gray-400 dark:hover:text-white dark:hover:bg-gray-700 focus:ring-4 focus:ring-gray-300 dark:focus:ring-gray-600 relative"
|
||||||
|
id="notification-bell"
|
||||||
|
data-dropdown-toggle="notification-dropdown"
|
||||||
|
hx-get="/notifications/dropdown"
|
||||||
|
hx-trigger="click once"
|
||||||
|
hx-target="#notification-dropdown-content"
|
||||||
|
data-dropdown-placement="bottom-start"
|
||||||
|
>
|
||||||
|
<span class="sr-only">View notifications</span>
|
||||||
|
<i class="fas fa-bell"></i>
|
||||||
|
<!-- Notification count will be placed here via HTMX -->
|
||||||
|
</button>
|
||||||
|
<!-- Place the notification count container outside but still in the relative container -->
|
||||||
|
<div id="notification-count-container" class="absolute top-0 right-0" hx-get="/notifications/count" hx-trigger="load, notification-updated from:body"></div>
|
||||||
|
</div>
|
||||||
|
<!-- Notification dropdown -->
|
||||||
|
<div
|
||||||
|
class="hidden overflow-hidden z-50 my-4 max-w-sm md:max-w-md w-full text-base list-none bg-white rounded divide-y divide-gray-100 shadow-lg dark:divide-gray-600 dark:bg-gray-700 rounded-xl fixed"
|
||||||
|
id="notification-dropdown"
|
||||||
|
style="min-width: 320px; width: 100%;"
|
||||||
|
data-dropdown-placement="bottom-start"
|
||||||
|
>
|
||||||
|
<div id="notification-dropdown-content">
|
||||||
|
<!-- Content will be loaded dynamically via HTMX -->
|
||||||
|
<div class="block py-2 px-4 text-base font-medium text-center text-gray-700 bg-gray-50 dark:bg-gray-600 dark:text-gray-300">
|
||||||
|
Notifications
|
||||||
|
</div>
|
||||||
|
<div class="py-4 px-4 text-center text-gray-500 dark:text-gray-400">
|
||||||
|
<div class="animate-pulse flex flex-col items-center">
|
||||||
|
<div class="rounded-full bg-gray-200 dark:bg-gray-700 h-12 w-12 mb-2"></div>
|
||||||
|
<div class="h-2 bg-gray-200 dark:bg-gray-700 rounded w-24 mb-4"></div>
|
||||||
|
<div class="h-2 bg-gray-200 dark:bg-gray-700 rounded w-full mb-2"></div>
|
||||||
|
<div class="h-2 bg-gray-200 dark:bg-gray-700 rounded w-full mb-2"></div>
|
||||||
|
<div class="h-2 bg-gray-200 dark:bg-gray-700 rounded w-3/4"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a href="/notifications" class="block py-2 text-md font-medium text-center text-gray-900 bg-gray-50 hover:bg-gray-100 dark:bg-gray-600 dark:text-white dark:hover:underline">
|
||||||
|
<div class="inline-flex items-center">
|
||||||
|
<svg aria-hidden="true" class="mr-2 w-4 h-4 text-gray-500 dark:text-gray-400" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M10 12a2 2 0 100-4 2 2 0 000 4z"></path>
|
||||||
|
<path fill-rule="evenodd" d="M.458 10C1.732 5.943 5.522 3 10 3s8.268 2.943 9.542 7c-1.274 4.057-5.064 7-9.542 7S1.732 14.057.458 10zM14 10a4 4 0 11-8 0 4 4 0 018 0z" clip-rule="evenodd"></path>
|
||||||
|
</svg>
|
||||||
|
View all
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- User menu dropdown -->
|
||||||
|
<div class="relative">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex text-sm rounded-full focus:ring-4 focus:ring-gray-300 dark:focus:ring-gray-600"
|
||||||
|
id="user-menu-button"
|
||||||
|
data-dropdown-toggle="user-dropdown"
|
||||||
|
data-dropdown-placement="bottom-end"
|
||||||
|
>
|
||||||
|
<span class="sr-only">Open user menu</span>
|
||||||
|
<div class="h-8 w-8 rounded-full bg-primary-100 text-primary-700 flex items-center justify-center dark:bg-primary-900 dark:text-primary-300">
|
||||||
|
<span class="text-sm font-medium">{ getUserInitial(ctx) }</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<!-- Dropdown menu -->
|
||||||
|
<div
|
||||||
|
class="z-50 hidden my-4 text-base list-none bg-white divide-y divide-gray-100 rounded-lg shadow dark:bg-gray-700 dark:divide-gray-600"
|
||||||
|
id="user-dropdown"
|
||||||
|
>
|
||||||
|
<div class="px-4 py-3">
|
||||||
|
<span class="block text-sm text-gray-900 dark:text-white">{ getUserEmail(ctx) }</span>
|
||||||
|
</div>
|
||||||
|
<ul class="py-2" aria-labelledby="user-menu-button">
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href="/profile"
|
||||||
|
class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:hover:bg-gray-600 dark:text-gray-200 dark:hover:text-white"
|
||||||
|
>
|
||||||
|
<i class="fas fa-user-circle mr-2 w-4"></i>
|
||||||
|
Profile
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<form method="POST" action="/logout">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="w-full text-left flex items-center px-4 py-2 text-sm text-red-600 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white"
|
||||||
|
>
|
||||||
|
<i class="fas fa-sign-out-alt mr-2 w-4"></i>
|
||||||
|
Sign out
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<!-- Toast Container -->
|
||||||
|
@toast.Container()
|
||||||
|
<!-- Page Content -->
|
||||||
|
<main class="flex-1 bg-gray-50 dark:bg-gray-900">
|
||||||
|
<div class="py-6 bg-gray-50 dark:bg-gray-900">
|
||||||
|
<div class="max-w-screen-2xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<!-- Notification area -->
|
||||||
|
<div id="notification-area" class="hidden" hx-swap-oob="true"></div>
|
||||||
|
{ children... }
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<!-- Footer -->
|
||||||
|
<footer class="bg-gray-50 dark:bg-gray-900 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<div class="max-w-screen-2xl mx-auto py-4 px-4 sm:px-6 lg:px-8">
|
||||||
|
<div class="flex items-center justify-between text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
<span>GoMFT © { getCurrentYear() }</span>
|
||||||
|
<div class="flex items-center space-x-4">
|
||||||
|
<a href="https://github.com/starfleetcptn/gomft" class="hover:text-primary-600 dark:hover:text-primary-400">
|
||||||
|
<i class="fab fa-github"></i>
|
||||||
|
</a>
|
||||||
|
<a href="https://discord.gg/f9dwtM3j" class="hover:text-primary-600 dark:hover:text-primary-400" target="_blank">
|
||||||
|
<i class="fab fa-discord"></i>
|
||||||
|
</a>
|
||||||
|
<a href={ GetReleaseURL() } class="hover:text-primary-600 dark:hover:text-primary-400">
|
||||||
|
<i class="fas fa-tag"></i> { AppVersion }
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
} else {
|
||||||
|
<!-- Public page layout -->
|
||||||
|
<div class="flex flex-col min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||||
|
<main class="flex-grow">
|
||||||
|
{ children... }
|
||||||
|
</main>
|
||||||
|
<footer class="bg-gray-50 dark:bg-gray-900 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<div class="max-w-screen-2xl mx-auto py-4 px-4 sm:px-6 lg:px-8">
|
||||||
|
<div class="flex items-center justify-between text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
<span>GoMFT © { getCurrentYear() }</span>
|
||||||
|
<div class="flex items-center space-x-4">
|
||||||
|
<a href="https://github.com/starfleetcptn/gomft" class="hover:text-primary-600 dark:hover:text-primary-400">
|
||||||
|
<i class="fab fa-github"></i>
|
||||||
|
</a>
|
||||||
|
<a href="https://discord.gg/f9dwtM3j" class="hover:text-primary-600 dark:hover:text-primary-400" target="_blank">
|
||||||
|
<i class="fab fa-discord"></i>
|
||||||
|
</a>
|
||||||
|
<a href={ GetReleaseURL() } class="hover:text-primary-600 dark:hover:text-primary-400">
|
||||||
|
<i class="fas fa-tag"></i> { AppVersion }
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
<main class="flex-grow w-full max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8 animate-fadeIn pb-mobile-nav">
|
<!-- Scripts -->
|
||||||
<!-- Hidden notification area for HTMX targets -->
|
<!-- Alpine.js and dependencies -->
|
||||||
<div id="notification-area" class="hidden" hx-swap-oob="true"></div>
|
<script defer src="/static/dist/vendor.js"></script>
|
||||||
|
<!-- Shared Scripts -->
|
||||||
|
@toast.ShowToastJS()
|
||||||
|
<!-- Application scripts -->
|
||||||
|
<script defer src="/static/dist/app.js"></script>
|
||||||
|
<script defer src="/static/dist/init.js"></script>
|
||||||
|
<!-- Dropdown fix for mobile -->
|
||||||
|
<script>
|
||||||
|
document.addEventListener("DOMContentLoaded", function() {
|
||||||
|
// Initialize the mobile dropdown separately
|
||||||
|
const mobileDropdownButton = document.querySelector('[data-collapse-toggle="dropdown-settings-mobile"]');
|
||||||
|
const mobileDropdown = document.getElementById("dropdown-settings-mobile");
|
||||||
|
|
||||||
{ children... }
|
if (mobileDropdownButton && mobileDropdown) {
|
||||||
</main>
|
// Show dropdown if on admin pages
|
||||||
<footer class="bg-white dark:bg-secondary-800 shadow-inner mt-auto w-full">
|
if (window.location.pathname.startsWith("/admin")) {
|
||||||
<div class="max-w-7xl mx-auto py-4 px-4 sm:px-6 lg:px-8">
|
// Keep it hidden by default, will be toggled by button
|
||||||
<p class="text-center text-sm text-secondary-500 dark:text-secondary-400">
|
mobileDropdown.classList.add("hidden");
|
||||||
GoMFT © { getCurrentYear() } | Secure File Transfer Solution
|
}
|
||||||
</p>
|
|
||||||
<div class="flex justify-center items-center space-x-4 mt-1">
|
|
||||||
<p class="text-xs text-secondary-400 dark:text-secondary-500">
|
|
||||||
<a href="https://github.com/starfleetcptn/gomft" class="hover:text-primary-600 dark:hover:text-primary-400 transition-colors duration-200" target="_blank" rel="noopener">
|
|
||||||
<i class="fab fa-github mr-1"></i>GitHub
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
<p class="text-xs text-secondary-400 dark:text-secondary-500">
|
|
||||||
<a href={ GetReleaseURL() } class="hover:text-primary-600 dark:hover:text-primary-400 transition-colors duration-200" target="_blank" rel="noopener">
|
|
||||||
<i class="fas fa-tag mr-1"></i>Version { AppVersion }
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
|
|
||||||
if isLoggedIn(ctx) {
|
mobileDropdownButton.addEventListener("click", function() {
|
||||||
<!-- Mobile Bottom Navigation -->
|
mobileDropdown.classList.toggle("hidden");
|
||||||
<div class="sm:hidden fixed bottom-0 left-0 right-0 bg-white dark:bg-secondary-800 border-t border-secondary-200 dark:border-secondary-700 shadow-lg w-full p-0 m-0">
|
});
|
||||||
<div class="grid grid-cols-6 h-16">
|
}
|
||||||
<a href="/dashboard" class="flex flex-col items-center justify-center text-secondary-500 dark:text-secondary-400 hover:text-primary-600 dark:hover:text-primary-400">
|
});
|
||||||
<i class="fas fa-tachometer-alt text-lg"></i>
|
</script>
|
||||||
<span class="text-xs mt-1">Dashboard</span>
|
|
||||||
</a>
|
|
||||||
<a href="/configs" class="flex flex-col items-center justify-center text-secondary-500 dark:text-secondary-400 hover:text-primary-600 dark:hover:text-primary-400">
|
|
||||||
<i class="fas fa-cogs text-lg"></i>
|
|
||||||
<span class="text-xs mt-1">Configs</span>
|
|
||||||
</a>
|
|
||||||
<a href="/jobs" class="flex flex-col items-center justify-center text-secondary-500 dark:text-secondary-400 hover:text-primary-600 dark:hover:text-primary-400">
|
|
||||||
<i class="fas fa-tasks text-lg"></i>
|
|
||||||
<span class="text-xs mt-1">Jobs</span>
|
|
||||||
</a>
|
|
||||||
<a href="/history" class="flex flex-col items-center justify-center text-secondary-500 dark:text-secondary-400 hover:text-primary-600 dark:hover:text-primary-400">
|
|
||||||
<i class="fas fa-history text-lg"></i>
|
|
||||||
<span class="text-xs mt-1">History</span>
|
|
||||||
</a>
|
|
||||||
<a href="/files" class="flex flex-col items-center justify-center text-secondary-500 dark:text-secondary-400 hover:text-primary-600 dark:hover:text-primary-400">
|
|
||||||
<i class="fas fa-file-alt text-lg"></i>
|
|
||||||
<span class="text-xs mt-1">Files</span>
|
|
||||||
</a>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="flex flex-col items-center justify-center text-secondary-500 dark:text-secondary-400 hover:text-primary-600 dark:hover:text-primary-400"
|
|
||||||
x-data="{}"
|
|
||||||
@click="$dispatch('toggle-mobile-menu')"
|
|
||||||
>
|
|
||||||
<i class="fas fa-ellipsis-h text-lg"></i>
|
|
||||||
<span class="text-xs mt-1">More</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
}
|
}
|
||||||
@@ -536,17 +593,17 @@ func isLoggedIn(ctx context.Context) bool {
|
|||||||
func getUserInitial(ctx context.Context) string {
|
func getUserInitial(ctx context.Context) string {
|
||||||
// Try as string first
|
// Try as string first
|
||||||
if username, ok := ctx.Value("username").(string); ok && username != "" {
|
if username, ok := ctx.Value("username").(string); ok && username != "" {
|
||||||
return string(username[0])
|
return strings.ToUpper(string(username[0]))
|
||||||
}
|
}
|
||||||
// Try as interface{} (from JWT claims)
|
// Try as interface{} (from JWT claims)
|
||||||
if username, ok := ctx.Value("username").(interface{}); ok {
|
if username, ok := ctx.Value("username").(interface{}); ok {
|
||||||
if strVal, ok := username.(string); ok && strVal != "" {
|
if strVal, ok := username.(string); ok && strVal != "" {
|
||||||
return string(strVal[0])
|
return strings.ToUpper(string(strVal[0]))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Try email as fallback
|
// Try email as fallback
|
||||||
if email, ok := ctx.Value("email").(string); ok && email != "" {
|
if email, ok := ctx.Value("email").(string); ok && email != "" {
|
||||||
return string(email[0])
|
return strings.ToUpper(string(email[0]))
|
||||||
}
|
}
|
||||||
return "U"
|
return "U"
|
||||||
}
|
}
|
||||||
@@ -555,12 +612,12 @@ func getUserInitial(ctx context.Context) string {
|
|||||||
func getUserEmail(ctx context.Context) string {
|
func getUserEmail(ctx context.Context) string {
|
||||||
// Try as string first
|
// Try as string first
|
||||||
if email, ok := ctx.Value("email").(string); ok && email != "" {
|
if email, ok := ctx.Value("email").(string); ok && email != "" {
|
||||||
return email
|
return strings.ToLower(email)
|
||||||
}
|
}
|
||||||
// Try as interface{} (from JWT claims)
|
// Try as interface{} (from JWT claims)
|
||||||
if email, ok := ctx.Value("email").(interface{}); ok {
|
if email, ok := ctx.Value("email").(interface{}); ok {
|
||||||
if strVal, ok := email.(string); ok && strVal != "" {
|
if strVal, ok := email.(string); ok && strVal != "" {
|
||||||
return strVal
|
return strings.ToLower(strVal)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return "user@example.com"
|
return "user@example.com"
|
||||||
|
|||||||
+117
-117
@@ -5,133 +5,133 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
templ Login(ctx context.Context, errorMessage string) {
|
templ Login(ctx context.Context, errorMessage string, hasExternalProviders bool) {
|
||||||
@LayoutWithContext("Login", ctx) {
|
@LayoutWithContext("Login", ctx) {
|
||||||
<div class="min-h-[calc(100vh-4rem)] flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8 bg-secondary-50 dark:bg-secondary-900">
|
<div class="min-h-[calc(100vh-4rem)] flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8 bg-gray-50 dark:bg-gray-900">
|
||||||
<div class="max-w-md w-full">
|
<div class="max-w-md w-full space-y-8">
|
||||||
<div class="card overflow-hidden shadow-lg">
|
<div class="bg-white dark:bg-gray-800 shadow-lg rounded-lg p-8">
|
||||||
<div class="p-8">
|
<div class="text-center mb-8">
|
||||||
<div class="text-center mb-8">
|
<div class="mx-auto mb-4 flex h-20 w-20 items-center justify-center rounded-full bg-primary-100 dark:bg-primary-900">
|
||||||
<div class="inline-flex items-center justify-center w-20 h-20 rounded-full bg-primary-100 dark:bg-primary-900 mb-4">
|
<i class="fas fa-lock text-primary-600 dark:text-primary-400 text-3xl"></i>
|
||||||
<i class="fas fa-lock text-primary-600 dark:text-primary-400 text-3xl"></i>
|
|
||||||
</div>
|
|
||||||
<h2 class="text-3xl font-bold text-secondary-900 dark:text-secondary-100">Sign In</h2>
|
|
||||||
<p class="mt-2 text-secondary-600 dark:text-secondary-400">Access your GoMFT account</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
<h2 class="text-3xl font-bold text-gray-900 dark:text-white">Sign In</h2>
|
||||||
if errorMessage != "" {
|
<p class="mt-2 text-gray-600 dark:text-gray-300">Access your GoMFT account</p>
|
||||||
if strings.HasPrefix(errorMessage, "Password reset") || strings.Contains(errorMessage, "success") {
|
|
||||||
<div class="bg-green-100 dark:bg-green-900 border border-green-400 dark:border-green-700 text-green-700 dark:text-green-300 px-4 py-3 rounded-lg mb-6" role="alert">
|
|
||||||
<div class="flex items-center">
|
|
||||||
<i class="fas fa-check-circle mr-2"></i>
|
|
||||||
<span class="block sm:inline">{ errorMessage }</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
} else {
|
|
||||||
<div class="bg-red-100 dark:bg-red-900 border border-red-400 dark:border-red-700 text-red-700 dark:text-red-300 px-4 py-3 rounded-lg mb-6" role="alert">
|
|
||||||
<div class="flex items-center">
|
|
||||||
<i class="fas fa-exclamation-circle mr-2"></i>
|
|
||||||
<span class="block sm:inline">{ errorMessage }</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
<form
|
|
||||||
class="space-y-6"
|
|
||||||
method="POST"
|
|
||||||
action="/login"
|
|
||||||
x-data="{
|
|
||||||
email: '',
|
|
||||||
password: '',
|
|
||||||
loading: false,
|
|
||||||
validate() {
|
|
||||||
return this.email && this.password;
|
|
||||||
}
|
|
||||||
}">
|
|
||||||
<div class="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label for="email" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Email address</label>
|
|
||||||
<div class="relative">
|
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
|
||||||
<i class="fas fa-envelope text-secondary-400 dark:text-secondary-600"></i>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
id="email"
|
|
||||||
name="email"
|
|
||||||
type="email"
|
|
||||||
required
|
|
||||||
x-model="email"
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
placeholder="you@example.com"/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label for="password" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Password</label>
|
|
||||||
<div class="relative">
|
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
|
||||||
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
id="password"
|
|
||||||
name="password"
|
|
||||||
type="password"
|
|
||||||
required
|
|
||||||
x-model="password"
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
placeholder="••••••••"/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div class="flex items-center">
|
|
||||||
<input
|
|
||||||
id="remember-me"
|
|
||||||
name="remember-me"
|
|
||||||
type="checkbox"
|
|
||||||
class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-secondary-300 dark:border-secondary-700 rounded"/>
|
|
||||||
<label for="remember-me" class="ml-2 block text-sm text-secondary-700 dark:text-secondary-300">Remember me</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="text-sm">
|
|
||||||
<a href="/forgot-password" class="font-medium text-primary-600 dark:text-primary-400 hover:text-primary-500 dark:hover:text-primary-300">Forgot password?</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="btn-primary w-full flex justify-center py-3"
|
|
||||||
x-bind:disabled="!validate()"
|
|
||||||
@click="loading = true">
|
|
||||||
<span x-show="!loading" class="flex items-center">
|
|
||||||
<i class="fas fa-sign-in-alt mr-2"></i>
|
|
||||||
Sign in
|
|
||||||
</span>
|
|
||||||
<span x-show="loading" class="flex items-center">
|
|
||||||
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
|
||||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
|
||||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
|
||||||
</svg>
|
|
||||||
Processing...
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="px-8 py-4 bg-secondary-50 dark:bg-secondary-800 border-t border-secondary-200 dark:border-secondary-700 text-center">
|
if errorMessage != "" {
|
||||||
<p class="text-sm text-secondary-600 dark:text-secondary-400">
|
if strings.HasPrefix(errorMessage, "Password reset") || strings.Contains(errorMessage, "success") {
|
||||||
|
<div class="mb-6 flex items-center rounded-lg bg-green-50 p-4 text-green-800 dark:bg-gray-800 dark:text-green-400" role="alert">
|
||||||
|
<i class="fas fa-check-circle mr-2"></i>
|
||||||
|
<span class="[&:not(:first-child)]:ms-2">{ errorMessage }</span>
|
||||||
|
</div>
|
||||||
|
} else {
|
||||||
|
<div class="mb-6 flex items-center rounded-lg bg-red-50 p-4 text-red-800 dark:bg-gray-800 dark:text-red-400" role="alert">
|
||||||
|
<i class="fas fa-exclamation-circle mr-2"></i>
|
||||||
|
<span class="[&:not(:first-child)]:ms-2">{ errorMessage }</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
<form
|
||||||
|
class="space-y-6"
|
||||||
|
method="POST"
|
||||||
|
action="/login"
|
||||||
|
x-data="{
|
||||||
|
email: '',
|
||||||
|
password: '',
|
||||||
|
loading: false,
|
||||||
|
validate() {
|
||||||
|
return this.email && this.password;
|
||||||
|
}
|
||||||
|
}">
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label for="email" class="mb-2 block text-sm font-medium text-gray-900 dark:text-white">Email address</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||||
|
<i class="fas fa-envelope text-gray-500 dark:text-gray-400"></i>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
x-model="email"
|
||||||
|
class="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 pl-10 text-gray-900 focus:border-primary-500 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400 dark:focus:border-primary-500 dark:focus:ring-primary-500"
|
||||||
|
placeholder="you@example.com"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="password" class="mb-2 block text-sm font-medium text-gray-900 dark:text-white">Password</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||||
|
<i class="fas fa-key text-gray-500 dark:text-gray-400"></i>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
x-model="password"
|
||||||
|
class="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 pl-10 text-gray-900 focus:border-primary-500 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400 dark:focus:border-primary-500 dark:focus:ring-primary-500"
|
||||||
|
placeholder="••••••••"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<input
|
||||||
|
id="remember-me"
|
||||||
|
name="remember-me"
|
||||||
|
type="checkbox"
|
||||||
|
class="h-4 w-4 rounded border-gray-300 bg-gray-100 text-primary-600 focus:ring-2 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-700 dark:ring-offset-gray-800"/>
|
||||||
|
<label for="remember-me" class="ms-2 text-sm text-gray-900 dark:text-gray-300">Remember me</label>
|
||||||
|
</div>
|
||||||
|
<a href="/forgot-password" class="text-sm font-medium text-primary-600 hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300">Forgot password?</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="w-full rounded-lg bg-primary-600 px-5 py-3 text-center text-sm font-medium text-white hover:bg-primary-700 focus:outline-none focus:ring-4 focus:ring-primary-300 dark:bg-primary-600 dark:hover:bg-primary-700 dark:focus:ring-primary-800"
|
||||||
|
x-bind:disabled="!validate()"
|
||||||
|
@click="loading = true">
|
||||||
|
<span x-show="!loading" class="flex items-center justify-center">
|
||||||
|
<i class="fas fa-sign-in-alt mr-2"></i>
|
||||||
|
Sign in
|
||||||
|
</span>
|
||||||
|
<span x-show="loading" class="flex items-center justify-center">
|
||||||
|
<svg class="me-3 h-5 w-5 animate-spin text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
Processing...
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="mt-6 border-t border-gray-200 px-8 py-4 text-center dark:border-gray-700">
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-300 mb-3">
|
||||||
<i class="fas fa-info-circle mr-1"></i>
|
<i class="fas fa-info-circle mr-1"></i>
|
||||||
Contact an administrator to create an account
|
Contact an administrator to create an account
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
if hasExternalProviders {
|
||||||
|
<!-- External Authentication Providers -->
|
||||||
|
<div id="external-auth-providers" class="mt-4">
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-300 mb-3">Or sign in with:</p>
|
||||||
|
<div id="provider-buttons" class="flex flex-col gap-2" hx-get="/auth/providers" hx-trigger="load" hx-target="#provider-buttons">
|
||||||
|
<div class="animate-pulse flex justify-center">
|
||||||
|
<div class="h-10 bg-gray-200 rounded w-full max-w-[200px] dark:bg-gray-700"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Security Notice -->
|
<div class="text-center">
|
||||||
<div class="mt-8 text-center">
|
<div class="inline-flex items-center text-sm text-gray-600 dark:text-gray-400">
|
||||||
<div class="inline-flex items-center text-sm text-secondary-500 dark:text-secondary-400">
|
|
||||||
<i class="fas fa-shield-alt mr-2 text-primary-500"></i>
|
<i class="fas fa-shield-alt mr-2 text-primary-500"></i>
|
||||||
<span>Secure, encrypted connection</span>
|
<span>Secure, encrypted connection</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package dialog
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
// "strconv" // No longer needed here
|
||||||
|
)
|
||||||
|
|
||||||
|
// NotificationDialog component for confirmation dialogs using Flowbite modal
|
||||||
|
templ NotificationDialog(id string, title string, message string, confirmClass string, confirmText string, action string, serviceID uint, serviceName string) {
|
||||||
|
<div id={ id } tabindex="-1" aria-hidden="true" class="hidden fixed top-0 right-0 left-0 z-50 justify-center items-center w-full md:inset-0 h-[calc(100%-1rem)] max-h-full">
|
||||||
|
<!-- Backdrop -->
|
||||||
|
<div id={ fmt.Sprintf("%s-backdrop", id) } class="fixed inset-0 bg-gray-900/50 dark:bg-gray-900/80 backdrop-blur-sm"></div>
|
||||||
|
<!-- Modal content -->
|
||||||
|
<div class="relative p-4 w-full max-w-md max-h-full mx-auto">
|
||||||
|
<div class="relative bg-white rounded-lg shadow dark:bg-gray-700">
|
||||||
|
<div class="p-6 text-center">
|
||||||
|
if action == "delete" {
|
||||||
|
<i class="fas fa-trash-alt text-red-400 text-3xl mb-4"></i>
|
||||||
|
} else {
|
||||||
|
<i class="fas fa-exclamation-triangle text-yellow-400 text-3xl mb-4"></i>
|
||||||
|
}
|
||||||
|
<h3 class="mb-5 text-lg font-normal text-gray-500 dark:text-gray-400">{ message }</h3>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class={ confirmClass }
|
||||||
|
hx-delete={ fmt.Sprintf("/admin/settings/notifications/%d", serviceID) }
|
||||||
|
hx-target="body"
|
||||||
|
data-service-name={ serviceName }
|
||||||
|
data-service-id={ fmt.Sprint(serviceID) }
|
||||||
|
id={ fmt.Sprintf("delete-btn-%d", serviceID) }
|
||||||
|
onclick={ templ.ComponentScript{Call: fmt.Sprintf("triggerServiceDelete('%s', %d, '%s')", id, serviceID, serviceName)} }>
|
||||||
|
{ confirmText }
|
||||||
|
</button>
|
||||||
|
<button type="button" onclick={ templ.ComponentScript{Call: fmt.Sprintf("closeModal('%s')", id)} } class="text-gray-500 bg-white hover:bg-gray-100 focus:ring-4 focus:outline-none focus:ring-gray-200 rounded-lg border border-gray-200 text-sm font-medium px-5 py-2.5 hover:text-gray-900 focus:z-10 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-500 dark:hover:text-white dark:hover:bg-gray-600 dark:focus:ring-gray-600">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scripts (triggerServiceDelete, closeModal, showModal) are now expected to be defined globally or in the calling template (e.g., list.templ).
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package dialog
|
||||||
|
|
||||||
|
// DialogScripts provides the JavaScript function specific to the notification delete confirmation dialog.
|
||||||
|
templ DialogScripts() {
|
||||||
|
<script type="text/javascript">
|
||||||
|
// Called when the delete confirmation button is clicked.
|
||||||
|
// Primarily closes the modal; the actual delete is handled by hx-delete.
|
||||||
|
function triggerServiceDelete(dialogId, serviceId, serviceName) {
|
||||||
|
console.log(`Confirmed delete for service: ${serviceName} (ID: ${serviceId}). Closing modal: ${dialogId}`);
|
||||||
|
// Call the global closeModal function defined elsewhere (e.g., app.js)
|
||||||
|
if (typeof closeModal === 'function') {
|
||||||
|
closeModal(dialogId);
|
||||||
|
} else {
|
||||||
|
console.error('Global closeModal function not found.');
|
||||||
|
}
|
||||||
|
// Optional: Show a "Deleting..." toast here if desired.
|
||||||
|
// The hx-delete attribute on the button will trigger the actual backend request.
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package fields
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/starfleetcptn/gomft/components/notifications/types"
|
||||||
|
// No utils needed for this specific template yet
|
||||||
|
)
|
||||||
|
|
||||||
|
templ EmailFields(data types.NotificationFormData) {
|
||||||
|
<!-- TODO: Populate value attributes if editing an email service -->
|
||||||
|
<div id="email_fields" class="hidden notification-fields">
|
||||||
|
<div class="mb-6">
|
||||||
|
<label for="smtp_host" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">SMTP Host</label>
|
||||||
|
<input type="text" id="smtp_host" name="smtp_host" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="smtp.example.com"/>
|
||||||
|
</div>
|
||||||
|
<div class="mb-6">
|
||||||
|
<label for="smtp_port" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">SMTP Port</label>
|
||||||
|
<input type="number" id="smtp_port" name="smtp_port" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="587"/>
|
||||||
|
</div>
|
||||||
|
<div class="mb-6">
|
||||||
|
<label for="smtp_username" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">SMTP Username</label>
|
||||||
|
<input type="text" id="smtp_username" name="smtp_username" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="user@example.com"/>
|
||||||
|
</div>
|
||||||
|
<div class="mb-6">
|
||||||
|
<label for="smtp_password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">SMTP Password</label>
|
||||||
|
<input type="password" id="smtp_password" name="smtp_password" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"/>
|
||||||
|
</div>
|
||||||
|
<div class="mb-6">
|
||||||
|
<label for="from_email" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">From Email</label>
|
||||||
|
<input type="email" id="from_email" name="from_email" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="notifications@example.com"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package fields
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/starfleetcptn/gomft/components/notifications/types"
|
||||||
|
// "github.com/starfleetcptn/gomft/components/notifications/form/utils" // Removed as unused
|
||||||
|
)
|
||||||
|
|
||||||
|
templ GotifyFields(data types.NotificationFormData) {
|
||||||
|
<div id="gotify_fields" class="hidden notification-fields">
|
||||||
|
<div class="mb-6">
|
||||||
|
<label for="gotify_url" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Gotify Server URL</label>
|
||||||
|
if data.NotificationService.GotifyURL != "" {
|
||||||
|
<input type="url" id="gotify_url" name="gotify_url" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="https://gotify.example.com" value={ data.NotificationService.GotifyURL }/>
|
||||||
|
} else {
|
||||||
|
<input type="url" id="gotify_url" name="gotify_url" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="https://gotify.example.com" value=""/>
|
||||||
|
}
|
||||||
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">URL of your Gotify server</p>
|
||||||
|
</div>
|
||||||
|
<div class="mb-6">
|
||||||
|
<label for="gotify_token" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Application Token</label>
|
||||||
|
if data.NotificationService.GotifyToken != "" {
|
||||||
|
<input type="text" id="gotify_token" name="gotify_token" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="A-M-XiEQj.zX5d" value={ data.NotificationService.GotifyToken }/>
|
||||||
|
} else {
|
||||||
|
<input type="text" id="gotify_token" name="gotify_token" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="A-M-XiEQj.zX5d" value=""/>
|
||||||
|
}
|
||||||
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Find this in your Gotify application settings</p>
|
||||||
|
</div>
|
||||||
|
<div class="mb-6">
|
||||||
|
<label for="gotify_priority" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Default Priority</label>
|
||||||
|
<select id="gotify_priority" name="gotify_priority" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500">
|
||||||
|
<option value="0">Low (0)</option>
|
||||||
|
if data.NotificationService.GotifyPriority != "" && data.NotificationService.GotifyPriority == "5" {
|
||||||
|
<option value="5" selected="selected">Normal (5)</option>
|
||||||
|
} else {
|
||||||
|
<option value="5">Normal (5)</option>
|
||||||
|
}
|
||||||
|
<option value="8">High (8)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="mb-6">
|
||||||
|
<label for="gotify_title_template" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Message Title Template</label>
|
||||||
|
if data.NotificationService.GotifyTitleTemplate != "" {
|
||||||
|
<input type="text" id="gotify_title_template" name="gotify_title_template" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" value={ data.NotificationService.GotifyTitleTemplate }/>
|
||||||
|
} else {
|
||||||
|
<input type="text" id="gotify_title_template" name="gotify_title_template" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="{{job.name}} {{job.status}}" value=""/>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="mb-6">
|
||||||
|
<label for="gotify_message_template" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Message Body Template</label>
|
||||||
|
<textarea
|
||||||
|
id="gotify_message_template"
|
||||||
|
name="gotify_message_template"
|
||||||
|
rows="4"
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
placeholder="Job '{{job.name}}' {{job.status}} at {{job.completed_at}}. {{job.file_count}} files transferred ({{job.transfer_bytes}} bytes)."
|
||||||
|
>
|
||||||
|
if data.NotificationService.GotifyMessageTemplate != "" {
|
||||||
|
data.NotificationService.GotifyMessageTemplate
|
||||||
|
}</textarea>
|
||||||
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Use placeholders for dynamic values. Available variables: job.*, instance.*, timestamp, notification.*</p>
|
||||||
|
</div>
|
||||||
|
// Removed duplicate Event Triggers section - now handled in form.templ
|
||||||
|
<!-- Test notification button for Gotify -->
|
||||||
|
<div class="mb-6 p-4 bg-gray-50 border border-gray-200 rounded-lg dark:bg-gray-700 dark:border-gray-600">
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<h4 class="text-base font-medium text-gray-900 dark:text-white">Test Configuration</h4>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
id="test-gotify-btn"
|
||||||
|
hx-post="/admin/settings/notifications/test"
|
||||||
|
hx-trigger="click"
|
||||||
|
hx-target="#test-notification-result"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
class="px-3 py-2 text-xs font-medium text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 rounded-lg dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800"
|
||||||
|
>
|
||||||
|
<i class="fas fa-paper-plane mr-1"></i>
|
||||||
|
Send Test Notification
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Send a test notification to verify your Gotify configuration works correctly before saving.
|
||||||
|
</p>
|
||||||
|
<div id="test-notification-result" class="mt-3 hidden">
|
||||||
|
<!-- Result will be shown here -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
package fields
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/starfleetcptn/gomft/components/notifications/types"
|
||||||
|
// "github.com/starfleetcptn/gomft/components/notifications/form/utils" // Removed as unused
|
||||||
|
)
|
||||||
|
|
||||||
|
templ NtfyFields(data types.NotificationFormData) {
|
||||||
|
<div id="ntfy_fields" class="hidden notification-fields">
|
||||||
|
<div class="mb-6">
|
||||||
|
<label for="ntfy_server" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Ntfy Server</label>
|
||||||
|
if data.NotificationService.NtfyServer != "" {
|
||||||
|
<input type="url" id="ntfy_server" name="ntfy_server" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="https://ntfy.sh" value={ data.NotificationService.NtfyServer }/>
|
||||||
|
} else {
|
||||||
|
<input type="url" id="ntfy_server" name="ntfy_server" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="https://ntfy.sh" value="https://ntfy.sh"/>
|
||||||
|
}
|
||||||
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">The Ntfy server URL (default: ntfy.sh)</p>
|
||||||
|
</div>
|
||||||
|
<div class="mb-6">
|
||||||
|
<label for="ntfy_topic" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Topic</label>
|
||||||
|
if data.NotificationService.NtfyTopic != "" {
|
||||||
|
<input type="text" id="ntfy_topic" name="ntfy_topic" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="your-unique-topic" value={ data.NotificationService.NtfyTopic }/>
|
||||||
|
} else {
|
||||||
|
<input type="text" id="ntfy_topic" name="ntfy_topic" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="your-unique-topic" value="gomft"/>
|
||||||
|
}
|
||||||
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Choose a unique, unguessable topic name</p>
|
||||||
|
</div>
|
||||||
|
<div class="mb-6">
|
||||||
|
<label for="ntfy_priority" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Default Priority</label>
|
||||||
|
<select id="ntfy_priority" name="ntfy_priority" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500">
|
||||||
|
<option value="1">Low (1)</option>
|
||||||
|
if data.NotificationService.NtfyPriority == "3" {
|
||||||
|
<option value="3" selected="selected">Default (3)</option>
|
||||||
|
} else {
|
||||||
|
<option value="3">Default (3)</option>
|
||||||
|
}
|
||||||
|
<option value="4">High (4)</option>
|
||||||
|
<option value="5">Urgent (5)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="mb-6">
|
||||||
|
<label for="ntfy_username" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Username (Optional)</label>
|
||||||
|
if data.NotificationService.NtfyUsername != "" {
|
||||||
|
<input type="text" id="ntfy_username" name="ntfy_username" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="Username for protected topics" value={ data.NotificationService.NtfyUsername }/>
|
||||||
|
} else {
|
||||||
|
<input type="text" id="ntfy_username" name="ntfy_username" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="Username for protected topics" value=""/>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="mb-6">
|
||||||
|
<label for="ntfy_password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Password (Optional)</label>
|
||||||
|
if data.NotificationService.NtfyPassword != "" {
|
||||||
|
<input type="password" id="ntfy_password" name="ntfy_password" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="Password for protected topics" value={ data.NotificationService.NtfyPassword }/>
|
||||||
|
} else {
|
||||||
|
<input type="password" id="ntfy_password" name="ntfy_password" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="Password for protected topics" value=""/>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="mb-6">
|
||||||
|
<label for="ntfy_title_template" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Notification Title Template</label>
|
||||||
|
if data.NotificationService.NtfyTitleTemplate != "" {
|
||||||
|
<input type="text" id="ntfy_title_template" name="ntfy_title_template" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" value={ data.NotificationService.NtfyTitleTemplate }/>
|
||||||
|
} else {
|
||||||
|
<input type="text" id="ntfy_title_template" name="ntfy_title_template" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="{{job.name}} {{job.status}}" value=""/>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="mb-6">
|
||||||
|
<label for="ntfy_message_template" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Message Body Template</label>
|
||||||
|
<textarea
|
||||||
|
id="ntfy_message_template"
|
||||||
|
name="ntfy_message_template"
|
||||||
|
rows="4"
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
placeholder="Job '{{job.name}}' {{job.status}} at {{job.completed_at}}. {{job.file_count}} files transferred ({{job.transfer_bytes}} bytes)."
|
||||||
|
>
|
||||||
|
if data.NotificationService.NtfyMessageTemplate != "" {
|
||||||
|
data.NotificationService.NtfyMessageTemplate
|
||||||
|
} </textarea>
|
||||||
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Use placeholders for dynamic values. Available variables: job.*, instance.*, timestamp, notification.*</p>
|
||||||
|
</div>
|
||||||
|
// Removed duplicate Event Triggers section - now handled in form.templ
|
||||||
|
<!-- Test notification button for Ntfy -->
|
||||||
|
<div class="mb-6 p-4 bg-gray-50 border border-gray-200 rounded-lg dark:bg-gray-700 dark:border-gray-600">
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<h4 class="text-base font-medium text-gray-900 dark:text-white">Test Configuration</h4>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
id="test-ntfy-btn"
|
||||||
|
hx-post="/admin/settings/notifications/test"
|
||||||
|
hx-trigger="click"
|
||||||
|
hx-target="#test-notification-result"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
class="px-3 py-2 text-xs font-medium text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 rounded-lg dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800"
|
||||||
|
>
|
||||||
|
<i class="fas fa-paper-plane mr-1"></i>
|
||||||
|
Send Test Notification
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Send a test notification to verify your Ntfy configuration works correctly before saving.
|
||||||
|
</p>
|
||||||
|
<div id="test-notification-result" class="mt-3 hidden">
|
||||||
|
<!-- Result will be shown here -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package fields
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/starfleetcptn/gomft/components/notifications/types"
|
||||||
|
// "github.com/starfleetcptn/gomft/components/notifications/form/utils" // Removed as unused
|
||||||
|
)
|
||||||
|
|
||||||
|
templ PushbulletFields(data types.NotificationFormData) {
|
||||||
|
<div id="pushbullet_fields" class="hidden notification-fields">
|
||||||
|
<div class="mb-6">
|
||||||
|
<label for="pushbullet_api_key" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">API Key</label>
|
||||||
|
if data.NotificationService.PushbulletAPIKey != "" {
|
||||||
|
<input type="text" id="pushbullet_api_key" name="pushbullet_api_key" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="o.XyzAbCdEfGhIjKlMnOpQrSt" value={ data.NotificationService.PushbulletAPIKey }/>
|
||||||
|
} else {
|
||||||
|
<input type="text" id="pushbullet_api_key" name="pushbullet_api_key" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="o.XyzAbCdEfGhIjKlMnOpQrSt" value=""/>
|
||||||
|
}
|
||||||
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Get your API key from <a href="https://www.pushbullet.com/#settings/account" target="_blank" class="text-blue-500 hover:underline">Pushbullet Account Settings</a></p>
|
||||||
|
</div>
|
||||||
|
<div class="mb-6">
|
||||||
|
<label for="pushbullet_device_iden" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Device Identifier (Optional)</label>
|
||||||
|
if data.NotificationService.PushbulletDeviceID != "" {
|
||||||
|
<input type="text" id="pushbullet_device_iden" name="pushbullet_device_iden" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="Leave empty to send to all devices" value={ data.NotificationService.PushbulletDeviceID }/>
|
||||||
|
} else {
|
||||||
|
<input type="text" id="pushbullet_device_iden" name="pushbullet_device_iden" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="Leave empty to send to all devices" value=""/>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="mb-6">
|
||||||
|
<label for="pushbullet_title_template" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Notification Title Template</label>
|
||||||
|
if data.NotificationService.PushbulletTitleTemplate != "" {
|
||||||
|
<input type="text" id="pushbullet_title_template" name="pushbullet_title_template" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" value={ data.NotificationService.PushbulletTitleTemplate }/>
|
||||||
|
} else {
|
||||||
|
<input type="text" id="pushbullet_title_template" name="pushbullet_title_template" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="{{job.name}} {{job.status}}" value=""/>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="mb-6">
|
||||||
|
<label for="pushbullet_body_template" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Message Body Template</label>
|
||||||
|
<textarea
|
||||||
|
id="pushbullet_body_template"
|
||||||
|
name="pushbullet_body_template"
|
||||||
|
rows="4"
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
placeholder="Job '{{job.name}}' {{job.status}} at {{job.completed_at}}. {{job.file_count}} files transferred ({{job.transfer_bytes}} bytes)."
|
||||||
|
>
|
||||||
|
if data.NotificationService.PushbulletBodyTemplate != "" {
|
||||||
|
data.NotificationService.PushbulletBodyTemplate
|
||||||
|
} </textarea>
|
||||||
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Use placeholders for dynamic values. Available variables: job.*, instance.*, timestamp, notification.*</p>
|
||||||
|
</div>
|
||||||
|
// Removed duplicate Event Triggers section - now handled in form.templ
|
||||||
|
<!-- Test notification button for Pushbullet -->
|
||||||
|
<div class="mb-6 p-4 bg-gray-50 border border-gray-200 rounded-lg dark:bg-gray-700 dark:border-gray-600">
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<h4 class="text-base font-medium text-gray-900 dark:text-white">Test Configuration</h4>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
id="test-pushbullet-btn"
|
||||||
|
hx-post="/admin/settings/notifications/test"
|
||||||
|
hx-trigger="click"
|
||||||
|
hx-target="#test-notification-result"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
class="px-3 py-2 text-xs font-medium text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 rounded-lg dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800"
|
||||||
|
>
|
||||||
|
<i class="fas fa-paper-plane mr-1"></i>
|
||||||
|
Send Test Notification
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Send a test notification to verify your Pushbullet configuration works correctly before saving.
|
||||||
|
</p>
|
||||||
|
<div id="test-notification-result" class="mt-3 hidden">
|
||||||
|
<!-- Result will be shown here -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
package fields
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/starfleetcptn/gomft/components/notifications/types"
|
||||||
|
// "github.com/starfleetcptn/gomft/components/notifications/form/utils" // Removed as unused
|
||||||
|
)
|
||||||
|
|
||||||
|
templ PushoverFields(data types.NotificationFormData) {
|
||||||
|
<div id="pushover_fields" class="hidden notification-fields">
|
||||||
|
<div class="mb-6">
|
||||||
|
<label for="pushover_app_token" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">API Token/Key</label>
|
||||||
|
if data.NotificationService.PushoverAPIToken != "" {
|
||||||
|
<input type="text" id="pushover_app_token" name="pushover_app_token" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="azGDORePK8gMaC0QOYAMyEEuzJnyUi" value={ data.NotificationService.PushoverAPIToken }/>
|
||||||
|
} else {
|
||||||
|
<input type="text" id="pushover_app_token" name="pushover_app_token" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="azGDORePK8gMaC0QOYAMyEEuzJnyUi" value=""/>
|
||||||
|
}
|
||||||
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Your application's API token/key from <a href="https://pushover.net/apps" target="_blank" class="text-blue-500 hover:underline">Pushover Dashboard</a></p>
|
||||||
|
</div>
|
||||||
|
<div class="mb-6">
|
||||||
|
<label for="pushover_user_key" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">User Key</label>
|
||||||
|
if data.NotificationService.PushoverUserKey != "" {
|
||||||
|
<input type="text" id="pushover_user_key" name="pushover_user_key" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="uQiRzpo4DXghDmr9QzzfQu27cmVRsG" value={ data.NotificationService.PushoverUserKey }/>
|
||||||
|
} else {
|
||||||
|
<input type="text" id="pushover_user_key" name="pushover_user_key" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="uQiRzpo4DXghDmr9QzzfQu27cmVRsG" value=""/>
|
||||||
|
}
|
||||||
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Your user key from <a href="https://pushover.net/" target="_blank" class="text-blue-500 hover:underline">Pushover Dashboard</a></p>
|
||||||
|
</div>
|
||||||
|
<div class="mb-6">
|
||||||
|
<label for="pushover_device" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Device Name (Optional)</label>
|
||||||
|
if data.NotificationService.PushoverDevice != "" {
|
||||||
|
<input type="text" id="pushover_device" name="pushover_device" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="Leave empty to send to all devices" value={ data.NotificationService.PushoverDevice }/>
|
||||||
|
} else {
|
||||||
|
<input type="text" id="pushover_device" name="pushover_device" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="Leave empty to send to all devices" value=""/>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="mb-6">
|
||||||
|
<label for="pushover_priority" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Default Priority</label>
|
||||||
|
<select id="pushover_priority" name="pushover_priority" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500">
|
||||||
|
<option value="-2">Lowest (-2)</option>
|
||||||
|
<option value="-1">Low (-1)</option>
|
||||||
|
if data.NotificationService.PushoverPriority != "" && data.NotificationService.PushoverPriority == "0" {
|
||||||
|
<option value="0" selected="selected">Normal (0)</option>
|
||||||
|
} else {
|
||||||
|
<option value="0">Normal (0)</option>
|
||||||
|
}
|
||||||
|
<option value="1">High (1)</option>
|
||||||
|
<option value="2">Emergency (2)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="mb-6">
|
||||||
|
<label for="pushover_sound" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Sound</label>
|
||||||
|
<select id="pushover_sound" name="pushover_sound" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500">
|
||||||
|
<option value="pushover">Pushover (default)</option>
|
||||||
|
<option value="bike">Bike</option>
|
||||||
|
<option value="bugle">Bugle</option>
|
||||||
|
<option value="cashregister">Cash Register</option>
|
||||||
|
<option value="classical">Classical</option>
|
||||||
|
<option value="cosmic">Cosmic</option>
|
||||||
|
<option value="falling">Falling</option>
|
||||||
|
<option value="gamelan">Gamelan</option>
|
||||||
|
<option value="incoming">Incoming</option>
|
||||||
|
<option value="intermission">Intermission</option>
|
||||||
|
<option value="magic">Magic</option>
|
||||||
|
<option value="mechanical">Mechanical</option>
|
||||||
|
<option value="pianobar">Piano Bar</option>
|
||||||
|
<option value="siren">Siren</option>
|
||||||
|
<option value="spacealarm">Space Alarm</option>
|
||||||
|
<option value="tugboat">Tug Boat</option>
|
||||||
|
<option value="alien">Alien Alarm (long)</option>
|
||||||
|
<option value="climb">Climb (long)</option>
|
||||||
|
<option value="persistent">Persistent (long)</option>
|
||||||
|
<option value="echo">Echo (long)</option>
|
||||||
|
<option value="updown">Up Down (long)</option>
|
||||||
|
<option value="vibrate">Vibrate Only</option>
|
||||||
|
<option value="none">None (silent)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="mb-6">
|
||||||
|
<label for="pushover_title_template" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Message Title Template</label>
|
||||||
|
if data.NotificationService.PushoverTitleTemplate != "" {
|
||||||
|
<input type="text" id="pushover_title_template" name="pushover_title_template" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" value={ data.NotificationService.PushoverTitleTemplate }/>
|
||||||
|
} else {
|
||||||
|
<input type="text" id="pushover_title_template" name="pushover_title_template" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="'{{job.name}}' {{job.status}}" value=""/>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="mb-6">
|
||||||
|
<label for="pushover_message_template" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Message Body Template</label>
|
||||||
|
<textarea
|
||||||
|
id="pushover_message_template"
|
||||||
|
name="pushover_message_template"
|
||||||
|
rows="4"
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
placeholder="Job '{{job.name}}' {{job.status}} at {{job.completed_at}}. {{job.file_count}} files transferred ({{job.transfer_bytes}} bytes)."
|
||||||
|
>
|
||||||
|
if data.NotificationService.PushoverMessageTemplate != "" {
|
||||||
|
data.NotificationService.PushoverMessageTemplate
|
||||||
|
} </textarea>
|
||||||
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Use placeholders for dynamic values. Available variables: job.*, instance.*, timestamp, notification.*</p>
|
||||||
|
</div>
|
||||||
|
// Removed duplicate Event Triggers section - now handled in form.templ
|
||||||
|
<!-- Test notification button for Pushover -->
|
||||||
|
<div class="mb-6 p-4 bg-gray-50 border border-gray-200 rounded-lg dark:bg-gray-700 dark:border-gray-600">
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<h4 class="text-base font-medium text-gray-900 dark:text-white">Test Configuration</h4>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
id="test-pushover-btn"
|
||||||
|
hx-post="/admin/settings/notifications/test"
|
||||||
|
hx-trigger="click"
|
||||||
|
hx-target="#test-notification-result"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
class="px-3 py-2 text-xs font-medium text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 rounded-lg dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800"
|
||||||
|
>
|
||||||
|
<i class="fas fa-paper-plane mr-1"></i>
|
||||||
|
Send Test Notification
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Send a test notification to verify your Pushover configuration works correctly before saving.
|
||||||
|
</p>
|
||||||
|
<div id="test-notification-result" class="mt-3 hidden">
|
||||||
|
<!-- Result will be shown here -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
package fields
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/starfleetcptn/gomft/components/notifications/types"
|
||||||
|
// "github.com/starfleetcptn/gomft/components/notifications/form/utils" // Removed as unused
|
||||||
|
)
|
||||||
|
|
||||||
|
templ WebhookFields(data types.NotificationFormData) {
|
||||||
|
<div id="webhook_fields" class="hidden notification-fields">
|
||||||
|
<div class="mb-6">
|
||||||
|
<label for="webhook_url" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Webhook URL</label>
|
||||||
|
if data.NotificationService.WebhookURL != "" {
|
||||||
|
<input type="url" id="webhook_url" name="webhook_url" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="https://api.example.com/webhook" value={ data.NotificationService.WebhookURL }/>
|
||||||
|
} else {
|
||||||
|
<input type="url" id="webhook_url" name="webhook_url" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="https://api.example.com/webhook" value=""/>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="mb-6">
|
||||||
|
<label for="method" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">HTTP Method</label>
|
||||||
|
<select id="method" name="method" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500">
|
||||||
|
if data.NotificationService.Method != "" {
|
||||||
|
if data.NotificationService.Method == "POST" {
|
||||||
|
<option value="POST" selected="selected">POST</option>
|
||||||
|
} else {
|
||||||
|
<option value="POST">POST</option>
|
||||||
|
}
|
||||||
|
if data.NotificationService.Method == "PUT" {
|
||||||
|
<option value="PUT" selected="selected">PUT</option>
|
||||||
|
} else {
|
||||||
|
<option value="PUT">PUT</option>
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
<option value="POST">POST</option>
|
||||||
|
<option value="PUT">PUT</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="mb-6">
|
||||||
|
<label for="headers" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Headers (JSON)</label>
|
||||||
|
if data.NotificationService.Headers != "" {
|
||||||
|
<textarea id="headers" name="headers" rows="3" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder='{"Content-Type": "application/json", "Authorization": "Bearer token"}'>{ data.NotificationService.Headers }</textarea>
|
||||||
|
} else {
|
||||||
|
<textarea id="headers" name="headers" rows="3" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder='{"Content-Type": "application/json", "Authorization": "Bearer token"}'></textarea>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="mb-6">
|
||||||
|
<label for="payload_template" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Payload Template (JSON)</label>
|
||||||
|
<textarea
|
||||||
|
id="payload_template"
|
||||||
|
name="payload_template"
|
||||||
|
rows="5"
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
placeholder='{
|
||||||
|
"event": "{{job.event}}",
|
||||||
|
"job": {
|
||||||
|
"id": "{{job.id}}",
|
||||||
|
"name": "{{job.name}}",
|
||||||
|
"status": "{{job.status}}",
|
||||||
|
"message": "{{job.message}}",
|
||||||
|
"started_at": "{{job.started_at}}",
|
||||||
|
"completed_at": "{{job.completed_at}}",
|
||||||
|
"duration_seconds": {{job.duration_seconds}},
|
||||||
|
"config_id": "{{job.config_id}}",
|
||||||
|
"config_name": "{{job.config_name}}",
|
||||||
|
"transfer_bytes": {{job.transfer_bytes}},
|
||||||
|
"file_count": {{job.file_count}}
|
||||||
|
},
|
||||||
|
"instance": {
|
||||||
|
"id": "{{instance.id}}",
|
||||||
|
"name": "{{instance.name}}",
|
||||||
|
"version": "{{instance.version}}",
|
||||||
|
"environment": "{{instance.environment}}"
|
||||||
|
},
|
||||||
|
"timestamp": "{{timestamp}}",
|
||||||
|
"notification_id": "{{notification.id}}"
|
||||||
|
}'
|
||||||
|
>
|
||||||
|
if data.NotificationService.PayloadTemplate != "" {
|
||||||
|
data.NotificationService.PayloadTemplate
|
||||||
|
} </textarea>
|
||||||
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Use placeholders for dynamic values. Available variables: job.*, instance.*, timestamp, notification.*</p>
|
||||||
|
</div>
|
||||||
|
// Removed duplicate Event Triggers section - now handled in form.templ
|
||||||
|
<div class="mb-6">
|
||||||
|
<label for="secret_key" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Secret Key (for signature verification)</label>
|
||||||
|
if data.NotificationService.SecretKey != "" {
|
||||||
|
<input type="text" id="secret_key" name="secret_key" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="Optional signature verification key" value={ data.NotificationService.SecretKey }/>
|
||||||
|
}
|
||||||
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">If provided, all webhooks will include an X-GoMFT-Signature header</p>
|
||||||
|
</div>
|
||||||
|
<div class="mb-6">
|
||||||
|
<label for="retry_policy" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Retry Policy</label>
|
||||||
|
<select id="retry_policy" name="retry_policy" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500">
|
||||||
|
if data.NotificationService.RetryPolicy != "" {
|
||||||
|
if data.NotificationService.RetryPolicy == "none" {
|
||||||
|
<option value="none" selected="selected">No retries</option>
|
||||||
|
} else {
|
||||||
|
<option value="none">No retries</option>
|
||||||
|
}
|
||||||
|
if data.NotificationService.RetryPolicy == "simple" {
|
||||||
|
<option value="simple" selected="selected">Simple (3 retries)</option>
|
||||||
|
} else {
|
||||||
|
<option value="simple">Simple (3 retries)</option>
|
||||||
|
}
|
||||||
|
if data.NotificationService.RetryPolicy == "exponential" {
|
||||||
|
<option value="exponential" selected="selected">Exponential backoff</option>
|
||||||
|
} else {
|
||||||
|
<option value="exponential">Exponential backoff</option>
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
<option value="none">No retries</option>
|
||||||
|
<option value="simple">Simple (3 retries)</option>
|
||||||
|
<option value="exponential">Exponential backoff</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<!-- Test notification button -->
|
||||||
|
<div class="mb-6 p-4 bg-gray-50 border border-gray-200 rounded-lg dark:bg-gray-700 dark:border-gray-600">
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<h4 class="text-base font-medium text-gray-900 dark:text-white">Test Configuration</h4>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
id="test-webhook-btn"
|
||||||
|
hx-post="/admin/settings/notifications/test"
|
||||||
|
hx-trigger="click"
|
||||||
|
hx-target="#test-notification-result"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
class="px-3 py-2 text-xs font-medium text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 rounded-lg dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800"
|
||||||
|
>
|
||||||
|
<i class="fas fa-paper-plane mr-1"></i>
|
||||||
|
Send Test Notification
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Send a test notification to verify your configuration works correctly before saving.
|
||||||
|
</p>
|
||||||
|
<div id="test-notification-result" class="mt-3 hidden">
|
||||||
|
<!-- Result will be shown here -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
package form
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"github.com/starfleetcptn/gomft/components" // For LayoutWithContext
|
||||||
|
"github.com/starfleetcptn/gomft/components/notifications/types"
|
||||||
|
"github.com/starfleetcptn/gomft/components/notifications/form/utils"
|
||||||
|
"github.com/starfleetcptn/gomft/components/notifications/form/fields" // Import fields
|
||||||
|
)
|
||||||
|
|
||||||
|
templ NotificationForm(ctx context.Context, data types.NotificationFormData) {
|
||||||
|
@components.LayoutWithContext(utils.GetNotificationFormTitle(data.IsNew), ctx) {
|
||||||
|
<script>
|
||||||
|
// Toggle notification fields based on selection
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const typeSelector = document.getElementById('notification_type');
|
||||||
|
// Ensure typeSelector exists before adding listener
|
||||||
|
if (!typeSelector) {
|
||||||
|
console.warn("Notification type selector not found.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const allFields = document.querySelectorAll('.notification-fields');
|
||||||
|
const commonFields = document.querySelectorAll('.common-fields');
|
||||||
|
|
||||||
|
function toggleFields() {
|
||||||
|
// Hide all specific fields first
|
||||||
|
allFields.forEach(field => field.classList.add('hidden'));
|
||||||
|
|
||||||
|
// Show/hide common fields based on selection
|
||||||
|
const selectedType = typeSelector.value;
|
||||||
|
if (selectedType) {
|
||||||
|
// Show common fields (name, description, is_enabled, submit)
|
||||||
|
commonFields.forEach(field => field.classList.remove('hidden'));
|
||||||
|
|
||||||
|
// Show the selected type's specific fields
|
||||||
|
const fieldsToShow = document.getElementById(`${selectedType}_fields`);
|
||||||
|
if (fieldsToShow) {
|
||||||
|
fieldsToShow.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Hide common fields if no type selected
|
||||||
|
commonFields.forEach(field => field.classList.add('hidden'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
typeSelector.addEventListener('change', toggleFields);
|
||||||
|
|
||||||
|
// Initialize form state on load (if editing or if a type is pre-selected)
|
||||||
|
toggleFields();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Status and Error Messages (Handled by shared toast component in layout) -->
|
||||||
|
|
||||||
|
<div id="notification-form-container" class="notifications-page bg-gray-50 dark:bg-gray-900 min-h-screen">
|
||||||
|
<div class="pb-8 w-full max-w-4xl mx-auto">
|
||||||
|
<!-- Success Message (hidden, used for HTMX responses) -->
|
||||||
|
if data.SuccessMessage != "" {
|
||||||
|
<div class="hidden success-message">{ data.SuccessMessage }</div>
|
||||||
|
}
|
||||||
|
<!-- Error Message (hidden, used for HTMX responses) -->
|
||||||
|
if data.ErrorMessage != "" {
|
||||||
|
<div class="hidden error-message">{ data.ErrorMessage }</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="mb-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||||
|
<i class="fas fa-bell w-6 h-6 mr-2 text-blue-500 dark:text-blue-400"></i>
|
||||||
|
{ utils.GetNotificationFormTitle(data.IsNew) }
|
||||||
|
</h1>
|
||||||
|
<a href="/admin/settings/notifications" class="flex items-center justify-center text-gray-700 bg-gray-100 hover:bg-gray-200 focus:ring-4 focus:ring-gray-300 font-medium rounded-lg px-5 py-2.5 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600 focus:outline-none dark:focus:ring-gray-700">
|
||||||
|
<i class="fas fa-arrow-left w-4 h-4 mr-2"></i>
|
||||||
|
Back to Notification Services
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add Notification Service Form -->
|
||||||
|
<div class="mb-6 p-6 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800">
|
||||||
|
<form id="notification-form"
|
||||||
|
if data.IsNew {
|
||||||
|
hx-post="/admin/settings/notifications"
|
||||||
|
hx-redirect="/admin/settings/notifications"
|
||||||
|
} else {
|
||||||
|
hx-put={ fmt.Sprintf("/admin/settings/notifications/%d", data.NotificationService.ID) }
|
||||||
|
hx-redirect="/admin/settings/notifications"
|
||||||
|
}
|
||||||
|
hx-target="body">
|
||||||
|
<div class="mb-6">
|
||||||
|
<label for="notification_type" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Notification Type</label>
|
||||||
|
<select id="notification_type" name="type" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500">
|
||||||
|
<option value="">Select a type</option>
|
||||||
|
if data.NotificationService != nil && data.NotificationService.Type == "webhook" {
|
||||||
|
<option value="webhook" selected="selected">Webhook</option>
|
||||||
|
} else {
|
||||||
|
<option value="webhook">Webhook</option>
|
||||||
|
}
|
||||||
|
if data.NotificationService != nil && data.NotificationService.Type == "pushbullet" {
|
||||||
|
<option value="pushbullet" selected="selected">Pushbullet</option>
|
||||||
|
} else {
|
||||||
|
<option value="pushbullet">Pushbullet</option>
|
||||||
|
}
|
||||||
|
if data.NotificationService != nil && data.NotificationService.Type == "ntfy" {
|
||||||
|
<option value="ntfy" selected="selected">Ntfy</option>
|
||||||
|
} else {
|
||||||
|
<option value="ntfy">Ntfy</option>
|
||||||
|
}
|
||||||
|
if data.NotificationService != nil && data.NotificationService.Type == "gotify" {
|
||||||
|
<option value="gotify" selected="selected">Gotify</option>
|
||||||
|
} else {
|
||||||
|
<option value="gotify">Gotify</option>
|
||||||
|
}
|
||||||
|
if data.NotificationService != nil && data.NotificationService.Type == "pushover" {
|
||||||
|
<option value="pushover" selected="selected">Pushover</option>
|
||||||
|
} else {
|
||||||
|
<option value="pushover">Pushover</option>
|
||||||
|
}
|
||||||
|
<option value="email" disabled>Email (Coming Soon)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="mb-6 hidden common-fields">
|
||||||
|
<label for="notification_name" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Name</label>
|
||||||
|
if data.NotificationService.Name != "" {
|
||||||
|
<input type="text" id="notification_name" name="name" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="My Notification Service" required value={ data.NotificationService.Name }/>
|
||||||
|
} else {
|
||||||
|
<input type="text" id="notification_name" name="name" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="My Notification Service" required value=""/>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="mb-6 hidden common-fields">
|
||||||
|
<label for="notification_description" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Description</label>
|
||||||
|
if data.NotificationService.Description != "" {
|
||||||
|
<textarea id="notification_description" name="description" rows="3" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="Description for this notification service">{ data.NotificationService.Description }</textarea>
|
||||||
|
} else {
|
||||||
|
<textarea id="notification_description" name="description" rows="3" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="Description for this notification service"></textarea>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Dynamic fields based on notification type -->
|
||||||
|
@fields.EmailFields(data)
|
||||||
|
@fields.WebhookFields(data)
|
||||||
|
@fields.PushbulletFields(data)
|
||||||
|
@fields.NtfyFields(data)
|
||||||
|
@fields.GotifyFields(data)
|
||||||
|
@fields.PushoverFields(data)
|
||||||
|
|
||||||
|
|
||||||
|
<div class="mb-6 hidden common-fields">
|
||||||
|
<label class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Event Triggers</label>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mb-2">Select the job events that should trigger this notification.</p>
|
||||||
|
<div class="flex flex-wrap gap-4">
|
||||||
|
for _, event := range []string{"job_start", "job_complete", "job_error"} {
|
||||||
|
<div class="flex items-center">
|
||||||
|
<input
|
||||||
|
id={ "trigger_" + event }
|
||||||
|
name="event_triggers[]"
|
||||||
|
type="checkbox"
|
||||||
|
value={ event }
|
||||||
|
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"
|
||||||
|
if utils.IsEventTriggerSelected(data.NotificationService, event, data.IsNew) {
|
||||||
|
checked
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<label for={ "trigger_" + event } class="ml-2 text-sm font-medium text-gray-900 dark:text-gray-300">{ utils.FormatEventTriggerName(event) }</label>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-start mb-6 hidden common-fields">
|
||||||
|
<div class="flex items-center h-5">
|
||||||
|
<input type="hidden" name="is_enabled" value="false">
|
||||||
|
<input
|
||||||
|
id="is_enabled"
|
||||||
|
name="is_enabled"
|
||||||
|
type="checkbox"
|
||||||
|
value="true"
|
||||||
|
class="w-4 h-4 border border-gray-300 rounded bg-gray-50 focus:ring-3 focus:ring-blue-300 dark:bg-gray-700 dark:border-gray-600 dark:focus:ring-blue-600 dark:ring-offset-gray-800"
|
||||||
|
if data.NotificationService != nil && data.NotificationService.IsEnabled {
|
||||||
|
checked
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="ml-3 text-sm">
|
||||||
|
<label for="is_enabled" class="font-medium text-gray-900 dark:text-white">Enable this notification service</label>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">Check this box to make the service active.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="hidden common-fields">
|
||||||
|
<button type="submit" class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800">
|
||||||
|
if data.IsNew {
|
||||||
|
Add Service
|
||||||
|
} else {
|
||||||
|
Save Changes
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Help Notice -->
|
||||||
|
<div class="mt-8 p-4 bg-gray-50 border border-gray-200 rounded-lg dark:bg-gray-800 dark:border-gray-700">
|
||||||
|
<div class="flex">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<i class="fas fa-info-circle text-blue-400 dark:text-blue-400"></i>
|
||||||
|
</div>
|
||||||
|
<div class="ml-3">
|
||||||
|
<p class="text-sm text-blue-700 dark:text-blue-400">
|
||||||
|
Configure your notification service to receive alerts for job events. Different notification types have different configuration options.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Theme-specific background handled by Tailwind classes -->
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package form
|
||||||
|
|
||||||
|
// FormScripts contains JavaScript specific to the notification form page.
|
||||||
|
templ FormScripts() {
|
||||||
|
<script>
|
||||||
|
// Toggle notification fields based on selection
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const typeSelector = document.getElementById('notification_type');
|
||||||
|
// Ensure typeSelector exists before adding listener
|
||||||
|
if (!typeSelector) {
|
||||||
|
console.warn("Notification type selector not found.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const allFields = document.querySelectorAll('.notification-fields');
|
||||||
|
const commonFields = document.querySelectorAll('.common-fields');
|
||||||
|
|
||||||
|
function toggleFields() {
|
||||||
|
// Hide all specific fields first
|
||||||
|
allFields.forEach(field => field.classList.add('hidden'));
|
||||||
|
|
||||||
|
// Show/hide common fields based on selection
|
||||||
|
const selectedType = typeSelector.value;
|
||||||
|
if (selectedType) {
|
||||||
|
// Show common fields (name, description, is_enabled, submit)
|
||||||
|
commonFields.forEach(field => field.classList.remove('hidden'));
|
||||||
|
|
||||||
|
// Show the selected type's specific fields
|
||||||
|
const fieldsToShow = document.getElementById(`${selectedType}_fields`);
|
||||||
|
if (fieldsToShow) {
|
||||||
|
fieldsToShow.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Hide common fields if no type selected
|
||||||
|
commonFields.forEach(field => field.classList.add('hidden'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
typeSelector.addEventListener('change', toggleFields);
|
||||||
|
|
||||||
|
// Initialize form state on load (if editing or if a type is pre-selected)
|
||||||
|
toggleFields();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetNotificationFormTitle returns the title for the notification form page.
|
||||||
|
func GetNotificationFormTitle(isNew bool) string {
|
||||||
|
if isNew {
|
||||||
|
return "Add Notification Service"
|
||||||
|
}
|
||||||
|
return "Edit Notification Service"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Contains checks if a string slice contains a specific string.
|
||||||
|
func Contains(slice []string, item string) bool {
|
||||||
|
for _, a := range slice {
|
||||||
|
if a == item {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// BoolToString converts a boolean to its string representation "true" or "false".
|
||||||
|
// Useful for setting HTML attributes that expect string values.
|
||||||
|
func BoolToString(b bool) string {
|
||||||
|
if b {
|
||||||
|
return "true"
|
||||||
|
}
|
||||||
|
return "false"
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsEventTriggerSelected checks if a specific event trigger should be pre-selected.
|
||||||
|
// It now accepts the anonymous struct type defined in types.NotificationFormData.
|
||||||
|
// Defaults to checking 'job_complete' and 'job_error' when creating a new service.
|
||||||
|
func IsEventTriggerSelected(service *struct {
|
||||||
|
ID uint
|
||||||
|
Name string
|
||||||
|
Description string
|
||||||
|
Type string
|
||||||
|
IsEnabled bool
|
||||||
|
EventTriggers []string
|
||||||
|
RetryPolicy string
|
||||||
|
WebhookURL string
|
||||||
|
Method string
|
||||||
|
Headers string
|
||||||
|
PayloadTemplate string
|
||||||
|
SecretKey string
|
||||||
|
PushbulletAPIKey string
|
||||||
|
PushbulletDeviceID string
|
||||||
|
PushbulletTitleTemplate string
|
||||||
|
PushbulletBodyTemplate string
|
||||||
|
NtfyServer string
|
||||||
|
NtfyTopic string
|
||||||
|
NtfyPriority string
|
||||||
|
NtfyUsername string
|
||||||
|
NtfyPassword string
|
||||||
|
NtfyTitleTemplate string
|
||||||
|
NtfyMessageTemplate string
|
||||||
|
GotifyURL string
|
||||||
|
GotifyToken string
|
||||||
|
GotifyPriority string
|
||||||
|
GotifyTitleTemplate string
|
||||||
|
GotifyMessageTemplate string
|
||||||
|
PushoverAPIToken string
|
||||||
|
PushoverUserKey string
|
||||||
|
PushoverDevice string
|
||||||
|
PushoverPriority string
|
||||||
|
PushoverSound string
|
||||||
|
PushoverTitleTemplate string
|
||||||
|
PushoverMessageTemplate string
|
||||||
|
}, event string, isNew bool) bool {
|
||||||
|
if !isNew && service != nil {
|
||||||
|
// Use the Contains helper function
|
||||||
|
return Contains(service.EventTriggers, event)
|
||||||
|
}
|
||||||
|
// Default for new services: check complete and error
|
||||||
|
return isNew && (event == "job_complete" || event == "job_error")
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatEventTriggerName converts event trigger keys to human-readable names.
|
||||||
|
func FormatEventTriggerName(event string) string {
|
||||||
|
// Replace underscores with spaces and capitalize words
|
||||||
|
name := strings.ReplaceAll(event, "_", " ")
|
||||||
|
name = strings.Title(name) // Use strings.Title for capitalization
|
||||||
|
return name
|
||||||
|
}
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
package list
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"github.com/starfleetcptn/gomft/components"
|
||||||
|
"github.com/starfleetcptn/gomft/components/notifications/dialog"
|
||||||
|
"github.com/starfleetcptn/gomft/components/notifications/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
// List renders the notification services list page.
|
||||||
|
templ List(ctx context.Context, data types.SettingsNotificationsData) {
|
||||||
|
@components.LayoutWithContext("Notification Services", ctx) {
|
||||||
|
<!-- Status and Error Messages (Handled by shared toast component in layout) -->
|
||||||
|
|
||||||
|
<div id="notifications-container" class="notifications-page bg-gray-50 dark:bg-gray-900 min-h-screen">
|
||||||
|
<div class="pb-8 w-full">
|
||||||
|
<!-- Success Message (hidden, used for HTMX responses/toast trigger) -->
|
||||||
|
if data.SuccessMessage != "" {
|
||||||
|
<div class="hidden success-message">{ data.SuccessMessage }</div>
|
||||||
|
}
|
||||||
|
<!-- Error Message (hidden, used for HTMX responses/toast trigger) -->
|
||||||
|
if data.ErrorMessage != "" {
|
||||||
|
<div class="hidden error-message">{ data.ErrorMessage }</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="mb-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||||
|
<i class="fas fa-bell w-6 h-6 mr-2 text-blue-500 dark:text-blue-400"></i>
|
||||||
|
Notification Services
|
||||||
|
</h1>
|
||||||
|
<a href="/admin/settings/notifications/new" class="flex items-center justify-center text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg px-5 py-2.5 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800">
|
||||||
|
<i class="fas fa-plus w-4 h-4 mr-2"></i>
|
||||||
|
Add Notification Service
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- List of Notification Services -->
|
||||||
|
if len(data.NotificationServices) == 0 {
|
||||||
|
<div class="text-center py-8 bg-white dark:bg-gray-800 shadow-md rounded-lg">
|
||||||
|
<div class="inline-flex items-center justify-center w-16 h-16 rounded-full bg-blue-100 dark:bg-blue-900 mb-4">
|
||||||
|
<i class="fas fa-bell text-2xl text-blue-600 dark:text-blue-400"></i>
|
||||||
|
</div>
|
||||||
|
<h3 class="mb-2 text-lg font-semibold text-gray-900 dark:text-white">No notification services configured</h3>
|
||||||
|
<p class="text-gray-500 dark:text-gray-400 mb-4">Add a notification service to receive alerts for job events.</p>
|
||||||
|
<a href="/admin/settings/notifications/new" class="inline-flex items-center px-3 py-2 text-sm font-medium text-center text-white bg-blue-700 rounded-lg hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800">
|
||||||
|
<i class="fas fa-plus w-4 h-4 mr-2"></i>
|
||||||
|
Add First Notification Service
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
} else {
|
||||||
|
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 overflow-hidden">
|
||||||
|
<ul class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
for _, service := range data.NotificationServices {
|
||||||
|
<li>
|
||||||
|
<div class="block hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
|
||||||
|
<div class="px-4 py-4 sm:px-6">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center">
|
||||||
|
if service.Type == "email" {
|
||||||
|
<div class="w-10 h-10 rounded-full bg-blue-100 flex items-center justify-center text-blue-600 dark:bg-blue-900 dark:text-blue-400 mr-3">
|
||||||
|
<i class="fas fa-envelope"></i>
|
||||||
|
</div>
|
||||||
|
} else if service.Type == "webhook" {
|
||||||
|
<div class="w-10 h-10 rounded-full bg-green-100 flex items-center justify-center text-green-600 dark:bg-green-900 dark:text-green-400 mr-3">
|
||||||
|
<i class="fas fa-code"></i>
|
||||||
|
</div>
|
||||||
|
} else { // Default icon
|
||||||
|
<div class="w-10 h-10 rounded-full bg-gray-100 flex items-center justify-center text-gray-600 dark:bg-gray-700 dark:text-gray-400 mr-3">
|
||||||
|
<i class="fas fa-bell"></i>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-blue-600 dark:text-blue-400 truncate">
|
||||||
|
{ service.Name }
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
{ service.Description }
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ml-2 flex-shrink-0 flex space-x-2">
|
||||||
|
<a
|
||||||
|
href={ templ.SafeURL(fmt.Sprintf("/admin/settings/notifications/%d/edit", service.ID)) }
|
||||||
|
class="text-gray-500 bg-white focus:outline-none hover:bg-gray-100 focus:ring-4 focus:ring-gray-200 rounded-lg text-sm p-2 mr-1 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white dark:focus:ring-gray-700"
|
||||||
|
>
|
||||||
|
<i class="fas fa-edit"></i>
|
||||||
|
</a>
|
||||||
|
<!-- Add notification delete dialog -->
|
||||||
|
@dialog.NotificationDialog(
|
||||||
|
fmt.Sprintf("delete-notification-dialog-%d", service.ID),
|
||||||
|
"Delete Notification Service",
|
||||||
|
fmt.Sprintf("Are you sure you want to delete the notification service '%s'? This cannot be undone.", service.Name),
|
||||||
|
"text-white bg-red-700 hover:bg-red-800 focus:ring-4 focus:ring-red-300 font-medium rounded-lg text-sm px-5 py-2.5 dark:bg-red-600 dark:hover:bg-red-700 focus:outline-none dark:focus:ring-red-800",
|
||||||
|
"Delete",
|
||||||
|
"delete",
|
||||||
|
service.ID,
|
||||||
|
service.Name,
|
||||||
|
)
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={ templ.ComponentScript{Call: fmt.Sprintf("showModal('delete-notification-dialog-%d')", service.ID)} }
|
||||||
|
class="text-red-500 bg-white focus:outline-none hover:bg-gray-100 focus:ring-4 focus:ring-gray-200 rounded-lg text-sm p-2 dark:bg-gray-800 dark:text-red-400 dark:hover:bg-gray-700 dark:hover:text-white dark:focus:ring-gray-700"
|
||||||
|
>
|
||||||
|
<i class="fas fa-trash-alt"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3 sm:flex sm:justify-between">
|
||||||
|
<div class="sm:flex flex-col md:flex-row gap-2 md:gap-6">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<span
|
||||||
|
class={ "px-2 py-1 text-xs font-medium rounded-full",
|
||||||
|
templ.KV("bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300", service.IsEnabled),
|
||||||
|
templ.KV("bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300", !service.IsEnabled) }
|
||||||
|
>
|
||||||
|
if service.IsEnabled {
|
||||||
|
Active
|
||||||
|
} else {
|
||||||
|
Disabled
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
<span class="ml-2 px-2 py-1 text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300 rounded-full">
|
||||||
|
{ service.Type }
|
||||||
|
</span>
|
||||||
|
if len(service.EventTriggers) > 0 && service.Type == "webhook" {
|
||||||
|
<span class="ml-2 px-2 py-1 text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300 rounded-full">
|
||||||
|
{ fmt.Sprintf("%d triggers", len(service.EventTriggers)) }
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
if service.SuccessCount > 0 || service.FailureCount > 0 {
|
||||||
|
<span class="ml-2 px-2 py-1 text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300 rounded-full">
|
||||||
|
{ fmt.Sprintf("%d/%d", service.SuccessCount, service.SuccessCount + service.FailureCount) }
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
if service.Type == "webhook" {
|
||||||
|
<div class="mt-2 md:mt-0 flex items-center space-x-4">
|
||||||
|
<div class="text-xs">
|
||||||
|
<span class="text-gray-500 dark:text-gray-400">Events:</span>
|
||||||
|
<span class="ml-1 text-gray-900 dark:text-gray-300">
|
||||||
|
if len(service.EventTriggers) == 0 {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
for i, trigger := range service.EventTriggers {
|
||||||
|
if i > 0 {
|
||||||
|
<span>, </span>
|
||||||
|
}
|
||||||
|
{ trigger }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs">
|
||||||
|
<span class="text-gray-500 dark:text-gray-400">Retry:</span>
|
||||||
|
<span class="ml-1 text-gray-900 dark:text-gray-300">
|
||||||
|
if service.RetryPolicy == "" {
|
||||||
|
Default
|
||||||
|
} else {
|
||||||
|
{ service.RetryPolicy }
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
} else {
|
||||||
|
<div class="mt-2 md:mt-0 flex items-center text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
<i class="far fa-clock w-4 h-4 mr-1.5 text-gray-400 dark:text-gray-500"></i>
|
||||||
|
<p>
|
||||||
|
Last sent:
|
||||||
|
if service.SuccessCount > 0 {
|
||||||
|
"Recently"
|
||||||
|
} else {
|
||||||
|
"Never"
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Help Notice Placeholder -->
|
||||||
|
<div class="mt-8 p-4 bg-gray-50 border border-gray-200 rounded-lg dark:bg-gray-800 dark:border-gray-700">
|
||||||
|
<div class="flex">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<i class="fas fa-info-circle text-blue-400 dark:text-blue-400"></i>
|
||||||
|
</div>
|
||||||
|
<div class="ml-3">
|
||||||
|
<p class="text-sm text-blue-700 dark:text-blue-400">
|
||||||
|
Notification services allow the system to send alerts for job events such as completion, errors, or when jobs start.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@dialog.DialogScripts()
|
||||||
|
|
||||||
|
}
|
||||||
|
// Script call removed for now
|
||||||
|
}
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
package list
|
||||||
|
|
||||||
|
// ListScripts contains JavaScript specific to the notification list page.
|
||||||
|
templ ListScripts() {
|
||||||
|
<script>
|
||||||
|
// Notification system (showToast function is now in shared/toast/toast_js.templ)
|
||||||
|
|
||||||
|
// Track all HTMX events for debugging
|
||||||
|
document.addEventListener('htmx:beforeRequest', function(event) {
|
||||||
|
// Check if this is a DELETE request for a notification service
|
||||||
|
const path = event.detail.path;
|
||||||
|
const method = event.detail.verb;
|
||||||
|
|
||||||
|
console.log(`Request path: ${path}, method: ${method}`);
|
||||||
|
|
||||||
|
// Pattern match for notification service deletions (e.g., /admin/settings/notifications/123)
|
||||||
|
if (path && method === 'DELETE' && path.match(/^\/admin\/settings\/notifications\/\d+$/)) {
|
||||||
|
console.log("Detected notification service deletion request via URL pattern");
|
||||||
|
|
||||||
|
// This is definitely a delete request - store this information
|
||||||
|
window.isServiceDeleteRequest = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('htmx:afterRequest', function(event) {
|
||||||
|
// Check for notification service deletion multiple ways
|
||||||
|
const isDeleteRequest =
|
||||||
|
// Check global flag from the triggerServiceDelete function
|
||||||
|
window.currentlyDeletingService ||
|
||||||
|
// Check flag from beforeRequest handler
|
||||||
|
window.isServiceDeleteRequest ||
|
||||||
|
// Check URL pattern directly from this event
|
||||||
|
(event.detail.pathInfo &&
|
||||||
|
event.detail.pathInfo.requestPath &&
|
||||||
|
event.detail.pathInfo.requestPath.match(/^\/admin\/settings\/notifications\/\d+$/) &&
|
||||||
|
event.detail.verb === 'DELETE');
|
||||||
|
|
||||||
|
console.log(`Is delete request: ${isDeleteRequest}`);
|
||||||
|
|
||||||
|
// If this is a successful delete request, show notification
|
||||||
|
if (isDeleteRequest && event.detail.successful) {
|
||||||
|
console.log("Delete request was successful");
|
||||||
|
|
||||||
|
let serviceName = "Unknown";
|
||||||
|
|
||||||
|
// Try multiple sources for service name
|
||||||
|
if (event.detail.elt && event.detail.elt.getAttribute) {
|
||||||
|
serviceName = event.detail.elt.getAttribute('data-service-name') || serviceName;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (serviceName === "Unknown" && window.lastDeletedService) {
|
||||||
|
// Fallback to our stored service info
|
||||||
|
serviceName = window.lastDeletedService.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Showing success notification for deleted service: ${serviceName}`);
|
||||||
|
// Ensure showToast is globally available
|
||||||
|
if (typeof showToast === 'function') {
|
||||||
|
showToast(`Notification service "${serviceName}" deleted successfully`, 'success');
|
||||||
|
} else {
|
||||||
|
console.error("showToast function not found!");
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Clear flags
|
||||||
|
window.currentlyDeletingService = false;
|
||||||
|
window.isServiceDeleteRequest = false;
|
||||||
|
window.lastDeletedService = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('htmx:responseError', function(event) {
|
||||||
|
console.log("HTMX response error:", event.detail);
|
||||||
|
|
||||||
|
// Similar logic as success but for errors
|
||||||
|
const isDeleteRequest =
|
||||||
|
window.currentlyDeletingService ||
|
||||||
|
window.isServiceDeleteRequest ||
|
||||||
|
(event.detail.pathInfo &&
|
||||||
|
event.detail.pathInfo.requestPath &&
|
||||||
|
event.detail.pathInfo.requestPath.match(/^\/admin\/settings\/notifications\/\d+$/) &&
|
||||||
|
event.detail.verb === 'DELETE');
|
||||||
|
|
||||||
|
let errorMsg = 'An error occurred';
|
||||||
|
if (event.detail.xhr && event.detail.xhr.responseText) {
|
||||||
|
errorMsg = event.detail.xhr.responseText;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isDeleteRequest) {
|
||||||
|
console.log("Delete request failed");
|
||||||
|
|
||||||
|
let serviceName = "Unknown";
|
||||||
|
|
||||||
|
// Try multiple sources for service name
|
||||||
|
if (event.detail.elt && event.detail.elt.getAttribute) {
|
||||||
|
serviceName = event.detail.elt.getAttribute('data-service-name') || serviceName;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (serviceName === "Unknown" && window.lastDeletedService) {
|
||||||
|
// Fallback to our stored service info
|
||||||
|
serviceName = window.lastDeletedService.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
let specificErrorMsg = `Failed to delete notification service "${serviceName}"`;
|
||||||
|
|
||||||
|
if (event.detail.xhr && event.detail.xhr.responseText) {
|
||||||
|
// Try to provide a more specific error from the response
|
||||||
|
specificErrorMsg = `Error deleting "${serviceName}": ${event.detail.xhr.responseText}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Showing error notification: ${specificErrorMsg}`);
|
||||||
|
if (typeof showToast === 'function') {
|
||||||
|
showToast(specificErrorMsg, 'error');
|
||||||
|
} else {
|
||||||
|
console.error("showToast function not found!");
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Clear flags
|
||||||
|
window.currentlyDeletingService = false;
|
||||||
|
window.isServiceDeleteRequest = false;
|
||||||
|
window.lastDeletedService = null;
|
||||||
|
} else {
|
||||||
|
// General error toast
|
||||||
|
if (typeof showToast === 'function') {
|
||||||
|
showToast(errorMsg, 'error');
|
||||||
|
} else {
|
||||||
|
console.error("showToast function not found!");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle modal hide buttons (This might be better placed globally or in layout if modals are used elsewhere)
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
// This listener handles closing modals via data-modal-hide attribute
|
||||||
|
// It might conflict or be redundant if Flowbite's JS handles this already.
|
||||||
|
// Consider removing if Flowbite is initialized globally.
|
||||||
|
const hideButtons = document.querySelectorAll('[data-modal-hide]');
|
||||||
|
hideButtons.forEach(button => {
|
||||||
|
button.addEventListener('click', function() {
|
||||||
|
const modalId = this.getAttribute('data-modal-hide');
|
||||||
|
const modal = document.getElementById(modalId);
|
||||||
|
if (modal) {
|
||||||
|
modal.classList.add('hidden');
|
||||||
|
modal.classList.remove('flex');
|
||||||
|
}
|
||||||
|
const backdrop = document.getElementById(modalId + "-backdrop");
|
||||||
|
if (backdrop) {
|
||||||
|
backdrop.classList.add("hidden");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Show any success or error messages passed via data struct as toasts
|
||||||
|
const successDiv = document.querySelector('.success-message');
|
||||||
|
if (successDiv) {
|
||||||
|
const successMsg = successDiv.textContent.trim();
|
||||||
|
if (successMsg && typeof showToast === 'function') {
|
||||||
|
showToast(successMsg, 'success');
|
||||||
|
} else if (successMsg) {
|
||||||
|
console.error("showToast function not found, cannot display success message:", successMsg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const errorDiv = document.querySelector('.error-message');
|
||||||
|
if (errorDiv) {
|
||||||
|
const errorMsg = errorDiv.textContent.trim();
|
||||||
|
if (errorMsg && typeof showToast === 'function') {
|
||||||
|
showToast(errorMsg, 'error');
|
||||||
|
} else if (errorMsg) {
|
||||||
|
console.error("showToast function not found, cannot display error message:", errorMsg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Script for handling the service deletion trigger
|
||||||
|
function triggerServiceDelete(dialogId, serviceID, serviceName) {
|
||||||
|
// Hide the dialog first
|
||||||
|
closeModal(dialogId); // Reuse closeModal logic
|
||||||
|
|
||||||
|
// Add debugging info
|
||||||
|
console.log(`Notification service deletion triggered for: ${serviceName} (ID: ${serviceID})`);
|
||||||
|
|
||||||
|
// Store data in a way that's accessible to event handlers
|
||||||
|
window.lastDeletedService = {
|
||||||
|
id: serviceID,
|
||||||
|
name: serviceName
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add custom marker to track this deletion
|
||||||
|
window.currentlyDeletingService = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Script for closing the modal
|
||||||
|
function closeModal(id) {
|
||||||
|
const dialog = document.getElementById(id);
|
||||||
|
if (dialog) {
|
||||||
|
dialog.classList.add("hidden");
|
||||||
|
dialog.classList.remove("flex");
|
||||||
|
}
|
||||||
|
const backdrop = document.getElementById(id + "-backdrop");
|
||||||
|
if (backdrop) {
|
||||||
|
// Instead of removing, hide it to potentially reuse
|
||||||
|
backdrop.classList.add("hidden");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Script for showing the modal
|
||||||
|
function showModal(id) {
|
||||||
|
const dialog = document.getElementById(id);
|
||||||
|
if (dialog) {
|
||||||
|
dialog.classList.remove("hidden");
|
||||||
|
dialog.classList.add("flex"); // Use flex to center content
|
||||||
|
}
|
||||||
|
const backdrop = document.getElementById(id + "-backdrop");
|
||||||
|
if (backdrop) {
|
||||||
|
backdrop.classList.remove("hidden");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package types
|
||||||
|
|
||||||
|
// SettingsNotificationsData defines the data needed for the notifications list page
|
||||||
|
type SettingsNotificationsData struct {
|
||||||
|
NotificationServices []NotificationServiceData
|
||||||
|
SuccessMessage string
|
||||||
|
ErrorMessage string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NotificationServiceData defines the data for a single service in the list
|
||||||
|
type NotificationServiceData struct {
|
||||||
|
ID uint
|
||||||
|
Name string
|
||||||
|
Type string
|
||||||
|
IsEnabled bool
|
||||||
|
Config map[string]string // Keep for now, might refine later
|
||||||
|
Description string
|
||||||
|
EventTriggers []string
|
||||||
|
PayloadTemplate string
|
||||||
|
SecretKey string
|
||||||
|
RetryPolicy string
|
||||||
|
SuccessCount int
|
||||||
|
FailureCount int
|
||||||
|
}
|
||||||
|
|
||||||
|
// NotificationFormData defines the data needed for the notification add/edit form
|
||||||
|
// TODO: This will be moved from notification_form.templ later
|
||||||
|
type NotificationFormData struct {
|
||||||
|
NotificationService *struct {
|
||||||
|
ID uint
|
||||||
|
Name string
|
||||||
|
Description string
|
||||||
|
Type string
|
||||||
|
IsEnabled bool
|
||||||
|
EventTriggers []string
|
||||||
|
RetryPolicy string
|
||||||
|
WebhookURL string
|
||||||
|
Method string
|
||||||
|
Headers string
|
||||||
|
PayloadTemplate string
|
||||||
|
SecretKey string
|
||||||
|
PushbulletAPIKey string
|
||||||
|
PushbulletDeviceID string
|
||||||
|
PushbulletTitleTemplate string
|
||||||
|
PushbulletBodyTemplate string
|
||||||
|
NtfyServer string
|
||||||
|
NtfyTopic string
|
||||||
|
NtfyPriority string
|
||||||
|
NtfyUsername string
|
||||||
|
NtfyPassword string
|
||||||
|
NtfyTitleTemplate string
|
||||||
|
NtfyMessageTemplate string
|
||||||
|
GotifyURL string
|
||||||
|
GotifyToken string
|
||||||
|
GotifyPriority string
|
||||||
|
GotifyTitleTemplate string
|
||||||
|
GotifyMessageTemplate string
|
||||||
|
PushoverAPIToken string
|
||||||
|
PushoverUserKey string
|
||||||
|
PushoverDevice string
|
||||||
|
PushoverPriority string
|
||||||
|
PushoverSound string
|
||||||
|
PushoverTitleTemplate string
|
||||||
|
PushoverMessageTemplate string
|
||||||
|
}
|
||||||
|
IsNew bool
|
||||||
|
SuccessMessage string
|
||||||
|
ErrorMessage string
|
||||||
|
}
|
||||||
+161
-121
@@ -5,55 +5,53 @@ import (
|
|||||||
"github.com/starfleetcptn/gomft/internal/db"
|
"github.com/starfleetcptn/gomft/internal/db"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Dialog component for 2FA disable confirmation
|
// Dialog component for 2FA disable confirmation with Flowbite styling
|
||||||
templ TwoFactorDisableDialog() {
|
templ TwoFactorDisableDialog() {
|
||||||
<div id="disable-2fa-dialog" class="hidden fixed inset-0 bg-secondary-900/50 dark:bg-secondary-900/80 backdrop-blur-sm z-50 flex items-center justify-center">
|
<div id="disable-2fa-dialog" tabindex="-1" aria-hidden="true" class="hidden fixed top-0 right-0 left-0 z-50 justify-center items-center w-full inset-0 overflow-y-auto overflow-x-hidden bg-gray-900/50 dark:bg-gray-900/80 backdrop-blur-sm">
|
||||||
<div class="bg-white dark:bg-secondary-800 rounded-lg shadow-xl max-w-md w-full mx-4 overflow-hidden">
|
<div class="relative p-4 w-full max-w-md max-h-full mx-auto">
|
||||||
<div class="px-6 pt-5 pb-3 text-center">
|
<div class="relative bg-white rounded-lg shadow-xl dark:bg-gray-800 border border-gray-200 dark:border-gray-700">
|
||||||
<div class="flex justify-center mb-2">
|
<div class="p-5 text-center">
|
||||||
<i class="fas fa-shield-alt text-yellow-400 text-3xl"></i>
|
<div class="h-14 w-14 rounded-full bg-yellow-100 dark:bg-yellow-900 p-2 flex items-center justify-center mx-auto mb-5">
|
||||||
</div>
|
<i class="fas fa-shield-alt text-yellow-500 dark:text-yellow-300 text-xl"></i>
|
||||||
<h3 class="text-xl font-medium text-secondary-900 dark:text-secondary-100">
|
</div>
|
||||||
Disable Two-Factor Authentication
|
<h3 class="mb-5 text-xl font-semibold text-gray-900 dark:text-white">
|
||||||
</h3>
|
Disable Two-Factor Authentication
|
||||||
</div>
|
</h3>
|
||||||
<div class="px-6 py-4">
|
<p class="mb-6 text-gray-500 dark:text-gray-400">
|
||||||
<p class="text-secondary-700 dark:text-secondary-300 mb-4">
|
Are you sure you want to disable two-factor authentication? This will make your account less secure.
|
||||||
Are you sure you want to disable two-factor authentication? This will make your account less secure.
|
</p>
|
||||||
</p>
|
<div class="mb-6">
|
||||||
<div class="space-y-4">
|
<label for="current-password-2fa" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
|
||||||
<div>
|
|
||||||
<label for="current-password-2fa" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
|
|
||||||
<i class="fas fa-lock mr-1"></i> Current Password
|
<i class="fas fa-lock mr-1"></i> Current Password
|
||||||
</label>
|
</label>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
|
||||||
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
|
<i class="fas fa-key text-gray-400 dark:text-gray-500"></i>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
id="current-password-2fa"
|
id="current-password-2fa"
|
||||||
name="current_password"
|
name="current_password"
|
||||||
class="form-input pl-10 w-full"
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full pl-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
placeholder="Enter your current password"
|
placeholder="Enter your current password"
|
||||||
required/>
|
required/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="disable-2fa-result"></div>
|
<div id="disable-2fa-result" class="mb-5"></div>
|
||||||
|
<div class="flex justify-center space-x-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-white bg-red-600 hover:bg-red-700 focus:ring-4 focus:outline-none focus:ring-red-300 font-medium rounded-lg text-sm px-5 py-2.5 inline-flex items-center dark:bg-red-600 dark:hover:bg-red-700 dark:focus:ring-red-800"
|
||||||
|
onclick="submitDisable2FA()">
|
||||||
|
<i class="fas fa-times mr-2"></i>
|
||||||
|
Disable 2FA
|
||||||
|
</button>
|
||||||
|
<button type="button" class="py-2.5 px-5 text-sm font-medium text-gray-900 focus:outline-none bg-white rounded-lg border border-gray-200 hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-4 focus:ring-gray-100 dark:focus:ring-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700" onclick="hideDisable2FADialog()">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="px-6 py-4 flex justify-end space-x-3">
|
|
||||||
<button type="button" class="btn-secondary" onclick="hideDisable2FADialog()">
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn-danger"
|
|
||||||
onclick="submitDisable2FA()">
|
|
||||||
<i class="fas fa-times mr-1"></i>
|
|
||||||
Disable 2FA
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -68,6 +66,7 @@ templ Profile(ctx context.Context, user db.User) {
|
|||||||
// Global functions for dialog control
|
// Global functions for dialog control
|
||||||
window.hideDisable2FADialog = function() {
|
window.hideDisable2FADialog = function() {
|
||||||
document.getElementById('disable-2fa-dialog').classList.add('hidden');
|
document.getElementById('disable-2fa-dialog').classList.add('hidden');
|
||||||
|
document.getElementById('disable-2fa-dialog').classList.remove('flex');
|
||||||
document.getElementById('current-password-2fa').value = '';
|
document.getElementById('current-password-2fa').value = '';
|
||||||
document.getElementById('disable-2fa-result').innerHTML = '';
|
document.getElementById('disable-2fa-result').innerHTML = '';
|
||||||
};
|
};
|
||||||
@@ -75,14 +74,18 @@ templ Profile(ctx context.Context, user db.User) {
|
|||||||
window.showDisable2FADialog = function() {
|
window.showDisable2FADialog = function() {
|
||||||
console.log('Showing 2FA disable dialog');
|
console.log('Showing 2FA disable dialog');
|
||||||
document.getElementById('disable-2fa-dialog').classList.remove('hidden');
|
document.getElementById('disable-2fa-dialog').classList.remove('hidden');
|
||||||
|
document.getElementById('disable-2fa-dialog').classList.add('flex');
|
||||||
};
|
};
|
||||||
|
|
||||||
window.submitDisable2FA = function() {
|
window.submitDisable2FA = function() {
|
||||||
const password = document.getElementById('current-password-2fa').value;
|
const password = document.getElementById('current-password-2fa').value;
|
||||||
if (!password) {
|
if (!password) {
|
||||||
document.getElementById('disable-2fa-result').innerHTML = `
|
document.getElementById('disable-2fa-result').innerHTML = `
|
||||||
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded" role="alert">
|
<div class="p-4 text-sm text-red-800 rounded-lg bg-red-50 dark:bg-red-900 dark:text-red-400" role="alert">
|
||||||
<span class="block sm:inline">Current password is required</span>
|
<div class="flex items-center">
|
||||||
|
<i class="fas fa-exclamation-circle mr-2"></i>
|
||||||
|
<span>Current password is required</span>
|
||||||
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -111,64 +114,88 @@ templ Profile(ctx context.Context, user db.User) {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
@TwoFactorDisableDialog()
|
@TwoFactorDisableDialog()
|
||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
<div class="p-4 md:p-6 2xl:p-10">
|
||||||
<div class="flex items-center justify-between mb-6">
|
<!-- Profile Header -->
|
||||||
<h1 class="text-3xl font-bold text-secondary-900 dark:text-secondary-100">
|
<div class="mb-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||||
<i class="fas fa-user-circle mr-2 text-primary-600 dark:text-primary-400"></i>
|
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||||
|
<i class="fas fa-user-circle w-6 h-6 mr-2 text-blue-500"></i>
|
||||||
Profile
|
Profile
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||||
<!-- Profile Information Card -->
|
<!-- Profile Information Card -->
|
||||||
<div class="card">
|
<div class="bg-white border border-gray-200 rounded-xl shadow-sm dark:border-gray-700 dark:bg-gray-800 transition-all duration-200 hover:shadow-md">
|
||||||
<div class="card-header">
|
<div class="p-5 border-b border-gray-200 dark:border-gray-700">
|
||||||
<h3 class="text-lg font-medium text-secondary-900 dark:text-secondary-100">
|
<div class="flex items-center space-x-3">
|
||||||
<i class="fas fa-id-card mr-2 text-primary-500"></i>
|
<div class="h-10 w-10 bg-blue-100 dark:bg-blue-900 rounded-full flex items-center justify-center">
|
||||||
Profile Information
|
<i class="fas fa-id-card text-blue-600 dark:text-blue-300"></i>
|
||||||
</h3>
|
</div>
|
||||||
<p class="mt-1 text-sm text-secondary-500 dark:text-secondary-400">Personal details and application settings.</p>
|
<div>
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
|
Profile Information
|
||||||
|
</h3>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">Personal details and application settings</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="p-5">
|
||||||
<dl class="space-y-6">
|
<dl class="space-y-6">
|
||||||
<div class="flex flex-col sm:flex-row">
|
<div class="flex flex-col sm:flex-row">
|
||||||
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400 sm:w-1/3 mb-1 sm:mb-0">Email</dt>
|
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400 sm:w-1/3 mb-1 sm:mb-0">Email</dt>
|
||||||
<dd class="text-sm text-secondary-900 dark:text-secondary-100 sm:w-2/3">{ user.Email }</dd>
|
<dd class="text-sm font-medium text-gray-900 dark:text-white sm:w-2/3">{ user.Email }</dd>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col sm:flex-row">
|
<div class="flex flex-col sm:flex-row">
|
||||||
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400 sm:w-1/3 mb-1 sm:mb-0">Role</dt>
|
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400 sm:w-1/3 mb-1 sm:mb-0">Roles</dt>
|
||||||
<dd class="text-sm text-secondary-900 dark:text-secondary-100 sm:w-2/3">
|
<dd class="text-sm font-medium text-gray-900 dark:text-white sm:w-2/3">
|
||||||
if user.GetIsAdmin() {
|
<div class="flex flex-wrap gap-2">
|
||||||
<span class="badge badge-success">
|
if user.GetIsAdmin() {
|
||||||
<i class="fas fa-user-shield mr-1"></i> Administrator
|
<span class="bg-blue-100 text-blue-800 text-xs font-medium px-3 py-1 rounded-full dark:bg-blue-900 dark:text-blue-300 inline-flex items-center">
|
||||||
</span>
|
<i class="fas fa-user-shield w-3 h-3 mr-1.5"></i> Administrator
|
||||||
} else {
|
</span>
|
||||||
<span class="badge badge-info">
|
}
|
||||||
<i class="fas fa-user mr-1"></i> Regular User
|
for _, role := range user.Roles {
|
||||||
</span>
|
<span class="bg-gray-100 text-gray-800 text-xs font-medium px-3 py-1 rounded-full dark:bg-gray-700 dark:text-gray-300 inline-flex items-center">
|
||||||
}
|
<i class="fas fa-user-tag w-3 h-3 mr-1.5"></i> { role.Name }
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col sm:flex-row">
|
<div class="flex flex-col sm:flex-row">
|
||||||
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400 sm:w-1/3 mb-1 sm:mb-0">Two-Factor Authentication</dt>
|
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400 sm:w-1/3 mb-1 sm:mb-0">Permissions</dt>
|
||||||
<dd class="text-sm text-secondary-900 dark:text-secondary-100 sm:w-2/3">
|
<dd class="text-sm font-medium text-gray-900 dark:text-white sm:w-2/3">
|
||||||
|
<div class="flex flex-wrap gap-1">
|
||||||
|
for _, role := range user.Roles {
|
||||||
|
for _, perm := range role.GetPermissions() {
|
||||||
|
<span class="bg-green-100 text-green-800 text-xs font-medium px-2 py-0.5 rounded dark:bg-green-900 dark:text-green-300">
|
||||||
|
{ perm }
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col sm:flex-row">
|
||||||
|
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400 sm:w-1/3 mb-1 sm:mb-0">Two-Factor Authentication</dt>
|
||||||
|
<dd class="text-sm font-medium text-gray-900 dark:text-white sm:w-2/3">
|
||||||
if user.TwoFactorEnabled {
|
if user.TwoFactorEnabled {
|
||||||
<div class="flex flex-col space-y-3">
|
<div class="flex flex-col space-y-3">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<span class="badge badge-success">
|
<span class="bg-green-100 text-green-800 text-xs font-medium px-3 py-1 rounded-full dark:bg-green-900 dark:text-green-300 inline-flex items-center">
|
||||||
<i class="fas fa-shield-alt mr-1"></i> Enabled
|
<i class="fas fa-shield-alt w-3 h-3 mr-1.5"></i> Enabled
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-wrap gap-2">
|
<div class="flex flex-wrap gap-2 mt-2">
|
||||||
<a href="/profile/2fa/backup-codes" class="btn-secondary btn-sm">
|
<a href="/profile/2fa/backup-codes" class="py-2 px-3 text-xs font-medium text-gray-900 focus:outline-none bg-white rounded-lg border border-gray-200 hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-4 focus:ring-gray-100 dark:focus:ring-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700 inline-flex items-center shadow-sm">
|
||||||
<i class="fas fa-key mr-1"></i>
|
<i class="fas fa-key w-3 h-3 mr-1.5"></i>
|
||||||
Manage Backup Codes
|
Manage Backup Codes
|
||||||
</a>
|
</a>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn-danger btn-sm"
|
class="text-white bg-red-600 hover:bg-red-700 focus:ring-4 focus:outline-none focus:ring-red-300 font-medium rounded-lg text-xs px-3 py-2 text-center inline-flex items-center dark:bg-red-600 dark:hover:bg-red-700 dark:focus:ring-red-800 shadow-sm"
|
||||||
onclick="showDisable2FADialog()">
|
onclick="showDisable2FADialog()">
|
||||||
<i class="fas fa-times mr-1"></i>
|
<i class="fas fa-times w-3 h-3 mr-1.5"></i>
|
||||||
Disable 2FA
|
Disable 2FA
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -176,13 +203,13 @@ templ Profile(ctx context.Context, user db.User) {
|
|||||||
} else {
|
} else {
|
||||||
<div class="flex flex-col space-y-3">
|
<div class="flex flex-col space-y-3">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<span class="badge badge-warning">
|
<span class="bg-yellow-100 text-yellow-800 text-xs font-medium px-3 py-1 rounded-full dark:bg-yellow-900 dark:text-yellow-300 inline-flex items-center">
|
||||||
<i class="fas fa-shield-alt mr-1"></i> Disabled
|
<i class="fas fa-shield-alt w-3 h-3 mr-1.5"></i> Disabled
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="mt-2">
|
||||||
<a href="/profile/2fa/setup" class="btn-primary btn-sm">
|
<a href="/profile/2fa/setup" class="text-white bg-blue-600 hover:bg-blue-700 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-xs px-3 py-2 text-center inline-flex items-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800 shadow-sm">
|
||||||
<i class="fas fa-lock mr-1"></i>
|
<i class="fas fa-lock w-3 h-3 mr-1.5"></i>
|
||||||
Enable 2FA
|
Enable 2FA
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -191,12 +218,12 @@ templ Profile(ctx context.Context, user db.User) {
|
|||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col sm:flex-row">
|
<div class="flex flex-col sm:flex-row">
|
||||||
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400 sm:w-1/3 mb-1 sm:mb-0">Theme</dt>
|
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400 sm:w-1/3 mb-1 sm:mb-0">Theme</dt>
|
||||||
<dd class="text-sm text-secondary-900 dark:text-secondary-100 sm:w-2/3">
|
<dd class="text-sm font-medium text-gray-900 dark:text-white sm:w-2/3">
|
||||||
<form
|
<form
|
||||||
hx-post="/profile/theme"
|
hx-post="/profile/theme"
|
||||||
hx-swap="none"
|
hx-swap="none"
|
||||||
class="flex items-center space-x-4">
|
class="flex items-center space-x-5">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<input
|
<input
|
||||||
type="radio"
|
type="radio"
|
||||||
@@ -206,9 +233,9 @@ templ Profile(ctx context.Context, user db.User) {
|
|||||||
checked?={ user.Theme == "light" || user.Theme == "" }
|
checked?={ user.Theme == "light" || user.Theme == "" }
|
||||||
hx-trigger="change"
|
hx-trigger="change"
|
||||||
hx-post="/profile/theme"
|
hx-post="/profile/theme"
|
||||||
class="form-checkbox" />
|
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600" />
|
||||||
<label for="theme-light" class="ml-2 block text-sm text-secondary-700 dark:text-secondary-300">
|
<label for="theme-light" class="ml-2 text-sm font-medium text-gray-900 dark:text-gray-300">
|
||||||
<i class="fas fa-sun mr-1"></i> Light
|
<i class="fas fa-sun mr-1.5"></i> Light
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
@@ -220,9 +247,9 @@ templ Profile(ctx context.Context, user db.User) {
|
|||||||
checked?={ user.Theme == "dark" }
|
checked?={ user.Theme == "dark" }
|
||||||
hx-trigger="change"
|
hx-trigger="change"
|
||||||
hx-post="/profile/theme"
|
hx-post="/profile/theme"
|
||||||
class="form-checkbox" />
|
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600" />
|
||||||
<label for="theme-dark" class="ml-2 block text-sm text-secondary-700 dark:text-secondary-300">
|
<label for="theme-dark" class="ml-2 text-sm font-medium text-gray-900 dark:text-gray-300">
|
||||||
<i class="fas fa-moon mr-1"></i> Dark
|
<i class="fas fa-moon mr-1.5"></i> Dark
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
@@ -234,9 +261,9 @@ templ Profile(ctx context.Context, user db.User) {
|
|||||||
checked?={ user.Theme == "system" }
|
checked?={ user.Theme == "system" }
|
||||||
hx-trigger="change"
|
hx-trigger="change"
|
||||||
hx-post="/profile/theme"
|
hx-post="/profile/theme"
|
||||||
class="form-checkbox" />
|
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600" />
|
||||||
<label for="theme-system" class="ml-2 block text-sm text-secondary-700 dark:text-secondary-300">
|
<label for="theme-system" class="ml-2 text-sm font-medium text-gray-900 dark:text-gray-300">
|
||||||
<i class="fas fa-desktop mr-1"></i> System
|
<i class="fas fa-desktop mr-1.5"></i> System
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -247,15 +274,21 @@ templ Profile(ctx context.Context, user db.User) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Change Password Card -->
|
<!-- Change Password Card -->
|
||||||
<div class="card">
|
<div class="bg-white border border-gray-200 rounded-xl shadow-sm dark:border-gray-700 dark:bg-gray-800 transition-all duration-200 hover:shadow-md">
|
||||||
<div class="card-header">
|
<div class="p-5 border-b border-gray-200 dark:border-gray-700">
|
||||||
<h3 class="text-lg font-medium text-secondary-900 dark:text-secondary-100">
|
<div class="flex items-center space-x-3">
|
||||||
<i class="fas fa-key mr-2 text-primary-500"></i>
|
<div class="h-10 w-10 bg-blue-100 dark:bg-blue-900 rounded-full flex items-center justify-center">
|
||||||
Change Password
|
<i class="fas fa-key text-blue-600 dark:text-blue-300"></i>
|
||||||
</h3>
|
</div>
|
||||||
<p class="mt-1 text-sm text-secondary-500 dark:text-secondary-400">Update your password to keep your account secure.</p>
|
<div>
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
|
Change Password
|
||||||
|
</h3>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">Update your password to keep your account secure</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="p-5">
|
||||||
<form
|
<form
|
||||||
method="POST"
|
method="POST"
|
||||||
action="/change-password"
|
action="/change-password"
|
||||||
@@ -264,7 +297,7 @@ templ Profile(ctx context.Context, user db.User) {
|
|||||||
hx-swap="innerHTML"
|
hx-swap="innerHTML"
|
||||||
hx-headers='{"X-Profile-Page": "true"}'
|
hx-headers='{"X-Profile-Page": "true"}'
|
||||||
hx-indicator="#password-change-indicator"
|
hx-indicator="#password-change-indicator"
|
||||||
class="space-y-4"
|
class="space-y-5"
|
||||||
x-data="{
|
x-data="{
|
||||||
currentPassword: '',
|
currentPassword: '',
|
||||||
newPassword: '',
|
newPassword: '',
|
||||||
@@ -283,57 +316,57 @@ templ Profile(ctx context.Context, user db.User) {
|
|||||||
<div id="password-result"></div>
|
<div id="password-result"></div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label for="current-password" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
|
<label for="current-password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
|
||||||
<i class="fas fa-lock mr-1"></i> Current Password
|
<i class="fas fa-lock mr-1.5"></i> Current Password
|
||||||
</label>
|
</label>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
|
||||||
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
|
<i class="fas fa-key text-gray-400 dark:text-gray-500"></i>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
id="current-password"
|
id="current-password"
|
||||||
name="current_password"
|
name="current_password"
|
||||||
x-model="currentPassword"
|
x-model="currentPassword"
|
||||||
class="form-input pl-10 w-full"
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full pl-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
placeholder="••••••••"
|
placeholder="••••••••"
|
||||||
required/>
|
required/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label for="new-password" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
|
<label for="new-password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
|
||||||
<i class="fas fa-lock-open mr-1"></i> New Password
|
<i class="fas fa-lock-open mr-1.5"></i> New Password
|
||||||
</label>
|
</label>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
|
||||||
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
|
<i class="fas fa-key text-gray-400 dark:text-gray-500"></i>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
id="new-password"
|
id="new-password"
|
||||||
name="new_password"
|
name="new_password"
|
||||||
x-model="newPassword"
|
x-model="newPassword"
|
||||||
class="form-input pl-10 w-full"
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full pl-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
placeholder="••••••••"
|
placeholder="••••••••"
|
||||||
required/>
|
required/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label for="confirm-password" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
|
<label for="confirm-password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
|
||||||
<i class="fas fa-check-double mr-1"></i> Confirm New Password
|
<i class="fas fa-check-double mr-1.5"></i> Confirm New Password
|
||||||
</label>
|
</label>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
|
||||||
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
|
<i class="fas fa-key text-gray-400 dark:text-gray-500"></i>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
id="confirm-password"
|
id="confirm-password"
|
||||||
name="confirm_password"
|
name="confirm_password"
|
||||||
x-model="confirmPassword"
|
x-model="confirmPassword"
|
||||||
class="form-input pl-10 w-full"
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full pl-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
placeholder="••••••••"
|
placeholder="••••••••"
|
||||||
required/>
|
required/>
|
||||||
</div>
|
</div>
|
||||||
@@ -342,13 +375,13 @@ templ Profile(ctx context.Context, user db.User) {
|
|||||||
<div>
|
<div>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
class="btn-primary w-full flex justify-center py-3"
|
class="w-full text-white bg-blue-600 hover:bg-blue-700 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800 shadow-sm"
|
||||||
x-bind:disabled="!validate() || loading">
|
x-bind:disabled="!validate() || loading">
|
||||||
<span x-show="!loading" class="flex items-center">
|
<span x-show="!loading" class="flex items-center justify-center">
|
||||||
<i class="fas fa-save mr-2"></i>
|
<i class="fas fa-save mr-2"></i>
|
||||||
Update Password
|
Update Password
|
||||||
</span>
|
</span>
|
||||||
<span x-show="loading" class="flex items-center">
|
<span x-show="loading" class="flex items-center justify-center">
|
||||||
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
@@ -359,9 +392,11 @@ templ Profile(ctx context.Context, user db.User) {
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<div class="px-6 py-4 bg-secondary-50 dark:bg-secondary-800 border-t border-secondary-200 dark:border-secondary-700">
|
<div class="p-5 bg-gray-50 dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 rounded-b-xl">
|
||||||
<div class="flex items-center text-sm text-secondary-500 dark:text-secondary-400">
|
<div class="flex items-center text-sm text-gray-600 dark:text-gray-400">
|
||||||
<i class="fas fa-shield-alt mr-2 text-primary-500"></i>
|
<div class="mr-3 text-blue-500 flex-shrink-0">
|
||||||
|
<i class="fas fa-shield-alt"></i>
|
||||||
|
</div>
|
||||||
<span>Password must be at least 8 characters with letters, numbers, and special characters</span>
|
<span>Password must be at least 8 characters with letters, numbers, and special characters</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -369,10 +404,15 @@ templ Profile(ctx context.Context, user db.User) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Security Notice -->
|
<!-- Security Notice -->
|
||||||
<div class="mt-8 text-center">
|
<div class="mt-8">
|
||||||
<div class="inline-flex items-center text-sm text-secondary-500 dark:text-secondary-400">
|
<div class="p-5 text-sm text-blue-800 rounded-xl bg-blue-50 border border-blue-200 dark:bg-blue-900/50 dark:text-blue-400 dark:border-blue-800 flex items-start shadow-sm">
|
||||||
<i class="fas fa-shield-alt mr-2 text-primary-500"></i>
|
<div class="mr-3 text-blue-500 dark:text-blue-400 flex-shrink-0 mt-0.5">
|
||||||
<span>All profile changes are securely logged for your protection</span>
|
<i class="fas fa-shield-alt"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 class="text-sm font-medium mb-1">Security Information</h4>
|
||||||
|
<p>All profile changes are securely logged for your protection. For security reasons, you'll be required to enter your password for sensitive changes.</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,63 +1,66 @@
|
|||||||
package common
|
package common
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"github.com/starfleetcptn/gomft/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
templ NameField() {
|
templ NameField() {
|
||||||
<div class="space-y-6">
|
<div class="mb-6">
|
||||||
<div class="sm:col-span-4">
|
<label for="name" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Configuration Name</label>
|
||||||
<label for="name" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Name</label>
|
<div class="relative">
|
||||||
<div class="relative">
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<i class="fas fa-tag text-gray-400 dark:text-gray-500"></i>
|
||||||
<i class="fas fa-tag text-secondary-400 dark:text-secondary-600"></i>
|
|
||||||
</div>
|
|
||||||
<input type="text" name="name" id="name" x-model="name" required
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
placeholder="Transfer configuration name" />
|
|
||||||
</div>
|
</div>
|
||||||
|
<input type="text" name="name" id="name" x-model="name" required
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
placeholder="Transfer configuration name" />
|
||||||
</div>
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Choose a descriptive name to identify this configuration.</p>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
templ FilePatternFields() {
|
templ FilePatternFields() {
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<div class="sm:col-span-4">
|
<div class="mb-6">
|
||||||
<label for="file_pattern" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">File
|
<label for="file_pattern" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">File Pattern</label>
|
||||||
Pattern</label>
|
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
<i class="fas fa-filter text-secondary-400 dark:text-secondary-600"></i>
|
<i class="fas fa-filter text-gray-400 dark:text-gray-500"></i>
|
||||||
</div>
|
</div>
|
||||||
<input type="text" name="file_pattern" id="file_pattern" x-model="filePattern"
|
<input type="text" name="file_pattern" id="file_pattern" x-model="filePattern"
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
placeholder="*.txt, *.csv" />
|
placeholder="*.txt, *.csv" />
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
Glob pattern for files to transfer. Leave empty to transfer all files.
|
Glob pattern for files to transfer. Leave empty to transfer all files.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sm:col-span-4">
|
<div class="mb-6">
|
||||||
<label for="output_pattern"
|
<label for="output_pattern" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Output Pattern</label>
|
||||||
class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Output Pattern</label>
|
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
<i class="fas fa-file-export text-secondary-400 dark:text-secondary-600"></i>
|
<i class="fas fa-file-export text-gray-400 dark:text-gray-500"></i>
|
||||||
</div>
|
</div>
|
||||||
<input type="text" name="output_pattern" id="output_pattern" x-model="outputPattern"
|
<input type="text" name="output_pattern" id="output_pattern" x-model="outputPattern"
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
placeholder="${filename}" />
|
placeholder="${filename}" />
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
Pattern for output filename. Use variables like ${`filename`}, ${`timestamp`}, ${`date`}
|
Pattern for output filename. Use variables like ${`filename`}, ${`timestamp`}, ${`date`}
|
||||||
</p>
|
</p>
|
||||||
<div>
|
|
||||||
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
<div class="mt-4 p-4 bg-gray-50 rounded-lg border border-gray-200 dark:bg-gray-800 dark:border-gray-700">
|
||||||
Pattern for filenames. Available variables:<br />
|
<h4 class="text-sm font-medium text-gray-900 dark:text-white mb-2">Available variables:</h4>
|
||||||
${`filename`} - Original filename without extension (e.g., "report")<br />
|
<p class="text-sm text-gray-500 dark:text-gray-400 space-y-1">
|
||||||
${`ext`} - Original file extension (e.g., "csv")<br />
|
<span class="block">• ${`filename`} - Original filename without extension (e.g., "report")</span>
|
||||||
${`date:format`} - Current date using Go's time format:<br />
|
<span class="block">• ${`ext`} - Original file extension (e.g., "csv")</span>
|
||||||
• 2006-01-02 → YYYY-MM-DD<br />
|
<span class="block">• ${`date:format`} - Current date using Go's time format:</span>
|
||||||
• 20060102 → YYYYMMDD<br />
|
<span class="block pl-4">- 2006-01-02 → YYYY-MM-DD</span>
|
||||||
• 2006-01-02 15:04:05 → YYYY-MM-DD_HH:MM:SS<br />
|
<span class="block pl-4">- 20060102 → YYYYMMDD</span>
|
||||||
Example: ${`filename`}_${`date:2006-01-02`}_${`ext`} → "report_2023-03-01.csv"
|
<span class="block pl-4">- 2006-01-02 15:04:05 → YYYY-MM-DD_HH:MM:SS</span>
|
||||||
|
<span class="block italic mt-2">Example: ${`filename`}_${`date:2006-01-02`}_${`ext`} → "report_2023-03-01.csv"</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -65,163 +68,517 @@ templ FilePatternFields() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
templ ArchiveOptions() {
|
templ ArchiveOptions() {
|
||||||
<div class="border border-secondary-200 dark:border-secondary-700 rounded-lg p-6 bg-secondary-50 dark:bg-secondary-800/50 shadow-sm">
|
<div class="p-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:bg-gray-800 dark:border-gray-700">
|
||||||
<h3 class="text-lg font-medium text-secondary-900 dark:text-secondary-100 mb-4">Archive & Delete Options</h3>
|
<div class="space-y-6">
|
||||||
|
<div class="flex items-center">
|
||||||
<div class="mb-4">
|
<label class="relative inline-flex items-center cursor-pointer">
|
||||||
<label for="archive_enabled" class="flex items-center cursor-pointer">
|
<input type="checkbox" id="archive_enabled" name="archive_enabled" x-model="archiveEnabled"
|
||||||
<div class="relative">
|
class="sr-only peer" :value="archiveEnabled ? 'true' : 'false'">
|
||||||
<input id="archive_enabled" name="archive_enabled" type="checkbox" x-model="archiveEnabled"
|
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
|
||||||
class="sr-only"
|
<span class="ms-3 text-sm font-medium text-gray-900 dark:text-white">Enable archiving</span>
|
||||||
:value="archiveEnabled ? 'true' : 'false'"
|
|
||||||
/>
|
|
||||||
<div class="block bg-secondary-200 dark:bg-secondary-700 w-14 h-8 rounded-full"></div>
|
|
||||||
<div class="dot absolute left-1 top-1 bg-white dark:bg-secondary-100 w-6 h-6 rounded-full transition"
|
|
||||||
:class="archiveEnabled ? 'transform translate-x-6 bg-primary-500' : ''"></div>
|
|
||||||
</div>
|
|
||||||
<div class="ml-3 text-secondary-700 dark:text-secondary-300 font-medium">
|
|
||||||
Enable archiving
|
|
||||||
</div>
|
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-4" x-show="archiveEnabled">
|
<div x-show="archiveEnabled" class="mt-4">
|
||||||
<label for="archive_path" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Archive Path</label>
|
<label for="archive_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Archive Path</label>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<span class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
<i class="fas fa-folder text-secondary-400 dark:text-secondary-600"></i>
|
<i class="fas fa-folder text-gray-400 dark:text-gray-500"></i>
|
||||||
</span>
|
</div>
|
||||||
<input id="archive_path" name="archive_path" type="text"
|
<input id="archive_path" name="archive_path" type="text" x-model="archivePath"
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
placeholder="Path to archive files"
|
placeholder="Path to archive files" />
|
||||||
x-model="archivePath"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
Files will be moved here after successful transfer
|
Files will be moved here after successful transfer
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-4">
|
<div class="flex items-center">
|
||||||
<label for="delete_after_transfer" class="flex items-center cursor-pointer">
|
<label class="relative inline-flex items-center cursor-pointer">
|
||||||
<div class="relative">
|
<input type="checkbox" id="delete_after_transfer" name="delete_after_transfer" x-model="deleteAfterTransfer"
|
||||||
<input id="delete_after_transfer" name="delete_after_transfer" type="checkbox" x-model="deleteAfterTransfer"
|
class="sr-only peer" :value="deleteAfterTransfer ? 'true' : 'false'">
|
||||||
class="sr-only"
|
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-red-300 dark:peer-focus:ring-red-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-red-600"></div>
|
||||||
:value="deleteAfterTransfer ? 'true' : 'false'"
|
<span class="ms-3 text-sm font-medium text-gray-900 dark:text-white">Delete source files after transfer</span>
|
||||||
/>
|
|
||||||
<div class="block bg-secondary-200 dark:bg-secondary-700 w-14 h-8 rounded-full"></div>
|
|
||||||
<div class="dot absolute left-1 top-1 bg-white dark:bg-secondary-100 w-6 h-6 rounded-full transition"
|
|
||||||
:class="deleteAfterTransfer ? 'transform translate-x-6 bg-red-500' : ''"></div>
|
|
||||||
</div>
|
|
||||||
<div class="ml-3 text-secondary-700 dark:text-secondary-300 font-medium">
|
|
||||||
Delete source files after transfer
|
|
||||||
</div>
|
|
||||||
</label>
|
</label>
|
||||||
<p class="mt-1 ml-14 text-xs text-red-500 dark:text-red-400" x-show="deleteAfterTransfer">
|
|
||||||
<i class="fas fa-exclamation-triangle mr-1"></i> Warning: This will permanently delete the original files
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div x-show="deleteAfterTransfer" class="mt-2">
|
||||||
<div class="mb-4">
|
<div class="p-4 mb-4 text-sm text-red-800 rounded-lg bg-red-50 dark:bg-red-900/30 dark:text-red-300" role="alert">
|
||||||
<label for="skip_processed_files" class="flex items-center cursor-pointer">
|
<div class="flex items-center">
|
||||||
<div class="relative">
|
<i class="fas fa-exclamation-triangle mr-2"></i>
|
||||||
<input id="skip_processed_files" name="skip_processed_files" type="checkbox" x-model="skipProcessedFiles"
|
<span>Warning: This will permanently delete the original files</span>
|
||||||
class="sr-only"
|
|
||||||
:value="skipProcessedFiles ? 'true' : 'false'"
|
|
||||||
/>
|
|
||||||
<div class="block bg-secondary-200 dark:bg-secondary-700 w-14 h-8 rounded-full"></div>
|
|
||||||
<div class="dot absolute left-1 top-1 bg-white dark:bg-secondary-100 w-6 h-6 rounded-full transition"
|
|
||||||
:class="skipProcessedFiles ? 'transform translate-x-6 bg-primary-500' : ''"></div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="ml-3 text-secondary-700 dark:text-secondary-300 font-medium">
|
|
||||||
Skip files that have already been processed
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
<p class="mt-1 ml-14 text-xs text-secondary-500 dark:text-secondary-400">
|
|
||||||
Files with the same hash that have been successfully processed before will be skipped
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-4">
|
|
||||||
<label for="max_concurrent_transfers" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Concurrent Transfers</label>
|
|
||||||
<div class="flex items-center">
|
|
||||||
<input type="range" id="max_concurrent_transfers" name="max_concurrent_transfers"
|
|
||||||
class="form-range w-2/3 h-2 bg-secondary-200 dark:bg-secondary-700 rounded-lg appearance-none cursor-pointer"
|
|
||||||
min="1" max="32" step="1"
|
|
||||||
x-model="maxConcurrentTransfers"
|
|
||||||
/>
|
|
||||||
<span class="ml-4 text-secondary-700 dark:text-secondary-300" x-text="maxConcurrentTransfers"></span>
|
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center">
|
||||||
|
<label class="relative inline-flex items-center cursor-pointer">
|
||||||
|
<input type="checkbox" id="skip_processed_files" name="skip_processed_files" x-model="skipProcessedFiles"
|
||||||
|
class="sr-only peer" :value="skipProcessedFiles ? 'true' : 'false'">
|
||||||
|
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
|
||||||
|
<span class="ms-3 text-sm font-medium text-gray-900 dark:text-white">Skip files that have already been processed</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<p class="ms-14 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Files with the same hash that have been successfully processed before will be skipped
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="mt-6">
|
||||||
|
<label for="max_concurrent_transfers" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
|
||||||
|
Concurrent Transfers: <span x-text="maxConcurrentTransfers"></span>
|
||||||
|
</label>
|
||||||
|
<input type="range" id="max_concurrent_transfers" name="max_concurrent_transfers"
|
||||||
|
class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer dark:bg-gray-700"
|
||||||
|
min="1" max="32" step="1" x-model="maxConcurrentTransfers">
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
Number of files to transfer simultaneously (higher values may improve performance but increase resource usage)
|
Number of files to transfer simultaneously (higher values may improve performance but increase resource usage)
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
templ RcloneFlags() {
|
templ RcloneFlags(currentCommandID uint) {
|
||||||
<div>
|
<div class="mb-6">
|
||||||
<label for="rclone_flags" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300">Rclone Flags</label>
|
<label for="command_id" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Rclone Command</label>
|
||||||
<div class="mt-1 relative rounded-md shadow-sm">
|
<div class="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
<i class="fas fa-flag text-secondary-400 dark:text-secondary-600"></i>
|
<i class="fas fa-terminal text-gray-400 dark:text-gray-500"></i>
|
||||||
|
</div>
|
||||||
|
@RcloneCommandOptions(currentCommandID) // Pass it down
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Select the rclone command to use for this configuration.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Flag container is now rendered directly in ConfigForm -->
|
||||||
|
|
||||||
|
<label for="rclone_flags" class="block mb-2 mt-6 text-sm font-medium text-gray-900 dark:text-white">Additional Rclone Flags</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
|
<i class="fas fa-flag text-gray-400 dark:text-gray-500"></i>
|
||||||
</div>
|
</div>
|
||||||
<input type="text" name="rclone_flags" id="rclone_flags" x-model="rcloneFlags"
|
<input type="text" name="rclone_flags" id="rclone_flags" x-model="rcloneFlags"
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
@change="updateCommandRequirements()"
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
placeholder="--transfers 4 --checkers 8" />
|
placeholder="--transfers 4 --checkers 8" />
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
Optional: Additional rclone flags for fine-tuning the transfer.
|
Optional: Additional rclone flags for fine-tuning the transfer.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
templ SourceSelection() {
|
// New placeholder templ for rclone command options
|
||||||
<div class="sm:col-span-3">
|
templ RcloneCommandOptions(currentCommandID uint) { // Accept currentCommandID
|
||||||
<label for="source_type" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Source
|
<div
|
||||||
Type</label>
|
hx-get="/api/rclone/commands"
|
||||||
<div class="relative">
|
hx-trigger="load"
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
hx-target="this"
|
||||||
<i class="fas fa-server text-secondary-400 dark:text-secondary-600"></i>
|
hx-swap="outerHTML"
|
||||||
</div>
|
hx-vals={ fmt.Sprintf(`{"commandId": %d}`, currentCommandID) }>
|
||||||
<select id="source_type" name="source_type" x-model="sourceType"
|
<!-- Loading placeholder -->
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500">
|
<option value="">Loading commands...</option>
|
||||||
<option value="local">Local</option>
|
|
||||||
<option value="sftp">SFTP</option>
|
|
||||||
<option value="ftp">FTP</option>
|
|
||||||
<option value="s3">S3</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</option>
|
|
||||||
<option value="gphotos">Google Photos</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ SourceSelection(providers []db.StorageProvider) {
|
||||||
|
<div class="mb-6">
|
||||||
|
<label for="source_type" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Source Type</label>
|
||||||
|
|
||||||
|
<!-- Provider selection -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<div class="flex items-center mb-4">
|
||||||
|
<input id="use_source_provider" name="use_source_provider" type="checkbox" x-model="useSourceProvider" value="true" class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600">
|
||||||
|
<label for="use_source_provider" class="ml-2 text-sm font-medium text-gray-900 dark:text-white">Use existing storage provider</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template x-if="useSourceProvider">
|
||||||
|
<div class="mt-2 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label for="source_provider_id" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Select provider</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
|
<i class="fas fa-server text-gray-400 dark:text-gray-500"></i>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
id="source_provider_id"
|
||||||
|
name="source_provider_id"
|
||||||
|
x-model="sourceProviderId"
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
hx-get="/api/storage-providers/options"
|
||||||
|
hx-trigger="load delay:500ms"
|
||||||
|
hx-target="this"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
<option value="">Select a provider...</option>
|
||||||
|
for _, provider := range providers {
|
||||||
|
<option value={ fmt.Sprintf("%d", provider.ID) }>{ provider.Name } ({ string(provider.Type) })</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<a href="/storage-providers/new" target="_blank" class="text-blue-600 hover:underline flex items-center text-sm">
|
||||||
|
<i class="fas fa-plus mr-1"></i> Add new storage provider
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<button type="button"
|
||||||
|
class="text-sm text-white bg-blue-600 hover:bg-blue-700 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg px-3 py-1.5 dark:bg-blue-500 dark:hover:bg-blue-600 dark:focus:ring-blue-800"
|
||||||
|
hx-get="/api/storage-providers/options"
|
||||||
|
hx-target="#source_provider_id"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
<i class="fas fa-sync-alt mr-1"></i> Refresh List
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Manual configuration -->
|
||||||
|
<template x-if="!useSourceProvider">
|
||||||
|
<div>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
|
<i class="fas fa-server text-gray-400 dark:text-gray-500"></i>
|
||||||
|
</div>
|
||||||
|
<select id="source_type" name="source_type" x-model="sourceType"
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500">
|
||||||
|
<option value="local">Local</option>
|
||||||
|
<option value="sftp">SFTP</option>
|
||||||
|
<option value="ftp">FTP</option>
|
||||||
|
<option value="s3">S3</option>
|
||||||
|
<option value="b2">Backblaze B2</option>
|
||||||
|
<option value="wasabi">Wasabi</option>
|
||||||
|
<option value="minio">MinIO</option>
|
||||||
|
<option value="smb">SMB</option>
|
||||||
|
<option value="nextcloud">NextCloud</option>
|
||||||
|
<option value="webdav">WebDAV</option>
|
||||||
|
<option value="drive">Google Drive (BETA)</option>
|
||||||
|
<option value="gphotos">Google Photos (BETA)</option>
|
||||||
|
<option value="hetzner">Hetzner Storage Box</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
templ DestinationSelection() {
|
templ DestinationSelection(providers []db.StorageProvider) {
|
||||||
<div class="sm:col-span-3">
|
<div class="mb-6">
|
||||||
<label for="destination_type"
|
<label for="destination_type" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Destination Type</label>
|
||||||
class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Destination Type</label>
|
|
||||||
<div class="relative">
|
<!-- Provider selection -->
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="mb-4">
|
||||||
<i class="fas fa-server text-secondary-400 dark:text-secondary-600"></i>
|
<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>
|
</div>
|
||||||
<select id="destination_type" name="destination_type" x-model="destinationType"
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500">
|
<template x-if="useDestinationProvider">
|
||||||
<option value="local">Local</option>
|
<div class="mt-2 space-y-4">
|
||||||
<option value="sftp">SFTP</option>
|
<div>
|
||||||
<option value="ftp">FTP</option>
|
<label for="destination_provider_id" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Select provider</label>
|
||||||
<option value="s3">S3</option>
|
<div class="relative">
|
||||||
<option value="minio">MinIO</option>
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
<option value="smb">SMB</option>
|
<i class="fas fa-server text-gray-400 dark:text-gray-500"></i>
|
||||||
<option value="nextcloud">NextCloud</option>
|
</div>
|
||||||
<option value="webdav">WebDAV</option>
|
<select
|
||||||
<option value="gdrive">Google Drive</option>
|
id="destination_provider_id"
|
||||||
<option value="gphotos">Google Photos</option>
|
name="destination_provider_id"
|
||||||
</select>
|
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>
|
</div>
|
||||||
|
|
||||||
|
<!-- Manual configuration -->
|
||||||
|
<template x-if="!useDestinationProvider">
|
||||||
|
<div>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
|
<i class="fas fa-server text-gray-400 dark:text-gray-500"></i>
|
||||||
|
</div>
|
||||||
|
<select id="destination_type" name="destination_type" x-model="destinationType"
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500">
|
||||||
|
<option value="local">Local</option>
|
||||||
|
<option value="sftp">SFTP</option>
|
||||||
|
<option value="ftp">FTP</option>
|
||||||
|
<option value="s3">S3</option>
|
||||||
|
<option value="b2">Backblaze B2</option>
|
||||||
|
<option value="wasabi">Wasabi</option>
|
||||||
|
<option value="minio">MinIO</option>
|
||||||
|
<option value="smb">SMB</option>
|
||||||
|
<option value="nextcloud">NextCloud</option>
|
||||||
|
<option value="webdav">WebDAV</option>
|
||||||
|
<option value="drive">Google Drive (BETA)</option>
|
||||||
|
<option value="gphotos">Google Photos (BETA)</option>
|
||||||
|
<option value="hetzner">Hetzner Storage Box</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RcloneCommandOptionsContent renders the command options organized by category
|
||||||
|
templ RcloneCommandOptionsContent(categoryMap map[string][]db.RcloneCommand, categories []string, currentCommandID uint, commandFlagsJSON string, commandFlagValuesJSON string) { // Add flag JSON strings
|
||||||
|
<select id="command_id" name="command_id" x-model="commandId"
|
||||||
|
hx-get="/api/rclone/command-flags"
|
||||||
|
hx-target="#command-flags-container"
|
||||||
|
hx-trigger="change"
|
||||||
|
hx-include="[name='command_id']"
|
||||||
|
hx-vals={ fmt.Sprintf(`{"commandFlags": %s, "commandFlagValues": %s}`, commandFlagsJSON, commandFlagValuesJSON) }
|
||||||
|
@change="updateCommandRequirements()"
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500">
|
||||||
|
<option value="">Select command...</option>
|
||||||
|
for _, category := range categories {
|
||||||
|
if commands, ok := categoryMap[category]; ok && len(commands) > 0 {
|
||||||
|
<optgroup label={ category }>
|
||||||
|
for _, cmd := range commands {
|
||||||
|
if cmd.ID == currentCommandID {
|
||||||
|
<option value={ fmt.Sprintf("%d", cmd.ID) } selected>{ cmd.Name } - { cmd.Description }</option>
|
||||||
|
} else {
|
||||||
|
<option value={ fmt.Sprintf("%d", cmd.ID) }>{ cmd.Name } - { cmd.Description }</option>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</optgroup>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
}
|
||||||
|
|
||||||
|
// RcloneCommandFlagsContent renders the command flags for a selected command
|
||||||
|
templ RcloneCommandFlagsContent(command *db.RcloneCommand, selectedFlagsMap map[uint]bool, selectedFlagValues map[uint]string) {
|
||||||
|
if command == nil {
|
||||||
|
<div class="p-4 text-red-500">Command not found</div>
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<h4 class="font-medium text-gray-900 dark:text-white mb-2">{ command.Name }</h4>
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">{ command.Description }</p>
|
||||||
|
|
||||||
|
if len(command.Flags) > 0 {
|
||||||
|
<div class="mb-3">
|
||||||
|
<h5 class="font-medium text-gray-900 dark:text-white mb-2">Command Flags:</h5>
|
||||||
|
<div class="space-y-3">
|
||||||
|
for _, flag := range command.Flags {
|
||||||
|
if flag.DataType == "bool" {
|
||||||
|
<!-- Boolean flag (simple checkbox) -->
|
||||||
|
<div class="flex items-start mb-4">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id={ fmt.Sprintf("flag_%d", flag.ID) }
|
||||||
|
name="command_flags"
|
||||||
|
value={ fmt.Sprintf("%d", flag.ID) }
|
||||||
|
class="mt-0.5 rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:focus:ring-blue-600"
|
||||||
|
if selectedFlagsMap[flag.ID] {
|
||||||
|
checked
|
||||||
|
} />
|
||||||
|
<div class="ml-3">
|
||||||
|
<label for={ fmt.Sprintf("flag_%d", flag.ID) } class="font-medium text-gray-900 dark:text-white">
|
||||||
|
{ flag.Name } - { flag.Description }
|
||||||
|
</label>
|
||||||
|
if flag.DefaultValue != "" {
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">Default: { flag.DefaultValue }</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
} else {
|
||||||
|
<!-- Non-boolean flag (requires value) -->
|
||||||
|
<div class="flex items-start mb-4 w-full">
|
||||||
|
<div class="w-full">
|
||||||
|
<div class="flex items-center mb-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id={ fmt.Sprintf("flag_enable_%d", flag.ID) }
|
||||||
|
name={ fmt.Sprintf("flag_enable_%d", flag.ID) }
|
||||||
|
class="mr-2 rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:focus:ring-blue-600"
|
||||||
|
data-input-id={ fmt.Sprintf("flag_value_%d", flag.ID) }
|
||||||
|
onclick="toggleFlagValue(this)"
|
||||||
|
if selectedFlagsMap[flag.ID] {
|
||||||
|
checked
|
||||||
|
} />
|
||||||
|
<label for={ fmt.Sprintf("flag_enable_%d", flag.ID) } class="font-medium text-gray-900 dark:text-white">
|
||||||
|
{ flag.Name } - { flag.Description }
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="w-full mt-2">
|
||||||
|
@renderFlagInput(flag, selectedFlagValues[flag.ID], selectedFlagsMap[flag.ID]) // Pass value and enabled status
|
||||||
|
if flag.DefaultValue != "" {
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Default: { flag.DefaultValue }</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- JavaScript to handle enabling/disabling flag value inputs -->
|
||||||
|
<script>
|
||||||
|
function toggleFlagValue(checkbox) {
|
||||||
|
const inputId = checkbox.getAttribute('data-input-id');
|
||||||
|
const input = document.getElementById(inputId);
|
||||||
|
|
||||||
|
if (input) {
|
||||||
|
input.disabled = !checkbox.checked;
|
||||||
|
if (checkbox.checked) {
|
||||||
|
// Focus on the input when enabled
|
||||||
|
input.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also enable/disable the hidden input for flag ID
|
||||||
|
const hiddenId = inputId.replace('flag_value_', 'flag_hidden_');
|
||||||
|
const hiddenInput = document.getElementById(hiddenId);
|
||||||
|
if (hiddenInput) {
|
||||||
|
hiddenInput.disabled = !checkbox.checked;
|
||||||
|
console.log('Toggle hidden input:', hiddenId, checkbox.checked);
|
||||||
|
}
|
||||||
|
|
||||||
|
// For debugging
|
||||||
|
console.log('Toggle flag value:', inputId, checkbox.checked);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize all flag inputs on page load
|
||||||
|
// Initialization is now handled by server-side rendering
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="mt-4 p-3 bg-blue-50 text-blue-800 rounded-lg border border-blue-100 dark:bg-blue-900/20 dark:text-blue-300 dark:border-blue-900 text-sm">
|
||||||
|
<div class="flex items-center mb-1">
|
||||||
|
<i class="fas fa-info-circle mr-2"></i>
|
||||||
|
<span class="font-medium">Usage Examples</span>
|
||||||
|
</div>
|
||||||
|
if command.Name == "ls" ||
|
||||||
|
command.Name == "lsd" ||
|
||||||
|
command.Name == "lsl" ||
|
||||||
|
command.Name == "lsjson" ||
|
||||||
|
command.Name == "lsf" ||
|
||||||
|
command.Name == "delete" ||
|
||||||
|
command.Name == "purge" ||
|
||||||
|
command.Name == "rmdirs" ||
|
||||||
|
command.Name == "mkdir" ||
|
||||||
|
command.Name == "touch" ||
|
||||||
|
command.Name == "md5sum" ||
|
||||||
|
command.Name == "sha1sum" ||
|
||||||
|
command.Name == "sha256sum" ||
|
||||||
|
command.Name == "size" ||
|
||||||
|
command.Name == "stat" ||
|
||||||
|
command.Name == "version" {
|
||||||
|
<code class="block mt-1 font-mono text-xs overflow-x-auto">rclone { command.Name } [flags] source:path</code>
|
||||||
|
} else {
|
||||||
|
<code class="block mt-1 font-mono text-xs overflow-x-auto">rclone { command.Name } [flags] source:path dest:path</code>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
} else {
|
||||||
|
<div class="p-3 bg-gray-50 text-gray-600 rounded-lg border border-gray-200 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-700 text-sm">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<i class="fas fa-info-circle mr-2"></i>
|
||||||
|
<span>This command doesn't have any specific flags.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to render appropriate input based on flag data type
|
||||||
|
templ renderFlagInput(flag db.RcloneCommandFlag, value string, enabled bool) {
|
||||||
|
if flag.DataType == "int" {
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id={ fmt.Sprintf("flag_value_%d", flag.ID) }
|
||||||
|
name={ fmt.Sprintf("flag_value_%d", flag.ID) }
|
||||||
|
class="w-full bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 p-2 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
placeholder={ flag.DefaultValue }
|
||||||
|
value={ value }
|
||||||
|
if !enabled {
|
||||||
|
disabled
|
||||||
|
} />
|
||||||
|
<!-- Hidden input to include this flag ID when checked -->
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="command_flags"
|
||||||
|
value={ fmt.Sprintf("%d", flag.ID) }
|
||||||
|
if !enabled {
|
||||||
|
disabled
|
||||||
|
}
|
||||||
|
id={ fmt.Sprintf("flag_hidden_%d", flag.ID) }
|
||||||
|
data-enable-with={ fmt.Sprintf("flag_enable_%d", flag.ID) }
|
||||||
|
/>
|
||||||
|
} else if flag.DataType == "float" {
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id={ fmt.Sprintf("flag_value_%d", flag.ID) }
|
||||||
|
name={ fmt.Sprintf("flag_value_%d", flag.ID) }
|
||||||
|
step="0.01"
|
||||||
|
class="w-full bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 p-2 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
placeholder={ flag.DefaultValue }
|
||||||
|
value={ value }
|
||||||
|
if !enabled {
|
||||||
|
disabled
|
||||||
|
} />
|
||||||
|
<!-- Hidden input to include this flag ID when checked -->
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="command_flags"
|
||||||
|
value={ fmt.Sprintf("%d", flag.ID) }
|
||||||
|
if !enabled {
|
||||||
|
disabled
|
||||||
|
}
|
||||||
|
id={ fmt.Sprintf("flag_hidden_%d", flag.ID) }
|
||||||
|
data-enable-with={ fmt.Sprintf("flag_enable_%d", flag.ID) }
|
||||||
|
/>
|
||||||
|
} else {
|
||||||
|
<!-- Default to text input for string and other types -->
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id={ fmt.Sprintf("flag_value_%d", flag.ID) }
|
||||||
|
name={ fmt.Sprintf("flag_value_%d", flag.ID) }
|
||||||
|
class="w-full bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 p-2 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
placeholder={ flag.DefaultValue }
|
||||||
|
value={ value }
|
||||||
|
if !enabled {
|
||||||
|
disabled
|
||||||
|
} />
|
||||||
|
<!-- Hidden input to include this flag ID when checked -->
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="command_flags"
|
||||||
|
value={ fmt.Sprintf("%d", flag.ID) }
|
||||||
|
if !enabled {
|
||||||
|
disabled
|
||||||
|
}
|
||||||
|
id={ fmt.Sprintf("flag_hidden_%d", flag.ID) }
|
||||||
|
data-enable-with={ fmt.Sprintf("flag_enable_%d", flag.ID) }
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package destination
|
||||||
|
|
||||||
|
templ B2DestinationForm() {
|
||||||
|
<div class="space-y-6 mt-4">
|
||||||
|
<div class="p-4 mb-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900/30 dark:text-blue-300" role="alert">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<i class="fas fa-info-circle mr-2"></i>
|
||||||
|
<span>Configure your Backblaze B2 details below. You'll need your account ID, application key, and bucket information.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-6">
|
||||||
|
<label for="dest_bucket" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Bucket Name</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
|
<i class="fas fa-archive text-gray-400 dark:text-gray-500"></i>
|
||||||
|
</div>
|
||||||
|
<input type="text" id="dest_bucket" name="dest_bucket" x-model="destBucket" x-bind:required="requiresDestination"
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
placeholder="my-b2-bucket" />
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Name of your Backblaze B2 bucket (case-sensitive)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-6">
|
||||||
|
<label for="dest_access_key" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Account ID</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
|
<i class="fas fa-id-card text-gray-400 dark:text-gray-500"></i>
|
||||||
|
</div>
|
||||||
|
<input type="text" id="dest_access_key" name="dest_access_key" x-model="destAccessKey" x-bind:required="requiresDestination"
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
placeholder="0123456789abcdef0123456789" />
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Your Backblaze B2 Account ID
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-6">
|
||||||
|
<label for="dest_secret_key" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Application Key</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
|
<i class="fas fa-lock text-gray-400 dark:text-gray-500"></i>
|
||||||
|
</div>
|
||||||
|
<input type="password" id="dest_secret_key" name="dest_secret_key" x-model="destSecretKey" x-bind:required="requiresDestination"
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
placeholder="K0123456789abcdef0123456789abcdef0123456789" />
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Your Backblaze B2 Application Key
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-6">
|
||||||
|
<label for="dest_endpoint" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Endpoint (Optional)</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
|
<i class="fas fa-server text-gray-400 dark:text-gray-500"></i>
|
||||||
|
</div>
|
||||||
|
<input type="text" id="dest_endpoint" name="dest_endpoint" x-model="destEndpoint"
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
placeholder="s3.us-west-000.backblazeb2.com" />
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Custom endpoint URL (only needed for non-standard regions or private endpoints)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-6">
|
||||||
|
<label for="destination_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Path in Bucket</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
|
<i class="fas fa-folder-open text-gray-400 dark:text-gray-500"></i>
|
||||||
|
</div>
|
||||||
|
<input type="text" id="destination_path" name="destination_path" x-model="destinationPath"
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
placeholder="path/to/files/" />
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Path prefix within the bucket (e.g., "backups/"). Leave empty to access the entire bucket.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-4 mb-4 text-sm text-yellow-800 rounded-lg bg-yellow-50 dark:bg-yellow-900/30 dark:text-yellow-300" role="alert">
|
||||||
|
<div class="flex">
|
||||||
|
<i class="fas fa-shield-alt mr-2 flex-shrink-0"></i>
|
||||||
|
<div>
|
||||||
|
<h3 class="font-medium">Security Note</h3>
|
||||||
|
<p class="mt-1">It's recommended to create an application key with restricted permissions for this configuration. The application key should only have access to the specific B2 bucket and operations needed.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@@ -1,109 +1,81 @@
|
|||||||
package destination
|
package destination
|
||||||
|
|
||||||
templ FTPDestinationForm() {
|
templ FTPDestinationForm() {
|
||||||
<div class="sm:col-span-6 space-y-6">
|
<div class="space-y-6 mt-4">
|
||||||
<div class="sm:col-span-4">
|
<div>
|
||||||
<label for="dest_host" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Host</label>
|
<label for="dest_host" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">FTP Host</label>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
<i class="fas fa-server text-secondary-400 dark:text-secondary-600"></i>
|
<i class="fas fa-server text-gray-400 dark:text-gray-500"></i>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input type="text" id="dest_host" name="dest_host" x-model="destHost" x-bind:required="requiresDestination"
|
||||||
type="text"
|
class="bg-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"
|
||||||
name="dest_host"
|
placeholder="ftp.example.com" />
|
||||||
id="dest_host"
|
|
||||||
x-model="destHost"
|
|
||||||
required
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
placeholder="ftp.example.com"/>
|
|
||||||
</div>
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Hostname or IP address of the FTP server</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sm:col-span-4">
|
<div>
|
||||||
<label for="dest_port" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Port</label>
|
<label for="dest_port" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Port</label>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
<i class="fas fa-plug text-secondary-400 dark:text-secondary-600"></i>
|
<i class="fas fa-network-wired text-gray-400 dark:text-gray-500"></i>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input type="number" id="dest_port" name="dest_port" x-model="destPort"
|
||||||
type="number"
|
class="bg-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"
|
||||||
name="dest_port"
|
placeholder="21" min="1" max="65535"
|
||||||
id="dest_port"
|
x-init="if (!destPort || destPort === 0) destPort = 21"
|
||||||
x-model="destPort"
|
x-effect="if (destinationType === 'ftp' && (destPort === 0 || destPort === 22)) destPort = 21" />
|
||||||
required
|
|
||||||
min="1"
|
|
||||||
max="65535"
|
|
||||||
value="21"
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
placeholder="21"/>
|
|
||||||
</div>
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">FTP port number (default: 21)</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sm:col-span-4">
|
<div>
|
||||||
<label for="destination_path" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Remote Path</label>
|
<label for="dest_user" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Username</label>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
<i class="fas fa-folder text-secondary-400 dark:text-secondary-600"></i>
|
<i class="fas fa-user text-gray-400 dark:text-gray-500"></i>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input type="text" id="dest_user" name="dest_user" x-model="destUser" x-bind:required="requiresDestination"
|
||||||
type="text"
|
class="bg-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"
|
||||||
name="destination_path"
|
placeholder="username" />
|
||||||
id="destination_path"
|
|
||||||
x-model="destinationPath"
|
|
||||||
required
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
placeholder="/path/to/destination"/>
|
|
||||||
</div>
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">FTP username for authentication</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sm:col-span-4">
|
<div>
|
||||||
<label for="dest_user" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Username</label>
|
<label for="dest_password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Password</label>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
<i class="fas fa-user text-secondary-400 dark:text-secondary-600"></i>
|
<i class="fas fa-lock text-gray-400 dark:text-gray-500"></i>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input type="password" id="dest_password" name="dest_password" x-model="destPassword" x-bind:required="requiresDestination"
|
||||||
type="text"
|
class="bg-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"
|
||||||
name="dest_user"
|
placeholder="Enter password" />
|
||||||
id="dest_user"
|
|
||||||
x-model="destUser"
|
|
||||||
required
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"/>
|
|
||||||
</div>
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">FTP password for authentication</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sm:col-span-4">
|
<div class="flex items-center mb-6">
|
||||||
<label for="dest_password" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Password</label>
|
<label class="relative inline-flex items-center cursor-pointer">
|
||||||
<div class="relative">
|
<input type="checkbox" id="dest_passive_mode" name="dest_passive_mode" x-model="destPassiveMode"
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
class="sr-only peer" :value="destPassiveMode ? 'true' : 'false'">
|
||||||
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
|
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
|
||||||
</div>
|
<span class="ms-3 text-sm font-medium text-gray-900 dark:text-white">Use Passive Mode</span>
|
||||||
<input
|
</label>
|
||||||
type="password"
|
|
||||||
name="dest_password"
|
|
||||||
id="dest_password"
|
|
||||||
x-model="destPassword"
|
|
||||||
required
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
placeholder="Password"/>
|
|
||||||
</div>
|
|
||||||
<input type="hidden" name="dest_password" :value="destPassword"/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-start mb-4">
|
<div>
|
||||||
<div class="flex items-center h-5">
|
<label for="destination_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Remote Path</label>
|
||||||
<input
|
<div class="relative">
|
||||||
id="dest_passive_mode"
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
name="dest_passive_mode"
|
<i class="fas fa-folder-open text-gray-400 dark:text-gray-500"></i>
|
||||||
type="checkbox"
|
</div>
|
||||||
x-model="destPassiveMode"
|
<input type="text" id="destination_path" name="destination_path" x-model="destinationPath" x-bind:required="requiresDestination"
|
||||||
:value="destPassiveMode ? 'true' : 'false'"
|
class="bg-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"
|
||||||
class="focus:ring-primary-500 h-4 w-4 text-primary-600 border-secondary-300 dark:border-secondary-700 rounded">
|
placeholder="/remote/path/to/files" />
|
||||||
</div>
|
|
||||||
<div class="ml-3 text-sm">
|
|
||||||
<label for="dest_passive_mode" class="font-medium text-secondary-700 dark:text-secondary-300">Use Passive Mode</label>
|
|
||||||
<p class="text-secondary-500 dark:text-secondary-400">Enable passive mode for FTP connection</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Full path to the directory on the FTP server</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -1,175 +1,106 @@
|
|||||||
package destination
|
package destination
|
||||||
|
|
||||||
templ GoogleDriveDestinationForm() {
|
templ GoogleDriveDestinationForm() {
|
||||||
<div class="space-y-6" x-init="$watch('useBuiltinAuthDest', value => {
|
<div class="space-y-6 mt-4">
|
||||||
if(value) {
|
<div class="p-4 mb-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900/30 dark:text-blue-300" role="alert">
|
||||||
destClientId = '';
|
<div class="flex items-center">
|
||||||
destClientSecret = '';
|
<i class="fas fa-info-circle mr-2"></i>
|
||||||
}
|
<span>Google Drive requires authentication after creating this configuration. You'll be prompted to authenticate once the configuration is saved.</span>
|
||||||
})">
|
</div>
|
||||||
<div class="mb-6">
|
</div>
|
||||||
<label for="use_builtin_auth_dest" class="flex items-center cursor-pointer">
|
|
||||||
<div class="relative">
|
|
||||||
<input id="use_builtin_auth_dest" name="use_builtin_auth_dest" type="checkbox"
|
|
||||||
class="sr-only"
|
|
||||||
x-model="useBuiltinAuthDest"
|
|
||||||
:value="useBuiltinAuthDest ? 'true' : 'false'"
|
|
||||||
/>
|
|
||||||
<div class="block bg-secondary-200 dark:bg-secondary-700 w-14 h-8 rounded-full"></div>
|
|
||||||
<div class="dot absolute left-1 top-1 bg-white dark:bg-secondary-100 w-6 h-6 rounded-full transition"
|
|
||||||
:class="useBuiltinAuthDest ? 'transform translate-x-6 bg-primary-500' : ''"></div>
|
|
||||||
</div>
|
|
||||||
<div class="ml-3 text-secondary-700 dark:text-secondary-300 font-medium">
|
|
||||||
Use rclone's built-in Google authentication (recommended)
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
<p class="mt-1 ml-14 text-xs text-secondary-500 dark:text-secondary-400">
|
|
||||||
Simple one-click authentication using rclone's shared credentials
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div x-bind:class="{ 'opacity-50': useBuiltinAuthDest }">
|
<div>
|
||||||
<div>
|
<div class="flex items-center mb-6">
|
||||||
<label for="dest_client_id" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
|
<label class="relative inline-flex items-center cursor-pointer">
|
||||||
Google Client ID
|
<input type="checkbox" id="use_builtin_auth_dest" name="use_builtin_auth_dest" x-model="useBuiltinAuthDest"
|
||||||
<span x-show="useBuiltinAuthDest" class="text-secondary-400 dark:text-secondary-600 text-xs font-normal">(Using rclone default)</span>
|
class="sr-only peer" :value="useBuiltinAuthDest ? 'true' : 'false'">
|
||||||
</label>
|
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
|
||||||
<div class="relative">
|
<span class="ms-3 text-sm font-medium text-gray-900 dark:text-white">Use built-in authentication</span>
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
</label>
|
||||||
<i class="fas fa-id-card text-secondary-400 dark:text-secondary-600"></i>
|
</div>
|
||||||
</div>
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
<input type="text" id="dest_client_id" name="dest_client_id" x-model="destClientId"
|
Toggle to use your own Google API credentials instead of the built-in ones.
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
</p>
|
||||||
x-bind:disabled="useBuiltinAuthDest"
|
</div>
|
||||||
placeholder="Google Drive OAuth Client ID" />
|
|
||||||
</div>
|
|
||||||
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
|
||||||
Client ID from Google Cloud Console
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-4">
|
<div x-show="!useBuiltinAuthDest">
|
||||||
<label for="dest_client_secret" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
|
<div class="p-4 mb-4 text-sm text-yellow-800 rounded-lg bg-yellow-50 dark:bg-yellow-900/30 dark:text-yellow-300" role="alert">
|
||||||
Google Client Secret
|
<div class="flex">
|
||||||
<span x-show="useBuiltinAuthDest" class="text-secondary-400 dark:text-secondary-600 text-xs font-normal">(Using rclone default)</span>
|
<i class="fas fa-exclamation-triangle mr-2 flex-shrink-0"></i>
|
||||||
</label>
|
<span>You'll need to create your own Google API project with Drive API enabled and create OAuth credentials. Learn how at <a href="https://developers.google.com/drive/api/quickstart/go" class="font-medium underline hover:text-yellow-700 dark:hover:text-yellow-200" target="_blank">Google API documentation</a>.</span>
|
||||||
<div class="relative">
|
</div>
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
</div>
|
||||||
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
|
|
||||||
</div>
|
|
||||||
<input type="password" id="dest_client_secret" name="dest_client_secret" x-model="destClientSecret"
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
x-bind:disabled="useBuiltinAuthDest"
|
|
||||||
placeholder="Google Drive OAuth Client Secret" />
|
|
||||||
</div>
|
|
||||||
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
|
||||||
Client Secret from Google Cloud Console
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-4">
|
<div class="mb-6">
|
||||||
<label for="dest_drive_id" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Folder ID (Optional)</label>
|
<label for="dest_client_id" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Client ID</label>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
<i class="fas fa-folder text-secondary-400 dark:text-secondary-600"></i>
|
<i class="fas fa-id-card text-gray-400 dark:text-gray-500"></i>
|
||||||
</div>
|
</div>
|
||||||
<input type="text" id="dest_drive_id" name="dest_drive_id" x-model="destDriveId"
|
<input type="text" id="dest_client_id" name="dest_client_id" x-model="destClientId"
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
class="bg-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="Google Drive Folder ID (optional)" />
|
placeholder="Your Google API Client ID" x-bind:required="!useBuiltinAuthDest" />
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">OAuth 2.0 Client ID from Google Cloud Console</p>
|
||||||
Folder ID to use as the root (leave empty for "My Drive")
|
</div>
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-4">
|
<div class="mb-6">
|
||||||
<label for="dest_team_drive" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Shared/Team Drive ID (Optional)</label>
|
<label for="dest_client_secret" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Client Secret</label>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
<i class="fas fa-users text-secondary-400 dark:text-secondary-600"></i>
|
<i class="fas fa-key text-gray-400 dark:text-gray-500"></i>
|
||||||
</div>
|
</div>
|
||||||
<input type="text" id="dest_team_drive" name="dest_team_drive" x-model="destTeamDrive"
|
<input type="password" id="dest_client_secret" name="dest_client_secret" x-model="destClientSecret"
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
class="bg-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="Shared/Team Drive ID (optional)" />
|
placeholder="Your Google API Client Secret" x-bind:required="!useBuiltinAuthDest" />
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">OAuth 2.0 Client Secret from Google Cloud Console</p>
|
||||||
ID of the Shared Drive / Team Drive to use
|
</div>
|
||||||
</p>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-4">
|
<div>
|
||||||
<label for="destination_path" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Path</label>
|
<label for="destination_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Drive Path</label>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
<i class="fas fa-folder-open text-secondary-400 dark:text-secondary-600"></i>
|
<i class="fab fa-google-drive text-gray-400 dark:text-gray-500"></i>
|
||||||
</div>
|
</div>
|
||||||
<input type="text" id="destination_path" name="destination_path" x-model="destinationPath"
|
<input type="text" id="destination_path" name="destination_path" x-model="destinationPath"
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
class="bg-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 within Google Drive (e.g., /backup)" />
|
placeholder="/path/in/drive" />
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
Path within the Drive where files will be uploaded
|
Path to the directory in your Google Drive. Leave empty for the root of your Drive.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="p-4 bg-amber-50 dark:bg-amber-900/50 rounded-lg border border-amber-100 dark:border-amber-700">
|
<div>
|
||||||
<div class="flex">
|
<label for="dest_drive_id" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Drive ID (Optional)</label>
|
||||||
<div class="flex-shrink-0">
|
<div class="relative">
|
||||||
<i class="fas fa-exclamation-triangle text-amber-500 dark:text-amber-400"></i>
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
</div>
|
<i class="fas fa-fingerprint text-gray-400 dark:text-gray-500"></i>
|
||||||
<div class="ml-3">
|
</div>
|
||||||
<h3 class="text-sm font-medium text-amber-800 dark:text-amber-200">Authentication Required</h3>
|
<input type="text" id="dest_drive_id" name="dest_drive_id" x-model="destDriveId"
|
||||||
<div class="mt-2 text-sm text-amber-700 dark:text-amber-300">
|
class="bg-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"
|
||||||
<p>After saving this configuration, you'll need to authenticate with Google Drive:</p>
|
placeholder="Specific Google Drive ID" />
|
||||||
<ol class="list-decimal list-inside mt-2 space-y-1">
|
</div>
|
||||||
<li>Go to the configurations page</li>
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
<li>Click the "Authenticate" button for this configuration</li>
|
For accessing a shared drive. Leave empty to use your personal Google Drive.
|
||||||
<li>You'll be redirected to Google to grant access permissions</li>
|
</p>
|
||||||
<li>After granting permission, you'll be automatically redirected back to GoMFT</li>
|
</div>
|
||||||
</ol>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="p-4 bg-blue-50 dark:bg-blue-900/50 rounded-lg border border-blue-100 dark:border-blue-700">
|
<div>
|
||||||
<div class="flex">
|
<label for="dest_team_drive" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Team Drive (Optional)</label>
|
||||||
<div class="flex-shrink-0">
|
<div class="relative">
|
||||||
<i class="fas fa-info-circle text-blue-400 dark:text-blue-300"></i>
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
</div>
|
<i class="fas fa-users text-gray-400 dark:text-gray-500"></i>
|
||||||
<div class="ml-3">
|
</div>
|
||||||
<h3 class="text-sm font-medium text-blue-800 dark:text-blue-200">Authentication Information</h3>
|
<input type="text" id="dest_team_drive" name="dest_team_drive" x-model="destTeamDrive"
|
||||||
<div class="mt-2 text-sm text-blue-700 dark:text-blue-300">
|
class="bg-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"
|
||||||
<template x-if="useBuiltinAuthDest">
|
placeholder="Team Drive ID" />
|
||||||
<div>
|
</div>
|
||||||
<p>You're using rclone's built-in authentication, which simplifies the setup process:</p>
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
<ul class="list-disc list-inside mt-1 space-y-1">
|
For accessing a shared team drive. Leave empty for personal drive.
|
||||||
<li>No need to create your own Google API credentials</li>
|
</p>
|
||||||
<li>Authentication happens through Google's secure OAuth flow</li>
|
</div>
|
||||||
<li>Automatic redirection back to GoMFT after authentication</li>
|
</div>
|
||||||
</ul>
|
|
||||||
<p class="mt-2 text-xs text-amber-600 dark:text-amber-400">
|
|
||||||
<i class="fas fa-exclamation-triangle mr-1"></i>
|
|
||||||
Note: The built-in authentication uses shared credentials which have rate limits across all rclone users.
|
|
||||||
If you plan to transfer large amounts of data or run many concurrent transfers, consider creating your own credentials.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<template x-if="!useBuiltinAuthDest">
|
|
||||||
<div>
|
|
||||||
<p>To use Google Drive with your own credentials:</p>
|
|
||||||
<ol class="list-decimal list-inside mt-1 space-y-1">
|
|
||||||
<li>Go to the <a href="https://console.cloud.google.com/" target="_blank" class="text-blue-600 dark:text-blue-400 underline">Google Cloud Console</a></li>
|
|
||||||
<li>Create a project and enable the Google Drive API</li>
|
|
||||||
<li>Create OAuth 2.0 credentials (Client ID & Secret)</li>
|
|
||||||
<li>Set authorized redirect URI to <code class="bg-blue-100 dark:bg-blue-800 px-1 py-0.5 rounded">http://localhost:53682/</code></li>
|
|
||||||
</ol>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
@@ -1,216 +1,188 @@
|
|||||||
package destination
|
package destination
|
||||||
|
|
||||||
templ GooglePhotosDestinationForm() {
|
templ GooglePhotosDestinationForm() {
|
||||||
<div class="space-y-6" x-init="$watch('useBuiltinAuthDest', value => {
|
<div class="space-y-6 mt-4" x-init="$watch('useBuiltinAuthDest', value => {
|
||||||
if(value) {
|
if(value) {
|
||||||
destClientId = '';
|
destClientId = '';
|
||||||
destClientSecret = '';
|
destClientSecret = '';
|
||||||
}
|
}
|
||||||
})">
|
})">
|
||||||
<div class="mb-6">
|
<div class="p-4 mb-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900/30 dark:text-blue-300" role="alert">
|
||||||
<label for="use_builtin_auth_dest" class="flex items-center cursor-pointer">
|
<div class="flex items-center">
|
||||||
<div class="relative">
|
<i class="fas fa-info-circle mr-2"></i>
|
||||||
<input id="use_builtin_auth_dest" name="use_builtin_auth_dest" type="checkbox"
|
<span>Google Photos requires authentication after creating this configuration. You'll be prompted to authenticate once the configuration is saved.</span>
|
||||||
class="sr-only"
|
</div>
|
||||||
x-model="useBuiltinAuthDest"
|
</div>
|
||||||
:value="useBuiltinAuthDest ? 'true' : 'false'"
|
|
||||||
/>
|
|
||||||
<div class="block bg-secondary-200 dark:bg-secondary-700 w-14 h-8 rounded-full"></div>
|
|
||||||
<div class="dot absolute left-1 top-1 bg-white dark:bg-secondary-100 w-6 h-6 rounded-full transition"
|
|
||||||
:class="useBuiltinAuthDest ? 'transform translate-x-6 bg-primary-500' : ''"></div>
|
|
||||||
</div>
|
|
||||||
<div class="ml-3 text-secondary-700 dark:text-secondary-300 font-medium">
|
|
||||||
Use rclone's built-in Google authentication (recommended)
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
<p class="mt-1 ml-14 text-xs text-secondary-500 dark:text-secondary-400">
|
|
||||||
Simple one-click authentication using rclone's shared credentials
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div x-bind:class="{ 'opacity-50': useBuiltinAuthDest }">
|
<div>
|
||||||
<div>
|
<div class="flex items-center mb-6">
|
||||||
<label for="dest_client_id" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
|
<label class="relative inline-flex items-center cursor-pointer">
|
||||||
Google Client ID
|
<input type="checkbox" id="use_builtin_auth_dest" name="use_builtin_auth_dest" x-model="useBuiltinAuthDest"
|
||||||
<span x-show="useBuiltinAuthDest" class="text-secondary-400 dark:text-secondary-600 text-xs font-normal">(Using rclone default)</span>
|
class="sr-only peer" :value="useBuiltinAuthDest ? 'true' : 'false'">
|
||||||
</label>
|
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
|
||||||
<div class="relative">
|
<span class="ms-3 text-sm font-medium text-gray-900 dark:text-white">Use rclone's built-in Google authentication (recommended)</span>
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
</label>
|
||||||
<i class="fas fa-id-card text-secondary-400 dark:text-secondary-600"></i>
|
</div>
|
||||||
</div>
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
<input type="text" id="dest_client_id" name="dest_client_id" x-model="destClientId"
|
Simple one-click authentication using rclone's shared credentials
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
</p>
|
||||||
x-bind:disabled="useBuiltinAuthDest"
|
</div>
|
||||||
placeholder="Google Photos OAuth Client ID" />
|
|
||||||
</div>
|
|
||||||
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
|
||||||
Client ID from Google Cloud Console
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-4">
|
<div x-bind:class="{ 'opacity-50': useBuiltinAuthDest }">
|
||||||
<label for="dest_client_secret" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
|
<div class="mb-6">
|
||||||
Google Client Secret
|
<label for="dest_client_id" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
|
||||||
<span x-show="useBuiltinAuthDest" class="text-secondary-400 dark:text-secondary-600 text-xs font-normal">(Using rclone default)</span>
|
Google Client ID
|
||||||
</label>
|
<span x-show="useBuiltinAuthDest" class="text-gray-500 dark:text-gray-400 text-xs font-normal">(Using rclone default)</span>
|
||||||
<div class="relative">
|
</label>
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="relative">
|
||||||
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
</div>
|
<i class="fas fa-id-card text-gray-400 dark:text-gray-500"></i>
|
||||||
<input type="password" id="dest_client_secret" name="dest_client_secret" x-model="destClientSecret"
|
</div>
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
<input type="text" id="dest_client_id" name="dest_client_id" x-model="destClientId"
|
||||||
x-bind:disabled="useBuiltinAuthDest"
|
class="bg-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="Google Photos OAuth Client Secret" />
|
x-bind:disabled="useBuiltinAuthDest"
|
||||||
</div>
|
placeholder="Google Photos OAuth Client ID" />
|
||||||
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
</div>
|
||||||
Client Secret from Google Cloud Console
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
</p>
|
Client ID from Google Cloud Console
|
||||||
</div>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-4">
|
<div class="mb-6">
|
||||||
<label for="dest_read_only" class="flex items-center cursor-pointer">
|
<label for="dest_client_secret" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
|
||||||
<div class="relative">
|
Google Client Secret
|
||||||
<input id="dest_read_only" name="dest_read_only" type="checkbox"
|
<span x-show="useBuiltinAuthDest" class="text-gray-500 dark:text-gray-400 text-xs font-normal">(Using rclone default)</span>
|
||||||
class="sr-only"
|
</label>
|
||||||
x-model="destReadOnly"
|
<div class="relative">
|
||||||
:value="destReadOnly ? 'true' : 'false'"
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
/>
|
<i class="fas fa-key text-gray-400 dark:text-gray-500"></i>
|
||||||
<div class="block bg-secondary-200 dark:bg-secondary-700 w-14 h-8 rounded-full"></div>
|
</div>
|
||||||
<div class="dot absolute left-1 top-1 bg-white dark:bg-secondary-100 w-6 h-6 rounded-full transition"
|
<input type="password" id="dest_client_secret" name="dest_client_secret" x-model="destClientSecret"
|
||||||
:class="destReadOnly ? 'transform translate-x-6 bg-primary-500' : ''"></div>
|
class="bg-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"
|
||||||
</div>
|
x-bind:disabled="useBuiltinAuthDest"
|
||||||
<div class="ml-3 text-secondary-700 dark:text-secondary-300 font-medium">
|
placeholder="Google Photos OAuth Client Secret" />
|
||||||
Read-only mode
|
</div>
|
||||||
</div>
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
</label>
|
Client Secret from Google Cloud Console
|
||||||
<p class="mt-1 ml-14 text-xs text-secondary-500 dark:text-secondary-400">
|
</p>
|
||||||
Only request read-only access to your photos
|
</div>
|
||||||
</p>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-4">
|
<div class="flex items-center mb-6">
|
||||||
<label for="dest_start_year" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Start Year (Optional)</label>
|
<label class="relative inline-flex items-center cursor-pointer">
|
||||||
<div class="relative">
|
<input type="checkbox" id="dest_read_only" name="dest_read_only" x-model="destReadOnly"
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
class="sr-only peer" :value="destReadOnly ? 'true' : 'false'">
|
||||||
<i class="fas fa-calendar text-secondary-400 dark:text-secondary-600"></i>
|
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
|
||||||
</div>
|
<span class="ms-3 text-sm font-medium text-gray-900 dark:text-white">Read-only mode</span>
|
||||||
<input type="number" id="dest_start_year" name="dest_start_year" x-model="destStartYear"
|
</label>
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
</div>
|
||||||
placeholder="Only include photos after this year (default: 2000)" min="1900" max="2100" />
|
<p class="ms-14 text-sm text-gray-500 dark:text-gray-400">
|
||||||
</div>
|
Only request read-only access to your photos
|
||||||
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
</p>
|
||||||
Only include photos uploaded after this year
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-4">
|
<div class="mb-6">
|
||||||
<label for="dest_include_archived" class="flex items-center cursor-pointer">
|
<label for="dest_start_year" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Start Year (Optional)</label>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<input id="dest_include_archived" name="dest_include_archived" type="checkbox"
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
class="sr-only"
|
<i class="fas fa-calendar text-gray-400 dark:text-gray-500"></i>
|
||||||
x-model="destIncludeArchived"
|
</div>
|
||||||
:value="destIncludeArchived ? 'true' : 'false'"
|
<input type="number" id="dest_start_year" name="dest_start_year" x-model="destStartYear"
|
||||||
/>
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
<div class="block bg-secondary-200 dark:bg-secondary-700 w-14 h-8 rounded-full"></div>
|
placeholder="Only include photos after this year (default: 2000)" min="1900" max="2100" />
|
||||||
<div class="dot absolute left-1 top-1 bg-white dark:bg-secondary-100 w-6 h-6 rounded-full transition"
|
</div>
|
||||||
:class="destIncludeArchived ? 'transform translate-x-6 bg-primary-500' : ''"></div>
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
</div>
|
Only include photos uploaded after this year
|
||||||
<div class="ml-3 text-secondary-700 dark:text-secondary-300 font-medium">
|
</p>
|
||||||
Include archived media
|
</div>
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
<p class="mt-1 ml-14 text-xs text-secondary-500 dark:text-secondary-400">
|
|
||||||
Include archived photos and videos in media listings
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-4">
|
<div class="flex items-center mb-6">
|
||||||
<label for="destination_path" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Album Path</label>
|
<label class="relative inline-flex items-center cursor-pointer">
|
||||||
<div class="relative">
|
<input type="checkbox" id="dest_include_archived" name="dest_include_archived" x-model="destIncludeArchived"
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
class="sr-only peer" :value="destIncludeArchived ? 'true' : 'false'">
|
||||||
<i class="fas fa-images text-secondary-400 dark:text-secondary-600"></i>
|
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
|
||||||
</div>
|
<span class="ms-3 text-sm font-medium text-gray-900 dark:text-white">Include archived media</span>
|
||||||
<input type="text" id="destination_path" name="destination_path" x-model="destinationPath"
|
</label>
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
</div>
|
||||||
placeholder="Album path (e.g., album/my-photos)" />
|
<p class="ms-14 text-sm text-gray-500 dark:text-gray-400">
|
||||||
</div>
|
Include archived photos and videos in media listings
|
||||||
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
</p>
|
||||||
Path within Google Photos where files will be uploaded.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="p-4 bg-amber-50 dark:bg-amber-900/50 rounded-lg border border-amber-100 dark:border-amber-700">
|
<div class="mb-6">
|
||||||
<div class="flex">
|
<label for="destination_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Album Path</label>
|
||||||
<div class="flex-shrink-0">
|
<div class="relative">
|
||||||
<i class="fas fa-exclamation-triangle text-amber-500 dark:text-amber-400"></i>
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
</div>
|
<i class="fas fa-images text-gray-400 dark:text-gray-500"></i>
|
||||||
<div class="ml-3">
|
</div>
|
||||||
<h3 class="text-sm font-medium text-amber-800 dark:text-amber-200">Authentication Required</h3>
|
<input type="text" id="destination_path" name="destination_path" x-model="destinationPath"
|
||||||
<div class="mt-2 text-sm text-amber-700 dark:text-amber-300">
|
class="bg-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"
|
||||||
<p>After saving this configuration, you'll need to authenticate with Google Photos:</p>
|
placeholder="Album path (e.g., album/my-photos)" />
|
||||||
<ol class="list-decimal list-inside mt-2 space-y-1">
|
</div>
|
||||||
<li>Go to the configurations page</li>
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
<li>Click the "Authenticate" button for this configuration</li>
|
Path within Google Photos to download files from
|
||||||
<li>You'll be redirected to Google to grant access permissions</li>
|
</p>
|
||||||
<li>After granting permission, you'll be automatically redirected back to GoMFT</li>
|
</div>
|
||||||
</ol>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="p-4 bg-blue-50 dark:bg-blue-900/50 rounded-lg border border-blue-100 dark:border-blue-700">
|
<div class="p-4 mb-4 text-sm text-yellow-800 rounded-lg bg-yellow-50 dark:bg-yellow-900/30 dark:text-yellow-300" role="alert">
|
||||||
<div class="flex">
|
<div class="flex">
|
||||||
<div class="flex-shrink-0">
|
<i class="fas fa-exclamation-triangle mr-2 flex-shrink-0"></i>
|
||||||
<i class="fas fa-info-circle text-blue-400 dark:text-blue-300"></i>
|
<div>
|
||||||
</div>
|
<h3 class="font-medium">Authentication Required</h3>
|
||||||
<div class="ml-3">
|
<p class="mt-1">After saving this configuration, you'll need to authenticate with Google Photos:</p>
|
||||||
<h3 class="text-sm font-medium text-blue-800 dark:text-blue-200">Authentication Information</h3>
|
<ol class="list-decimal list-inside mt-2 space-y-1">
|
||||||
<div class="mt-2 text-sm text-blue-700 dark:text-blue-300">
|
<li>Go to the configurations page</li>
|
||||||
<template x-if="useBuiltinAuthDest">
|
<li>Click the "Authenticate" button for this configuration</li>
|
||||||
<div>
|
<li>You'll be redirected to Google to grant access permissions</li>
|
||||||
<p>You're using rclone's built-in authentication, which simplifies the setup process:</p>
|
<li>After granting permission, you'll be automatically redirected back to GoMFT</li>
|
||||||
<ul class="list-disc list-inside mt-1 space-y-1">
|
</ol>
|
||||||
<li>No need to create your own Google API credentials</li>
|
</div>
|
||||||
<li>Authentication happens through Google's secure OAuth flow</li>
|
</div>
|
||||||
<li>Automatic redirection back to GoMFT after authentication</li>
|
</div>
|
||||||
</ul>
|
|
||||||
<p class="mt-2 text-xs text-amber-600 dark:text-amber-400">
|
|
||||||
<i class="fas fa-exclamation-triangle mr-1"></i>
|
|
||||||
Note: The built-in authentication uses shared credentials which have rate limits across all rclone users.
|
|
||||||
If you plan to transfer large amounts of data or run many concurrent transfers, consider creating your own credentials.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<template x-if="!useBuiltinAuthDest">
|
|
||||||
<div>
|
|
||||||
<p>To use Google Photos with your own credentials:</p>
|
|
||||||
<ol class="list-decimal list-inside mt-1 space-y-1">
|
|
||||||
<li>Go to the <a href="https://console.cloud.google.com/" target="_blank" class="text-blue-600 dark:text-blue-400 underline">Google Cloud Console</a></li>
|
|
||||||
<li>Create a project and enable the Google Photos API</li>
|
|
||||||
<li>Create OAuth 2.0 credentials (Client ID & Secret)</li>
|
|
||||||
<li>Set authorized redirect URI to <code class="bg-blue-100 dark:bg-blue-800 px-1 py-0.5 rounded">http://localhost:53682/</code></li>
|
|
||||||
</ol>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="p-4 bg-yellow-50 dark:bg-yellow-900/50 rounded-lg border border-yellow-100 dark:border-yellow-700">
|
<div class="p-4 mb-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900/30 dark:text-blue-300" role="alert">
|
||||||
<div class="flex">
|
<div class="flex">
|
||||||
<div class="flex-shrink-0">
|
<i class="fas fa-info-circle mr-2 flex-shrink-0"></i>
|
||||||
<i class="fas fa-exclamation-circle text-yellow-500 dark:text-yellow-400"></i>
|
<div>
|
||||||
</div>
|
<h3 class="font-medium">Authentication Information</h3>
|
||||||
<div class="ml-3">
|
<div class="mt-1">
|
||||||
<h3 class="text-sm font-medium text-yellow-800 dark:text-yellow-200">Important Note About Google Photos</h3>
|
<template x-if="useBuiltinAuthDest">
|
||||||
<div class="mt-2 text-sm text-yellow-700 dark:text-yellow-300">
|
<div>
|
||||||
<p>All media items uploaded to Google Photos with rclone are stored in full resolution at original quality. These uploads will count towards storage in your Google Account.</p>
|
<p>You're using rclone's built-in authentication, which simplifies the setup process:</p>
|
||||||
</div>
|
<ul class="list-disc list-inside mt-1 space-y-1">
|
||||||
</div>
|
<li>No need to create your own Google API credentials</li>
|
||||||
</div>
|
<li>Authentication happens through Google's secure OAuth flow</li>
|
||||||
</div>
|
<li>Automatic redirection back to GoMFT after authentication</li>
|
||||||
</div>
|
</ul>
|
||||||
|
<p class="mt-2 text-yellow-700 dark:text-yellow-300">
|
||||||
|
<i class="fas fa-exclamation-triangle mr-1"></i>
|
||||||
|
Note: The built-in authentication uses shared credentials which have rate limits across all rclone users.
|
||||||
|
If you plan to transfer large amounts of data or run many concurrent transfers, consider creating your own credentials.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template x-if="!useBuiltinAuthDest">
|
||||||
|
<div>
|
||||||
|
<p>To use Google Photos with your own credentials:</p>
|
||||||
|
<ol class="list-decimal list-inside mt-1 space-y-1">
|
||||||
|
<li>Go to the <a href="https://console.cloud.google.com/" target="_blank" class="font-medium underline hover:text-blue-700 dark:hover:text-blue-300">Google Cloud Console</a></li>
|
||||||
|
<li>Create a project and enable the Google Photos API</li>
|
||||||
|
<li>Create OAuth 2.0 credentials (Client ID & Secret)</li>
|
||||||
|
<li>Set authorized redirect URI to <code class="bg-blue-100 dark:bg-blue-800 px-1.5 py-0.5 rounded text-xs">http://localhost:53682/</code></li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-4 mb-4 text-sm text-yellow-800 rounded-lg bg-yellow-50 dark:bg-yellow-900/30 dark:text-yellow-300" role="alert">
|
||||||
|
<div class="flex">
|
||||||
|
<i class="fas fa-exclamation-circle mr-2 flex-shrink-0"></i>
|
||||||
|
<div>
|
||||||
|
<h3 class="font-medium">Important Note About Google Photos</h3>
|
||||||
|
<p class="mt-1">When downloading from Google Photos, be aware that some original metadata may not be preserved. Google Photos processes and may compress some images upon upload.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
package destination
|
||||||
|
|
||||||
|
templ HetznerDestinationForm() {
|
||||||
|
<div class="space-y-6 mt-4">
|
||||||
|
<div class="p-4 mb-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900/30 dark:text-blue-300" role="alert">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<i class="fas fa-info-circle mr-2"></i>
|
||||||
|
<span>Configure your Hetzner Storage Box details below. You'll need your server details, username, and password.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-6">
|
||||||
|
<label for="dest_host" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Storage Box Host</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
|
<i class="fas fa-server text-gray-400 dark:text-gray-500"></i>
|
||||||
|
</div>
|
||||||
|
<input type="text" id="dest_host" name="dest_host" x-model="destHost" x-bind:required="requiresDestination"
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
placeholder="uXXXXXX.your-storagebox.de" />
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Your Hetzner Storage Box hostname (e.g., uXXXXXX.your-storagebox.de)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-6">
|
||||||
|
<label for="dest_port" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Port</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
|
<i class="fas fa-plug text-gray-400 dark:text-gray-500"></i>
|
||||||
|
</div>
|
||||||
|
<input type="number" id="dest_port" name="dest_port" x-model="destPort" x-bind:required="requiresDestination"
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
placeholder="23" min="1" max="65535" value="23" />
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Connection port (default: 23)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-6">
|
||||||
|
<label for="dest_auth_type" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Authentication Type</label>
|
||||||
|
<div class="flex">
|
||||||
|
<div class="flex items-center me-4">
|
||||||
|
<input id="dest_auth_password" type="radio" value="password" name="dest_auth_type" x-model="destAuthType"
|
||||||
|
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600">
|
||||||
|
<label for="dest_auth_password" class="ms-2 text-sm font-medium text-gray-900 dark:text-white">Password</label>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center me-4">
|
||||||
|
<input id="dest_auth_key" type="radio" value="key" name="dest_auth_type" x-model="destAuthType"
|
||||||
|
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600">
|
||||||
|
<label for="dest_auth_key" class="ms-2 text-sm font-medium text-gray-900 dark:text-white">SSH Key</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-6">
|
||||||
|
<label for="dest_user" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Username</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
|
<i class="fas fa-user text-gray-400 dark:text-gray-500"></i>
|
||||||
|
</div>
|
||||||
|
<input type="text" id="dest_user" name="dest_user" x-model="destUser" x-bind:required="requiresDestination"
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
placeholder="uXXXXXX" />
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Your Hetzner Storage Box username (typically matches your Storage Box number)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div x-show="destAuthType === 'password'" class="mb-6">
|
||||||
|
<label for="dest_password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Password</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
|
<i class="fas fa-lock text-gray-400 dark:text-gray-500"></i>
|
||||||
|
</div>
|
||||||
|
<input type="password" id="dest_password" name="dest_password" x-model="destPassword" x-bind:required="requiresDestination && destAuthType === 'password'"
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
placeholder="Your password" />
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Your Hetzner Storage Box password
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div x-show="destAuthType === 'key'" class="mb-6">
|
||||||
|
<label for="dest_key_file" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">SSH Key File</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
|
<i class="fas fa-key text-gray-400 dark:text-gray-500"></i>
|
||||||
|
</div>
|
||||||
|
<input type="text" id="dest_key_file" name="dest_key_file" x-model="destKeyFile" x-bind:required="requiresDestination && destAuthType === 'key'"
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
placeholder="/path/to/id_rsa" />
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Path to your SSH private key file (must be readable by the application)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-6">
|
||||||
|
<label for="destination_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Remote Path</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
|
<i class="fas fa-folder-open text-gray-400 dark:text-gray-500"></i>
|
||||||
|
</div>
|
||||||
|
<input type="text" id="destination_path" name="destination_path" x-model="destinationPath" x-bind:required="requiresDestination"
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
placeholder="/backups" />
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Path on your Storage Box (e.g., /backups, /path/to/files)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-4 mb-4 text-sm text-yellow-800 rounded-lg bg-yellow-50 dark:bg-yellow-900/30 dark:text-yellow-300" role="alert">
|
||||||
|
<div class="flex">
|
||||||
|
<i class="fas fa-shield-alt mr-2 flex-shrink-0"></i>
|
||||||
|
<div>
|
||||||
|
<h3 class="font-medium">Security Note</h3>
|
||||||
|
<p class="mt-1">For increased security, consider using SSH key authentication instead of password authentication when possible.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@@ -1,39 +1,31 @@
|
|||||||
package destination
|
package destination
|
||||||
|
|
||||||
templ LocalDestinationForm() {
|
templ LocalDestinationForm() {
|
||||||
<div class="sm:col-span-6 space-y-6">
|
<div class="space-y-4 mt-4">
|
||||||
<div class="sm:col-span-4">
|
<div>
|
||||||
<label for="destination_path" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Local Path</label>
|
<label for="destination_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Local Path</label>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
<i class="fas fa-folder-open text-secondary-400 dark:text-secondary-600"></i>
|
<i class="fas fa-folder-open text-gray-400 dark:text-gray-500"></i>
|
||||||
|
</div>
|
||||||
|
<input type="text" id="destination_path" name="destination_path" x-model="destinationPath" x-bind:required="requiresDestination"
|
||||||
|
:class="{ 'border-red-500 dark:border-red-700': destPathValid === false, 'border-green-500 dark:border-green-700': destPathValid === true }"
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
placeholder="/path/to/files" />
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Full path to the local directory containing your files</p>
|
||||||
|
<div x-show="destPathValid === false" class="mt-2 text-sm text-red-600 dark:text-red-400">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<i class="fas fa-exclamation-circle mr-2"></i>
|
||||||
|
<span x-text="destPathError"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div x-show="destPathValid === true" class="mt-2 text-sm text-green-600 dark:text-green-400">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<i class="fas fa-check-circle mr-2"></i>
|
||||||
|
<span x-text="destPathError || 'Path is valid'"></span>
|
||||||
</div>
|
</div>
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="destination_path"
|
|
||||||
id="destination_path"
|
|
||||||
x-model="destinationPath"
|
|
||||||
required
|
|
||||||
:class="{ 'border-red-300 dark:border-red-700': destPathValid === false, 'border-green-300 dark:border-green-700': destPathValid === true }"
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
placeholder="Local directory path (e.g., /path/to/destination)"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
|
||||||
Full path to the local directory where files will be saved
|
|
||||||
</p>
|
|
||||||
<!-- Path validation message -->
|
|
||||||
<template x-if="destPathValid !== null">
|
|
||||||
<p x-show="destPathError" class="mt-1 text-sm" :class="destPathValid ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'" x-text="destPathError"></p>
|
|
||||||
</template>
|
|
||||||
</div>
|
</div>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn-secondary h-10 whitespace-nowrap"
|
|
||||||
@click="checkPath(destinationPath, 'dest')"
|
|
||||||
>
|
|
||||||
<i class="fas fa-check-circle mr-2"></i>
|
|
||||||
Check Location
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,91 +1,113 @@
|
|||||||
package destination
|
package destination
|
||||||
|
|
||||||
templ MinIODestinationForm() {
|
templ MinIODestinationForm() {
|
||||||
<div class="sm:col-span-6 space-y-6">
|
<div class="space-y-6 mt-4">
|
||||||
<div class="sm:col-span-4">
|
<div class="p-4 mb-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900/30 dark:text-blue-300" role="alert">
|
||||||
<label for="dest_endpoint" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Endpoint URL</label>
|
<div class="flex items-center">
|
||||||
<div class="relative">
|
<i class="fas fa-info-circle mr-2"></i>
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<span>Configure your MinIO server details below. You'll need your access key, secret key, and endpoint information.</span>
|
||||||
<i class="fas fa-server text-secondary-400 dark:text-secondary-600"></i>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="dest_endpoint"
|
|
||||||
id="dest_endpoint"
|
|
||||||
x-model="destEndpoint"
|
|
||||||
required
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
placeholder="https://minio.example.com"/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sm:col-span-4">
|
<div class="mb-6">
|
||||||
<label for="dest_bucket" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Bucket Name</label>
|
<label for="dest_endpoint" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Endpoint URL</label>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
<i class="fas fa-database text-secondary-400 dark:text-secondary-600"></i>
|
<i class="fas fa-server text-gray-400 dark:text-gray-500"></i>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input type="text" id="dest_endpoint" name="dest_endpoint" x-model="destEndpoint" x-bind:required="requiresDestination"
|
||||||
type="text"
|
class="bg-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"
|
||||||
name="dest_bucket"
|
placeholder="https://minio.example.com" />
|
||||||
id="dest_bucket"
|
|
||||||
x-model="destBucket"
|
|
||||||
required
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
placeholder="my-bucket"/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Full URL to your MinIO server including protocol (http:// or https://)
|
||||||
<div class="sm:col-span-4">
|
|
||||||
<label for="destination_path" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Path Prefix</label>
|
|
||||||
<div class="relative">
|
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
|
||||||
<i class="fas fa-folder text-secondary-400 dark:text-secondary-600"></i>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="destination_path"
|
|
||||||
id="destination_path"
|
|
||||||
x-model="destinationPath"
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
placeholder="path/prefix/"/>
|
|
||||||
</div>
|
|
||||||
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
|
||||||
Optional. If specified, files will be uploaded to this path in the bucket.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sm:col-span-4">
|
<div class="mb-6">
|
||||||
<label for="dest_access_key" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Access Key</label>
|
<label for="dest_bucket" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Bucket Name</label>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
|
<i class="fas fa-archive text-gray-400 dark:text-gray-500"></i>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input type="text" id="dest_bucket" name="dest_bucket" x-model="destBucket" x-bind:required="requiresDestination"
|
||||||
type="text"
|
class="bg-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"
|
||||||
name="dest_access_key"
|
placeholder="my-bucket" />
|
||||||
id="dest_access_key"
|
|
||||||
x-model="destAccessKey"
|
|
||||||
required
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"/>
|
|
||||||
</div>
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Name of your MinIO bucket (case-sensitive)
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sm:col-span-4">
|
|
||||||
<label for="dest_secret_key" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Secret Key</label>
|
<div class="mb-6">
|
||||||
|
<label for="dest_region" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Region (Optional)</label>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
<i class="fas fa-lock text-secondary-400 dark:text-secondary-600"></i>
|
<i class="fas fa-globe-americas text-gray-400 dark:text-gray-500"></i>
|
||||||
|
</div>
|
||||||
|
<input type="text" id="dest_region" name="dest_region" x-model="destRegion"
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
placeholder="us-east-1" />
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Optional: Specify the region if your MinIO setup requires it.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-6">
|
||||||
|
<label for="dest_access_key" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Access Key</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
|
<i class="fas fa-key text-gray-400 dark:text-gray-500"></i>
|
||||||
|
</div>
|
||||||
|
<input type="text" id="dest_access_key" name="dest_access_key" x-model="destAccessKey" x-bind:required="requiresDestination"
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
placeholder="minioadmin" />
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Your MinIO Access Key for authentication
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-6">
|
||||||
|
<label for="dest_secret_key" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Secret Key</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
|
<i class="fas fa-lock text-gray-400 dark:text-gray-500"></i>
|
||||||
|
</div>
|
||||||
|
<input type="password" id="dest_secret_key" name="dest_secret_key" x-model="destSecretKey" x-bind:required="requiresDestination"
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
placeholder="minioadmin" />
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Your MinIO Secret Key for authentication
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-6">
|
||||||
|
<label for="destination_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Path in Bucket</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
|
<i class="fas fa-folder-open text-gray-400 dark:text-gray-500"></i>
|
||||||
|
</div>
|
||||||
|
<input type="text" id="destination_path" name="destination_path" x-model="destinationPath"
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
placeholder="path/to/files/" />
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Path prefix within the bucket (e.g., "backups/"). Leave empty to access the entire bucket.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-4 mb-4 text-sm text-yellow-800 rounded-lg bg-yellow-50 dark:bg-yellow-900/30 dark:text-yellow-300" role="alert">
|
||||||
|
<div class="flex">
|
||||||
|
<i class="fas fa-exclamation-triangle mr-2 flex-shrink-0"></i>
|
||||||
|
<div>
|
||||||
|
<h3 class="font-medium">Configuration Note</h3>
|
||||||
|
<p class="mt-1">Make sure your MinIO server is properly configured with TLS if you're using HTTPS, and that the server is accessible from this machine.</p>
|
||||||
</div>
|
</div>
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
name="dest_secret_key"
|
|
||||||
id="dest_secret_key"
|
|
||||||
x-model="destSecretKey"
|
|
||||||
required
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"/>
|
|
||||||
</div>
|
</div>
|
||||||
<input type="hidden" name="dest_secret_key" :value="destSecretKey"/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -1,56 +1,82 @@
|
|||||||
package destination
|
package destination
|
||||||
|
|
||||||
templ NextCloudDestinationForm() {
|
templ NextCloudDestinationForm() {
|
||||||
<div class="sm:col-span-6 space-y-6">
|
<div class="space-y-6 mt-4">
|
||||||
<div class="sm:col-span-4">
|
<div class="p-4 mb-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900/30 dark:text-blue-300" role="alert">
|
||||||
<label for="dest_host" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">NextCloud URL</label>
|
<div class="flex items-center">
|
||||||
<div class="relative">
|
<i class="fas fa-info-circle mr-2"></i>
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<span>Configure your NextCloud connection details below. You'll need your server URL, username, and password.</span>
|
||||||
<i class="fas fa-cloud text-secondary-400 dark:text-secondary-600"></i>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="dest_host"
|
|
||||||
id="dest_host"
|
|
||||||
x-model="destHost"
|
|
||||||
required
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
placeholder="https://nextcloud.example.com"/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sm:col-span-4">
|
<div class="mb-6">
|
||||||
<label for="dest_user" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Username</label>
|
<label for="dest_host" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">NextCloud URL</label>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
<i class="fas fa-user text-secondary-400 dark:text-secondary-600"></i>
|
<i class="fas fa-cloud text-gray-400 dark:text-gray-500"></i>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input type="text" id="dest_host" name="dest_host" x-model="destHost" x-bind:required="requiresDestination"
|
||||||
type="text"
|
class="bg-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"
|
||||||
name="dest_user"
|
placeholder="https://nextcloud.example.com" />
|
||||||
id="dest_user"
|
|
||||||
x-model="destUser"
|
|
||||||
required
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"/>
|
|
||||||
</div>
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Full URL to your NextCloud server including protocol (https://)
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sm:col-span-4">
|
<div class="mb-6">
|
||||||
<label for="dest_password" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Password</label>
|
<label for="dest_user" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Username</label>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
|
<i class="fas fa-user text-gray-400 dark:text-gray-500"></i>
|
||||||
|
</div>
|
||||||
|
<input type="text" id="dest_user" name="dest_user" x-model="destUser" x-bind:required="requiresDestination"
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
placeholder="username" />
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Your NextCloud username
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-6">
|
||||||
|
<label for="dest_password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Password</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
|
<i class="fas fa-lock text-gray-400 dark:text-gray-500"></i>
|
||||||
|
</div>
|
||||||
|
<input type="password" id="dest_password" name="dest_password" x-model="destPassword" x-bind:required="requiresDestination"
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
placeholder="password" />
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Your NextCloud account password
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-6">
|
||||||
|
<label for="destination_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Remote Path</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
|
<i class="fas fa-folder-open text-gray-400 dark:text-gray-500"></i>
|
||||||
|
</div>
|
||||||
|
<input type="text" id="destination_path" name="destination_path" x-model="destinationPath" x-bind:required="requiresDestination"
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
placeholder="/path/to/files" />
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Path to your files in NextCloud
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-4 mb-4 text-sm text-yellow-800 rounded-lg bg-yellow-50 dark:bg-yellow-900/30 dark:text-yellow-300" role="alert">
|
||||||
|
<div class="flex">
|
||||||
|
<i class="fas fa-lightbulb mr-2 flex-shrink-0"></i>
|
||||||
|
<div>
|
||||||
|
<h3 class="font-medium">Connection Tip</h3>
|
||||||
|
<p class="mt-1">If you have two-factor authentication enabled on your NextCloud account, you'll need to create an app password in your NextCloud security settings.</p>
|
||||||
</div>
|
</div>
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
name="dest_password"
|
|
||||||
id="dest_password"
|
|
||||||
x-model="destPassword"
|
|
||||||
required
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
placeholder="Password"/>
|
|
||||||
</div>
|
</div>
|
||||||
<input type="hidden" name="dest_password" :value="destPassword"/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -1,91 +1,97 @@
|
|||||||
package destination
|
package destination
|
||||||
|
|
||||||
templ S3DestinationForm() {
|
templ S3DestinationForm() {
|
||||||
<div class="sm:col-span-6 space-y-6">
|
<div class="space-y-6 mt-4">
|
||||||
<div class="sm:col-span-4">
|
<div class="p-4 mb-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900/30 dark:text-blue-300" role="alert">
|
||||||
<label for="dest_bucket" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Bucket Name</label>
|
<div class="flex items-center">
|
||||||
<div class="relative">
|
<i class="fas fa-info-circle mr-2"></i>
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<span>Configure your AWS S3 bucket details below. You'll need your access key, secret key, and bucket information.</span>
|
||||||
<i class="fas fa-database text-secondary-400 dark:text-secondary-600"></i>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="dest_bucket"
|
|
||||||
id="dest_bucket"
|
|
||||||
x-model="destBucket"
|
|
||||||
required
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
placeholder="my-bucket"/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sm:col-span-4">
|
<div class="mb-6">
|
||||||
<label for="dest_region" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">AWS Region</label>
|
<label for="dest_region" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">AWS Region</label>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
<i class="fas fa-globe-americas text-secondary-400 dark:text-secondary-600"></i>
|
<i class="fas fa-globe text-gray-400 dark:text-gray-500"></i>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input type="text" id="dest_region" name="dest_region" x-model="destRegion" x-bind:required="requiresDestination"
|
||||||
type="text"
|
class="bg-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"
|
||||||
name="dest_region"
|
placeholder="us-east-1" />
|
||||||
id="dest_region"
|
|
||||||
x-model="destRegion"
|
|
||||||
required
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
placeholder="us-east-1"/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
AWS Region where your S3 bucket is located (e.g., us-east-1, eu-west-1)
|
||||||
<div class="sm:col-span-4">
|
|
||||||
<label for="destination_path" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">S3 Path Prefix</label>
|
|
||||||
<div class="relative">
|
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
|
||||||
<i class="fas fa-folder text-secondary-400 dark:text-secondary-600"></i>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="destination_path"
|
|
||||||
id="destination_path"
|
|
||||||
x-model="destinationPath"
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
placeholder="optional/path/prefix/"/>
|
|
||||||
</div>
|
|
||||||
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
|
||||||
Optional. If specified, files will be uploaded to this path in the bucket.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sm:col-span-4">
|
<div class="mb-6">
|
||||||
<label for="dest_access_key" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Access Key</label>
|
<label for="dest_bucket" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Bucket Name</label>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
|
<i class="fas fa-archive text-gray-400 dark:text-gray-500"></i>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input type="text" id="dest_bucket" name="dest_bucket" x-model="destBucket" x-bind:required="requiresDestination"
|
||||||
type="text"
|
class="bg-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"
|
||||||
name="dest_access_key"
|
placeholder="my-bucket" />
|
||||||
id="dest_access_key"
|
|
||||||
x-model="destAccessKey"
|
|
||||||
required
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"/>
|
|
||||||
</div>
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Name of your S3 bucket (case-sensitive)
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sm:col-span-4">
|
<div class="mb-6">
|
||||||
<label for="dest_secret_key" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Secret Key</label>
|
<label for="dest_access_key" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Access Key ID</label>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
<i class="fas fa-lock text-secondary-400 dark:text-secondary-600"></i>
|
<i class="fas fa-key text-gray-400 dark:text-gray-500"></i>
|
||||||
|
</div>
|
||||||
|
<input type="text" id="dest_access_key" name="dest_access_key" x-model="destAccessKey" x-bind:required="requiresDestination"
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
placeholder="AKIAIOSFODNN7EXAMPLE" />
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Your AWS Access Key ID
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-6">
|
||||||
|
<label for="dest_secret_key" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Secret Access Key</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
|
<i class="fas fa-lock text-gray-400 dark:text-gray-500"></i>
|
||||||
|
</div>
|
||||||
|
<input type="password" id="dest_secret_key" name="dest_secret_key" x-model="destSecretKey" x-bind:required="requiresDestination"
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
placeholder="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" />
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Your AWS Secret Access Key
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-6">
|
||||||
|
<label for="destination_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Path in Bucket</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
|
<i class="fas fa-folder-open text-gray-400 dark:text-gray-500"></i>
|
||||||
|
</div>
|
||||||
|
<input type="text" id="destination_path" name="destination_path" x-model="destinationPath"
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
placeholder="path/to/files/" />
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Path prefix within the bucket (e.g., "backups/"). Leave empty to access the entire bucket.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-4 mb-4 text-sm text-yellow-800 rounded-lg bg-yellow-50 dark:bg-yellow-900/30 dark:text-yellow-300" role="alert">
|
||||||
|
<div class="flex">
|
||||||
|
<i class="fas fa-shield-alt mr-2 flex-shrink-0"></i>
|
||||||
|
<div>
|
||||||
|
<h3 class="font-medium">Security Note</h3>
|
||||||
|
<p class="mt-1">It's recommended to use an IAM user with restricted permissions for this configuration. The IAM user should only have access to the specific S3 bucket and operations needed.</p>
|
||||||
</div>
|
</div>
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
name="dest_secret_key"
|
|
||||||
id="dest_secret_key"
|
|
||||||
x-model="destSecretKey"
|
|
||||||
required
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"/>
|
|
||||||
</div>
|
</div>
|
||||||
<input type="hidden" name="dest_secret_key" :value="destSecretKey"/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -1,147 +1,97 @@
|
|||||||
package destination
|
package destination
|
||||||
|
|
||||||
templ SFTPDestinationForm() {
|
templ SFTPDestinationForm() {
|
||||||
<div class="sm:col-span-6 space-y-6">
|
<div class="space-y-6 mt-4">
|
||||||
<div class="sm:col-span-4">
|
<div>
|
||||||
<label for="dest_host" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Host</label>
|
<label for="dest_host" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">SFTP Host</label>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
<i class="fas fa-server text-secondary-400 dark:text-secondary-600"></i>
|
<i class="fas fa-server text-gray-400 dark:text-gray-500"></i>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input type="text" id="dest_host" name="dest_host" x-model="destHost" x-bind:required="requiresDestination"
|
||||||
type="text"
|
class="bg-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"
|
||||||
name="dest_host"
|
placeholder="sftp.example.com" />
|
||||||
id="dest_host"
|
|
||||||
x-model="destHost"
|
|
||||||
required
|
|
||||||
aria-describedby="dest_host_help"
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
placeholder="sftp.example.com"/>
|
|
||||||
</div>
|
</div>
|
||||||
<p id="dest_host_help" class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Hostname or IP address of the SFTP server</p>
|
||||||
Enter the SFTP server hostname or IP address.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sm:col-span-4">
|
<div>
|
||||||
<label for="dest_port" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Port</label>
|
<label for="dest_port" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Port</label>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
<i class="fas fa-plug text-secondary-400 dark:text-secondary-600"></i>
|
<i class="fas fa-network-wired text-gray-400 dark:text-gray-500"></i>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input type="number" id="dest_port" name="dest_port" x-model="destPort"
|
||||||
type="number"
|
class="bg-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"
|
||||||
name="dest_port"
|
placeholder="22" min="1" max="65535" x-init="if (!destPort || destPort === 0) destPort = 22" />
|
||||||
id="dest_port"
|
|
||||||
x-model="destPort"
|
|
||||||
required
|
|
||||||
min="1"
|
|
||||||
max="65535"
|
|
||||||
value="22"
|
|
||||||
aria-describedby="dest_port_help"
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
placeholder="22"/>
|
|
||||||
</div>
|
</div>
|
||||||
<p id="dest_port_help" class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">SFTP port number (default: 22)</p>
|
||||||
Default SFTP port is 22.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sm:col-span-4">
|
<div>
|
||||||
<label for="dest_path" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Remote Path</label>
|
<label for="dest_user" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Username</label>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
<i class="fas fa-folder text-secondary-400 dark:text-secondary-600"></i>
|
<i class="fas fa-user text-gray-400 dark:text-gray-500"></i>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input type="text" id="dest_user" name="dest_user" x-model="destUser" x-bind:required="requiresDestination"
|
||||||
type="text"
|
class="bg-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"
|
||||||
name="destination_path"
|
placeholder="username" />
|
||||||
id="destination_path"
|
|
||||||
x-model="destinationPath"
|
|
||||||
required
|
|
||||||
aria-describedby="destination_path_help"
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
placeholder="/path/to/files"/>
|
|
||||||
</div>
|
</div>
|
||||||
<p id="destination_path_help" class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">SFTP username for authentication</p>
|
||||||
Absolute path to the files on the remote server.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sm:col-span-4">
|
<div>
|
||||||
<label for="dest_user" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Username</label>
|
<label class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Authentication Method</label>
|
||||||
<div class="relative">
|
<div class="flex items-center mb-4">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<input id="dest_auth_password" type="radio" value="password" name="dest_auth_type" x-model="destAuthType"
|
||||||
<i class="fas fa-user text-secondary-400 dark:text-secondary-600"></i>
|
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600">
|
||||||
</div>
|
<label for="dest_auth_password" class="ms-2 text-sm font-medium text-gray-900 dark:text-white">Password</label>
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="dest_user"
|
|
||||||
id="dest_user"
|
|
||||||
x-model="destUser"
|
|
||||||
required
|
|
||||||
aria-describedby="dest_user_help"
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"/>
|
|
||||||
</div>
|
</div>
|
||||||
<p id="dest_user_help" class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
<div class="flex items-center">
|
||||||
Username for SFTP authentication.
|
<input id="dest_auth_key" type="radio" value="key" name="dest_auth_type" x-model="destAuthType"
|
||||||
</p>
|
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600">
|
||||||
</div>
|
<label for="dest_auth_key" class="ms-2 text-sm font-medium text-gray-900 dark:text-white">SSH Key</label>
|
||||||
|
|
||||||
<div class="sm:col-span-4">
|
|
||||||
<label for="dest_auth_type" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Authentication Type</label>
|
|
||||||
<div class="relative">
|
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
|
||||||
<i class="fas fa-lock text-secondary-400 dark:text-secondary-600"></i>
|
|
||||||
</div>
|
|
||||||
<select
|
|
||||||
id="dest_auth_type"
|
|
||||||
name="dest_auth_type"
|
|
||||||
x-model="destAuthType"
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500">
|
|
||||||
<option value="password">Password</option>
|
|
||||||
<option value="key_file">SSH Key File</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sm:col-span-4" x-show="destAuthType === 'password'">
|
<div x-show="destAuthType === 'password'">
|
||||||
<label for="dest_password" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Password</label>
|
<label for="dest_password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Password</label>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
|
<i class="fas fa-lock text-gray-400 dark:text-gray-500"></i>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input type="password" id="dest_password" name="dest_password" x-model="destPassword"
|
||||||
type="password"
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
name="dest_password"
|
placeholder="Enter password" x-bind:required="destAuthType === 'password'" />
|
||||||
id="dest_password"
|
|
||||||
x-model="destPassword"
|
|
||||||
x-bind:required="destAuthType === 'password'"
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
placeholder="Password"/>
|
|
||||||
</div>
|
</div>
|
||||||
<input type="hidden" name="dest_password" :value="destPassword"/>
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">SFTP password for authentication</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sm:col-span-4" x-show="destAuthType === 'key_file'">
|
<div x-show="destAuthType === 'key'">
|
||||||
<label for="dest_key_file" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Key File</label>
|
<label for="dest_key_file" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">SSH Key File</label>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
<i class="fas fa-file-alt text-secondary-400 dark:text-secondary-600"></i>
|
<i class="fas fa-key text-gray-400 dark:text-gray-500"></i>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input type="text" id="dest_key_file" name="dest_key_file" x-model="destKeyFile"
|
||||||
type="text"
|
class="bg-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"
|
||||||
name="dest_key_file"
|
placeholder="/path/to/id_rsa" x-bind:required="destAuthType === 'key'" />
|
||||||
id="dest_key_file"
|
|
||||||
x-model="destKeyFile"
|
|
||||||
x-bind:required="destAuthType === 'key_file'"
|
|
||||||
aria-describedby="dest_key_file_help"
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
placeholder="/path/to/key"/>
|
|
||||||
</div>
|
</div>
|
||||||
<p id="dest_key_file_help" class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Path to the SSH private key file</p>
|
||||||
Absolute path to SSH private key file.
|
</div>
|
||||||
</p>
|
|
||||||
|
<div>
|
||||||
|
<label for="destination_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Remote Path</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
|
<i class="fas fa-folder-open text-gray-400 dark:text-gray-500"></i>
|
||||||
|
</div>
|
||||||
|
<input type="text" id="destination_path" name="destination_path" x-model="destinationPath" x-bind:required="requiresDestination"
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
placeholder="/remote/path/to/files" />
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Full path to the directory on the SFTP server</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -1,111 +1,112 @@
|
|||||||
package destination
|
package destination
|
||||||
|
|
||||||
templ SMBDestinationForm() {
|
templ SMBDestinationForm() {
|
||||||
<div class="sm:col-span-6 space-y-6">
|
<div class="space-y-6 mt-4">
|
||||||
<div class="sm:col-span-4">
|
<div class="p-4 mb-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900/30 dark:text-blue-300" role="alert">
|
||||||
<label for="dest_host" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Server</label>
|
<div class="flex items-center">
|
||||||
<div class="relative">
|
<i class="fas fa-info-circle mr-2"></i>
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<span>Configure your SMB/CIFS share connection details below. You'll need server address, share name, and credentials.</span>
|
||||||
<i class="fas fa-server text-secondary-400 dark:text-secondary-600"></i>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="dest_host"
|
|
||||||
id="dest_host"
|
|
||||||
x-model="destHost"
|
|
||||||
required
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
placeholder="server.example.com"/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sm:col-span-4">
|
<div class="mb-6">
|
||||||
<label for="dest_share" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Share Name</label>
|
<label for="dest_host" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Server Address</label>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
<i class="fas fa-share-alt text-secondary-400 dark:text-secondary-600"></i>
|
<i class="fas fa-server text-gray-400 dark:text-gray-500"></i>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input type="text" id="dest_host" name="dest_host" x-model="destHost" x-bind:required="requiresDestination"
|
||||||
type="text"
|
class="bg-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"
|
||||||
name="dest_share"
|
placeholder="192.168.1.100 or server.example.com" />
|
||||||
id="dest_share"
|
|
||||||
x-model="destShare"
|
|
||||||
required
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
placeholder="share_name"/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
IP address or hostname of the SMB/CIFS server
|
||||||
<div class="sm:col-span-4">
|
|
||||||
<label for="destination_path" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Path</label>
|
|
||||||
<div class="relative">
|
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
|
||||||
<i class="fas fa-folder text-secondary-400 dark:text-secondary-600"></i>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="destination_path"
|
|
||||||
id="destination_path"
|
|
||||||
x-model="destinationPath"
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
placeholder="path\to\destination"/>
|
|
||||||
</div>
|
|
||||||
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
|
||||||
Optional. Subdirectory within the share.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sm:col-span-4">
|
<div class="mb-6">
|
||||||
<label for="dest_domain" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Domain</label>
|
<label for="dest_share" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Share Name</label>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
<i class="fas fa-building text-secondary-400 dark:text-secondary-600"></i>
|
<i class="fas fa-share-alt text-gray-400 dark:text-gray-500"></i>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input type="text" id="dest_share" name="dest_share" x-model="destShare" x-bind:required="requiresDestination"
|
||||||
type="text"
|
class="bg-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"
|
||||||
name="dest_domain"
|
placeholder="Documents" />
|
||||||
id="dest_domain"
|
|
||||||
x-model="destDomain"
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
placeholder="DOMAIN"/>
|
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
Optional. Windows domain for authentication.
|
Name of the shared folder on the server (without leading slashes)
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sm:col-span-4">
|
<div class="mb-6">
|
||||||
<label for="dest_user" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Username</label>
|
<label for="dest_domain" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Domain (Optional)</label>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
<i class="fas fa-user text-secondary-400 dark:text-secondary-600"></i>
|
<i class="fas fa-sitemap text-gray-400 dark:text-gray-500"></i>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input type="text" id="dest_domain" name="dest_domain" x-model="destDomain"
|
||||||
type="text"
|
class="bg-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"
|
||||||
name="dest_user"
|
placeholder="WORKGROUP or DOMAIN" />
|
||||||
id="dest_user"
|
|
||||||
x-model="destUser"
|
|
||||||
required
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"/>
|
|
||||||
</div>
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Windows domain name (if applicable). Default is 'WORKGROUP' for standalone servers.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sm:col-span-4">
|
<div class="mb-6">
|
||||||
<label for="dest_password" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Password</label>
|
<label for="dest_user" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Username</label>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
|
<i class="fas fa-user text-gray-400 dark:text-gray-500"></i>
|
||||||
|
</div>
|
||||||
|
<input type="text" id="dest_user" name="dest_user" x-model="destUser" x-bind:required="requiresDestination"
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
placeholder="smb_username" />
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Username for SMB/CIFS authentication
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-6">
|
||||||
|
<label for="dest_password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Password</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
|
<i class="fas fa-lock text-gray-400 dark:text-gray-500"></i>
|
||||||
|
</div>
|
||||||
|
<input type="password" id="dest_password" name="dest_password" x-model="destPassword" x-bind:required="requiresDestination"
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
placeholder="SMB password" />
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Password for SMB/CIFS authentication
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-6">
|
||||||
|
<label for="destination_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Subfolder Path (Optional)</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
|
<i class="fas fa-folder-open text-gray-400 dark:text-gray-500"></i>
|
||||||
|
</div>
|
||||||
|
<input type="text" id="destination_path" name="destination_path" x-model="destinationPath"
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
placeholder="path/to/subfolder" />
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Path to a subfolder within the share (Optional). Use forward slashes.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-4 mb-4 text-sm text-yellow-800 rounded-lg bg-yellow-50 dark:bg-yellow-900/30 dark:text-yellow-300" role="alert">
|
||||||
|
<div class="flex">
|
||||||
|
<i class="fas fa-exclamation-triangle mr-2 flex-shrink-0"></i>
|
||||||
|
<div>
|
||||||
|
<h3 class="font-medium">Connection Note</h3>
|
||||||
|
<p class="mt-1">Make sure the SMB server is accessible from this machine and that the share permissions allow the specified user to read the files.</p>
|
||||||
</div>
|
</div>
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
name="dest_password"
|
|
||||||
id="dest_password"
|
|
||||||
x-model="destPassword"
|
|
||||||
required
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
placeholder="Password"/>
|
|
||||||
</div>
|
</div>
|
||||||
<input type="hidden" name="dest_password" :value="destPassword"/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
package destination
|
||||||
|
|
||||||
|
templ WasabiDestinationForm() {
|
||||||
|
<div class="space-y-6 mt-4">
|
||||||
|
<div class="p-4 mb-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900/30 dark:text-blue-300" role="alert">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<i class="fas fa-info-circle mr-2"></i>
|
||||||
|
<span>Configure your Wasabi Cloud Storage details below. You'll need your access key, secret key, region, and bucket information.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-6">
|
||||||
|
<label for="dest_region" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Wasabi Region</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
|
<i class="fas fa-globe text-gray-400 dark:text-gray-500"></i>
|
||||||
|
</div>
|
||||||
|
<input type="text" id="dest_region" name="dest_region" x-model="destRegion" x-bind:required="requiresDestination"
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
placeholder="us-east-1" />
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Wasabi region where your bucket is located (e.g., us-east-1, us-west-1, eu-central-1)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-6">
|
||||||
|
<label for="dest_bucket" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Bucket Name</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
|
<i class="fas fa-archive text-gray-400 dark:text-gray-500"></i>
|
||||||
|
</div>
|
||||||
|
<input type="text" id="dest_bucket" name="dest_bucket" x-model="destBucket" x-bind:required="requiresDestination"
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
placeholder="my-wasabi-bucket" />
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Name of your Wasabi bucket (case-sensitive)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-6">
|
||||||
|
<label for="dest_access_key" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Access Key</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
|
<i class="fas fa-key text-gray-400 dark:text-gray-500"></i>
|
||||||
|
</div>
|
||||||
|
<input type="text" id="dest_access_key" name="dest_access_key" x-model="destAccessKey" x-bind:required="requiresDestination"
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
placeholder="ABCDEFGHIJKLMNOPQRST" />
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Your Wasabi Access Key
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-6">
|
||||||
|
<label for="dest_secret_key" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Secret Key</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
|
<i class="fas fa-lock text-gray-400 dark:text-gray-500"></i>
|
||||||
|
</div>
|
||||||
|
<input type="password" id="dest_secret_key" name="dest_secret_key" x-model="destSecretKey" x-bind:required="requiresDestination"
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
placeholder="abcdefghijklmnopqrstuvwxyz1234567890ABCD" />
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Your Wasabi Secret Key
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-6">
|
||||||
|
<label for="dest_endpoint" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Endpoint (Optional)</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
|
<i class="fas fa-server text-gray-400 dark:text-gray-500"></i>
|
||||||
|
</div>
|
||||||
|
<input type="text" id="dest_endpoint" name="dest_endpoint" x-model="destEndpoint"
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
placeholder="s3.wasabisys.com" />
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Wasabi endpoint URL (usually s3.wasabisys.com or region-specific endpoints)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-6">
|
||||||
|
<label for="destination_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Path in Bucket</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
|
<i class="fas fa-folder-open text-gray-400 dark:text-gray-500"></i>
|
||||||
|
</div>
|
||||||
|
<input type="text" id="destination_path" name="destination_path" x-model="destinationPath"
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
placeholder="path/to/files/" />
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Path prefix within the bucket (e.g., "backups/"). Leave empty to access the entire bucket.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-4 mb-4 text-sm text-yellow-800 rounded-lg bg-yellow-50 dark:bg-yellow-900/30 dark:text-yellow-300" role="alert">
|
||||||
|
<div class="flex">
|
||||||
|
<i class="fas fa-shield-alt mr-2 flex-shrink-0"></i>
|
||||||
|
<div>
|
||||||
|
<h3 class="font-medium">Security Note</h3>
|
||||||
|
<p class="mt-1">It's recommended to use an IAM user with restricted permissions for this configuration. The IAM user should only have access to the specific Wasabi bucket and operations needed.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@@ -1,75 +1,82 @@
|
|||||||
package destination
|
package destination
|
||||||
|
|
||||||
templ WebDAVDestinationForm() {
|
templ WebDAVDestinationForm() {
|
||||||
<div class="sm:col-span-6 space-y-6">
|
<div class="space-y-6 mt-4">
|
||||||
<div class="sm:col-span-4">
|
<div class="p-4 mb-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900/30 dark:text-blue-300" role="alert">
|
||||||
<label for="dest_host" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">WebDAV URL</label>
|
<div class="flex items-center">
|
||||||
<div class="relative">
|
<i class="fas fa-info-circle mr-2"></i>
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<span>Configure your WebDAV connection details below. You'll need the server URL, username, and password.</span>
|
||||||
<i class="fas fa-globe text-secondary-400 dark:text-secondary-600"></i>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="dest_host"
|
|
||||||
id="dest_host"
|
|
||||||
x-model="destHost"
|
|
||||||
required
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
placeholder="https://webdav.example.com/remote.php/webdav/"/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sm:col-span-4">
|
<div class="mb-6">
|
||||||
<label for="destination_path" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Remote Path</label>
|
<label for="dest_host" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">WebDAV URL</label>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
<i class="fas fa-folder text-secondary-400 dark:text-secondary-600"></i>
|
<i class="fas fa-globe text-gray-400 dark:text-gray-500"></i>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input type="text" id="dest_host" name="dest_host" x-model="destHost" x-bind:required="requiresDestination"
|
||||||
type="text"
|
class="bg-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"
|
||||||
name="destination_path"
|
placeholder="https://webdav.example.com" />
|
||||||
id="destination_path"
|
|
||||||
x-model="destinationPath"
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
placeholder="/path/to/destination"/>
|
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
Optional. Relative to the WebDAV URL.
|
Full URL to your WebDAV server including protocol (https://)
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sm:col-span-4">
|
<div class="mb-6">
|
||||||
<label for="dest_user" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Username</label>
|
<label for="dest_user" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Username</label>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
<i class="fas fa-user text-secondary-400 dark:text-secondary-600"></i>
|
<i class="fas fa-user text-gray-400 dark:text-gray-500"></i>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input type="text" id="dest_user" name="dest_user" x-model="destUser" x-bind:required="requiresDestination"
|
||||||
type="text"
|
class="bg-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"
|
||||||
name="dest_user"
|
placeholder="username" />
|
||||||
id="dest_user"
|
|
||||||
x-model="destUser"
|
|
||||||
required
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"/>
|
|
||||||
</div>
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Your WebDAV username
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sm:col-span-4">
|
<div class="mb-6">
|
||||||
<label for="dest_password" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Password</label>
|
<label for="dest_password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Password</label>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
|
<i class="fas fa-lock text-gray-400 dark:text-gray-500"></i>
|
||||||
|
</div>
|
||||||
|
<input type="password" id="dest_password" name="dest_password" x-model="destPassword" x-bind:required="requiresDestination"
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
placeholder="password" />
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Your WebDAV account password
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-6">
|
||||||
|
<label for="destination_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Remote Path</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
|
<i class="fas fa-folder-open text-gray-400 dark:text-gray-500"></i>
|
||||||
|
</div>
|
||||||
|
<input type="text" id="destination_path" name="destination_path" x-model="destinationPath"
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
placeholder="/path/to/files" />
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Path to your files on the WebDAV server
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-4 mb-4 text-sm text-yellow-800 rounded-lg bg-yellow-50 dark:bg-yellow-900/30 dark:text-yellow-300" role="alert">
|
||||||
|
<div class="flex">
|
||||||
|
<i class="fas fa-lightbulb mr-2 flex-shrink-0"></i>
|
||||||
|
<div>
|
||||||
|
<h3 class="font-medium">Integration Note</h3>
|
||||||
|
<p class="mt-1">WebDAV is supported by many services including ownCloud, Nextcloud, Box, and many others. Make sure your WebDAV URL includes the full path to the WebDAV interface.</p>
|
||||||
</div>
|
</div>
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
name="dest_password"
|
|
||||||
id="dest_password"
|
|
||||||
x-model="destPassword"
|
|
||||||
required
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
placeholder="Password"/>
|
|
||||||
</div>
|
</div>
|
||||||
<input type="hidden" name="dest_password" :value="destPassword"/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -1,191 +0,0 @@
|
|||||||
package providers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/starfleetcptn/gomft/components/providers/common"
|
|
||||||
"github.com/starfleetcptn/gomft/components/providers/source"
|
|
||||||
"github.com/starfleetcptn/gomft/components/providers/destination"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Returns the form ID based on the form type and whether it's a source or destination
|
|
||||||
func formID(formType string, isSource bool) string {
|
|
||||||
if isSource {
|
|
||||||
return "source_config_form"
|
|
||||||
}
|
|
||||||
return "destination_config_form"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Returns a user-friendly display name for the provider
|
|
||||||
func providerDisplayName(provider string) string {
|
|
||||||
switch provider {
|
|
||||||
case "sftp":
|
|
||||||
return "SFTP"
|
|
||||||
case "local":
|
|
||||||
return "Local Filesystem"
|
|
||||||
case "s3":
|
|
||||||
return "Amazon S3"
|
|
||||||
case "ftp":
|
|
||||||
return "FTP"
|
|
||||||
case "azure":
|
|
||||||
return "Azure Blob Storage"
|
|
||||||
default:
|
|
||||||
return strings.Title(provider)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
templ ProviderForm(formType string, providers []string, isSource bool) {
|
|
||||||
<form
|
|
||||||
id={formID(formType, isSource)}
|
|
||||||
x-data={fmt.Sprintf("{ %sProvider: '', showAdvanced: false }", formType)}
|
|
||||||
class="space-y-8">
|
|
||||||
|
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-12 gap-y-6 gap-x-4">
|
|
||||||
@common.NameField()
|
|
||||||
|
|
||||||
<div class="sm:col-span-4">
|
|
||||||
<label for={fmt.Sprintf("%s_provider", formType)} class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Provider Type</label>
|
|
||||||
<div class="relative">
|
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
|
||||||
<i class="fas fa-server text-secondary-400 dark:text-secondary-600"></i>
|
|
||||||
</div>
|
|
||||||
<select
|
|
||||||
id={fmt.Sprintf("%s_provider", formType)}
|
|
||||||
name={fmt.Sprintf("%s_provider", formType)}
|
|
||||||
x-model={fmt.Sprintf("%sProvider", formType)}
|
|
||||||
class="form-select pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500">
|
|
||||||
<option value="" disabled selected>Select provider type</option>
|
|
||||||
for _, provider := range providers {
|
|
||||||
<option value={provider}>{providerDisplayName(provider)}</option>
|
|
||||||
}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="sm:col-span-6" x-show={fmt.Sprintf("%sProvider === 'sftp'", formType)}>
|
|
||||||
if isSource {
|
|
||||||
@source.SFTPSourceForm()
|
|
||||||
} else {
|
|
||||||
@destination.SFTPDestinationForm()
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="sm:col-span-6" x-show={fmt.Sprintf("%sProvider === 'local'", formType)}>
|
|
||||||
if isSource {
|
|
||||||
@source.LocalSourceForm()
|
|
||||||
} else {
|
|
||||||
@destination.LocalDestinationForm()
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="sm:col-span-6" x-show={fmt.Sprintf("%sProvider === 's3'", formType)}>
|
|
||||||
if isSource {
|
|
||||||
@source.S3SourceForm()
|
|
||||||
} else {
|
|
||||||
@destination.S3DestinationForm()
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="sm:col-span-6" x-show={fmt.Sprintf("%sProvider === 'ftp'", formType)}>
|
|
||||||
if isSource {
|
|
||||||
@source.FTPSourceForm()
|
|
||||||
} else {
|
|
||||||
@destination.FTPDestinationForm()
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="sm:col-span-12" x-show={fmt.Sprintf("%sProvider", formType)}>
|
|
||||||
<div class="mt-6">
|
|
||||||
<label for="show_advanced" class="flex items-center cursor-pointer">
|
|
||||||
<div class="relative">
|
|
||||||
<input id="show_advanced" type="checkbox" x-model="showAdvanced" class="sr-only" />
|
|
||||||
<div class="block bg-gray-200 w-14 h-8 rounded-full"></div>
|
|
||||||
<div class="dot absolute left-1 top-1 bg-white w-6 h-6 rounded-full transition"
|
|
||||||
:class="showAdvanced ? 'transform translate-x-6 bg-primary-500' : ''"></div>
|
|
||||||
</div>
|
|
||||||
<div class="ml-3 text-gray-700 font-medium">
|
|
||||||
Show Advanced Options
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div x-show="showAdvanced">
|
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-12 gap-y-6 gap-x-4 mt-6">
|
|
||||||
@common.FilePatternFields()
|
|
||||||
if isSource {
|
|
||||||
@common.ArchiveOptions()
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
}
|
|
||||||
|
|
||||||
script formAlpineInit() {
|
|
||||||
return {
|
|
||||||
initProviderForm() {
|
|
||||||
// Initialize with values if editing existing config
|
|
||||||
if (window.editData && window.editData.configs) {
|
|
||||||
const config = window.editData.configs.find(c =>
|
|
||||||
isSource ? (c.id === window.editData.source_config_id) : (c.id === window.editData.destination_config_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (config) {
|
|
||||||
this[formType + 'Provider'] = config.provider;
|
|
||||||
this.name = config.name;
|
|
||||||
|
|
||||||
// Provider-specific fields
|
|
||||||
if (config.provider === 'sftp') {
|
|
||||||
this.host = config.host;
|
|
||||||
this.port = config.port;
|
|
||||||
this.username = config.username;
|
|
||||||
this.path = config.path;
|
|
||||||
|
|
||||||
if (config.key_file && config.key_file !== '') {
|
|
||||||
this.authType = 'key_file';
|
|
||||||
this.keyFile = config.key_file;
|
|
||||||
} else {
|
|
||||||
this.authType = 'password';
|
|
||||||
// Password is not included in edit data for security
|
|
||||||
}
|
|
||||||
} else if (config.provider === 'local') {
|
|
||||||
this.path = config.path;
|
|
||||||
} else if (config.provider === 's3') {
|
|
||||||
this.bucket = config.bucket;
|
|
||||||
this.region = config.region;
|
|
||||||
this.path = config.path;
|
|
||||||
this.accessKey = config.access_key;
|
|
||||||
|
|
||||||
if (config.endpoint && config.endpoint !== '') {
|
|
||||||
this.useCustomEndpoint = true;
|
|
||||||
this.endpoint = config.endpoint;
|
|
||||||
} else {
|
|
||||||
this.useCustomEndpoint = false;
|
|
||||||
}
|
|
||||||
} else if (config.provider === 'ftp') {
|
|
||||||
this.host = config.host;
|
|
||||||
this.port = config.port;
|
|
||||||
this.username = config.username;
|
|
||||||
this.path = config.path;
|
|
||||||
this.useFTPS = config.use_ftps;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Advanced options
|
|
||||||
if (config.include_pattern) this.filePattern = config.include_pattern;
|
|
||||||
if (config.exclude_pattern) this.excludePattern = config.exclude_pattern;
|
|
||||||
|
|
||||||
if (isSource && config.extract_archives) {
|
|
||||||
this.extractArchives = true;
|
|
||||||
this.deleteArchives = config.delete_archives;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
providerChanged() {
|
|
||||||
console.log("Provider changed to: " + this[formType + 'Provider']);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,367 +0,0 @@
|
|||||||
package providers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/starfleetcptn/gomft/components/providers/common"
|
|
||||||
"github.com/starfleetcptn/gomft/components/providers/destination"
|
|
||||||
"github.com/starfleetcptn/gomft/components/providers/source"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Test that both source and destination providers can be rendered together with common components
|
|
||||||
func TestProvidersIntegration(t *testing.T) {
|
|
||||||
// Create context for test
|
|
||||||
ctx := context.Background()
|
|
||||||
assert := assert.New(t)
|
|
||||||
|
|
||||||
// Test rendering common components
|
|
||||||
{
|
|
||||||
var buf strings.Builder
|
|
||||||
err := common.NameField().Render(ctx, &buf)
|
|
||||||
assert.NoError(err, "Failed to render NameField")
|
|
||||||
html := buf.String()
|
|
||||||
assert.Contains(html, `<label for="name"`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test rendering source components
|
|
||||||
{
|
|
||||||
var buf strings.Builder
|
|
||||||
err := source.LocalSourceForm().Render(ctx, &buf)
|
|
||||||
assert.NoError(err, "Failed to render LocalSourceForm")
|
|
||||||
html := buf.String()
|
|
||||||
assert.Contains(html, `<label for="source_path"`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test rendering destination components
|
|
||||||
{
|
|
||||||
var buf strings.Builder
|
|
||||||
err := destination.LocalDestinationForm().Render(ctx, &buf)
|
|
||||||
assert.NoError(err, "Failed to render LocalDestinationForm")
|
|
||||||
html := buf.String()
|
|
||||||
assert.Contains(html, `<label for="destination_path"`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test that all source providers are available
|
|
||||||
func TestSourceProviders(t *testing.T) {
|
|
||||||
// Create context for test
|
|
||||||
ctx := context.Background()
|
|
||||||
assert := assert.New(t)
|
|
||||||
|
|
||||||
// Test each source provider can be rendered
|
|
||||||
providers := []struct {
|
|
||||||
name string
|
|
||||||
template func() (string, error)
|
|
||||||
}{
|
|
||||||
{"LocalSourceForm", func() (string, error) {
|
|
||||||
var buf strings.Builder
|
|
||||||
err := source.LocalSourceForm().Render(ctx, &buf)
|
|
||||||
return buf.String(), err
|
|
||||||
}},
|
|
||||||
{"SFTPSourceForm", func() (string, error) {
|
|
||||||
var buf strings.Builder
|
|
||||||
err := source.SFTPSourceForm().Render(ctx, &buf)
|
|
||||||
return buf.String(), err
|
|
||||||
}},
|
|
||||||
{"S3SourceForm", func() (string, error) {
|
|
||||||
var buf strings.Builder
|
|
||||||
err := source.S3SourceForm().Render(ctx, &buf)
|
|
||||||
return buf.String(), err
|
|
||||||
}},
|
|
||||||
{"FTPSourceForm", func() (string, error) {
|
|
||||||
var buf strings.Builder
|
|
||||||
err := source.FTPSourceForm().Render(ctx, &buf)
|
|
||||||
return buf.String(), err
|
|
||||||
}},
|
|
||||||
{"SMBSourceForm", func() (string, error) {
|
|
||||||
var buf strings.Builder
|
|
||||||
err := source.SMBSourceForm().Render(ctx, &buf)
|
|
||||||
return buf.String(), err
|
|
||||||
}},
|
|
||||||
{"WebDAVSourceForm", func() (string, error) {
|
|
||||||
var buf strings.Builder
|
|
||||||
err := source.WebDAVSourceForm().Render(ctx, &buf)
|
|
||||||
return buf.String(), err
|
|
||||||
}},
|
|
||||||
{"GoogleDriveSourceForm", func() (string, error) {
|
|
||||||
var buf strings.Builder
|
|
||||||
err := source.GoogleDriveSourceForm().Render(ctx, &buf)
|
|
||||||
return buf.String(), err
|
|
||||||
}},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, provider := range providers {
|
|
||||||
t.Run(provider.name, func(t *testing.T) {
|
|
||||||
html, err := provider.template()
|
|
||||||
assert.NoError(err, "Failed to render "+provider.name)
|
|
||||||
assert.NotEmpty(html, provider.name+" rendered empty HTML")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test that all destination providers are available
|
|
||||||
func TestDestinationProviders(t *testing.T) {
|
|
||||||
// Create context for test
|
|
||||||
ctx := context.Background()
|
|
||||||
assert := assert.New(t)
|
|
||||||
|
|
||||||
// Test each destination provider can be rendered
|
|
||||||
providers := []struct {
|
|
||||||
name string
|
|
||||||
template func() (string, error)
|
|
||||||
}{
|
|
||||||
{"LocalDestinationForm", func() (string, error) {
|
|
||||||
var buf strings.Builder
|
|
||||||
err := destination.LocalDestinationForm().Render(ctx, &buf)
|
|
||||||
return buf.String(), err
|
|
||||||
}},
|
|
||||||
{"SFTPDestinationForm", func() (string, error) {
|
|
||||||
var buf strings.Builder
|
|
||||||
err := destination.SFTPDestinationForm().Render(ctx, &buf)
|
|
||||||
return buf.String(), err
|
|
||||||
}},
|
|
||||||
{"S3DestinationForm", func() (string, error) {
|
|
||||||
var buf strings.Builder
|
|
||||||
err := destination.S3DestinationForm().Render(ctx, &buf)
|
|
||||||
return buf.String(), err
|
|
||||||
}},
|
|
||||||
{"FTPDestinationForm", func() (string, error) {
|
|
||||||
var buf strings.Builder
|
|
||||||
err := destination.FTPDestinationForm().Render(ctx, &buf)
|
|
||||||
return buf.String(), err
|
|
||||||
}},
|
|
||||||
{"SMBDestinationForm", func() (string, error) {
|
|
||||||
var buf strings.Builder
|
|
||||||
err := destination.SMBDestinationForm().Render(ctx, &buf)
|
|
||||||
return buf.String(), err
|
|
||||||
}},
|
|
||||||
{"WebDAVDestinationForm", func() (string, error) {
|
|
||||||
var buf strings.Builder
|
|
||||||
err := destination.WebDAVDestinationForm().Render(ctx, &buf)
|
|
||||||
return buf.String(), err
|
|
||||||
}},
|
|
||||||
{"GoogleDriveDestinationForm", func() (string, error) {
|
|
||||||
var buf strings.Builder
|
|
||||||
err := destination.GoogleDriveDestinationForm().Render(ctx, &buf)
|
|
||||||
return buf.String(), err
|
|
||||||
}},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, provider := range providers {
|
|
||||||
t.Run(provider.name, func(t *testing.T) {
|
|
||||||
html, err := provider.template()
|
|
||||||
assert.NoError(err, "Failed to render "+provider.name)
|
|
||||||
assert.NotEmpty(html, provider.name+" rendered empty HTML")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test the complete configuration wizard flow
|
|
||||||
func TestConfigurationWizard(t *testing.T) {
|
|
||||||
// Create context for test
|
|
||||||
ctx := context.Background()
|
|
||||||
assert := assert.New(t)
|
|
||||||
|
|
||||||
// First test common configuration fields
|
|
||||||
var buf strings.Builder
|
|
||||||
err := common.NameField().Render(ctx, &buf)
|
|
||||||
assert.NoError(err, "Failed to render name field")
|
|
||||||
nameField := buf.String()
|
|
||||||
assert.Contains(nameField, `<input type="text" name="name" id="name"`)
|
|
||||||
|
|
||||||
// Test source selection
|
|
||||||
buf.Reset()
|
|
||||||
err = common.SourceSelection().Render(ctx, &buf)
|
|
||||||
assert.NoError(err, "Failed to render source selection")
|
|
||||||
sourceSelection := buf.String()
|
|
||||||
assert.Contains(sourceSelection, `<select id="source_type" name="source_type"`)
|
|
||||||
|
|
||||||
// Test specific source form (local example)
|
|
||||||
buf.Reset()
|
|
||||||
err = source.LocalSourceForm().Render(ctx, &buf)
|
|
||||||
assert.NoError(err, "Failed to render local source form")
|
|
||||||
localSource := buf.String()
|
|
||||||
assert.Contains(localSource, `<input type="text" name="source_path" id="source_path"`)
|
|
||||||
|
|
||||||
// Test destination selection
|
|
||||||
buf.Reset()
|
|
||||||
err = common.DestinationSelection().Render(ctx, &buf)
|
|
||||||
assert.NoError(err, "Failed to render destination selection")
|
|
||||||
destinationSelection := buf.String()
|
|
||||||
assert.Contains(destinationSelection, `<select id="destination_type" name="destination_type"`)
|
|
||||||
|
|
||||||
// Test specific destination form (S3 example)
|
|
||||||
buf.Reset()
|
|
||||||
err = destination.S3DestinationForm().Render(ctx, &buf)
|
|
||||||
assert.NoError(err, "Failed to render S3 destination form")
|
|
||||||
s3Destination := buf.String()
|
|
||||||
assert.Contains(s3Destination, `<input type="text" name="dest_bucket" id="dest_bucket"`)
|
|
||||||
|
|
||||||
// Test advanced options
|
|
||||||
buf.Reset()
|
|
||||||
err = common.ArchiveOptions().Render(ctx, &buf)
|
|
||||||
assert.NoError(err, "Failed to render archive options")
|
|
||||||
archiveOptions := buf.String()
|
|
||||||
assert.Contains(archiveOptions, `Enable archiving`)
|
|
||||||
|
|
||||||
buf.Reset()
|
|
||||||
err = common.FilePatternFields().Render(ctx, &buf)
|
|
||||||
assert.NoError(err, "Failed to render file pattern fields")
|
|
||||||
filePatterns := buf.String()
|
|
||||||
assert.Contains(filePatterns, `<input type="text" name="file_pattern" id="file_pattern"`)
|
|
||||||
|
|
||||||
// All essential components for the configuration wizard are present and renderable
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test that provider forms have proper conditional logic
|
|
||||||
func TestProviderFormConditionals(t *testing.T) {
|
|
||||||
// Create context for test
|
|
||||||
ctx := context.Background()
|
|
||||||
assert := assert.New(t)
|
|
||||||
|
|
||||||
// Test SFTP Source form conditionals (password vs key file)
|
|
||||||
{
|
|
||||||
var buf strings.Builder
|
|
||||||
err := source.SFTPSourceForm().Render(ctx, &buf)
|
|
||||||
assert.NoError(err, "Failed to render SFTP source form")
|
|
||||||
html := buf.String()
|
|
||||||
|
|
||||||
// Should have auth type selection
|
|
||||||
assert.Contains(html, `<select id="source_auth_type" name="source_auth_type"`)
|
|
||||||
|
|
||||||
// Should have password field that's conditionally shown
|
|
||||||
assert.Contains(html, `x-show="sourceAuthType === 'password'"`)
|
|
||||||
assert.Contains(html, `<input type="password" name="source_password"`)
|
|
||||||
|
|
||||||
// Should have key file field that's conditionally shown
|
|
||||||
assert.Contains(html, `x-show="sourceAuthType === 'key_file'"`)
|
|
||||||
assert.Contains(html, `<input type="text" name="source_key_file"`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test S3 Source form conditionals
|
|
||||||
{
|
|
||||||
var buf strings.Builder
|
|
||||||
err := source.S3SourceForm().Render(ctx, &buf)
|
|
||||||
assert.NoError(err, "Failed to render S3 source form")
|
|
||||||
html := buf.String()
|
|
||||||
|
|
||||||
// Should have both required and optional fields
|
|
||||||
assert.Contains(html, `<input type="text" name="source_bucket" id="source_bucket" x-model="sourceBucket" required`)
|
|
||||||
assert.Contains(html, `<input type="text" name="source_region" id="source_region"`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test advanced options show/hide behavior
|
|
||||||
{
|
|
||||||
var buf strings.Builder
|
|
||||||
err := common.ArchiveOptions().Render(ctx, &buf)
|
|
||||||
assert.NoError(err, "Failed to render archive options")
|
|
||||||
html := buf.String()
|
|
||||||
|
|
||||||
// Archive path should only show when archive is enabled
|
|
||||||
assert.Contains(html, `x-show="archiveEnabled"`)
|
|
||||||
assert.Contains(html, `<input id="archive_path" name="archive_path" type="text"`)
|
|
||||||
|
|
||||||
// Toggle behavior
|
|
||||||
assert.Contains(html, `x-model="archiveEnabled"`)
|
|
||||||
assert.Contains(html, `<input id="archive_enabled" name="archive_enabled" type="checkbox"`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test for accessibility attributes in provider forms
|
|
||||||
func TestProviderFormsAccessibility(t *testing.T) {
|
|
||||||
// Create context for test
|
|
||||||
ctx := context.Background()
|
|
||||||
assert := assert.New(t)
|
|
||||||
|
|
||||||
// Test source form for accessibility
|
|
||||||
{
|
|
||||||
var buf strings.Builder
|
|
||||||
err := source.LocalSourceForm().Render(ctx, &buf)
|
|
||||||
assert.NoError(err, "Failed to render local source form")
|
|
||||||
html := buf.String()
|
|
||||||
|
|
||||||
// Should have labels with proper for attributes
|
|
||||||
assert.Contains(html, `<label for="source_path"`)
|
|
||||||
|
|
||||||
// Should have input with id matching label's for attribute
|
|
||||||
assert.Contains(html, `<input type="text" name="source_path" id="source_path"`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test destination form for accessibility
|
|
||||||
{
|
|
||||||
var buf strings.Builder
|
|
||||||
err := destination.LocalDestinationForm().Render(ctx, &buf)
|
|
||||||
assert.NoError(err, "Failed to render local destination form")
|
|
||||||
html := buf.String()
|
|
||||||
|
|
||||||
// Should have labels with proper for attributes
|
|
||||||
assert.Contains(html, `<label for="destination_path"`)
|
|
||||||
|
|
||||||
// Should have input with id matching label's for attribute
|
|
||||||
assert.Contains(html, `<input type="text" name="destination_path" id="destination_path"`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test dynamic form rendering based on provider selection
|
|
||||||
func TestDynamicFormRendering(t *testing.T) {
|
|
||||||
// Create context for test
|
|
||||||
ctx := context.Background()
|
|
||||||
assert := assert.New(t)
|
|
||||||
|
|
||||||
// Test source selection dynamic rendering
|
|
||||||
{
|
|
||||||
var buf strings.Builder
|
|
||||||
err := common.SourceSelection().Render(ctx, &buf)
|
|
||||||
assert.NoError(err, "Failed to render source selection")
|
|
||||||
html := buf.String()
|
|
||||||
|
|
||||||
// Should have x-model for binding selected value
|
|
||||||
assert.Contains(html, `x-model="sourceType"`)
|
|
||||||
|
|
||||||
// The source selection component doesn't contain x-show attributes
|
|
||||||
// These assertions are removed as they're not part of the actual component
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test destination selection dynamic rendering
|
|
||||||
{
|
|
||||||
var buf strings.Builder
|
|
||||||
err := common.DestinationSelection().Render(ctx, &buf)
|
|
||||||
assert.NoError(err, "Failed to render destination selection")
|
|
||||||
html := buf.String()
|
|
||||||
|
|
||||||
// Should have x-model for binding selected value
|
|
||||||
assert.Contains(html, `x-model="destinationType"`)
|
|
||||||
|
|
||||||
// The destination selection component doesn't contain x-show attributes
|
|
||||||
// These assertions are removed as they're not part of the actual component
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test for proper Alpine.js initialization
|
|
||||||
{
|
|
||||||
var buf strings.Builder
|
|
||||||
err := source.LocalSourceForm().Render(ctx, &buf)
|
|
||||||
assert.NoError(err)
|
|
||||||
html := buf.String()
|
|
||||||
|
|
||||||
// The LocalSourceForm doesn't initialize Alpine.js data
|
|
||||||
// It's expected to be used within a parent component that does
|
|
||||||
assert.Contains(html, `x-model="sourcePath"`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test that wizard has a submission handler
|
|
||||||
{
|
|
||||||
var buf strings.Builder
|
|
||||||
// The source selection component doesn't contain form tags
|
|
||||||
// These assertions are checking for elements that should be in a parent component
|
|
||||||
err := common.SourceSelection().Render(ctx, &buf)
|
|
||||||
assert.NoError(err)
|
|
||||||
html := buf.String()
|
|
||||||
|
|
||||||
// Check for the select element instead
|
|
||||||
assert.Contains(html, `<select id="source_type" name="source_type"`)
|
|
||||||
assert.Contains(html, `x-model="sourceType"`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package source
|
||||||
|
|
||||||
|
templ B2SourceForm() {
|
||||||
|
<div class="space-y-6 mt-4">
|
||||||
|
<div class="p-4 mb-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900/30 dark:text-blue-300" role="alert">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<i class="fas fa-info-circle mr-2"></i>
|
||||||
|
<span>Configure your Backblaze B2 details below. You'll need your account ID, application key, and bucket information.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-6">
|
||||||
|
<label for="source_bucket" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Bucket Name</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
|
<i class="fas fa-archive text-gray-400 dark:text-gray-500"></i>
|
||||||
|
</div>
|
||||||
|
<input type="text" id="source_bucket" name="source_bucket" x-model="sourceBucket" required
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
placeholder="my-b2-bucket" />
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Name of your Backblaze B2 bucket (case-sensitive)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-6">
|
||||||
|
<label for="source_access_key" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Account ID</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
|
<i class="fas fa-id-card text-gray-400 dark:text-gray-500"></i>
|
||||||
|
</div>
|
||||||
|
<input type="text" id="source_access_key" name="source_access_key" x-model="sourceAccessKey" required
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
placeholder="0123456789abcdef0123456789" />
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Your Backblaze B2 Account ID
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-6">
|
||||||
|
<label for="source_secret_key" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Application Key</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
|
<i class="fas fa-lock text-gray-400 dark:text-gray-500"></i>
|
||||||
|
</div>
|
||||||
|
<input type="password" id="source_secret_key" name="source_secret_key" x-model="sourceSecretKey" required
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
placeholder="K0123456789abcdef0123456789abcdef0123456789" />
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Your Backblaze B2 Application Key
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-6">
|
||||||
|
<label for="source_endpoint" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Endpoint (Optional)</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
|
<i class="fas fa-server text-gray-400 dark:text-gray-500"></i>
|
||||||
|
</div>
|
||||||
|
<input type="text" id="source_endpoint" name="source_endpoint" x-model="sourceEndpoint"
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
placeholder="s3.us-west-000.backblazeb2.com" />
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Custom endpoint URL (only needed for non-standard regions or private endpoints)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-6">
|
||||||
|
<label for="source_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Path in Bucket</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
|
<i class="fas fa-folder-open text-gray-400 dark:text-gray-500"></i>
|
||||||
|
</div>
|
||||||
|
<input type="text" id="source_path" name="source_path" x-model="sourcePath"
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
placeholder="path/to/files/" />
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Path prefix within the bucket (e.g., "backups/"). Leave empty to access the entire bucket.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-4 mb-4 text-sm text-yellow-800 rounded-lg bg-yellow-50 dark:bg-yellow-900/30 dark:text-yellow-300" role="alert">
|
||||||
|
<div class="flex">
|
||||||
|
<i class="fas fa-shield-alt mr-2 flex-shrink-0"></i>
|
||||||
|
<div>
|
||||||
|
<h3 class="font-medium">Security Note</h3>
|
||||||
|
<p class="mt-1">It's recommended to create an application key with restricted permissions for this configuration. The application key should only have access to the specific B2 bucket and operations needed.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@@ -1,109 +1,79 @@
|
|||||||
package source
|
package source
|
||||||
|
|
||||||
templ FTPSourceForm() {
|
templ FTPSourceForm() {
|
||||||
<div class="sm:col-span-6 space-y-6">
|
<div class="space-y-6 mt-4">
|
||||||
<div class="sm:col-span-4">
|
<div>
|
||||||
<label for="source_host" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Host</label>
|
<label for="source_host" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">FTP Host</label>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
<i class="fas fa-server text-secondary-400 dark:text-secondary-600"></i>
|
<i class="fas fa-server text-gray-400 dark:text-gray-500"></i>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input type="text" id="source_host" name="source_host" x-model="sourceHost" required
|
||||||
type="text"
|
class="bg-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"
|
||||||
name="source_host"
|
placeholder="ftp.example.com" />
|
||||||
id="source_host"
|
|
||||||
x-model="sourceHost"
|
|
||||||
required
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
placeholder="ftp.example.com"/>
|
|
||||||
</div>
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Hostname or IP address of the FTP server</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sm:col-span-4">
|
<div>
|
||||||
<label for="source_port" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Port</label>
|
<label for="source_port" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Port</label>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
<i class="fas fa-plug text-secondary-400 dark:text-secondary-600"></i>
|
<i class="fas fa-network-wired text-gray-400 dark:text-gray-500"></i>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input type="number" id="source_port" name="source_port" x-model="sourcePort"
|
||||||
type="number"
|
class="bg-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"
|
||||||
name="source_port"
|
placeholder="21" min="1" max="65535" value="21"/>
|
||||||
id="source_port"
|
|
||||||
x-model="sourcePort"
|
|
||||||
required
|
|
||||||
min="1"
|
|
||||||
max="65535"
|
|
||||||
value="21"
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
placeholder="21"/>
|
|
||||||
</div>
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">FTP port number (default: 21)</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sm:col-span-4">
|
<div>
|
||||||
<label for="source_path" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Remote Path</label>
|
<label for="source_user" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Username</label>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
<i class="fas fa-folder text-secondary-400 dark:text-secondary-600"></i>
|
<i class="fas fa-user text-gray-400 dark:text-gray-500"></i>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input type="text" id="source_user" name="source_user" x-model="sourceUser" required
|
||||||
type="text"
|
class="bg-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"
|
||||||
name="source_path"
|
placeholder="username" />
|
||||||
id="source_path"
|
|
||||||
x-model="sourcePath"
|
|
||||||
required
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
placeholder="/path/to/files"/>
|
|
||||||
</div>
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">FTP username for authentication</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sm:col-span-4">
|
<div>
|
||||||
<label for="source_user" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Username</label>
|
<label for="source_password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Password</label>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
<i class="fas fa-user text-secondary-400 dark:text-secondary-600"></i>
|
<i class="fas fa-lock text-gray-400 dark:text-gray-500"></i>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input type="password" id="source_password" name="source_password" x-model="sourcePassword" required
|
||||||
type="text"
|
class="bg-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"
|
||||||
name="source_user"
|
placeholder="Enter password" />
|
||||||
id="source_user"
|
|
||||||
x-model="sourceUser"
|
|
||||||
required
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"/>
|
|
||||||
</div>
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">FTP password for authentication</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sm:col-span-4">
|
<div class="flex items-center mb-6">
|
||||||
<label for="source_password" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Password</label>
|
<label class="relative inline-flex items-center cursor-pointer">
|
||||||
<div class="relative">
|
<input type="checkbox" id="source_passive_mode" name="source_passive_mode" x-model="sourcePassiveMode"
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
class="sr-only peer" :value="sourcePassiveMode ? 'true' : 'false'">
|
||||||
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
|
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
|
||||||
</div>
|
<span class="ms-3 text-sm font-medium text-gray-900 dark:text-white">Use Passive Mode</span>
|
||||||
<input
|
</label>
|
||||||
type="password"
|
|
||||||
name="source_password"
|
|
||||||
id="source_password"
|
|
||||||
x-model="sourcePassword"
|
|
||||||
required
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
placeholder="Password"/>
|
|
||||||
</div>
|
|
||||||
<input type="hidden" name="source_password" :value="sourcePassword"/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-start mb-4">
|
<div>
|
||||||
<div class="flex items-center h-5">
|
<label for="source_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Remote Path</label>
|
||||||
<input
|
<div class="relative">
|
||||||
id="source_passive_mode"
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
name="source_passive_mode"
|
<i class="fas fa-folder-open text-gray-400 dark:text-gray-500"></i>
|
||||||
type="checkbox"
|
</div>
|
||||||
x-model="sourcePassiveMode"
|
<input type="text" id="source_path" name="source_path" x-model="sourcePath" required
|
||||||
:value="sourcePassiveMode ? 'true' : 'false'"
|
class="bg-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"
|
||||||
class="focus:ring-primary-500 h-4 w-4 text-primary-600 border-secondary-300 dark:border-secondary-700 rounded">
|
placeholder="/remote/path/to/files" />
|
||||||
</div>
|
|
||||||
<div class="ml-3 text-sm">
|
|
||||||
<label for="source_passive_mode" class="font-medium text-secondary-700 dark:text-secondary-300">Use Passive Mode</label>
|
|
||||||
<p class="text-secondary-500 dark:text-secondary-400">Enable passive mode for FTP connection</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Full path to the directory on the FTP server</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -1,175 +1,106 @@
|
|||||||
package source
|
package source
|
||||||
|
|
||||||
templ GoogleDriveSourceForm() {
|
templ GoogleDriveSourceForm() {
|
||||||
<div class="space-y-6" x-init="$watch('useBuiltinAuthSource', value => {
|
<div class="space-y-6 mt-4">
|
||||||
if(value) {
|
<div class="p-4 mb-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900/30 dark:text-blue-300" role="alert">
|
||||||
sourceClientId = '';
|
<div class="flex items-center">
|
||||||
sourceClientSecret = '';
|
<i class="fas fa-info-circle mr-2"></i>
|
||||||
}
|
<span>Google Drive requires authentication after creating this configuration. You'll be prompted to authenticate once the configuration is saved.</span>
|
||||||
})">
|
</div>
|
||||||
<div class="mb-6">
|
</div>
|
||||||
<label for="use_builtin_auth_source" class="flex items-center cursor-pointer">
|
|
||||||
<div class="relative">
|
|
||||||
<input id="use_builtin_auth_source" name="use_builtin_auth_source" type="checkbox"
|
|
||||||
class="sr-only"
|
|
||||||
x-model="useBuiltinAuthSource"
|
|
||||||
:value="useBuiltinAuthSource ? 'true' : 'false'"
|
|
||||||
/>
|
|
||||||
<div class="block bg-secondary-200 dark:bg-secondary-700 w-14 h-8 rounded-full"></div>
|
|
||||||
<div class="dot absolute left-1 top-1 bg-white dark:bg-secondary-100 w-6 h-6 rounded-full transition"
|
|
||||||
:class="useBuiltinAuthSource ? 'transform translate-x-6 bg-primary-500' : ''"></div>
|
|
||||||
</div>
|
|
||||||
<div class="ml-3 text-secondary-700 dark:text-secondary-300 font-medium">
|
|
||||||
Use rclone's built-in Google authentication (recommended)
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
<p class="mt-1 ml-14 text-xs text-secondary-500 dark:text-secondary-400">
|
|
||||||
Simple one-click authentication using rclone's shared credentials
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div x-bind:class="{ 'opacity-50': useBuiltinAuthSource }">
|
<div>
|
||||||
<div>
|
<div class="flex items-center mb-6">
|
||||||
<label for="source_client_id" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
|
<label class="relative inline-flex items-center cursor-pointer">
|
||||||
Google Client ID
|
<input type="checkbox" id="use_builtin_auth_source" name="use_builtin_auth_source" x-model="useBuiltinAuthSource"
|
||||||
<span x-show="useBuiltinAuthSource" class="text-secondary-400 dark:text-secondary-600 text-xs font-normal">(Using rclone default)</span>
|
class="sr-only peer" :value="useBuiltinAuthSource ? 'true' : 'false'">
|
||||||
</label>
|
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
|
||||||
<div class="relative">
|
<span class="ms-3 text-sm font-medium text-gray-900 dark:text-white">Use built-in authentication</span>
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
</label>
|
||||||
<i class="fas fa-id-card text-secondary-400 dark:text-secondary-600"></i>
|
</div>
|
||||||
</div>
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
<input type="text" id="source_client_id" name="source_client_id" x-model="sourceClientId"
|
Toggle to use your own Google API credentials instead of the built-in ones.
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
</p>
|
||||||
x-bind:disabled="useBuiltinAuthSource"
|
</div>
|
||||||
placeholder="Google Drive OAuth Client ID" />
|
|
||||||
</div>
|
|
||||||
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
|
||||||
Client ID from Google Cloud Console
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-4">
|
<div x-show="!useBuiltinAuthSource">
|
||||||
<label for="source_client_secret" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
|
<div class="p-4 mb-4 text-sm text-yellow-800 rounded-lg bg-yellow-50 dark:bg-yellow-900/30 dark:text-yellow-300" role="alert">
|
||||||
Google Client Secret
|
<div class="flex">
|
||||||
<span x-show="useBuiltinAuthSource" class="text-secondary-400 dark:text-secondary-600 text-xs font-normal">(Using rclone default)</span>
|
<i class="fas fa-exclamation-triangle mr-2 flex-shrink-0"></i>
|
||||||
</label>
|
<span>You'll need to create your own Google API project with Drive API enabled and create OAuth credentials. Learn how at <a href="https://developers.google.com/drive/api/quickstart/go" class="font-medium underline hover:text-yellow-700 dark:hover:text-yellow-200" target="_blank">Google API documentation</a>.</span>
|
||||||
<div class="relative">
|
</div>
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
</div>
|
||||||
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
|
|
||||||
</div>
|
|
||||||
<input type="password" id="source_client_secret" name="source_client_secret" x-model="sourceClientSecret"
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
x-bind:disabled="useBuiltinAuthSource"
|
|
||||||
placeholder="Google Drive OAuth Client Secret" />
|
|
||||||
</div>
|
|
||||||
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
|
||||||
Client Secret from Google Cloud Console
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-4">
|
<div class="mb-6">
|
||||||
<label for="source_drive_id" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Folder ID (Optional)</label>
|
<label for="source_client_id" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Client ID</label>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
<i class="fas fa-folder text-secondary-400 dark:text-secondary-600"></i>
|
<i class="fas fa-id-card text-gray-400 dark:text-gray-500"></i>
|
||||||
</div>
|
</div>
|
||||||
<input type="text" id="source_drive_id" name="source_drive_id" x-model="sourceDriveId"
|
<input type="text" id="source_client_id" name="source_client_id" x-model="sourceClientId"
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
class="bg-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="Google Drive Folder ID (optional)" />
|
placeholder="Your Google API Client ID" x-bind:required="!useBuiltinAuthSource" />
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">OAuth 2.0 Client ID from Google Cloud Console</p>
|
||||||
Folder ID to use as the root (leave empty for "My Drive")
|
</div>
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-4">
|
<div class="mb-6">
|
||||||
<label for="source_team_drive" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Shared/Team Drive ID (Optional)</label>
|
<label for="source_client_secret" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Client Secret</label>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
<i class="fas fa-users text-secondary-400 dark:text-secondary-600"></i>
|
<i class="fas fa-key text-gray-400 dark:text-gray-500"></i>
|
||||||
</div>
|
</div>
|
||||||
<input type="text" id="source_team_drive" name="source_team_drive" x-model="sourceTeamDrive"
|
<input type="password" id="source_client_secret" name="source_client_secret" x-model="sourceClientSecret"
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
class="bg-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="Shared/Team Drive ID (optional)" />
|
placeholder="Your Google API Client Secret" x-bind:required="!useBuiltinAuthSource" />
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">OAuth 2.0 Client Secret from Google Cloud Console</p>
|
||||||
ID of the Shared Drive / Team Drive to use
|
</div>
|
||||||
</p>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-4">
|
<div>
|
||||||
<label for="source_path" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Path</label>
|
<label for="source_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Drive Path</label>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
<i class="fas fa-folder-open text-secondary-400 dark:text-secondary-600"></i>
|
<i class="fab fa-google-drive text-gray-400 dark:text-gray-500"></i>
|
||||||
</div>
|
</div>
|
||||||
<input type="text" id="source_path" name="source_path" x-model="sourcePath"
|
<input type="text" id="source_path" name="source_path" x-model="sourcePath"
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
class="bg-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 within Google Drive (e.g., /backup)" />
|
placeholder="/path/in/drive" />
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
Path within the Drive from which files will be transferred
|
Path to the directory in your Google Drive. Leave empty for the root of your Drive.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="p-4 bg-amber-50 dark:bg-amber-900/50 rounded-lg border border-amber-100 dark:border-amber-700">
|
<div>
|
||||||
<div class="flex">
|
<label for="source_drive_id" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Drive ID (Optional)</label>
|
||||||
<div class="flex-shrink-0">
|
<div class="relative">
|
||||||
<i class="fas fa-exclamation-triangle text-amber-500 dark:text-amber-400"></i>
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
</div>
|
<i class="fas fa-fingerprint text-gray-400 dark:text-gray-500"></i>
|
||||||
<div class="ml-3">
|
</div>
|
||||||
<h3 class="text-sm font-medium text-amber-800 dark:text-amber-200">Authentication Required</h3>
|
<input type="text" id="source_drive_id" name="source_drive_id" x-model="sourceDriveId"
|
||||||
<div class="mt-2 text-sm text-amber-700 dark:text-amber-300">
|
class="bg-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"
|
||||||
<p>After saving this configuration, you'll need to authenticate with Google Drive:</p>
|
placeholder="Specific Google Drive ID" />
|
||||||
<ol class="list-decimal list-inside mt-2 space-y-1">
|
</div>
|
||||||
<li>Go to the configurations page</li>
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
<li>Click the "Authenticate" button for this configuration</li>
|
For accessing a shared drive. Leave empty to use your personal Google Drive.
|
||||||
<li>You'll be redirected to Google to grant access permissions</li>
|
</p>
|
||||||
<li>After granting permission, you'll be automatically redirected back to GoMFT</li>
|
</div>
|
||||||
</ol>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="p-4 bg-blue-50 dark:bg-blue-900/50 rounded-lg border border-blue-100 dark:border-blue-700">
|
<div>
|
||||||
<div class="flex">
|
<label for="source_team_drive" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Team Drive (Optional)</label>
|
||||||
<div class="flex-shrink-0">
|
<div class="relative">
|
||||||
<i class="fas fa-info-circle text-blue-400 dark:text-blue-300"></i>
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
</div>
|
<i class="fas fa-users text-gray-400 dark:text-gray-500"></i>
|
||||||
<div class="ml-3">
|
</div>
|
||||||
<h3 class="text-sm font-medium text-blue-800 dark:text-blue-200">Authentication Information</h3>
|
<input type="text" id="source_team_drive" name="source_team_drive" x-model="sourceTeamDrive"
|
||||||
<div class="mt-2 text-sm text-blue-700 dark:text-blue-300">
|
class="bg-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"
|
||||||
<template x-if="useBuiltinAuthSource">
|
placeholder="Team Drive ID" />
|
||||||
<div>
|
</div>
|
||||||
<p>You're using rclone's built-in authentication, which simplifies the setup process:</p>
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
<ul class="list-disc list-inside mt-1 space-y-1">
|
For accessing a shared team drive. Leave empty for personal drive.
|
||||||
<li>No need to create your own Google API credentials</li>
|
</p>
|
||||||
<li>Authentication happens through Google's secure OAuth flow</li>
|
</div>
|
||||||
<li>Automatic redirection back to GoMFT after authentication</li>
|
</div>
|
||||||
</ul>
|
|
||||||
<p class="mt-2 text-xs text-amber-600 dark:text-amber-400">
|
|
||||||
<i class="fas fa-exclamation-triangle mr-1"></i>
|
|
||||||
Note: The built-in authentication uses shared credentials which have rate limits across all rclone users.
|
|
||||||
If you plan to transfer large amounts of data or run many concurrent transfers, consider creating your own credentials.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<template x-if="!useBuiltinAuthSource">
|
|
||||||
<div>
|
|
||||||
<p>To use Google Drive with your own credentials:</p>
|
|
||||||
<ol class="list-decimal list-inside mt-1 space-y-1">
|
|
||||||
<li>Go to the <a href="https://console.cloud.google.com/" target="_blank" class="text-blue-600 dark:text-blue-400 underline">Google Cloud Console</a></li>
|
|
||||||
<li>Create a project and enable the Google Drive API</li>
|
|
||||||
<li>Create OAuth 2.0 credentials (Client ID & Secret)</li>
|
|
||||||
<li>Set authorized redirect URI to <code class="bg-blue-100 dark:bg-blue-800 px-1 py-0.5 rounded">http://localhost:53682/</code></li>
|
|
||||||
</ol>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
@@ -1,216 +1,188 @@
|
|||||||
package source
|
package source
|
||||||
|
|
||||||
templ GooglePhotosSourceForm() {
|
templ GooglePhotosSourceForm() {
|
||||||
<div class="space-y-6" x-init="$watch('useBuiltinAuthSource', value => {
|
<div class="space-y-6 mt-4" x-init="$watch('useBuiltinAuthSource', value => {
|
||||||
if(value) {
|
if(value) {
|
||||||
sourceClientId = '';
|
sourceClientId = '';
|
||||||
sourceClientSecret = '';
|
sourceClientSecret = '';
|
||||||
}
|
}
|
||||||
})">
|
})">
|
||||||
<div class="mb-6">
|
<div class="p-4 mb-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900/30 dark:text-blue-300" role="alert">
|
||||||
<label for="use_builtin_auth_source" class="flex items-center cursor-pointer">
|
<div class="flex items-center">
|
||||||
<div class="relative">
|
<i class="fas fa-info-circle mr-2"></i>
|
||||||
<input id="use_builtin_auth_source" name="use_builtin_auth_source" type="checkbox"
|
<span>Google Photos requires authentication after creating this configuration. You'll be prompted to authenticate once the configuration is saved.</span>
|
||||||
class="sr-only"
|
</div>
|
||||||
x-model="useBuiltinAuthSource"
|
</div>
|
||||||
:value="useBuiltinAuthSource ? 'true' : 'false'"
|
|
||||||
/>
|
|
||||||
<div class="block bg-secondary-200 dark:bg-secondary-700 w-14 h-8 rounded-full"></div>
|
|
||||||
<div class="dot absolute left-1 top-1 bg-white dark:bg-secondary-100 w-6 h-6 rounded-full transition"
|
|
||||||
:class="useBuiltinAuthSource ? 'transform translate-x-6 bg-primary-500' : ''"></div>
|
|
||||||
</div>
|
|
||||||
<div class="ml-3 text-secondary-700 dark:text-secondary-300 font-medium">
|
|
||||||
Use rclone's built-in Google authentication (recommended)
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
<p class="mt-1 ml-14 text-xs text-secondary-500 dark:text-secondary-400">
|
|
||||||
Simple one-click authentication using rclone's shared credentials
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div x-bind:class="{ 'opacity-50': useBuiltinAuthSource }">
|
<div>
|
||||||
<div>
|
<div class="flex items-center mb-6">
|
||||||
<label for="source_client_id" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
|
<label class="relative inline-flex items-center cursor-pointer">
|
||||||
Google Client ID
|
<input type="checkbox" id="use_builtin_auth_source" name="use_builtin_auth_source" x-model="useBuiltinAuthSource"
|
||||||
<span x-show="useBuiltinAuthSource" class="text-secondary-400 dark:text-secondary-600 text-xs font-normal">(Using rclone default)</span>
|
class="sr-only peer" :value="useBuiltinAuthSource ? 'true' : 'false'">
|
||||||
</label>
|
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
|
||||||
<div class="relative">
|
<span class="ms-3 text-sm font-medium text-gray-900 dark:text-white">Use rclone's built-in Google authentication (recommended)</span>
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
</label>
|
||||||
<i class="fas fa-id-card text-secondary-400 dark:text-secondary-600"></i>
|
</div>
|
||||||
</div>
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
<input type="text" id="source_client_id" name="source_client_id" x-model="sourceClientId"
|
Simple one-click authentication using rclone's shared credentials
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
</p>
|
||||||
x-bind:disabled="useBuiltinAuthSource"
|
</div>
|
||||||
placeholder="Google Photos OAuth Client ID" />
|
|
||||||
</div>
|
|
||||||
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
|
||||||
Client ID from Google Cloud Console
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-4">
|
<div x-bind:class="{ 'opacity-50': useBuiltinAuthSource }">
|
||||||
<label for="source_client_secret" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
|
<div class="mb-6">
|
||||||
Google Client Secret
|
<label for="source_client_id" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
|
||||||
<span x-show="useBuiltinAuthSource" class="text-secondary-400 dark:text-secondary-600 text-xs font-normal">(Using rclone default)</span>
|
Google Client ID
|
||||||
</label>
|
<span x-show="useBuiltinAuthSource" class="text-gray-500 dark:text-gray-400 text-xs font-normal">(Using rclone default)</span>
|
||||||
<div class="relative">
|
</label>
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="relative">
|
||||||
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
</div>
|
<i class="fas fa-id-card text-gray-400 dark:text-gray-500"></i>
|
||||||
<input type="password" id="source_client_secret" name="source_client_secret" x-model="sourceClientSecret"
|
</div>
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
<input type="text" id="source_client_id" name="source_client_id" x-model="sourceClientId"
|
||||||
x-bind:disabled="useBuiltinAuthSource"
|
class="bg-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="Google Photos OAuth Client Secret" />
|
x-bind:disabled="useBuiltinAuthSource"
|
||||||
</div>
|
placeholder="Google Photos OAuth Client ID" />
|
||||||
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
</div>
|
||||||
Client Secret from Google Cloud Console
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
</p>
|
Client ID from Google Cloud Console
|
||||||
</div>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-4">
|
<div class="mb-6">
|
||||||
<label for="source_read_only" class="flex items-center cursor-pointer">
|
<label for="source_client_secret" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
|
||||||
<div class="relative">
|
Google Client Secret
|
||||||
<input id="source_read_only" name="source_read_only" type="checkbox"
|
<span x-show="useBuiltinAuthSource" class="text-gray-500 dark:text-gray-400 text-xs font-normal">(Using rclone default)</span>
|
||||||
class="sr-only"
|
</label>
|
||||||
x-model="sourceReadOnly"
|
<div class="relative">
|
||||||
:value="sourceReadOnly ? 'true' : 'false'"
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
/>
|
<i class="fas fa-key text-gray-400 dark:text-gray-500"></i>
|
||||||
<div class="block bg-secondary-200 dark:bg-secondary-700 w-14 h-8 rounded-full"></div>
|
</div>
|
||||||
<div class="dot absolute left-1 top-1 bg-white dark:bg-secondary-100 w-6 h-6 rounded-full transition"
|
<input type="password" id="source_client_secret" name="source_client_secret" x-model="sourceClientSecret"
|
||||||
:class="sourceReadOnly ? 'transform translate-x-6 bg-primary-500' : ''"></div>
|
class="bg-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"
|
||||||
</div>
|
x-bind:disabled="useBuiltinAuthSource"
|
||||||
<div class="ml-3 text-secondary-700 dark:text-secondary-300 font-medium">
|
placeholder="Google Photos OAuth Client Secret" />
|
||||||
Read-only mode
|
</div>
|
||||||
</div>
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
</label>
|
Client Secret from Google Cloud Console
|
||||||
<p class="mt-1 ml-14 text-xs text-secondary-500 dark:text-secondary-400">
|
</p>
|
||||||
Only request read-only access to your photos
|
</div>
|
||||||
</p>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-4">
|
<div class="flex items-center mb-6">
|
||||||
<label for="source_start_year" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Start Year (Optional)</label>
|
<label class="relative inline-flex items-center cursor-pointer">
|
||||||
<div class="relative">
|
<input type="checkbox" id="source_read_only" name="source_read_only" x-model="sourceReadOnly"
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
class="sr-only peer" :value="sourceReadOnly ? 'true' : 'false'">
|
||||||
<i class="fas fa-calendar text-secondary-400 dark:text-secondary-600"></i>
|
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
|
||||||
</div>
|
<span class="ms-3 text-sm font-medium text-gray-900 dark:text-white">Read-only mode</span>
|
||||||
<input type="number" id="source_start_year" name="source_start_year" x-model="sourceStartYear"
|
</label>
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
</div>
|
||||||
placeholder="Only include photos after this year (default: 2000)" min="1900" max="2100" />
|
<p class="ms-14 text-sm text-gray-500 dark:text-gray-400">
|
||||||
</div>
|
Only request read-only access to your photos
|
||||||
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
</p>
|
||||||
Only include photos uploaded after this year
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-4">
|
<div class="mb-6">
|
||||||
<label for="source_include_archived" class="flex items-center cursor-pointer">
|
<label for="source_start_year" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Start Year (Optional)</label>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<input id="source_include_archived" name="source_include_archived" type="checkbox"
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
class="sr-only"
|
<i class="fas fa-calendar text-gray-400 dark:text-gray-500"></i>
|
||||||
x-model="sourceIncludeArchived"
|
</div>
|
||||||
:value="sourceIncludeArchived ? 'true' : 'false'"
|
<input type="number" id="source_start_year" name="source_start_year" x-model="sourceStartYear"
|
||||||
/>
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
<div class="block bg-secondary-200 dark:bg-secondary-700 w-14 h-8 rounded-full"></div>
|
placeholder="Only include photos after this year (default: 2000)" min="1900" max="2100" />
|
||||||
<div class="dot absolute left-1 top-1 bg-white dark:bg-secondary-100 w-6 h-6 rounded-full transition"
|
</div>
|
||||||
:class="sourceIncludeArchived ? 'transform translate-x-6 bg-primary-500' : ''"></div>
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
</div>
|
Only include photos uploaded after this year
|
||||||
<div class="ml-3 text-secondary-700 dark:text-secondary-300 font-medium">
|
</p>
|
||||||
Include archived media
|
</div>
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
<p class="mt-1 ml-14 text-xs text-secondary-500 dark:text-secondary-400">
|
|
||||||
Include archived photos and videos in media listings
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-4">
|
<div class="flex items-center mb-6">
|
||||||
<label for="source_path" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Album Path</label>
|
<label class="relative inline-flex items-center cursor-pointer">
|
||||||
<div class="relative">
|
<input type="checkbox" id="source_include_archived" name="source_include_archived" x-model="sourceIncludeArchived"
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
class="sr-only peer" :value="sourceIncludeArchived ? 'true' : 'false'">
|
||||||
<i class="fas fa-images text-secondary-400 dark:text-secondary-600"></i>
|
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
|
||||||
</div>
|
<span class="ms-3 text-sm font-medium text-gray-900 dark:text-white">Include archived media</span>
|
||||||
<input type="text" id="source_path" name="source_path" x-model="sourcePath"
|
</label>
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
</div>
|
||||||
placeholder="Album path (e.g., album/my-photos)" />
|
<p class="ms-14 text-sm text-gray-500 dark:text-gray-400">
|
||||||
</div>
|
Include archived photos and videos in media listings
|
||||||
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
</p>
|
||||||
Path within Google Photos to download files from
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="p-4 bg-amber-50 dark:bg-amber-900/50 rounded-lg border border-amber-100 dark:border-amber-700">
|
<div class="mb-6">
|
||||||
<div class="flex">
|
<label for="source_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Album Path</label>
|
||||||
<div class="flex-shrink-0">
|
<div class="relative">
|
||||||
<i class="fas fa-exclamation-triangle text-amber-500 dark:text-amber-400"></i>
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
</div>
|
<i class="fas fa-images text-gray-400 dark:text-gray-500"></i>
|
||||||
<div class="ml-3">
|
</div>
|
||||||
<h3 class="text-sm font-medium text-amber-800 dark:text-amber-200">Authentication Required</h3>
|
<input type="text" id="source_path" name="source_path" x-model="sourcePath"
|
||||||
<div class="mt-2 text-sm text-amber-700 dark:text-amber-300">
|
class="bg-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"
|
||||||
<p>After saving this configuration, you'll need to authenticate with Google Photos:</p>
|
placeholder="Album path (e.g., album/my-photos)" />
|
||||||
<ol class="list-decimal list-inside mt-2 space-y-1">
|
</div>
|
||||||
<li>Go to the configurations page</li>
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
<li>Click the "Authenticate" button for this configuration</li>
|
Path within Google Photos to download files from
|
||||||
<li>You'll be redirected to Google to grant access permissions</li>
|
</p>
|
||||||
<li>After granting permission, you'll be automatically redirected back to GoMFT</li>
|
</div>
|
||||||
</ol>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="p-4 bg-blue-50 dark:bg-blue-900/50 rounded-lg border border-blue-100 dark:border-blue-700">
|
<div class="p-4 mb-4 text-sm text-yellow-800 rounded-lg bg-yellow-50 dark:bg-yellow-900/30 dark:text-yellow-300" role="alert">
|
||||||
<div class="flex">
|
<div class="flex">
|
||||||
<div class="flex-shrink-0">
|
<i class="fas fa-exclamation-triangle mr-2 flex-shrink-0"></i>
|
||||||
<i class="fas fa-info-circle text-blue-400 dark:text-blue-300"></i>
|
<div>
|
||||||
</div>
|
<h3 class="font-medium">Authentication Required</h3>
|
||||||
<div class="ml-3">
|
<p class="mt-1">After saving this configuration, you'll need to authenticate with Google Photos:</p>
|
||||||
<h3 class="text-sm font-medium text-blue-800 dark:text-blue-200">Authentication Information</h3>
|
<ol class="list-decimal list-inside mt-2 space-y-1">
|
||||||
<div class="mt-2 text-sm text-blue-700 dark:text-blue-300">
|
<li>Go to the configurations page</li>
|
||||||
<template x-if="useBuiltinAuthSource">
|
<li>Click the "Authenticate" button for this configuration</li>
|
||||||
<div>
|
<li>You'll be redirected to Google to grant access permissions</li>
|
||||||
<p>You're using rclone's built-in authentication, which simplifies the setup process:</p>
|
<li>After granting permission, you'll be automatically redirected back to GoMFT</li>
|
||||||
<ul class="list-disc list-inside mt-1 space-y-1">
|
</ol>
|
||||||
<li>No need to create your own Google API credentials</li>
|
</div>
|
||||||
<li>Authentication happens through Google's secure OAuth flow</li>
|
</div>
|
||||||
<li>Automatic redirection back to GoMFT after authentication</li>
|
</div>
|
||||||
</ul>
|
|
||||||
<p class="mt-2 text-xs text-amber-600 dark:text-amber-400">
|
|
||||||
<i class="fas fa-exclamation-triangle mr-1"></i>
|
|
||||||
Note: The built-in authentication uses shared credentials which have rate limits across all rclone users.
|
|
||||||
If you plan to transfer large amounts of data or run many concurrent transfers, consider creating your own credentials.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<template x-if="!useBuiltinAuthSource">
|
|
||||||
<div>
|
|
||||||
<p>To use Google Photos with your own credentials:</p>
|
|
||||||
<ol class="list-decimal list-inside mt-1 space-y-1">
|
|
||||||
<li>Go to the <a href="https://console.cloud.google.com/" target="_blank" class="text-blue-600 dark:text-blue-400 underline">Google Cloud Console</a></li>
|
|
||||||
<li>Create a project and enable the Google Photos API</li>
|
|
||||||
<li>Create OAuth 2.0 credentials (Client ID & Secret)</li>
|
|
||||||
<li>Set authorized redirect URI to <code class="bg-blue-100 dark:bg-blue-800 px-1 py-0.5 rounded">http://localhost:53682/</code></li>
|
|
||||||
</ol>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="p-4 bg-yellow-50 dark:bg-yellow-900/50 rounded-lg border border-yellow-100 dark:border-yellow-700">
|
<div class="p-4 mb-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900/30 dark:text-blue-300" role="alert">
|
||||||
<div class="flex">
|
<div class="flex">
|
||||||
<div class="flex-shrink-0">
|
<i class="fas fa-info-circle mr-2 flex-shrink-0"></i>
|
||||||
<i class="fas fa-exclamation-circle text-yellow-500 dark:text-yellow-400"></i>
|
<div>
|
||||||
</div>
|
<h3 class="font-medium">Authentication Information</h3>
|
||||||
<div class="ml-3">
|
<div class="mt-1">
|
||||||
<h3 class="text-sm font-medium text-yellow-800 dark:text-yellow-200">Important Note About Google Photos</h3>
|
<template x-if="useBuiltinAuthSource">
|
||||||
<div class="mt-2 text-sm text-yellow-700 dark:text-yellow-300">
|
<div>
|
||||||
<p>When downloading from Google Photos, be aware that some original metadata may not be preserved. Google Photos processes and may compress some images upon upload.</p>
|
<p>You're using rclone's built-in authentication, which simplifies the setup process:</p>
|
||||||
</div>
|
<ul class="list-disc list-inside mt-1 space-y-1">
|
||||||
</div>
|
<li>No need to create your own Google API credentials</li>
|
||||||
</div>
|
<li>Authentication happens through Google's secure OAuth flow</li>
|
||||||
</div>
|
<li>Automatic redirection back to GoMFT after authentication</li>
|
||||||
</div>
|
</ul>
|
||||||
|
<p class="mt-2 text-yellow-700 dark:text-yellow-300">
|
||||||
|
<i class="fas fa-exclamation-triangle mr-1"></i>
|
||||||
|
Note: The built-in authentication uses shared credentials which have rate limits across all rclone users.
|
||||||
|
If you plan to transfer large amounts of data or run many concurrent transfers, consider creating your own credentials.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template x-if="!useBuiltinAuthSource">
|
||||||
|
<div>
|
||||||
|
<p>To use Google Photos with your own credentials:</p>
|
||||||
|
<ol class="list-decimal list-inside mt-1 space-y-1">
|
||||||
|
<li>Go to the <a href="https://console.cloud.google.com/" target="_blank" class="font-medium underline hover:text-blue-700 dark:hover:text-blue-300">Google Cloud Console</a></li>
|
||||||
|
<li>Create a project and enable the Google Photos API</li>
|
||||||
|
<li>Create OAuth 2.0 credentials (Client ID & Secret)</li>
|
||||||
|
<li>Set authorized redirect URI to <code class="bg-blue-100 dark:bg-blue-800 px-1.5 py-0.5 rounded text-xs">http://localhost:53682/</code></li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-4 mb-4 text-sm text-yellow-800 rounded-lg bg-yellow-50 dark:bg-yellow-900/30 dark:text-yellow-300" role="alert">
|
||||||
|
<div class="flex">
|
||||||
|
<i class="fas fa-exclamation-circle mr-2 flex-shrink-0"></i>
|
||||||
|
<div>
|
||||||
|
<h3 class="font-medium">Important Note About Google Photos</h3>
|
||||||
|
<p class="mt-1">When downloading from Google Photos, be aware that some original metadata may not be preserved. Google Photos processes and may compress some images upon upload.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
package source
|
||||||
|
|
||||||
|
templ HetznerSourceForm() {
|
||||||
|
<div class="space-y-6 mt-4">
|
||||||
|
<div class="p-4 mb-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900/30 dark:text-blue-300" role="alert">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<i class="fas fa-info-circle mr-2"></i>
|
||||||
|
<span>Configure your Hetzner Storage Box details below. You'll need your server details, username, and password.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-6">
|
||||||
|
<label for="source_host" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Storage Box Host</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
|
<i class="fas fa-server text-gray-400 dark:text-gray-500"></i>
|
||||||
|
</div>
|
||||||
|
<input type="text" id="source_host" name="source_host" x-model="sourceHost" required
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
placeholder="uXXXXXX.your-storagebox.de" />
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Your Hetzner Storage Box hostname (e.g., uXXXXXX.your-storagebox.de)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-6">
|
||||||
|
<label for="source_port" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Port</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
|
<i class="fas fa-plug text-gray-400 dark:text-gray-500"></i>
|
||||||
|
</div>
|
||||||
|
<input type="number" id="source_port" name="source_port" x-model="sourcePort" required
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
placeholder="23" min="1" max="65535" value="23" />
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Connection port (default: 23)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-6">
|
||||||
|
<label for="source_auth_type" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Authentication Type</label>
|
||||||
|
<div class="flex">
|
||||||
|
<div class="flex items-center me-4">
|
||||||
|
<input id="source_auth_password" type="radio" value="password" name="source_auth_type" x-model="sourceAuthType"
|
||||||
|
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600">
|
||||||
|
<label for="source_auth_password" class="ms-2 text-sm font-medium text-gray-900 dark:text-white">Password</label>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center me-4">
|
||||||
|
<input id="source_auth_key" type="radio" value="key" name="source_auth_type" x-model="sourceAuthType"
|
||||||
|
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600">
|
||||||
|
<label for="source_auth_key" class="ms-2 text-sm font-medium text-gray-900 dark:text-white">SSH Key</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-6">
|
||||||
|
<label for="source_user" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Username</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
|
<i class="fas fa-user text-gray-400 dark:text-gray-500"></i>
|
||||||
|
</div>
|
||||||
|
<input type="text" id="source_user" name="source_user" x-model="sourceUser" required
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
placeholder="uXXXXXX" />
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Your Hetzner Storage Box username (typically matches your Storage Box number)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div x-show="sourceAuthType === 'password'" class="mb-6">
|
||||||
|
<label for="source_password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Password</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
|
<i class="fas fa-lock text-gray-400 dark:text-gray-500"></i>
|
||||||
|
</div>
|
||||||
|
<input type="password" id="source_password" name="source_password" x-model="sourcePassword" x-bind:required="sourceAuthType === 'password'"
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
placeholder="Your password" />
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Your Hetzner Storage Box password
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div x-show="sourceAuthType === 'key'" class="mb-6">
|
||||||
|
<label for="source_key_file" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">SSH Key File</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
|
<i class="fas fa-key text-gray-400 dark:text-gray-500"></i>
|
||||||
|
</div>
|
||||||
|
<input type="text" id="source_key_file" name="source_key_file" x-model="sourceKeyFile" x-bind:required="sourceAuthType === 'key'"
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
placeholder="/path/to/id_rsa" />
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Path to your SSH private key file (must be readable by the application)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-6">
|
||||||
|
<label for="source_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Remote Path</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
|
<i class="fas fa-folder-open text-gray-400 dark:text-gray-500"></i>
|
||||||
|
</div>
|
||||||
|
<input type="text" id="source_path" name="source_path" x-model="sourcePath" required
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
placeholder="/backups" />
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Path on your Storage Box (e.g., /backups, /path/to/files)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-4 mb-4 text-sm text-yellow-800 rounded-lg bg-yellow-50 dark:bg-yellow-900/30 dark:text-yellow-300" role="alert">
|
||||||
|
<div class="flex">
|
||||||
|
<i class="fas fa-shield-alt mr-2 flex-shrink-0"></i>
|
||||||
|
<div>
|
||||||
|
<h3 class="font-medium">Security Note</h3>
|
||||||
|
<p class="mt-1">For increased security, consider using SSH key authentication instead of password authentication when possible.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@@ -1,39 +1,31 @@
|
|||||||
package source
|
package source
|
||||||
|
|
||||||
templ LocalSourceForm() {
|
templ LocalSourceForm() {
|
||||||
<div class="sm:col-span-6 space-y-6">
|
<div class="space-y-4 mt-4">
|
||||||
<div class="sm:col-span-4">
|
<div>
|
||||||
<label for="source_path" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Local Path</label>
|
<label for="source_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Local Path</label>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
<i class="fas fa-folder-open text-secondary-400 dark:text-secondary-600"></i>
|
<i class="fas fa-folder-open text-gray-400 dark:text-gray-500"></i>
|
||||||
|
</div>
|
||||||
|
<input type="text" id="source_path" name="source_path" x-model="sourcePath" required
|
||||||
|
:class="{ 'border-red-500 dark:border-red-700': sourcePathValid === false, 'border-green-500 dark:border-green-700': sourcePathValid === true }"
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
placeholder="/path/to/files" />
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Full path to the local directory containing your files</p>
|
||||||
|
<div x-show="sourcePathValid === false" class="mt-2 text-sm text-red-600 dark:text-red-400">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<i class="fas fa-exclamation-circle mr-2"></i>
|
||||||
|
<span x-text="sourcePathError"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div x-show="sourcePathValid === true" class="mt-2 text-sm text-green-600 dark:text-green-400">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<i class="fas fa-check-circle mr-2"></i>
|
||||||
|
<span x-text="sourcePathError || 'Path is valid'"></span>
|
||||||
</div>
|
</div>
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="source_path"
|
|
||||||
id="source_path"
|
|
||||||
x-model="sourcePath"
|
|
||||||
required
|
|
||||||
:class="{ 'border-red-300 dark:border-red-700': sourcePathValid === false, 'border-green-300 dark:border-green-700': sourcePathValid === true }"
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
placeholder="Local directory path (e.g., /path/to/files)"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
|
||||||
Full path to the local directory containing your files
|
|
||||||
</p>
|
|
||||||
<!-- Path validation message -->
|
|
||||||
<template x-if="sourcePathValid !== null">
|
|
||||||
<p x-show="sourcePathError" class="mt-1 text-sm" :class="sourcePathValid ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'" x-text="sourcePathError"></p>
|
|
||||||
</template>
|
|
||||||
</div>
|
</div>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn-secondary h-10 whitespace-nowrap"
|
|
||||||
@click="checkPath(sourcePath, 'source')"
|
|
||||||
>
|
|
||||||
<i class="fas fa-check-circle mr-2"></i>
|
|
||||||
Check Location
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,91 +1,113 @@
|
|||||||
package source
|
package source
|
||||||
|
|
||||||
templ MinIOSourceForm() {
|
templ MinIOSourceForm() {
|
||||||
<div class="sm:col-span-6 space-y-6">
|
<div class="space-y-6 mt-4">
|
||||||
<div class="sm:col-span-4">
|
<div class="p-4 mb-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900/30 dark:text-blue-300" role="alert">
|
||||||
<label for="source_endpoint" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Endpoint URL</label>
|
<div class="flex items-center">
|
||||||
<div class="relative">
|
<i class="fas fa-info-circle mr-2"></i>
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<span>Configure your MinIO server details below. You'll need your access key, secret key, and endpoint information.</span>
|
||||||
<i class="fas fa-server text-secondary-400 dark:text-secondary-600"></i>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="source_endpoint"
|
|
||||||
id="source_endpoint"
|
|
||||||
x-model="sourceEndpoint"
|
|
||||||
required
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
placeholder="https://minio.example.com"/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sm:col-span-4">
|
<div class="mb-6">
|
||||||
<label for="source_bucket" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Bucket Name</label>
|
<label for="source_endpoint" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Endpoint URL</label>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
<i class="fas fa-database text-secondary-400 dark:text-secondary-600"></i>
|
<i class="fas fa-server text-gray-400 dark:text-gray-500"></i>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input type="text" id="source_endpoint" name="source_endpoint" x-model="sourceEndpoint" required
|
||||||
type="text"
|
class="bg-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"
|
||||||
name="source_bucket"
|
placeholder="https://minio.example.com" />
|
||||||
id="source_bucket"
|
|
||||||
x-model="sourceBucket"
|
|
||||||
required
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
placeholder="my-bucket"/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Full URL to your MinIO server including protocol (http:// or https://)
|
||||||
<div class="sm:col-span-4">
|
|
||||||
<label for="source_path" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Path Prefix</label>
|
|
||||||
<div class="relative">
|
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
|
||||||
<i class="fas fa-folder text-secondary-400 dark:text-secondary-600"></i>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="source_path"
|
|
||||||
id="source_path"
|
|
||||||
x-model="sourcePath"
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
placeholder="path/prefix/"/>
|
|
||||||
</div>
|
|
||||||
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
|
||||||
Optional. If specified, only files in this path will be processed.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sm:col-span-4">
|
<div class="mb-6">
|
||||||
<label for="source_access_key" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Access Key</label>
|
<label for="source_bucket" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Bucket Name</label>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
|
<i class="fas fa-archive text-gray-400 dark:text-gray-500"></i>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input type="text" id="source_bucket" name="source_bucket" x-model="sourceBucket" required
|
||||||
type="text"
|
class="bg-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"
|
||||||
name="source_access_key"
|
placeholder="my-bucket" />
|
||||||
id="source_access_key"
|
|
||||||
x-model="sourceAccessKey"
|
|
||||||
required
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"/>
|
|
||||||
</div>
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Name of your MinIO bucket (case-sensitive)
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sm:col-span-4">
|
|
||||||
<label for="source_secret_key" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Secret Key</label>
|
<div class="mb-6">
|
||||||
|
<label for="source_region" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Region (Optional)</label>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
<i class="fas fa-lock text-secondary-400 dark:text-secondary-600"></i>
|
<i class="fas fa-globe-americas text-gray-400 dark:text-gray-500"></i>
|
||||||
|
</div>
|
||||||
|
<input type="text" id="source_region" name="source_region" x-model="sourceRegion"
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
placeholder="us-east-1" />
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Optional: Specify the region if your MinIO setup requires it.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-6">
|
||||||
|
<label for="source_access_key" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Access Key</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
|
<i class="fas fa-key text-gray-400 dark:text-gray-500"></i>
|
||||||
|
</div>
|
||||||
|
<input type="text" id="source_access_key" name="source_access_key" x-model="sourceAccessKey" required
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
placeholder="minioadmin" />
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Your MinIO Access Key for authentication
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-6">
|
||||||
|
<label for="source_secret_key" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Secret Key</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
|
<i class="fas fa-lock text-gray-400 dark:text-gray-500"></i>
|
||||||
|
</div>
|
||||||
|
<input type="password" id="source_secret_key" name="source_secret_key" x-model="sourceSecretKey" required
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
placeholder="minioadmin" />
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Your MinIO Secret Key for authentication
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-6">
|
||||||
|
<label for="source_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Path in Bucket</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
|
<i class="fas fa-folder-open text-gray-400 dark:text-gray-500"></i>
|
||||||
|
</div>
|
||||||
|
<input type="text" id="source_path" name="source_path" x-model="sourcePath"
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
placeholder="path/to/files/" />
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Path prefix within the bucket (e.g., "backups/"). Leave empty to access the entire bucket.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-4 mb-4 text-sm text-yellow-800 rounded-lg bg-yellow-50 dark:bg-yellow-900/30 dark:text-yellow-300" role="alert">
|
||||||
|
<div class="flex">
|
||||||
|
<i class="fas fa-exclamation-triangle mr-2 flex-shrink-0"></i>
|
||||||
|
<div>
|
||||||
|
<h3 class="font-medium">Configuration Note</h3>
|
||||||
|
<p class="mt-1">Make sure your MinIO server is properly configured with TLS if you're using HTTPS, and that the server is accessible from this machine.</p>
|
||||||
</div>
|
</div>
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
name="source_secret_key"
|
|
||||||
id="source_secret_key"
|
|
||||||
x-model="sourceSecretKey"
|
|
||||||
required
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"/>
|
|
||||||
</div>
|
</div>
|
||||||
<input type="hidden" name="source_secret_key" :value="sourceSecretKey"/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -1,75 +1,82 @@
|
|||||||
package source
|
package source
|
||||||
|
|
||||||
templ NextCloudSourceForm() {
|
templ NextCloudSourceForm() {
|
||||||
<div class="sm:col-span-6 space-y-6">
|
<div class="space-y-6 mt-4">
|
||||||
<div class="sm:col-span-4">
|
<div class="p-4 mb-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900/30 dark:text-blue-300" role="alert">
|
||||||
<label for="source_host" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">NextCloud URL</label>
|
<div class="flex items-center">
|
||||||
<div class="relative">
|
<i class="fas fa-info-circle mr-2"></i>
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<span>Configure your NextCloud connection details below. You'll need your server URL, username, and password.</span>
|
||||||
<i class="fas fa-cloud text-secondary-400 dark:text-secondary-600"></i>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="source_host"
|
|
||||||
id="source_host"
|
|
||||||
x-model="sourceHost"
|
|
||||||
required
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
placeholder="https://nextcloud.example.com"/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sm:col-span-4">
|
<div class="mb-6">
|
||||||
<label for="source_path" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Remote Path</label>
|
<label for="source_host" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">NextCloud URL</label>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
<i class="fas fa-folder text-secondary-400 dark:text-secondary-600"></i>
|
<i class="fas fa-cloud text-gray-400 dark:text-gray-500"></i>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input type="text" id="source_host" name="source_host" x-model="sourceHost" required
|
||||||
type="text"
|
class="bg-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"
|
||||||
name="source_path"
|
placeholder="https://nextcloud.example.com" />
|
||||||
id="source_path"
|
|
||||||
x-model="sourcePath"
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
placeholder="/remote.php/dav/files/username/path/to/files"/>
|
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
Optional. If left empty, root directory will be used.
|
Full URL to your NextCloud server including protocol (https://)
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sm:col-span-4">
|
<div class="mb-6">
|
||||||
<label for="source_user" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Username</label>
|
<label for="source_user" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Username</label>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
<i class="fas fa-user text-secondary-400 dark:text-secondary-600"></i>
|
<i class="fas fa-user text-gray-400 dark:text-gray-500"></i>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input type="text" id="source_user" name="source_user" x-model="sourceUser" required
|
||||||
type="text"
|
class="bg-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"
|
||||||
name="source_user"
|
placeholder="username" />
|
||||||
id="source_user"
|
|
||||||
x-model="sourceUser"
|
|
||||||
required
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"/>
|
|
||||||
</div>
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Your NextCloud username
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sm:col-span-4">
|
<div class="mb-6">
|
||||||
<label for="source_password" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Password</label>
|
<label for="source_password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Password</label>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
|
<i class="fas fa-lock text-gray-400 dark:text-gray-500"></i>
|
||||||
|
</div>
|
||||||
|
<input type="password" id="source_password" name="source_password" x-model="sourcePassword" required
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
placeholder="password" />
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Your NextCloud account password
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-6">
|
||||||
|
<label for="source_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Remote Path</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
|
<i class="fas fa-folder-open text-gray-400 dark:text-gray-500"></i>
|
||||||
|
</div>
|
||||||
|
<input type="text" id="source_path" name="source_path" x-model="sourcePath" required
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
placeholder="/path/to/files" />
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Path to your files in NextCloud
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-4 mb-4 text-sm text-yellow-800 rounded-lg bg-yellow-50 dark:bg-yellow-900/30 dark:text-yellow-300" role="alert">
|
||||||
|
<div class="flex">
|
||||||
|
<i class="fas fa-lightbulb mr-2 flex-shrink-0"></i>
|
||||||
|
<div>
|
||||||
|
<h3 class="font-medium">Connection Tip</h3>
|
||||||
|
<p class="mt-1">If you have two-factor authentication enabled on your NextCloud account, you'll need to create an app password in your NextCloud security settings.</p>
|
||||||
</div>
|
</div>
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
name="source_password"
|
|
||||||
id="source_password"
|
|
||||||
x-model="sourcePassword"
|
|
||||||
required
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
placeholder="Password"/>
|
|
||||||
</div>
|
</div>
|
||||||
<input type="hidden" name="source_password" :value="sourcePassword"/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -1,100 +1,96 @@
|
|||||||
package source
|
package source
|
||||||
|
|
||||||
templ S3SourceForm() {
|
templ S3SourceForm() {
|
||||||
<div class="sm:col-span-6 space-y-6">
|
<div class="space-y-6 mt-4">
|
||||||
<div class="sm:col-span-4">
|
<div class="p-4 mb-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900/30 dark:text-blue-300" role="alert">
|
||||||
<label for="source_bucket" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Bucket Name</label>
|
<div class="flex items-center">
|
||||||
<div class="relative">
|
<i class="fas fa-info-circle mr-2"></i>
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<span>Configure your AWS S3 bucket details below. You'll need your access key, secret key, and bucket information.</span>
|
||||||
<i class="fab fa-aws text-secondary-400 dark:text-secondary-600"></i>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="source_bucket"
|
|
||||||
id="source_bucket"
|
|
||||||
x-model="sourceBucket"
|
|
||||||
required
|
|
||||||
aria-describedby="source_bucket_help"
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
placeholder="my-bucket"/>
|
|
||||||
</div>
|
</div>
|
||||||
<p id="source_bucket_help" class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
</div>
|
||||||
Enter your S3 bucket name.
|
|
||||||
|
<div class="mb-6">
|
||||||
|
<label for="source_region" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">AWS Region</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
|
<i class="fas fa-globe text-gray-400 dark:text-gray-500"></i>
|
||||||
|
</div>
|
||||||
|
<input type="text" id="source_region" name="source_region" x-model="sourceRegion" required
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
placeholder="us-east-1" />
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
AWS Region where your S3 bucket is located (e.g., us-east-1, eu-west-1)
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sm:col-span-4">
|
<div class="mb-6">
|
||||||
<label for="source_region" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">AWS Region</label>
|
<label for="source_bucket" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Bucket Name</label>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
<i class="fas fa-globe text-secondary-400 dark:text-secondary-600"></i>
|
<i class="fas fa-archive text-gray-400 dark:text-gray-500"></i>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input type="text" id="source_bucket" name="source_bucket" x-model="sourceBucket" required
|
||||||
type="text"
|
class="bg-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"
|
||||||
name="source_region"
|
placeholder="my-bucket" />
|
||||||
id="source_region"
|
|
||||||
x-model="sourceRegion"
|
|
||||||
required
|
|
||||||
aria-describedby="source_region_help"
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
placeholder="us-west-2"/>
|
|
||||||
</div>
|
</div>
|
||||||
<p id="source_region_help" class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
AWS region for the S3 bucket (e.g., us-west-2).
|
Name of your S3 bucket (case-sensitive)
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sm:col-span-4">
|
<div class="mb-6">
|
||||||
<label for="source_path" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Path Prefix</label>
|
<label for="source_access_key" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Access Key ID</label>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
<i class="fas fa-folder text-secondary-400 dark:text-secondary-600"></i>
|
<i class="fas fa-key text-gray-400 dark:text-gray-500"></i>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input type="text" id="source_access_key" name="source_access_key" x-model="sourceAccessKey" required
|
||||||
type="text"
|
class="bg-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"
|
||||||
name="source_path"
|
placeholder="AKIAIOSFODNN7EXAMPLE" />
|
||||||
id="source_path"
|
|
||||||
x-model="sourcePath"
|
|
||||||
aria-describedby="source_path_help"
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
placeholder="path/to/files/"/>
|
|
||||||
</div>
|
</div>
|
||||||
<p id="source_path_help" class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
Optional path prefix within the bucket (e.g., 'path/to/files/').
|
Your AWS Access Key ID
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sm:col-span-4">
|
<div class="mb-6">
|
||||||
<label for="source_access_key" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Access Key ID</label>
|
<label for="source_secret_key" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Secret Access Key</label>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
|
<i class="fas fa-lock text-gray-400 dark:text-gray-500"></i>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input type="password" id="source_secret_key" name="source_secret_key" x-model="sourceSecretKey" required
|
||||||
type="text"
|
class="bg-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"
|
||||||
name="source_access_key"
|
placeholder="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" />
|
||||||
id="source_access_key"
|
|
||||||
x-model="sourceAccessKey"
|
|
||||||
required
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
placeholder="AKIAIOSFODNN7EXAMPLE"/>
|
|
||||||
</div>
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Your AWS Secret Access Key
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sm:col-span-4">
|
<div class="mb-6">
|
||||||
<label for="source_secret_key" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Secret Access Key</label>
|
<label for="source_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Path in Bucket</label>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
<i class="fas fa-lock text-secondary-400 dark:text-secondary-600"></i>
|
<i class="fas fa-folder-open text-gray-400 dark:text-gray-500"></i>
|
||||||
|
</div>
|
||||||
|
<input type="text" id="source_path" name="source_path" x-model="sourcePath"
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
placeholder="path/to/files/" />
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Path prefix within the bucket (e.g., "backups/"). Leave empty to access the entire bucket.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-4 mb-4 text-sm text-yellow-800 rounded-lg bg-yellow-50 dark:bg-yellow-900/30 dark:text-yellow-300" role="alert">
|
||||||
|
<div class="flex">
|
||||||
|
<i class="fas fa-shield-alt mr-2 flex-shrink-0"></i>
|
||||||
|
<div>
|
||||||
|
<h3 class="font-medium">Security Note</h3>
|
||||||
|
<p class="mt-1">It's recommended to use an IAM user with restricted permissions for this configuration. The IAM user should only have access to the specific S3 bucket and operations needed.</p>
|
||||||
</div>
|
</div>
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
name="source_secret_key"
|
|
||||||
id="source_secret_key"
|
|
||||||
x-model="sourceSecretKey"
|
|
||||||
required
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
placeholder="Your secret access key"/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,147 +1,97 @@
|
|||||||
package source
|
package source
|
||||||
|
|
||||||
templ SFTPSourceForm() {
|
templ SFTPSourceForm() {
|
||||||
<div class="sm:col-span-6 space-y-6">
|
<div class="space-y-6 mt-4">
|
||||||
<div class="sm:col-span-4">
|
<div>
|
||||||
<label for="source_host" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Host</label>
|
<label for="source_host" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">SFTP Host</label>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
<i class="fas fa-server text-secondary-400 dark:text-secondary-600"></i>
|
<i class="fas fa-server text-gray-400 dark:text-gray-500"></i>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input type="text" id="source_host" name="source_host" x-model="sourceHost" required
|
||||||
type="text"
|
class="bg-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"
|
||||||
name="source_host"
|
placeholder="sftp.example.com" />
|
||||||
id="source_host"
|
|
||||||
x-model="sourceHost"
|
|
||||||
required
|
|
||||||
aria-describedby="source_host_help"
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
placeholder="sftp.example.com"/>
|
|
||||||
</div>
|
</div>
|
||||||
<p id="source_host_help" class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Hostname or IP address of the SFTP server</p>
|
||||||
Enter the SFTP server hostname or IP address.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sm:col-span-4">
|
<div>
|
||||||
<label for="source_port" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Port</label>
|
<label for="source_port" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Port</label>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
<i class="fas fa-plug text-secondary-400 dark:text-secondary-600"></i>
|
<i class="fas fa-network-wired text-gray-400 dark:text-gray-500"></i>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input type="number" id="source_port" name="source_port" x-model="sourcePort"
|
||||||
type="number"
|
class="bg-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"
|
||||||
name="source_port"
|
placeholder="22" min="1" max="65535" />
|
||||||
id="source_port"
|
|
||||||
x-model="sourcePort"
|
|
||||||
required
|
|
||||||
min="1"
|
|
||||||
max="65535"
|
|
||||||
value="22"
|
|
||||||
aria-describedby="source_port_help"
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
placeholder="22"/>
|
|
||||||
</div>
|
</div>
|
||||||
<p id="source_port_help" class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">SFTP port number (default: 22)</p>
|
||||||
Default SFTP port is 22.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sm:col-span-4">
|
<div>
|
||||||
<label for="source_path" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Remote Path</label>
|
<label for="source_user" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Username</label>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
<i class="fas fa-folder text-secondary-400 dark:text-secondary-600"></i>
|
<i class="fas fa-user text-gray-400 dark:text-gray-500"></i>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input type="text" id="source_user" name="source_user" x-model="sourceUser" required
|
||||||
type="text"
|
class="bg-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"
|
||||||
name="source_path"
|
placeholder="username" />
|
||||||
id="source_path"
|
|
||||||
x-model="sourcePath"
|
|
||||||
required
|
|
||||||
aria-describedby="source_path_help"
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
placeholder="/path/to/files"/>
|
|
||||||
</div>
|
</div>
|
||||||
<p id="source_path_help" class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">SFTP username for authentication</p>
|
||||||
Absolute path to the files on the remote server.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sm:col-span-4">
|
<div>
|
||||||
<label for="source_user" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Username</label>
|
<label class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Authentication Method</label>
|
||||||
<div class="relative">
|
<div class="flex items-center mb-4">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<input id="source_auth_password" type="radio" value="password" name="source_auth_type" x-model="sourceAuthType"
|
||||||
<i class="fas fa-user text-secondary-400 dark:text-secondary-600"></i>
|
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600">
|
||||||
</div>
|
<label for="source_auth_password" class="ms-2 text-sm font-medium text-gray-900 dark:text-white">Password</label>
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="source_user"
|
|
||||||
id="source_user"
|
|
||||||
x-model="sourceUser"
|
|
||||||
required
|
|
||||||
aria-describedby="source_user_help"
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"/>
|
|
||||||
</div>
|
</div>
|
||||||
<p id="source_user_help" class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
<div class="flex items-center">
|
||||||
Username for SFTP authentication.
|
<input id="source_auth_key" type="radio" value="key" name="source_auth_type" x-model="sourceAuthType"
|
||||||
</p>
|
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600">
|
||||||
</div>
|
<label for="source_auth_key" class="ms-2 text-sm font-medium text-gray-900 dark:text-white">SSH Key</label>
|
||||||
|
|
||||||
<div class="sm:col-span-4">
|
|
||||||
<label for="source_auth_type" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Authentication Type</label>
|
|
||||||
<div class="relative">
|
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
|
||||||
<i class="fas fa-lock text-secondary-400 dark:text-secondary-600"></i>
|
|
||||||
</div>
|
|
||||||
<select
|
|
||||||
id="source_auth_type"
|
|
||||||
name="source_auth_type"
|
|
||||||
x-model="sourceAuthType"
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500">
|
|
||||||
<option value="password">Password</option>
|
|
||||||
<option value="key_file">SSH Key File</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sm:col-span-4" x-show="sourceAuthType === 'password'">
|
<div x-show="sourceAuthType === 'password'">
|
||||||
<label for="source_password" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Password</label>
|
<label for="source_password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Password</label>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
|
<i class="fas fa-lock text-gray-400 dark:text-gray-500"></i>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input type="password" id="source_password" name="source_password" x-model="sourcePassword"
|
||||||
type="password"
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
name="source_password"
|
placeholder="Enter password" x-bind:required="sourceAuthType === 'password'" />
|
||||||
id="source_password"
|
|
||||||
x-model="sourcePassword"
|
|
||||||
x-bind:required="sourceAuthType === 'password'"
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
placeholder="Password"/>
|
|
||||||
</div>
|
</div>
|
||||||
<input type="hidden" name="source_password" :value="sourcePassword"/>
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">SFTP password for authentication</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sm:col-span-4" x-show="sourceAuthType === 'key_file'">
|
<div x-show="sourceAuthType === 'key'">
|
||||||
<label for="source_key_file" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Key File</label>
|
<label for="source_key_file" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">SSH Key File</label>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
<i class="fas fa-file-alt text-secondary-400 dark:text-secondary-600"></i>
|
<i class="fas fa-key text-gray-400 dark:text-gray-500"></i>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input type="text" id="source_key_file" name="source_key_file" x-model="sourceKeyFile"
|
||||||
type="text"
|
class="bg-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"
|
||||||
name="source_key_file"
|
placeholder="/path/to/id_rsa" x-bind:required="sourceAuthType === 'key'" />
|
||||||
id="source_key_file"
|
|
||||||
x-model="sourceKeyFile"
|
|
||||||
x-bind:required="sourceAuthType === 'key_file'"
|
|
||||||
aria-describedby="source_key_file_help"
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
placeholder="/path/to/key"/>
|
|
||||||
</div>
|
</div>
|
||||||
<p id="source_key_file_help" class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Path to the SSH private key file</p>
|
||||||
Absolute path to SSH private key file.
|
</div>
|
||||||
</p>
|
|
||||||
|
<div>
|
||||||
|
<label for="source_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Remote Path</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
|
<i class="fas fa-folder-open text-gray-400 dark:text-gray-500"></i>
|
||||||
|
</div>
|
||||||
|
<input type="text" id="source_path" name="source_path" x-model="sourcePath" required
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
placeholder="/remote/path/to/files" />
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Full path to the directory on the SFTP server</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -1,111 +1,112 @@
|
|||||||
package source
|
package source
|
||||||
|
|
||||||
templ SMBSourceForm() {
|
templ SMBSourceForm() {
|
||||||
<div class="sm:col-span-6 space-y-6">
|
<div class="space-y-6 mt-4">
|
||||||
<div class="sm:col-span-4">
|
<div class="p-4 mb-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900/30 dark:text-blue-300" role="alert">
|
||||||
<label for="source_host" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Server</label>
|
<div class="flex items-center">
|
||||||
<div class="relative">
|
<i class="fas fa-info-circle mr-2"></i>
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<span>Configure your SMB/CIFS share connection details below. You'll need server address, share name, and credentials.</span>
|
||||||
<i class="fas fa-server text-secondary-400 dark:text-secondary-600"></i>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="source_host"
|
|
||||||
id="source_host"
|
|
||||||
x-model="sourceHost"
|
|
||||||
required
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
placeholder="server.example.com"/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sm:col-span-4">
|
<div class="mb-6">
|
||||||
<label for="source_share" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Share Name</label>
|
<label for="source_host" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Server Address</label>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
<i class="fas fa-share-alt text-secondary-400 dark:text-secondary-600"></i>
|
<i class="fas fa-server text-gray-400 dark:text-gray-500"></i>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input type="text" id="source_host" name="source_host" x-model="sourceHost" required
|
||||||
type="text"
|
class="bg-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"
|
||||||
name="source_share"
|
placeholder="192.168.1.100 or server.example.com" />
|
||||||
id="source_share"
|
|
||||||
x-model="sourceShare"
|
|
||||||
required
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
placeholder="share_name"/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
IP address or hostname of the SMB/CIFS server
|
||||||
<div class="sm:col-span-4">
|
|
||||||
<label for="source_path" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Path</label>
|
|
||||||
<div class="relative">
|
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
|
||||||
<i class="fas fa-folder text-secondary-400 dark:text-secondary-600"></i>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="source_path"
|
|
||||||
id="source_path"
|
|
||||||
x-model="sourcePath"
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
placeholder="path\to\files"/>
|
|
||||||
</div>
|
|
||||||
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
|
||||||
Optional. Subdirectory within the share.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sm:col-span-4">
|
<div class="mb-6">
|
||||||
<label for="source_domain" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Domain</label>
|
<label for="source_share" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Share Name</label>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
<i class="fas fa-building text-secondary-400 dark:text-secondary-600"></i>
|
<i class="fas fa-share-alt text-gray-400 dark:text-gray-500"></i>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input type="text" id="source_share" name="source_share" x-model="sourceShare" required
|
||||||
type="text"
|
class="bg-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"
|
||||||
name="source_domain"
|
placeholder="Documents" />
|
||||||
id="source_domain"
|
|
||||||
x-model="sourceDomain"
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
placeholder="DOMAIN"/>
|
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
Optional. Windows domain for authentication.
|
Name of the shared folder on the server (without leading slashes)
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sm:col-span-4">
|
<div class="mb-6">
|
||||||
<label for="source_user" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Username</label>
|
<label for="source_domain" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Domain (Optional)</label>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
<i class="fas fa-user text-secondary-400 dark:text-secondary-600"></i>
|
<i class="fas fa-sitemap text-gray-400 dark:text-gray-500"></i>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input type="text" id="source_domain" name="source_domain" x-model="sourceDomain"
|
||||||
type="text"
|
class="bg-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"
|
||||||
name="source_user"
|
placeholder="WORKGROUP or DOMAIN" />
|
||||||
id="source_user"
|
|
||||||
x-model="sourceUser"
|
|
||||||
required
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"/>
|
|
||||||
</div>
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Windows domain name (if applicable). Default is 'WORKGROUP' for standalone servers.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sm:col-span-4">
|
<div class="mb-6">
|
||||||
<label for="source_password" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Password</label>
|
<label for="source_user" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Username</label>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
|
<i class="fas fa-user text-gray-400 dark:text-gray-500"></i>
|
||||||
|
</div>
|
||||||
|
<input type="text" id="source_user" name="source_user" x-model="sourceUser" required
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
placeholder="smb_username" />
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Username for SMB/CIFS authentication
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-6">
|
||||||
|
<label for="source_password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Password</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
|
<i class="fas fa-lock text-gray-400 dark:text-gray-500"></i>
|
||||||
|
</div>
|
||||||
|
<input type="password" id="source_password" name="source_password" x-model="sourcePassword" required
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
placeholder="SMB password" />
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Password for SMB/CIFS authentication
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-6">
|
||||||
|
<label for="source_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Subfolder Path (Optional)</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
|
<i class="fas fa-folder-open text-gray-400 dark:text-gray-500"></i>
|
||||||
|
</div>
|
||||||
|
<input type="text" id="source_path" name="source_path" x-model="sourcePath"
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
placeholder="path/to/subfolder" />
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Path to a subfolder within the share (Optional). Use forward slashes.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-4 mb-4 text-sm text-yellow-800 rounded-lg bg-yellow-50 dark:bg-yellow-900/30 dark:text-yellow-300" role="alert">
|
||||||
|
<div class="flex">
|
||||||
|
<i class="fas fa-exclamation-triangle mr-2 flex-shrink-0"></i>
|
||||||
|
<div>
|
||||||
|
<h3 class="font-medium">Connection Note</h3>
|
||||||
|
<p class="mt-1">Make sure the SMB server is accessible from this machine and that the share permissions allow the specified user to read the files.</p>
|
||||||
</div>
|
</div>
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
name="source_password"
|
|
||||||
id="source_password"
|
|
||||||
x-model="sourcePassword"
|
|
||||||
required
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
placeholder="Password"/>
|
|
||||||
</div>
|
</div>
|
||||||
<input type="hidden" name="source_password" :value="sourcePassword"/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
package source
|
||||||
|
|
||||||
|
templ WasabiSourceForm() {
|
||||||
|
<div class="space-y-6 mt-4">
|
||||||
|
<div class="p-4 mb-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900/30 dark:text-blue-300" role="alert">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<i class="fas fa-info-circle mr-2"></i>
|
||||||
|
<span>Configure your Wasabi Cloud Storage details below. You'll need your access key, secret key, region, and bucket information.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-6">
|
||||||
|
<label for="source_region" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Wasabi Region</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
|
<i class="fas fa-globe text-gray-400 dark:text-gray-500"></i>
|
||||||
|
</div>
|
||||||
|
<input type="text" id="source_region" name="source_region" x-model="sourceRegion" required
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
placeholder="us-east-1" />
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Wasabi region where your bucket is located (e.g., us-east-1, us-west-1, eu-central-1)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-6">
|
||||||
|
<label for="source_bucket" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Bucket Name</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
|
<i class="fas fa-archive text-gray-400 dark:text-gray-500"></i>
|
||||||
|
</div>
|
||||||
|
<input type="text" id="source_bucket" name="source_bucket" x-model="sourceBucket" required
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
placeholder="my-wasabi-bucket" />
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Name of your Wasabi bucket (case-sensitive)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-6">
|
||||||
|
<label for="source_access_key" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Access Key</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
|
<i class="fas fa-key text-gray-400 dark:text-gray-500"></i>
|
||||||
|
</div>
|
||||||
|
<input type="text" id="source_access_key" name="source_access_key" x-model="sourceAccessKey" required
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
placeholder="ABCDEFGHIJKLMNOPQRST" />
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Your Wasabi Access Key
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-6">
|
||||||
|
<label for="source_secret_key" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Secret Key</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
|
<i class="fas fa-lock text-gray-400 dark:text-gray-500"></i>
|
||||||
|
</div>
|
||||||
|
<input type="password" id="source_secret_key" name="source_secret_key" x-model="sourceSecretKey" required
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
placeholder="abcdefghijklmnopqrstuvwxyz1234567890ABCD" />
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Your Wasabi Secret Key
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-6">
|
||||||
|
<label for="source_endpoint" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Endpoint (Optional)</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
|
<i class="fas fa-server text-gray-400 dark:text-gray-500"></i>
|
||||||
|
</div>
|
||||||
|
<input type="text" id="source_endpoint" name="source_endpoint" x-model="sourceEndpoint"
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
placeholder="s3.wasabisys.com" />
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Wasabi endpoint URL (usually s3.wasabisys.com or region-specific endpoints)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-6">
|
||||||
|
<label for="source_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Path in Bucket</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
|
<i class="fas fa-folder-open text-gray-400 dark:text-gray-500"></i>
|
||||||
|
</div>
|
||||||
|
<input type="text" id="source_path" name="source_path" x-model="sourcePath"
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
placeholder="path/to/files/" />
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Path prefix within the bucket (e.g., "backups/"). Leave empty to access the entire bucket.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-4 mb-4 text-sm text-yellow-800 rounded-lg bg-yellow-50 dark:bg-yellow-900/30 dark:text-yellow-300" role="alert">
|
||||||
|
<div class="flex">
|
||||||
|
<i class="fas fa-shield-alt mr-2 flex-shrink-0"></i>
|
||||||
|
<div>
|
||||||
|
<h3 class="font-medium">Security Note</h3>
|
||||||
|
<p class="mt-1">It's recommended to use an IAM user with restricted permissions for this configuration. The IAM user should only have access to the specific Wasabi bucket and operations needed.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@@ -1,75 +1,82 @@
|
|||||||
package source
|
package source
|
||||||
|
|
||||||
templ WebDAVSourceForm() {
|
templ WebDAVSourceForm() {
|
||||||
<div class="sm:col-span-6 space-y-6">
|
<div class="space-y-6 mt-4">
|
||||||
<div class="sm:col-span-4">
|
<div class="p-4 mb-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900/30 dark:text-blue-300" role="alert">
|
||||||
<label for="source_host" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">WebDAV URL</label>
|
<div class="flex items-center">
|
||||||
<div class="relative">
|
<i class="fas fa-info-circle mr-2"></i>
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<span>Configure your WebDAV connection details below. You'll need the server URL, username, and password.</span>
|
||||||
<i class="fas fa-globe text-secondary-400 dark:text-secondary-600"></i>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="source_host"
|
|
||||||
id="source_host"
|
|
||||||
x-model="sourceHost"
|
|
||||||
required
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
placeholder="https://webdav.example.com/remote.php/webdav/"/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sm:col-span-4">
|
<div class="mb-6">
|
||||||
<label for="source_path" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Remote Path</label>
|
<label for="source_host" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">WebDAV URL</label>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
<i class="fas fa-folder text-secondary-400 dark:text-secondary-600"></i>
|
<i class="fas fa-globe text-gray-400 dark:text-gray-500"></i>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input type="text" id="source_host" name="source_host" x-model="sourceHost" required
|
||||||
type="text"
|
class="bg-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"
|
||||||
name="source_path"
|
placeholder="https://webdav.example.com" />
|
||||||
id="source_path"
|
|
||||||
x-model="sourcePath"
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
placeholder="/path/to/files"/>
|
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
Optional. Relative to the WebDAV URL.
|
Full URL to your WebDAV server including protocol (https://)
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sm:col-span-4">
|
<div class="mb-6">
|
||||||
<label for="source_user" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Username</label>
|
<label for="source_user" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Username</label>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
<i class="fas fa-user text-secondary-400 dark:text-secondary-600"></i>
|
<i class="fas fa-user text-gray-400 dark:text-gray-500"></i>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input type="text" id="source_user" name="source_user" x-model="sourceUser" required
|
||||||
type="text"
|
class="bg-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"
|
||||||
name="source_user"
|
placeholder="username" />
|
||||||
id="source_user"
|
|
||||||
x-model="sourceUser"
|
|
||||||
required
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"/>
|
|
||||||
</div>
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Your WebDAV username
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sm:col-span-4">
|
<div class="mb-6">
|
||||||
<label for="source_password" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Password</label>
|
<label for="source_password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Password</label>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
|
<i class="fas fa-lock text-gray-400 dark:text-gray-500"></i>
|
||||||
|
</div>
|
||||||
|
<input type="password" id="source_password" name="source_password" x-model="sourcePassword" required
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
placeholder="password" />
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Your WebDAV account password
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-6">
|
||||||
|
<label for="source_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Remote Path</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
|
<i class="fas fa-folder-open text-gray-400 dark:text-gray-500"></i>
|
||||||
|
</div>
|
||||||
|
<input type="text" id="source_path" name="source_path" x-model="sourcePath"
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
placeholder="/path/to/files" />
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Path to your files on the WebDAV server
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-4 mb-4 text-sm text-yellow-800 rounded-lg bg-yellow-50 dark:bg-yellow-900/30 dark:text-yellow-300" role="alert">
|
||||||
|
<div class="flex">
|
||||||
|
<i class="fas fa-lightbulb mr-2 flex-shrink-0"></i>
|
||||||
|
<div>
|
||||||
|
<h3 class="font-medium">Integration Note</h3>
|
||||||
|
<p class="mt-1">WebDAV is supported by many services including ownCloud, Nextcloud, Box, and many others. Make sure your WebDAV URL includes the full path to the WebDAV interface.</p>
|
||||||
</div>
|
</div>
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
name="source_password"
|
|
||||||
id="source_password"
|
|
||||||
x-model="sourcePassword"
|
|
||||||
required
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
placeholder="Password"/>
|
|
||||||
</div>
|
</div>
|
||||||
<input type="hidden" name="source_password" :value="sourcePassword"/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package components
|
||||||
|
|
||||||
|
type RcloneImportPreview struct {
|
||||||
|
Remotes []RcloneRemotePreview
|
||||||
|
Error string
|
||||||
|
}
|
||||||
|
|
||||||
|
type RcloneRemotePreview struct {
|
||||||
|
Name string
|
||||||
|
Type string
|
||||||
|
Fields map[string]string
|
||||||
|
Import bool // Should import
|
||||||
|
}
|
||||||
+151
-81
@@ -6,98 +6,168 @@ import (
|
|||||||
|
|
||||||
templ Register(ctx context.Context, errorMessage string) {
|
templ Register(ctx context.Context, errorMessage string) {
|
||||||
@LayoutWithContext("Register", ctx) {
|
@LayoutWithContext("Register", ctx) {
|
||||||
<div class="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
<div class="p-4 md:p-6 2xl:p-10">
|
||||||
<div class="max-w-md w-full space-y-8">
|
<!-- Page Header -->
|
||||||
<div>
|
<div class="mb-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||||
<h2 class="mt-6 text-center text-3xl font-extrabold text-gray-900">
|
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||||
Create a new user account
|
<i class="fas fa-user-plus w-6 h-6 mr-2 text-blue-500"></i>
|
||||||
</h2>
|
Create Account
|
||||||
<p class="mt-2 text-center text-sm text-gray-600">
|
</h1>
|
||||||
Complete the form below to create a new user
|
</div>
|
||||||
</p>
|
<p class="text-sm text-gray-500 dark:text-gray-400 mb-6">
|
||||||
</div>
|
Complete the form below to create a new user account
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Registration Card -->
|
||||||
|
<div class="max-w-md mx-auto">
|
||||||
if errorMessage != "" {
|
if errorMessage != "" {
|
||||||
<div class="mt-4">
|
<div class="p-4 mb-6 text-sm text-red-800 rounded-lg bg-red-50 dark:bg-red-900/50 dark:text-red-400 border border-red-200 dark:border-red-800" role="alert">
|
||||||
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative" role="alert">
|
<div class="flex items-center">
|
||||||
<span class="block sm:inline">{ errorMessage }</span>
|
<i class="fas fa-exclamation-circle flex-shrink-0 mr-2"></i>
|
||||||
|
<span>{ errorMessage }</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
<form class="mt-8 space-y-6" hx-post="/register" hx-target="body"
|
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800">
|
||||||
x-data="{
|
<div class="p-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
email: '',
|
<div class="flex items-center space-x-3">
|
||||||
password: '',
|
<div class="h-10 w-10 bg-blue-100 dark:bg-blue-900 rounded-full flex items-center justify-center">
|
||||||
confirmPassword: '',
|
<i class="fas fa-user-plus text-blue-600 dark:text-blue-300"></i>
|
||||||
loading: false,
|
</div>
|
||||||
validate() {
|
<div>
|
||||||
return this.email &&
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
this.password &&
|
Registration
|
||||||
this.confirmPassword &&
|
</h3>
|
||||||
this.password === this.confirmPassword;
|
<p class="text-sm text-gray-500 dark:text-gray-400">Create your account credentials</p>
|
||||||
}
|
</div>
|
||||||
}"
|
|
||||||
@htmx:before-request="loading = true"
|
|
||||||
@htmx:after-request="loading = false">
|
|
||||||
<input type="hidden" name="remember" value="true" />
|
|
||||||
<div class="rounded-md shadow-sm -space-y-px">
|
|
||||||
<div>
|
|
||||||
<label for="email" class="sr-only">Email address</label>
|
|
||||||
<input
|
|
||||||
id="email"
|
|
||||||
name="email"
|
|
||||||
type="email"
|
|
||||||
required
|
|
||||||
x-model="email"
|
|
||||||
class="form-input rounded-t-md"
|
|
||||||
placeholder="Email address"/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label for="password" class="sr-only">Password</label>
|
|
||||||
<input
|
|
||||||
id="password"
|
|
||||||
name="password"
|
|
||||||
type="password"
|
|
||||||
required
|
|
||||||
x-model="password"
|
|
||||||
class="form-input"
|
|
||||||
placeholder="Password"/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label for="confirm-password" class="sr-only">Confirm Password</label>
|
|
||||||
<input
|
|
||||||
id="confirm-password"
|
|
||||||
name="confirm_password"
|
|
||||||
type="password"
|
|
||||||
required
|
|
||||||
x-model="confirmPassword"
|
|
||||||
class="form-input rounded-b-md"
|
|
||||||
placeholder="Confirm Password"/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="p-6">
|
||||||
|
<form
|
||||||
|
hx-post="/register"
|
||||||
|
hx-target="body"
|
||||||
|
class="space-y-4"
|
||||||
|
x-data="{
|
||||||
|
email: '',
|
||||||
|
password: '',
|
||||||
|
confirmPassword: '',
|
||||||
|
loading: false,
|
||||||
|
validate() {
|
||||||
|
return this.email &&
|
||||||
|
this.password &&
|
||||||
|
this.confirmPassword &&
|
||||||
|
this.password === this.confirmPassword;
|
||||||
|
}
|
||||||
|
}"
|
||||||
|
@htmx:before-request="loading = true"
|
||||||
|
@htmx:after-request="loading = false">
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="email" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
|
||||||
|
Email Address
|
||||||
|
</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
|
||||||
|
<i class="fas fa-envelope text-gray-400 dark:text-gray-500"></i>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
x-model="email"
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full pl-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
placeholder="your.email@example.com"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
|
||||||
|
<i class="fas fa-lock text-gray-400 dark:text-gray-500"></i>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
x-model="password"
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full pl-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
placeholder="••••••••"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="confirm-password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
|
||||||
|
Confirm Password
|
||||||
|
</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
|
||||||
|
<i class="fas fa-lock text-gray-400 dark:text-gray-500"></i>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="confirm-password"
|
||||||
|
name="confirm_password"
|
||||||
|
x-model="confirmPassword"
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full pl-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
placeholder="••••••••"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<template x-if="confirmPassword && password !== confirmPassword">
|
||||||
|
<p class="mt-2 text-sm text-red-600 dark:text-red-500">
|
||||||
|
<i class="fas fa-exclamation-circle mr-1"></i>
|
||||||
|
Passwords do not match
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="w-full text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800"
|
||||||
|
x-bind:disabled="!validate() || loading">
|
||||||
|
<span x-show="!loading" class="flex items-center justify-center">
|
||||||
|
<i class="fas fa-user-plus mr-2"></i>
|
||||||
|
Create Account
|
||||||
|
</span>
|
||||||
|
<span x-show="loading" class="flex items-center justify-center">
|
||||||
|
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
Processing...
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="p-4 bg-gray-50 dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 rounded-b-lg">
|
||||||
|
<div class="text-center">
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Already have an account?
|
||||||
|
<a href="/login" class="font-medium text-blue-600 dark:text-blue-500 hover:underline">
|
||||||
|
Sign in
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Help Notice -->
|
||||||
|
<div class="mt-6 max-w-md mx-auto">
|
||||||
|
<div class="p-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900/50 dark:text-blue-400 border border-blue-200 dark:border-blue-800 flex items-start">
|
||||||
|
<i class="fas fa-info-circle flex-shrink-0 mr-3 mt-0.5"></i>
|
||||||
<div>
|
<div>
|
||||||
<button
|
<h4 class="text-sm font-medium mb-1">Account Information</h4>
|
||||||
type="submit"
|
<p>Creating an account will allow you to manage file transfers and configurations. Your email address will only be used for authentication and important notifications related to your transfers.</p>
|
||||||
class="btn-primary w-full flex justify-center"
|
|
||||||
x-bind:disabled="!validate() || loading">
|
|
||||||
<span x-show="!loading">Create User</span>
|
|
||||||
<span x-show="loading" class="flex items-center">
|
|
||||||
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
|
||||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
|
||||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
|
||||||
</svg>
|
|
||||||
Processing...
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
|
||||||
<div class="text-center">
|
|
||||||
<p class="text-sm text-gray-600">
|
|
||||||
Already have an account?
|
|
||||||
<a href="/login" class="font-medium text-blue-600 hover:text-blue-500">Sign in</a>
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,773 @@
|
|||||||
|
package components
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NotificationService defines a notification service configuration
|
||||||
|
type NotificationService struct {
|
||||||
|
ID uint
|
||||||
|
Name string
|
||||||
|
Type string // "email", "webhook", etc.
|
||||||
|
IsEnabled bool
|
||||||
|
Config map[string]string
|
||||||
|
Description string
|
||||||
|
EventTriggers []string
|
||||||
|
PayloadTemplate string
|
||||||
|
SecretKey string
|
||||||
|
RetryPolicy string
|
||||||
|
SuccessCount int
|
||||||
|
FailureCount int
|
||||||
|
}
|
||||||
|
|
||||||
|
// SettingsNotificationsData contains data for the notifications page in settings
|
||||||
|
type SettingsNotificationsData struct {
|
||||||
|
NotificationServices []NotificationService
|
||||||
|
ErrorMessage string
|
||||||
|
SuccessMessage string
|
||||||
|
}
|
||||||
|
|
||||||
|
// SettingsData contains data for the settings page
|
||||||
|
type SettingsData struct {
|
||||||
|
NotificationServices []NotificationService
|
||||||
|
ErrorMessage string
|
||||||
|
SuccessMessage string
|
||||||
|
}
|
||||||
|
|
||||||
|
templ Settings(ctx context.Context, data SettingsData) {
|
||||||
|
@LayoutWithContext("Application Settings", ctx) {
|
||||||
|
<div id="settings-container" style="min-height: 100vh;" class="bg-gray-50 dark:bg-gray-900">
|
||||||
|
<div class="pb-8 w-full">
|
||||||
|
<div class="mb-6">
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||||
|
<i class="fas fa-cog w-6 h-6 mr-2 text-blue-500"></i>
|
||||||
|
Application Settings
|
||||||
|
</h1>
|
||||||
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Configure global settings for your GoMFT instance.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<!-- Success Message -->
|
||||||
|
if data.SuccessMessage != "" {
|
||||||
|
<div class="p-4 mb-6 text-sm text-green-800 rounded-lg bg-green-50 dark:bg-green-900 dark:text-green-400" role="alert">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<i class="fas fa-check-circle mr-2"></i>
|
||||||
|
<span>{ data.SuccessMessage }</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<!-- Error Message -->
|
||||||
|
if data.ErrorMessage != "" {
|
||||||
|
<div class="p-4 mb-6 text-sm text-red-800 rounded-lg bg-red-50 dark:bg-red-900 dark:text-red-400" role="alert">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<i class="fas fa-exclamation-circle mr-2"></i>
|
||||||
|
<span>{ data.ErrorMessage }</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<!-- Tabs -->
|
||||||
|
<div class="mb-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<ul class="flex flex-wrap -mb-px text-sm font-medium text-center" id="settingsTabs" role="tablist">
|
||||||
|
<li class="mr-2" role="presentation">
|
||||||
|
<button class="inline-block p-4 border-b-2 rounded-t-lg border-blue-600 text-blue-600 dark:text-blue-500 dark:border-blue-500" id="notifications-tab" data-tabs-target="#notifications" type="button" role="tab" aria-controls="notifications" aria-selected="true">
|
||||||
|
<i class="fas fa-bell mr-2"></i>Notifications
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li class="mr-2" role="presentation">
|
||||||
|
<button class="inline-block p-4 border-b-2 border-transparent rounded-t-lg hover:border-gray-300 hover:text-gray-600 dark:hover:text-gray-300" id="general-tab" data-tabs-target="#general" type="button" role="tab" aria-controls="general" aria-selected="false" disabled>
|
||||||
|
<i class="fas fa-sliders-h mr-2"></i>General (Coming Soon)
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li role="presentation">
|
||||||
|
<button class="inline-block p-4 border-b-2 border-transparent rounded-t-lg hover:border-gray-300 hover:text-gray-600 dark:hover:text-gray-300" id="security-tab" data-tabs-target="#security" type="button" role="tab" aria-controls="security" aria-selected="false" disabled>
|
||||||
|
<i class="fas fa-shield-alt mr-2"></i>Security (Coming Soon)
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<!-- Tab Content -->
|
||||||
|
<div id="settingsTabContent">
|
||||||
|
<!-- Notifications Tab -->
|
||||||
|
<div class="block p-4 rounded-lg bg-white dark:bg-gray-800" id="notifications" role="tabpanel" aria-labelledby="notifications-tab">
|
||||||
|
<div class="flex justify-between items-center mb-4">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Notification Services</h2>
|
||||||
|
</div>
|
||||||
|
<!-- Add Notification Service Form -->
|
||||||
|
<div class="mb-6 p-6 border border-gray-200 rounded-lg shadow-sm dark:border-gray-700">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||||
|
Add Notification Service
|
||||||
|
</h3>
|
||||||
|
<form id="add-notification-form" hx-post="/admin/settings/notifications" hx-target="body">
|
||||||
|
<div class="mb-6">
|
||||||
|
<label for="notification_type" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Notification Type</label>
|
||||||
|
<select id="notification_type" name="type" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500">
|
||||||
|
<option value="">Select a type</option>
|
||||||
|
<option value="webhook">Webhook</option>
|
||||||
|
<option value="email" disabled>Email (Coming Soon)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="mb-6 hidden common-fields">
|
||||||
|
<label for="notification_name" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Name</label>
|
||||||
|
<input type="text" id="notification_name" name="name" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="My Notification Service" required/>
|
||||||
|
</div>
|
||||||
|
<div class="mb-6 hidden common-fields">
|
||||||
|
<label for="notification_description" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Description</label>
|
||||||
|
<textarea id="notification_description" name="description" rows="3" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="Description for this notification service"></textarea>
|
||||||
|
</div>
|
||||||
|
<!-- Dynamic fields based on notification type -->
|
||||||
|
<div id="email_fields" class="hidden notification-fields">
|
||||||
|
<div class="mb-6">
|
||||||
|
<label for="smtp_host" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">SMTP Host</label>
|
||||||
|
<input type="text" id="smtp_host" name="smtp_host" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="smtp.example.com"/>
|
||||||
|
</div>
|
||||||
|
<div class="mb-6">
|
||||||
|
<label for="smtp_port" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">SMTP Port</label>
|
||||||
|
<input type="number" id="smtp_port" name="smtp_port" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="587"/>
|
||||||
|
</div>
|
||||||
|
<div class="mb-6">
|
||||||
|
<label for="smtp_username" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">SMTP Username</label>
|
||||||
|
<input type="text" id="smtp_username" name="smtp_username" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="user@example.com"/>
|
||||||
|
</div>
|
||||||
|
<div class="mb-6">
|
||||||
|
<label for="smtp_password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">SMTP Password</label>
|
||||||
|
<input type="password" id="smtp_password" name="smtp_password" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"/>
|
||||||
|
</div>
|
||||||
|
<div class="mb-6">
|
||||||
|
<label for="from_email" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">From Email</label>
|
||||||
|
<input type="email" id="from_email" name="from_email" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="notifications@example.com"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="webhook_fields" class="hidden notification-fields">
|
||||||
|
<div class="mb-6">
|
||||||
|
<label for="webhook_url" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Webhook URL</label>
|
||||||
|
<input type="url" id="webhook_url" name="webhook_url" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="https://api.example.com/webhook"/>
|
||||||
|
</div>
|
||||||
|
<div class="mb-6">
|
||||||
|
<label for="method" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">HTTP Method</label>
|
||||||
|
<select id="method" name="method" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500">
|
||||||
|
<option value="POST">POST</option>
|
||||||
|
<option value="PUT">PUT</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="mb-6">
|
||||||
|
<label for="headers" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Headers (JSON)</label>
|
||||||
|
<textarea id="headers" name="headers" rows="3" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder='{"Content-Type": "application/json", "Authorization": "Bearer token"}'></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="mb-6">
|
||||||
|
<label for="payload_template" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Payload Template (JSON)</label>
|
||||||
|
<textarea
|
||||||
|
id="payload_template"
|
||||||
|
name="payload_template"
|
||||||
|
rows="5"
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
placeholder='{
|
||||||
|
"event": "{{job.event}}",
|
||||||
|
"job": {
|
||||||
|
"id": "{{job.id}}",
|
||||||
|
"name": "{{job.name}}",
|
||||||
|
"status": "{{job.status}}",
|
||||||
|
"message": "{{job.message}}",
|
||||||
|
"started_at": "{{job.started_at}}",
|
||||||
|
"completed_at": "{{job.completed_at}}",
|
||||||
|
"duration_seconds": {{job.duration_seconds}},
|
||||||
|
"config_id": "{{job.config_id}}",
|
||||||
|
"config_name": "{{job.config_name}}",
|
||||||
|
"transfer_bytes": {{job.transfer_bytes}},
|
||||||
|
"file_count": {{job.file_count}}
|
||||||
|
},
|
||||||
|
"instance": {
|
||||||
|
"id": "{{instance.id}}",
|
||||||
|
"name": "{{instance.name}}",
|
||||||
|
"version": "{{instance.version}}",
|
||||||
|
"environment": "{{instance.environment}}"
|
||||||
|
},
|
||||||
|
"timestamp": "{{timestamp}}",
|
||||||
|
"notification_id": "{{notification.id}}"
|
||||||
|
}'
|
||||||
|
></textarea>
|
||||||
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Use { `variable` } placeholders for dynamic values. Available variables: job.*, instance.*, timestamp, notification.*</p>
|
||||||
|
</div>
|
||||||
|
<div class="mb-6">
|
||||||
|
<label class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Event Triggers</label>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<input id="trigger_job_start" name="trigger_job_start" type="checkbox" class="w-4 h-4 border border-gray-300 rounded bg-gray-50 focus:ring-3 focus:ring-blue-300 dark:bg-gray-700 dark:border-gray-600 dark:focus:ring-blue-600 dark:ring-offset-gray-800" checked/>
|
||||||
|
<label for="trigger_job_start" class="ml-2 text-sm font-medium text-gray-900 dark:text-white">Job Start</label>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<input id="trigger_job_complete" name="trigger_job_complete" type="checkbox" class="w-4 h-4 border border-gray-300 rounded bg-gray-50 focus:ring-3 focus:ring-blue-300 dark:bg-gray-700 dark:border-gray-600 dark:focus:ring-blue-600 dark:ring-offset-gray-800" checked/>
|
||||||
|
<label for="trigger_job_complete" class="ml-2 text-sm font-medium text-gray-900 dark:text-white">Job Complete</label>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<input id="trigger_job_error" name="trigger_job_error" type="checkbox" class="w-4 h-4 border border-gray-300 rounded bg-gray-50 focus:ring-3 focus:ring-blue-300 dark:bg-gray-700 dark:border-gray-600 dark:focus:ring-blue-600 dark:ring-offset-gray-800" checked/>
|
||||||
|
<label for="trigger_job_error" class="ml-2 text-sm font-medium text-gray-900 dark:text-white">Job Error</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-6">
|
||||||
|
<label for="secret_key" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Secret Key (for signature verification)</label>
|
||||||
|
<input type="text" id="secret_key" name="secret_key" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="Optional signature verification key"/>
|
||||||
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">If provided, all webhooks will include an X-GoMFT-Signature header</p>
|
||||||
|
</div>
|
||||||
|
<div class="mb-6">
|
||||||
|
<label for="retry_policy" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Retry Policy</label>
|
||||||
|
<select id="retry_policy" name="retry_policy" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500">
|
||||||
|
<option value="none">No retries</option>
|
||||||
|
<option value="simple" selected>Simple (3 retries)</option>
|
||||||
|
<option value="exponential">Exponential backoff</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<!-- Test notification button -->
|
||||||
|
<div class="mb-6 p-4 bg-gray-50 border border-gray-200 rounded-lg dark:bg-gray-700 dark:border-gray-600">
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<h4 class="text-base font-medium text-gray-900 dark:text-white">Test Configuration</h4>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
id="test-webhook-btn"
|
||||||
|
hx-post="/admin/settings/notifications/test"
|
||||||
|
hx-trigger="click"
|
||||||
|
hx-target="#test-notification-result"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
class="px-3 py-2 text-xs font-medium text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 rounded-lg dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800"
|
||||||
|
>
|
||||||
|
<i class="fas fa-paper-plane mr-1"></i>
|
||||||
|
Send Test Notification
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Send a test notification to verify your configuration works correctly before saving.
|
||||||
|
</p>
|
||||||
|
<div id="test-notification-result" class="mt-3 hidden">
|
||||||
|
<!-- Result will be shown here -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-start mb-6 hidden common-fields">
|
||||||
|
<div class="flex items-center h-5">
|
||||||
|
<input id="is_enabled" name="is_enabled" type="checkbox" class="w-4 h-4 border border-gray-300 rounded bg-gray-50 focus:ring-3 focus:ring-blue-300 dark:bg-gray-700 dark:border-gray-600 dark:focus:ring-blue-600 dark:ring-offset-gray-800" checked/>
|
||||||
|
</div>
|
||||||
|
<div class="ml-3 text-sm">
|
||||||
|
<label for="is_enabled" class="font-medium text-gray-900 dark:text-white">Enable this notification service</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="hidden common-fields">
|
||||||
|
<button type="submit" class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800">Add Service</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
if len(data.NotificationServices) == 0 {
|
||||||
|
<div class="text-center py-8">
|
||||||
|
<div class="inline-flex items-center justify-center w-16 h-16 rounded-full bg-blue-100 dark:bg-blue-900 mb-4">
|
||||||
|
<i class="fas fa-bell text-2xl text-blue-600 dark:text-blue-400"></i>
|
||||||
|
</div>
|
||||||
|
<h3 class="mb-2 text-lg font-semibold text-gray-900 dark:text-white">No notification services configured</h3>
|
||||||
|
<p class="text-gray-500 dark:text-gray-400 mb-4">Use the form above to add your first notification service.</p>
|
||||||
|
</div>
|
||||||
|
} else {
|
||||||
|
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 overflow-hidden">
|
||||||
|
<ul class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
for _, service := range data.NotificationServices {
|
||||||
|
<li>
|
||||||
|
<div class="block hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
|
||||||
|
<div class="px-4 py-4 sm:px-6">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center">
|
||||||
|
if service.Type == "email" {
|
||||||
|
<div class="w-10 h-10 rounded-full bg-blue-100 flex items-center justify-center text-blue-600 dark:bg-blue-900 dark:text-blue-400 mr-3">
|
||||||
|
<i class="fas fa-envelope"></i>
|
||||||
|
</div>
|
||||||
|
} else if service.Type == "webhook" {
|
||||||
|
<div class="w-10 h-10 rounded-full bg-green-100 flex items-center justify-center text-green-600 dark:bg-green-900 dark:text-green-400 mr-3">
|
||||||
|
<i class="fas fa-code"></i>
|
||||||
|
</div>
|
||||||
|
} else {
|
||||||
|
<div class="w-10 h-10 rounded-full bg-gray-100 flex items-center justify-center text-gray-600 dark:bg-gray-700 dark:text-gray-400 mr-3">
|
||||||
|
<i class="fas fa-bell"></i>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-blue-600 dark:text-blue-400 truncate">
|
||||||
|
{ service.Name }
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
{ service.Description }
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ml-2 flex-shrink-0 flex space-x-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-gray-500 bg-white focus:outline-none hover:bg-gray-100 focus:ring-4 focus:ring-gray-200 rounded-lg text-sm p-2 mr-1 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white dark:focus:ring-gray-700"
|
||||||
|
data-modal-target="edit-notification-modal"
|
||||||
|
data-modal-toggle="edit-notification-modal"
|
||||||
|
data-service-id={ fmt.Sprint(service.ID) }
|
||||||
|
>
|
||||||
|
<i class="fas fa-edit"></i>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-red-500 bg-white focus:outline-none hover:bg-gray-100 focus:ring-4 focus:ring-gray-200 rounded-lg text-sm p-2 dark:bg-gray-800 dark:text-red-400 dark:hover:bg-gray-700 dark:hover:text-white dark:focus:ring-gray-700"
|
||||||
|
hx-delete={ "/admin/settings/notifications/" + fmt.Sprint(service.ID) }
|
||||||
|
hx-confirm="Are you sure you want to delete this notification service? This cannot be undone."
|
||||||
|
hx-target="body"
|
||||||
|
>
|
||||||
|
<i class="fas fa-trash-alt"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3 sm:flex sm:justify-between">
|
||||||
|
<div class="sm:flex flex-col md:flex-row gap-2 md:gap-6">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<span
|
||||||
|
class={ "px-2 py-1 text-xs font-medium rounded-full",
|
||||||
|
templ.KV("bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300", service.IsEnabled),
|
||||||
|
templ.KV("bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300", !service.IsEnabled) }
|
||||||
|
>
|
||||||
|
if service.IsEnabled {
|
||||||
|
Active
|
||||||
|
} else {
|
||||||
|
Disabled
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
<span class="ml-2 px-2 py-1 text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300 rounded-full">
|
||||||
|
{ service.Type }
|
||||||
|
</span>
|
||||||
|
if len(service.EventTriggers) > 0 && service.Type == "webhook" {
|
||||||
|
<span class="ml-2 px-2 py-1 text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300 rounded-full">
|
||||||
|
{ fmt.Sprintf("%d triggers", len(service.EventTriggers)) }
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
if service.SuccessCount > 0 || service.FailureCount > 0 {
|
||||||
|
<span class="ml-2 px-2 py-1 text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300 rounded-full">
|
||||||
|
{ fmt.Sprintf("%d/%d", service.SuccessCount, service.SuccessCount + service.FailureCount) }
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
if service.Type == "webhook" {
|
||||||
|
<div class="mt-2 md:mt-0 flex items-center space-x-4">
|
||||||
|
<div class="text-xs">
|
||||||
|
<span class="text-gray-500 dark:text-gray-400">Events:</span>
|
||||||
|
<span class="ml-1 text-gray-900 dark:text-gray-300">
|
||||||
|
if len(service.EventTriggers) == 0 {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
for i, trigger := range service.EventTriggers {
|
||||||
|
if i > 0 {
|
||||||
|
<span>, </span>
|
||||||
|
}
|
||||||
|
{ trigger }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs">
|
||||||
|
<span class="text-gray-500 dark:text-gray-400">Retry:</span>
|
||||||
|
<span class="ml-1 text-gray-900 dark:text-gray-300">
|
||||||
|
if service.RetryPolicy == "" {
|
||||||
|
Default
|
||||||
|
} else {
|
||||||
|
{ service.RetryPolicy }
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
} else {
|
||||||
|
<div class="mt-2 md:mt-0 flex items-center text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
<i class="far fa-clock w-4 h-4 mr-1.5 text-gray-400 dark:text-gray-500"></i>
|
||||||
|
<p>
|
||||||
|
Last sent:
|
||||||
|
if service.SuccessCount > 0 {
|
||||||
|
"Recently"
|
||||||
|
} else {
|
||||||
|
"Never"
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<!-- General Tab -->
|
||||||
|
<div class="hidden p-4 rounded-lg bg-white dark:bg-gray-800" id="general" role="tabpanel" aria-labelledby="general-tab">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">General Settings</h2>
|
||||||
|
<form class="space-y-6" action="/settings/general" method="POST">
|
||||||
|
<div class="grid gap-6 mb-6 md:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label for="app_name" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Application Name</label>
|
||||||
|
<input type="text" id="app_name" name="app_name" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" value="GoMFT"/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="base_url" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Base URL</label>
|
||||||
|
<input type="url" id="base_url" name="base_url" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" value="http://localhost:8080"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800">Save Settings</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<!-- Security Tab -->
|
||||||
|
<div class="hidden p-4 rounded-lg bg-white dark:bg-gray-800" id="security" role="tabpanel" aria-labelledby="security-tab">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Security Settings</h2>
|
||||||
|
<!-- Authentication Sources -->
|
||||||
|
<div class="mb-8">
|
||||||
|
<div class="flex justify-between items-center mb-4">
|
||||||
|
<h3 class="text-lg font-medium text-gray-900 dark:text-white">Authentication Sources</h3>
|
||||||
|
<button type="button" data-modal-target="add-auth-source-modal" data-modal-toggle="add-auth-source-modal" class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-4 py-2 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800">
|
||||||
|
<i class="fas fa-plus mr-2"></i>Add Source
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 overflow-hidden">
|
||||||
|
<div class="p-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Configure external authentication providers to allow users to log in using existing accounts.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<!-- Authentication Sources List -->
|
||||||
|
<ul class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
<!-- Local Authentication (always present) -->
|
||||||
|
<li>
|
||||||
|
<div class="px-4 py-4 sm:px-6">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="w-10 h-10 rounded-full bg-gray-100 flex items-center justify-center text-gray-600 dark:bg-gray-700 dark:text-gray-400 mr-3">
|
||||||
|
<i class="fas fa-key"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-blue-600 dark:text-blue-400">
|
||||||
|
Local Authentication
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
Default username and password authentication
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ml-2 flex-shrink-0 flex">
|
||||||
|
<span class="px-2 py-1 text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300 rounded-full">
|
||||||
|
Active
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<!-- Authentik SSO (if configured) -->
|
||||||
|
<!-- This would be populated from backend data in a real implementation -->
|
||||||
|
<li>
|
||||||
|
<div class="px-4 py-4 sm:px-6">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="w-10 h-10 rounded-full bg-purple-100 flex items-center justify-center text-purple-600 dark:bg-purple-900 dark:text-purple-400 mr-3">
|
||||||
|
<i class="fas fa-passport"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-blue-600 dark:text-blue-400">
|
||||||
|
Authentik SSO
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
Single Sign-On with Authentik Identity Provider
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ml-2 flex items-center space-x-2">
|
||||||
|
<span class="px-2 py-1 text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300 rounded-full">
|
||||||
|
Active
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-gray-500 bg-white focus:outline-none hover:bg-gray-100 focus:ring-4 focus:ring-gray-200 rounded-lg text-sm p-2 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white dark:focus:ring-gray-700"
|
||||||
|
data-modal-target="edit-auth-source-modal"
|
||||||
|
data-modal-toggle="edit-auth-source-modal"
|
||||||
|
>
|
||||||
|
<i class="fas fa-edit"></i>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-red-500 bg-white focus:outline-none hover:bg-gray-100 focus:ring-4 focus:ring-gray-200 rounded-lg text-sm p-2 dark:bg-gray-800 dark:text-red-400 dark:hover:bg-gray-700 dark:hover:text-white dark:focus:ring-gray-700"
|
||||||
|
hx-delete="/admin/settings/auth-sources/1"
|
||||||
|
hx-confirm="Are you sure you want to delete this authentication source?"
|
||||||
|
hx-target="body"
|
||||||
|
>
|
||||||
|
<i class="fas fa-trash-alt"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 sm:flex sm:justify-between">
|
||||||
|
<div class="sm:flex gap-4">
|
||||||
|
<div class="flex items-center text-xs">
|
||||||
|
<span class="text-gray-500 dark:text-gray-400">Client ID:</span>
|
||||||
|
<span class="ml-1 text-gray-900 dark:text-gray-300">gomft_app</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center text-xs">
|
||||||
|
<span class="text-gray-500 dark:text-gray-400">Provider URL:</span>
|
||||||
|
<span class="ml-1 text-gray-900 dark:text-gray-300">https://auth.example.com</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form class="space-y-6" hx-post="/admin/settings/security" hx-target="body">
|
||||||
|
<div class="grid gap-6 mb-6 md:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label for="session_timeout" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Session Timeout (minutes)</label>
|
||||||
|
<input type="number" id="session_timeout" name="session_timeout" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" value="60" min="5" max="1440"/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="password_policy" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Password Policy</label>
|
||||||
|
<select id="password_policy" name="password_policy" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500">
|
||||||
|
<option value="standard">Standard (8+ chars, 1 uppercase, 1 number)</option>
|
||||||
|
<option value="strong">Strong (12+ chars, uppercase, number, symbol)</option>
|
||||||
|
<option value="very_strong">Very Strong (16+ chars, uppercase, number, symbol)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-start mb-6">
|
||||||
|
<div class="flex items-center h-5">
|
||||||
|
<input id="enable_2fa" name="enable_2fa" type="checkbox" class="w-4 h-4 border border-gray-300 rounded bg-gray-50 focus:ring-3 focus:ring-blue-300 dark:bg-gray-700 dark:border-gray-600 dark:focus:ring-blue-600 dark:ring-offset-gray-800" checked/>
|
||||||
|
</div>
|
||||||
|
<div class="ml-3 text-sm">
|
||||||
|
<label for="enable_2fa" class="font-medium text-gray-900 dark:text-white">Enable Two-Factor Authentication</label>
|
||||||
|
<p class="text-xs font-normal text-gray-500 dark:text-gray-400">Require 2FA for all admin users</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800">Save Settings</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@toggleNotificationFields()
|
||||||
|
@addAuthSourceModals()
|
||||||
|
@setupTabSwitching()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add new templ component for auth source modals
|
||||||
|
templ addAuthSourceModals() {
|
||||||
|
<!-- Add Authentication Source Modal -->
|
||||||
|
<div id="add-auth-source-modal" tabindex="-1" aria-hidden="true" class="fixed top-0 left-0 right-0 z-50 hidden w-full p-4 overflow-x-hidden overflow-y-auto md:inset-0 h-[calc(100%-1rem)] max-h-full">
|
||||||
|
<div class="relative w-full max-w-2xl max-h-full">
|
||||||
|
<div class="relative bg-white rounded-lg shadow dark:bg-gray-800">
|
||||||
|
<div class="flex items-start justify-between p-4 border-b rounded-t dark:border-gray-700">
|
||||||
|
<h3 class="text-xl font-semibold text-gray-900 dark:text-white">
|
||||||
|
Add Authentication Source
|
||||||
|
</h3>
|
||||||
|
<button type="button" class="text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm w-8 h-8 ml-auto inline-flex justify-center items-center dark:hover:bg-gray-700 dark:hover:text-white" data-modal-hide="add-auth-source-modal">
|
||||||
|
<i class="fas fa-times"></i>
|
||||||
|
<span class="sr-only">Close modal</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<form hx-post="/admin/settings/auth-sources" hx-target="body">
|
||||||
|
<div class="p-6 space-y-6">
|
||||||
|
<div class="mb-6">
|
||||||
|
<label for="auth_type" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Authentication Type</label>
|
||||||
|
<select id="auth_type" name="type" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500">
|
||||||
|
<option value="">Select a type</option>
|
||||||
|
<option value="authentik">Authentik SSO</option>
|
||||||
|
<option value="oauth2">Generic OAuth 2.0</option>
|
||||||
|
<option value="oidc">OpenID Connect</option>
|
||||||
|
<option value="saml" disabled>SAML 2.0 (Coming Soon)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div id="authentik_fields" class="auth-type-fields">
|
||||||
|
<div class="mb-6">
|
||||||
|
<label for="name" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Name</label>
|
||||||
|
<input type="text" id="name" name="name" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="Authentik SSO" required/>
|
||||||
|
</div>
|
||||||
|
<div class="mb-6">
|
||||||
|
<label for="provider_url" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Authentik Provider URL</label>
|
||||||
|
<input type="url" id="provider_url" name="provider_url" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="https://auth.example.com" required/>
|
||||||
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">The base URL of your Authentik instance</p>
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-6 mb-6 md:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label for="client_id" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Client ID</label>
|
||||||
|
<input type="text" id="client_id" name="client_id" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="gomft_app" required/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="client_secret" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Client Secret</label>
|
||||||
|
<input type="password" id="client_secret" name="client_secret" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" required/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-6">
|
||||||
|
<label for="redirect_url" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Redirect URL</label>
|
||||||
|
<input type="text" id="redirect_url" name="redirect_url" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" value="http://localhost:8080/auth/callback" disabled/>
|
||||||
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Configure this URL in your Authentik provider settings</p>
|
||||||
|
</div>
|
||||||
|
<div class="mb-6">
|
||||||
|
<label for="scopes" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Scopes</label>
|
||||||
|
<input type="text" id="scopes" name="scopes" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" value="openid email profile" required/>
|
||||||
|
</div>
|
||||||
|
<div class="mb-6">
|
||||||
|
<label class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">User Attribute Mapping</label>
|
||||||
|
<div class="grid gap-6 mb-6 md:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label for="username_attribute" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Username Attribute</label>
|
||||||
|
<input type="text" id="username_attribute" name="username_attribute" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" value="preferred_username" required/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="email_attribute" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Email Attribute</label>
|
||||||
|
<input type="text" id="email_attribute" name="email_attribute" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" value="email" required/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-6 mb-6 md:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label for="name_attribute" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Name Attribute</label>
|
||||||
|
<input type="text" id="name_attribute" name="name_attribute" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" value="name" required/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="groups_attribute" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Groups Attribute</label>
|
||||||
|
<input type="text" id="groups_attribute" name="groups_attribute" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" value="groups" required/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-start mb-6">
|
||||||
|
<div class="flex items-center h-5">
|
||||||
|
<input id="is_enabled" name="is_enabled" type="checkbox" class="w-4 h-4 border border-gray-300 rounded bg-gray-50 focus:ring-3 focus:ring-blue-300 dark:bg-gray-700 dark:border-gray-600 dark:focus:ring-blue-600 dark:ring-offset-gray-800" checked/>
|
||||||
|
</div>
|
||||||
|
<div class="ml-3 text-sm">
|
||||||
|
<label for="is_enabled" class="font-medium text-gray-900 dark:text-white">Enable this authentication source</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="p-4 mb-6 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900/50 dark:text-blue-400 border border-blue-200 dark:border-blue-800 flex items-start">
|
||||||
|
<i class="fas fa-info-circle flex-shrink-0 mr-3 mt-0.5"></i>
|
||||||
|
<div>
|
||||||
|
<p>To configure Authentik for this application:</p>
|
||||||
|
<ol class="list-decimal ml-5 mt-2">
|
||||||
|
<li>Create a new OAuth2/OIDC Provider in Authentik</li>
|
||||||
|
<li>Set the redirect URI to the value shown above</li>
|
||||||
|
<li>Obtain the Client ID and Secret from Authentik</li>
|
||||||
|
<li>Configure group mappings if needed for permission control</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center p-6 space-x-2 border-t border-gray-200 rounded-b dark:border-gray-700">
|
||||||
|
<button type="submit" class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800">Save</button>
|
||||||
|
<button type="button" class="text-gray-500 bg-white hover:bg-gray-100 focus:ring-4 focus:outline-none focus:ring-blue-300 rounded-lg border border-gray-200 text-sm font-medium px-5 py-2.5 hover:text-gray-900 focus:z-10 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-500 dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-gray-700" data-modal-hide="add-auth-source-modal">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Edit Authentication Source Modal would be similar to the Add modal -->
|
||||||
|
}
|
||||||
|
|
||||||
|
// Script to toggle notification fields based on selection
|
||||||
|
script toggleNotificationFields() {
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const typeSelector = document.getElementById('notification_type');
|
||||||
|
const allFields = document.querySelectorAll('.notification-fields');
|
||||||
|
const commonFields = document.querySelectorAll('.common-fields');
|
||||||
|
|
||||||
|
typeSelector.addEventListener('change', function() {
|
||||||
|
// Hide all fields first
|
||||||
|
allFields.forEach(field => field.classList.add('hidden'));
|
||||||
|
|
||||||
|
// Show/hide common fields based on selection
|
||||||
|
const selectedType = this.value;
|
||||||
|
if (selectedType) {
|
||||||
|
// Show common fields (name, description)
|
||||||
|
commonFields.forEach(field => field.classList.remove('hidden'));
|
||||||
|
|
||||||
|
// Show the selected type's specific fields
|
||||||
|
const fieldsToShow = document.getElementById(`${selectedType}_fields`);
|
||||||
|
if (fieldsToShow) {
|
||||||
|
fieldsToShow.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Hide common fields if no type selected
|
||||||
|
commonFields.forEach(field => field.classList.add('hidden'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add script to toggle authentication source fields based on type selection
|
||||||
|
script toggleAuthSourceFields() {
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const typeSelector = document.getElementById('auth_type');
|
||||||
|
const authFields = document.querySelectorAll('.auth-type-fields');
|
||||||
|
|
||||||
|
if (typeSelector) {
|
||||||
|
typeSelector.addEventListener('change', function() {
|
||||||
|
// Hide all auth type fields first
|
||||||
|
authFields.forEach(field => field.classList.add('hidden'));
|
||||||
|
|
||||||
|
// Show the selected type's fields
|
||||||
|
const selectedType = this.value;
|
||||||
|
if (selectedType) {
|
||||||
|
const fieldsToShow = document.getElementById(`${selectedType}_fields`);
|
||||||
|
if (fieldsToShow) {
|
||||||
|
fieldsToShow.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add script to handle tab switching
|
||||||
|
script setupTabSwitching() {
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const tabButtons = document.querySelectorAll('[role="tab"]');
|
||||||
|
const tabPanels = document.querySelectorAll('[role="tabpanel"]');
|
||||||
|
|
||||||
|
function switchTab(targetId) {
|
||||||
|
// Hide all tab panels
|
||||||
|
tabPanels.forEach(panel => {
|
||||||
|
panel.classList.add('hidden');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Show the target panel
|
||||||
|
const targetPanel = document.querySelector(targetId);
|
||||||
|
if (targetPanel) {
|
||||||
|
targetPanel.classList.remove('hidden');
|
||||||
|
targetPanel.classList.add('block');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update tab button styles
|
||||||
|
tabButtons.forEach(button => {
|
||||||
|
// Get the target from the button
|
||||||
|
const buttonTarget = button.getAttribute('data-tabs-target');
|
||||||
|
|
||||||
|
if (buttonTarget === targetId) {
|
||||||
|
// Active tab
|
||||||
|
button.classList.add('border-blue-600', 'text-blue-600', 'dark:text-blue-500', 'dark:border-blue-500');
|
||||||
|
button.classList.remove('border-transparent', 'hover:border-gray-300', 'hover:text-gray-600', 'dark:hover:text-gray-300');
|
||||||
|
button.setAttribute('aria-selected', 'true');
|
||||||
|
} else {
|
||||||
|
// Inactive tab
|
||||||
|
button.classList.remove('border-blue-600', 'text-blue-600', 'dark:text-blue-500', 'dark:border-blue-500');
|
||||||
|
button.classList.add('border-transparent', 'hover:border-gray-300', 'hover:text-gray-600', 'dark:hover:text-gray-300');
|
||||||
|
button.setAttribute('aria-selected', 'false');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add click event listeners to tab buttons
|
||||||
|
tabButtons.forEach(button => {
|
||||||
|
button.addEventListener('click', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const targetId = this.getAttribute('data-tabs-target');
|
||||||
|
switchTab(targetId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize the tabs (select the one with aria-selected="true")
|
||||||
|
const initialTab = document.querySelector('[role="tab"][aria-selected="true"]');
|
||||||
|
if (initialTab) {
|
||||||
|
const targetId = initialTab.getAttribute('data-tabs-target');
|
||||||
|
switchTab(targetId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package toast
|
||||||
|
|
||||||
|
templ Container() {
|
||||||
|
<div id="toast-container" class="fixed top-5 right-5 z-50 flex flex-col gap-2"></div>
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
package toast
|
||||||
|
|
||||||
|
templ ShowToastJS() {
|
||||||
|
<script>
|
||||||
|
// Notification system
|
||||||
|
// Global tracking of shown messages to prevent duplicates
|
||||||
|
window.shownToastMessages = window.shownToastMessages || [];
|
||||||
|
|
||||||
|
function showToast(message, type) {
|
||||||
|
// Check if this exact message has been shown in the last 500ms
|
||||||
|
const messageKey = `${message}-${type}`;
|
||||||
|
if (window.shownToastMessages.includes(messageKey)) {
|
||||||
|
console.log(`Preventing duplicate toast: ${messageKey}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add to shown messages
|
||||||
|
window.shownToastMessages.push(messageKey);
|
||||||
|
|
||||||
|
// Remove from tracking after 500ms to allow the same message later if needed
|
||||||
|
setTimeout(() => {
|
||||||
|
const index = window.shownToastMessages.indexOf(messageKey);
|
||||||
|
if (index > -1) {
|
||||||
|
window.shownToastMessages.splice(index, 1);
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
|
||||||
|
const toastContainer = document.getElementById('toast-container');
|
||||||
|
if (!toastContainer) {
|
||||||
|
console.error("Toast container not found!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create toast element
|
||||||
|
const toast = document.createElement('div');
|
||||||
|
toast.id = 'toast-' + type + '-' + Date.now();
|
||||||
|
toast.className = 'flex items-center w-full max-w-xs p-4 mb-4 rounded-lg shadow text-gray-500 bg-white dark:text-gray-400 dark:bg-gray-800 transform translate-y-16 opacity-0 transition-all duration-300 ease-out';
|
||||||
|
toast.role = 'alert';
|
||||||
|
|
||||||
|
// Set toast content based on type
|
||||||
|
let iconClass, bgColorClass, textColorClass;
|
||||||
|
|
||||||
|
if (type === 'success') {
|
||||||
|
iconClass = 'text-green-500 bg-green-100 dark:bg-green-800 dark:text-green-200';
|
||||||
|
bgColorClass = 'text-green-500 dark:text-green-200';
|
||||||
|
textColorClass = 'text-green-500 dark:text-green-200';
|
||||||
|
} else if (type === 'error') {
|
||||||
|
iconClass = 'text-red-500 bg-red-100 dark:bg-red-800 dark:text-red-200';
|
||||||
|
bgColorClass = 'text-red-500 dark:text-red-200';
|
||||||
|
textColorClass = 'text-red-500 dark:text-red-200';
|
||||||
|
} else { // Default to info
|
||||||
|
iconClass = 'text-blue-500 bg-blue-100 dark:bg-blue-800 dark:text-blue-200';
|
||||||
|
bgColorClass = 'text-blue-500 dark:text-blue-200';
|
||||||
|
textColorClass = 'text-blue-500 dark:text-blue-200';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create icon div
|
||||||
|
const iconDiv = document.createElement('div');
|
||||||
|
iconDiv.className = `inline-flex items-center justify-center flex-shrink-0 w-8 h-8 rounded-lg ${iconClass}`;
|
||||||
|
iconDiv.innerHTML = type === 'success'
|
||||||
|
? '<i class="fas fa-check"></i>'
|
||||||
|
: type === 'error'
|
||||||
|
? '<i class="fas fa-exclamation-circle"></i>'
|
||||||
|
: '<i class="fas fa-info-circle"></i>';
|
||||||
|
|
||||||
|
// Create message div and set text content safely
|
||||||
|
const messageDiv = document.createElement('div');
|
||||||
|
messageDiv.className = 'ml-3 text-sm font-normal';
|
||||||
|
messageDiv.textContent = message; // Use textContent for safety
|
||||||
|
|
||||||
|
// Create close button
|
||||||
|
const closeButton = document.createElement('button'); // Keep this declaration
|
||||||
|
closeButton.type = 'button';
|
||||||
|
closeButton.className = 'ml-auto -mx-1.5 -my-1.5 bg-white text-gray-400 hover:text-gray-900 rounded-lg focus:ring-2 focus:ring-gray-300 p-1.5 hover:bg-gray-100 inline-flex h-8 w-8 dark:text-gray-500 dark:hover:text-white dark:bg-gray-800 dark:hover:bg-gray-700';
|
||||||
|
closeButton.setAttribute('data-dismiss-target', `#${toast.id}`);
|
||||||
|
closeButton.setAttribute('aria-label', 'Close');
|
||||||
|
closeButton.innerHTML = `
|
||||||
|
<span class="sr-only">Close</span>
|
||||||
|
<i class="fas fa-times"></i>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Append elements to the toast
|
||||||
|
toast.appendChild(iconDiv);
|
||||||
|
toast.appendChild(messageDiv);
|
||||||
|
toast.appendChild(closeButton);
|
||||||
|
|
||||||
|
// Add toast to container
|
||||||
|
toastContainer.appendChild(toast);
|
||||||
|
|
||||||
|
// Trigger animation after a small delay
|
||||||
|
setTimeout(() => {
|
||||||
|
toast.classList.remove('translate-y-16', 'opacity-0');
|
||||||
|
toast.classList.add('translate-y-0', 'opacity-100');
|
||||||
|
}, 10);
|
||||||
|
|
||||||
|
// Add event listener to the close button we created earlier
|
||||||
|
closeButton.addEventListener('click', function() { // Use the existing closeButton variable
|
||||||
|
// Animate out before removing
|
||||||
|
toast.classList.add('opacity-0', 'translate-y-4');
|
||||||
|
setTimeout(() => {
|
||||||
|
toast.remove();
|
||||||
|
}, 300);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto-remove toast after 5 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
toast.classList.add('opacity-0', 'translate-y-4');
|
||||||
|
setTimeout(() => {
|
||||||
|
toast.remove();
|
||||||
|
}, 300);
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
}
|
||||||
@@ -0,0 +1,954 @@
|
|||||||
|
package components
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"github.com/starfleetcptn/gomft/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
type StorageProviderFormData struct {
|
||||||
|
Provider *db.StorageProvider
|
||||||
|
IsEdit bool
|
||||||
|
Error string
|
||||||
|
}
|
||||||
|
|
||||||
|
// getTitle returns the appropriate title based on whether we're editing or creating
|
||||||
|
func getTitle(isEdit bool) string {
|
||||||
|
if isEdit {
|
||||||
|
return "Edit Storage Provider"
|
||||||
|
}
|
||||||
|
return "New Storage Provider"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main template for Storage Provider form
|
||||||
|
templ StorageProviderForm(ctx context.Context, data StorageProviderFormData) {
|
||||||
|
@LayoutWithContext(getTitle(data.IsEdit), ctx) {
|
||||||
|
<div class="min-h-screen bg-gray-50 dark:bg-gray-900 py-8">
|
||||||
|
<div class="max-w-3xl mx-auto">
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||||
|
<i class="fas fa-server w-6 h-6 mr-2 text-blue-500 dark:text-blue-400"></i>
|
||||||
|
if data.IsEdit {
|
||||||
|
Edit Storage Provider
|
||||||
|
} else {
|
||||||
|
New Storage Provider
|
||||||
|
}
|
||||||
|
</h1>
|
||||||
|
<a href="/storage-providers" class="text-blue-600 dark:text-blue-400 hover:underline flex items-center">
|
||||||
|
<i class="fas fa-arrow-left mr-1.5"></i>
|
||||||
|
Back to Providers
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
if data.Error != "" {
|
||||||
|
<div class="mb-4 p-4 text-sm text-red-800 rounded-lg bg-red-50 dark:bg-gray-800 dark:text-red-400">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<i class="fas fa-exclamation-circle mr-2"></i>
|
||||||
|
<span>{ data.Error }</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-6 mb-6">
|
||||||
|
if data.IsEdit {
|
||||||
|
<form action={ templ.SafeURL(fmt.Sprintf("/storage-providers/%d", data.Provider.ID)) } method="POST">
|
||||||
|
<input type="hidden" name="_method" value="PUT" />
|
||||||
|
@formFields(data)
|
||||||
|
</form>
|
||||||
|
} else {
|
||||||
|
<form action="/storage-providers" method="POST">
|
||||||
|
@formFields(data)
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Include the provider form scripts -->
|
||||||
|
@providerFormScript()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Form fields template
|
||||||
|
templ formFields(data StorageProviderFormData) {
|
||||||
|
<!-- Basic Information -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Basic Information</h2>
|
||||||
|
|
||||||
|
<!-- Provider Name -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="name" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Provider Name <span class="text-red-500">*</span></label>
|
||||||
|
<input type="text" id="name" name="name" value={ data.Provider.Name } class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="Storage Provider Name" required />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Provider Type -->
|
||||||
|
<div>
|
||||||
|
<label for="type" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Provider Type <span class="text-red-500">*</span></label>
|
||||||
|
<select id="type" name="type" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" required onchange="toggleProviderFields()">
|
||||||
|
<option value="" disabled
|
||||||
|
if data.Provider.Type == "" {
|
||||||
|
selected="selected"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Select provider type
|
||||||
|
</option>
|
||||||
|
<!-- Server-based providers -->
|
||||||
|
<optgroup label="Server-based">
|
||||||
|
<option value="sftp"
|
||||||
|
if data.Provider.Type == db.ProviderTypeSFTP {
|
||||||
|
selected="selected"
|
||||||
|
}
|
||||||
|
>SFTP</option>
|
||||||
|
<option value="hetzner"
|
||||||
|
if data.Provider.Type == db.ProviderTypeHetzner {
|
||||||
|
selected="selected"
|
||||||
|
}
|
||||||
|
>Hetzner Storage Box</option>
|
||||||
|
<option value="ftp"
|
||||||
|
if data.Provider.Type == db.ProviderTypeFTP {
|
||||||
|
selected="selected"
|
||||||
|
}
|
||||||
|
>FTP</option>
|
||||||
|
<option value="smb"
|
||||||
|
if data.Provider.Type == db.ProviderTypeSMB {
|
||||||
|
selected="selected"
|
||||||
|
}
|
||||||
|
>SMB/CIFS</option>
|
||||||
|
</optgroup>
|
||||||
|
|
||||||
|
<!-- Object Storage -->
|
||||||
|
<optgroup label="Object Storage">
|
||||||
|
<option value="s3"
|
||||||
|
if data.Provider.Type == db.ProviderTypeS3 {
|
||||||
|
selected="selected"
|
||||||
|
}
|
||||||
|
>Amazon S3</option>
|
||||||
|
<option value="wasabi"
|
||||||
|
if data.Provider.Type == "wasabi" {
|
||||||
|
selected="selected"
|
||||||
|
}
|
||||||
|
>Wasabi</option>
|
||||||
|
<option value="minio"
|
||||||
|
if data.Provider.Type == "minio" {
|
||||||
|
selected="selected"
|
||||||
|
}
|
||||||
|
>MinIO</option>
|
||||||
|
<option value="b2"
|
||||||
|
if data.Provider.Type == "b2" {
|
||||||
|
selected="selected"
|
||||||
|
}
|
||||||
|
>Backblaze B2</option>
|
||||||
|
</optgroup>
|
||||||
|
|
||||||
|
<!-- Web-based storage -->
|
||||||
|
<optgroup label="Web Storage">
|
||||||
|
<option value="webdav"
|
||||||
|
if data.Provider.Type == "webdav" {
|
||||||
|
selected="selected"
|
||||||
|
}
|
||||||
|
>WebDAV</option>
|
||||||
|
<option value="nextcloud"
|
||||||
|
if data.Provider.Type == "nextcloud" {
|
||||||
|
selected="selected"
|
||||||
|
}
|
||||||
|
>Nextcloud</option>
|
||||||
|
</optgroup>
|
||||||
|
|
||||||
|
<!-- Cloud providers -->
|
||||||
|
<optgroup label="Cloud Storage">
|
||||||
|
<option value="onedrive"
|
||||||
|
if data.Provider.Type == db.ProviderTypeOneDrive {
|
||||||
|
selected="selected"
|
||||||
|
}
|
||||||
|
>OneDrive</option>
|
||||||
|
<option value="drive"
|
||||||
|
if data.Provider.Type == db.ProviderTypeGoogleDrive {
|
||||||
|
selected="selected"
|
||||||
|
}
|
||||||
|
>Google Drive</option>
|
||||||
|
<option value="gphotos"
|
||||||
|
if data.Provider.Type == db.ProviderTypeGooglePhoto {
|
||||||
|
selected="selected"
|
||||||
|
}
|
||||||
|
>Google Photos</option>
|
||||||
|
</optgroup>
|
||||||
|
|
||||||
|
<!-- Local -->
|
||||||
|
<optgroup label="Local">
|
||||||
|
<option value="local"
|
||||||
|
if data.Provider.Type == db.ProviderTypeLocal {
|
||||||
|
selected="selected"
|
||||||
|
}
|
||||||
|
>Local Filesystem</option>
|
||||||
|
</optgroup>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Connection Details - SFTP/FTP/SMB -->
|
||||||
|
<div id="sftp-ftp-fields" class="mb-6 provider-fields hidden">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Connection Details</h2>
|
||||||
|
|
||||||
|
<!-- Host -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="host" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Host <span class="text-red-500">*</span></label>
|
||||||
|
<input type="text" id="host" name="host" value={ data.Provider.Host } class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="e.g., sftp.example.com or 192.168.1.10" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Port -->
|
||||||
|
<div id="port-field" class="mb-4">
|
||||||
|
<label for="port" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Port</label>
|
||||||
|
<input type="number" id="port" name="port" value={ fmt.Sprint(data.Provider.Port) } class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="22" />
|
||||||
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Leave empty for default (SFTP: 22, FTP: 21, SMB: 445, WebDAV: 80/443)</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Username -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="username" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Username <span class="text-red-500">*</span></label>
|
||||||
|
<input type="text" id="username" name="username" value={ data.Provider.Username } class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="Your login username" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Password -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Password</label>
|
||||||
|
<input type="password" id="password" name="password" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="Your login password" />
|
||||||
|
if data.IsEdit {
|
||||||
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Leave empty to keep the current password</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Key File (SFTP only) -->
|
||||||
|
<div id="key-file-field" class="mb-4 hidden">
|
||||||
|
<label for="keyFile" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Key File Path</label>
|
||||||
|
<input type="text" id="keyFile" name="keyFile" value={ data.Provider.KeyFile } class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="e.g., /home/user/.ssh/id_rsa" />
|
||||||
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Absolute path to private key file (if using key-based authentication)</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Domain (SMB only) -->
|
||||||
|
<div id="domain-field" class="mb-4 hidden">
|
||||||
|
<label for="domain" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Domain</label>
|
||||||
|
<input type="text" id="domain" name="domain" value={ data.Provider.Domain } class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="e.g., WORKGROUP or domain.local" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Passive Mode (FTP only) -->
|
||||||
|
<div id="passive-mode-field" class="mb-4 hidden">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<input id="passiveMode" name="passiveMode" type="checkbox" value="true" class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"
|
||||||
|
if data.Provider.PassiveMode != nil && *data.Provider.PassiveMode {
|
||||||
|
checked="checked"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<label for="passiveMode" class="ml-2 text-sm font-medium text-gray-900 dark:text-white">Use Passive Mode</label>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Recommended for most FTP connections through firewalls</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- S3 Fields -->
|
||||||
|
<div id="s3-fields" class="mb-6 provider-fields hidden">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">S3 Connection Details</h2>
|
||||||
|
|
||||||
|
<!-- Endpoint -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="endpoint" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
|
||||||
|
Endpoint
|
||||||
|
<span id="endpoint-required" class="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input type="text" id="endpoint" name="endpoint" value={ data.Provider.Endpoint }
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
placeholder="e.g., s3.amazonaws.com, s3.us-west-1.wasabisys.com" />
|
||||||
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
Custom endpoint URL (only needed for non-standard regions or non-AWS S3-compatible services). Optional for B2.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Region -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="region" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
|
||||||
|
Region
|
||||||
|
<span id="region-required" class="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input type="text" id="region" name="region" value={ data.Provider.Region }
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
placeholder="e.g., us-east-1, eu-central-1" />
|
||||||
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
AWS Region where your S3 bucket is located (e.g., us-east-1, eu-west-1). Optional for B2 and Wasabi.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bucket -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="bucket" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Bucket <span class="text-red-500">*</span></label>
|
||||||
|
<input type="text" id="bucket" name="bucket" value={ data.Provider.Bucket }
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
placeholder="Your bucket name" />
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Name of your S3 bucket (case-sensitive)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Access Key -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="accessKey" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Access Key <span class="text-red-500">*</span></label>
|
||||||
|
<input type="text" id="accessKey" name="accessKey" value={ data.Provider.AccessKey }
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
placeholder="Your access key/key ID" />
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Your AWS Access Key ID
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Secret Key -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="secretKey" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Secret Key <span class="text-red-500">*</span></label>
|
||||||
|
<input type="password" id="secretKey" name="secretKey"
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
placeholder="Your secret access key" />
|
||||||
|
if data.IsEdit {
|
||||||
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Leave empty to keep the current secret key</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Cloud Storage Fields (OneDrive, Google Drive, Google Photos) -->
|
||||||
|
<div id="cloud-fields" class="mb-6 provider-fields hidden">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Cloud Storage Details</h2>
|
||||||
|
|
||||||
|
<!-- Client ID -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="clientID" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Client ID <span class="text-red-500">*</span></label>
|
||||||
|
<input type="text" id="clientID" name="clientID" value={ data.Provider.ClientID } class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="OAuth client ID from developer console" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Client Secret -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="clientSecret" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Client Secret <span class="text-red-500">*</span></label>
|
||||||
|
<input type="password" id="clientSecret" name="clientSecret" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="OAuth client secret from developer console" />
|
||||||
|
if data.IsEdit {
|
||||||
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Leave empty to keep the current client secret</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Google Drive - Drive ID -->
|
||||||
|
<div id="drive-id-field" class="mb-4 hidden">
|
||||||
|
<label for="driveID" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Drive ID</label>
|
||||||
|
<input type="text" id="driveID" name="driveID" value={ data.Provider.DriveID } class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="ID of shared/team drive (from Drive URL)" />
|
||||||
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Only required for shared drives</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Google Drive - Team Drive -->
|
||||||
|
<div id="team-drive-field" class="mb-4 hidden">
|
||||||
|
<label for="teamDrive" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Team Drive</label>
|
||||||
|
<input type="text" id="teamDrive" name="teamDrive" value={ data.Provider.TeamDrive } class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="Team drive identifier" />
|
||||||
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Only required for team drives</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Google Photos - Read Only -->
|
||||||
|
<div id="readonly-field" class="mb-4 hidden">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<input id="readOnly" name="readOnly" type="checkbox" value="true" class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"
|
||||||
|
if data.Provider.ReadOnly != nil && *data.Provider.ReadOnly {
|
||||||
|
checked="checked"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<label for="readOnly" class="ml-2 text-sm font-medium text-gray-900 dark:text-white">Read Only Mode</label>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Google Photos has limited write capabilities</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Local File System Fields -->
|
||||||
|
<div id="local-fields" class="mb-6 provider-fields hidden">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Local File System</h2>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="localPath" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Base Path <span class="text-red-500">*</span></label>
|
||||||
|
<input type="text" id="localPath" name="localPath" value={ data.Provider.Host } class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="e.g., /data/files or C:\transfer\data" />
|
||||||
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Absolute path on the server's file system</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Submit Buttons -->
|
||||||
|
<div class="flex items-center justify-between mt-8">
|
||||||
|
<a href="/storage-providers" class="text-gray-500 bg-gray-50 hover:bg-gray-100 focus:ring-4 focus:outline-none focus:ring-gray-200 rounded-lg border border-gray-200 text-sm font-medium px-5 py-2.5 hover:text-gray-900 focus:z-10 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-500 dark:hover:text-white dark:hover:bg-gray-600 dark:focus:ring-gray-600">
|
||||||
|
Cancel
|
||||||
|
</a>
|
||||||
|
<div class="flex space-x-2">
|
||||||
|
<button type="submit" name="test" value="true" class="text-white bg-blue-600 hover:bg-blue-700 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm w-full sm:w-auto px-5 py-2.5 text-center dark:bg-blue-500 dark:hover:bg-blue-600 dark:focus:ring-blue-700">
|
||||||
|
Save & Test
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm w-full sm:w-auto px-5 py-2.5 text-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800">
|
||||||
|
Save Provider
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Hidden fields for S3 form data to ensure it gets submitted correctly -->
|
||||||
|
<input type="hidden" id="hidden_endpoint" name="endpoint" value={ data.Provider.Endpoint } />
|
||||||
|
<input type="hidden" id="hidden_region" name="region" value={ data.Provider.Region } />
|
||||||
|
<input type="hidden" id="hidden_bucket" name="bucket" value={ data.Provider.Bucket } />
|
||||||
|
<input type="hidden" id="hidden_accessKey" name="accessKey" value={ data.Provider.AccessKey } />
|
||||||
|
<input type="hidden" id="hidden_secretKey" name="secretKey" />
|
||||||
|
|
||||||
|
<!-- Hidden fields for Google Drive and Google Photos -->
|
||||||
|
<input type="hidden" id="hidden_clientID" name="clientID" value={ data.Provider.ClientID } />
|
||||||
|
<input type="hidden" id="hidden_clientSecret" name="clientSecret" />
|
||||||
|
}
|
||||||
|
|
||||||
|
// JavaScript helper for toggling provider fields
|
||||||
|
templ providerFormScript() {
|
||||||
|
<script>
|
||||||
|
// Set current active fields on page load
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
toggleProviderFields();
|
||||||
|
|
||||||
|
// Add event listener to track if user manually changes the port
|
||||||
|
const portField = document.getElementById('port');
|
||||||
|
portField.addEventListener('input', function() {
|
||||||
|
// Mark the field as user-modified
|
||||||
|
this.dataset.userModified = 'true';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add event listeners for S3 fields
|
||||||
|
document.getElementById('endpoint').addEventListener('input', function() {
|
||||||
|
document.getElementById('hidden_endpoint').value = this.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('region').addEventListener('input', function() {
|
||||||
|
document.getElementById('hidden_region').value = this.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('bucket').addEventListener('input', function() {
|
||||||
|
document.getElementById('hidden_bucket').value = this.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('accessKey').addEventListener('input', function() {
|
||||||
|
document.getElementById('hidden_accessKey').value = this.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('secretKey').addEventListener('input', function() {
|
||||||
|
document.getElementById('hidden_secretKey').value = this.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add event listeners for Google Drive/Photos fields
|
||||||
|
document.getElementById('clientID').addEventListener('input', function() {
|
||||||
|
document.getElementById('hidden_clientID').value = this.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('clientSecret').addEventListener('input', function() {
|
||||||
|
document.getElementById('hidden_clientSecret').value = this.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add form submit listener to ensure all hidden fields are populated
|
||||||
|
const forms = document.querySelectorAll('form');
|
||||||
|
forms.forEach(form => {
|
||||||
|
form.addEventListener('submit', function(e) {
|
||||||
|
// Find active provider type
|
||||||
|
const providerType = document.getElementById('type').value;
|
||||||
|
|
||||||
|
// If S3-compatible, update hidden fields
|
||||||
|
if (['s3', 'wasabi', 'minio', 'b2'].includes(providerType)) {
|
||||||
|
document.getElementById('hidden_endpoint').value = document.getElementById('endpoint').value;
|
||||||
|
document.getElementById('hidden_region').value = document.getElementById('region').value;
|
||||||
|
document.getElementById('hidden_bucket').value = document.getElementById('bucket').value;
|
||||||
|
document.getElementById('hidden_accessKey').value = document.getElementById('accessKey').value;
|
||||||
|
|
||||||
|
// Make sure secretKey is always copied to the hidden field
|
||||||
|
// This is especially important for B2 which uses this as Application Key
|
||||||
|
const secretKeyValue = document.getElementById('secretKey').value;
|
||||||
|
document.getElementById('hidden_secretKey').value = secretKeyValue;
|
||||||
|
|
||||||
|
// For validation - ensure we have appropriate fields for each provider type
|
||||||
|
if (providerType === 'b2') {
|
||||||
|
// B2 does not require region or endpoint
|
||||||
|
if (!document.getElementById('hidden_bucket').value) {
|
||||||
|
alert('Bucket name is required');
|
||||||
|
e.preventDefault();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!document.getElementById('hidden_accessKey').value) {
|
||||||
|
alert('Account ID is required');
|
||||||
|
e.preventDefault();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!secretKeyValue && !document.getElementById('hidden_secretKey').value) {
|
||||||
|
alert('Application Key is required');
|
||||||
|
e.preventDefault();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} else if (providerType === 'wasabi') {
|
||||||
|
// Wasabi does not require region
|
||||||
|
if (!document.getElementById('hidden_endpoint').value) {
|
||||||
|
alert('Endpoint is required for Wasabi');
|
||||||
|
e.preventDefault();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!document.getElementById('hidden_bucket').value) {
|
||||||
|
alert('Bucket name is required');
|
||||||
|
e.preventDefault();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!document.getElementById('hidden_accessKey').value) {
|
||||||
|
alert('Access key is required');
|
||||||
|
e.preventDefault();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!secretKeyValue && !document.getElementById('hidden_secretKey').value) {
|
||||||
|
alert('Secret key is required');
|
||||||
|
e.preventDefault();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Submitting S3-compatible form with:', {
|
||||||
|
provider: providerType,
|
||||||
|
endpoint: document.getElementById('hidden_endpoint').value,
|
||||||
|
region: document.getElementById('hidden_region').value,
|
||||||
|
bucket: document.getElementById('hidden_bucket').value,
|
||||||
|
accessKey: document.getElementById('hidden_accessKey').value,
|
||||||
|
secretKey: document.getElementById('hidden_secretKey').value ? '[PRESENT]' : '[EMPTY]',
|
||||||
|
secretKeyLength: document.getElementById('hidden_secretKey').value.length
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// If Google Drive or Google Photos, update hidden fields
|
||||||
|
if (['drive', 'gphotos', 'onedrive'].includes(providerType)) {
|
||||||
|
document.getElementById('hidden_clientID').value = document.getElementById('clientID').value;
|
||||||
|
|
||||||
|
// Make sure clientSecret is always copied to the hidden field
|
||||||
|
const clientSecretValue = document.getElementById('clientSecret').value;
|
||||||
|
document.getElementById('hidden_clientSecret').value = clientSecretValue;
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
if (!document.getElementById('hidden_clientID').value) {
|
||||||
|
alert('Client ID is required');
|
||||||
|
e.preventDefault();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Submitting cloud storage form with:', {
|
||||||
|
provider: providerType,
|
||||||
|
clientID: document.getElementById('hidden_clientID').value,
|
||||||
|
clientSecret: document.getElementById('hidden_clientSecret').value ? '[PRESENT]' : '[EMPTY]',
|
||||||
|
clientSecretLength: document.getElementById('hidden_clientSecret').value.length
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debug WebDAV form submission
|
||||||
|
if (['webdav', 'nextcloud'].includes(providerType)) {
|
||||||
|
console.log('Submitting WebDAV form with:', {
|
||||||
|
provider: providerType,
|
||||||
|
host: document.getElementById('host').value,
|
||||||
|
username: document.getElementById('username').value,
|
||||||
|
password: document.getElementById('password').value ? '[PRESENT]' : '[EMPTY]',
|
||||||
|
passwordLength: document.getElementById('password').value.length
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function toggleProviderFields() {
|
||||||
|
const provider = document.getElementById('type').value;
|
||||||
|
|
||||||
|
// Hide all provider fields first
|
||||||
|
const providerFieldsets = document.querySelectorAll('.provider-fields');
|
||||||
|
providerFieldsets.forEach(fieldset => {
|
||||||
|
fieldset.classList.add('hidden');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto-populate port based on provider type
|
||||||
|
const portField = document.getElementById('port');
|
||||||
|
const isPortEmpty = portField.value === '';
|
||||||
|
const userModified = portField.dataset.userModified === 'true';
|
||||||
|
|
||||||
|
// Only set default port if field is empty or hasn't been modified by user
|
||||||
|
if (isPortEmpty || !userModified) {
|
||||||
|
if (provider === 'sftp') {
|
||||||
|
portField.value = '22';
|
||||||
|
} else if (provider === 'hetzner') {
|
||||||
|
portField.value = '23';
|
||||||
|
} else if (provider === 'ftp') {
|
||||||
|
portField.value = '21';
|
||||||
|
} else if (provider === 'smb') {
|
||||||
|
portField.value = '445';
|
||||||
|
} else if (provider === 'webdav' || provider === 'nextcloud') {
|
||||||
|
portField.value = '443';
|
||||||
|
} else {
|
||||||
|
portField.value = '';
|
||||||
|
}
|
||||||
|
// Reset user modified flag if we're setting it programmatically
|
||||||
|
portField.dataset.userModified = 'false';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show the appropriate fields based on the selected provider type
|
||||||
|
console.log("Provider type selected:", provider);
|
||||||
|
|
||||||
|
// Server-based connection fields (SFTP, FTP, Hetzner)
|
||||||
|
if (['sftp', 'ftp', 'hetzner'].includes(provider)) {
|
||||||
|
document.getElementById('sftp-ftp-fields').classList.remove('hidden');
|
||||||
|
|
||||||
|
// SFTP/Hetzner-specific fields
|
||||||
|
if (provider === 'sftp' || provider === 'hetzner') {
|
||||||
|
document.getElementById('key-file-field').classList.remove('hidden');
|
||||||
|
|
||||||
|
// Update placeholders for Hetzner
|
||||||
|
if (provider === 'hetzner') {
|
||||||
|
// Update host field
|
||||||
|
const hostField = document.getElementById('host');
|
||||||
|
if (hostField) {
|
||||||
|
hostField.placeholder = "uXXXXXX.your-storagebox.de";
|
||||||
|
|
||||||
|
// Update host label and description
|
||||||
|
const hostLabel = document.querySelector('label[for="host"]');
|
||||||
|
if (hostLabel) {
|
||||||
|
hostLabel.textContent = "Storage Box Host";
|
||||||
|
}
|
||||||
|
|
||||||
|
const hostDescription = hostField.nextElementSibling;
|
||||||
|
if (hostDescription?.tagName === 'P') {
|
||||||
|
hostDescription.textContent = "Your Hetzner Storage Box hostname (e.g., uXXXXXX.your-storagebox.de)";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update username field
|
||||||
|
const usernameField = document.getElementById('username');
|
||||||
|
if (usernameField) {
|
||||||
|
usernameField.placeholder = "uXXXXXX";
|
||||||
|
|
||||||
|
// Update username description
|
||||||
|
const usernameDescription = usernameField.nextElementSibling;
|
||||||
|
if (usernameDescription?.tagName === 'P') {
|
||||||
|
usernameDescription.textContent = "Your Hetzner Storage Box username (typically matches your Storage Box number)";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update key file field
|
||||||
|
const keyFileField = document.getElementById('keyFile');
|
||||||
|
if (keyFileField) {
|
||||||
|
keyFileField.placeholder = "/path/to/id_rsa";
|
||||||
|
|
||||||
|
// Update key file description
|
||||||
|
const keyFileDescription = keyFileField.nextElementSibling;
|
||||||
|
if (keyFileDescription?.tagName === 'P') {
|
||||||
|
keyFileDescription.textContent = "Path to your SSH private key file for Hetzner Storage Box authentication";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update port field description
|
||||||
|
const portField = document.getElementById('port');
|
||||||
|
if (portField) {
|
||||||
|
const portDescription = portField.nextElementSibling;
|
||||||
|
if (portDescription?.tagName === 'P') {
|
||||||
|
portDescription.textContent = "Connection port for Hetzner Storage Box (default: 23)";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FTP-specific fields
|
||||||
|
if (provider === 'ftp') {
|
||||||
|
document.getElementById('passive-mode-field').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebDAV-based fields
|
||||||
|
if (['webdav', 'nextcloud'].includes(provider)) {
|
||||||
|
document.getElementById('sftp-ftp-fields').classList.remove('hidden');
|
||||||
|
// Hide fields that WebDAV doesn't use
|
||||||
|
document.getElementById('port-field').classList.add('hidden');
|
||||||
|
|
||||||
|
// Update placeholders for WebDAV
|
||||||
|
const hostField = document.getElementById('host');
|
||||||
|
if (hostField) {
|
||||||
|
hostField.placeholder = "https://webdav.example.com";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update label for host field
|
||||||
|
const hostLabel = document.querySelector('label[for="host"]');
|
||||||
|
if (hostLabel) {
|
||||||
|
hostLabel.textContent = "WebDAV URL";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update description for host field
|
||||||
|
const hostDescription = hostField?.nextElementSibling;
|
||||||
|
if (hostDescription?.tagName === 'P') {
|
||||||
|
hostDescription.textContent = provider === 'webdav' ?
|
||||||
|
"Full URL to your WebDAV server including protocol (https://)" :
|
||||||
|
"Full URL to your Nextcloud WebDAV endpoint (e.g., https://nextcloud.example.com/remote.php/dav/files/username/)";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update username field
|
||||||
|
const usernameField = document.getElementById('username');
|
||||||
|
if (usernameField) {
|
||||||
|
usernameField.placeholder = provider === 'nextcloud' ? "nextcloud_username" : "webdav_username";
|
||||||
|
|
||||||
|
// Update username description
|
||||||
|
const usernameDescription = usernameField.nextElementSibling;
|
||||||
|
if (usernameDescription?.tagName === 'P') {
|
||||||
|
usernameDescription.textContent = provider === 'nextcloud' ?
|
||||||
|
"Your Nextcloud username for authentication" :
|
||||||
|
"Your WebDAV username for authentication";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update password field
|
||||||
|
const passwordField = document.getElementById('password');
|
||||||
|
if (passwordField) {
|
||||||
|
passwordField.name = "password"; // Ensure name is set correctly
|
||||||
|
|
||||||
|
// Update password description
|
||||||
|
const passwordDescription = passwordField.nextElementSibling;
|
||||||
|
if (passwordDescription?.tagName === 'P') {
|
||||||
|
// Check if we're in edit mode
|
||||||
|
const editMode = passwordDescription.textContent.includes("Leave empty to keep");
|
||||||
|
|
||||||
|
if (editMode) {
|
||||||
|
// Edit mode - tell user they can leave password empty to keep current one
|
||||||
|
passwordDescription.textContent = provider === 'nextcloud' ?
|
||||||
|
"Leave empty to keep the current Nextcloud password" :
|
||||||
|
"Leave empty to keep the current WebDAV password";
|
||||||
|
} else {
|
||||||
|
// New provider - show regular password help text
|
||||||
|
passwordDescription.textContent = provider === 'nextcloud' ?
|
||||||
|
"Your Nextcloud password or app-specific password" :
|
||||||
|
"Your WebDAV password for authentication";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.getElementById('key-file-field')) {
|
||||||
|
document.getElementById('key-file-field').classList.add('hidden');
|
||||||
|
}
|
||||||
|
if (document.getElementById('domain-field')) {
|
||||||
|
document.getElementById('domain-field').classList.add('hidden');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Reset placeholders for other providers
|
||||||
|
const hostField = document.getElementById('host');
|
||||||
|
if (hostField && ['sftp', 'ftp', 'smb'].includes(provider)) {
|
||||||
|
hostField.placeholder = provider === 'sftp' ? "sftp.example.com" :
|
||||||
|
provider === 'ftp' ? "ftp.example.com" :
|
||||||
|
"192.168.1.10";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset label for host field
|
||||||
|
const hostLabel = document.querySelector('label[for="host"]');
|
||||||
|
if (hostLabel) {
|
||||||
|
hostLabel.textContent = "Host";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset username field
|
||||||
|
const usernameField = document.getElementById('username');
|
||||||
|
if (usernameField) {
|
||||||
|
usernameField.placeholder = provider === 'sftp' ? "sftp_username" :
|
||||||
|
provider === 'ftp' ? "ftp_username" :
|
||||||
|
provider === 'smb' ? "smb_username" : "username";
|
||||||
|
|
||||||
|
// Reset username description
|
||||||
|
const usernameDescription = usernameField.nextElementSibling;
|
||||||
|
if (usernameDescription?.tagName === 'P') {
|
||||||
|
usernameDescription.textContent = "Your login username";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset password field
|
||||||
|
const passwordField = document.getElementById('password');
|
||||||
|
if (passwordField) {
|
||||||
|
// Reset password description
|
||||||
|
const passwordDescription = passwordField.nextElementSibling;
|
||||||
|
if (passwordDescription?.tagName === 'P') {
|
||||||
|
// Check if we're in edit mode
|
||||||
|
const editMode = passwordDescription.textContent.includes("Leave empty to keep");
|
||||||
|
|
||||||
|
if (editMode) {
|
||||||
|
// Edit mode - tell user they can leave password empty to keep current one
|
||||||
|
if (provider === 'sftp') {
|
||||||
|
passwordDescription.textContent = "Leave empty to keep the current SFTP password";
|
||||||
|
} else if (provider === 'ftp') {
|
||||||
|
passwordDescription.textContent = "Leave empty to keep the current FTP password";
|
||||||
|
} else if (provider === 'smb') {
|
||||||
|
passwordDescription.textContent = "Leave empty to keep the current SMB password";
|
||||||
|
} else if (provider === 'hetzner') {
|
||||||
|
passwordDescription.textContent = "Leave empty to keep the current Hetzner Storage Box password";
|
||||||
|
} else {
|
||||||
|
passwordDescription.textContent = "Leave empty to keep the current password";
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// New provider - show regular password help text
|
||||||
|
if (provider === 'sftp') {
|
||||||
|
passwordDescription.textContent = "Your SFTP server password";
|
||||||
|
} else if (provider === 'ftp') {
|
||||||
|
passwordDescription.textContent = "Your FTP server password";
|
||||||
|
} else if (provider === 'smb') {
|
||||||
|
passwordDescription.textContent = "Your SMB/CIFS share password";
|
||||||
|
} else if (provider === 'hetzner') {
|
||||||
|
passwordDescription.textContent = "Your Hetzner Storage Box password";
|
||||||
|
} else {
|
||||||
|
passwordDescription.textContent = "Your login password";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SMB-specific fields
|
||||||
|
if (provider === 'smb') {
|
||||||
|
document.getElementById('sftp-ftp-fields').classList.remove('hidden');
|
||||||
|
document.getElementById('domain-field').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// S3-compatible storage
|
||||||
|
if (['s3', 'wasabi', 'minio', 'b2'].includes(provider)) {
|
||||||
|
document.getElementById('s3-fields').classList.remove('hidden');
|
||||||
|
|
||||||
|
// Set region and endpoint requirements based on provider type
|
||||||
|
const regionRequired = document.getElementById('region-required');
|
||||||
|
const endpointRequired = document.getElementById('endpoint-required');
|
||||||
|
const regionInput = document.getElementById('region');
|
||||||
|
const endpointInput = document.getElementById('endpoint');
|
||||||
|
|
||||||
|
// Update Secret Key field description based on provider type
|
||||||
|
const secretKeyField = document.getElementById('secretKey');
|
||||||
|
if (secretKeyField) {
|
||||||
|
const secretKeyDescription = secretKeyField.nextElementSibling;
|
||||||
|
if (secretKeyDescription?.tagName === 'P') {
|
||||||
|
// Check if in edit mode
|
||||||
|
const editMode = secretKeyDescription.textContent.includes("Leave empty to keep");
|
||||||
|
|
||||||
|
if (editMode) {
|
||||||
|
// Edit mode - provider specific text
|
||||||
|
if (provider === 'b2') {
|
||||||
|
secretKeyDescription.textContent = "Leave empty to keep the current B2 Application Key";
|
||||||
|
} else if (provider === 'wasabi') {
|
||||||
|
secretKeyDescription.textContent = "Leave empty to keep the current Wasabi Secret Key";
|
||||||
|
} else if (provider === 'minio') {
|
||||||
|
secretKeyDescription.textContent = "Leave empty to keep the current MinIO Secret Key";
|
||||||
|
} else {
|
||||||
|
secretKeyDescription.textContent = "Leave empty to keep the current AWS Secret Access Key";
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// New provider - provider specific text
|
||||||
|
if (provider === 'b2') {
|
||||||
|
secretKeyDescription.textContent = "Your Backblaze B2 Application Key";
|
||||||
|
} else if (provider === 'wasabi') {
|
||||||
|
secretKeyDescription.textContent = "Your Wasabi Secret Key";
|
||||||
|
} else if (provider === 'minio') {
|
||||||
|
secretKeyDescription.textContent = "Your MinIO Secret Key";
|
||||||
|
} else {
|
||||||
|
secretKeyDescription.textContent = "Your AWS Secret Access Key";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For B2: endpoint and region are optional
|
||||||
|
if (provider === 'b2') {
|
||||||
|
regionRequired.style.display = 'none';
|
||||||
|
endpointRequired.style.display = 'none';
|
||||||
|
regionInput.removeAttribute('required');
|
||||||
|
endpointInput.removeAttribute('required');
|
||||||
|
}
|
||||||
|
// For Wasabi: region is optional
|
||||||
|
else if (provider === 'wasabi') {
|
||||||
|
regionRequired.style.display = 'none';
|
||||||
|
endpointRequired.style.display = 'inline';
|
||||||
|
regionInput.removeAttribute('required');
|
||||||
|
endpointInput.setAttribute('required', 'required');
|
||||||
|
}
|
||||||
|
// For S3 and MinIO: both are required
|
||||||
|
else {
|
||||||
|
regionRequired.style.display = 'inline';
|
||||||
|
endpointRequired.style.display = 'inline';
|
||||||
|
regionInput.setAttribute('required', 'required');
|
||||||
|
endpointInput.setAttribute('required', 'required');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Google services
|
||||||
|
if (['drive', 'gphotos'].includes(provider)) {
|
||||||
|
document.getElementById('cloud-fields').classList.remove('hidden');
|
||||||
|
document.getElementById('built-in-auth-field').classList.remove('hidden');
|
||||||
|
|
||||||
|
// Update client secret field description
|
||||||
|
const clientSecretField = document.getElementById('clientSecret');
|
||||||
|
if (clientSecretField) {
|
||||||
|
const clientSecretDescription = clientSecretField.nextElementSibling;
|
||||||
|
if (clientSecretDescription?.tagName === 'P') {
|
||||||
|
// Check if in edit mode
|
||||||
|
const editMode = clientSecretDescription.textContent.includes("Leave empty to keep");
|
||||||
|
|
||||||
|
if (editMode) {
|
||||||
|
// Edit mode - provider specific text
|
||||||
|
if (provider === 'drive') {
|
||||||
|
clientSecretDescription.textContent = "Leave empty to keep the current Google Drive client secret";
|
||||||
|
} else if (provider === 'gphotos') {
|
||||||
|
clientSecretDescription.textContent = "Leave empty to keep the current Google Photos client secret";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Google Drive specific fields
|
||||||
|
if (provider === 'drive') {
|
||||||
|
document.getElementById('drive-id-field').classList.remove('hidden');
|
||||||
|
document.getElementById('team-drive-field').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Google Photos specific fields
|
||||||
|
if (provider === 'gphotos') {
|
||||||
|
document.getElementById('gphotos-options-field').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// OneDrive
|
||||||
|
if (provider === 'onedrive') {
|
||||||
|
document.getElementById('cloud-fields').classList.remove('hidden');
|
||||||
|
|
||||||
|
// Update client secret field description
|
||||||
|
const clientSecretField = document.getElementById('clientSecret');
|
||||||
|
if (clientSecretField) {
|
||||||
|
const clientSecretDescription = clientSecretField.nextElementSibling;
|
||||||
|
if (clientSecretDescription?.tagName === 'P') {
|
||||||
|
// Check if in edit mode
|
||||||
|
const editMode = clientSecretDescription.textContent.includes("Leave empty to keep");
|
||||||
|
|
||||||
|
if (editMode) {
|
||||||
|
clientSecretDescription.textContent = "Leave empty to keep the current OneDrive client secret";
|
||||||
|
} else {
|
||||||
|
clientSecretDescription.textContent = "Your Microsoft Azure OAuth client secret";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Local filesystem fields
|
||||||
|
if (provider === 'local') {
|
||||||
|
document.getElementById('local-fields').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test result dialog
|
||||||
|
templ ConnectionTestResult(success bool, message string, errorCode string) {
|
||||||
|
<div class="text-center">
|
||||||
|
if success {
|
||||||
|
<i class="fas fa-check-circle text-green-500 text-5xl mb-4"></i>
|
||||||
|
<h3 class="mb-2 text-lg font-semibold text-green-500 dark:text-green-400">Connection Successful</h3>
|
||||||
|
} else {
|
||||||
|
<i class="fas fa-times-circle text-red-500 text-5xl mb-4"></i>
|
||||||
|
<h3 class="mb-2 text-lg font-semibold text-red-500 dark:text-red-400">Connection Failed</h3>
|
||||||
|
}
|
||||||
|
|
||||||
|
<p class="text-gray-500 dark:text-gray-400 mb-4">
|
||||||
|
{ message }
|
||||||
|
</p>
|
||||||
|
|
||||||
|
if errorCode != "" {
|
||||||
|
<div class="text-sm bg-gray-100 dark:bg-gray-800 p-2 rounded">
|
||||||
|
<p class="text-gray-700 dark:text-gray-300">Error code: { errorCode }</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
package components
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
)
|
||||||
|
|
||||||
|
// StorageProviderGDriveHeadlessAuthData contains data needed for rendering the headless auth page for storage providers
|
||||||
|
type StorageProviderGDriveHeadlessAuthData struct {
|
||||||
|
AuthCommand string
|
||||||
|
ProviderID string
|
||||||
|
}
|
||||||
|
|
||||||
|
// StorageProviderGDriveHeadlessAuth renders the headless authentication page for Google Drive/Photos for storage providers
|
||||||
|
templ StorageProviderGDriveHeadlessAuth(ctx context.Context, data StorageProviderGDriveHeadlessAuthData) {
|
||||||
|
// Force the layout to display as authenticated content
|
||||||
|
@LayoutWithContext("Google Authentication - Headless Mode", ctx) {
|
||||||
|
<style>
|
||||||
|
/* Ensure proper styling for the headless auth page */
|
||||||
|
body.dark .auth-page {
|
||||||
|
background-color: #111827 !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div id="auth-container" class="auth-page w-full pb-8 bg-gray-50 dark:bg-gray-900" style="min-height: 100vh; background-color: rgb(249, 250, 251);">
|
||||||
|
<div class="max-w-4xl mx-auto">
|
||||||
|
<div class="mb-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||||
|
<i class="fas fa-key w-6 h-6 mr-2 text-blue-500 dark:text-blue-400"></i>
|
||||||
|
Headless Google Authentication
|
||||||
|
</h1>
|
||||||
|
<a href="/storage-providers" class="flex items-center justify-center text-gray-700 bg-gray-100 hover:bg-gray-200 focus:ring-4 focus:ring-gray-300 font-medium rounded-lg px-5 py-2.5 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600 focus:outline-none dark:focus:ring-gray-700">
|
||||||
|
<i class="fas fa-arrow-left w-4 h-4 mr-2"></i>
|
||||||
|
Back to Storage Providers
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 p-6">
|
||||||
|
<div class="bg-blue-50 dark:bg-blue-900/30 border-l-4 border-blue-500 p-4 mb-6">
|
||||||
|
<div class="flex">
|
||||||
|
<div class="flex-shrink-0 mt-0.5">
|
||||||
|
<i class="fas fa-info-circle h-5 w-5 text-blue-500"></i>
|
||||||
|
</div>
|
||||||
|
<div class="ml-3">
|
||||||
|
<p class="text-sm text-blue-700 dark:text-blue-300">
|
||||||
|
You need to authenticate with Google using a web browser. Since you're running GoMFT behind a reverse proxy or in a headless environment, you'll need to complete authentication on a machine with a web browser.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-8">
|
||||||
|
<h2 class="text-lg font-medium mb-3 text-gray-900 dark:text-white">Step 1: Run the following command on a machine with a web browser</h2>
|
||||||
|
<div class="relative mb-4">
|
||||||
|
<pre id="auth-command-text" class="bg-gray-50 dark:bg-gray-900 rounded-md p-4 overflow-x-auto text-sm font-mono">{ data.AuthCommand }</pre>
|
||||||
|
<button id="copy-command" class="absolute top-2 right-2 bg-gray-200 dark:bg-gray-700 p-1.5 rounded hover:bg-gray-300 dark:hover:bg-gray-600" title="Copy to clipboard">
|
||||||
|
<i class="fas fa-copy h-5 w-5 text-gray-700 dark:text-gray-300"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 class="text-md font-medium mb-2 text-gray-900 dark:text-white">What this command does:</h3>
|
||||||
|
<ul class="list-disc ml-6 text-sm text-gray-700 dark:text-gray-300 space-y-1">
|
||||||
|
<li>Opens a browser window on the machine where you run it</li>
|
||||||
|
<li>Allows you to authenticate with Google</li>
|
||||||
|
<li>Generates an authentication token</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-6">
|
||||||
|
<h2 class="text-lg font-medium mb-3 text-gray-900 dark:text-white">Step 2: Paste the authentication token below</h2>
|
||||||
|
<p class="text-sm text-gray-700 dark:text-gray-300 mb-4">
|
||||||
|
After completing authentication in the browser, you'll receive a token. Copy and paste that token here:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form action="/storage-providers/gdrive-headless-token" method="POST" class="space-y-4">
|
||||||
|
<input type="hidden" name="provider_id" value={ data.ProviderID } />
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="auth_token" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Authentication Token</label>
|
||||||
|
<textarea
|
||||||
|
id="auth_token"
|
||||||
|
name="auth_token"
|
||||||
|
rows="5"
|
||||||
|
class="mt-1 block w-full rounded-md border border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white dark:placeholder-gray-400"
|
||||||
|
placeholder="Paste your authentication token here..."
|
||||||
|
required
|
||||||
|
></textarea>
|
||||||
|
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">The token will look like a long JSON string containing access credentials.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-end mt-6">
|
||||||
|
<a href="/storage-providers" class="mr-4 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-gray-500 dark:hover:text-gray-400">
|
||||||
|
Cancel
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
Submit Token
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Help Section -->
|
||||||
|
<div class="bg-gray-50 dark:bg-gray-800 rounded-lg shadow-sm mt-8 p-4 border border-gray-200 dark:border-gray-700">
|
||||||
|
<div class="flex items-start">
|
||||||
|
<div class="flex items-center h-5">
|
||||||
|
<i class="fas fa-info-circle w-4 h-4 text-blue-500 dark:text-blue-400 mr-2"></i>
|
||||||
|
</div>
|
||||||
|
<div class="ml-2 text-sm">
|
||||||
|
<p class="text-gray-700 dark:text-gray-300">This authentication process is necessary for GoMFT to access your Google Drive or Google Photos account.</p>
|
||||||
|
<p class="mt-1 text-gray-600 dark:text-gray-400">The token is only used for authentication and is stored securely. You'll only need to complete this process once for each storage provider.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Set dark background color if in dark mode
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
if (document.documentElement.classList.contains('dark')) {
|
||||||
|
document.getElementById('auth-container').style.backgroundColor = '#111827';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add event listener for theme changes
|
||||||
|
const themeToggle = document.getElementById('theme-toggle');
|
||||||
|
if (themeToggle) {
|
||||||
|
themeToggle.addEventListener('click', function() {
|
||||||
|
setTimeout(function() {
|
||||||
|
const isDark = document.documentElement.classList.contains('dark');
|
||||||
|
document.getElementById('auth-container').style.backgroundColor = isDark ? '#111827' : 'rgb(249, 250, 251)';
|
||||||
|
}, 50);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store the actual command text
|
||||||
|
const actualCommand = document.getElementById('auth-command-text').textContent.trim();
|
||||||
|
|
||||||
|
// Add click handler to copy button
|
||||||
|
document.getElementById('copy-command').addEventListener('click', function() {
|
||||||
|
// Copy the actual command text, not the template variable
|
||||||
|
navigator.clipboard.writeText(actualCommand).then(function() {
|
||||||
|
// Show a success message
|
||||||
|
const button = document.getElementById('copy-command');
|
||||||
|
const originalTitle = button.getAttribute('title');
|
||||||
|
button.setAttribute('title', 'Copied!');
|
||||||
|
|
||||||
|
// Also show visual feedback
|
||||||
|
button.classList.add('bg-green-200', 'dark:bg-green-700');
|
||||||
|
button.classList.remove('bg-gray-200', 'dark:bg-gray-700');
|
||||||
|
|
||||||
|
setTimeout(function() {
|
||||||
|
button.setAttribute('title', originalTitle);
|
||||||
|
button.classList.remove('bg-green-200', 'dark:bg-green-700');
|
||||||
|
button.classList.add('bg-gray-200', 'dark:bg-gray-700');
|
||||||
|
}, 2000);
|
||||||
|
}).catch(function(err) {
|
||||||
|
console.error('Failed to copy text: ', err);
|
||||||
|
alert('Failed to copy command. Please select and copy it manually.');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,728 @@
|
|||||||
|
package components
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"github.com/starfleetcptn/gomft/components/shared/toast"
|
||||||
|
"github.com/starfleetcptn/gomft/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
type StorageProvidersData struct {
|
||||||
|
Providers []db.StorageProvider
|
||||||
|
Error string
|
||||||
|
Status string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main template for Storage Providers page
|
||||||
|
templ StorageProviders(ctx context.Context, data StorageProvidersData) {
|
||||||
|
@LayoutWithContext("Storage Providers", ctx) {
|
||||||
|
@toast.Container()
|
||||||
|
@toast.ShowToastJS()
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Handle test provider button clicks
|
||||||
|
window.testProvider = function(button) {
|
||||||
|
const providerId = button.getAttribute('data-provider-id');
|
||||||
|
const providerName = button.getAttribute('data-provider-name') || `Provider #${providerId}`;
|
||||||
|
showToast(`Testing connection to "${providerName}"...`, 'info');
|
||||||
|
button.addEventListener('htmx:afterRequest', function(event) {
|
||||||
|
if (event.detail.successful) {
|
||||||
|
showToast(`Connection to "${providerName}" successful!`, 'success');
|
||||||
|
} else {
|
||||||
|
let errorMsg = `Failed to connect to "${providerName}"`;
|
||||||
|
if (event.detail.xhr && event.detail.xhr.responseText) {
|
||||||
|
try {
|
||||||
|
const error = JSON.parse(event.detail.xhr.responseText);
|
||||||
|
errorMsg = error.error ? `Connection error: ${error.error}` : `Connection error: ${event.detail.xhr.responseText}`;
|
||||||
|
} catch (e) {
|
||||||
|
errorMsg = `Connection error: ${event.detail.xhr.responseText}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
showToast(errorMsg, 'error');
|
||||||
|
}
|
||||||
|
}, { once: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Duplicate Provider HTMX Event Handling (configs.templ style) ---
|
||||||
|
// Track all HTMX events for duplicate provider
|
||||||
|
document.addEventListener('htmx:beforeRequest', function(event) {
|
||||||
|
const path = event.detail.path;
|
||||||
|
const method = event.detail.verb;
|
||||||
|
if (path && method === 'POST' && path.match(/^\/storage-providers\/\d+\/duplicate$/)) {
|
||||||
|
window.isProviderDuplicateRequest = true;
|
||||||
|
// Store the provider name for toast
|
||||||
|
const providerId = path.match(/^\/storage-providers\/(\d+)\/duplicate$/)[1];
|
||||||
|
const btn = document.querySelector(`button[hx-post="/storage-providers/${providerId}/duplicate"]`);
|
||||||
|
if (btn) {
|
||||||
|
window.duplicatingProviderName = btn.getAttribute('data-provider-name') || `Provider #${providerId}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('htmx:afterRequest', function(event) {
|
||||||
|
// Handle delete provider events (existing logic)
|
||||||
|
if (event.detail.pathInfo && event.detail.pathInfo.requestPath &&
|
||||||
|
event.detail.pathInfo.requestPath.match(/^\/storage-providers\/\d+$/) &&
|
||||||
|
event.detail.verb === 'DELETE') {
|
||||||
|
const providerName = event.detail.elt.getAttribute('data-provider-name') || 'Provider';
|
||||||
|
if (event.detail.successful) {
|
||||||
|
showToast(`Provider "${providerName}" deleted successfully`, 'success');
|
||||||
|
} else {
|
||||||
|
let errorMsg = `Failed to delete provider "${providerName}"`;
|
||||||
|
if (event.detail.xhr && event.detail.xhr.responseText) {
|
||||||
|
try {
|
||||||
|
const error = JSON.parse(event.detail.xhr.responseText);
|
||||||
|
errorMsg = error.error ? error.error : `Error: ${event.detail.xhr.responseText}`;
|
||||||
|
} catch (e) {
|
||||||
|
errorMsg = `Error: ${event.detail.xhr.responseText}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
showToast(errorMsg, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle duplicate provider success
|
||||||
|
const isDuplicateRequest = window.isProviderDuplicateRequest &&
|
||||||
|
event.detail.pathInfo &&
|
||||||
|
event.detail.pathInfo.requestPath &&
|
||||||
|
event.detail.pathInfo.requestPath.match(/^\/storage-providers\/\d+\/duplicate$/);
|
||||||
|
if (isDuplicateRequest && event.detail.successful) {
|
||||||
|
const providerName = window.duplicatingProviderName || 'provider';
|
||||||
|
showToast(`Provider "${providerName}" duplicated successfully`, 'success');
|
||||||
|
window.isProviderDuplicateRequest = false;
|
||||||
|
window.duplicatingProviderName = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('htmx:responseError', function(event) {
|
||||||
|
// Handle any HTMX response error
|
||||||
|
let errorMsg = "An error occurred";
|
||||||
|
let entityName = "Operation";
|
||||||
|
|
||||||
|
// Get more context about the operation that failed
|
||||||
|
if (event.detail.elt && event.detail.elt.getAttribute('data-provider-name')) {
|
||||||
|
entityName = event.detail.elt.getAttribute('data-provider-name');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle duplicate provider errors
|
||||||
|
const isDuplicateRequest = window.isProviderDuplicateRequest &&
|
||||||
|
event.detail.pathInfo &&
|
||||||
|
event.detail.pathInfo.requestPath &&
|
||||||
|
event.detail.pathInfo.requestPath.match(/^\/storage-providers\/\d+\/duplicate$/);
|
||||||
|
if (isDuplicateRequest) {
|
||||||
|
const providerName = window.duplicatingProviderName || 'provider';
|
||||||
|
errorMsg = `Failed to duplicate provider "${providerName}"`;
|
||||||
|
if (event.detail.xhr && event.detail.xhr.responseText) {
|
||||||
|
try {
|
||||||
|
const error = JSON.parse(event.detail.xhr.responseText);
|
||||||
|
errorMsg = error.error ? error.error : errorMsg;
|
||||||
|
} catch (e) {
|
||||||
|
if (event.detail.xhr.responseText.trim()) {
|
||||||
|
errorMsg = event.detail.xhr.responseText;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
showToast(errorMsg, 'error');
|
||||||
|
window.isProviderDuplicateRequest = false;
|
||||||
|
window.duplicatingProviderName = null;
|
||||||
|
}
|
||||||
|
// Handle other HTMX errors
|
||||||
|
else if (event.detail.xhr && event.detail.xhr.responseText) {
|
||||||
|
try {
|
||||||
|
const error = JSON.parse(event.detail.xhr.responseText);
|
||||||
|
errorMsg = error.error ? error.error : `Error during ${entityName} operation`;
|
||||||
|
} catch (e) {
|
||||||
|
if (event.detail.xhr.responseText.trim()) {
|
||||||
|
errorMsg = event.detail.xhr.responseText;
|
||||||
|
} else {
|
||||||
|
errorMsg = `Error during ${entityName} operation`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
showToast(errorMsg, 'error');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the visual error alert for all error types
|
||||||
|
const errorAlert = document.getElementById('htmx-error-alert');
|
||||||
|
const errorMessageSpan = document.getElementById('htmx-error-message');
|
||||||
|
if (errorAlert && errorMessageSpan) {
|
||||||
|
errorMessageSpan.textContent = errorMsg;
|
||||||
|
errorAlert.classList.remove('hidden');
|
||||||
|
// Auto-hide after 10 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
errorAlert.classList.add('hidden');
|
||||||
|
}, 10000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set a flag before duplicate request to show toast after reload
|
||||||
|
document.addEventListener('click', function(e) {
|
||||||
|
const btn = e.target.closest('button[data-provider-id][hx-post*="/duplicate"]');
|
||||||
|
if (btn) {
|
||||||
|
localStorage.setItem('showProviderDuplicateToast', '1');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
// Display error from data.Error if present
|
||||||
|
const errorElement = document.getElementById('provider-error-message');
|
||||||
|
if (errorElement && errorElement.textContent.trim()) {
|
||||||
|
showToast(errorElement.textContent.trim(), 'error');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display status messages programmatically
|
||||||
|
const statusElement = document.getElementById('provider-status-message');
|
||||||
|
if (statusElement && statusElement.textContent.trim()) {
|
||||||
|
const status = statusElement.textContent.trim();
|
||||||
|
let message = '';
|
||||||
|
let type = 'info';
|
||||||
|
|
||||||
|
// Convert status to appropriate toast message
|
||||||
|
switch(status) {
|
||||||
|
case 'created':
|
||||||
|
message = 'Provider created successfully';
|
||||||
|
type = 'success';
|
||||||
|
break;
|
||||||
|
case 'updated':
|
||||||
|
message = 'Provider updated successfully';
|
||||||
|
type = 'success';
|
||||||
|
break;
|
||||||
|
case 'deleted':
|
||||||
|
message = 'Provider deleted successfully';
|
||||||
|
type = 'success';
|
||||||
|
break;
|
||||||
|
case 'duplicated':
|
||||||
|
message = 'Provider duplicated successfully';
|
||||||
|
type = 'success';
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
if (status) {
|
||||||
|
message = `Provider ${status}`;
|
||||||
|
type = 'info';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message) {
|
||||||
|
showToast(message, type);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show toasts based on localStorage or URL parameters
|
||||||
|
if (localStorage.getItem('showProviderDuplicateToast')) {
|
||||||
|
showToast('Provider duplicated successfully', 'success');
|
||||||
|
localStorage.removeItem('showProviderDuplicateToast');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process URL parameters for toast notifications
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
|
||||||
|
// Handle combined status and test_status parameters
|
||||||
|
if (urlParams.get('status') === 'created') {
|
||||||
|
const testStatus = urlParams.get('test_status');
|
||||||
|
const errorMsg = urlParams.get('error');
|
||||||
|
|
||||||
|
if (testStatus === 'success') {
|
||||||
|
showToast('Provider created and tested successfully', 'success');
|
||||||
|
} else if (testStatus === 'failed' && errorMsg) {
|
||||||
|
showToast(`Provider created but test failed: ${errorMsg}`, 'error');
|
||||||
|
} else {
|
||||||
|
showToast('Provider created successfully', 'success');
|
||||||
|
}
|
||||||
|
} else if (urlParams.get('status') === 'updated') {
|
||||||
|
const testStatus = urlParams.get('test_status');
|
||||||
|
const errorMsg = urlParams.get('error');
|
||||||
|
|
||||||
|
if (testStatus === 'success') {
|
||||||
|
showToast('Provider updated and tested successfully', 'success');
|
||||||
|
} else if (testStatus === 'failed' && errorMsg) {
|
||||||
|
showToast(`Provider updated but test failed: ${errorMsg}`, 'error');
|
||||||
|
} else {
|
||||||
|
showToast('Provider updated successfully', 'success');
|
||||||
|
}
|
||||||
|
} else if (urlParams.get('error')) {
|
||||||
|
showToast(urlParams.get('error'), 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Error message holder (hidden, used to pass server-side errors to JS) -->
|
||||||
|
if data.Error != "" {
|
||||||
|
<div id="provider-error-message" class="hidden">{ data.Error }</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Status message holder (hidden, used to pass server-side status to JS) -->
|
||||||
|
if data.Status != "" {
|
||||||
|
<div id="provider-status-message" class="hidden">{ data.Status }</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div id="providers-container" style="min-height: 100vh; background-color: rgb(249, 250, 251);" class="providers-page bg-gray-50 dark:bg-gray-900">
|
||||||
|
<!-- Providers list follows; import button is above -->
|
||||||
|
<div class="pb-8 w-full">
|
||||||
|
<!-- Display error alert if data.Error is not empty -->
|
||||||
|
if data.Error != "" {
|
||||||
|
<div class="mb-4 p-4 text-sm text-red-800 rounded-lg bg-red-50 dark:bg-gray-800 dark:text-red-400" role="alert">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<i class="fas fa-exclamation-circle flex-shrink-0 mr-2"></i>
|
||||||
|
<span>{ data.Error }</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Dynamic error alert container for HTMX errors -->
|
||||||
|
// <div id="htmx-error-alert" class="mb-4 p-4 text-sm text-red-800 rounded-lg bg-red-50 dark:bg-gray-800 dark:text-red-400 hidden" role="alert">
|
||||||
|
// <div class="flex items-center">
|
||||||
|
// <i class="fas fa-exclamation-circle flex-shrink-0 mr-2"></i>
|
||||||
|
// <span id="htmx-error-message"></span>
|
||||||
|
// <button type="button" class="ml-auto -mx-1.5 -my-1.5 bg-red-50 text-red-500 rounded-lg focus:ring-2 focus:ring-red-400 p-1.5 hover:bg-red-200 inline-flex items-center justify-center h-8 w-8 dark:bg-gray-800 dark:text-red-400 dark:hover:bg-gray-700" onclick="document.getElementById('htmx-error-alert').classList.add('hidden')">
|
||||||
|
// <span class="sr-only">Dismiss</span>
|
||||||
|
// <i class="fas fa-times"></i>
|
||||||
|
// </button>
|
||||||
|
// </div>
|
||||||
|
// </div>
|
||||||
|
|
||||||
|
<div class="mb-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||||
|
<i class="fas fa-server w-6 h-6 mr-2 text-blue-500 dark:text-blue-400"></i>
|
||||||
|
Storage Providers
|
||||||
|
</h1>
|
||||||
|
<!-- Import rclone config and New Provider links side by side -->
|
||||||
|
<div class="flex gap-x-4">
|
||||||
|
<a href="/storage-providers/import" class="bg-blue-600 hover:bg-blue-700 text-white font-semibold px-4 py-2 rounded-lg shadow flex items-center">
|
||||||
|
<i class="fas fa-file-import mr-2"></i>
|
||||||
|
Import rclone config
|
||||||
|
</a>
|
||||||
|
<a href="/storage-providers/new" class="flex items-center justify-center text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg px-5 py-2.5 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800">
|
||||||
|
<i class="fas fa-plus w-4 h-4 mr-2"></i>
|
||||||
|
New Provider
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6">
|
||||||
|
if len(data.Providers) == 0 {
|
||||||
|
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 p-8 flex flex-col items-center justify-center text-center">
|
||||||
|
<div class="inline-flex h-16 w-16 flex-shrink-0 items-center justify-center rounded-full bg-gray-100 mb-4 dark:bg-gray-700">
|
||||||
|
<i class="fas fa-server text-gray-400 dark:text-gray-500 text-3xl"></i>
|
||||||
|
</div>
|
||||||
|
<h3 class="mb-2 text-lg font-semibold text-gray-900 dark:text-white">No storage providers</h3>
|
||||||
|
<p class="text-gray-500 dark:text-gray-400 mb-4">Get started by creating a new storage provider.</p>
|
||||||
|
<a href="/storage-providers/new" class="inline-flex items-center px-3 py-2 text-sm font-medium text-center text-white bg-blue-700 rounded-lg hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800">
|
||||||
|
<i class="fas fa-plus w-4 h-4 mr-2"></i>
|
||||||
|
Create First Provider
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
} else {
|
||||||
|
@StorageProviders_ProvidersList(data.Providers)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Help Section -->
|
||||||
|
<div class="bg-gray-50 dark:bg-gray-800 rounded-lg shadow-sm mt-8 p-4 border border-gray-200 dark:border-gray-700">
|
||||||
|
<div class="flex items-start mb-2">
|
||||||
|
<div class="flex items-center h-5">
|
||||||
|
<i class="fas fa-info-circle w-4 h-4 text-blue-500 dark:text-blue-400 mr-2"></i>
|
||||||
|
</div>
|
||||||
|
<div class="ml-2 text-sm">
|
||||||
|
<p class="text-gray-700 dark:text-gray-300">Storage providers define connection details to different storage systems.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-start mt-4">
|
||||||
|
<div class="flex items-center h-5">
|
||||||
|
<i class="fas fa-shield-alt w-4 h-4 text-blue-500 dark:text-blue-400 mr-2"></i>
|
||||||
|
</div>
|
||||||
|
<div class="ml-2 text-sm">
|
||||||
|
<p class="text-gray-700 dark:text-gray-300">Your credentials are encrypted for security. You can test connections before using them in transfers.</p>
|
||||||
|
<p class="mt-1 text-gray-600 dark:text-gray-400">Google Drive and Google Photos providers require authentication. Click the "Authenticate" button to complete setup.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-start mt-4">
|
||||||
|
<div class="flex items-center h-5">
|
||||||
|
<i class="fas fa-link w-4 h-4 text-blue-500 dark:text-blue-400 mr-2"></i>
|
||||||
|
</div>
|
||||||
|
<div class="ml-2 text-sm">
|
||||||
|
<p class="text-gray-700 dark:text-gray-300">Providers can be used in multiple transfer configurations. Deleting a provider will affect any transfer that uses it.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/static/js/storage-providers.js"></script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Function to initialize all auth dropdowns
|
||||||
|
function initAllAuthDropdowns() {
|
||||||
|
// Get all dropdown buttons
|
||||||
|
const dropdownButtons = document.querySelectorAll('[id^="auth-dropdown-button-"]');
|
||||||
|
|
||||||
|
dropdownButtons.forEach(button => {
|
||||||
|
const providerId = button.getAttribute('data-provider-id');
|
||||||
|
const menu = document.getElementById(`auth-dropdown-menu-${providerId}`);
|
||||||
|
|
||||||
|
if (button && menu) {
|
||||||
|
// Add click listener
|
||||||
|
button.addEventListener('click', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
// Position dropdown based on available space
|
||||||
|
const buttonRect = button.getBoundingClientRect();
|
||||||
|
const spaceRight = window.innerWidth - buttonRect.right;
|
||||||
|
const spaceLeft = buttonRect.left;
|
||||||
|
|
||||||
|
// Check if there's more space on the left or right side
|
||||||
|
if (spaceLeft > spaceRight) {
|
||||||
|
menu.classList.add('right-0');
|
||||||
|
menu.classList.remove('left-0');
|
||||||
|
} else {
|
||||||
|
menu.classList.add('left-0');
|
||||||
|
menu.classList.remove('right-0');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle visibility
|
||||||
|
menu.classList.toggle('hidden');
|
||||||
|
console.log(`Toggled dropdown for provider ${providerId}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`Initialized dropdown for provider ${providerId}`);
|
||||||
|
} else {
|
||||||
|
console.error(`Could not find dropdown elements for provider ${providerId}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close dropdowns when clicking elsewhere
|
||||||
|
document.addEventListener('click', function(e) {
|
||||||
|
dropdownButtons.forEach(button => {
|
||||||
|
const providerId = button.getAttribute('data-provider-id');
|
||||||
|
const menu = document.getElementById(`auth-dropdown-menu-${providerId}`);
|
||||||
|
|
||||||
|
if (menu && !button.contains(e.target) && !menu.contains(e.target)) {
|
||||||
|
menu.classList.add('hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize dropdowns when DOM is loaded
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
// Check for status messages based on URL parameters
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
if (urlParams.get('status') === 'gdrive_auth_success') {
|
||||||
|
showToast("Google Drive authentication completed successfully", 'success');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize all authentication dropdowns
|
||||||
|
initAllAuthDropdowns();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Partial for just the providers-list div
|
||||||
|
// Usage: @StorageProviders_ProvidersList(providers)
|
||||||
|
templ StorageProviders_ProvidersList(providers []db.StorageProvider) {
|
||||||
|
<div id="providers-list" class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 overflow-hidden">
|
||||||
|
<ul class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
for _, provider := range providers {
|
||||||
|
<li>
|
||||||
|
<div class="block hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
|
||||||
|
<div class="px-4 py-4 sm:px-6">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<p class="text-sm font-medium text-blue-600 dark:text-blue-400 truncate">
|
||||||
|
{ provider.Name }
|
||||||
|
</p>
|
||||||
|
<span class="ml-2 bg-blue-100 text-blue-800 text-xs font-medium mr-2 px-2.5 py-0.5 rounded-full dark:bg-blue-900 dark:text-blue-300">
|
||||||
|
{ string(provider.Type) }
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="ml-2 flex-shrink-0 flex space-x-2">
|
||||||
|
<!-- Test Connection Button -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
hx-post={ fmt.Sprintf("/storage-providers/%d/test", provider.ID) }
|
||||||
|
hx-swap="none"
|
||||||
|
data-provider-id={ fmt.Sprint(provider.ID) }
|
||||||
|
data-provider-name={ provider.Name }
|
||||||
|
onclick="window.testProvider(this)"
|
||||||
|
class="test-provider-btn text-blue-700 bg-blue-100 hover:bg-blue-200 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-3 py-1.5 text-center inline-flex items-center dark:bg-blue-700 dark:text-blue-300 dark:hover:bg-blue-600 dark:focus:ring-blue-800">
|
||||||
|
<i class="fas fa-plug w-3.5 h-3.5 mr-1.5"></i>
|
||||||
|
Test
|
||||||
|
</button>
|
||||||
|
<!-- Duplicate Button -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
hx-post={ fmt.Sprintf("/storage-providers/%d/duplicate", provider.ID) }
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
hx-target="#providers-list"
|
||||||
|
data-provider-id={ fmt.Sprint(provider.ID) }
|
||||||
|
data-provider-name={ provider.Name }
|
||||||
|
class="text-indigo-700 bg-indigo-100 hover:bg-indigo-200 focus:ring-4 focus:outline-none focus:ring-indigo-300 font-medium rounded-lg text-sm px-3 py-1.5 text-center inline-flex items-center dark:bg-indigo-700 dark:text-indigo-300 dark:hover:bg-indigo-600 dark:focus:ring-indigo-800">
|
||||||
|
<i class="fas fa-clone w-3.5 h-3.5 mr-1.5"></i>
|
||||||
|
Duplicate
|
||||||
|
</button>
|
||||||
|
<!-- Edit Button -->
|
||||||
|
<a href={ templ.SafeURL(fmt.Sprintf("/storage-providers/%d", provider.ID)) } class="text-gray-700 bg-gray-100 hover:bg-gray-200 focus:ring-4 focus:outline-none focus:ring-gray-300 font-medium rounded-lg text-sm px-3 py-1.5 text-center inline-flex items-center dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600 dark:focus:ring-gray-700">
|
||||||
|
<i class="fas fa-edit w-3.5 h-3.5 mr-1.5"></i>
|
||||||
|
Edit
|
||||||
|
</a>
|
||||||
|
<!-- Delete Button -->
|
||||||
|
@StorageProviderDialog(
|
||||||
|
fmt.Sprintf("delete-provider-dialog-%d", provider.ID),
|
||||||
|
"Delete Provider",
|
||||||
|
fmt.Sprintf("Are you sure you want to delete the provider '%s'? This cannot be undone.", provider.Name),
|
||||||
|
"text-white bg-red-600 hover:bg-red-800 focus:ring-4 focus:outline-none focus:ring-red-300 dark:focus:ring-red-800 font-medium rounded-lg text-sm inline-flex items-center px-5 py-2.5 text-center",
|
||||||
|
"Delete",
|
||||||
|
"delete",
|
||||||
|
provider.ID,
|
||||||
|
provider.Name,
|
||||||
|
)
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={ showProviderModal(fmt.Sprintf("delete-provider-dialog-%d", provider.ID)) }
|
||||||
|
class="text-white bg-red-700 hover:bg-red-800 focus:ring-4 focus:outline-none focus:ring-red-300 font-medium rounded-lg text-sm px-3 py-1.5 text-center inline-flex items-center dark:bg-red-600 dark:hover:bg-red-700 dark:focus:ring-red-800">
|
||||||
|
<i class="fas fa-trash-alt w-3.5 h-3.5 mr-1.5"></i>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3 sm:flex sm:justify-between">
|
||||||
|
<div class="sm:flex flex-col md:flex-row gap-2 md:gap-6">
|
||||||
|
<!-- Show different details based on provider type -->
|
||||||
|
if provider.Type == "local" {
|
||||||
|
<p class="flex items-center text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
<i class="fas fa-folder w-4 h-4 mr-1.5 text-gray-400 dark:text-gray-500"></i>
|
||||||
|
Path: { provider.Host }
|
||||||
|
</p>
|
||||||
|
} else if provider.Type == "s3" || provider.Type == "wasabi" || provider.Type == "minio" {
|
||||||
|
<p class="flex items-center text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
<i class="fas fa-server w-4 h-4 mr-1.5 text-gray-400 dark:text-gray-500"></i>
|
||||||
|
Endpoint: { provider.Host }
|
||||||
|
</p>
|
||||||
|
<p class="mt-2 md:mt-0 flex items-center text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
<i class="fas fa-box w-4 h-4 mr-1.5 text-gray-400 dark:text-gray-500"></i>
|
||||||
|
Bucket: { provider.Bucket }
|
||||||
|
</p>
|
||||||
|
if provider.Region != "" {
|
||||||
|
<p class="mt-2 md:mt-0 flex items-center text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
<i class="fas fa-globe w-4 h-4 mr-1.5 text-gray-400 dark:text-gray-500"></i>
|
||||||
|
Region: { provider.Region }
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
} else if provider.Type == "drive" || provider.Type == "gphotos" {
|
||||||
|
<p class="flex items-center text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
<i class="fab fa-google-drive w-4 h-4 mr-1.5 text-gray-400 dark:text-gray-500"></i>
|
||||||
|
Google
|
||||||
|
if provider.Type == "drive" {
|
||||||
|
Drive
|
||||||
|
} else {
|
||||||
|
Photos
|
||||||
|
}
|
||||||
|
if provider.DriveID != "" {
|
||||||
|
: { provider.DriveID }
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
if provider.Authenticated != nil && *provider.Authenticated {
|
||||||
|
<span class="mt-2 md:mt-0 bg-green-100 text-green-800 text-xs font-medium mr-2 px-2.5 py-0.5 rounded-full dark:bg-green-900 dark:text-green-300">
|
||||||
|
<i class="fas fa-check-circle w-3 h-3 mr-1 inline"></i>
|
||||||
|
Authenticated
|
||||||
|
</span>
|
||||||
|
} else {
|
||||||
|
<div class="flex flex-col md:flex-row items-start md:items-center mt-2 md:mt-0">
|
||||||
|
|
||||||
|
<!-- Google Authentication Dropdown -->
|
||||||
|
<div class="relative inline-block text-left mt-2 md:mt-0">
|
||||||
|
<button
|
||||||
|
id={ fmt.Sprintf("auth-dropdown-button-%d", provider.ID) }
|
||||||
|
data-provider-id={ fmt.Sprintf("%d", provider.ID) }
|
||||||
|
type="button"
|
||||||
|
class="text-yellow-700 bg-yellow-100 hover:bg-yellow-200 focus:ring-4 focus:outline-none focus:ring-yellow-300 font-medium rounded-lg text-sm px-3 py-1.5 text-center inline-flex items-center dark:bg-yellow-900 dark:text-yellow-300 dark:hover:bg-yellow-800 dark:focus:ring-yellow-800"
|
||||||
|
aria-expanded="false"
|
||||||
|
aria-haspopup="true">
|
||||||
|
<i class="fas fa-key w-3.5 h-3.5 mr-1.5"></i>
|
||||||
|
Authenticate with Google
|
||||||
|
<i class="fas fa-chevron-down w-3.5 h-3.5 ml-1.5"></i>
|
||||||
|
</button>
|
||||||
|
<div id={ fmt.Sprintf("auth-dropdown-menu-%d", provider.ID) } class="origin-top-right absolute left-0 mt-2 w-56 rounded-md shadow-lg bg-white dark:bg-gray-700 ring-1 ring-black ring-opacity-5 focus:outline-none z-50 hidden" style="max-height: 200px; overflow-y: auto;" role="menu" aria-orientation="vertical" aria-labelledby={ fmt.Sprintf("auth-dropdown-button-%d", provider.ID) }>
|
||||||
|
<div class="py-1" role="none">
|
||||||
|
<a href={ templ.SafeURL(fmt.Sprintf("/storage-providers/%d/gdrive-auth", provider.ID)) } class="text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 group flex items-center px-4 py-2 text-sm" role="menuitem">
|
||||||
|
<i class="fas fa-globe w-4 h-4 mr-3 text-gray-500 dark:text-gray-400"></i>
|
||||||
|
Standard Authentication
|
||||||
|
</a>
|
||||||
|
<a href={ templ.SafeURL(fmt.Sprintf("/storage-providers/%d/gdrive-headless-auth", provider.ID)) } class="text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 group flex items-center px-4 py-2 text-sm" role="menuitem">
|
||||||
|
<i class="fas fa-terminal w-4 h-4 mr-3 text-gray-500 dark:text-gray-400"></i>
|
||||||
|
Headless Authentication
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Hidden fallback links - only shown when JavaScript is disabled -->
|
||||||
|
<noscript>
|
||||||
|
<div class="flex flex-col text-xs text-gray-500 dark:text-gray-400 mt-1 ml-1">
|
||||||
|
<a href={ templ.SafeURL(fmt.Sprintf("/storage-providers/%d/gdrive-auth", provider.ID)) } class="hover:underline hover:text-blue-500">
|
||||||
|
Direct Standard Auth
|
||||||
|
</a>
|
||||||
|
<a href={ templ.SafeURL(fmt.Sprintf("/storage-providers/%d/gdrive-headless-auth", provider.ID)) } class="hover:underline hover:text-blue-500">
|
||||||
|
Direct Headless Auth
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</noscript>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
} else if provider.Type == "webdav" || provider.Type == "nextcloud" {
|
||||||
|
<p class="flex items-center text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
<i class="fas fa-cloud w-4 h-4 mr-1.5 text-gray-400 dark:text-gray-500"></i>
|
||||||
|
Server: { provider.Host }
|
||||||
|
</p>
|
||||||
|
if provider.Username != "" {
|
||||||
|
<p class="mt-2 md:mt-0 flex items-center text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
<i class="fas fa-user w-4 h-4 mr-1.5 text-gray-400 dark:text-gray-500"></i>
|
||||||
|
User: { provider.Username }
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
} else if provider.Type == "sftp" || provider.Type == "hetzner" {
|
||||||
|
<p class="flex items-center text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
<i class="fas fa-server w-4 h-4 mr-1.5 text-gray-400 dark:text-gray-500"></i>
|
||||||
|
Host: { provider.Host }
|
||||||
|
if provider.Port > 0 {
|
||||||
|
:{ fmt.Sprint(provider.Port) }
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
if provider.Username != "" {
|
||||||
|
<p class="mt-2 md:mt-0 flex items-center text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
<i class="fas fa-user w-4 h-4 mr-1.5 text-gray-400 dark:text-gray-500"></i>
|
||||||
|
User: { provider.Username }
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
} else if provider.Type == "b2" {
|
||||||
|
<p class="flex items-center text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
<i class="fas fa-cloud w-4 h-4 mr-1.5 text-gray-400 dark:text-gray-500"></i>
|
||||||
|
Backblaze B2
|
||||||
|
</p>
|
||||||
|
if provider.Bucket != "" {
|
||||||
|
<p class="mt-2 md:mt-0 flex items-center text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
<i class="fas fa-box w-4 h-4 mr-1.5 text-gray-400 dark:text-gray-500"></i>
|
||||||
|
Bucket: { provider.Bucket }
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
<!-- Default display for other provider types -->
|
||||||
|
<p class="flex items-center text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
<i class="fas fa-server w-4 h-4 mr-1.5 text-gray-400 dark:text-gray-500"></i>
|
||||||
|
Host: { provider.Host }
|
||||||
|
if provider.Port > 0 {
|
||||||
|
:{ fmt.Sprint(provider.Port) }
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 md:mt-0 flex items-center text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
<i class="far fa-clock w-4 h-4 mr-1.5 text-gray-400 dark:text-gray-500"></i>
|
||||||
|
<p>
|
||||||
|
Updated: { provider.UpdatedAt.Format("2006-01-02 15:04:05") }
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Authentication Notice (More Visible) -->
|
||||||
|
if (provider.Type == "drive" || provider.Type == "gphotos") && (provider.Authenticated == nil || !*provider.Authenticated) {
|
||||||
|
<div class="mt-3 flex items-center justify-between bg-yellow-50 dark:bg-yellow-900/30 rounded-lg p-3 border border-yellow-200 dark:border-yellow-800">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<i class="fas fa-exclamation-triangle text-yellow-500 w-5 h-5 mr-2"></i>
|
||||||
|
<span class="text-sm text-yellow-700 dark:text-yellow-300">
|
||||||
|
Authentication required for Google
|
||||||
|
if provider.Type == "drive" {
|
||||||
|
Drive
|
||||||
|
} else {
|
||||||
|
Photos
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<a href={ templ.SafeURL(fmt.Sprintf("/storage-providers/%d/gdrive-auth", provider.ID)) } class="text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 px-3 py-1.5 rounded-lg">
|
||||||
|
<i class="fas fa-globe w-3.5 h-3.5 mr-1.5"></i>
|
||||||
|
Standard Auth
|
||||||
|
</a>
|
||||||
|
<a href={ templ.SafeURL(fmt.Sprintf("/storage-providers/%d/gdrive-headless-auth", provider.ID)) } class="text-sm font-medium text-white bg-green-600 hover:bg-green-700 px-3 py-1.5 rounded-lg">
|
||||||
|
<i class="fas fa-terminal w-3.5 h-3.5 mr-1.5"></i>
|
||||||
|
Headless Auth
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dialog component for confirmation dialogs for storage providers
|
||||||
|
// Usage: @StorageProviderDialog(id, title, message, confirmClass, confirmText, action, providerID, providerName)
|
||||||
|
templ StorageProviderDialog(id string, title string, message string, confirmClass string, confirmText string, action string, providerID uint, providerName string) {
|
||||||
|
<div id={ id } tabindex="-1" aria-hidden="true" class="hidden fixed top-0 right-0 left-0 z-50 justify-center items-center w-full md:inset-0 h-[calc(100%-1rem)] max-h-full">
|
||||||
|
<!-- Backdrop -->
|
||||||
|
<div id={ fmt.Sprintf("%s-backdrop", id) } class="fixed inset-0 bg-gray-900/50 dark:bg-gray-900/80 backdrop-blur-sm"></div>
|
||||||
|
<!-- Modal content -->
|
||||||
|
<div class="relative p-4 w-full max-w-md max-h-full mx-auto">
|
||||||
|
<div class="relative bg-white rounded-lg shadow dark:bg-gray-700">
|
||||||
|
<div class="p-6 text-center">
|
||||||
|
<i class="fas fa-trash-alt text-red-400 text-3xl mb-4"></i>
|
||||||
|
<h3 class="mb-5 text-lg font-normal text-gray-500 dark:text-gray-400">{ message }</h3>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class={ confirmClass }
|
||||||
|
hx-delete={ fmt.Sprintf("/storage-providers/%d", providerID) }
|
||||||
|
hx-target="closest li"
|
||||||
|
hx-swap="delete"
|
||||||
|
data-provider-name={ providerName }
|
||||||
|
data-provider-id={ fmt.Sprint(providerID) }
|
||||||
|
id={ fmt.Sprintf("delete-provider-btn-%d", providerID) }
|
||||||
|
onclick={ triggerProviderDelete(id, providerID, providerName) }>
|
||||||
|
{ confirmText }
|
||||||
|
</button>
|
||||||
|
<button type="button" onclick={ closeProviderModal(id) } class="text-gray-500 bg-white hover:bg-gray-100 focus:ring-4 focus:outline-none focus:ring-gray-200 rounded-lg border border-gray-200 text-sm font-medium px-5 py-2.5 hover:text-gray-900 focus:z-10 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-500 dark:hover:text-white dark:hover:bg-gray-600 dark:focus:ring-gray-600">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
script closeProviderModal(id string) {
|
||||||
|
const modal = document.getElementById(id);
|
||||||
|
const backdrop = document.getElementById(id + '-backdrop');
|
||||||
|
if (modal) {
|
||||||
|
modal.classList.add('hidden');
|
||||||
|
modal.classList.remove('flex');
|
||||||
|
}
|
||||||
|
if (backdrop) {
|
||||||
|
backdrop.remove();
|
||||||
|
}
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
script showProviderModal(id string) {
|
||||||
|
const modal = document.getElementById(id);
|
||||||
|
if (modal) {
|
||||||
|
modal.classList.remove('hidden');
|
||||||
|
modal.classList.add('flex');
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
script triggerProviderDelete(dialogId string, providerID uint, providerName string) {
|
||||||
|
// Hide the dialog
|
||||||
|
document.getElementById(dialogId).classList.add("hidden");
|
||||||
|
document.getElementById(dialogId).classList.remove("flex");
|
||||||
|
// Store data for event handlers
|
||||||
|
window.lastDeletedProvider = {
|
||||||
|
id: providerID,
|
||||||
|
name: providerName
|
||||||
|
};
|
||||||
|
window.currentlyDeletingProvider = true;
|
||||||
|
}
|
||||||
@@ -0,0 +1,437 @@
|
|||||||
|
package components
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Storage Providers Import Page
|
||||||
|
// User uploads a config, previews/edit remotes, selects which to import
|
||||||
|
// On submit, POSTs selected remotes to /storage-providers/import/confirm
|
||||||
|
|
||||||
|
// Main import page template
|
||||||
|
templ StorageProvidersImport(ctx context.Context, preview RcloneImportPreview) {
|
||||||
|
<div id="providers-container" style="min-height: 100vh; background-color: rgb(249, 250, 251);" class="providers-page bg-gray-50 dark:bg-gray-900 pb-8 w-full">
|
||||||
|
<!-- Header with back button -->
|
||||||
|
<div class="mb-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||||
|
<i class="fas fa-file-import w-6 h-6 mr-2 text-blue-500 dark:text-blue-400"></i>
|
||||||
|
Import rclone Config
|
||||||
|
</h1>
|
||||||
|
<a href="/storage-providers" class="flex items-center justify-center text-gray-700 bg-gray-100 hover:bg-gray-200 focus:ring-4 focus:ring-gray-300 font-medium rounded-lg px-5 py-2.5 dark:bg-gray-700 dark:hover:bg-gray-600 dark:text-white focus:outline-none dark:focus:ring-gray-800">
|
||||||
|
<i class="fas fa-arrow-left w-4 h-4 mr-2"></i>
|
||||||
|
Back to Providers
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main content -->
|
||||||
|
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 p-6">
|
||||||
|
<div class="mb-6">
|
||||||
|
<h2 class="text-xl font-bold text-gray-900 dark:text-white mb-3">Import from rclone Config</h2>
|
||||||
|
<p class="text-gray-700 dark:text-gray-300 mb-4">
|
||||||
|
Upload your rclone configuration file to import storage providers. You'll be able to preview and select which remotes to import, and add any missing credentials.
|
||||||
|
</p>
|
||||||
|
<div class="p-4 mb-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-gray-800 dark:text-blue-400" role="alert">
|
||||||
|
<div class="flex">
|
||||||
|
<i class="fas fa-info-circle flex-shrink-0 inline w-5 h-5 mr-3 mt-0.5"></i>
|
||||||
|
<div>
|
||||||
|
<span class="font-medium">Instructions:</span>
|
||||||
|
<ul class="mt-1.5 ml-4 list-disc">
|
||||||
|
<li>Upload your <code>rclone.conf</code> file (usually found in <code>~/.config/rclone/</code> or <code>%USERPROFILE%\.config\rclone\</code>)</li>
|
||||||
|
<li>Review the detected remotes and select which ones to import</li>
|
||||||
|
<li>Add any missing credentials that may not be in your config file</li>
|
||||||
|
<li>Use the "Add Custom Field" button to add any provider-specific options</li>
|
||||||
|
</ul>
|
||||||
|
<div class="mt-2">
|
||||||
|
<a href="https://rclone.org/docs/" target="_blank" class="text-blue-600 dark:text-blue-500 underline hover:no-underline">rclone documentation</a> |
|
||||||
|
<button type="button" id="show-common-values" class="text-blue-600 dark:text-blue-500 underline hover:no-underline">Show common configuration values</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Common values reference (hidden by default) -->
|
||||||
|
<div id="common-values-reference" class="hidden p-4 mb-4 text-sm text-gray-800 rounded-lg bg-gray-50 dark:bg-gray-800 dark:text-gray-300 border border-gray-200 dark:border-gray-700">
|
||||||
|
<h3 class="font-medium text-base mb-2">Common Configuration Values by Provider Type</h3>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<h4 class="font-medium mb-1">SFTP</h4>
|
||||||
|
<ul class="ml-4 list-disc">
|
||||||
|
<li><code>host</code>: Server hostname or IP</li>
|
||||||
|
<li><code>user</code>: Username</li>
|
||||||
|
<li><code>pass</code>: Password (if not using key)</li>
|
||||||
|
<li><code>port</code>: SSH port (default: 22)</li>
|
||||||
|
<li><code>key_file</code>: Path to private key</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 class="font-medium mb-1">S3 / Wasabi / Minio</h4>
|
||||||
|
<ul class="ml-4 list-disc">
|
||||||
|
<li><code>access_key_id</code>: Access key</li>
|
||||||
|
<li><code>secret_access_key</code>: Secret key</li>
|
||||||
|
<li><code>region</code>: Region name</li>
|
||||||
|
<li><code>endpoint</code>: Custom endpoint URL</li>
|
||||||
|
<li><code>bucket</code>: Bucket name</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 class="font-medium mb-1">FTP</h4>
|
||||||
|
<ul class="ml-4 list-disc">
|
||||||
|
<li><code>host</code>: Server hostname or IP</li>
|
||||||
|
<li><code>user</code>: Username</li>
|
||||||
|
<li><code>pass</code>: Password</li>
|
||||||
|
<li><code>port</code>: FTP port (default: 21)</li>
|
||||||
|
<li><code>tls</code>: Use FTPS (true/false)</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 class="font-medium mb-1">Google Drive</h4>
|
||||||
|
<ul class="ml-4 list-disc">
|
||||||
|
<li><code>client_id</code>: OAuth client ID</li>
|
||||||
|
<li><code>client_secret</code>: OAuth client secret</li>
|
||||||
|
<li><code>refresh_token</code>: OAuth refresh token</li>
|
||||||
|
<li><code>team_drive</code>: Team Drive ID (optional)</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 class="font-medium mb-1">OneDrive</h4>
|
||||||
|
<ul class="ml-4 list-disc">
|
||||||
|
<li><code>client_id</code>: OAuth client ID</li>
|
||||||
|
<li><code>client_secret</code>: OAuth client secret</li>
|
||||||
|
<li><code>refresh_token</code>: OAuth refresh token</li>
|
||||||
|
<li><code>drive_id</code>: Drive ID (optional)</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 class="font-medium mb-1">WebDAV / Nextcloud</h4>
|
||||||
|
<ul class="ml-4 list-disc">
|
||||||
|
<li><code>url</code>: WebDAV URL</li>
|
||||||
|
<li><code>user</code>: Username</li>
|
||||||
|
<li><code>pass</code>: Password</li>
|
||||||
|
<li><code>vendor</code>: nextcloud/owncloud/etc</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" id="hide-common-values" class="mt-3 text-blue-600 dark:text-blue-500 underline hover:no-underline">Hide reference</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form id="rclone-upload-form" enctype="multipart/form-data" method="POST" action="/storage-providers/import/preview" hx-post="/storage-providers/import/preview" hx-target="#import-preview" hx-swap="innerHTML" class="mt-4">
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Select rclone config file:</label>
|
||||||
|
<input type="file" name="rclone_config" accept=".conf,.txt,.ini,.cfg" required class="block w-full text-sm text-gray-900 border border-gray-300 rounded-lg cursor-pointer bg-gray-50 dark:text-gray-400 focus:outline-none dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400" />
|
||||||
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Accepted formats: .conf, .txt, .ini, .cfg</p>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800">
|
||||||
|
<i class="fas fa-search mr-2"></i>
|
||||||
|
Preview Remotes
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="import-preview" class="mt-6">
|
||||||
|
@RcloneImportPreviewContent(ctx, preview)
|
||||||
|
</div>
|
||||||
|
<div id="import-result" class="mt-6"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
// Just the preview content for HTMX updates
|
||||||
|
templ RcloneImportPreviewContent(ctx context.Context, preview RcloneImportPreview) {
|
||||||
|
if preview.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>{preview.Error}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
} else if preview.Remotes != nil && len(preview.Remotes) > 0 {
|
||||||
|
<div class="mb-4">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-2">Found {fmt.Sprintf("%d", len(preview.Remotes))} remotes</h3>
|
||||||
|
<p class="text-gray-700 dark:text-gray-300 mb-4">Select which remotes to import and edit their details if needed.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form id="confirm-import-form" method="POST" action="/storage-providers/import/confirm" hx-post="/storage-providers/import/confirm" hx-target="#import-result" hx-swap="innerHTML">
|
||||||
|
<div class="relative overflow-x-auto shadow-md sm:rounded-lg">
|
||||||
|
<table class="w-full text-sm text-left text-gray-500 dark:text-gray-400">
|
||||||
|
<thead class="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-700 dark:text-gray-400">
|
||||||
|
<tr>
|
||||||
|
<th scope="col" class="px-6 py-3">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<input type="checkbox" checked 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 dark:focus:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600" id="select-all-checkbox" onclick="toggleAllCheckboxes(this)" />
|
||||||
|
<label for="select-all-checkbox" class="ml-2 text-sm font-medium text-gray-900 dark:text-gray-300">Import?</label>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
<th scope="col" class="px-6 py-3">Name</th>
|
||||||
|
<th scope="col" class="px-6 py-3">Type</th>
|
||||||
|
<th scope="col" class="px-6 py-3">Fields</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
if len(preview.Remotes) > 0 {
|
||||||
|
for _, remote := range preview.Remotes {
|
||||||
|
<tr class="bg-white border-b dark:bg-gray-800 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
<input type="checkbox" name={"import_" + remote.Name} checked 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 dark:focus:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600" />
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
<input type="text" name={"name_" + remote.Name} value={remote.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" />
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
<input type="text" name={"type_" + remote.Name} value={remote.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" />
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<!-- Existing fields from rclone config -->
|
||||||
|
for k, v := range remote.Fields {
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="font-mono text-xs text-gray-700 dark:text-gray-300 min-w-[80px]">{k}:</span>
|
||||||
|
<input type="text" name={"field_" + remote.Name + "_" + k} value={v} class="bg-gray-50 border border-gray-300 text-gray-900 text-xs rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-1.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Add missing credentials section -->
|
||||||
|
<div class="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<div class="text-sm font-medium text-gray-900 dark:text-white mb-2">Add or Update Credentials</div>
|
||||||
|
|
||||||
|
<!-- Username field (if not present) -->
|
||||||
|
if _, exists := remote.Fields["user"]; !exists && (remote.Type == "sftp" || remote.Type == "ftp" || remote.Type == "smb" || remote.Type == "webdav") {
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="font-mono text-xs text-gray-700 dark:text-gray-300 min-w-[80px]">user:</span>
|
||||||
|
<input type="text" name={"field_" + remote.Name + "_user"} placeholder="Username" class="bg-gray-50 border border-gray-300 text-gray-900 text-xs rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-1.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Password field (if not present) -->
|
||||||
|
if _, exists := remote.Fields["pass"]; !exists && (remote.Type == "sftp" || remote.Type == "ftp" || remote.Type == "smb" || remote.Type == "webdav") {
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="font-mono text-xs text-gray-700 dark:text-gray-300 min-w-[80px]">pass:</span>
|
||||||
|
<input type="password" name={"field_" + remote.Name + "_pass"} placeholder="Password" class="bg-gray-50 border border-gray-300 text-gray-900 text-xs rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-1.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- S3 credentials -->
|
||||||
|
if remote.Type == "s3" || remote.Type == "wasabi" || remote.Type == "minio" || remote.Type == "b2" {
|
||||||
|
<!-- Access Key -->
|
||||||
|
if _, exists := remote.Fields["access_key_id"]; !exists {
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="font-mono text-xs text-gray-700 dark:text-gray-300 min-w-[80px]">access_key_id:</span>
|
||||||
|
<input type="text" name={"field_" + remote.Name + "_access_key_id"} placeholder="Access Key" class="bg-gray-50 border border-gray-300 text-gray-900 text-xs rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-1.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Secret Key -->
|
||||||
|
if _, exists := remote.Fields["secret_access_key"]; !exists {
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="font-mono text-xs text-gray-700 dark:text-gray-300 min-w-[80px]">secret_access_key:</span>
|
||||||
|
<input type="password" name={"field_" + remote.Name + "_secret_access_key"} placeholder="Secret Key" class="bg-gray-50 border border-gray-300 text-gray-900 text-xs rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-1.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- OAuth credentials -->
|
||||||
|
if remote.Type == "drive" || remote.Type == "onedrive" || remote.Type == "gphotos" {
|
||||||
|
<!-- Client ID -->
|
||||||
|
if _, exists := remote.Fields["client_id"]; !exists {
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="font-mono text-xs text-gray-700 dark:text-gray-300 min-w-[80px]">client_id:</span>
|
||||||
|
<input type="text" name={"field_" + remote.Name + "_client_id"} placeholder="Client ID" class="bg-gray-50 border border-gray-300 text-gray-900 text-xs rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-1.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Client Secret -->
|
||||||
|
if _, exists := remote.Fields["client_secret"]; !exists {
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="font-mono text-xs text-gray-700 dark:text-gray-300 min-w-[80px]">client_secret:</span>
|
||||||
|
<input type="password" name={"field_" + remote.Name + "_client_secret"} placeholder="Client Secret" class="bg-gray-50 border border-gray-300 text-gray-900 text-xs rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-1.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Add custom field button -->
|
||||||
|
<div class="mt-2">
|
||||||
|
<button type="button" id={"add-field-btn-" + remote.Name} class="text-xs text-blue-700 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300">
|
||||||
|
<i class="fas fa-plus mr-1"></i> Add Custom Field
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Container for dynamically added custom fields -->
|
||||||
|
<div id={"custom-fields-" + remote.Name} class="mt-2"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
<tr class="bg-white border-b dark:bg-gray-800 dark:border-gray-700">
|
||||||
|
<td colspan="4" class="px-6 py-4 text-center text-gray-500 dark:text-gray-400">No remotes found in config.</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6">
|
||||||
|
<button type="submit" class="text-white bg-green-700 hover:bg-green-800 focus:ring-4 focus:ring-green-300 font-medium rounded-lg text-sm px-5 py-2.5 dark:bg-green-600 dark:hover:bg-green-700 focus:outline-none dark:focus:ring-green-800">
|
||||||
|
<i class="fas fa-file-import mr-2"></i>
|
||||||
|
Import Selected Remotes
|
||||||
|
</button>
|
||||||
|
<a href="/storage-providers" class="ml-2 text-gray-900 bg-white border border-gray-300 focus:outline-none hover:bg-gray-100 focus:ring-4 focus:ring-gray-200 font-medium rounded-lg text-sm px-5 py-2.5 dark:bg-gray-800 dark:text-white dark:border-gray-600 dark:hover:bg-gray-700 dark:hover:border-gray-600 dark:focus:ring-gray-700">Cancel</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function toggleAllCheckboxes(source) {
|
||||||
|
const checkboxes = document.querySelectorAll('input[type="checkbox"][name^="import_"]');
|
||||||
|
for (let i = 0; i < checkboxes.length; i++) {
|
||||||
|
checkboxes[i].checked = source.checked;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addCustomField(remoteName) {
|
||||||
|
console.log('Adding custom field for remote:', remoteName);
|
||||||
|
const container = document.getElementById('custom-fields-' + remoteName);
|
||||||
|
if (!container) {
|
||||||
|
console.error('Container not found for remote:', remoteName);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fieldCount = container.children.length;
|
||||||
|
const fieldId = 'custom-field-' + remoteName + '-' + fieldCount;
|
||||||
|
|
||||||
|
const fieldRow = document.createElement('div');
|
||||||
|
fieldRow.className = 'flex items-center gap-2 mt-2';
|
||||||
|
fieldRow.id = fieldId;
|
||||||
|
|
||||||
|
// Create key input
|
||||||
|
const keyInput = document.createElement('input');
|
||||||
|
keyInput.type = 'text';
|
||||||
|
keyInput.className = 'bg-gray-50 border border-gray-300 text-gray-900 text-xs rounded-lg focus:ring-blue-500 focus:border-blue-500 w-1/3 p-1.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500';
|
||||||
|
keyInput.placeholder = 'Field name';
|
||||||
|
keyInput.id = 'custom-key-' + remoteName + '-' + fieldCount;
|
||||||
|
keyInput.onchange = function() {
|
||||||
|
const valueInput = document.getElementById('custom-value-' + remoteName + '-' + fieldCount);
|
||||||
|
if (valueInput && this.value) {
|
||||||
|
valueInput.name = 'field_' + remoteName + '_' + this.value;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create value input
|
||||||
|
const valueInput = document.createElement('input');
|
||||||
|
valueInput.type = 'text';
|
||||||
|
valueInput.className = 'bg-gray-50 border border-gray-300 text-gray-900 text-xs rounded-lg focus:ring-blue-500 focus:border-blue-500 w-2/3 p-1.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500';
|
||||||
|
valueInput.placeholder = 'Value';
|
||||||
|
valueInput.id = 'custom-value-' + remoteName + '-' + fieldCount;
|
||||||
|
// Name will be set when key changes
|
||||||
|
|
||||||
|
// Create remove button
|
||||||
|
const removeBtn = document.createElement('button');
|
||||||
|
removeBtn.type = 'button';
|
||||||
|
removeBtn.className = 'text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300';
|
||||||
|
removeBtn.innerHTML = '<i class="fas fa-times"></i>';
|
||||||
|
removeBtn.onclick = function() {
|
||||||
|
document.getElementById(fieldId).remove();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add elements to the row
|
||||||
|
fieldRow.appendChild(keyInput);
|
||||||
|
fieldRow.appendChild(valueInput);
|
||||||
|
fieldRow.appendChild(removeBtn);
|
||||||
|
|
||||||
|
// Add row to container
|
||||||
|
container.appendChild(fieldRow);
|
||||||
|
|
||||||
|
// Focus on the new key input
|
||||||
|
keyInput.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize the buttons directly instead of using DOMContentLoaded
|
||||||
|
function initializeCustomFieldButtons() {
|
||||||
|
console.log('Initializing custom field buttons');
|
||||||
|
const addFieldButtons = document.querySelectorAll('[id^="add-field-btn-"]');
|
||||||
|
console.log('Found buttons:', addFieldButtons.length);
|
||||||
|
|
||||||
|
// Add click event listeners to each button
|
||||||
|
addFieldButtons.forEach(function(button) {
|
||||||
|
const remoteName = button.id.replace('add-field-btn-', '');
|
||||||
|
console.log('Adding listener for remote:', remoteName);
|
||||||
|
button.addEventListener('click', function() {
|
||||||
|
console.log('Button clicked for remote:', remoteName);
|
||||||
|
addCustomField(remoteName);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try both approaches for maximum compatibility
|
||||||
|
// 1. Initialize immediately if document is already loaded
|
||||||
|
if (document.readyState === 'complete' || document.readyState === 'interactive') {
|
||||||
|
setTimeout(initializeCustomFieldButtons, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Also listen for DOMContentLoaded
|
||||||
|
document.addEventListener('DOMContentLoaded', initializeCustomFieldButtons);
|
||||||
|
|
||||||
|
// 3. Also initialize when the form is loaded via HTMX
|
||||||
|
document.addEventListener('htmx:afterSwap', function(event) {
|
||||||
|
if (event.detail.target.id === 'import-preview') {
|
||||||
|
console.log('HTMX content loaded, initializing buttons');
|
||||||
|
setTimeout(initializeCustomFieldButtons, 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. Toggle common values reference
|
||||||
|
function initializeReferenceToggle() {
|
||||||
|
console.log('Initializing reference toggle buttons');
|
||||||
|
const showBtn = document.getElementById('show-common-values');
|
||||||
|
const hideBtn = document.getElementById('hide-common-values');
|
||||||
|
const reference = document.getElementById('common-values-reference');
|
||||||
|
|
||||||
|
if (showBtn && hideBtn && reference) {
|
||||||
|
console.log('Found reference toggle elements');
|
||||||
|
// Remove any existing listeners to prevent duplicates
|
||||||
|
showBtn.removeEventListener('click', showReference);
|
||||||
|
hideBtn.removeEventListener('click', hideReference);
|
||||||
|
|
||||||
|
// Add new listeners
|
||||||
|
showBtn.addEventListener('click', showReference);
|
||||||
|
hideBtn.addEventListener('click', hideReference);
|
||||||
|
|
||||||
|
// Define the functions
|
||||||
|
function showReference() {
|
||||||
|
console.log('Showing reference');
|
||||||
|
reference.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideReference() {
|
||||||
|
console.log('Hiding reference');
|
||||||
|
reference.classList.add('hidden');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('Reference toggle elements not found');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize on DOMContentLoaded
|
||||||
|
document.addEventListener('DOMContentLoaded', initializeReferenceToggle);
|
||||||
|
|
||||||
|
// Also initialize on page load
|
||||||
|
if (document.readyState === 'complete' || document.readyState === 'interactive') {
|
||||||
|
setTimeout(initializeReferenceToggle, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also initialize when content is loaded via HTMX
|
||||||
|
document.addEventListener('htmx:afterSwap', function(event) {
|
||||||
|
console.log('HTMX content swapped, target:', event.detail.target.id);
|
||||||
|
setTimeout(initializeReferenceToggle, 1);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</form>
|
||||||
|
} else {
|
||||||
|
<div class="flex p-4 mb-4 text-sm text-gray-800 border border-gray-300 rounded-lg bg-gray-50 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-600" role="alert">
|
||||||
|
<i class="fas fa-info-circle flex-shrink-0 inline w-5 h-5 mr-3"></i>
|
||||||
|
<span class="sr-only">Info</span>
|
||||||
|
<div>
|
||||||
|
Upload a config file to preview rclone remotes for import.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user