initial commit

This commit is contained in:
StarFleetCPTN committed 2025-03-07 23:21:12 -08:00
commit 953ad364ab
68 files changed
+11440

No files matched your search

+578
View File
@@ -0,0 +1,578 @@
package components
import (
"context"
"fmt"
"time"
)
type BackupFile struct {
Name string
Size string
ModTime time.Time
}
type AdminToolsData struct {
JobHistoryCount int
DatabaseSize string
LastBackupTime *time.Time
BackupCount int
SystemUptime string
ActiveJobs int
TotalConfigs int
TotalJobs int
TotalUsers int
DatabasePath string
BackupPath string
MaintenanceMessage string
BackupFiles []BackupFile
}
// Dialog component for confirmation dialogs
templ Dialog(id string, title string, message string, confirmClass string, confirmText string, formId string, targetAction 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={ hideDialog(id) }>
Cancel
</button>
if formId != "" {
<button
type="button"
class={ confirmClass }
onclick={ submitFormAndHideDialog(formId, id) }>
{ confirmText }
</button>
} else {
<button
type="button"
class={ confirmClass }
hx-target={ targetAction }
onclick={ hideDialog(id) }>
{ confirmText }
</button>
}
</div>
</div>
</div>
}
script hideDialog(id string) {
document.getElementById(id).classList.add("hidden");
}
script submitFormAndHideDialog(formId string, dialogId string) {
document.getElementById(formId).submit();
document.getElementById(dialogId).classList.add("hidden");
}
script showDialog(id string) {
document.getElementById(id).classList.remove("hidden");
}
// Backup dialog component specifically for restore and delete actions
templ BackupActionDialog(id string, title string, message string, confirmClass string, confirmText string, action string, backupName 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-5xl"></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-center space-x-3">
<button type="button" class="btn-secondary" onclick={ hideDialog(id) }>
Cancel
</button>
if action == "restore" {
<button
type="button"
class="btn-warning"
hx-post={ fmt.Sprintf("/admin/restore-database/%s", backupName) }
hx-swap="none"
onclick={ hideDialog(id) }>
{ confirmText }
</button>
} else if action == "delete" {
<button
type="button"
class="btn-danger"
hx-delete={ fmt.Sprintf("/admin/delete-backup/%s", backupName) }
hx-target="closest tr"
hx-swap="delete"
onclick={ hideDialog(id) }>
{ confirmText }
</button>
}
</div>
</div>
</div>
}
templ AdminTools(ctx context.Context, data AdminToolsData) {
@LayoutWithContext("Admin Tools", ctx) {
<div class="py-6">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex items-center justify-between mb-8">
<h1 class="text-3xl font-bold text-secondary-900 dark:text-secondary-100">
<i class="fas fa-tools mr-2 text-primary-600 dark:text-primary-400"></i>
Admin Tools
</h1>
<div class="text-sm text-secondary-500 dark:text-secondary-400">
<span id="current-date" class="font-medium"></span>
<script>
document.getElementById('current-date').textContent = new Date().toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
</script>
</div>
</div>
if data.MaintenanceMessage != "" {
<div class="mb-6 bg-yellow-50 dark:bg-yellow-900/20 border-l-4 border-yellow-400 p-4 rounded">
<div class="flex">
<div class="flex-shrink-0">
<i class="fas fa-exclamation-triangle text-yellow-400"></i>
</div>
<div class="ml-3">
<p class="text-sm text-yellow-700 dark:text-yellow-300">
{ data.MaintenanceMessage }
</p>
</div>
</div>
</div>
}
<!-- System Overview -->
<div class="mb-8">
<h2 class="text-xl font-semibold text-secondary-900 dark:text-secondary-100 mb-4">
<i class="fas fa-chart-line mr-2 text-primary-500"></i>
System Overview
</h2>
<div class="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4">
<div class="card overflow-hidden">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<i class="fas fa-database text-blue-500 text-2xl"></i>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400 truncate">
Database Size
</dt>
<dd>
<div class="text-lg font-medium text-secondary-900 dark:text-secondary-100">
{ data.DatabaseSize }
</div>
</dd>
</dl>
</div>
</div>
</div>
</div>
<div class="card overflow-hidden">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<i class="fas fa-history text-green-500 text-2xl"></i>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400 truncate">
Job History Records
</dt>
<dd>
<div class="text-lg font-medium text-secondary-900 dark:text-secondary-100">
{ fmt.Sprint(data.JobHistoryCount) }
</div>
</dd>
</dl>
</div>
</div>
</div>
</div>
<div class="card overflow-hidden">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<i class="fas fa-clock text-purple-500 text-2xl"></i>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400 truncate">
System Uptime
</dt>
<dd>
<div class="text-lg font-medium text-secondary-900 dark:text-secondary-100">
{ data.SystemUptime }
</div>
</dd>
</dl>
</div>
</div>
</div>
</div>
<div class="card overflow-hidden">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<i class="fas fa-tasks text-orange-500 text-2xl"></i>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400 truncate">
Active Jobs
</dt>
<dd>
<div class="text-lg font-medium text-secondary-900 dark:text-secondary-100">
{ fmt.Sprint(data.ActiveJobs) }
</div>
</dd>
</dl>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Admin Tools Cards -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Backup & Restore -->
<div class="card">
<div class="card-header">
<h3 class="text-lg font-medium text-secondary-900 dark:text-secondary-100">
<i class="fas fa-save mr-2 text-primary-500"></i>
Backup & Restore
</h3>
</div>
<div class="card-body space-y-4">
<div class="flex items-center justify-between text-sm text-secondary-500 dark:text-secondary-400 mb-2">
if data.LastBackupTime != nil {
<span>Last backup: { data.LastBackupTime.Format("Jan 02, 2006 15:04:05") }</span>
} else {
<span>Last backup: Never</span>
}
</div>
<button id="backup-form"
hx-post="/admin/backup-database"
hx-swap="none"
hx-indicator="#backup-indicator"
hx-on::after-request="htmx.trigger('#refresh-backups-trigger', 'click')"
class="btn-primary w-full flex items-center justify-center">
<i class="fas fa-database mr-2"></i>
<span>Backup Database</span>
<div id="backup-indicator" class="htmx-indicator ml-2">
<i class="fas fa-circle-notch fa-spin"></i>
</div>
</button>
<!-- Hidden refresh trigger -->
<button id="refresh-backups-trigger"
class="hidden"
hx-get="/admin/refresh-backups"
hx-target="#backups-container"></button>
<form id="export-configs-form" action="/admin/export-configs" method="GET">
<button type="submit" class="btn-secondary w-full flex items-center justify-center">
<i class="fas fa-file-export mr-2"></i>
<span>Export All Configurations</span>
<div id="export-configs-indicator" class="htmx-indicator ml-2">
<i class="fas fa-circle-notch fa-spin"></i>
</div>
</button>
</form>
<form id="export-jobs-form" action="/admin/export-jobs" method="GET">
<button type="submit" class="btn-secondary w-full flex items-center justify-center">
<i class="fas fa-file-export mr-2"></i>
<span>Export All Jobs</span>
<div id="export-jobs-indicator" class="htmx-indicator ml-2">
<i class="fas fa-circle-notch fa-spin"></i>
</div>
</button>
</form>
<div class="border-t border-secondary-200 dark:border-secondary-700 pt-4 mt-4">
<h4 class="text-sm font-medium text-secondary-900 dark:text-secondary-100 mb-2">Restore Database</h4>
<form id="restore-form" hx-post="/admin/restore-database" hx-encoding="multipart/form-data" hx-swap="none" hx-indicator="#restore-indicator">
<div class="flex items-center space-x-2">
<div class="flex-grow">
<label class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
Select Backup File
</label>
<input type="file" name="backup_file" class="block w-full text-sm text-secondary-500 dark:text-secondary-400
file:mr-4 file:py-2 file:px-4
file:rounded-md file:border-0
file:text-sm file:font-medium
file:bg-primary-50 file:text-primary-700
dark:file:bg-primary-900 dark:file:text-primary-300
hover:file:bg-primary-100 dark:hover:file:bg-primary-800
focus:outline-none" required />
</div>
<button type="submit" class="btn-warning flex-shrink-0 flex items-center justify-center h-10">
<div class="flex items-center justify-center">
<i class="fas fa-upload mr-1"></i>
<span>Restore</span>
<div id="restore-indicator" class="htmx-indicator ml-2">
<i class="fas fa-circle-notch fa-spin"></i>
</div>
</div>
</button>
</div>
<p class="text-xs text-red-600 dark:text-red-400 mt-2">
<i class="fas fa-exclamation-triangle mr-1"></i>
Warning: This will replace your current database. Make sure to backup first!
</p>
</form>
</div>
</div>
</div>
<!-- Maintenance Tools -->
<div class="card">
<div class="card-header">
<h3 class="text-lg font-medium text-secondary-900 dark:text-secondary-100">
<i class="fas fa-wrench mr-2 text-primary-500"></i>
Maintenance Tools
</h3>
</div>
<div class="card-body space-y-4">
<!-- Clear Job History Button and Dialog -->
@Dialog("clear-job-dialog", "Clear Job History", "Are you sure you want to clear all job history? This cannot be undone.", "btn-danger", "Clear History", "purge-form", "")
<form id="purge-form" hx-post="/admin/clear-job-history" hx-swap="none" hx-indicator="#clear-history-indicator">
<button type="button" class="btn-danger w-full flex items-center justify-center" onclick={ showDialog("clear-job-dialog") }>
<i class="fas fa-trash-alt mr-2"></i>
<span>Clear Job History</span>
<div id="clear-history-indicator" class="htmx-indicator ml-2">
<i class="fas fa-circle-notch fa-spin"></i>
</div>
</button>
</form>
<div class="border-t border-secondary-200 dark:border-secondary-700 pt-4 mt-4">
<form id="vacuum-form" hx-post="/admin/vacuum-database" hx-swap="none" hx-indicator="#vacuum-indicator">
<button type="submit" class="btn-secondary w-full flex items-center justify-center">
<i class="fas fa-compress-alt mr-2"></i>
<span>Optimize Database</span>
<div id="vacuum-indicator" class="htmx-indicator ml-2">
<i class="fas fa-circle-notch fa-spin"></i>
</div>
</button>
<p class="text-xs text-secondary-500 dark:text-secondary-400 mt-2">
Runs VACUUM to optimize the database and reclaim unused space.
</p>
</form>
</div>
</div>
</div>
</div>
<!-- Available Backups -->
<div id="backups-container" class="mt-8">
@BackupsList(data)
</div>
<!-- System Information -->
<div class="mt-8">
<div class="card">
<div class="card-header">
<h3 class="text-lg font-medium text-secondary-900 dark:text-secondary-100">
<i class="fas fa-info-circle mr-2 text-primary-500"></i>
System Information
</h3>
</div>
<div class="card-body">
<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">Database 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 overflow-auto">
{ data.DatabasePath }
</dd>
</div>
<div class="sm:col-span-1">
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">Backup Directory</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 overflow-auto">
{ data.BackupPath }
</dd>
</div>
<div class="sm:col-span-1">
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">Total Users</dt>
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100">
{ fmt.Sprint(data.TotalUsers) }
</dd>
</div>
<div class="sm:col-span-1">
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">Total Configurations</dt>
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100">
{ fmt.Sprint(data.TotalConfigs) }
</dd>
</div>
<div class="sm:col-span-1">
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">Total Jobs</dt>
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100">
{ fmt.Sprint(data.TotalJobs) }
</dd>
</div>
</dl>
</div>
</div>
</div>
</div>
</div>
}
}
// BackupsList is a separate component for the backups list that can be refreshed via HTMX
templ BackupsList(data AdminToolsData) {
if len(data.BackupFiles) > 0 {
<div class="card">
<div class="card-header flex justify-between items-center">
<h3 class="text-lg font-medium text-secondary-900 dark:text-secondary-100">
<i class="fas fa-history mr-2 text-primary-500"></i>
Available Backups
</h3>
<button
hx-get="/admin/refresh-backups"
hx-target="#backups-container"
hx-indicator="#refresh-backups-indicator"
class="text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300"
title="Refresh backups list">
<i class="fas fa-sync-alt"></i>
<span id="refresh-backups-indicator" class="htmx-indicator ml-1">
<i class="fas fa-circle-notch fa-spin"></i>
</span>
</button>
</div>
<div class="card-body">
<div class="bg-secondary-50 dark:bg-secondary-900 rounded-lg overflow-hidden">
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-secondary-200 dark:divide-secondary-700">
<thead>
<tr class="bg-secondary-100 dark:bg-secondary-800">
<th class="px-4 py-2 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400">Name</th>
<th class="px-4 py-2 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400">Size</th>
<th class="px-4 py-2 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400">Date</th>
<th class="px-4 py-2 text-right text-xs font-medium text-secondary-500 dark:text-secondary-400">Action</th>
</tr>
</thead>
<tbody class="divide-y divide-secondary-200 dark:divide-secondary-700">
for _, backup := range data.BackupFiles {
<tr class="hover:bg-secondary-100 dark:hover:bg-secondary-800 transition-colors">
<td class="px-4 py-2 text-sm text-secondary-900 dark:text-secondary-100">{ backup.Name }</td>
<td class="px-4 py-2 text-sm text-secondary-500 dark:text-secondary-400">{ backup.Size }</td>
<td class="px-4 py-2 text-sm text-secondary-500 dark:text-secondary-400">{ backup.ModTime.Format("Jan 02, 2006 15:04:05") }</td>
<td class="px-4 py-2 text-right">
<a href={ templ.SafeURL(fmt.Sprintf("/admin/download-backup/%s", backup.Name)) }
class="text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300 mr-2"
title="Download backup">
<i class="fas fa-download"></i>
</a>
<!-- Restore Dialog -->
@BackupActionDialog(
fmt.Sprintf("restore-dialog-%s", backup.Name),
"RESTORE BACKUP",
fmt.Sprintf("Are you sure you want to restore the backup '%s'? This will replace your current database.", backup.Name),
"btn-warning",
"Restore",
"restore",
backup.Name,
)
<button
type="button"
class="text-yellow-600 hover:text-yellow-700 dark:text-yellow-400 dark:hover:text-yellow-300 mr-2"
title="Restore this backup"
onclick={ showDialog(fmt.Sprintf("restore-dialog-%s", backup.Name)) }>
<i class="fas fa-upload"></i>
</button>
<!-- Delete Dialog -->
@BackupActionDialog(
fmt.Sprintf("delete-dialog-%s", backup.Name),
"DELETE BACKUP",
fmt.Sprintf("Are you sure you want to delete the backup '%s'? This cannot be undone.", backup.Name),
"btn-danger",
"Delete",
"delete",
backup.Name,
)
<button
type="button"
class="text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"
title="Delete backup"
onclick={ showDialog(fmt.Sprintf("delete-dialog-%s", backup.Name)) }>
<i class="fas fa-trash-alt"></i>
</button>
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
</div>
} else {
<div class="card">
<div class="card-header flex justify-between items-center">
<h3 class="text-lg font-medium text-secondary-900 dark:text-secondary-100">
<i class="fas fa-history mr-2 text-primary-500"></i>
Available Backups
</h3>
<button
hx-get="/admin/refresh-backups"
hx-target="#backups-container"
hx-indicator="#refresh-backups-indicator"
class="text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300"
title="Refresh backups list">
<i class="fas fa-sync-alt"></i>
<span id="refresh-backups-indicator" class="htmx-indicator ml-1">
<i class="fas fa-circle-notch fa-spin"></i>
</span>
</button>
</div>
<div class="card-body">
<div class="text-center py-6">
<div class="mx-auto w-12 h-12 rounded-full bg-secondary-100 dark:bg-secondary-800 flex items-center justify-center mb-4">
<i class="fas fa-folder-open text-secondary-400 dark:text-secondary-500"></i>
</div>
<h3 class="text-sm font-medium text-secondary-900 dark:text-secondary-100">No Backups Available</h3>
<p class="mt-1 text-sm text-secondary-500 dark:text-secondary-400">
Create a backup using the "Backup Database" button.
</p>
</div>
</div>
</div>
}
}
File diff suppressed because it is too large. Load diff
+109
View File
@@ -0,0 +1,109 @@
package components
import (
"context"
"fmt"
"github.com/starfleetcptn/gomft/internal/db"
)
type ConfigsData struct {
Configs []db.TransferConfig
}
templ Configs(ctx context.Context, data ConfigsData) {
@LayoutWithContext("Transfer Configurations", ctx) {
<div class="py-6">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center mb-8">
<h1 class="text-3xl font-bold text-secondary-900 dark:text-secondary-100">
<i class="fas fa-cogs mr-2 text-primary-600 dark:text-primary-400"></i>
Transfer Configurations
</h1>
<a href="/configs/new" class="btn-primary">
<i class="fas fa-plus mr-2"></i>
New Configuration
</a>
</div>
<div class="mt-6">
if len(data.Configs) == 0 {
<div class="card 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">
<i class="fas fa-folder-open text-secondary-400 dark:text-secondary-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>
} else {
<div class="card overflow-hidden">
<ul role="list" class="divide-y divide-secondary-200 dark:divide-secondary-700">
for _, config := range data.Configs {
<li>
<div class="block hover:bg-secondary-50 dark:hover:bg-secondary-750 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-primary-600 dark:text-primary-400 truncate">
{ config.Name }
</p>
</div>
<div class="ml-2 flex-shrink-0 flex space-x-2">
<a href={ templ.SafeURL(fmt.Sprintf("/configs/%d", config.ID)) } class="btn-secondary btn-sm">
<i class="fas fa-edit mr-1"></i>
Edit
</a>
<button
hx-delete={ fmt.Sprintf("/configs/%d", config.ID) }
hx-confirm="Are you sure you want to delete this configuration?"
hx-target="closest li"
hx-swap="outerHTML"
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-upload flex-shrink-0 mr-1.5 h-5 w-5 text-secondary-400 dark:text-secondary-500"></i>
Source: { config.SourceType }: { config.SourcePath }
</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-download flex-shrink-0 mr-1.5 h-5 w-5 text-secondary-400 dark:text-secondary-500"></i>
Destination: { config.DestinationType }: { config.DestinationPath }
</p>
</div>
<div class="mt-2 flex items-center text-sm text-secondary-500 dark:text-secondary-400 sm:mt-0">
<i class="fas fa-calendar-alt flex-shrink-0 mr-1.5 h-5 w-5 text-secondary-400 dark:text-secondary-500"></i>
<p>
Updated: { config.UpdatedAt.Format("2006-01-02 15:04:05") }
</p>
</div>
</div>
</div>
</div>
</li>
}
</ul>
</div>
}
</div>
<!-- Help Notice -->
<div class="mt-8 text-center">
<p class="text-sm text-secondary-500 dark:text-secondary-400">
<i class="fas fa-info-circle mr-1 text-primary-500"></i>
Configurations define how files are transferred between systems
</p>
</div>
</div>
</div>
}
}
+192
View File
@@ -0,0 +1,192 @@
package components
import (
"context"
"fmt"
"github.com/starfleetcptn/gomft/internal/db"
"strconv"
)
type DashboardData struct {
RecentJobs []db.JobHistory
ActiveTransfers int
CompletedToday int
FailedTransfers int
}
templ Dashboard(ctx context.Context, data DashboardData) {
@LayoutWithContext("Dashboard", ctx) {
<div class="py-6">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex items-center justify-between mb-8">
<h1 class="text-3xl font-bold text-secondary-900 dark:text-secondary-100">
<i class="fas fa-tachometer-alt mr-2 text-primary-600 dark:text-primary-400"></i>
Dashboard
</h1>
<div class="text-sm text-secondary-500 dark:text-secondary-400">
<span id="current-date" class="font-medium"></span>
<script>
document.getElementById('current-date').textContent = new Date().toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
</script>
</div>
</div>
<!-- Stats Overview Cards -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<div class="card overflow-hidden">
<div class="p-6 flex items-center">
<div class="rounded-full bg-blue-100 dark:bg-blue-900 p-3 mr-4">
<i class="fas fa-exchange-alt text-blue-600 dark:text-blue-300 text-xl"></i>
</div>
<div>
<p class="text-sm font-medium text-secondary-500 dark:text-secondary-400">Active Transfers</p>
<p class="text-2xl font-bold text-secondary-900 dark:text-secondary-100">{ strconv.Itoa(data.ActiveTransfers) }</p>
</div>
</div>
</div>
<div class="card overflow-hidden">
<div class="p-6 flex items-center">
<div class="rounded-full bg-green-100 dark:bg-green-900 p-3 mr-4">
<i class="fas fa-check-circle text-green-600 dark:text-green-300 text-xl"></i>
</div>
<div>
<p class="text-sm font-medium text-secondary-500 dark:text-secondary-400">Completed Today</p>
<p class="text-2xl font-bold text-secondary-900 dark:text-secondary-100">{ strconv.Itoa(data.CompletedToday) }</p>
</div>
</div>
</div>
<div class="card overflow-hidden">
<div class="p-6 flex items-center">
<div class="rounded-full bg-red-100 dark:bg-red-900 p-3 mr-4">
<i class="fas fa-exclamation-circle text-red-600 dark:text-red-300 text-xl"></i>
</div>
<div>
<p class="text-sm font-medium text-secondary-500 dark:text-secondary-400">Failed Transfers</p>
<p class="text-2xl font-bold text-secondary-900 dark:text-secondary-100">{ strconv.Itoa(data.FailedTransfers) }</p>
</div>
</div>
</div>
</div>
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
<div class="card">
<div class="card-header">
<h3 class="text-lg font-medium text-secondary-900 dark:text-secondary-100">
<i class="fas fa-history mr-2 text-primary-500"></i>
Recent Jobs
</h3>
</div>
<div class="card-body">
if len(data.RecentJobs) == 0 {
<div class="py-8 text-center">
<div class="inline-block p-4 rounded-full bg-secondary-100 dark:bg-secondary-800 mb-4">
<i class="fas fa-inbox text-secondary-400 text-3xl"></i>
</div>
<p class="text-secondary-500 dark:text-secondary-400">No recent jobs found</p>
<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">
<span>Create your first job</span>
<i class="fas fa-arrow-right ml-1"></i>
</a>
</div>
} else {
<div class="flow-root">
<ul role="list" class="-my-5 divide-y divide-secondary-200 dark:divide-secondary-700">
for _, job := range data.RecentJobs {
<li class="py-4 hover:bg-secondary-50 dark:hover:bg-secondary-800 px-4 rounded-lg transition-colors">
<div class="flex items-center space-x-4">
<div class="flex-shrink-0">
if job.Status == "completed" {
<span class="h-10 w-10 rounded-full bg-green-100 flex items-center justify-center dark:bg-green-900">
<i class="fas fa-check text-green-600 dark:text-green-300"></i>
</span>
} 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">
<i class="fas fa-times text-red-600 dark:text-red-300"></i>
</span>
} else {
<span class="h-10 w-10 rounded-full bg-blue-100 flex items-center justify-center dark:bg-blue-900">
<i class="fas fa-sync-alt text-blue-600 dark:text-blue-300"></i>
</span>
}
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-secondary-900 truncate dark:text-secondary-100">
{ job.Job.Config.Name }
</p>
<div class="flex items-center mt-1">
<i class="fas fa-clock text-xs text-secondary-500 dark:text-secondary-400 mr-1"></i>
<p class="text-sm text-secondary-500 truncate dark:text-secondary-400">
Started: { job.StartTime.Format("Jan 02, 2006 15:04:05") }
</p>
</div>
</div>
<div>
<a href={ templ.SafeURL(fmt.Sprintf("/job-runs/%d", job.ID)) }
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">
<i class="fas fa-eye mr-1"></i>
View
</a>
</div>
</div>
</li>
}
</ul>
</div>
<div class="mt-6">
<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">
<i class="fas fa-list-ul mr-2"></i>
View all jobs
</a>
</div>
}
</div>
</div>
<div class="card">
<div class="card-header">
<h3 class="text-lg font-medium text-secondary-900 dark:text-secondary-100">
<i class="fas fa-bolt mr-2 text-primary-500"></i>
Quick Actions
</h3>
</div>
<div class="card-body space-y-4">
<a href="/configs/new" class="btn-primary text-center flex items-center justify-center">
<i class="fas fa-plus-circle mr-2"></i>
Create New Config
</a>
<a href="/jobs/new" class="btn-primary text-center flex items-center justify-center">
<i class="fas fa-play-circle mr-2"></i>
Create New Job
</a>
<a href="/history" class="btn-secondary text-center flex items-center justify-center">
<i class="fas fa-history mr-2"></i>
View Transfer History
</a>
<!-- System Status Card -->
<div class="mt-6 bg-secondary-50 dark:bg-secondary-800 rounded-lg p-4 border border-secondary-200 dark:border-secondary-700">
<h4 class="text-sm font-medium text-secondary-900 dark:text-secondary-100 mb-2">System Status</h4>
<div class="space-y-2">
<div class="flex items-center justify-between">
<span class="text-xs text-secondary-500 dark:text-secondary-400">Server</span>
<span class="badge badge-success">Online</span>
</div>
<div class="flex items-center justify-between">
<span class="text-xs text-secondary-500 dark:text-secondary-400">Scheduler</span>
<span class="badge badge-success">Running</span>
</div>
<div class="flex items-center justify-between">
<span class="text-xs text-secondary-500 dark:text-secondary-400">Database</span>
<span class="badge badge-success">Connected</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
}
}
+243
View File
@@ -0,0 +1,243 @@
package components
import (
"context"
)
templ ForgotPassword(ctx context.Context, errorMessage string, successMessage string) {
@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="max-w-md w-full">
<div class="card overflow-hidden shadow-lg">
<div class="p-8">
<div class="text-center mb-8">
<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>
</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>
if errorMessage != "" {
<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 class="px-8 py-4 bg-secondary-50 dark:bg-secondary-800 border-t border-secondary-200 dark:border-secondary-700 text-center">
<p class="text-sm text-secondary-600 dark:text-secondary-400">
<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-arrow-left mr-2"></i>
Back to login
</a>
</p>
</div>
</div>
<!-- Security Notice -->
<div class="mt-8 text-center">
<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>
<span>Secure, encrypted connection</span>
</div>
</div>
</div>
</div>
}
}
// Reset password page for when users click the link from their email
templ ResetPassword(ctx context.Context, token string, errorMessage string) {
@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="max-w-md w-full">
<div class="card overflow-hidden shadow-lg">
<div class="p-8">
<div class="text-center mb-8">
<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>
</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>
if errorMessage != "" {
<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 class="px-8 py-4 bg-secondary-50 dark:bg-secondary-800 border-t border-secondary-200 dark:border-secondary-700 text-center">
<p class="text-sm text-secondary-600 dark:text-secondary-400">
<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-arrow-left mr-2"></i>
Back to login
</a>
</p>
</div>
</div>
<!-- Security Notice -->
<div class="mt-8 text-center">
<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>
<span>Secure, encrypted connection</span>
</div>
</div>
</div>
</div>
}
}
+353
View File
@@ -0,0 +1,353 @@
package components
import (
"context"
"fmt"
"github.com/starfleetcptn/gomft/internal/db"
)
type HistoryData struct {
History []db.JobHistory
CurrentPage int
TotalPages int
SearchTerm string
PageSize int
Total int
}
// min returns the smaller of x or y
func min(x, y int) int {
if x < y {
return x
}
return y
}
// HistoryContent renders only the content part of the history page for HTMX requests
templ HistoryContent(ctx context.Context, data HistoryData) {
if len(data.History) == 0 {
<div class="text-center py-12 bg-white dark:bg-secondary-800 rounded-lg shadow">
<div class="inline-block p-4 rounded-full bg-secondary-100 dark:bg-secondary-700 mb-4">
<i class="fas fa-inbox text-secondary-400 dark:text-secondary-500 text-3xl"></i>
</div>
<h3 class="mt-2 text-lg font-medium text-secondary-900 dark:text-secondary-100">No transfer history</h3>
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>
} 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>
} else {
<p class="mt-1 text-sm text-secondary-500 dark:text-secondary-400">Transfer history will appear here once jobs have run.</p>
}
</div>
} else {
<div class="bg-white dark:bg-secondary-800 shadow overflow-hidden rounded-lg">
<ul role="list" class="divide-y divide-secondary-200 dark:divide-secondary-700">
for _, history := range data.History {
<li class="hover:bg-secondary-50 dark:hover:bg-secondary-750 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-primary-600 dark:text-primary-400 truncate">{ history.Job.Config.Name }</p>
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">
<i class="fas fa-check mr-1"></i> Completed
</span>
} 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">
<i class="fas fa-times mr-1"></i> Failed
</span>
} 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">
<i class="fas fa-sync-alt mr-1"></i> { history.Status }
</span>
}
</div>
<div>
<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">
<i class="fas fa-eye mr-1"></i>
View Details
</a>
</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-calendar-alt flex-shrink-0 mr-1.5 h-5 w-5 text-secondary-400 dark:text-secondary-500"></i>
Started: { history.StartTime.Format("Jan 02, 2006 15:04:05") }
</p>
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">
<i class="fas fa-clock flex-shrink-0 mr-1.5 h-5 w-5 text-secondary-400 dark:text-secondary-500"></i>
Duration: { history.EndTime.Sub(history.StartTime).String() }
</p>
}
</div>
<div class="mt-2 flex items-center text-sm text-secondary-500 dark:text-secondary-400 sm:mt-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>
<p>
{ formatBytes(history.BytesTransferred) } transferred
</p>
}
</div>
</div>
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">
<p class="text-sm text-red-600 dark:text-red-400">
<i class="fas fa-exclamation-triangle mr-1"></i>
Error: { history.ErrorMessage }
</p>
</div>
}
</div>
</li>
}
</ul>
</div>
<!-- Pagination -->
if data.TotalPages > 1 {
<div class="mt-6 flex items-center justify-between">
<div class="flex-1 flex justify-between sm:hidden">
if data.CurrentPage > 1 {
<button
hx-get="/history"
hx-target="#history-content"
hx-vals={ fmt.Sprintf(`{"page": %d, "pageSize": %d, "search": "%s"}`, data.CurrentPage-1, data.PageSize, data.SearchTerm) }
hx-indicator="#mobile-prev-indicator"
class="relative 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 transition-colors">
<i class="fas fa-chevron-left mr-1"></i> Previous
<span id="mobile-prev-indicator" class="htmx-indicator ml-1">
<i class="fas fa-spinner fa-spin"></i>
</span>
</button>
} else {
<span class="relative inline-flex items-center px-4 py-2 border border-secondary-300 dark:border-secondary-700 text-sm font-medium rounded-md text-secondary-300 dark:text-secondary-600 bg-secondary-100 dark:bg-secondary-800 cursor-not-allowed">
<i class="fas fa-chevron-left mr-1"></i> Previous
</span>
}
if data.CurrentPage < data.TotalPages {
<button
hx-get="/history"
hx-target="#history-content"
hx-vals={ fmt.Sprintf(`{"page": %d, "pageSize": %d, "search": "%s"}`, data.CurrentPage+1, data.PageSize, data.SearchTerm) }
hx-indicator="#mobile-next-indicator"
class="ml-3 relative 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 transition-colors">
Next <i class="fas fa-chevron-right ml-1"></i>
<span id="mobile-next-indicator" class="htmx-indicator ml-1">
<i class="fas fa-spinner fa-spin"></i>
</span>
</button>
} else {
<span class="ml-3 relative inline-flex items-center px-4 py-2 border border-secondary-300 dark:border-secondary-700 text-sm font-medium rounded-md text-secondary-300 dark:text-secondary-600 bg-secondary-100 dark:bg-secondary-800 cursor-not-allowed">
Next <i class="fas fa-chevron-right ml-1"></i>
</span>
}
</div>
<div class="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
<div>
<p class="text-sm text-secondary-700 dark:text-secondary-300">
Showing
<span class="font-medium">{ fmt.Sprint((data.CurrentPage-1)*data.PageSize + 1) }</span>
to
<span class="font-medium">{ fmt.Sprint(min((data.CurrentPage)*data.PageSize, data.Total)) }</span>
of
<span class="font-medium">{ fmt.Sprint(data.Total) }</span>
results
</p>
</div>
<div>
<nav class="relative z-0 inline-flex rounded-md shadow-sm -space-x-px" aria-label="Pagination">
if data.CurrentPage > 1 {
<button
hx-get="/history"
hx-target="#history-content"
hx-vals={ fmt.Sprintf(`{"page": %d, "pageSize": %d, "search": "%s"}`, data.CurrentPage-1, data.PageSize, data.SearchTerm) }
hx-indicator="#prev-indicator"
class="relative inline-flex items-center px-2 py-2 rounded-l-md border border-secondary-300 dark:border-secondary-600 bg-white dark:bg-secondary-700 text-sm font-medium text-secondary-500 dark:text-secondary-400 hover:bg-secondary-50 dark:hover:bg-secondary-600 transition-colors">
<span class="sr-only">Previous</span>
<i class="fas fa-chevron-left h-5 w-5"></i>
<span id="prev-indicator" class="htmx-indicator ml-1">
<i class="fas fa-spinner fa-spin"></i>
</span>
</button>
} else {
<span class="relative inline-flex items-center px-2 py-2 rounded-l-md border border-secondary-300 dark:border-secondary-700 bg-secondary-100 dark:bg-secondary-800 text-sm font-medium text-secondary-300 dark:text-secondary-600 cursor-not-allowed">
<span class="sr-only">Previous</span>
<i class="fas fa-chevron-left h-5 w-5"></i>
</span>
}
<!-- Page numbers -->
@pageNumbers(data.CurrentPage, data.TotalPages, data.PageSize, data.SearchTerm)
if data.CurrentPage < data.TotalPages {
<button
hx-get="/history"
hx-target="#history-content"
hx-vals={ fmt.Sprintf(`{"page": %d, "pageSize": %d, "search": "%s"}`, data.CurrentPage+1, data.PageSize, data.SearchTerm) }
hx-indicator="#next-indicator"
class="relative inline-flex items-center px-2 py-2 rounded-r-md border border-secondary-300 dark:border-secondary-600 bg-white dark:bg-secondary-700 text-sm font-medium text-secondary-500 dark:text-secondary-400 hover:bg-secondary-50 dark:hover:bg-secondary-600 transition-colors">
<span class="sr-only">Next</span>
<i class="fas fa-chevron-right h-5 w-5"></i>
<span id="next-indicator" class="htmx-indicator ml-1">
<i class="fas fa-spinner fa-spin"></i>
</span>
</button>
} else {
<span class="relative inline-flex items-center px-2 py-2 rounded-r-md border border-secondary-300 dark:border-secondary-700 bg-secondary-100 dark:bg-secondary-800 text-sm font-medium text-secondary-300 dark:text-secondary-600 cursor-not-allowed">
<span class="sr-only">Next</span>
<i class="fas fa-chevron-right h-5 w-5"></i>
</span>
}
</nav>
</div>
</div>
</div>
}
}
}
templ History(ctx context.Context, data HistoryData) {
@LayoutWithContext("Transfer History", ctx) {
<div id="history-page" class="py-6">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex items-center justify-between mb-8">
<h1 class="text-3xl font-bold text-secondary-900 dark:text-secondary-100">
<i class="fas fa-history mr-2 text-primary-600 dark:text-primary-400"></i>
Transfer History
</h1>
<div class="text-sm text-secondary-500 dark:text-secondary-400">
<span class="font-medium">Total: { fmt.Sprint(data.Total) } transfers</span>
</div>
</div>
<!-- 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">
<form hx-get="/history" hx-target="#history-content" hx-indicator="#search-indicator" class="w-full sm:w-auto mb-4 sm:mb-0">
<div class="flex">
<div class="relative flex-grow">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<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>
<button
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"
>
<span>Search</span>
<span id="search-indicator" class="htmx-indicator ml-1">
<i class="fas fa-spinner fa-spin"></i>
</span>
</button>
if data.SearchTerm != "" {
<button
hx-get="/history"
hx-target="#history-content"
hx-indicator="#clear-indicator"
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"
>
<i class="fas fa-times mr-1"></i>
Clear
<span id="clear-indicator" class="htmx-indicator ml-1">
<i class="fas fa-spinner fa-spin"></i>
</span>
</button>
}
</div>
</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 id="history-content" class="mt-6">
@HistoryContent(ctx, data)
</div>
</div>
</div>
}
}
templ pageNumbers(currentPage int, totalPages int, pageSize int, searchTerm string) {
// Show at most 5 page numbers with the current page in the middle when possible
{{startPage := max(1, currentPage-2)}}
{{endPage := min(totalPages, startPage+4)}}
// Adjust startPage if we're near the end
if endPage - startPage < 4 && startPage > 1 {
startPage = max(1, endPage-4)
}
for i := startPage; i <= endPage; i++ {
if i == currentPage {
<span aria-current="page" class="relative inline-flex items-center px-4 py-2 border border-primary-500 bg-primary-50 dark:bg-primary-900/30 text-sm font-medium text-primary-600 dark:text-primary-400">
{ fmt.Sprint(i) }
</span>
} else {
<button
hx-get="/history"
hx-target="#history-content"
hx-vals={ fmt.Sprintf(`{"page": %d, "pageSize": %d, "search": "%s"}`, i, pageSize, searchTerm) }
hx-indicator="#page-indicator"
class="relative inline-flex items-center px-4 py-2 border border-secondary-300 dark:border-secondary-600 bg-white dark:bg-secondary-700 text-sm font-medium text-secondary-700 dark:text-secondary-300 hover:bg-secondary-50 dark:hover:bg-secondary-600 transition-colors">
{ fmt.Sprint(i) }
<span id="page-indicator" class="htmx-indicator ml-1">
<i class="fas fa-spinner fa-spin"></i>
</span>
</button>
}
}
}
func formatBytes(bytes int64) string {
const unit = 1024
if bytes < unit {
return fmt.Sprintf("%d B", bytes)
}
div, exp := int64(unit), 0
for n := bytes / unit; n >= unit; n /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp])
}
func max(x, y int) int {
if x > y {
return x
}
return y
}
+135
View File
@@ -0,0 +1,135 @@
package components
import (
"context"
)
templ Home(ctx context.Context) {
@LayoutWithContext("Home", ctx) {
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div class="text-center">
<h1 class="text-4xl font-bold text-secondary-900 dark:text-secondary-100 mb-4">
<i class="fas fa-exchange-alt text-primary-600 dark:text-primary-400 mr-3"></i>
Welcome to GoMFT
</h1>
<p class="text-xl text-secondary-600 dark:text-secondary-300 mb-12">A modern managed file transfer solution</p>
if isLoggedIn(ctx) {
<div class="space-y-8">
<!-- Feature Cards -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<div class="card hover:shadow-lg transition-shadow duration-300">
<div class="p-6">
<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">
<i class="fas fa-cogs text-primary-600 dark:text-primary-300 text-2xl"></i>
</div>
<h2 class="text-2xl font-semibold text-secondary-900 dark:text-secondary-100 mb-4 text-center">Transfer Configs</h2>
<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>
</div>
<!-- Quick Access Section -->
<div class="mt-12">
<h2 class="text-2xl font-bold text-secondary-900 dark:text-secondary-100 mb-6">
<i class="fas fa-bolt text-primary-600 dark:text-primary-400 mr-2"></i>
Quick Access
</h2>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<a href="/dashboard" class="card p-4 hover:bg-secondary-50 dark:hover:bg-secondary-800 flex items-center transition-colors">
<i class="fas fa-tachometer-alt text-primary-600 dark:text-primary-400 text-xl mr-3"></i>
<span class="text-secondary-900 dark:text-secondary-100">Dashboard</span>
</a>
<a href="/configs/new" class="card p-4 hover:bg-secondary-50 dark:hover:bg-secondary-800 flex items-center transition-colors">
<i class="fas fa-plus-circle text-primary-600 dark:text-primary-400 text-xl mr-3"></i>
<span class="text-secondary-900 dark:text-secondary-100">New Config</span>
</a>
<a href="/jobs/new" class="card p-4 hover:bg-secondary-50 dark:hover:bg-secondary-800 flex items-center transition-colors">
<i class="fas fa-play-circle text-primary-600 dark:text-primary-400 text-xl mr-3"></i>
<span class="text-secondary-900 dark:text-secondary-100">New Job</span>
</a>
<a href="/profile" class="card p-4 hover:bg-secondary-50 dark:hover:bg-secondary-800 flex items-center transition-colors">
<i class="fas fa-user-circle text-primary-600 dark:text-primary-400 text-xl mr-3"></i>
<span class="text-secondary-900 dark:text-secondary-100">Profile</span>
</a>
</div>
</div>
</div>
} else {
<div class="max-w-md mx-auto card p-8 hover:shadow-lg transition-shadow duration-300">
<div class="rounded-full bg-primary-100 dark:bg-primary-900 p-4 w-20 h-20 flex items-center justify-center mb-6 mx-auto">
<i class="fas fa-sign-in-alt text-primary-600 dark:text-primary-300 text-3xl"></i>
</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 -->
<div class="mt-12 pt-8 border-t border-secondary-200 dark:border-secondary-700">
<h3 class="text-lg font-medium text-secondary-900 dark:text-secondary-100 mb-4">Key Features</h3>
<ul class="space-y-3 text-left">
<li class="flex items-start">
<i class="fas fa-check-circle text-green-500 mt-1 mr-2"></i>
<span class="text-secondary-600 dark:text-secondary-400">Secure file transfers with encryption</span>
</li>
<li class="flex items-start">
<i class="fas fa-check-circle text-green-500 mt-1 mr-2"></i>
<span class="text-secondary-600 dark:text-secondary-400">Automated scheduling and monitoring</span>
</li>
<li class="flex items-start">
<i class="fas fa-check-circle text-green-500 mt-1 mr-2"></i>
<span class="text-secondary-600 dark:text-secondary-400">Comprehensive transfer history tracking</span>
</li>
<li class="flex items-start">
<i class="fas fa-check-circle text-green-500 mt-1 mr-2"></i>
<span class="text-secondary-600 dark:text-secondary-400">User-friendly interface with dark mode support</span>
</li>
</ul>
</div>
</div>
}
</div>
</div>
}
}
+291
View File
@@ -0,0 +1,291 @@
package components
import (
"fmt"
"github.com/starfleetcptn/gomft/internal/db"
"context"
)
type JobFormData struct {
Job *db.Job
Configs []db.TransferConfig
IsNew bool
}
func getJobFormTitle(isNew bool) string {
if isNew {
return "New Job"
}
return "Edit Job"
}
func getJobTitle(isNew bool) string {
if isNew {
return "Create New Job"
}
return "Edit Job"
}
templ JobForm(ctx context.Context, data JobFormData) {
@LayoutWithContext(getJobFormTitle(data.IsNew), 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="max-w-3xl w-full">
<div class="card overflow-hidden shadow-lg">
<div class="p-8">
<div class="text-center mb-8">
<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-calendar-alt text-primary-600 dark:text-primary-400 text-3xl"></i>
</div>
<h2 class="text-3xl font-bold text-secondary-900 dark:text-secondary-100">
{ getJobTitle(data.IsNew) }
</h2>
<p class="mt-2 text-secondary-600 dark:text-secondary-400">Configure your scheduled transfer job</p>
</div>
if data.IsNew {
<form
class="space-y-6"
hx-post="/jobs"
hx-target="body"
hx-boost="true"
@htmx:before-request="loading = true"
@htmx:after-request="loading = false"
@htmx:response-error="$dispatch('notification', { message: 'Failed to create job: ' + event.detail.xhr.responseText, type: 'error' })"
x-data="{ name: '', configId: '', schedule: '', enabled: true, loading: false, validate() { return this.configId && this.schedule; } }">
<div class="space-y-6">
<div>
<label for="name" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Job Name</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-tag text-secondary-400 dark:text-secondary-600"></i>
</div>
<input
type="text"
name="name"
id="name"
x-model="name"
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="Daily Production Backup"/>
</div>
<p class="mt-2 text-sm text-secondary-500 dark:text-secondary-400">
<i class="fas fa-info-circle mr-1"></i>
Descriptive name for this job (optional). If not provided, the config name will be used.
</p>
</div>
<div>
<label for="config_id" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Transfer Configuration</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-cog text-secondary-400 dark:text-secondary-600"></i>
</div>
<select
id="config_id"
name="config_id"
x-model="configId"
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">
<option value="">Select a configuration</option>
for _, config := range data.Configs {
<option value={ fmt.Sprint(config.ID) }>{ config.Name }</option>
}
</select>
</div>
</div>
<div>
<label for="schedule" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Schedule (Cron Expression)</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-clock text-secondary-400 dark:text-secondary-600"></i>
</div>
<input
type="text"
name="schedule"
id="schedule"
x-model="schedule"
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="*/15 * * * *"/>
</div>
<p class="mt-2 text-sm text-secondary-500 dark:text-secondary-400">
<i class="fas fa-info-circle mr-1"></i>
Use standard cron expression format. Example: */15 * * * * (every 15 minutes)
</p>
</div>
<div class="bg-secondary-50 dark:bg-secondary-800 p-4 rounded-lg border border-secondary-200 dark:border-secondary-700">
<div class="flex items-center">
<input
type="checkbox"
id="enabled"
x-model="enabled"
class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-secondary-300 dark:border-secondary-700 rounded"
checked/>
<input type="hidden" name="enabled" :value="enabled.toString()"/>
<label for="enabled" class="ml-2 block text-sm font-medium text-secondary-700 dark:text-secondary-300">Enable this job</label>
</div>
<p class="mt-2 text-sm text-secondary-500 dark:text-secondary-400">
<i class="fas fa-exclamation-triangle mr-1 text-amber-500"></i>
Disabled jobs will not run automatically.
</p>
</div>
</div>
<div class="pt-5 flex justify-end space-x-3">
<a href="/jobs" class="btn-secondary flex items-center justify-center px-4 py-2">
<i class="fas fa-times mr-2"></i>
Cancel
</a>
<button
type="submit"
class="btn-primary flex items-center justify-center px-4 py-2"
x-bind:disabled="!validate() || loading">
<span x-show="!loading" class="flex items-center">
<i class="fas fa-plus mr-2"></i>
Create Job
</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>
} else {
<form
class="space-y-6"
hx-post={ fmt.Sprintf("/jobs/%d", data.Job.ID) }
hx-target="body"
hx-boost="true"
@htmx:before-request="loading = true"
@htmx:after-request="loading = false"
@htmx:response-error="$dispatch('notification', { message: 'Failed to update job: ' + event.detail.xhr.responseText, type: 'error' })"
x-data={ fmt.Sprintf("{ name: '%s', configId: '%d', schedule: '%s', enabled: %v, loading: false, validate() { return this.configId && this.schedule; } }", data.Job.Name, data.Job.ConfigID, data.Job.Schedule, data.Job.Enabled) }>
<div class="space-y-6">
<div>
<label for="name" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Job Name</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-tag text-secondary-400 dark:text-secondary-600"></i>
</div>
<input
type="text"
name="name"
id="name"
x-model="name"
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="Daily Production Backup"/>
</div>
<p class="mt-2 text-sm text-secondary-500 dark:text-secondary-400">
<i class="fas fa-info-circle mr-1"></i>
Descriptive name for this job (optional). If not provided, the config name will be used.
</p>
</div>
<div>
<label for="config_id" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Transfer Configuration</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-cog text-secondary-400 dark:text-secondary-600"></i>
</div>
<select
id="config_id"
name="config_id"
x-model="configId"
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">
<option value="">Select a configuration</option>
for _, config := range data.Configs {
<option value={ fmt.Sprint(config.ID) } if data.Job != nil && data.Job.ConfigID == config.ID { selected }>{ config.Name }</option>
}
</select>
</div>
</div>
<div>
<label for="schedule" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Schedule (Cron Expression)</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-clock text-secondary-400 dark:text-secondary-600"></i>
</div>
<input
type="text"
name="schedule"
id="schedule"
x-model="schedule"
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="*/15 * * * *"/>
</div>
<p class="mt-2 text-sm text-secondary-500 dark:text-secondary-400">
<i class="fas fa-info-circle mr-1"></i>
Use standard cron expression format. Example: */15 * * * * (every 15 minutes)
</p>
</div>
<div class="bg-secondary-50 dark:bg-secondary-800 p-4 rounded-lg border border-secondary-200 dark:border-secondary-700">
<div class="flex items-center">
<input
type="checkbox"
id="enabled"
x-model="enabled"
class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-secondary-300 dark:border-secondary-700 rounded"/>
<input type="hidden" name="enabled" :value="enabled.toString()"/>
<label for="enabled" class="ml-2 block text-sm font-medium text-secondary-700 dark:text-secondary-300">Enable this job</label>
</div>
<p class="mt-2 text-sm text-secondary-500 dark:text-secondary-400">
<i class="fas fa-exclamation-triangle mr-1 text-amber-500"></i>
Disabled jobs will not run automatically.
</p>
</div>
</div>
<div class="pt-5 flex justify-end space-x-3">
<a href="/jobs" class="btn-secondary flex items-center justify-center px-4 py-2">
<i class="fas fa-times mr-2"></i>
Cancel
</a>
<button
type="submit"
class="btn-primary flex items-center justify-center px-4 py-2"
x-bind:disabled="!validate() || loading">
<span x-show="!loading" class="flex items-center">
<i class="fas fa-save mr-2"></i>
Save Changes
</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 class="px-8 py-4 bg-secondary-50 dark:bg-secondary-800 border-t border-secondary-200 dark:border-secondary-700 text-center">
<p class="text-sm text-secondary-600 dark:text-secondary-400">
<i class="fas fa-info-circle mr-1"></i>
Jobs will run according to their schedule and execute the selected transfer configuration
</p>
</div>
</div>
<!-- Help Notice -->
<div class="mt-8 text-center">
<div class="inline-flex items-center text-sm text-secondary-500 dark:text-secondary-400">
<i class="fas fa-question-circle mr-2 text-primary-500"></i>
<span>Need help with cron expressions? Try <a href="https://crontab.guru/" target="_blank" class="text-primary-600 hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300">crontab.guru</a></span>
</div>
</div>
</div>
</div>
}
}
+251
View File
@@ -0,0 +1,251 @@
package components
import (
"context"
"fmt"
"github.com/starfleetcptn/gomft/internal/db"
"time"
)
type JobRunDetailsData struct {
JobHistory db.JobHistory
Job db.Job
Config db.TransferConfig
}
templ JobRunDetails(ctx context.Context, data JobRunDetailsData) {
@LayoutWithContext("Job Run Details", ctx) {
<div class="py-6">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="mb-6">
<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-1"></i> Back to Dashboard
</a>
</div>
<div class="flex items-center justify-between mb-8">
<h1 class="text-3xl font-bold text-secondary-900 dark:text-secondary-100">
<i class="fas fa-file-alt mr-2 text-primary-600 dark:text-primary-400"></i>
Job Run Details
</h1>
</div>
<!-- Job Run Information Card -->
<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">
<div class="flex items-center justify-between">
<h3 class="text-lg leading-6 font-medium text-secondary-900 dark:text-secondary-100">
if data.Job.Name != "" {
{ data.Job.Name }
} else {
{ data.Config.Name }
}
</h3>
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">
<i class="fas fa-check mr-1"></i> Completed
</span>
} 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">
<i class="fas fa-times mr-1"></i> Failed
</span>
} 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">
<i class="fas fa-sync-alt mr-1"></i> { data.JobHistory.Status }
</span>
}
</div>
if data.Job.Name != "" && data.Job.Name != data.Config.Name {
<p class="mt-1 text-sm text-secondary-500 dark:text-secondary-400">
Config: { data.Config.Name }
</p>
}
</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 lg:grid-cols-3">
<div class="sm:col-span-1">
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">
<i class="fas fa-calendar-alt mr-1"></i> Start Time
</dt>
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100">
{ data.JobHistory.StartTime.Format("Jan 02, 2006 15:04:05") }
</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="text-secondary-500 dark:text-secondary-400">Still running...</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 {
{ formatDuration(data.JobHistory.EndTime.Sub(data.JobHistory.StartTime)) }
} else {
{ formatDuration(time.Since(data.JobHistory.StartTime)) } (ongoing)
}
</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.Sprint(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">
if data.Config.SourceType == "sftp" {
{ data.Config.SourceUser } `@` { data.Config.SourceHost }:{ data.Config.SourcePath }
} else {
{ 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">
if data.Config.DestinationType == "sftp" {
data.Config.DestUser@data.Config.DestHost:data.Config.DestinationPath
} else {
{ 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>
if data.Config.ArchiveEnabled {
<div class="sm:col-span-1">
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">Archive 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.ArchivePath }
</dd>
</div>
}
</dl>
</div>
</div>
<!-- Error Information (if any) -->
if 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 Details
</h3>
</div>
<div class="px-4 py-5 sm:p-6 bg-red-50 dark:bg-red-900/20">
<pre class="text-sm text-red-600 dark:text-red-400 whitespace-pre-wrap font-mono">{ data.JobHistory.ErrorMessage }</pre>
</div>
</div>
}
<!-- Action Buttons -->
<div class="flex flex-col sm:flex-row gap-4 mt-8">
<a href="/jobs" class="btn-secondary text-center 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="btn-primary text-center flex items-center justify-center">
<i class="fas fa-edit mr-2"></i>
Edit Job
</a>
</div>
</div>
</div>
}
}
// formatDuration formats a duration in a human-readable way
func formatDuration(d time.Duration) string {
d = d.Round(time.Second)
h := d / time.Hour
d -= h * time.Hour
m := d / time.Minute
d -= m * time.Minute
s := d / time.Second
if h > 0 {
return fmt.Sprintf("%dh %dm %ds", h, m, s)
}
if m > 0 {
return fmt.Sprintf("%dm %ds", m, s)
}
return fmt.Sprintf("%ds", s)
}
+131
View File
@@ -0,0 +1,131 @@
package components
import (
"context"
"fmt"
"github.com/starfleetcptn/gomft/internal/db"
)
type JobsData struct {
Jobs []db.Job
}
templ Jobs(ctx context.Context, data JobsData) {
@LayoutWithContext("Transfer Jobs", ctx) {
<div class="py-6">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center mb-8">
<h1 class="text-3xl font-bold text-secondary-900 dark:text-secondary-100">
<i class="fas fa-exchange-alt mr-2 text-primary-600 dark:text-primary-400"></i>
Transfer Jobs
</h1>
<a href="/jobs/new" class="btn-primary">
<i class="fas fa-plus mr-2"></i>
New Job
</a>
</div>
<div class="mt-6">
if len(data.Jobs) == 0 {
<div class="card 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">
<i class="fas fa-tasks text-secondary-400 dark:text-secondary-500 text-3xl"></i>
</div>
<h3 class="mt-2 text-lg font-medium text-secondary-900 dark:text-secondary-100">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>
<div class="mt-6">
<a href="/jobs/new" class="btn-primary">
<i class="fas fa-plus mr-2"></i>
New Job
</a>
</div>
</div>
} else {
<div class="card overflow-hidden">
<ul role="list" class="divide-y divide-secondary-200 dark:divide-secondary-700">
for _, job := range data.Jobs {
<li>
<div class="block hover:bg-secondary-50 dark:hover:bg-secondary-750 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-primary-600 dark:text-primary-400 truncate">
if job.Name != "" {
{ job.Name }
} else {
{ job.Config.Name }
}
</p>
if job.Enabled {
<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">
<i class="fas fa-check-circle mr-1"></i>
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">
<i class="fas fa-pause-circle mr-1"></i>
Inactive
</span>
}
</div>
<div class="ml-2 flex-shrink-0 flex space-x-2">
<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>
<button
hx-delete={ fmt.Sprintf("/jobs/%d", job.ID) }
hx-confirm="Are you sure you want to delete this job?"
hx-target="closest li"
hx-swap="outerHTML"
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-cog flex-shrink-0 mr-1.5 h-5 w-5 text-secondary-400 dark:text-secondary-500"></i>
Config: { job.Config.Name }
</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>
}
</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>
</li>
}
</ul>
</div>
}
</div>
<!-- Help Notice -->
<div class="mt-8 text-center">
<p class="text-sm text-secondary-500 dark:text-secondary-400">
<i class="fas fa-info-circle mr-1 text-primary-500"></i>
Transfer jobs run according to their schedule and transfer files between configured sources and destinations
</p>
</div>
</div>
</div>
}
}
+398
View File
@@ -0,0 +1,398 @@
package components
import (
"context"
"github.com/gin-gonic/gin"
)
// CreateTemplateContext creates a new context with user information from Gin's context
func CreateTemplateContext(c *gin.Context) context.Context {
ctx := context.Background()
if userID, exists := c.Get("userID"); exists {
ctx = context.WithValue(ctx, "userID", userID)
}
if username, exists := c.Get("username"); exists {
ctx = context.WithValue(ctx, "username", username)
}
if email, exists := c.Get("email"); exists {
ctx = context.WithValue(ctx, "email", email)
}
if isAdmin, exists := c.Get("isAdmin"); exists {
ctx = context.WithValue(ctx, "isAdmin", isAdmin)
}
return ctx
}
templ Layout(title string) {
@LayoutWithContext(title, context.Background())
}
templ LayoutWithContext(title string, ctx context.Context) {
<!DOCTYPE html>
<html lang="en" class="light h-full">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<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="16x16" href="/static/favicon-16x16.png">
<link rel="manifest" href="/static/site.webmanifest">
<title>{ title } - GoMFT</title>
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<script src="https://unpkg.com/alpinejs@3.13.5/dist/cdn.min.js" defer></script>
<script src="https://cdn.tailwindcss.com"></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="/static/css/app.css"/>
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
colors: {
primary: {
50: '#f0f9ff',
100: '#e0f2fe',
200: '#bae6fd',
300: '#7dd3fc',
400: '#38bdf8',
500: '#0ea5e9',
600: '#0284c7',
700: '#0369a1',
800: '#075985',
900: '#0c4a6e',
950: '#082f49',
},
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>
<style type="text/tailwindcss">
@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 {
from { opacity: 0; }
to { opacity: 1; }
}
.animate-fadeIn {
animation: fadeIn 0.3s ease-in-out;
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: theme('colors.secondary.100');
border-radius: 4px;
}
.dark ::-webkit-scrollbar-track {
background: theme('colors.secondary.800');
}
::-webkit-scrollbar-thumb {
background: theme('colors.secondary.300');
border-radius: 4px;
}
.dark ::-webkit-scrollbar-thumb {
background: theme('colors.secondary.600');
}
::-webkit-scrollbar-thumb:hover {
background: theme('colors.secondary.400');
}
.dark ::-webkit-scrollbar-thumb:hover {
background: theme('colors.secondary.500');
}
</style>
<link rel="stylesheet" href="https://rsms.me/inter/inter.css">
</head>
<body class="min-h-screen flex flex-col bg-secondary-50 dark:bg-secondary-900 h-full">
if isLoggedIn(ctx) {
<nav class="bg-white dark:bg-secondary-800 shadow-sm sticky top-0 z-10">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-16">
<div class="flex">
<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>
GoMFT
</a>
<div class="hidden sm:ml-6 sm:flex sm:space-x-8">
<a href="/dashboard" class="nav-link">
<i class="fas fa-tachometer-alt mr-1"></i> Dashboard
</a>
<a href="/configs" class="nav-link">
<i class="fas fa-cogs mr-1"></i> Configs
</a>
<a href="/jobs" class="nav-link">
<i class="fas fa-tasks mr-1"></i> Jobs
</a>
<a href="/history" class="nav-link">
<i class="fas fa-history mr-1"></i> History
</a>
if isAdmin(ctx) {
<a href="/admin/users" class="nav-link">
<i class="fas fa-users mr-1"></i> Users
</a>
<a href="/admin/tools" class="nav-link">
<i class="fas fa-tools mr-1"></i> Admin Tools
</a>
}
</div>
</div>
<div class="flex items-center space-x-4">
<!-- Theme Toggle -->
<button
id="theme-toggle"
type="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"
onclick="toggleTheme()"
>
<i class="fas fa-sun hidden dark:block"></i>
<i class="fas fa-moon block dark:hidden"></i>
</button>
<!-- 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>
</nav>
}
<main class="flex-grow w-full max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8 animate-fadeIn">
{ children... }
</main>
<footer class="bg-white dark:bg-secondary-800 shadow-inner mt-auto w-full">
<div class="max-w-7xl mx-auto py-4 px-4 sm:px-6 lg:px-8">
<p class="text-center text-sm text-secondary-500 dark:text-secondary-400">
GoMFT &copy; { getCurrentYear() } | Secure File Transfer Solution
</p>
</div>
</footer>
</body>
</html>
}
// Helper function to check if user is admin
func isAdmin(ctx context.Context) bool {
// Try as bool first
if admin, ok := ctx.Value("isAdmin").(bool); ok {
return admin
}
// Try as interface{} (from JWT claims)
if admin, ok := ctx.Value("isAdmin").(interface{}); ok {
if boolVal, ok := admin.(bool); ok {
return boolVal
}
}
return false
}
// Helper function to check if user is logged in
func isLoggedIn(ctx context.Context) bool {
// First try as uint
if userID, ok := ctx.Value("userID").(uint); ok && userID > 0 {
return true
}
// Then try as float64 (from JWT claims)
if userID, ok := ctx.Value("userID").(float64); ok && userID > 0 {
return true
}
return false
}
// Helper function to get user initial for avatar
func getUserInitial(ctx context.Context) string {
// Try as string first
if username, ok := ctx.Value("username").(string); ok && username != "" {
return string(username[0])
}
// Try as interface{} (from JWT claims)
if username, ok := ctx.Value("username").(interface{}); ok {
if strVal, ok := username.(string); ok && strVal != "" {
return string(strVal[0])
}
}
// Try email as fallback
if email, ok := ctx.Value("email").(string); ok && email != "" {
return string(email[0])
}
return "U"
}
// Helper function to get user email
func getUserEmail(ctx context.Context) string {
// Try as string first
if email, ok := ctx.Value("email").(string); ok && email != "" {
return email
}
// Try as interface{} (from JWT claims)
if email, ok := ctx.Value("email").(interface{}); ok {
if strVal, ok := email.(string); ok && strVal != "" {
return strVal
}
}
return "user@example.com"
}
// Helper function to get current year
func getCurrentYear() string {
return "2025"
}
+142
View File
@@ -0,0 +1,142 @@
package components
import (
"context"
"strings"
)
templ Login(ctx context.Context, errorMessage string) {
@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="max-w-md w-full">
<div class="card overflow-hidden shadow-lg">
<div class="p-8">
<div class="text-center mb-8">
<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>
</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>
if errorMessage != "" {
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 class="px-8 py-4 bg-secondary-50 dark:bg-secondary-800 border-t border-secondary-200 dark:border-secondary-700 text-center">
<p class="text-sm text-secondary-600 dark:text-secondary-400">
<i class="fas fa-info-circle mr-1"></i>
Contact an administrator to create an account
</p>
</div>
</div>
<!-- Security Notice -->
<div class="mt-8 text-center">
<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>
<span>Secure, encrypted connection</span>
</div>
</div>
</div>
</div>
}
}
+235
View File
@@ -0,0 +1,235 @@
package components
import (
"context"
"github.com/starfleetcptn/gomft/internal/db"
)
templ Profile(ctx context.Context, user db.User) {
@LayoutWithContext("Profile", ctx) {
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div class="flex items-center justify-between mb-6">
<h1 class="text-3xl font-bold text-secondary-900 dark:text-secondary-100">
<i class="fas fa-user-circle mr-2 text-primary-600 dark:text-primary-400"></i>
Profile
</h1>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
<!-- Profile Information Card -->
<div class="card">
<div class="card-header">
<h3 class="text-lg font-medium text-secondary-900 dark:text-secondary-100">
<i class="fas fa-id-card mr-2 text-primary-500"></i>
Profile Information
</h3>
<p class="mt-1 text-sm text-secondary-500 dark:text-secondary-400">Personal details and application settings.</p>
</div>
<div class="card-body">
<dl class="space-y-6">
<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>
<dd class="text-sm text-secondary-900 dark:text-secondary-100 sm:w-2/3">{ user.Email }</dd>
</div>
<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>
<dd class="text-sm text-secondary-900 dark:text-secondary-100 sm:w-2/3">
if user.IsAdmin {
<span class="badge badge-success">
<i class="fas fa-user-shield mr-1"></i> Administrator
</span>
} else {
<span class="badge badge-info">
<i class="fas fa-user mr-1"></i> Regular User
</span>
}
</dd>
</div>
<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>
<dd class="text-sm text-secondary-900 dark:text-secondary-100 sm:w-2/3">
<form
hx-post="/profile/theme"
hx-swap="none"
class="flex items-center space-x-4">
<div class="flex items-center">
<input
type="radio"
id="theme-light"
name="theme"
value="light"
checked?={ user.Theme == "light" || user.Theme == "" }
hx-trigger="change"
hx-post="/profile/theme"
class="form-checkbox" />
<label for="theme-light" class="ml-2 block text-sm text-secondary-700 dark:text-secondary-300">
<i class="fas fa-sun mr-1"></i> Light
</label>
</div>
<div class="flex items-center">
<input
type="radio"
id="theme-dark"
name="theme"
value="dark"
checked?={ user.Theme == "dark" }
hx-trigger="change"
hx-post="/profile/theme"
class="form-checkbox" />
<label for="theme-dark" class="ml-2 block text-sm text-secondary-700 dark:text-secondary-300">
<i class="fas fa-moon mr-1"></i> Dark
</label>
</div>
<div class="flex items-center">
<input
type="radio"
id="theme-system"
name="theme"
value="system"
checked?={ user.Theme == "system" }
hx-trigger="change"
hx-post="/profile/theme"
class="form-checkbox" />
<label for="theme-system" class="ml-2 block text-sm text-secondary-700 dark:text-secondary-300">
<i class="fas fa-desktop mr-1"></i> System
</label>
</div>
</form>
</dd>
</div>
</dl>
</div>
</div>
<!-- Change Password Card -->
<div class="card">
<div class="card-header">
<h3 class="text-lg font-medium text-secondary-900 dark:text-secondary-100">
<i class="fas fa-key mr-2 text-primary-500"></i>
Change Password
</h3>
<p class="mt-1 text-sm text-secondary-500 dark:text-secondary-400">Update your password to keep your account secure.</p>
</div>
<div class="card-body">
<form
method="POST"
action="/change-password"
hx-post="/change-password"
hx-target="#password-result"
hx-swap="innerHTML"
hx-headers='{"X-Profile-Page": "true"}'
hx-indicator="#password-change-indicator"
class="space-y-4"
x-data="{
currentPassword: '',
newPassword: '',
confirmPassword: '',
loading: false,
validate() {
return this.currentPassword &&
this.newPassword &&
this.confirmPassword &&
this.newPassword === this.confirmPassword;
}
}"
@htmx:before-request="loading = true"
@htmx:after-request="loading = false">
<div id="password-result"></div>
<div>
<label for="current-password" 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
</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
type="password"
id="current-password"
name="current_password"
x-model="currentPassword"
class="form-input pl-10 w-full"
placeholder="••••••••"
required/>
</div>
</div>
<div>
<label for="new-password" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
<i class="fas fa-lock-open mr-1"></i> 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
type="password"
id="new-password"
name="new_password"
x-model="newPassword"
class="form-input pl-10 w-full"
placeholder="••••••••"
required/>
</div>
</div>
<div>
<label for="confirm-password" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
<i class="fas fa-check-double mr-1"></i> 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-key text-secondary-400 dark:text-secondary-600"></i>
</div>
<input
type="password"
id="confirm-password"
name="confirm_password"
x-model="confirmPassword"
class="form-input pl-10 w-full"
placeholder="••••••••"
required/>
</div>
</div>
<div>
<button
type="submit"
class="btn-primary w-full flex justify-center py-3"
x-bind:disabled="!validate() || loading">
<span x-show="!loading" class="flex items-center">
<i class="fas fa-save mr-2"></i>
Update 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 class="px-6 py-4 bg-secondary-50 dark:bg-secondary-800 border-t border-secondary-200 dark:border-secondary-700">
<div class="flex items-center text-sm text-secondary-500 dark:text-secondary-400">
<i class="fas fa-shield-alt mr-2 text-primary-500"></i>
<span>Password must be at least 8 characters with letters, numbers, and special characters</span>
</div>
</div>
</div>
</div>
<!-- Security Notice -->
<div class="mt-8 text-center">
<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>
<span>All profile changes are securely logged for your protection</span>
</div>
</div>
</div>
}
}
+105
View File
@@ -0,0 +1,105 @@
package components
import (
"context"
)
templ Register(ctx context.Context, errorMessage string) {
@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="max-w-md w-full space-y-8">
<div>
<h2 class="mt-6 text-center text-3xl font-extrabold text-gray-900">
Create a new user account
</h2>
<p class="mt-2 text-center text-sm text-gray-600">
Complete the form below to create a new user
</p>
</div>
if errorMessage != "" {
<div class="mt-4">
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative" role="alert">
<span class="block sm:inline">{ errorMessage }</span>
</div>
</div>
}
<form class="mt-8 space-y-6" hx-post="/register" hx-target="body"
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">
<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>
<button
type="submit"
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>
</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>
}
}
+286
View File
@@ -0,0 +1,286 @@
package components
import (
"github.com/starfleetcptn/gomft/internal/db"
"strconv"
"context"
)
type UsersData struct {
Users []db.User
}
templ Users(ctx context.Context, data UsersData) {
@LayoutWithContext("User Management", ctx) {
<div class="py-6">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center mb-8">
<h1 class="text-3xl font-bold text-secondary-900 dark:text-secondary-100">
<i class="fas fa-users mr-2 text-primary-600 dark:text-primary-400"></i>
User Management
</h1>
<a href="/admin/users/new" class="btn-primary">
<i class="fas fa-user-plus mr-2"></i>
Add User
</a>
</div>
<div class="mt-6">
if len(data.Users) == 0 {
<div class="card 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">
<i class="fas fa-user-slash text-secondary-400 dark:text-secondary-500 text-3xl"></i>
</div>
<h3 class="mt-2 text-lg font-medium text-secondary-900 dark:text-secondary-100">No users found</h3>
<p class="mt-1 text-sm text-secondary-500 dark:text-secondary-400">Get started by adding a new user.</p>
<div class="mt-6">
<a href="/admin/users/new" class="btn-primary">
<i class="fas fa-user-plus mr-2"></i>
Add User
</a>
</div>
</div>
} else {
<div class="card overflow-hidden">
<table class="min-w-full divide-y divide-secondary-200 dark:divide-secondary-700">
<thead class="bg-secondary-50 dark:bg-secondary-800">
<tr>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider">
<i class="fas fa-envelope mr-1"></i> Email
</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider">
<i class="fas fa-user-tag mr-1"></i> Role
</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider">
<i class="fas fa-calendar-plus mr-1"></i> Created
</th>
<th scope="col" class="relative px-6 py-3">
<span class="sr-only">Actions</span>
</th>
</tr>
</thead>
<tbody class="bg-white dark:bg-secondary-750 divide-y divide-secondary-200 dark:divide-secondary-700">
if len(data.Users) == 0 {
<tr>
<td colspan="4" class="px-6 py-4 whitespace-nowrap text-center text-sm text-secondary-500 dark:text-secondary-400">
No users found
</td>
</tr>
} else {
for _, user := range data.Users {
<tr class="hover:bg-secondary-50 dark:hover:bg-secondary-700 transition-colors">
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm font-medium text-secondary-900 dark:text-secondary-100">{ user.Email }</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
if user.IsAdmin {
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-primary-100 dark:bg-primary-900 text-primary-800 dark:text-primary-300">
<i class="fas fa-user-shield mr-1"></i> Admin
</span>
} else {
<span class="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">
<i class="fas fa-user mr-1"></i> User
</span>
}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-secondary-500 dark:text-secondary-400">
{ user.CreatedAt.Format("Jan 02, 2006") }
</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<button
class="btn-danger btn-sm"
hx-delete={ "/admin/users/" + strconv.Itoa(int(user.ID)) }
hx-confirm="Are you sure you want to delete this user? This action cannot be undone."
hx-target="body"
>
<i class="fas fa-trash-alt mr-1"></i> Delete
</button>
</td>
</tr>
}
}
</tbody>
</table>
</div>
}
</div>
<!-- Help Notice -->
<div class="mt-8 text-center">
<p class="text-sm text-secondary-500 dark:text-secondary-400">
<i class="fas fa-shield-alt mr-1 text-primary-500"></i>
User accounts provide secure access to the GoMFT application with role-based permissions
</p>
</div>
</div>
</div>
}
}
type UserFormData struct {
IsNew bool
ErrorMessage string
}
templ UserForm(ctx context.Context, data UserFormData) {
@LayoutWithContext("Add User", ctx) {
<div class="py-6">
<div class="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="mb-8">
<h1 class="text-3xl font-bold text-secondary-900 dark:text-secondary-100">
<i class="fas fa-user-plus mr-2 text-primary-600 dark:text-primary-400"></i>
Add New User
</h1>
</div>
<div class="card overflow-hidden">
<div class="p-6">
if data.ErrorMessage != "" {
<div class="mb-6">
<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" role="alert">
<div class="flex items-center">
<i class="fas fa-exclamation-circle mr-2"></i>
<span class="block sm:inline">{ data.ErrorMessage }</span>
</div>
</div>
</div>
}
<form
class="space-y-6"
hx-post="/admin/users"
hx-target="body"
x-data="{
email: '',
password: '',
confirmPassword: '',
isAdmin: false,
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>
<div class="mb-6">
<h3 class="text-lg font-medium text-secondary-900 dark:text-secondary-100">User Information</h3>
<p class="mt-1 text-sm text-secondary-500 dark:text-secondary-400">Create a new user account with appropriate permissions.</p>
</div>
<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</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
type="email"
name="email"
id="email"
x-model="email"
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="user@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
type="password"
name="password"
id="password"
x-model="password"
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="••••••••" />
</div>
</div>
<div>
<label for="confirm_password" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Confirm 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
type="password"
name="confirm_password"
id="confirm_password"
x-model="confirmPassword"
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="••••••••" />
<p class="mt-1 text-sm text-red-600 dark:text-red-400" x-show="confirmPassword && password !== confirmPassword">
<i class="fas fa-exclamation-triangle mr-1"></i>
Passwords do not match
</p>
</div>
</div>
<div class="pt-2">
<div class="relative flex items-start">
<div class="flex items-center h-5">
<input
id="is_admin"
name="is_admin"
type="checkbox"
x-model="isAdmin"
class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-secondary-300 dark:border-secondary-700 rounded" />
</div>
<div class="ml-3 text-sm">
<label for="is_admin" class="font-medium text-secondary-700 dark:text-secondary-300">Administrator</label>
<p class="text-secondary-500 dark:text-secondary-400">Grant administrative privileges to this user</p>
</div>
</div>
</div>
</div>
</div>
<div class="border-t border-secondary-200 dark:border-secondary-700 pt-5">
<div class="flex justify-end space-x-3">
<a href="/admin/users" class="btn-secondary">
<i class="fas fa-times mr-2"></i>
Cancel
</a>
<button
type="submit"
class="btn-primary"
x-bind:disabled="!validate() || loading">
<span x-show="!loading" class="flex items-center">
<i class="fas fa-user-plus mr-2"></i>
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>
<div class="px-6 py-4 bg-secondary-50 dark:bg-secondary-800 border-t border-secondary-200 dark:border-secondary-700">
<div class="flex items-center text-sm text-secondary-500 dark:text-secondary-400">
<i class="fas fa-shield-alt mr-2 text-primary-500"></i>
<span>User accounts provide secure access to the GoMFT application</span>
</div>
</div>
</div>
</div>
</div>
}
}