mirror of
https://github.com/StarFleetCPTN/GoMFT.git
synced 2026-09-20 13:30:51 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ab3403bca | ||
|
|
9a5ad3ec26 | ||
|
|
8f06776b53 | ||
|
|
248f9cce51 | ||
|
|
9316eb0a53 | ||
|
|
a01edc13cb | ||
|
|
2b2c305ccb | ||
|
|
6f1e877341 | ||
|
|
a6828f4c0e | ||
|
|
6e19df099c | ||
|
|
6503c19898 | ||
|
|
81557840dd | ||
|
|
2017cd8b7d | ||
|
|
f4c8c2121c | ||
|
|
2fbbb41f3f | ||
|
|
30ded57293 | ||
|
|
29b86f16b1 | ||
|
|
c41ca6aca3 | ||
|
|
4d80d104c0 | ||
|
|
69cab16382 |
@@ -0,0 +1,38 @@
|
|||||||
|
root = "."
|
||||||
|
testdata_dir = "testdata"
|
||||||
|
tmp_dir = "tmp"
|
||||||
|
|
||||||
|
[build]
|
||||||
|
args_bin = []
|
||||||
|
bin = "./tmp/main"
|
||||||
|
cmd = "templ generate && go build -o ./tmp/main ."
|
||||||
|
delay = 1000
|
||||||
|
exclude_dir = ["assets", "tmp", "vendor", "testdata", "node_modules"]
|
||||||
|
exclude_file = []
|
||||||
|
exclude_regex = ["_test.go", "_templ.go"]
|
||||||
|
exclude_unchanged = false
|
||||||
|
follow_symlink = false
|
||||||
|
full_bin = ""
|
||||||
|
include_dir = []
|
||||||
|
include_ext = ["go", "tpl", "tmpl", "templ", "html"]
|
||||||
|
kill_delay = "0s"
|
||||||
|
log = "build-errors.log"
|
||||||
|
send_interrupt = false
|
||||||
|
stop_on_error = true
|
||||||
|
|
||||||
|
[color]
|
||||||
|
app = ""
|
||||||
|
build = "yellow"
|
||||||
|
main = "magenta"
|
||||||
|
runner = "green"
|
||||||
|
watcher = "cyan"
|
||||||
|
|
||||||
|
[log]
|
||||||
|
time = false
|
||||||
|
|
||||||
|
[misc]
|
||||||
|
clean_on_exit = false
|
||||||
|
|
||||||
|
[screen]
|
||||||
|
clear_on_rebuild = false
|
||||||
|
keep_scroll = true
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
name: Build and Publish Docker Image DockHub
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*.*.*'
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
manual_version:
|
||||||
|
description: 'Manual version override (leave empty to use git tag)'
|
||||||
|
required: false
|
||||||
|
default: ''
|
||||||
|
|
||||||
|
env:
|
||||||
|
# Use github.repository as the default image name
|
||||||
|
IMAGE_NAME: ${{ github.repository }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-push:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
# Set the permissions needed for the DockerHub token to push to GHCR
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
# Set up Docker Buildx for efficient builds
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
# Login to DockerHub Container Registry
|
||||||
|
- name: Log in to DockerHub Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
|
|
||||||
|
# Extract metadata for Docker image
|
||||||
|
- name: Extract Docker metadata
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: starfleetcptn/gomft
|
||||||
|
tags: |
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
|
type=ref,event=branch
|
||||||
|
type=ref,event=pr
|
||||||
|
type=sha,format=long
|
||||||
|
type=raw,value=latest,enable={{is_default_branch}}
|
||||||
|
|
||||||
|
# Build and push Docker image
|
||||||
|
- name: Build and push
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: ${{ github.event_name != 'pull_request' }}
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
+12
-1
@@ -49,4 +49,15 @@ components/*.go
|
|||||||
data/
|
data/
|
||||||
|
|
||||||
# Ignore the tmp directory
|
# Ignore the tmp directory
|
||||||
tmp/
|
tmp/
|
||||||
|
|
||||||
|
# Ignore the configs directory
|
||||||
|
configs/
|
||||||
|
|
||||||
|
# Ignore Dirs
|
||||||
|
source/
|
||||||
|
destination/
|
||||||
|
archive/
|
||||||
|
|
||||||
|
# Ignore binaries
|
||||||
|
gomft
|
||||||
@@ -19,8 +19,8 @@ GoMFT is a web-based managed file transfer application built with Go, leveraging
|
|||||||

|

|
||||||
*Scheduling transfers with flexible cron expressions*
|
*Scheduling transfers with flexible cron expressions*
|
||||||
|
|
||||||
### User Mangement
|
### User Management
|
||||||

|

|
||||||
*Create user accounts and manage them*
|
*Create user accounts and manage them*
|
||||||
|
|
||||||
|
|
||||||
@@ -49,6 +49,8 @@ GoMFT is a web-based managed file transfer application built with Go, leveraging
|
|||||||
- **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
|
||||||
|
- **Docker Support**: Easy deployment with Docker images and Docker Compose support
|
||||||
|
- **Portable Deployment**: Run on any platform that supports Docker or Go
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
@@ -58,6 +60,8 @@ GoMFT is a web-based managed file transfer application built with Go, leveraging
|
|||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
|
### Standard Installation
|
||||||
|
|
||||||
1. Clone the repository:
|
1. Clone the repository:
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/starfleetcptn/gomft.git
|
git clone https://github.com/starfleetcptn/gomft.git
|
||||||
@@ -74,6 +78,54 @@ go mod download
|
|||||||
go build -o gomft
|
go build -o gomft
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Docker Installation
|
||||||
|
|
||||||
|
GoMFT is available as a Docker image for quick and easy deployment.
|
||||||
|
|
||||||
|
1. Pull the latest image from Docker Hub:
|
||||||
|
```bash
|
||||||
|
docker pull starfleetcptn/gomft:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Run the container:
|
||||||
|
```bash
|
||||||
|
docker run -d \
|
||||||
|
--name gomft \
|
||||||
|
-p 8080:8080 \
|
||||||
|
-v /path/to/data:/app/data \
|
||||||
|
starfleetcptn/gomft:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Access the web interface at `http://localhost:8080`
|
||||||
|
|
||||||
|
#### Docker Compose Example
|
||||||
|
|
||||||
|
For production deployments, you can use Docker Compose:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
version: '3'
|
||||||
|
services:
|
||||||
|
gomft:
|
||||||
|
image: starfleetcptn/gomft:latest
|
||||||
|
container_name: gomft
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
volumes:
|
||||||
|
- ./data:/app/data
|
||||||
|
- ./backups:/app/data/gomft/backups
|
||||||
|
environment:
|
||||||
|
- TZ=UTC
|
||||||
|
```
|
||||||
|
|
||||||
|
Save this as `docker-compose.yml` and run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
For more information and available tags, visit the [GoMFT Docker Hub page](https://hub.docker.com/r/starfleetcptn/gomft).
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
GoMFT uses a configuration file located at `./data/gomft/config.json`. On first run, a default configuration will be created:
|
GoMFT uses a configuration file located at `./data/gomft/config.json`. On first run, a default configuration will be created:
|
||||||
@@ -244,6 +296,7 @@ To configure email functionality:
|
|||||||
- **Authentication**: JWT (JSON Web Tokens)
|
- **Authentication**: JWT (JSON Web Tokens)
|
||||||
- **Database**: GORM with SQLite
|
- **Database**: GORM with SQLite
|
||||||
- **File Transfer**: rclone
|
- **File Transfer**: rclone
|
||||||
|
- **Deployment**: Docker containerization and traditional installation
|
||||||
|
|
||||||
### Building from Source
|
### Building from Source
|
||||||
|
|
||||||
@@ -273,4 +326,4 @@ air
|
|||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
MIT License - see LICENSE file for details
|
MIT License - see LICENSE file for details
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ func getInitialData(config *db.TransferConfig) string {
|
|||||||
destPassiveMode := true
|
destPassiveMode := true
|
||||||
archivePath := ""
|
archivePath := ""
|
||||||
archiveEnabled := false
|
archiveEnabled := false
|
||||||
|
deleteAfterTransfer := false
|
||||||
rcloneFlags := ""
|
rcloneFlags := ""
|
||||||
|
|
||||||
if config != nil {
|
if config != nil {
|
||||||
@@ -104,6 +105,7 @@ func getInitialData(config *db.TransferConfig) string {
|
|||||||
destPassiveMode = config.DestPassiveMode
|
destPassiveMode = config.DestPassiveMode
|
||||||
archivePath = config.ArchivePath
|
archivePath = config.ArchivePath
|
||||||
archiveEnabled = config.ArchiveEnabled
|
archiveEnabled = config.ArchiveEnabled
|
||||||
|
deleteAfterTransfer = config.DeleteAfterTransfer
|
||||||
rcloneFlags = config.RcloneFlags
|
rcloneFlags = config.RcloneFlags
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,6 +149,7 @@ func getInitialData(config *db.TransferConfig) string {
|
|||||||
// Existing fields
|
// Existing fields
|
||||||
archivePath: '%s',
|
archivePath: '%s',
|
||||||
archiveEnabled: %v,
|
archiveEnabled: %v,
|
||||||
|
deleteAfterTransfer: %v,
|
||||||
rcloneFlags: '%s',
|
rcloneFlags: '%s',
|
||||||
loading: false,
|
loading: false,
|
||||||
validate() {
|
validate() {
|
||||||
@@ -209,7 +212,7 @@ func getInitialData(config *db.TransferConfig) string {
|
|||||||
filePattern, outputPattern, destinationType, destinationPath, destHost, destPort, destUser, destPassword, destKeyFile,
|
filePattern, outputPattern, destinationType, destinationPath, destHost, destPort, destUser, destPassword, destKeyFile,
|
||||||
destBucket, destRegion, destAccessKey, destSecretKey, destEndpoint,
|
destBucket, destRegion, destAccessKey, destSecretKey, destEndpoint,
|
||||||
destShare, destDomain, destPassiveMode,
|
destShare, destDomain, destPassiveMode,
|
||||||
archivePath, archiveEnabled, rcloneFlags)
|
archivePath, archiveEnabled, deleteAfterTransfer, rcloneFlags)
|
||||||
}
|
}
|
||||||
|
|
||||||
templ ConfigForm(ctx context.Context, data ConfigFormData) {
|
templ ConfigForm(ctx context.Context, data ConfigFormData) {
|
||||||
@@ -1303,6 +1306,25 @@ templ ConfigForm(ctx context.Context, data ConfigFormData) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="sm:col-span-6 mt-4">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="delete_after_transfer"
|
||||||
|
x-model="deleteAfterTransfer"
|
||||||
|
class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-secondary-300 dark:border-secondary-700 rounded"/>
|
||||||
|
<input type="hidden" name="delete_after_transfer" :value="deleteAfterTransfer.toString()"/>
|
||||||
|
<label for="delete_after_transfer" class="ml-2 block text-sm text-secondary-700 dark:text-secondary-300">
|
||||||
|
Delete files from source after transfer
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-secondary-500 dark:text-secondary-400">
|
||||||
|
<i class="fas fa-exclamation-triangle text-yellow-500 mr-1"></i>
|
||||||
|
Original files will be permanently deleted from source after successful transfer
|
||||||
|
<span x-show="archiveEnabled">and archiving</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="sm:col-span-6">
|
<div class="sm:col-span-6">
|
||||||
<label for="rclone_flags" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Rclone Flags</label>
|
<label for="rclone_flags" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Rclone Flags</label>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
|
|||||||
+205
-4
@@ -6,12 +6,204 @@ import (
|
|||||||
"github.com/starfleetcptn/gomft/internal/db"
|
"github.com/starfleetcptn/gomft/internal/db"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Dialog component for confirmation dialogs
|
||||||
|
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 class="bg-white dark:bg-secondary-800 rounded-lg shadow-xl max-w-md w-full mx-4 overflow-hidden">
|
||||||
|
<div class="px-6 pt-5 pb-3 text-center">
|
||||||
|
<div class="flex justify-center mb-2">
|
||||||
|
<i class="fas fa-exclamation-triangle text-yellow-400 text-3xl"></i>
|
||||||
|
</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>
|
||||||
|
}
|
||||||
|
|
||||||
|
script hideConfigDialog(id string) {
|
||||||
|
document.getElementById(id).classList.add("hidden");
|
||||||
|
}
|
||||||
|
|
||||||
|
script showConfigDialog(id string) {
|
||||||
|
document.getElementById(id).classList.remove("hidden");
|
||||||
|
}
|
||||||
|
|
||||||
|
script triggerConfigDelete(dialogId string, configID uint, configName string) {
|
||||||
|
// Hide the dialog
|
||||||
|
document.getElementById(dialogId).classList.add("hidden");
|
||||||
|
|
||||||
|
// Store data in a way that's accessible to event handlers
|
||||||
|
window.lastDeletedConfig = {
|
||||||
|
id: configID,
|
||||||
|
name: configName
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add custom marker to track this deletion
|
||||||
|
window.currentlyDeletingConfig = true;
|
||||||
|
}
|
||||||
|
|
||||||
type ConfigsData struct {
|
type ConfigsData struct {
|
||||||
Configs []db.TransferConfig
|
Configs []db.TransferConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
templ Configs(ctx context.Context, data ConfigsData) {
|
templ Configs(ctx context.Context, data ConfigsData) {
|
||||||
@LayoutWithContext("Transfer Configurations", ctx) {
|
@LayoutWithContext("Transfer Configurations", ctx) {
|
||||||
|
<script>
|
||||||
|
// Debug notification system
|
||||||
|
console.log("Configs template loaded, setting up notification system");
|
||||||
|
|
||||||
|
// Create a global notyf instance if it doesn't exist yet
|
||||||
|
if (!window.notyf) {
|
||||||
|
window.notyf = new Notyf({
|
||||||
|
duration: 3000,
|
||||||
|
position: {
|
||||||
|
x: 'right',
|
||||||
|
y: 'top',
|
||||||
|
},
|
||||||
|
types: [
|
||||||
|
{
|
||||||
|
type: 'success',
|
||||||
|
background: '#38c172',
|
||||||
|
icon: {
|
||||||
|
className: 'fas fa-check-circle',
|
||||||
|
tagName: 'i'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'error',
|
||||||
|
background: '#e3342f',
|
||||||
|
icon: {
|
||||||
|
className: 'fas fa-exclamation-circle',
|
||||||
|
tagName: 'i'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
console.log("Notyf initialized:", window.notyf);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track all HTMX events for debugging
|
||||||
|
document.addEventListener('htmx:beforeRequest', function(event) {
|
||||||
|
|
||||||
|
// Check if this is a DELETE request by examining the URL and method
|
||||||
|
const path = event.detail.path;
|
||||||
|
const method = event.detail.verb;
|
||||||
|
|
||||||
|
// Pattern match for config deletions (e.g., /configs/123)
|
||||||
|
if (path && method === 'DELETE' && path.match(/^\/configs\/\d+$/)) {
|
||||||
|
|
||||||
|
// This is definitely a delete request - store this information
|
||||||
|
window.isConfigDeleteRequest = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Track HTMX after-request events for config deletion
|
||||||
|
document.addEventListener('htmx:afterRequest', function(event) {
|
||||||
|
|
||||||
|
// Check for config deletion multiple ways
|
||||||
|
const isDeleteRequest =
|
||||||
|
// Check global flag from the triggerConfigDelete function
|
||||||
|
window.currentlyDeletingConfig ||
|
||||||
|
// Check flag from beforeRequest handler
|
||||||
|
window.isConfigDeleteRequest ||
|
||||||
|
// Check URL pattern directly from this event
|
||||||
|
(event.detail.pathInfo && event.detail.pathInfo.requestPath &&
|
||||||
|
event.detail.pathInfo.requestPath.match(/^\/configs\/\d+$/) &&
|
||||||
|
event.detail.verb === 'DELETE');
|
||||||
|
|
||||||
|
|
||||||
|
// If this is a successful delete request, show notification
|
||||||
|
if (isDeleteRequest && event.detail.successful) {
|
||||||
|
|
||||||
|
let configName = "Unknown";
|
||||||
|
|
||||||
|
// Try multiple sources for config name
|
||||||
|
if (event.detail.elt && event.detail.elt.getAttribute) {
|
||||||
|
configName = event.detail.elt.getAttribute('data-config-name') || configName;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (configName === "Unknown" && window.lastDeletedConfig) {
|
||||||
|
// Fallback to our stored config info
|
||||||
|
configName = window.lastDeletedConfig.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.notyf.success(`Configuration "${configName}" deleted successfully`);
|
||||||
|
|
||||||
|
// Clear flags
|
||||||
|
window.currentlyDeletingConfig = false;
|
||||||
|
window.isConfigDeleteRequest = false;
|
||||||
|
window.lastDeletedConfig = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Track HTMX error events for config deletion
|
||||||
|
document.addEventListener('htmx:responseError', function(event) {
|
||||||
|
|
||||||
|
// Similar logic as success but for errors
|
||||||
|
const isDeleteRequest =
|
||||||
|
window.currentlyDeletingConfig ||
|
||||||
|
window.isConfigDeleteRequest ||
|
||||||
|
(event.detail.pathInfo && event.detail.pathInfo.requestPath &&
|
||||||
|
event.detail.pathInfo.requestPath.match(/^\/configs\/\d+$/) &&
|
||||||
|
event.detail.verb === 'DELETE');
|
||||||
|
|
||||||
|
if (isDeleteRequest) {
|
||||||
|
|
||||||
|
let configName = "Unknown";
|
||||||
|
|
||||||
|
// Try multiple sources for config name
|
||||||
|
if (event.detail.elt && event.detail.elt.getAttribute) {
|
||||||
|
configName = event.detail.elt.getAttribute('data-config-name') || configName;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (configName === "Unknown" && window.lastDeletedConfig) {
|
||||||
|
// Fallback to our stored config info
|
||||||
|
configName = window.lastDeletedConfig.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
let errorMsg = `Failed to delete configuration "${configName}"`;
|
||||||
|
|
||||||
|
if (event.detail.xhr && event.detail.xhr.responseText) {
|
||||||
|
errorMsg = event.detail.xhr.responseText
|
||||||
|
// error message is a json object
|
||||||
|
const error = JSON.parse(errorMsg);
|
||||||
|
errorMsg = `Error: ${error.error}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.notyf.error(errorMsg);
|
||||||
|
|
||||||
|
// Clear flags
|
||||||
|
window.currentlyDeletingConfig = false;
|
||||||
|
window.isConfigDeleteRequest = false;
|
||||||
|
window.lastDeletedConfig = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
<div class="py-6">
|
<div class="py-6">
|
||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<div class="flex justify-between items-center mb-8">
|
<div class="flex justify-between items-center mb-8">
|
||||||
@@ -58,11 +250,20 @@ templ Configs(ctx context.Context, data ConfigsData) {
|
|||||||
<i class="fas fa-edit mr-1"></i>
|
<i class="fas fa-edit mr-1"></i>
|
||||||
Edit
|
Edit
|
||||||
</a>
|
</a>
|
||||||
|
<!-- Add delete dialog for each configuration -->
|
||||||
|
@ConfigDialog(
|
||||||
|
fmt.Sprintf("delete-config-dialog-%d", config.ID),
|
||||||
|
"Delete Configuration",
|
||||||
|
fmt.Sprintf("Are you sure you want to delete the configuration '%s'? This cannot be undone.", config.Name),
|
||||||
|
"btn-danger",
|
||||||
|
"Delete",
|
||||||
|
"delete",
|
||||||
|
config.ID,
|
||||||
|
config.Name,
|
||||||
|
)
|
||||||
<button
|
<button
|
||||||
hx-delete={ fmt.Sprintf("/configs/%d", config.ID) }
|
type="button"
|
||||||
hx-confirm="Are you sure you want to delete this configuration?"
|
onclick={ showConfigDialog(fmt.Sprintf("delete-config-dialog-%d", config.ID)) }
|
||||||
hx-target="closest li"
|
|
||||||
hx-swap="outerHTML"
|
|
||||||
class="btn-danger btn-sm">
|
class="btn-danger btn-sm">
|
||||||
<i class="fas fa-trash-alt mr-1"></i>
|
<i class="fas fa-trash-alt mr-1"></i>
|
||||||
Delete
|
Delete
|
||||||
|
|||||||
+262
-4
@@ -6,12 +6,243 @@ import (
|
|||||||
"github.com/starfleetcptn/gomft/internal/db"
|
"github.com/starfleetcptn/gomft/internal/db"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Dialog component for confirmation dialogs - copied from admin_tools.templ
|
||||||
|
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 class="bg-white dark:bg-secondary-800 rounded-lg shadow-xl max-w-md w-full mx-4 overflow-hidden">
|
||||||
|
<div class="px-6 pt-5 pb-3 text-center">
|
||||||
|
<div class="flex justify-center mb-2">
|
||||||
|
<i class="fas fa-exclamation-triangle text-yellow-400 text-3xl"></i>
|
||||||
|
</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>
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
// Hide the dialog
|
||||||
|
document.getElementById(dialogId).classList.add("hidden");
|
||||||
|
|
||||||
|
// Add debugging info
|
||||||
|
console.log(`Job deletion triggered for: ${jobName} (ID: ${jobID})`);
|
||||||
|
|
||||||
|
// Store data in a way that's accessible to event handlers
|
||||||
|
window.lastDeletedJob = {
|
||||||
|
id: jobID,
|
||||||
|
name: jobName
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add custom marker to track this deletion
|
||||||
|
window.currentlyDeletingJob = true;
|
||||||
|
}
|
||||||
|
|
||||||
type JobsData struct {
|
type JobsData struct {
|
||||||
Jobs []db.Job
|
Jobs []db.Job
|
||||||
}
|
}
|
||||||
|
|
||||||
templ Jobs(ctx context.Context, data JobsData) {
|
templ Jobs(ctx context.Context, data JobsData) {
|
||||||
@LayoutWithContext("Transfer Jobs", ctx) {
|
@LayoutWithContext("Transfer Jobs", ctx) {
|
||||||
|
<script>
|
||||||
|
// Debug notification system
|
||||||
|
console.log("Jobs template loaded, setting up notification system");
|
||||||
|
|
||||||
|
// Create a global notyf instance immediately
|
||||||
|
window.notyf = new Notyf({
|
||||||
|
duration: 3000,
|
||||||
|
position: {
|
||||||
|
x: 'right',
|
||||||
|
y: 'top',
|
||||||
|
},
|
||||||
|
types: [
|
||||||
|
{
|
||||||
|
type: 'success',
|
||||||
|
background: '#38c172',
|
||||||
|
icon: {
|
||||||
|
className: 'fas fa-check-circle',
|
||||||
|
tagName: 'i'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'error',
|
||||||
|
background: '#e3342f',
|
||||||
|
icon: {
|
||||||
|
className: 'fas fa-exclamation-circle',
|
||||||
|
tagName: 'i'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
console.log("Notyf initialized:", window.notyf);
|
||||||
|
|
||||||
|
// Global function to handle job running
|
||||||
|
window.runJob = function(button) {
|
||||||
|
// Get job data from button attributes
|
||||||
|
const jobId = button.getAttribute('data-job-id');
|
||||||
|
const jobName = button.getAttribute('data-job-name') || `Job #${jobId}`;
|
||||||
|
|
||||||
|
console.log(`Run job clicked for: ${jobName} (ID: ${jobId})`);
|
||||||
|
|
||||||
|
// No loading notification - just listen for the response
|
||||||
|
button.addEventListener('htmx:afterRequest', function(event) {
|
||||||
|
console.log("Run job request completed:", event.detail);
|
||||||
|
if (event.detail.successful) {
|
||||||
|
let displayName = jobName;
|
||||||
|
// Try to extract job name from response headers
|
||||||
|
if (event.detail.headers && event.detail.headers['HX-Job-Name']) {
|
||||||
|
displayName = event.detail.headers['HX-Job-Name'];
|
||||||
|
}
|
||||||
|
console.log(`Showing success notification for job: ${displayName}`);
|
||||||
|
window.notyf.success(`Job "${displayName}" started successfully!`);
|
||||||
|
} else {
|
||||||
|
let errorMsg = `Failed to start job "${jobName}"`;
|
||||||
|
if (event.detail.xhr && event.detail.xhr.responseText) {
|
||||||
|
errorMsg = `Error: ${event.detail.xhr.responseText}`;
|
||||||
|
}
|
||||||
|
console.log(`Showing error notification: ${errorMsg}`);
|
||||||
|
window.notyf.error(errorMsg);
|
||||||
|
}
|
||||||
|
}, { once: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Track all HTMX events for debugging
|
||||||
|
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
|
||||||
|
const path = event.detail.path;
|
||||||
|
const method = event.detail.verb;
|
||||||
|
|
||||||
|
console.log(`Request path: ${path}, method: ${method}`);
|
||||||
|
|
||||||
|
// Pattern match for job deletions (e.g., /jobs/123)
|
||||||
|
if (path && method === 'DELETE' && path.match(/^\/jobs\/\d+$/)) {
|
||||||
|
console.log("Detected job deletion request via URL pattern");
|
||||||
|
|
||||||
|
// This is definitely a delete request - store this information
|
||||||
|
window.isJobDeleteRequest = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Track HTMX after-request events for job deletion
|
||||||
|
document.addEventListener('htmx:afterRequest', function(event) {
|
||||||
|
console.log("HTMX after request:", event.detail);
|
||||||
|
|
||||||
|
// Check for job deletion multiple ways
|
||||||
|
const isDeleteRequest =
|
||||||
|
// Check global flag from the triggerJobDelete function
|
||||||
|
window.currentlyDeletingJob ||
|
||||||
|
// Check flag from beforeRequest handler
|
||||||
|
window.isJobDeleteRequest ||
|
||||||
|
// Check URL pattern directly from this event
|
||||||
|
(event.detail.pathInfo && event.detail.pathInfo.requestPath &&
|
||||||
|
event.detail.pathInfo.requestPath.match(/^\/jobs\/\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 jobName = "Unknown";
|
||||||
|
|
||||||
|
// Try multiple sources for job name
|
||||||
|
if (event.detail.elt && event.detail.elt.getAttribute) {
|
||||||
|
jobName = event.detail.elt.getAttribute('data-job-name') || jobName;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (jobName === "Unknown" && window.lastDeletedJob) {
|
||||||
|
// Fallback to our stored job info
|
||||||
|
jobName = window.lastDeletedJob.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Showing success notification for deleted job: ${jobName}`);
|
||||||
|
window.notyf.success(`Job "${jobName}" deleted successfully`);
|
||||||
|
|
||||||
|
// Clear flags
|
||||||
|
window.currentlyDeletingJob = false;
|
||||||
|
window.isJobDeleteRequest = false;
|
||||||
|
window.lastDeletedJob = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Track HTMX error events for job deletion
|
||||||
|
document.addEventListener('htmx:responseError', function(event) {
|
||||||
|
console.log("HTMX response error:", event.detail);
|
||||||
|
|
||||||
|
// Similar logic as success but for errors
|
||||||
|
const isDeleteRequest =
|
||||||
|
window.currentlyDeletingJob ||
|
||||||
|
window.isJobDeleteRequest ||
|
||||||
|
(event.detail.pathInfo && event.detail.pathInfo.requestPath &&
|
||||||
|
event.detail.pathInfo.requestPath.match(/^\/jobs\/\d+$/) &&
|
||||||
|
event.detail.verb === 'DELETE');
|
||||||
|
|
||||||
|
if (isDeleteRequest) {
|
||||||
|
console.log("Delete request failed");
|
||||||
|
|
||||||
|
let jobName = "Unknown";
|
||||||
|
|
||||||
|
// Try multiple sources for job name
|
||||||
|
if (event.detail.elt && event.detail.elt.getAttribute) {
|
||||||
|
jobName = event.detail.elt.getAttribute('data-job-name') || jobName;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (jobName === "Unknown" && window.lastDeletedJob) {
|
||||||
|
// Fallback to our stored job info
|
||||||
|
jobName = window.lastDeletedJob.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
let errorMsg = `Failed to delete job "${jobName}"`;
|
||||||
|
|
||||||
|
if (event.detail.xhr && event.detail.xhr.responseText) {
|
||||||
|
errorMsg = `Error: ${event.detail.xhr.responseText}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Showing error notification: ${errorMsg}`);
|
||||||
|
window.notyf.error(errorMsg);
|
||||||
|
|
||||||
|
// Clear flags
|
||||||
|
window.currentlyDeletingJob = false;
|
||||||
|
window.isJobDeleteRequest = false;
|
||||||
|
window.lastDeletedJob = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
<div class="py-6">
|
<div class="py-6">
|
||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<div class="flex justify-between items-center mb-8">
|
<div class="flex justify-between items-center mb-8">
|
||||||
@@ -68,15 +299,34 @@ templ Jobs(ctx context.Context, data JobsData) {
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="ml-2 flex-shrink-0 flex space-x-2">
|
<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">
|
<a href={ templ.SafeURL(fmt.Sprintf("/jobs/%d", job.ID)) } class="btn-secondary btn-sm">
|
||||||
<i class="fas fa-edit mr-1"></i>
|
<i class="fas fa-edit mr-1"></i>
|
||||||
Edit
|
Edit
|
||||||
</a>
|
</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
|
<button
|
||||||
hx-delete={ fmt.Sprintf("/jobs/%d", job.ID) }
|
type="button"
|
||||||
hx-confirm="Are you sure you want to delete this job?"
|
onclick={ showJobDialog(fmt.Sprintf("delete-job-dialog-%d", job.ID)) }
|
||||||
hx-target="closest li"
|
|
||||||
hx-swap="outerHTML"
|
|
||||||
class="btn-danger btn-sm">
|
class="btn-danger btn-sm">
|
||||||
<i class="fas fa-trash-alt mr-1"></i>
|
<i class="fas fa-trash-alt mr-1"></i>
|
||||||
Delete
|
Delete
|
||||||
@@ -128,4 +378,12 @@ templ Jobs(ctx context.Context, data JobsData) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to determine the job name (reuse this logic to keep it consistent)
|
||||||
|
func determineJobName(job db.Job) string {
|
||||||
|
if job.Name != "" {
|
||||||
|
return job.Name
|
||||||
|
}
|
||||||
|
return job.Config.Name
|
||||||
}
|
}
|
||||||
+134
-2
@@ -32,7 +32,10 @@ templ LayoutWithContext(title string, ctx context.Context) {
|
|||||||
<html lang="en" class="light h-full">
|
<html lang="en" class="light h-full">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8"/>
|
<meta charset="UTF-8"/>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover"/>
|
||||||
|
<meta name="theme-color" content="#2563eb"/>
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes"/>
|
||||||
|
<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">
|
||||||
@@ -44,6 +47,42 @@ templ LayoutWithContext(title string, ctx context.Context) {
|
|||||||
<script src="/static/js/app.js"></script>
|
<script src="/static/js/app.js"></script>
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" />
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" />
|
||||||
<link rel="stylesheet" href="/static/css/app.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 = {
|
tailwind.config = {
|
||||||
darkMode: 'class',
|
darkMode: 'class',
|
||||||
@@ -283,6 +322,18 @@ templ LayoutWithContext(title string, ctx context.Context) {
|
|||||||
<i class="fas fa-moon block dark:hidden"></i>
|
<i class="fas fa-moon block dark:hidden"></i>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<!-- 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>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- User Menu -->
|
<!-- User Menu -->
|
||||||
<div x-data="{ open: false }" class="relative">
|
<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">
|
<button @click="open = !open" class="flex text-sm rounded-full focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500">
|
||||||
@@ -315,8 +366,56 @@ templ LayoutWithContext(title string, ctx context.Context) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
<!-- Mobile Navigation Menu -->
|
||||||
|
<div
|
||||||
|
x-data="{ open: false }"
|
||||||
|
x-on:toggle-mobile-menu.window="open = !open"
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<div class="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>
|
||||||
|
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 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">
|
||||||
|
<i class="fas fa-tools mr-2"></i> Admin Tools
|
||||||
|
</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>
|
||||||
|
</form>
|
||||||
|
</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">
|
<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">
|
||||||
|
<!-- Hidden notification area for HTMX targets -->
|
||||||
|
<div id="notification-area" class="hidden" hx-swap-oob="true"></div>
|
||||||
|
|
||||||
{ children... }
|
{ children... }
|
||||||
</main>
|
</main>
|
||||||
<footer class="bg-white dark:bg-secondary-800 shadow-inner mt-auto w-full">
|
<footer class="bg-white dark:bg-secondary-800 shadow-inner mt-auto w-full">
|
||||||
@@ -326,6 +425,39 @@ templ LayoutWithContext(title string, ctx context.Context) {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
if isLoggedIn(ctx) {
|
||||||
|
<!-- Mobile Bottom Navigation -->
|
||||||
|
<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 mobile-nav-container z-10">
|
||||||
|
<div class="grid grid-cols-5 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>
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,10 +5,11 @@ go 1.24.0
|
|||||||
require (
|
require (
|
||||||
github.com/a-h/templ v0.3.833
|
github.com/a-h/templ v0.3.833
|
||||||
github.com/gin-gonic/gin v1.10.0
|
github.com/gin-gonic/gin v1.10.0
|
||||||
|
github.com/glebarez/sqlite v1.11.0
|
||||||
|
github.com/go-gormigrate/gormigrate/v2 v2.1.3
|
||||||
github.com/golang-jwt/jwt/v5 v5.2.1
|
github.com/golang-jwt/jwt/v5 v5.2.1
|
||||||
github.com/robfig/cron/v3 v3.0.1
|
github.com/robfig/cron/v3 v3.0.1
|
||||||
golang.org/x/crypto v0.35.0
|
golang.org/x/crypto v0.35.0
|
||||||
gorm.io/driver/sqlite v1.5.7
|
|
||||||
gorm.io/gorm v1.25.12
|
gorm.io/gorm v1.25.12
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -16,22 +17,25 @@ require (
|
|||||||
github.com/bytedance/sonic v1.12.9 // indirect
|
github.com/bytedance/sonic v1.12.9 // indirect
|
||||||
github.com/bytedance/sonic/loader v0.2.3 // indirect
|
github.com/bytedance/sonic/loader v0.2.3 // indirect
|
||||||
github.com/cloudwego/base64x v0.1.5 // indirect
|
github.com/cloudwego/base64x v0.1.5 // indirect
|
||||||
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
|
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
|
||||||
github.com/gin-contrib/sse v1.0.0 // indirect
|
github.com/gin-contrib/sse v1.0.0 // indirect
|
||||||
|
github.com/glebarez/go-sqlite v1.21.2 // indirect
|
||||||
github.com/go-playground/locales v0.14.1 // indirect
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
github.com/go-playground/validator/v10 v10.25.0 // indirect
|
github.com/go-playground/validator/v10 v10.25.0 // indirect
|
||||||
github.com/goccy/go-json v0.10.5 // indirect
|
github.com/goccy/go-json v0.10.5 // indirect
|
||||||
|
github.com/google/uuid v1.3.0 // indirect
|
||||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||||
github.com/jinzhu/now v1.1.5 // indirect
|
github.com/jinzhu/now v1.1.5 // indirect
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
|
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
|
||||||
github.com/leodido/go-urn v1.4.0 // indirect
|
github.com/leodido/go-urn v1.4.0 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/mattn/go-sqlite3 v1.14.24 // indirect
|
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
|
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||||
golang.org/x/arch v0.14.0 // indirect
|
golang.org/x/arch v0.14.0 // indirect
|
||||||
@@ -40,4 +44,8 @@ require (
|
|||||||
golang.org/x/text v0.22.0 // indirect
|
golang.org/x/text v0.22.0 // indirect
|
||||||
google.golang.org/protobuf v1.36.5 // indirect
|
google.golang.org/protobuf v1.36.5 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
|
modernc.org/libc v1.22.5 // indirect
|
||||||
|
modernc.org/mathutil v1.5.0 // indirect
|
||||||
|
modernc.org/memory v1.5.0 // indirect
|
||||||
|
modernc.org/sqlite v1.23.1 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -11,12 +11,20 @@ github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQ
|
|||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
|
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
|
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
|
||||||
github.com/gin-contrib/sse v1.0.0 h1:y3bT1mUWUxDpW4JLQg/HnTqV4rozuW4tC9eFKTxYI9E=
|
github.com/gin-contrib/sse v1.0.0 h1:y3bT1mUWUxDpW4JLQg/HnTqV4rozuW4tC9eFKTxYI9E=
|
||||||
github.com/gin-contrib/sse v1.0.0/go.mod h1:zNuFdwarAygJBht0NTKiSi3jRf6RbqeILZ9Sp6Slhe0=
|
github.com/gin-contrib/sse v1.0.0/go.mod h1:zNuFdwarAygJBht0NTKiSi3jRf6RbqeILZ9Sp6Slhe0=
|
||||||
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
|
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
|
||||||
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||||
|
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
|
||||||
|
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
|
||||||
|
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
|
||||||
|
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
|
||||||
|
github.com/go-gormigrate/gormigrate/v2 v2.1.3 h1:ei3Vq/rpPI/jCJY9mRHJAKg5vU+EhZyWhBAkaAomQuw=
|
||||||
|
github.com/go-gormigrate/gormigrate/v2 v2.1.3/go.mod h1:VJ9FIOBAur+NmQ8c4tDVwOuiJcgupTG105FexPFrXzA=
|
||||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||||
@@ -32,6 +40,10 @@ github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVI
|
|||||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
|
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
|
||||||
|
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
|
||||||
|
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
|
||||||
|
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||||
@@ -46,8 +58,6 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
|||||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
|
|
||||||
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
|
||||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
@@ -57,6 +67,9 @@ github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNH
|
|||||||
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
|
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
@@ -93,8 +106,14 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8
|
|||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gorm.io/driver/sqlite v1.5.7 h1:8NvsrhP0ifM7LX9G4zPB97NwovUakUxc+2V2uuf3Z1I=
|
|
||||||
gorm.io/driver/sqlite v1.5.7/go.mod h1:U+J8craQU6Fzkcvu8oLeAQmi50TkwPEhHDEjQZXDah4=
|
|
||||||
gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
|
gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
|
||||||
gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
|
gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
|
||||||
|
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
|
||||||
|
modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
|
||||||
|
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
|
||||||
|
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
|
||||||
|
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
|
||||||
|
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
|
||||||
|
modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM=
|
||||||
|
modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=
|
||||||
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
||||||
|
|||||||
+3
-2
@@ -8,7 +8,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/starfleetcptn/gomft/internal/auth"
|
"github.com/starfleetcptn/gomft/internal/auth"
|
||||||
"gorm.io/driver/sqlite"
|
"github.com/glebarez/sqlite"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -91,6 +91,7 @@ type TransferConfig struct {
|
|||||||
ArchivePath string `form:"archive_path"`
|
ArchivePath string `form:"archive_path"`
|
||||||
ArchiveEnabled bool `gorm:"default:false" form:"archive_enabled"`
|
ArchiveEnabled bool `gorm:"default:false" form:"archive_enabled"`
|
||||||
RcloneFlags string `form:"rclone_flags"`
|
RcloneFlags string `form:"rclone_flags"`
|
||||||
|
DeleteAfterTransfer bool `gorm:"default:false" form:"delete_after_transfer"`
|
||||||
CreatedBy uint
|
CreatedBy uint
|
||||||
User User `gorm:"foreignkey:CreatedBy"`
|
User User `gorm:"foreignkey:CreatedBy"`
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
@@ -135,7 +136,7 @@ func Initialize(dbPath string) (*DB, error) {
|
|||||||
return nil, fmt.Errorf("failed to create database directory: %v", err)
|
return nil, fmt.Errorf("failed to create database directory: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Open database connection
|
// Open database connection with modernc.org/sqlite driver
|
||||||
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{})
|
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to connect to database: %v", err)
|
return nil, fmt.Errorf("failed to connect to database: %v", err)
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package migrations
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/go-gormigrate/gormigrate/v2"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AddDeleteAfterTransferColumn adds the delete_after_transfer column to transfer_configs table
|
||||||
|
func AddDeleteAfterTransferColumn() *gormigrate.Migration {
|
||||||
|
return &gormigrate.Migration{
|
||||||
|
ID: "add_delete_after_transfer_column",
|
||||||
|
Migrate: func(tx *gorm.DB) error {
|
||||||
|
return tx.Exec("ALTER TABLE transfer_configs ADD COLUMN delete_after_transfer BOOLEAN NOT NULL DEFAULT false").Error
|
||||||
|
},
|
||||||
|
Rollback: func(tx *gorm.DB) error {
|
||||||
|
return tx.Exec("ALTER TABLE transfer_configs DROP COLUMN delete_after_transfer").Error
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package migrations
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/go-gormigrate/gormigrate/v2"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// InitMigrations initializes the migrations
|
||||||
|
func InitMigrations() *gormigrate.Gormigrate {
|
||||||
|
migrations := []*gormigrate.Migration{
|
||||||
|
// ... existing migrations
|
||||||
|
AddDeleteAfterTransferColumn(),
|
||||||
|
}
|
||||||
|
|
||||||
|
return gormigrate.New(db, gormigrate.DefaultOptions, migrations)
|
||||||
|
}
|
||||||
@@ -161,7 +161,7 @@ func (s *Scheduler) executeJob(jobID uint) {
|
|||||||
"--config", configPath,
|
"--config", configPath,
|
||||||
"size",
|
"size",
|
||||||
"--include", job.Config.FilePattern,
|
"--include", job.Config.FilePattern,
|
||||||
job.Config.SourcePath,
|
fmt.Sprintf("source_%d:%s", job.Config.ID, job.Config.SourcePath),
|
||||||
}
|
}
|
||||||
// Get the rclone path from the environment variable or use the default path
|
// Get the rclone path from the environment variable or use the default path
|
||||||
rclonePath := os.Getenv("RCLONE_PATH")
|
rclonePath := os.Getenv("RCLONE_PATH")
|
||||||
@@ -169,9 +169,16 @@ func (s *Scheduler) executeJob(jobID uint) {
|
|||||||
rclonePath = "rclone"
|
rclonePath = "rclone"
|
||||||
}
|
}
|
||||||
output, err := exec.Command(rclonePath, sizeArgs...).CombinedOutput()
|
output, err := exec.Command(rclonePath, sizeArgs...).CombinedOutput()
|
||||||
fmt.Printf("Running rclone size: %v\nOutput: %s\n", sizeArgs, output)
|
fmt.Printf("Running rclone size: %s %s\nOutput: %s\n", rclonePath, strings.Join(sizeArgs, " "), output)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("Error running rclone size: %v\nOutput: %s\n", err, output)
|
fmt.Printf("Error running rclone size: %v\nOutput: %s\n", err, output)
|
||||||
|
// Update job history with error
|
||||||
|
history.Status = "failed"
|
||||||
|
history.ErrorMessage = fmt.Sprintf("Size calculation error: %v\nOutput: %s", err, string(output))
|
||||||
|
history.EndTime = &startTime // Use start time as end time for a quick failure
|
||||||
|
if err := s.db.UpdateJobHistory(history); err != nil {
|
||||||
|
fmt.Printf("Error updating job history for job %d: %v\n", jobID, err)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -232,7 +239,7 @@ func (s *Scheduler) executeJob(jobID uint) {
|
|||||||
// Prepare moveto command for transfer
|
// Prepare moveto command for transfer
|
||||||
transferArgs := []string{
|
transferArgs := []string{
|
||||||
"--config", configPath,
|
"--config", configPath,
|
||||||
"moveto",
|
"copyto",
|
||||||
"--progress",
|
"--progress",
|
||||||
"--stats-one-line",
|
"--stats-one-line",
|
||||||
"--verbose",
|
"--verbose",
|
||||||
@@ -317,6 +324,21 @@ func (s *Scheduler) executeJob(jobID uint) {
|
|||||||
fmt.Sprintf("Archive error for file %s: %v", file, archiveErr))
|
fmt.Sprintf("Archive error for file %s: %v", file, archiveErr))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if job.Config.DeleteAfterTransfer {
|
||||||
|
fmt.Printf("Deleting file %s for job %d\n", file, jobID)
|
||||||
|
deleteArgs := []string{
|
||||||
|
"--config", configPath,
|
||||||
|
"deletefile",
|
||||||
|
sourcePath, }
|
||||||
|
deleteCmd := exec.Command(rclonePath, deleteArgs...)
|
||||||
|
deleteOutput, deleteErr := deleteCmd.CombinedOutput()
|
||||||
|
fmt.Printf("Output for file %s: %s\n", file, string(deleteOutput))
|
||||||
|
if deleteErr != nil {
|
||||||
|
fmt.Printf("Error deleting file %s for job %d: %v\n", file, jobID, deleteErr)
|
||||||
|
transferErrors = append(transferErrors,
|
||||||
|
fmt.Sprintf("Delete error for file %s: %v", file, deleteErr))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -248,8 +248,48 @@ func (h *Handlers) HandleAPIDeleteJob(c *gin.Context) {
|
|||||||
|
|
||||||
// HandleAPIRunJob handles the API run job request
|
// HandleAPIRunJob handles the API run job request
|
||||||
func (h *Handlers) HandleAPIRunJob(c *gin.Context) {
|
func (h *Handlers) HandleAPIRunJob(c *gin.Context) {
|
||||||
// Implementation will be moved from the old handlers.go
|
id := c.Param("id")
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "API run job handler stub"})
|
userID := c.GetUint("userID")
|
||||||
|
|
||||||
|
var job db.Job
|
||||||
|
if err := h.DB.First(&job, id).Error; err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Job not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user owns this job
|
||||||
|
if job.CreatedBy != userID {
|
||||||
|
// Check if user is admin
|
||||||
|
isAdmin, exists := c.Get("isAdmin")
|
||||||
|
if !exists || isAdmin != true {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "You do not have permission to run this job"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine job name for response
|
||||||
|
jobName := job.Name
|
||||||
|
if jobName == "" {
|
||||||
|
// If job name is empty, try to get config name
|
||||||
|
var config db.TransferConfig
|
||||||
|
if err := h.DB.First(&config, job.ConfigID).Error; err == nil {
|
||||||
|
jobName = config.Name
|
||||||
|
} else {
|
||||||
|
jobName = fmt.Sprintf("Job #%d", job.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run the job immediately using the scheduler
|
||||||
|
if err := h.Scheduler.RunJobNow(job.ID); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to run job: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"message": "Job started successfully",
|
||||||
|
"jobId": job.ID,
|
||||||
|
"jobName": jobName,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleAPIHistory handles the API history request
|
// HandleAPIHistory handles the API history request
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -274,7 +275,8 @@ func (h *Handlers) HandleRunJob(c *gin.Context) {
|
|||||||
|
|
||||||
var job db.Job
|
var job db.Job
|
||||||
if err := h.DB.First(&job, id).Error; err != nil {
|
if err := h.DB.First(&job, id).Error; err != nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "Job not found"})
|
c.Header("Content-Type", "text/html")
|
||||||
|
c.String(http.StatusNotFound, "<script>window.notyfInstance.error('Job not found')</script>")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -283,19 +285,37 @@ func (h *Handlers) HandleRunJob(c *gin.Context) {
|
|||||||
// Check if user is admin
|
// Check if user is admin
|
||||||
isAdmin, exists := c.Get("isAdmin")
|
isAdmin, exists := c.Get("isAdmin")
|
||||||
if !exists || isAdmin != true {
|
if !exists || isAdmin != true {
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "You do not have permission to run this job"})
|
c.Header("Content-Type", "text/html")
|
||||||
|
c.String(http.StatusForbidden, "<script>window.notyfInstance.error('You do not have permission to run this job')</script>")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Determine job name for response
|
||||||
|
jobName := job.Name
|
||||||
|
if jobName == "" {
|
||||||
|
// If job name is empty, try to get config name
|
||||||
|
var config db.TransferConfig
|
||||||
|
if err := h.DB.First(&config, job.ConfigID).Error; err == nil {
|
||||||
|
jobName = config.Name
|
||||||
|
} else {
|
||||||
|
jobName = fmt.Sprintf("Job #%d", job.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Run the job immediately using the scheduler
|
// Run the job immediately using the scheduler
|
||||||
if err := h.Scheduler.RunJobNow(job.ID); err != nil {
|
if err := h.Scheduler.RunJobNow(job.ID); err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to run job: " + err.Error()})
|
c.Header("Content-Type", "text/html")
|
||||||
|
errorMsg := fmt.Sprintf("<script>window.notyfInstance.error('Failed to run job: %s')</script>", err.Error())
|
||||||
|
c.String(http.StatusInternalServerError, errorMsg)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
// Set custom header with job name for HTMX to use in the toast notification
|
||||||
"message": "Job started successfully",
|
c.Header("HX-Job-Name", jobName)
|
||||||
"jobId": job.ID,
|
c.Header("Content-Type", "text/html")
|
||||||
})
|
|
||||||
|
// Return HTML with JavaScript to trigger the notification
|
||||||
|
successScript := fmt.Sprintf("<script>window.notyfInstance.success('Job \"%s\" has been started successfully')</script>", jobName)
|
||||||
|
c.String(http.StatusOK, successScript)
|
||||||
}
|
}
|
||||||
@@ -20,6 +20,8 @@ body {
|
|||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
background-color: var(--background-color);
|
background-color: var(--background-color);
|
||||||
|
/* Prevent content from going under bottom nav on mobile */
|
||||||
|
padding-bottom: env(safe-area-inset-bottom);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Layout */
|
/* Layout */
|
||||||
@@ -223,8 +225,21 @@ body {
|
|||||||
border-top: 1px solid #e2e8f0;
|
border-top: 1px solid #e2e8f0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Mobile Bottom Navigation */
|
||||||
|
.mobile-bottom-nav {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pb-mobile-nav {
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
/* Responsive */
|
/* Responsive */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
|
.pb-mobile-nav {
|
||||||
|
padding-bottom: 5rem !important; /* Account for bottom nav */
|
||||||
|
}
|
||||||
|
|
||||||
.grid-cols-2,
|
.grid-cols-2,
|
||||||
.grid-cols-3 {
|
.grid-cols-3 {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
@@ -234,4 +249,120 @@ body {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Adjust cards for mobile */
|
||||||
|
.card {
|
||||||
|
padding: 1rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Table adjustments for mobile */
|
||||||
|
.table-responsive {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Adjust forms for mobile */
|
||||||
|
.form-label {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input {
|
||||||
|
font-size: 16px; /* Prevents zoom on iOS */
|
||||||
|
padding: 0.625rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Touch-friendly buttons */
|
||||||
|
.button {
|
||||||
|
padding: 0.625rem 1rem;
|
||||||
|
min-height: 44px; /* Better touch target */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile spacing */
|
||||||
|
.container {
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.px-4 {
|
||||||
|
padding-left: 0.75rem;
|
||||||
|
padding-right: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.py-4 {
|
||||||
|
padding-top: 0.75rem;
|
||||||
|
padding-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Small phones */
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.card-body {
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hidden-xs {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stack buttons on small screens */
|
||||||
|
.button-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure safe area at bottom of the page */
|
||||||
|
body {
|
||||||
|
padding-bottom: max(1rem, env(safe-area-inset-bottom));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Support for notch phones */
|
||||||
|
@supports (padding: max(0px)) {
|
||||||
|
body,
|
||||||
|
.container,
|
||||||
|
.navbar {
|
||||||
|
padding-left: max(1rem, env(safe-area-inset-left));
|
||||||
|
padding-right: max(1rem, env(safe-area-inset-right));
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-nav-container {
|
||||||
|
padding-bottom: max(0.5rem, env(safe-area-inset-bottom));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode support for mobile */
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.mobile-bottom-nav {
|
||||||
|
background-color: #1e293b;
|
||||||
|
border-top-color: #334155;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Improve form elements on mobile */
|
||||||
|
@media (hover: none) and (pointer: coarse) {
|
||||||
|
input[type="checkbox"],
|
||||||
|
input[type="radio"],
|
||||||
|
button,
|
||||||
|
.button,
|
||||||
|
select {
|
||||||
|
min-height: 44px;
|
||||||
|
min-width: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input, select, textarea {
|
||||||
|
font-size: 16px !important; /* Prevents zoom on focus in iOS */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
// Theme management for GoMFT application
|
// Theme management for GoMFT application
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
initializeTheme();
|
initializeTheme();
|
||||||
|
initializeMobileSupport();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Initialize theme based on user preference
|
// Initialize theme based on user preference
|
||||||
@@ -242,3 +243,94 @@ style.textContent = `
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
document.head.appendChild(style);
|
document.head.appendChild(style);
|
||||||
|
|
||||||
|
// Initialize mobile support features
|
||||||
|
function initializeMobileSupport() {
|
||||||
|
// Check if it's a mobile device
|
||||||
|
const isMobile = window.matchMedia('(max-width: 768px)').matches;
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
|
// Add padding to main content to prevent overlap with bottom nav
|
||||||
|
const bottomNav = document.querySelector('.mobile-nav-container');
|
||||||
|
if (bottomNav) {
|
||||||
|
const main = document.querySelector('main');
|
||||||
|
if (main) {
|
||||||
|
main.style.paddingBottom = (bottomNav.offsetHeight + 16) + 'px';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add active class to current page in bottom nav
|
||||||
|
highlightCurrentPageInBottomNav();
|
||||||
|
|
||||||
|
// Make tables scrollable on mobile
|
||||||
|
makeTablesResponsive();
|
||||||
|
|
||||||
|
// Improve mobile form experience
|
||||||
|
enhanceMobileForms();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listen for orientation changes
|
||||||
|
window.addEventListener('orientationchange', function() {
|
||||||
|
// Wait for orientation change to complete
|
||||||
|
setTimeout(function() {
|
||||||
|
if (window.matchMedia('(max-width: 768px)').matches) {
|
||||||
|
makeTablesResponsive();
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Highlight current page in mobile bottom navigation
|
||||||
|
function highlightCurrentPageInBottomNav() {
|
||||||
|
const currentPath = window.location.pathname;
|
||||||
|
const bottomNavLinks = document.querySelectorAll('.mobile-nav-container a');
|
||||||
|
|
||||||
|
bottomNavLinks.forEach(link => {
|
||||||
|
const href = link.getAttribute('href');
|
||||||
|
if (currentPath === href || (href !== '/' && currentPath.startsWith(href))) {
|
||||||
|
link.classList.add('text-primary-600', 'dark:text-primary-400');
|
||||||
|
link.classList.remove('text-secondary-500', 'dark:text-secondary-400');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make tables responsive on mobile
|
||||||
|
function makeTablesResponsive() {
|
||||||
|
const tables = document.querySelectorAll('table');
|
||||||
|
tables.forEach(table => {
|
||||||
|
if (!table.parentElement.classList.contains('table-responsive')) {
|
||||||
|
const wrapper = document.createElement('div');
|
||||||
|
wrapper.classList.add('table-responsive');
|
||||||
|
table.parentNode.insertBefore(wrapper, table);
|
||||||
|
wrapper.appendChild(table);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enhance mobile forms
|
||||||
|
function enhanceMobileForms() {
|
||||||
|
// Prevent zooming on inputs in iOS
|
||||||
|
const metaViewport = document.querySelector('meta[name=viewport]');
|
||||||
|
if (metaViewport) {
|
||||||
|
metaViewport.setAttribute('content', 'width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add 'required' visual indicator to required fields
|
||||||
|
const requiredInputs = document.querySelectorAll('input[required], select[required], textarea[required]');
|
||||||
|
requiredInputs.forEach(input => {
|
||||||
|
const label = input.previousElementSibling;
|
||||||
|
if (label && label.tagName === 'LABEL') {
|
||||||
|
if (!label.innerHTML.includes('*')) {
|
||||||
|
label.innerHTML += ' <span class="text-red-500">*</span>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Improve date input on mobile
|
||||||
|
const dateInputs = document.querySelectorAll('input[type="date"]');
|
||||||
|
dateInputs.forEach(input => {
|
||||||
|
input.addEventListener('focus', function() {
|
||||||
|
input.click(); // Force native date picker on mobile
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user