feat: Enhance user management and dashboard components

- Updated user management templates to improve user experience with a new layout and success/error message handling.
- Implemented password hashing for user creation and management, enhancing security.
- Refactored dashboard components to include better data presentation and responsiveness.
- Added dark mode support for various components, ensuring a consistent user interface across themes.
- Improved toast notification animations for better user feedback during actions.
This commit is contained in:
StarFleetCPTN
2025-03-23 11:42:16 -07:00
parent 3140d9e143
commit 30d0725331
13 changed files with 1656 additions and 761 deletions
+13 -3
View File
@@ -262,7 +262,7 @@ templ AdminDatabaseTools(ctx context.Context, backups []BackupFile) {
// Create toast element
const toast = document.createElement('div');
toast.id = 'toast-' + type + '-' + Date.now();
toast.className = 'flex items-center w-full max-w-xs p-4 mb-4 rounded-lg shadow text-gray-500 bg-white dark:text-gray-400 dark:bg-gray-800';
toast.className = 'flex items-center w-full max-w-xs p-4 mb-4 rounded-lg shadow text-gray-500 bg-white dark:text-gray-400 dark:bg-gray-800 transform translate-y-16 opacity-0 transition-all duration-300 ease-out';
toast.role = 'alert';
// Set toast content based on type
@@ -301,15 +301,25 @@ templ AdminDatabaseTools(ctx context.Context, backups []BackupFile) {
// Add toast to container
toastContainer.appendChild(toast);
// Trigger animation after a small delay to ensure the DOM has updated
setTimeout(() => {
toast.classList.remove('translate-y-16', 'opacity-0');
toast.classList.add('translate-y-0', 'opacity-100');
}, 10);
// Add event listener to close button
const closeButton = toast.querySelector('button[data-dismiss-target]');
closeButton.addEventListener('click', function() {
toast.remove();
// Animate out before removing
toast.classList.add('opacity-0', 'translate-y-4');
setTimeout(() => {
toast.remove();
}, 300);
});
// Auto-remove toast after 5 seconds
setTimeout(() => {
toast.classList.add('opacity-0', 'transition-opacity', 'duration-300');
toast.classList.add('opacity-0', 'translate-y-4');
setTimeout(() => {
toast.remove();
}, 300);
+50 -6
View File
@@ -94,14 +94,20 @@ templ AdminRoles(ctx context.Context, data RolesData) {
<div class="p-4 md:p-5 text-center">
<i class="fas fa-exclamation-triangle mx-auto mb-4 text-red-400 text-3xl"></i>
<h3 class="mb-5 text-lg font-normal text-gray-500 dark:text-gray-400">Are you sure you want to delete this role?</h3>
<form method="POST" id="delete-form" action="">
<button type="submit" class="text-white bg-red-600 hover:bg-red-800 focus:ring-4 focus:outline-none focus:ring-red-300 dark:focus:ring-red-800 font-medium rounded-lg text-sm inline-flex items-center px-5 py-2.5 text-center mr-2">
<div>
<button
id="confirm-delete-button"
type="button"
class="text-white bg-red-600 hover:bg-red-800 focus:ring-4 focus:outline-none focus:ring-red-300 dark:focus:ring-red-800 font-medium rounded-lg text-sm inline-flex items-center px-5 py-2.5 text-center mr-2">
Yes, delete it
</button>
<button type="button" data-modal-hide="confirm-delete-modal" class="text-gray-500 bg-white hover:bg-gray-100 focus:ring-4 focus:outline-none focus:ring-gray-200 rounded-lg border border-gray-200 text-sm font-medium px-5 py-2.5 hover:text-gray-900 focus:z-10 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-500 dark:hover:text-white dark:hover:bg-gray-600 dark:focus:ring-gray-600">
<button
type="button"
data-modal-hide="confirm-delete-modal"
class="text-gray-500 bg-white hover:bg-gray-100 focus:ring-4 focus:outline-none focus:ring-gray-200 rounded-lg border border-gray-200 text-sm font-medium px-5 py-2.5 hover:text-gray-900 focus:z-10 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-500 dark:hover:text-white dark:hover:bg-gray-600 dark:focus:ring-gray-600">
No, cancel
</button>
</form>
</div>
</div>
</div>
</div>
@@ -109,14 +115,52 @@ templ AdminRoles(ctx context.Context, data RolesData) {
<script>
document.addEventListener('DOMContentLoaded', function() {
let currentRoleId = null;
const modal = document.getElementById('confirm-delete-modal');
// Handle delete modal
const deleteButtons = document.querySelectorAll('[data-modal-target="confirm-delete-modal"]');
deleteButtons.forEach(button => {
button.addEventListener('click', function() {
const roleId = this.getAttribute('data-role-id');
document.getElementById('delete-form').action = `/admin/roles/${roleId}/delete`;
currentRoleId = this.getAttribute('data-role-id');
});
});
// Handle confirm delete button
const confirmDeleteButton = document.getElementById('confirm-delete-button');
confirmDeleteButton.addEventListener('click', function() {
if (currentRoleId) {
// Use fetch to send DELETE request
fetch(`/admin/roles/${currentRoleId}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json'
}
})
.then(response => {
if (response.ok) {
// Reload page on success
window.location.reload();
} else {
// Handle error
response.json().then(data => {
alert('Error: ' + (data.error || 'Failed to delete role'));
}).catch(() => {
alert('An error occurred while deleting the role');
});
}
})
.catch(error => {
console.error('Error:', error);
alert('An error occurred while deleting the role');
})
.finally(() => {
// Hide modal
const closeModalButton = document.querySelector('[data-modal-hide="confirm-delete-modal"]');
if (closeModalButton) closeModalButton.click();
});
}
});
});
</script>
}
+223 -201
View File
@@ -43,214 +43,216 @@ func GetRcloneVersion() string {
templ Dashboard(ctx context.Context, data DashboardData) {
@LayoutWithContext("Dashboard", ctx) {
<div class="p-4 md:p-6 2xl:p-10">
<!-- Page Header -->
<div class="flex flex-col md:flex-row items-start md:items-center justify-between mb-6 md:mb-8 gap-4">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
<i class="fas fa-chart-pie w-6 h-6 mr-2 text-blue-500 dark:text-blue-400"></i>
Dashboard
</h1>
<div class="bg-white dark:bg-gray-800 p-2 px-4 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
<span id="current-date" class="font-medium text-gray-700 dark:text-gray-300"></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 sm:grid-cols-2 lg:grid-cols-3 gap-4 mb-6">
<!-- Active Transfers -->
<div class="p-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 sm:p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Active Transfers</h3>
<div class="inline-flex items-center justify-center w-10 h-10 text-blue-600 bg-blue-100 rounded-lg dark:text-blue-300 dark:bg-blue-900">
<i class="fas fa-exchange-alt w-6 h-6 flex items-center justify-center"></i>
</div>
</div>
<div class="flex items-center">
<div class="flex-shrink-0">
<span class="text-4xl font-bold leading-none text-gray-900 dark:text-white">{ strconv.Itoa(data.ActiveTransfers) }</span>
</div>
<div class="ml-3 flex items-baseline text-sm font-medium">
<span class="text-gray-500 dark:text-gray-400">Currently in progress</span>
</div>
<div id="dashboard-container" style="min-height: 100vh;" class="bg-gray-50 dark:bg-gray-900">
<div class="pb-8 w-full">
<!-- Page Header -->
<div class="mb-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
<i class="fas fa-chart-pie w-6 h-6 mr-2 text-blue-500 dark:text-blue-400"></i>
Dashboard
</h1>
<div class="bg-white dark:bg-gray-800 p-2 px-4 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
<span id="current-date" class="font-medium text-gray-700 dark:text-gray-300"></span>
<script>
document.getElementById('current-date').textContent = new Date().toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
</script>
</div>
</div>
<!-- Completed Today -->
<div class="p-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 sm:p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Completed Today</h3>
<div class="inline-flex items-center justify-center w-10 h-10 text-green-600 bg-green-100 rounded-lg dark:text-green-300 dark:bg-green-900">
<i class="fas fa-check-circle w-6 h-6 flex items-center justify-center"></i>
</div>
</div>
<div class="flex items-center">
<div class="flex-shrink-0">
<span class="text-4xl font-bold leading-none text-gray-900 dark:text-white">{ strconv.Itoa(data.CompletedToday) }</span>
</div>
<div class="ml-3 flex items-baseline text-sm font-medium">
<span class="text-gray-500 dark:text-gray-400">Successfully completed</span>
</div>
</div>
</div>
<!-- Failed Transfers -->
<div class="p-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 sm:p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Failed Transfers</h3>
<div class="inline-flex items-center justify-center w-10 h-10 text-red-600 bg-red-100 rounded-lg dark:text-red-300 dark:bg-red-900">
<i class="fas fa-exclamation-circle w-6 h-6 flex items-center justify-center"></i>
</div>
</div>
<div class="flex items-center">
<div class="flex-shrink-0">
<span class="text-4xl font-bold leading-none text-gray-900 dark:text-white">{ strconv.Itoa(data.FailedTransfers) }</span>
</div>
<div class="ml-3 flex items-baseline text-sm font-medium">
<span class="text-gray-500 dark:text-gray-400">Require attention</span>
</div>
</div>
</div>
</div>
<!-- Main Content Area -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-4">
<!-- Recent Jobs Card -->
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800">
<div class="flex items-center justify-between px-4 py-4 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white flex items-center">
<i class="fas fa-history w-6 h-6 mr-2 text-blue-500 dark:text-blue-400 flex-shrink-0"></i>
Recent Jobs
</h3>
</div>
<div class="p-4">
if len(data.RecentJobs) == 0 {
<!-- Empty state with Flowbite styling -->
<div class="flex flex-col items-center justify-center py-8 px-4 text-center">
<div class="inline-flex h-16 w-16 flex-shrink-0 items-center justify-center rounded-full bg-gray-100 mb-4 dark:bg-gray-700">
<i class="fas fa-clipboard-list text-gray-500 dark:text-gray-400 text-3xl"></i>
</div>
<h3 class="mb-2 text-lg font-semibold text-gray-900 dark:text-white">No recent jobs found</h3>
<p class="text-gray-500 dark:text-gray-400 mb-4">Get started by creating your first job.</p>
<a href="/jobs/new" class="inline-flex items-center px-3 py-2 text-sm font-medium text-center text-white bg-blue-700 rounded-lg hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800">
<i class="fas fa-plus w-4 h-4 mr-2"></i>
Create your first job
</a>
<!-- Stats Overview Cards -->
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 mb-6">
<!-- Active Transfers -->
<div class="p-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 sm:p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Active Transfers</h3>
<div class="inline-flex items-center justify-center w-10 h-10 text-blue-600 bg-blue-100 rounded-lg dark:text-blue-300 dark:bg-blue-900">
<i class="fas fa-exchange-alt w-6 h-6 flex items-center justify-center"></i>
</div>
} else {
<div class="flow-root">
<ul class="divide-y divide-gray-200 dark:divide-gray-700">
for _, job := range data.RecentJobs {
<li class="py-3 sm:py-4 hover:bg-gray-50 dark:hover:bg-gray-700 px-3 rounded-lg transition-colors">
<div class="flex items-center">
<div class="flex-shrink-0">
if job.Status == "completed" {
<span class="inline-flex items-center justify-center h-12 w-12 rounded-full text-green-600 bg-green-100 dark:bg-green-900">
<i class="fas fa-check-circle w-6 h-6 flex items-center justify-center"></i>
</span>
} else if job.Status == "failed" {
<span class="inline-flex items-center justify-center h-12 w-12 rounded-full text-red-600 bg-red-100 dark:bg-red-900">
<i class="fas fa-exclamation-circle w-6 h-6 flex items-center justify-center"></i>
</span>
} else {
<span class="inline-flex items-center justify-center h-12 w-12 rounded-full text-blue-600 bg-blue-100 dark:bg-blue-900">
<i class="fas fa-tasks w-6 h-6 flex items-center justify-center"></i>
</span>
}
</div>
<div class="flex-1 min-w-0 ms-4">
<p class="text-sm font-medium text-gray-900 truncate dark:text-white">
{ getConfigNameForHistory(job, data.Configs) }
</p>
<div class="flex items-center mt-1">
<i class="fas fa-clock w-4 h-4 text-gray-500 dark:text-gray-400 mr-1"></i>
<p class="text-sm text-gray-500 truncate dark:text-gray-400">
Started: { job.StartTime.Format("Jan 02, 2006 15:04:05") }
</p>
</div>
<div class="flex items-center">
<div class="flex-shrink-0">
<span class="text-4xl font-bold leading-none text-gray-900 dark:text-white">{ strconv.Itoa(data.ActiveTransfers) }</span>
</div>
<div class="ml-3 flex items-baseline text-sm font-medium">
<span class="text-gray-500 dark:text-gray-400">Currently in progress</span>
</div>
</div>
</div>
<!-- Completed Today -->
<div class="p-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 sm:p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Completed Today</h3>
<div class="inline-flex items-center justify-center w-10 h-10 text-green-600 bg-green-100 rounded-lg dark:text-green-300 dark:bg-green-900">
<i class="fas fa-check-circle w-6 h-6 flex items-center justify-center"></i>
</div>
</div>
<div class="flex items-center">
<div class="flex-shrink-0">
<span class="text-4xl font-bold leading-none text-gray-900 dark:text-white">{ strconv.Itoa(data.CompletedToday) }</span>
</div>
<div class="ml-3 flex items-baseline text-sm font-medium">
<span class="text-gray-500 dark:text-gray-400">Successfully completed</span>
</div>
</div>
</div>
<!-- Failed Transfers -->
<div class="p-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 sm:p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Failed Transfers</h3>
<div class="inline-flex items-center justify-center w-10 h-10 text-red-600 bg-red-100 rounded-lg dark:text-red-300 dark:bg-red-900">
<i class="fas fa-exclamation-circle w-6 h-6 flex items-center justify-center"></i>
</div>
</div>
<div class="flex items-center">
<div class="flex-shrink-0">
<span class="text-4xl font-bold leading-none text-gray-900 dark:text-white">{ strconv.Itoa(data.FailedTransfers) }</span>
</div>
<div class="ml-3 flex items-baseline text-sm font-medium">
<span class="text-gray-500 dark:text-gray-400">Require attention</span>
</div>
</div>
</div>
</div>
<!-- Main Content Area -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-4">
<!-- Recent Jobs Card -->
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800">
<div class="flex items-center justify-between px-4 py-4 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white flex items-center">
<i class="fas fa-history w-6 h-6 mr-2 text-blue-500 dark:text-blue-400 flex-shrink-0"></i>
Recent Jobs
</h3>
</div>
<div class="p-4">
if len(data.RecentJobs) == 0 {
<!-- Empty state with Flowbite styling -->
<div class="flex flex-col items-center justify-center py-8 px-4 text-center">
<div class="inline-flex h-16 w-16 flex-shrink-0 items-center justify-center rounded-full bg-gray-100 mb-4 dark:bg-gray-700">
<i class="fas fa-clipboard-list text-gray-500 dark:text-gray-400 text-3xl"></i>
</div>
<h3 class="mb-2 text-lg font-semibold text-gray-900 dark:text-white">No recent jobs found</h3>
<p class="text-gray-500 dark:text-gray-400 mb-4">Get started by creating your first job.</p>
<a href="/jobs/new" class="inline-flex items-center px-3 py-2 text-sm font-medium text-center text-white bg-blue-700 rounded-lg hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800">
<i class="fas fa-plus w-4 h-4 mr-2"></i>
Create your first job
</a>
</div>
} else {
<div class="flow-root">
<ul class="divide-y divide-gray-200 dark:divide-gray-700">
for _, job := range data.RecentJobs {
<li class="py-3 sm:py-4 hover:bg-gray-50 dark:hover:bg-gray-700 px-3 rounded-lg transition-colors">
<div class="flex items-center">
<div class="flex-shrink-0">
if job.Status == "completed" {
<span class="inline-flex items-center justify-center h-12 w-12 rounded-full text-green-600 bg-green-100 dark:bg-green-900">
<i class="fas fa-check-circle w-6 h-6 flex items-center justify-center"></i>
</span>
} else if job.Status == "failed" {
<span class="inline-flex items-center justify-center h-12 w-12 rounded-full text-red-600 bg-red-100 dark:bg-red-900">
<i class="fas fa-exclamation-circle w-6 h-6 flex items-center justify-center"></i>
</span>
} else {
<span class="inline-flex items-center justify-center h-12 w-12 rounded-full text-blue-600 bg-blue-100 dark:bg-blue-900">
<i class="fas fa-tasks w-6 h-6 flex items-center justify-center"></i>
</span>
}
</div>
<div class="flex-1 min-w-0 ms-4">
<p class="text-sm font-medium text-gray-900 truncate dark:text-white">
{ getConfigNameForHistory(job, data.Configs) }
</p>
<div class="flex items-center mt-1">
<i class="fas fa-clock w-4 h-4 text-gray-500 dark:text-gray-400 mr-1"></i>
<p class="text-sm text-gray-500 truncate dark:text-gray-400">
Started: { job.StartTime.Format("Jan 02, 2006 15:04:05") }
</p>
</div>
</div>
<a href={ templ.SafeURL(fmt.Sprintf("/job-runs/%d", job.ID)) }
class="inline-flex items-center text-sm font-medium text-blue-600 hover:underline dark:text-blue-500">
View
<i class="fas fa-arrow-right w-3 h-3 ms-2"></i>
</a>
</div>
<a href={ templ.SafeURL(fmt.Sprintf("/job-runs/%d", job.ID)) }
class="inline-flex items-center text-sm font-medium text-blue-600 hover:underline dark:text-blue-500">
View
<i class="fas fa-arrow-right w-3 h-3 ms-2"></i>
</a>
</div>
</li>
}
</ul>
</div>
<div class="pt-4 border-t border-gray-200 dark:border-gray-700 mt-4">
<a href="/jobs" class="inline-flex items-center justify-center w-full px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-lg hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700">
<i class="fas fa-list w-4 h-4 mr-2"></i>
View all jobs
</a>
</div>
}
</li>
}
</ul>
</div>
<div class="pt-4 border-t border-gray-200 dark:border-gray-700 mt-4">
<a href="/jobs" class="inline-flex items-center justify-center w-full px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-lg hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700">
<i class="fas fa-list w-4 h-4 mr-2"></i>
View all jobs
</a>
</div>
}
</div>
</div>
</div>
<!-- Quick Actions Card -->
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800">
<div class="flex items-center justify-between px-4 py-4 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white flex items-center">
<i class="fas fa-bolt w-6 h-6 mr-2 text-blue-500 dark:text-blue-400 flex-shrink-0"></i>
Quick Actions
</h3>
</div>
<div class="p-4 space-y-4">
<a href="/configs/new" class="inline-flex items-center justify-center w-full text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg px-5 py-2.5 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800">
<i class="fas fa-plus w-5 h-5 mr-2"></i>
Create New Config
</a>
<a href="/jobs/new" class="inline-flex items-center justify-center w-full text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg px-5 py-2.5 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800">
<i class="fas fa-tasks w-5 h-5 mr-2"></i>
Create New Job
</a>
<a href="/history" class="inline-flex items-center justify-center w-full text-gray-900 bg-white border border-gray-300 focus:outline-none hover:bg-gray-100 focus:ring-4 focus:ring-gray-200 font-medium rounded-lg px-5 py-2.5 dark:bg-gray-800 dark:text-white dark:border-gray-600 dark:hover:bg-gray-700 dark:hover:border-gray-600 dark:focus:ring-gray-700">
<i class="fas fa-history w-5 h-5 mr-2"></i>
View Transfer History
</a>
<!-- System Status Card with Flowbite design -->
<div class="bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 shadow-sm mt-6">
<div class="px-4 py-2 bg-gray-50 dark:bg-gray-700 rounded-t-lg border-b border-gray-200 dark:border-gray-600">
<h4 class="text-sm font-medium text-gray-900 dark:text-white">System Status</h4>
</div>
<div class="px-4 py-3">
<div class="space-y-3">
<div class="flex items-center justify-between">
<span class="text-sm text-gray-700 dark:text-gray-300">Server</span>
<span class="bg-green-100 text-green-800 text-xs font-medium inline-flex items-center px-2.5 py-0.5 rounded-full dark:bg-green-900 dark:text-green-300">
<span class="w-2 h-2 mr-1 bg-green-500 rounded-full"></span>
Online
</span>
</div>
<div class="flex items-center justify-between">
<span class="text-sm text-gray-700 dark:text-gray-300">Scheduler</span>
<span class="bg-green-100 text-green-800 text-xs font-medium inline-flex items-center px-2.5 py-0.5 rounded-full dark:bg-green-900 dark:text-green-300">
<span class="w-2 h-2 mr-1 bg-green-500 rounded-full"></span>
Running
</span>
</div>
<div class="flex items-center justify-between">
<span class="text-sm text-gray-700 dark:text-gray-300">Database</span>
<span class="bg-green-100 text-green-800 text-xs font-medium inline-flex items-center px-2.5 py-0.5 rounded-full dark:bg-green-900 dark:text-green-300">
<span class="w-2 h-2 mr-1 bg-green-500 rounded-full"></span>
Connected
</span>
</div>
<div class="flex items-center justify-between">
<span class="text-sm text-gray-700 dark:text-gray-300">Rclone</span>
<span class="bg-blue-100 text-blue-800 text-xs font-medium inline-flex items-center px-2.5 py-0.5 rounded-full dark:bg-blue-900 dark:text-blue-300">
<i class="fas fa-terminal w-3 h-3 mr-1"></i>
if data.RcloneVersion != "" {
{ data.RcloneVersion }
} else {
Unknown
}
</span>
<!-- Quick Actions Card -->
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800">
<div class="flex items-center justify-between px-4 py-4 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white flex items-center">
<i class="fas fa-bolt w-6 h-6 mr-2 text-blue-500 dark:text-blue-400 flex-shrink-0"></i>
Quick Actions
</h3>
</div>
<div class="p-4 space-y-4">
<a href="/configs/new" class="inline-flex items-center justify-center w-full text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg px-5 py-2.5 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800">
<i class="fas fa-plus w-5 h-5 mr-2"></i>
Create New Config
</a>
<a href="/jobs/new" class="inline-flex items-center justify-center w-full text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg px-5 py-2.5 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800">
<i class="fas fa-tasks w-5 h-5 mr-2"></i>
Create New Job
</a>
<a href="/history" class="inline-flex items-center justify-center w-full text-gray-900 bg-white border border-gray-300 focus:outline-none hover:bg-gray-100 focus:ring-4 focus:ring-gray-200 font-medium rounded-lg px-5 py-2.5 dark:bg-gray-800 dark:text-white dark:border-gray-600 dark:hover:bg-gray-700 dark:hover:border-gray-600 dark:focus:ring-gray-700">
<i class="fas fa-history w-5 h-5 mr-2"></i>
View Transfer History
</a>
<!-- System Status Card with Flowbite design -->
<div class="bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 shadow-sm mt-6">
<div class="px-4 py-2 bg-gray-50 dark:bg-gray-700 rounded-t-lg border-b border-gray-200 dark:border-gray-600">
<h4 class="text-sm font-medium text-gray-900 dark:text-white">System Status</h4>
</div>
<div class="px-4 py-3">
<div class="space-y-3">
<div class="flex items-center justify-between">
<span class="text-sm text-gray-700 dark:text-gray-300">Server</span>
<span class="bg-green-100 text-green-800 text-xs font-medium inline-flex items-center px-2.5 py-0.5 rounded-full dark:bg-green-900 dark:text-green-300">
<span class="w-2 h-2 mr-1 bg-green-500 rounded-full"></span>
Online
</span>
</div>
<div class="flex items-center justify-between">
<span class="text-sm text-gray-700 dark:text-gray-300">Scheduler</span>
<span class="bg-green-100 text-green-800 text-xs font-medium inline-flex items-center px-2.5 py-0.5 rounded-full dark:bg-green-900 dark:text-green-300">
<span class="w-2 h-2 mr-1 bg-green-500 rounded-full"></span>
Running
</span>
</div>
<div class="flex items-center justify-between">
<span class="text-sm text-gray-700 dark:text-gray-300">Database</span>
<span class="bg-green-100 text-green-800 text-xs font-medium inline-flex items-center px-2.5 py-0.5 rounded-full dark:bg-green-900 dark:text-green-300">
<span class="w-2 h-2 mr-1 bg-green-500 rounded-full"></span>
Connected
</span>
</div>
<div class="flex items-center justify-between">
<span class="text-sm text-gray-700 dark:text-gray-300">Rclone</span>
<span class="bg-blue-100 text-blue-800 text-xs font-medium inline-flex items-center px-2.5 py-0.5 rounded-full dark:bg-blue-900 dark:text-blue-300">
<i class="fas fa-terminal w-3 h-3 mr-1"></i>
if data.RcloneVersion != "" {
{ data.RcloneVersion }
} else {
Unknown
}
</span>
</div>
</div>
</div>
</div>
@@ -258,6 +260,26 @@ templ Dashboard(ctx context.Context, data DashboardData) {
</div>
</div>
</div>
<script>
// Set dark background color if in dark mode
if (document.documentElement.classList.contains('dark')) {
document.getElementById('dashboard-container').style.backgroundColor = '#111827';
}
// Add event listener for theme changes
document.addEventListener('DOMContentLoaded', function() {
const themeToggle = document.getElementById('theme-toggle');
if (themeToggle) {
themeToggle.addEventListener('click', function() {
setTimeout(function() {
const isDark = document.documentElement.classList.contains('dark');
document.getElementById('dashboard-container').style.backgroundColor = isDark ? '#111827' : 'rgb(249, 250, 251)';
}, 50);
});
}
});
</script>
</div>
}
}
+343 -273
View File
@@ -341,17 +341,15 @@ templ FileMetadataListPartial(data FileMetadataListData) {
// Update the main FileMetadataList template to use the partial template
templ FileMetadataList(ctx context.Context, data FileMetadataListData) {
@LayoutWithContext("File Metadata", ctx) {
<div class="px-2 py-4 sm:px-4 w-full">
<div class="mb-6">
<div class="flex items-center justify-between mb-4">
<h1 class="text-2xl font-semibold text-gray-900 dark:text-white">
<div id="file-metadata-container" style="min-height: 100vh;" class="bg-gray-50 dark:bg-gray-900">
<div class="pb-8 w-full">
<div class="mb-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
<i class="fas fa-file-alt w-6 h-6 mr-2 text-blue-500"></i>
if data.Job != nil {
Files for Job: { data.Job.Name }
} else {
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
<i class="fas fa-file-alt w-6 h-6 mr-2 text-blue-500"></i>
File Metadata
</h1>
File Metadata
}
</h1>
<a href="/files/search" class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800">
@@ -425,6 +423,26 @@ templ FileMetadataList(ctx context.Context, data FileMetadataListData) {
</div>
</div>
</div>
<script>
// Set dark background color if in dark mode
if (document.documentElement.classList.contains('dark')) {
document.getElementById('file-metadata-container').style.backgroundColor = '#111827';
}
// Add event listener for theme changes
document.addEventListener('DOMContentLoaded', function() {
const themeToggle = document.getElementById('theme-toggle');
if (themeToggle) {
themeToggle.addEventListener('click', function() {
setTimeout(function() {
const isDark = document.documentElement.classList.contains('dark');
document.getElementById('file-metadata-container').style.backgroundColor = isDark ? '#111827' : 'rgb(249, 250, 251)';
}, 50);
});
}
});
</script>
</div>
}
}
@@ -432,213 +450,239 @@ templ FileMetadataList(ctx context.Context, data FileMetadataListData) {
// FileMetadataDetails renders detailed information about a file
templ FileMetadataDetails(ctx context.Context, data FileMetadataDetailsData) {
@LayoutWithContext("File Details", ctx) {
<div class="px-2 py-4 sm:px-4 w-full">
<div class="mb-6">
<a href="/files" class="inline-flex items-center text-blue-600 hover:underline dark:text-blue-500">
<i class="fas fa-arrow-left mr-2"></i> Back to Files
</a>
</div>
<div class="bg-white border border-gray-200 rounded-lg shadow dark:bg-gray-800 dark:border-gray-700 w-full">
<!-- Card header -->
<div class="p-4 md:p-5 border-b border-gray-200 dark:border-gray-700">
<h5 class="text-xl font-bold leading-none text-gray-900 dark:text-white">
{ data.File.FileName }
</h5>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
File ID: { strconv.FormatUint(uint64(data.File.ID), 10) }
</p>
<div id="file-details-container" style="min-height: 100vh;" class="bg-gray-50 dark:bg-gray-900">
<div class="pb-8 w-full">
<div class="mb-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
<i class="fas fa-file-alt w-6 h-6 mr-2 text-blue-500"></i>
File Details: { data.File.FileName }
</h1>
<a href="/files" class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800">
<i class="fas fa-arrow-left mr-2"></i> Back to Files
</a>
</div>
<!-- Card content -->
<div class="p-4 md:p-5">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- File Information -->
<div>
<h6 class="text-lg font-semibold mb-4 text-gray-900 dark:text-white flex items-center">
<i class="fas fa-file-alt mr-2 text-gray-500 dark:text-gray-400"></i> File Information
</h6>
<div class="overflow-x-auto relative shadow-md sm:rounded-lg">
<table class="w-full text-sm text-left text-gray-500 dark:text-gray-400">
<tbody>
<tr class="border-b dark:border-gray-700">
<th scope="row" class="py-3 px-4 font-medium text-gray-900 whitespace-nowrap dark:text-white bg-gray-50 dark:bg-gray-800">
Filename
</th>
<td class="py-3 px-4 bg-white dark:bg-gray-800">
{ data.File.FileName }
</td>
</tr>
<tr class="border-b dark:border-gray-700">
<th scope="row" class="py-3 px-4 font-medium text-gray-900 whitespace-nowrap dark:text-white bg-gray-50 dark:bg-gray-800">
Size
</th>
<td class="py-3 px-4 bg-white dark:bg-gray-800">
{ formatFileSize(data.File.FileSize) }
</td>
</tr>
<tr class="border-b dark:border-gray-700">
<th scope="row" class="py-3 px-4 font-medium text-gray-900 whitespace-nowrap dark:text-white bg-gray-50 dark:bg-gray-800">
Hash
</th>
<td class="py-3 px-4 break-all bg-white dark:bg-gray-800">
if data.File.FileHash != "" {
<span class="font-mono">{ data.File.FileHash }</span>
} else {
<span class="text-gray-400 dark:text-gray-500 italic">Not available</span>
}
</td>
</tr>
<tr class="border-b dark:border-gray-700">
<th scope="row" class="py-3 px-4 font-medium text-gray-900 whitespace-nowrap dark:text-white bg-gray-50 dark:bg-gray-800">
Status
</th>
<td class="py-3 px-4 bg-white dark:bg-gray-800">
<span class={ "text-xs font-medium px-2.5 py-0.5 rounded", getStatusBadgeClass(data.File.Status) }>
{ data.File.Status }
</span>
</td>
</tr>
<tr class="border-b dark:border-gray-700">
<th scope="row" class="py-3 px-4 font-medium text-gray-900 whitespace-nowrap dark:text-white bg-gray-50 dark:bg-gray-800">
Original Path
</th>
<td class="py-3 px-4 break-all bg-white dark:bg-gray-800">
<div class="flex items-center">
<i class="fas fa-folder mr-2 text-yellow-500"></i>
<span>{ data.File.OriginalPath }</span>
</div>
</td>
</tr>
<tr class="border-b dark:border-gray-700">
<th scope="row" class="py-3 px-4 font-medium text-gray-900 whitespace-nowrap dark:text-white bg-gray-50 dark:bg-gray-800">
Destination Path
</th>
<td class="py-3 px-4 break-all bg-white dark:bg-gray-800">
<div class="flex items-center">
<i class="fas fa-folder-open mr-2 text-blue-500"></i>
<span>{ data.File.DestinationPath }</span>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Processing Information -->
<div>
<h6 class="text-lg font-semibold mb-4 text-gray-900 dark:text-white flex items-center">
<i class="fas fa-cogs mr-2 text-gray-500 dark:text-gray-400"></i> Processing Information
</h6>
<div class="overflow-x-auto relative shadow-md sm:rounded-lg">
<table class="w-full text-sm text-left text-gray-500 dark:text-gray-400">
<tbody>
<tr class="border-b dark:border-gray-700">
<th scope="row" class="py-3 px-4 font-medium text-gray-900 whitespace-nowrap dark:text-white bg-gray-50 dark:bg-gray-800">
Job
</th>
<td class="py-3 px-4 bg-white dark:bg-gray-800">
<a href={ templ.SafeURL(fmt.Sprintf("/files/job/%d", data.File.JobID)) } class="font-medium text-blue-600 dark:text-blue-500 hover:underline">
{ data.File.Job.Name }
</a>
</td>
</tr>
<tr class="border-b dark:border-gray-700">
<th scope="row" class="py-3 px-4 font-medium text-gray-900 whitespace-nowrap dark:text-white bg-gray-50 dark:bg-gray-800">
Processed Time
</th>
<td class="py-3 px-4 bg-white dark:bg-gray-800">
<div class="flex items-center">
<i class="far fa-clock mr-2 text-gray-500"></i>
<span>{ data.File.ProcessedTime.Format("2006-01-02 15:04:05") }</span>
</div>
</td>
</tr>
<tr class="border-b dark:border-gray-700">
<th scope="row" class="py-3 px-4 font-medium text-gray-900 whitespace-nowrap dark:text-white bg-gray-50 dark:bg-gray-800">
Creation Time
</th>
<td class="py-3 px-4 bg-white dark:bg-gray-800">
<div class="flex items-center">
<i class="fas fa-calendar-plus mr-2 text-green-500"></i>
<span>{ data.File.CreationTime.Format("2006-01-02 15:04:05") }</span>
</div>
</td>
</tr>
<tr class="border-b dark:border-gray-700">
<th scope="row" class="py-3 px-4 font-medium text-gray-900 whitespace-nowrap dark:text-white bg-gray-50 dark:bg-gray-800">
Modification Time
</th>
<td class="py-3 px-4 bg-white dark:bg-gray-800">
<div class="flex items-center">
<i class="fas fa-calendar-alt mr-2 text-purple-500"></i>
<span>{ data.File.ModTime.Format("2006-01-02 15:04:05") }</span>
</div>
</td>
</tr>
if data.File.Status == "error" && data.File.ErrorMessage != "" {
<div class="bg-white border border-gray-200 rounded-lg shadow dark:bg-gray-800 dark:border-gray-700 w-full">
<!-- Card header -->
<div class="p-4 md:p-5 border-b border-gray-200 dark:border-gray-700">
<h5 class="text-xl font-bold leading-none text-gray-900 dark:text-white">
{ data.File.FileName }
</h5>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
File ID: { strconv.FormatUint(uint64(data.File.ID), 10) }
</p>
</div>
<!-- Card content -->
<div class="p-4 md:p-5">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- File Information -->
<div>
<h6 class="text-lg font-semibold mb-4 text-gray-900 dark:text-white flex items-center">
<i class="fas fa-file-alt mr-2 text-gray-500 dark:text-gray-400"></i> File Information
</h6>
<div class="overflow-x-auto relative shadow-md sm:rounded-lg">
<table class="w-full text-sm text-left text-gray-500 dark:text-gray-400">
<tbody>
<tr class="border-b dark:border-gray-700">
<th scope="row" class="py-3 px-4 font-medium text-gray-900 whitespace-nowrap dark:text-white bg-gray-50 dark:bg-gray-800">
Error
Filename
</th>
<td class="py-3 px-4 bg-white dark:bg-gray-800">
{ data.File.FileName }
</td>
</tr>
<tr class="border-b dark:border-gray-700">
<th scope="row" class="py-3 px-4 font-medium text-gray-900 whitespace-nowrap dark:text-white bg-gray-50 dark:bg-gray-800">
Size
</th>
<td class="py-3 px-4 bg-white dark:bg-gray-800">
{ formatFileSize(data.File.FileSize) }
</td>
</tr>
<tr class="border-b dark:border-gray-700">
<th scope="row" class="py-3 px-4 font-medium text-gray-900 whitespace-nowrap dark:text-white bg-gray-50 dark:bg-gray-800">
Hash
</th>
<td class="py-3 px-4 break-all bg-white dark:bg-gray-800">
<div class="flex items-start">
<i class="fas fa-exclamation-triangle mt-1 mr-2 text-red-500"></i>
<span class="text-red-600 dark:text-red-400">{ data.File.ErrorMessage }</span>
if data.File.FileHash != "" {
<span class="font-mono">{ data.File.FileHash }</span>
} else {
<span class="text-gray-400 dark:text-gray-500 italic">Not available</span>
}
</td>
</tr>
<tr class="border-b dark:border-gray-700">
<th scope="row" class="py-3 px-4 font-medium text-gray-900 whitespace-nowrap dark:text-white bg-gray-50 dark:bg-gray-800">
Status
</th>
<td class="py-3 px-4 bg-white dark:bg-gray-800">
<span class={ "text-xs font-medium px-2.5 py-0.5 rounded", getStatusBadgeClass(data.File.Status) }>
{ data.File.Status }
</span>
</td>
</tr>
<tr class="border-b dark:border-gray-700">
<th scope="row" class="py-3 px-4 font-medium text-gray-900 whitespace-nowrap dark:text-white bg-gray-50 dark:bg-gray-800">
Original Path
</th>
<td class="py-3 px-4 break-all bg-white dark:bg-gray-800">
<div class="flex items-center">
<i class="fas fa-folder mr-2 text-yellow-500"></i>
<span>{ data.File.OriginalPath }</span>
</div>
</td>
</tr>
}
<tr class="border-b dark:border-gray-700">
<th scope="row" class="py-3 px-4 font-medium text-gray-900 whitespace-nowrap dark:text-white bg-gray-50 dark:bg-gray-800">
Record Created
</th>
<td class="py-3 px-4 bg-white dark:bg-gray-800">
{ data.File.CreatedAt.Format("2006-01-02 15:04:05") }
</td>
</tr>
<tr class="border-b dark:border-gray-700">
<th scope="row" class="py-3 px-4 font-medium text-gray-900 whitespace-nowrap dark:text-white bg-gray-50 dark:bg-gray-800">
Record Updated
</th>
<td class="py-3 px-4 bg-white dark:bg-gray-800">
{ data.File.UpdatedAt.Format("2006-01-02 15:04:05") }
</td>
</tr>
</tbody>
</table>
<tr class="border-b dark:border-gray-700">
<th scope="row" class="py-3 px-4 font-medium text-gray-900 whitespace-nowrap dark:text-white bg-gray-50 dark:bg-gray-800">
Destination Path
</th>
<td class="py-3 px-4 break-all bg-white dark:bg-gray-800">
<div class="flex items-center">
<i class="fas fa-folder-open mr-2 text-blue-500"></i>
<span>{ data.File.DestinationPath }</span>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Processing Information -->
<div>
<h6 class="text-lg font-semibold mb-4 text-gray-900 dark:text-white flex items-center">
<i class="fas fa-cogs mr-2 text-gray-500 dark:text-gray-400"></i> Processing Information
</h6>
<div class="overflow-x-auto relative shadow-md sm:rounded-lg">
<table class="w-full text-sm text-left text-gray-500 dark:text-gray-400">
<tbody>
<tr class="border-b dark:border-gray-700">
<th scope="row" class="py-3 px-4 font-medium text-gray-900 whitespace-nowrap dark:text-white bg-gray-50 dark:bg-gray-800">
Job
</th>
<td class="py-3 px-4 bg-white dark:bg-gray-800">
<a href={ templ.SafeURL(fmt.Sprintf("/files/job/%d", data.File.JobID)) } class="font-medium text-blue-600 dark:text-blue-500 hover:underline">
{ data.File.Job.Name }
</a>
</td>
</tr>
<tr class="border-b dark:border-gray-700">
<th scope="row" class="py-3 px-4 font-medium text-gray-900 whitespace-nowrap dark:text-white bg-gray-50 dark:bg-gray-800">
Processed Time
</th>
<td class="py-3 px-4 bg-white dark:bg-gray-800">
<div class="flex items-center">
<i class="far fa-clock mr-2 text-gray-500"></i>
<span>{ data.File.ProcessedTime.Format("2006-01-02 15:04:05") }</span>
</div>
</td>
</tr>
<tr class="border-b dark:border-gray-700">
<th scope="row" class="py-3 px-4 font-medium text-gray-900 whitespace-nowrap dark:text-white bg-gray-50 dark:bg-gray-800">
Creation Time
</th>
<td class="py-3 px-4 bg-white dark:bg-gray-800">
<div class="flex items-center">
<i class="fas fa-calendar-plus mr-2 text-green-500"></i>
<span>{ data.File.CreationTime.Format("2006-01-02 15:04:05") }</span>
</div>
</td>
</tr>
<tr class="border-b dark:border-gray-700">
<th scope="row" class="py-3 px-4 font-medium text-gray-900 whitespace-nowrap dark:text-white bg-gray-50 dark:bg-gray-800">
Modification Time
</th>
<td class="py-3 px-4 bg-white dark:bg-gray-800">
<div class="flex items-center">
<i class="fas fa-calendar-alt mr-2 text-purple-500"></i>
<span>{ data.File.ModTime.Format("2006-01-02 15:04:05") }</span>
</div>
</td>
</tr>
if data.File.Status == "error" && data.File.ErrorMessage != "" {
<tr class="border-b dark:border-gray-700">
<th scope="row" class="py-3 px-4 font-medium text-gray-900 whitespace-nowrap dark:text-white bg-gray-50 dark:bg-gray-800">
Error
</th>
<td class="py-3 px-4 break-all bg-white dark:bg-gray-800">
<div class="flex items-start">
<i class="fas fa-exclamation-triangle mt-1 mr-2 text-red-500"></i>
<span class="text-red-600 dark:text-red-400">{ data.File.ErrorMessage }</span>
</div>
</td>
</tr>
}
<tr class="border-b dark:border-gray-700">
<th scope="row" class="py-3 px-4 font-medium text-gray-900 whitespace-nowrap dark:text-white bg-gray-50 dark:bg-gray-800">
Record Created
</th>
<td class="py-3 px-4 bg-white dark:bg-gray-800">
{ data.File.CreatedAt.Format("2006-01-02 15:04:05") }
</td>
</tr>
<tr class="border-b dark:border-gray-700">
<th scope="row" class="py-3 px-4 font-medium text-gray-900 whitespace-nowrap dark:text-white bg-gray-50 dark:bg-gray-800">
Record Updated
</th>
<td class="py-3 px-4 bg-white dark:bg-gray-800">
{ data.File.UpdatedAt.Format("2006-01-02 15:04:05") }
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Delete dialog -->
@FileMetadataDialog(
fmt.Sprintf("delete-file-dialog-%d", data.File.ID),
"Delete File Metadata",
fmt.Sprintf("Are you sure you want to delete the metadata for '%s'? This cannot be undone.", data.File.FileName),
"btn-danger",
"Delete",
"delete",
data.File.ID,
data.File.FileName,
"details",
)
<!-- Action buttons -->
<div class="mt-6 flex flex-wrap justify-end gap-3">
<a href="/files" class="py-2.5 px-5 text-sm font-medium text-gray-900 focus:outline-none bg-white rounded-lg border border-gray-200 hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-4 focus:ring-gray-100 dark:focus:ring-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700">
<i class="fas fa-list mr-2"></i> Back to Files
</a>
<button
type="button"
onclick={ showFileDialog(fmt.Sprintf("delete-file-dialog-%d", data.File.ID)) }
class="text-white bg-red-700 hover:bg-red-800 focus:ring-4 focus:ring-red-300 font-medium rounded-lg text-sm px-5 py-2.5 dark:bg-red-600 dark:hover:bg-red-700 focus:outline-none dark:focus:ring-red-800">
<i class="fas fa-trash mr-2"></i> Delete Record
</button>
<!-- Delete dialog -->
@FileMetadataDialog(
fmt.Sprintf("delete-file-dialog-%d", data.File.ID),
"Delete File Metadata",
fmt.Sprintf("Are you sure you want to delete the metadata for '%s'? This cannot be undone.", data.File.FileName),
"btn-danger",
"Delete",
"delete",
data.File.ID,
data.File.FileName,
"details",
)
<!-- Action buttons -->
<div class="mt-6 flex flex-wrap justify-end gap-3">
<a href="/files" class="py-2.5 px-5 text-sm font-medium text-gray-900 focus:outline-none bg-white rounded-lg border border-gray-200 hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-4 focus:ring-gray-100 dark:focus:ring-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700">
<i class="fas fa-list mr-2"></i> Back to Files
</a>
<button
type="button"
onclick={ showFileDialog(fmt.Sprintf("delete-file-dialog-%d", data.File.ID)) }
class="text-white bg-red-700 hover:bg-red-800 focus:ring-4 focus:ring-red-300 font-medium rounded-lg text-sm px-5 py-2.5 dark:bg-red-600 dark:hover:bg-red-700 focus:outline-none dark:focus:ring-red-800">
<i class="fas fa-trash mr-2"></i> Delete Record
</button>
</div>
</div>
</div>
</div>
<script>
// Set dark background color if in dark mode
if (document.documentElement.classList.contains('dark')) {
document.getElementById('file-details-container').style.backgroundColor = '#111827';
}
// Add event listener for theme changes
document.addEventListener('DOMContentLoaded', function() {
const themeToggle = document.getElementById('theme-toggle');
if (themeToggle) {
themeToggle.addEventListener('click', function() {
setTimeout(function() {
const isDark = document.documentElement.classList.contains('dark');
document.getElementById('file-details-container').style.backgroundColor = isDark ? '#111827' : 'rgb(249, 250, 251)';
}, 50);
});
}
});
</script>
</div>
}
}
@@ -646,82 +690,108 @@ templ FileMetadataDetails(ctx context.Context, data FileMetadataDetailsData) {
// FileMetadataSearch renders an advanced search form for file metadata
templ FileMetadataSearch(ctx context.Context, data FileMetadataSearchData) {
@LayoutWithContext("Search Files", ctx) {
<div class="px-2 py-4 sm:px-4 w-full">
<div class="mb-6">
<a href="/files" class="inline-flex items-center text-blue-600 hover:underline dark:text-blue-500">
<i class="fas fa-arrow-left mr-2"></i> Back to Files
</a>
</div>
<div class="p-4 mb-6 bg-white border border-gray-200 rounded-lg shadow dark:bg-gray-800 dark:border-gray-700 w-full">
<h5 class="mb-4 text-lg font-semibold text-gray-900 dark:text-white">Advanced File Search</h5>
<form
hx-get="/files/search/partial"
hx-target="#search-results-container"
hx-swap="innerHTML"
hx-indicator="#search-form-loading"
hx-headers='{"X-HX-Request": "true"}'
hx-boost="false"
class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label for="job_id" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Job ID</label>
<input type="text" id="job_id" name="job_id" value={ data.Filter.JobID } placeholder="Job ID"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" />
</div>
<div>
<label for="status" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Status</label>
<select id="status" name="status" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500">
<option value="">All Statuses</option>
<option value="processed" selected?={ data.Filter.Status == "processed" }>Processed</option>
<option value="archived" selected?={ data.Filter.Status == "archived" }>Archived</option>
<option value="deleted" selected?={ data.Filter.Status == "deleted" }>Deleted</option>
<option value="archived_and_deleted" selected?={ data.Filter.Status == "archived_and_deleted" }>Archived & Deleted</option>
<option value="error" selected?={ data.Filter.Status == "error" }>Error</option>
</select>
</div>
<div>
<label for="filename" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Filename</label>
<input type="text" id="filename" name="filename" value={ data.Filter.FileName } placeholder="Filename or partial match"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" />
</div>
<div>
<label for="hash" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">File Hash</label>
<input type="text" id="hash" name="hash" value={ data.Filter.Hash } placeholder="MD5 hash"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" />
</div>
<div>
<label for="start_date" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Processed After</label>
<input type="date" id="start_date" name="start_date" value={ data.Filter.StartDate }
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" />
</div>
<div>
<label for="end_date" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Processed Before</label>
<input type="date" id="end_date" name="end_date" value={ data.Filter.EndDate }
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" />
</div>
<div class="md:col-span-2 flex justify-end">
<button type="submit" class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800">
<i class="fas fa-search mr-2"></i> Search Files
</button>
<div id="search-form-loading" class="htmx-indicator ml-2 flex items-center">
<i class="fas fa-circle-notch fa-spin text-blue-600"></i>
<div id="file-search-container" style="min-height: 100vh;" class="bg-gray-50 dark:bg-gray-900">
<div class="pb-8 w-full">
<div class="mb-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
<i class="fas fa-search w-6 h-6 mr-2 text-blue-500"></i>
File Metadata Search
</h1>
<a href="/files" class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800">
<i class="fas fa-arrow-left mr-2"></i> Back to Files
</a>
</div>
<div class="p-4 mb-6 bg-white border border-gray-200 rounded-lg shadow dark:bg-gray-800 dark:border-gray-700 w-full">
<h5 class="mb-4 text-lg font-semibold text-gray-900 dark:text-white">Advanced File Search</h5>
<form
hx-get="/files/search/partial"
hx-target="#search-results-container"
hx-swap="innerHTML"
hx-indicator="#search-form-loading"
hx-headers='{"X-HX-Request": "true"}'
hx-boost="false"
class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label for="job_id" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Job ID</label>
<input type="text" id="job_id" name="job_id" value={ data.Filter.JobID } placeholder="Job ID"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" />
</div>
<div>
<label for="status" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Status</label>
<select id="status" name="status" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500">
<option value="">All Statuses</option>
<option value="processed" selected?={ data.Filter.Status == "processed" }>Processed</option>
<option value="archived" selected?={ data.Filter.Status == "archived" }>Archived</option>
<option value="deleted" selected?={ data.Filter.Status == "deleted" }>Deleted</option>
<option value="archived_and_deleted" selected?={ data.Filter.Status == "archived_and_deleted" }>Archived & Deleted</option>
<option value="error" selected?={ data.Filter.Status == "error" }>Error</option>
</select>
</div>
<div>
<label for="filename" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Filename</label>
<input type="text" id="filename" name="filename" value={ data.Filter.FileName } placeholder="Filename or partial match"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" />
</div>
<div>
<label for="hash" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">File Hash</label>
<input type="text" id="hash" name="hash" value={ data.Filter.Hash } placeholder="MD5 hash"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" />
</div>
<div>
<label for="start_date" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Processed After</label>
<input type="date" id="start_date" name="start_date" value={ data.Filter.StartDate }
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" />
</div>
<div>
<label for="end_date" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Processed Before</label>
<input type="date" id="end_date" name="end_date" value={ data.Filter.EndDate }
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" />
</div>
<div class="md:col-span-2 flex justify-end">
<button type="submit" class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800">
<i class="fas fa-search mr-2"></i> Search Files
</button>
<div id="search-form-loading" class="htmx-indicator ml-2 flex items-center">
<i class="fas fa-circle-notch fa-spin text-blue-600"></i>
</div>
</div>
</form>
</div>
<!-- Results -->
<div class="bg-white border border-gray-200 rounded-lg shadow dark:bg-gray-800 dark:border-gray-700 w-full">
<div class="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-600">
<h5 class="text-lg font-semibold text-gray-900 dark:text-white">Search Results ({ strconv.FormatInt(data.TotalCount, 10) })</h5>
<span class="text-sm text-gray-500 dark:text-gray-400">
Page { strconv.Itoa(data.Page) } of { strconv.Itoa(data.TotalPages) }
</span>
</div>
<div id="search-results-container" class="w-full">
@FileMetadataSearchContent(data)
</div>
</form>
</div>
<!-- Results -->
<div class="bg-white border border-gray-200 rounded-lg shadow dark:bg-gray-800 dark:border-gray-700 w-full">
<div class="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-600">
<h5 class="text-lg font-semibold text-gray-900 dark:text-white">Search Results ({ strconv.FormatInt(data.TotalCount, 10) })</h5>
<span class="text-sm text-gray-500 dark:text-gray-400">
Page { strconv.Itoa(data.Page) } of { strconv.Itoa(data.TotalPages) }
</span>
</div>
<div id="search-results-container" class="w-full">
@FileMetadataSearchContent(data)
</div>
</div>
<script>
// Set dark background color if in dark mode
if (document.documentElement.classList.contains('dark')) {
document.getElementById('file-search-container').style.backgroundColor = '#111827';
}
// Add event listener for theme changes
document.addEventListener('DOMContentLoaded', function() {
const themeToggle = document.getElementById('theme-toggle');
if (themeToggle) {
themeToggle.addEventListener('click', function() {
setTimeout(function() {
const isDark = document.documentElement.classList.contains('dark');
document.getElementById('file-search-container').style.backgroundColor = isDark ? '#111827' : 'rgb(249, 250, 251)';
}, 50);
});
}
});
</script>
</div>
}
}
+100 -78
View File
@@ -264,91 +264,113 @@ templ HistoryContent(ctx context.Context, data HistoryData) {
templ History(ctx context.Context, data HistoryData) {
@LayoutWithContext("Transfer History", ctx) {
<div class="py-6 px-4 mx-auto max-w-7xl lg:px-8">
<!-- Page Header -->
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between mb-8">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
<i class="fas fa-history w-6 h-6 mr-2 text-blue-500"></i>
Transfer History
</h1>
<div class="text-sm text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-gray-800 rounded-lg px-4 py-2 inline-flex items-center">
<i class="fas fa-info-circle mr-2"></i>
<span class="font-medium">Total: { fmt.Sprint(data.Total) } transfers</span>
</div>
</div>
<!-- Search and Pagination Controls -->
<div class="mb-6 p-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:bg-gray-800 dark:border-gray-700">
<form hx-get="/history" hx-target="#history-content" hx-indicator="#search-indicator" class="flex flex-col sm:flex-row gap-4 items-start sm:items-center">
<div class="w-full sm:w-auto 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-gray-400 dark:text-gray-500"></i>
</div>
<input
type="text"
name="search"
value={ data.SearchTerm }
placeholder="Search by job name or status..."
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full pl-10 pr-3 py-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
/>
<input type="hidden" name="page" value="1" />
<input type="hidden" name="pageSize" value={ fmt.Sprint(data.PageSize) } />
<div id="history-container" style="min-height: 100vh;" class="bg-gray-50 dark:bg-gray-900">
<div class="pb-8 w-full">
<!-- Page Header -->
<div class="mb-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
<i class="fas fa-history w-6 h-6 mr-2 text-blue-500"></i>
Transfer History
</h1>
<div class="text-sm text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-gray-800 rounded-lg px-4 py-2 inline-flex items-center">
<i class="fas fa-info-circle mr-2"></i>
<span class="font-medium">Total: { fmt.Sprint(data.Total) } transfers</span>
</div>
<div class="flex gap-2">
<button
type="submit"
class="inline-flex items-center py-2.5 px-4 text-sm font-medium text-white bg-blue-700 rounded-lg border border-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800"
>
<i class="fas fa-search mr-2 -ml-1"></i>
Search
<span id="search-indicator" class="htmx-indicator ml-2">
<i class="fas fa-spinner fa-spin"></i>
</span>
</button>
if data.SearchTerm != "" {
</div>
<!-- Search and Pagination Controls -->
<div class="mb-6 p-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:bg-gray-800 dark:border-gray-700">
<form hx-get="/history" hx-target="#history-content" hx-indicator="#search-indicator" class="flex flex-col sm:flex-row gap-4 items-start sm:items-center">
<div class="w-full sm:w-auto 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-gray-400 dark:text-gray-500"></i>
</div>
<input
type="text"
name="search"
value={ data.SearchTerm }
placeholder="Search by job name or status..."
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full pl-10 pr-3 py-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
/>
<input type="hidden" name="page" value="1" />
<input type="hidden" name="pageSize" value={ fmt.Sprint(data.PageSize) } />
</div>
<div class="flex gap-2">
<button
hx-get="/history"
hx-target="#history-content"
hx-indicator="#clear-indicator"
hx-vals={ fmt.Sprintf(`{"pageSize": %d}`, data.PageSize) }
class="py-2.5 px-5 text-sm font-medium text-gray-900 focus:outline-none bg-white rounded-lg border border-gray-200 hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-4 focus:ring-gray-200 dark:focus:ring-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700 inline-flex items-center"
type="submit"
class="inline-flex items-center py-2.5 px-4 text-sm font-medium text-white bg-blue-700 rounded-lg border border-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800"
>
<i class="fas fa-times mr-2"></i>
Clear
<span id="clear-indicator" class="htmx-indicator ml-2">
<i class="fas fa-search mr-2 -ml-1"></i>
Search
<span id="search-indicator" class="htmx-indicator ml-2">
<i class="fas fa-spinner fa-spin"></i>
</span>
</button>
}
</div>
<div class="ml-auto">
<label class="inline-flex items-center text-sm text-gray-700 dark:text-gray-300">
Show
<select
name="pageSize"
hx-get="/history"
hx-target="#history-content"
hx-include="[name='search']"
hx-indicator="#size-indicator"
class="mx-2 bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
>
<option value="10" selected?={ data.PageSize == 10 }>10</option>
<option value="25" selected?={ data.PageSize == 25 }>25</option>
<option value="50" selected?={ data.PageSize == 50 }>50</option>
<option value="100" selected?={ data.PageSize == 100 }>100</option>
</select>
entries
<span id="size-indicator" class="htmx-indicator ml-2">
<i class="fas fa-spinner fa-spin"></i>
</span>
</label>
</div>
</form>
</div>
<div id="history-content" class="mt-6">
@HistoryContent(ctx, data)
if data.SearchTerm != "" {
<button
hx-get="/history"
hx-target="#history-content"
hx-indicator="#clear-indicator"
hx-vals={ fmt.Sprintf(`{"pageSize": %d}`, data.PageSize) }
class="py-2.5 px-5 text-sm font-medium text-gray-900 focus:outline-none bg-white rounded-lg border border-gray-200 hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-4 focus:ring-gray-200 dark:focus:ring-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700 inline-flex items-center"
>
<i class="fas fa-times mr-2"></i>
Clear
<span id="clear-indicator" class="htmx-indicator ml-2">
<i class="fas fa-spinner fa-spin"></i>
</span>
</button>
}
</div>
<div class="ml-auto">
<label class="inline-flex items-center text-sm text-gray-700 dark:text-gray-300">
Show
<select
name="pageSize"
hx-get="/history"
hx-target="#history-content"
hx-include="[name='search']"
hx-indicator="#size-indicator"
class="mx-2 bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
>
<option value="10" selected?={ data.PageSize == 10 }>10</option>
<option value="25" selected?={ data.PageSize == 25 }>25</option>
<option value="50" selected?={ data.PageSize == 50 }>50</option>
<option value="100" selected?={ data.PageSize == 100 }>100</option>
</select>
entries
<span id="size-indicator" class="htmx-indicator ml-2">
<i class="fas fa-spinner fa-spin"></i>
</span>
</label>
</div>
</form>
</div>
<div id="history-content" class="mt-6">
@HistoryContent(ctx, data)
</div>
</div>
<script>
// Set dark background color if in dark mode
if (document.documentElement.classList.contains('dark')) {
document.getElementById('history-container').style.backgroundColor = '#111827';
}
// Add event listener for theme changes
document.addEventListener('DOMContentLoaded', function() {
const themeToggle = document.getElementById('theme-toggle');
if (themeToggle) {
themeToggle.addEventListener('click', function() {
setTimeout(function() {
const isDark = document.documentElement.classList.contains('dark');
document.getElementById('history-container').style.backgroundColor = isDark ? '#111827' : 'rgb(249, 250, 251)';
}, 50);
});
}
});
</script>
</div>
}
}
+3 -3
View File
@@ -139,9 +139,9 @@ templ NotificationDropdown(data NotificationsData) {
<div class={ "w-11 h-11 rounded-full flex items-center justify-center", GetNotificationBgColor(notification.Type) }>
<i class={ GetNotificationIcon(notification.Type) }></i>
</div>
<div class={ "flex absolute justify-center items-center ml-6 -mt-5 w-5 h-5 rounded-full border border-white dark:border-gray-700", GetNotificationBadgeColor(notification.Type) }>
<i class={ "text-white text-xs", GetNotificationBadgeIcon(notification.Type) }></i>
</div>
// <div class={ "flex absolute justify-center items-center ml-6 -mt-5 w-5 h-5 rounded-full border border-white dark:border-gray-700", GetNotificationBadgeColor(notification.Type) }>
// <i class={ "text-white text-xs", GetNotificationBadgeIcon(notification.Type) }></i>
// </div>
</div>
<div class="pl-3 w-full">
<div class="text-gray-500 font-normal text-sm mb-1.5 dark:text-gray-400">
+15 -13
View File
@@ -28,9 +28,10 @@ type SettingsData struct {
templ Settings(ctx context.Context, data SettingsData) {
@LayoutWithContext("Application Settings", ctx) {
<div class="p-4 md:p-6 2xl:p-10">
<div class="mb-6">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
<div id="settings-container" style="min-height: 100vh;" class="bg-gray-50 dark:bg-gray-900">
<div class="pb-8 w-full">
<div class="mb-6">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
<i class="fas fa-cog w-6 h-6 mr-2 text-blue-500"></i>
Application Settings
</h1>
@@ -67,16 +68,16 @@ templ Settings(ctx context.Context, data SettingsData) {
<i class="fas fa-bell mr-2"></i>Notifications
</button>
</li>
<li class="mr-2" role="presentation">
<button class="inline-block p-4 border-b-2 border-transparent rounded-t-lg hover:border-gray-300 hover:text-gray-600 dark:hover:text-gray-300" id="general-tab" data-tabs-target="#general" type="button" role="tab" aria-controls="general" aria-selected="false">
<i class="fas fa-sliders-h mr-2"></i>General
</button>
</li>
<li role="presentation">
<button class="inline-block p-4 border-b-2 border-transparent rounded-t-lg hover:border-gray-300 hover:text-gray-600 dark:hover:text-gray-300" id="security-tab" data-tabs-target="#security" type="button" role="tab" aria-controls="security" aria-selected="false">
<i class="fas fa-shield-alt mr-2"></i>Security
</button>
</li>
// <li class="mr-2" role="presentation">
// <button class="inline-block p-4 border-b-2 border-transparent rounded-t-lg hover:border-gray-300 hover:text-gray-600 dark:hover:text-gray-300" id="general-tab" data-tabs-target="#general" type="button" role="tab" aria-controls="general" aria-selected="false">
// <i class="fas fa-sliders-h mr-2"></i>General
// </button>
// </li>
// <li role="presentation">
// <button class="inline-block p-4 border-b-2 border-transparent rounded-t-lg hover:border-gray-300 hover:text-gray-600 dark:hover:text-gray-300" id="security-tab" data-tabs-target="#security" type="button" role="tab" aria-controls="security" aria-selected="false">
// <i class="fas fa-shield-alt mr-2"></i>Security
// </button>
// </li>
</ul>
</div>
@@ -435,6 +436,7 @@ templ Settings(ctx context.Context, data SettingsData) {
</div>
</div>
</div>
</div>
}
@toggleNotificationFields()
}
+70 -76
View File
@@ -7,17 +7,26 @@ import (
)
type UserManagementData struct {
Users []db.User
Roles []db.Role
ActiveTab string // "list", "create", or "edit"
EditUser *db.User
UserRoles []db.Role
ErrorMessage string
Users []db.User
Roles []db.Role
ActiveTab string // "list", "create", or "edit"
EditUser *db.User
UserRoles []db.Role
ErrorMessage string
SuccessMessage string
}
templ UserManagement(ctx context.Context, data UserManagementData) {
@LayoutWithContext("User Management", ctx) {
<div class="p-4 md:p-6 2xl:p-10">
@UserManagementContent(data)
}
}
// UserManagementContent is a partial component for HTMX to replace only the content
templ UserManagementContent(data UserManagementData) {
// <div id="userManagementContainer" class="px-4 py-8 mx-auto max-w-screen-xl lg:px-6">
<div id="userManagementContainer" style="min-height: 100vh;" class="bg-gray-50 dark:bg-gray-900">
<div class="pb-8 w-full">
<!-- Header -->
<div class="mb-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
@@ -25,38 +34,33 @@ templ UserManagement(ctx context.Context, data UserManagementData) {
User Management
</h1>
<div class="flex items-center space-x-2">
<button
<button
class="flex items-center justify-center text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg px-5 py-2.5 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800"
hx-get="/admin/users/new"
hx-target="#user-management-content"
hx-get="/admin/users/new"
hx-target="#userManagementContainer"
hx-push-url="true"
>
<i class="fas fa-user-plus w-4 h-4 mr-2"></i>
Add User
</button>
<button
class="flex items-center justify-center text-gray-500 bg-white hover:bg-gray-100 focus:ring-4 focus:ring-gray-300 font-medium rounded-lg px-5 py-2.5 dark:bg-gray-800 dark:hover:bg-gray-700 dark:text-gray-300 dark:focus:ring-gray-700 border border-gray-200 dark:border-gray-600"
hx-get="/admin/users"
hx-target="#user-management-content"
hx-push-url="true"
>
<i class="fas fa-list w-4 h-4 mr-2"></i>
View All Users
</button>
</div>
</div>
<!-- Status and Error Messages -->
<div id="status-message" class="hidden mb-4 p-4 text-sm text-green-700 bg-green-100 rounded-lg dark:bg-green-200 dark:text-green-800" role="alert"></div>
if data.SuccessMessage != "" {
<div id="status-message" class="mb-4 p-4 text-sm text-green-700 bg-green-100 rounded-lg dark:bg-green-200 dark:text-green-800" role="alert">
<div class="font-medium">{ data.SuccessMessage }</div>
</div>
} else {
<div id="status-message" class="hidden mb-4 p-4 text-sm text-green-700 bg-green-100 rounded-lg dark:bg-green-200 dark:text-green-800" role="alert"></div>
}
if data.ErrorMessage != "" {
<div id="error-message" class="mb-4 p-4 text-sm text-red-700 bg-red-100 rounded-lg dark:bg-red-200 dark:text-red-800" role="alert">
<div class="font-medium">{ data.ErrorMessage }</div>
</div>
}
<!-- Content -->
<div id="user-management-content">
<!-- Main Content Container -->
<div class="bg-white border border-gray-200 rounded-lg shadow-sm 2xl:col-span-2 dark:border-gray-700 dark:bg-gray-800">
<!-- Include the appropriate tab -->
if data.ActiveTab == "list" || data.ActiveTab == "" {
@userList(data.Users)
} else if data.ActiveTab == "create" {
@@ -65,7 +69,6 @@ templ UserManagement(ctx context.Context, data UserManagementData) {
@userForm(data.EditUser, false, data.Roles)
}
</div>
<!-- Help Notice -->
<div class="mt-8">
<div class="flex p-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900 dark:text-blue-400" role="alert">
@@ -77,7 +80,7 @@ templ UserManagement(ctx context.Context, data UserManagementData) {
</div>
</div>
</div>
}
</div>
}
// User listing component
@@ -149,17 +152,16 @@ templ userList(users []db.User) {
<button
class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-3 py-1.5 text-center inline-flex items-center me-2 dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800"
hx-get={ "/admin/users/" + strconv.Itoa(int(user.ID)) + "/edit" }
hx-target="#user-management-content"
hx-target="#userManagementContainer"
hx-push-url="true"
>
<i class="fas fa-edit w-3.5 h-3.5 mr-1.5"></i> Edit
</button>
<button
class="text-white bg-red-700 hover:bg-red-800 focus:ring-4 focus:outline-none focus:ring-red-300 font-medium rounded-lg text-sm px-3 py-1.5 text-center inline-flex items-center dark:bg-red-600 dark:hover:bg-red-700 dark:focus:ring-red-800"
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="#user-management-content"
hx-target="#userManagementContainer"
>
<i class="fas fa-trash-alt w-3.5 h-3.5 mr-1.5"></i> Delete
</button>
@@ -193,15 +195,14 @@ templ userForm(user *db.User, isNew bool, availableRoles []db.Role) {
}
</p>
</div>
<form
class="space-y-6"
<form
class="space-y-4 md:space-y-6"
if isNew {
hx-post="/admin/users"
} else {
hx-put={ "/admin/users/" + strconv.Itoa(int(user.ID)) }
}
hx-target="#user-management-content"
hx-target="#userManagementContainer"
x-data="{
password: '',
confirmPassword: '',
@@ -214,19 +215,18 @@ templ userForm(user *db.User, isNew bool, availableRoles []db.Role) {
<div class="space-y-4">
<div>
<label for="email" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Email</label>
<input
type="email"
name="email"
id="email"
required
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
<input
type="email"
name="email"
id="email"
required
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="user@example.com"
if !isNew {
value={ user.Email }
}
/>
</div>
<div>
<label for="password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
if isNew {
@@ -235,28 +235,27 @@ templ userForm(user *db.User, isNew bool, availableRoles []db.Role) {
New Password (leave blank to keep current)
}
</label>
<input
type="password"
name="password"
id="password"
<input
type="password"
name="password"
id="password"
x-model="password"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="••••••••"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="••••••••"
if isNew {
required="true"
}
/>
</div>
<div>
<label for="password_confirm" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Confirm Password</label>
<input
type="password"
name="password_confirm"
id="password_confirm"
<input
type="password"
name="password_confirm"
id="password_confirm"
x-model="confirmPassword"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="••••••••"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="••••••••"
if isNew {
required="true"
}
@@ -265,15 +264,14 @@ templ userForm(user *db.User, isNew bool, availableRoles []db.Role) {
Passwords do not match
</p>
</div>
<div class="pt-2">
<div class="flex items-start">
<div class="flex items-center h-5">
<input
id="is_admin"
name="is_admin"
type="checkbox"
class="w-4 h-4 border border-gray-300 rounded bg-gray-50 focus:ring-3 focus:ring-blue-300 dark:bg-gray-700 dark:border-gray-600 dark:focus:ring-blue-600 dark:ring-offset-gray-800 dark:focus:ring-offset-gray-800"
<input
id="is_admin"
name="is_admin"
type="checkbox"
class="w-4 h-4 border border-gray-300 rounded bg-gray-50 focus:ring-3 focus:ring-blue-300 dark:bg-gray-700 dark:border-gray-600 dark:focus:ring-blue-600 dark:ring-offset-gray-800 dark:focus:ring-offset-gray-800"
if !isNew && user.GetIsAdmin() {
checked="true"
}
@@ -285,18 +283,16 @@ templ userForm(user *db.User, isNew bool, availableRoles []db.Role) {
</div>
</div>
</div>
if !isNew {
<div class="border-t border-gray-200 dark:border-gray-700 pt-4 mt-4">
<h4 class="text-base font-medium text-gray-900 dark:text-white mb-2">Account Status</h4>
<div class="flex items-start">
<div class="flex items-center h-5">
<input
id="account_locked"
name="account_locked"
type="checkbox"
class="w-4 h-4 border border-gray-300 rounded bg-gray-50 focus:ring-3 focus:ring-blue-300 dark:bg-gray-700 dark:border-gray-600 dark:focus:ring-blue-600 dark:ring-offset-gray-800 dark:focus:ring-offset-gray-800"
<input
id="account_locked"
name="account_locked"
type="checkbox"
class="w-4 h-4 border border-gray-300 rounded bg-gray-50 focus:ring-3 focus:ring-blue-300 dark:bg-gray-700 dark:border-gray-600 dark:focus:ring-blue-600 dark:ring-offset-gray-800 dark:focus:ring-offset-gray-800"
if user.GetAccountLocked() {
checked="true"
}
@@ -309,7 +305,6 @@ templ userForm(user *db.User, isNew bool, availableRoles []db.Role) {
</div>
</div>
}
if len(availableRoles) > 0 {
<div class="border-t border-gray-200 dark:border-gray-700 pt-4 mt-4">
<h4 class="text-base font-medium text-gray-900 dark:text-white mb-2">Roles</h4>
@@ -317,11 +312,11 @@ templ userForm(user *db.User, isNew bool, availableRoles []db.Role) {
for _, role := range availableRoles {
<div class="flex items-start">
<div class="flex items-center h-5">
<input
<input
id={ "role_" + strconv.Itoa(int(role.ID)) }
name="roles[]"
name="roles[]"
value={ strconv.Itoa(int(role.ID)) }
type="checkbox"
type="checkbox"
class="w-4 h-4 border border-gray-300 rounded bg-gray-50 focus:ring-3 focus:ring-blue-300 dark:bg-gray-700 dark:border-gray-600 dark:focus:ring-blue-600 dark:ring-offset-gray-800 dark:focus:ring-offset-gray-800"
if !isNew && hasRole(user, role.ID) {
checked="true"
@@ -338,20 +333,19 @@ templ userForm(user *db.User, isNew bool, availableRoles []db.Role) {
</div>
}
</div>
<div class="flex items-center justify-between pt-4 border-t border-gray-200 dark:border-gray-700">
<button
type="button"
class="text-gray-500 bg-white hover:bg-gray-100 focus:ring-4 focus:outline-none focus:ring-gray-200 rounded-lg border border-gray-200 text-sm font-medium px-5 py-2.5 hover:text-gray-900 focus:z-10 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-500 dark:hover:text-white dark:hover:bg-gray-600 dark:focus:ring-gray-600"
hx-get="/admin/users"
hx-target="#user-management-content"
hx-target="#userManagementContainer"
hx-push-url="true"
>
<i class="fas fa-arrow-left mr-2"></i>
Cancel
</button>
<button
type="submit"
<button
type="submit"
class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800"
x-bind:disabled="!passwordsMatch() && confirmPassword !== ''"
>
@@ -376,4 +370,4 @@ func hasRole(user *db.User, roleID uint) bool {
}
}
return false
}
}
+26
View File
@@ -14,6 +14,7 @@ import (
"github.com/glebarez/sqlite"
"github.com/starfleetcptn/gomft/internal/db/migrations"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
)
@@ -1681,3 +1682,28 @@ func (db *DB) UnassignRoleFromUser(roleID, userID, unassignedByID uint) error {
return tx.Commit().Error
}
// User struct method to set password with secure hashing
func (u *User) SetPassword(password string) error {
// Validate password length
if len(password) < 8 {
return fmt.Errorf("password must be at least 8 characters long")
}
// Hash the password using bcrypt
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return fmt.Errorf("failed to hash password: %w", err)
}
// Store the hashed password
u.PasswordHash = string(hashedPassword)
u.LastPasswordChange = time.Now()
return nil
}
func (u *User) CheckPassword(password string) bool {
err := bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password))
return err == nil
}
+704
View File
@@ -571,3 +571,707 @@ func getUserID(c *gin.Context) uint {
}
return 0
}
// HandleUsers renders the user management page
func (h *Handlers) HandleUsers(c *gin.Context) {
ctx := components.CreateTemplateContext(c)
// Fetch users from the database
var users []db.User
if err := h.DB.Find(&users).Error; err != nil {
data := components.UserManagementData{
ActiveTab: "list",
ErrorMessage: "Failed to fetch users: " + err.Error(),
}
if c.GetHeader("HX-Request") == "true" {
_ = components.UserManagementContent(data).Render(ctx, c.Writer)
} else {
_ = components.UserManagement(ctx, data).Render(ctx, c.Writer)
}
return
}
// Preload roles for each user
for i := range users {
if err := h.DB.Model(&users[i]).Association("Roles").Find(&users[i].Roles); err != nil {
// Log error but continue
fmt.Printf("Error loading roles for user %d: %v\n", users[i].ID, err)
}
}
// Get success message from flash if available
successMsg := c.Query("success")
data := components.UserManagementData{
Users: users,
ActiveTab: "list",
SuccessMessage: successMsg,
}
if c.GetHeader("HX-Request") == "true" {
_ = components.UserManagementContent(data).Render(ctx, c.Writer)
} else {
_ = components.UserManagement(ctx, data).Render(ctx, c.Writer)
}
}
// HandleNewUser renders the new user creation page
func (h *Handlers) HandleNewUser(c *gin.Context) {
ctx := components.CreateTemplateContext(c)
// Fetch available roles
var roles []db.Role
if err := h.DB.Find(&roles).Error; err != nil {
data := components.UserManagementData{
ActiveTab: "create",
ErrorMessage: "Failed to fetch roles: " + err.Error(),
}
if c.GetHeader("HX-Request") == "true" {
_ = components.UserManagementContent(data).Render(ctx, c.Writer)
} else {
_ = components.UserManagement(ctx, data).Render(ctx, c.Writer)
}
return
}
data := components.UserManagementData{
ActiveTab: "create",
Roles: roles,
}
if c.GetHeader("HX-Request") == "true" {
_ = components.UserManagementContent(data).Render(ctx, c.Writer)
} else {
_ = components.UserManagement(ctx, data).Render(ctx, c.Writer)
}
}
// HandleCreateUser processes user creation
func (h *Handlers) HandleCreateUser(c *gin.Context) {
ctx := components.CreateTemplateContext(c)
email := c.PostForm("email")
password := c.PostForm("password")
passwordConfirm := c.PostForm("password_confirm")
isAdmin := c.PostForm("is_admin") == "on"
roleIDs := c.PostFormArray("roles[]")
// Validate inputs
if email == "" || password == "" {
data := components.UserManagementData{
ActiveTab: "create",
ErrorMessage: "Email and password are required",
}
if c.GetHeader("HX-Request") == "true" {
_ = components.UserManagementContent(data).Render(ctx, c.Writer)
} else {
_ = components.UserManagement(ctx, data).Render(ctx, c.Writer)
}
return
}
if password != passwordConfirm {
data := components.UserManagementData{
ActiveTab: "create",
ErrorMessage: "Passwords do not match",
}
if c.GetHeader("HX-Request") == "true" {
_ = components.UserManagementContent(data).Render(ctx, c.Writer)
} else {
_ = components.UserManagement(ctx, data).Render(ctx, c.Writer)
}
return
}
// Create new user
user := &db.User{
Email: email,
}
// Set password (this would use a proper password hashing mechanism)
if err := user.SetPassword(password); err != nil {
data := components.UserManagementData{
ActiveTab: "create",
ErrorMessage: "Failed to set password: " + err.Error(),
}
if c.GetHeader("HX-Request") == "true" {
_ = components.UserManagementContent(data).Render(ctx, c.Writer)
} else {
_ = components.UserManagement(ctx, data).Render(ctx, c.Writer)
}
return
}
// Set admin status
user.SetIsAdmin(isAdmin)
// Start transaction
tx := h.DB.Begin()
if err := tx.Error; err != nil {
data := components.UserManagementData{
ActiveTab: "create",
ErrorMessage: "Database error: " + err.Error(),
}
if c.GetHeader("HX-Request") == "true" {
_ = components.UserManagementContent(data).Render(ctx, c.Writer)
} else {
_ = components.UserManagement(ctx, data).Render(ctx, c.Writer)
}
return
}
// Save user
if err := tx.Create(user).Error; err != nil {
tx.Rollback()
data := components.UserManagementData{
ActiveTab: "create",
ErrorMessage: "Failed to create user: " + err.Error(),
}
if c.GetHeader("HX-Request") == "true" {
_ = components.UserManagementContent(data).Render(ctx, c.Writer)
} else {
_ = components.UserManagement(ctx, data).Render(ctx, c.Writer)
}
return
}
// Add roles if not admin and roles are selected
if !isAdmin && len(roleIDs) > 0 {
for _, roleIDStr := range roleIDs {
roleID, err := strconv.ParseUint(roleIDStr, 10, 32)
if err != nil {
continue // Skip invalid IDs
}
// Check if role exists
var role db.Role
if err := tx.First(&role, roleID).Error; err != nil {
continue // Skip if role doesn't exist
}
// Add role to user
if err := tx.Model(user).Association("Roles").Append(&role); err != nil {
tx.Rollback()
data := components.UserManagementData{
ActiveTab: "create",
ErrorMessage: "Failed to assign roles: " + err.Error(),
}
if c.GetHeader("HX-Request") == "true" {
_ = components.UserManagementContent(data).Render(ctx, c.Writer)
} else {
_ = components.UserManagement(ctx, data).Render(ctx, c.Writer)
}
return
}
}
}
// Create audit log entry
creatorID := getUserID(c)
auditLog := &db.AuditLog{
Action: "create",
EntityType: "user",
EntityID: user.ID,
UserID: creatorID,
Timestamp: time.Now(),
Details: map[string]interface{}{
"email": user.Email,
"is_admin": user.GetIsAdmin(),
},
}
if err := tx.Create(auditLog).Error; err != nil {
tx.Rollback()
data := components.UserManagementData{
ActiveTab: "create",
ErrorMessage: "Failed to create audit log: " + err.Error(),
}
if c.GetHeader("HX-Request") == "true" {
_ = components.UserManagementContent(data).Render(ctx, c.Writer)
} else {
_ = components.UserManagement(ctx, data).Render(ctx, c.Writer)
}
return
}
// Commit transaction
if err := tx.Commit().Error; err != nil {
data := components.UserManagementData{
ActiveTab: "create",
ErrorMessage: "Failed to commit transaction: " + err.Error(),
}
if c.GetHeader("HX-Request") == "true" {
_ = components.UserManagementContent(data).Render(ctx, c.Writer)
} else {
_ = components.UserManagement(ctx, data).Render(ctx, c.Writer)
}
return
}
// For HTMX requests, render the user list with success message
if c.GetHeader("HX-Request") == "true" {
// Fetch users from the database
var users []db.User
if err := h.DB.Find(&users).Error; err != nil {
data := components.UserManagementData{
ActiveTab: "list",
ErrorMessage: "Failed to fetch users after creation: " + err.Error(),
}
_ = components.UserManagementContent(data).Render(ctx, c.Writer)
return
}
// Preload roles for each user
for i := range users {
if err := h.DB.Model(&users[i]).Association("Roles").Find(&users[i].Roles); err != nil {
// Log error but continue
fmt.Printf("Error loading roles for user %d: %v\n", users[i].ID, err)
}
}
data := components.UserManagementData{
Users: users,
ActiveTab: "list",
SuccessMessage: "User created successfully",
}
_ = components.UserManagementContent(data).Render(ctx, c.Writer)
} else {
// Redirect to user list with success message for regular requests
c.Redirect(http.StatusFound, "/admin/users?success=User+created+successfully")
}
}
// HandleEditUser renders the user edit page
func (h *Handlers) HandleEditUser(c *gin.Context) {
ctx := components.CreateTemplateContext(c)
userID := c.Param("id")
// Fetch user
var user db.User
if err := h.DB.First(&user, userID).Error; err != nil {
data := components.UserManagementData{
ActiveTab: "list",
ErrorMessage: "User not found",
}
if c.GetHeader("HX-Request") == "true" {
_ = components.UserManagementContent(data).Render(ctx, c.Writer)
} else {
_ = components.UserManagement(ctx, data).Render(ctx, c.Writer)
}
return
}
// Fetch user's roles
if err := h.DB.Model(&user).Association("Roles").Find(&user.Roles); err != nil {
data := components.UserManagementData{
ActiveTab: "edit",
EditUser: &user,
ErrorMessage: "Failed to fetch user roles: " + err.Error(),
}
if c.GetHeader("HX-Request") == "true" {
_ = components.UserManagementContent(data).Render(ctx, c.Writer)
} else {
_ = components.UserManagement(ctx, data).Render(ctx, c.Writer)
}
return
}
// Fetch all available roles
var roles []db.Role
if err := h.DB.Find(&roles).Error; err != nil {
data := components.UserManagementData{
ActiveTab: "edit",
EditUser: &user,
ErrorMessage: "Failed to fetch roles: " + err.Error(),
}
if c.GetHeader("HX-Request") == "true" {
_ = components.UserManagementContent(data).Render(ctx, c.Writer)
} else {
_ = components.UserManagement(ctx, data).Render(ctx, c.Writer)
}
return
}
data := components.UserManagementData{
ActiveTab: "edit",
EditUser: &user,
Roles: roles,
}
if c.GetHeader("HX-Request") == "true" {
_ = components.UserManagementContent(data).Render(ctx, c.Writer)
} else {
_ = components.UserManagement(ctx, data).Render(ctx, c.Writer)
}
}
// HandleUpdateUser processes user updates
func (h *Handlers) HandleUpdateUser(c *gin.Context) {
ctx := components.CreateTemplateContext(c)
userID := c.Param("id")
email := c.PostForm("email")
password := c.PostForm("password")
passwordConfirm := c.PostForm("password_confirm")
isAdmin := c.PostForm("is_admin") == "on"
accountLocked := c.PostForm("account_locked") == "on"
roleIDs := c.PostFormArray("roles[]")
// Fetch existing user
var user db.User
if err := h.DB.First(&user, userID).Error; err != nil {
data := components.UserManagementData{
ActiveTab: "list",
ErrorMessage: "User not found",
}
if c.GetHeader("HX-Request") == "true" {
_ = components.UserManagementContent(data).Render(ctx, c.Writer)
} else {
_ = components.UserManagement(ctx, data).Render(ctx, c.Writer)
}
return
}
// Check if trying to update the last admin
if user.GetIsAdmin() && !isAdmin {
// Count how many admins there are
var adminCount int64
if err := h.DB.Model(&db.User{}).Where("metadata->>'is_admin' = 'true'").Count(&adminCount).Error; err != nil {
data := components.UserManagementData{
ActiveTab: "edit",
EditUser: &user,
ErrorMessage: "Failed to check admin count: " + err.Error(),
}
if c.GetHeader("HX-Request") == "true" {
_ = components.UserManagementContent(data).Render(ctx, c.Writer)
} else {
_ = components.UserManagement(ctx, data).Render(ctx, c.Writer)
}
return
}
// If this is the last admin, prevent removal of admin status
if adminCount <= 1 {
data := components.UserManagementData{
ActiveTab: "edit",
EditUser: &user,
ErrorMessage: "Cannot remove admin status from the last administrator",
}
if c.GetHeader("HX-Request") == "true" {
_ = components.UserManagementContent(data).Render(ctx, c.Writer)
} else {
_ = components.UserManagement(ctx, data).Render(ctx, c.Writer)
}
return
}
}
// Start transaction
tx := h.DB.Begin()
if err := tx.Error; err != nil {
data := components.UserManagementData{
ActiveTab: "edit",
EditUser: &user,
ErrorMessage: "Database error: " + err.Error(),
}
if c.GetHeader("HX-Request") == "true" {
_ = components.UserManagementContent(data).Render(ctx, c.Writer)
} else {
_ = components.UserManagement(ctx, data).Render(ctx, c.Writer)
}
return
}
// Update email
if email != "" && email != user.Email {
user.Email = email
}
// Update password if provided
if password != "" {
if password != passwordConfirm {
tx.Rollback()
data := components.UserManagementData{
ActiveTab: "edit",
EditUser: &user,
ErrorMessage: "Passwords do not match",
}
if c.GetHeader("HX-Request") == "true" {
_ = components.UserManagementContent(data).Render(ctx, c.Writer)
} else {
_ = components.UserManagement(ctx, data).Render(ctx, c.Writer)
}
return
}
if err := user.SetPassword(password); err != nil {
tx.Rollback()
data := components.UserManagementData{
ActiveTab: "edit",
EditUser: &user,
ErrorMessage: "Failed to set password: " + err.Error(),
}
if c.GetHeader("HX-Request") == "true" {
_ = components.UserManagementContent(data).Render(ctx, c.Writer)
} else {
_ = components.UserManagement(ctx, data).Render(ctx, c.Writer)
}
return
}
}
// Update admin status and account lock status
user.SetIsAdmin(isAdmin)
user.SetAccountLocked(accountLocked)
// Save user changes
if err := tx.Save(&user).Error; err != nil {
tx.Rollback()
data := components.UserManagementData{
ActiveTab: "edit",
EditUser: &user,
ErrorMessage: "Failed to update user: " + err.Error(),
}
if c.GetHeader("HX-Request") == "true" {
_ = components.UserManagementContent(data).Render(ctx, c.Writer)
} else {
_ = components.UserManagement(ctx, data).Render(ctx, c.Writer)
}
return
}
// Update roles if not admin
if !isAdmin {
// First, remove all existing roles
if err := tx.Model(&user).Association("Roles").Clear(); err != nil {
tx.Rollback()
data := components.UserManagementData{
ActiveTab: "edit",
EditUser: &user,
ErrorMessage: "Failed to clear existing roles: " + err.Error(),
}
if c.GetHeader("HX-Request") == "true" {
_ = components.UserManagementContent(data).Render(ctx, c.Writer)
} else {
_ = components.UserManagement(ctx, data).Render(ctx, c.Writer)
}
return
}
// Then add the selected roles
if len(roleIDs) > 0 {
for _, roleIDStr := range roleIDs {
roleID, err := strconv.ParseUint(roleIDStr, 10, 32)
if err != nil {
continue // Skip invalid IDs
}
// Check if role exists
var role db.Role
if err := tx.First(&role, roleID).Error; err != nil {
continue // Skip if role doesn't exist
}
// Add role to user
if err := tx.Model(&user).Association("Roles").Append(&role); err != nil {
tx.Rollback()
data := components.UserManagementData{
ActiveTab: "edit",
EditUser: &user,
ErrorMessage: "Failed to assign roles: " + err.Error(),
}
if c.GetHeader("HX-Request") == "true" {
_ = components.UserManagementContent(data).Render(ctx, c.Writer)
} else {
_ = components.UserManagement(ctx, data).Render(ctx, c.Writer)
}
return
}
}
}
}
// Create audit log entry
updaterID := getUserID(c)
auditLog := &db.AuditLog{
Action: "update",
EntityType: "user",
EntityID: user.ID,
UserID: updaterID,
Timestamp: time.Now(),
Details: map[string]interface{}{
"email": user.Email,
"is_admin": user.GetIsAdmin(),
"account_locked": user.GetAccountLocked(),
"password_changed": password != "",
},
}
if err := tx.Create(auditLog).Error; err != nil {
tx.Rollback()
data := components.UserManagementData{
ActiveTab: "edit",
EditUser: &user,
ErrorMessage: "Failed to create audit log: " + err.Error(),
}
if c.GetHeader("HX-Request") == "true" {
_ = components.UserManagementContent(data).Render(ctx, c.Writer)
} else {
_ = components.UserManagement(ctx, data).Render(ctx, c.Writer)
}
return
}
// Commit transaction
if err := tx.Commit().Error; err != nil {
data := components.UserManagementData{
ActiveTab: "edit",
EditUser: &user,
ErrorMessage: "Failed to commit transaction: " + err.Error(),
}
if c.GetHeader("HX-Request") == "true" {
_ = components.UserManagementContent(data).Render(ctx, c.Writer)
} else {
_ = components.UserManagement(ctx, data).Render(ctx, c.Writer)
}
return
}
// For HTMX requests, render the user list with success message
if c.GetHeader("HX-Request") == "true" {
// Fetch users from the database
var users []db.User
if err := h.DB.Find(&users).Error; err != nil {
data := components.UserManagementData{
ActiveTab: "list",
ErrorMessage: "Failed to fetch users after update: " + err.Error(),
}
_ = components.UserManagementContent(data).Render(ctx, c.Writer)
return
}
// Preload roles for each user
for i := range users {
if err := h.DB.Model(&users[i]).Association("Roles").Find(&users[i].Roles); err != nil {
// Log error but continue
fmt.Printf("Error loading roles for user %d: %v\n", users[i].ID, err)
}
}
data := components.UserManagementData{
Users: users,
ActiveTab: "list",
SuccessMessage: "User updated successfully",
}
_ = components.UserManagementContent(data).Render(ctx, c.Writer)
} else {
// Redirect to user list with success message for regular requests
c.Redirect(http.StatusFound, "/admin/users?success=User+updated+successfully")
}
}
// HandleDeleteUser processes user deletion
func (h *Handlers) HandleDeleteUser(c *gin.Context) {
userID := c.Param("id")
// Fetch user
var user db.User
if err := h.DB.First(&user, userID).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
return
}
// Check if this is an admin user
if user.GetIsAdmin() {
// Count how many admins there are
var adminCount int64
if err := h.DB.Model(&db.User{}).Where("metadata->>'is_admin' = 'true'").Count(&adminCount).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check admin count"})
return
}
// If this is the last admin, prevent deletion
if adminCount <= 1 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Cannot delete the last administrator"})
return
}
}
// Check if trying to delete yourself
currentUserID := getUserID(c)
if currentUserID == user.ID {
c.JSON(http.StatusBadRequest, gin.H{"error": "Cannot delete your own account"})
return
}
// Start transaction
tx := h.DB.Begin()
if err := tx.Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
return
}
// Create audit log entry before deletion
auditLog := &db.AuditLog{
Action: "delete",
EntityType: "user",
EntityID: user.ID,
UserID: currentUserID,
Timestamp: time.Now(),
Details: map[string]interface{}{
"email": user.Email,
"is_admin": user.GetIsAdmin(),
"account_locked": user.GetAccountLocked(),
},
}
if err := tx.Create(auditLog).Error; err != nil {
tx.Rollback()
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create audit log"})
return
}
// Remove roles association
if err := tx.Model(&user).Association("Roles").Clear(); err != nil {
tx.Rollback()
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to clear user roles"})
return
}
// Delete user
if err := tx.Delete(&user).Error; err != nil {
tx.Rollback()
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete user"})
return
}
// Commit transaction
if err := tx.Commit().Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to commit transaction"})
return
}
// Fetch the updated user list to return
ctx := components.CreateTemplateContext(c)
var users []db.User
if err := h.DB.Find(&users).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch updated user list"})
return
}
// Preload roles for each user
for i := range users {
if err := h.DB.Model(&users[i]).Association("Roles").Find(&users[i].Roles); err != nil {
// Log error but continue
fmt.Printf("Error loading roles for user %d: %v\n", users[i].ID, err)
}
}
// Render the updated user list
data := components.UserManagementData{
Users: users,
ActiveTab: "list",
SuccessMessage: "User deleted successfully",
}
// Always use the partial for HTMX delete requests
_ = components.UserManagementContent(data).Render(ctx, c.Writer)
}
+2 -1
View File
@@ -6,6 +6,7 @@ import (
"net/http"
"net/url"
"strconv"
"time"
"github.com/gin-gonic/gin"
"github.com/starfleetcptn/gomft/components"
@@ -24,7 +25,7 @@ func (h *Handlers) HandleDashboard(c *gin.Context) {
h.DB.Model(&db.JobHistory{}).Where("job_histories.status = 'running' AND job_histories.end_time IS NULL").Count(&totalJobs)
var completedJobs int64
h.DB.Model(&db.JobHistory{}).Where("status = ?", "completed").Count(&completedJobs)
h.DB.Model(&db.JobHistory{}).Where("status = ? AND start_time >= ?", "completed", time.Now().AddDate(0, 0, -1)).Count(&completedJobs)
var failedJobs int64
h.DB.Model(&db.JobHistory{}).Where("status = ?", "failed").Count(&failedJobs)
+3 -3
View File
@@ -99,17 +99,17 @@ func (h *Handlers) RegisterRoutes(router *gin.Engine) {
admin.Use(h.AuthMiddleware())
{
// Main admin dashboard - requires admin role
admin.GET("", h.AdminMiddleware(), h.HandleAdminDashboard)
// admin.GET("", h.AdminMiddleware(), h.HandleAdminDashboard)
// User management routes
userGroup := admin.Group("/users")
userGroup.Use(h.PermissionMiddleware("users.view"))
{
userGroup.GET("", h.HandleUsers)
userGroup.GET("/new", h.PermissionMiddleware("users.create"), h.AdminNewUserPage)
userGroup.GET("/new", h.PermissionMiddleware("users.create"), h.HandleNewUser)
userGroup.POST("", h.PermissionMiddleware("users.create"), h.HandleCreateUser)
userGroup.GET("/:id/edit", h.PermissionMiddleware("users.edit"), h.HandleEditUser)
userGroup.PUT("/:id", h.PermissionMiddleware("users.edit"), h.AdminUpdateUser)
userGroup.PUT("/:id", h.PermissionMiddleware("users.edit"), h.HandleUpdateUser)
userGroup.DELETE("/:id", h.PermissionMiddleware("users.delete"), h.HandleDeleteUser)
userGroup.PUT("/:id/toggle-lock", h.PermissionMiddleware("users.edit"), h.AdminToggleLockUser)
}
+104 -104
View File
@@ -16,100 +16,100 @@ import (
)
// HandleUsers handles the GET /admin/users route
func (h *Handlers) HandleUsers(c *gin.Context) {
var users []db.User
if err := h.DB.Find(&users).Error; err != nil {
c.String(http.StatusInternalServerError, "Failed to retrieve users")
return
}
// func (h *Handlers) HandleUsers(c *gin.Context) {
// var users []db.User
// if err := h.DB.Find(&users).Error; err != nil {
// c.String(http.StatusInternalServerError, "Failed to retrieve users")
// return
// }
// Create the data for the Users component, not UserManagement
data := components.UsersData{
Users: users,
}
// // Create the data for the Users component, not UserManagement
// data := components.UsersData{
// Users: users,
// }
// Use the Users component from users.templ and ensure context flows through consistently
ctx := h.CreateTemplateContext(c)
components.Users(ctx, data).Render(ctx, c.Writer)
}
// // Use the Users component from users.templ and ensure context flows through consistently
// ctx := h.CreateTemplateContext(c)
// components.Users(ctx, data).Render(ctx, c.Writer)
// }
// HandleNewUser handles the GET /admin/users/new route
func (h *Handlers) HandleNewUser(c *gin.Context) {
// Create the data for the UserForm component
data := components.UserFormData{
IsNew: true,
ErrorMessage: "",
}
// func (h *Handlers) HandleNewUser(c *gin.Context) {
// // Create the data for the UserForm component
// data := components.UserFormData{
// IsNew: true,
// ErrorMessage: "",
// }
// Use consistent context handling
ctx := h.CreateTemplateContext(c)
err := components.UserForm(ctx, data).Render(ctx, c.Writer)
if err != nil {
log.Printf("ERROR rendering UserForm: %v", err)
c.String(http.StatusInternalServerError, "Error rendering form: %v", err)
return
}
}
// // Use consistent context handling
// ctx := h.CreateTemplateContext(c)
// err := components.UserForm(ctx, data).Render(ctx, c.Writer)
// if err != nil {
// log.Printf("ERROR rendering UserForm: %v", err)
// c.String(http.StatusInternalServerError, "Error rendering form: %v", err)
// return
// }
// }
// HandleCreateUser handles the POST /admin/users/new route
func (h *Handlers) HandleCreateUser(c *gin.Context) {
email := c.PostForm("email")
password := c.PostForm("password")
isAdmin := c.PostForm("is_admin") == "on"
// func (h *Handlers) HandleCreateUser(c *gin.Context) {
// email := c.PostForm("email")
// password := c.PostForm("password")
// isAdmin := c.PostForm("is_admin") == "on"
// Check if email already exists
var existingUser db.User
if err := h.DB.Where("email = ?", email).First(&existingUser).Error; err == nil {
c.String(http.StatusBadRequest, "Email already exists")
return
}
// // Check if email already exists
// var existingUser db.User
// if err := h.DB.Where("email = ?", email).First(&existingUser).Error; err == nil {
// c.String(http.StatusBadRequest, "Email already exists")
// return
// }
// Hash the password
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
c.String(http.StatusInternalServerError, "Failed to hash password")
return
}
// // Hash the password
// hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
// if err != nil {
// c.String(http.StatusInternalServerError, "Failed to hash password")
// return
// }
// Create the user
user := db.User{
Email: email,
PasswordHash: string(hashedPassword),
LastPasswordChange: time.Now(),
}
user.SetIsAdmin(isAdmin)
// // Create the user
// user := db.User{
// Email: email,
// PasswordHash: string(hashedPassword),
// LastPasswordChange: time.Now(),
// }
// user.SetIsAdmin(isAdmin)
if err := h.DB.Create(&user).Error; err != nil {
c.String(http.StatusInternalServerError, "Failed to create user")
return
}
// if err := h.DB.Create(&user).Error; err != nil {
// c.String(http.StatusInternalServerError, "Failed to create user")
// return
// }
c.Redirect(http.StatusSeeOther, "/admin/users")
}
// c.Redirect(http.StatusSeeOther, "/admin/users")
// }
// HandleDeleteUser handles the POST /admin/users/delete route
func (h *Handlers) HandleDeleteUser(c *gin.Context) {
userID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.String(http.StatusBadRequest, "Invalid user ID")
return
}
// func (h *Handlers) HandleDeleteUser(c *gin.Context) {
// userID, err := strconv.ParseUint(c.Param("id"), 10, 32)
// if err != nil {
// c.String(http.StatusBadRequest, "Invalid user ID")
// return
// }
// Don't allow deleting the current user
currentUserID := c.GetUint("userID")
if uint(userID) == currentUserID {
c.String(http.StatusBadRequest, "Cannot delete your own account")
return
}
// // Don't allow deleting the current user
// currentUserID := c.GetUint("userID")
// if uint(userID) == currentUserID {
// c.String(http.StatusBadRequest, "Cannot delete your own account")
// return
// }
// Delete the user
if err := h.DB.Delete(&db.User{}, userID).Error; err != nil {
c.String(http.StatusInternalServerError, "Failed to delete user")
return
}
// // Delete the user
// if err := h.DB.Delete(&db.User{}, userID).Error; err != nil {
// c.String(http.StatusInternalServerError, "Failed to delete user")
// return
// }
c.Redirect(http.StatusSeeOther, "/admin/users")
}
// c.Redirect(http.StatusSeeOther, "/admin/users")
// }
// HandleRegisterPage handles the GET /register route
func (h *Handlers) HandleRegisterPage(c *gin.Context) {
@@ -304,37 +304,37 @@ func (h *Handlers) AdminCreateUser(c *gin.Context) {
}
// HandleEditUser handles the GET /admin/users/:id/edit route
func (h *Handlers) HandleEditUser(c *gin.Context) {
id := c.Param("id")
// func (h *Handlers) HandleEditUser(c *gin.Context) {
// id := c.Param("id")
var user db.User
if err := h.DB.First(&user, id).Error; err != nil {
c.Redirect(http.StatusFound, "/admin/users")
return
}
// var user db.User
// if err := h.DB.First(&user, id).Error; err != nil {
// c.Redirect(http.StatusFound, "/admin/users")
// return
// }
// Get user's roles
userRoles, err := h.DB.GetUserRoles(user.ID)
if err != nil {
log.Printf("Error fetching user roles: %v", err)
}
// // Get user's roles
// userRoles, err := h.DB.GetUserRoles(user.ID)
// if err != nil {
// log.Printf("Error fetching user roles: %v", err)
// }
// Get all available roles
var allRoles []db.Role
if err := h.DB.Find(&allRoles).Error; err != nil {
log.Printf("Error fetching roles: %v", err)
}
// // Get all available roles
// var allRoles []db.Role
// if err := h.DB.Find(&allRoles).Error; err != nil {
// log.Printf("Error fetching roles: %v", err)
// }
// Use UserEdit component to match the form that's being submitted
data := components.UserEditData{
User: &user,
Roles: allRoles,
UserRoles: userRoles,
IsNew: false,
}
ctx := h.CreateTemplateContext(c)
components.UserEdit(ctx, data).Render(ctx, c.Writer)
}
// // Use UserEdit component to match the form that's being submitted
// data := components.UserEditData{
// User: &user,
// Roles: allRoles,
// UserRoles: userRoles,
// IsNew: false,
// }
// ctx := h.CreateTemplateContext(c)
// components.UserEdit(ctx, data).Render(ctx, c.Writer)
// }
// AdminUpdateUser handles the PUT /admin/users/:id route
func (h *Handlers) AdminUpdateUser(c *gin.Context) {