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 // Create toast element
const toast = document.createElement('div'); const toast = document.createElement('div');
toast.id = 'toast-' + type + '-' + Date.now(); 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'; toast.role = 'alert';
// Set toast content based on type // Set toast content based on type
@@ -301,15 +301,25 @@ templ AdminDatabaseTools(ctx context.Context, backups []BackupFile) {
// Add toast to container // Add toast to container
toastContainer.appendChild(toast); 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 // Add event listener to close button
const closeButton = toast.querySelector('button[data-dismiss-target]'); const closeButton = toast.querySelector('button[data-dismiss-target]');
closeButton.addEventListener('click', function() { 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 // Auto-remove toast after 5 seconds
setTimeout(() => { setTimeout(() => {
toast.classList.add('opacity-0', 'transition-opacity', 'duration-300'); toast.classList.add('opacity-0', 'translate-y-4');
setTimeout(() => { setTimeout(() => {
toast.remove(); toast.remove();
}, 300); }, 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"> <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> <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> <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=""> <div>
<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"> <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 Yes, delete it
</button> </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 No, cancel
</button> </button>
</form> </div>
</div> </div>
</div> </div>
</div> </div>
@@ -109,14 +115,52 @@ templ AdminRoles(ctx context.Context, data RolesData) {
<script> <script>
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
let currentRoleId = null;
const modal = document.getElementById('confirm-delete-modal');
// Handle delete modal // Handle delete modal
const deleteButtons = document.querySelectorAll('[data-modal-target="confirm-delete-modal"]'); const deleteButtons = document.querySelectorAll('[data-modal-target="confirm-delete-modal"]');
deleteButtons.forEach(button => { deleteButtons.forEach(button => {
button.addEventListener('click', function() { button.addEventListener('click', function() {
const roleId = this.getAttribute('data-role-id'); currentRoleId = this.getAttribute('data-role-id');
document.getElementById('delete-form').action = `/admin/roles/${roleId}/delete`;
}); });
}); });
// 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> </script>
} }
+223 -201
View File
@@ -43,214 +43,216 @@ func GetRcloneVersion() string {
templ Dashboard(ctx context.Context, data DashboardData) { templ Dashboard(ctx context.Context, data DashboardData) {
@LayoutWithContext("Dashboard", ctx) { @LayoutWithContext("Dashboard", ctx) {
<div class="p-4 md:p-6 2xl:p-10"> <div id="dashboard-container" style="min-height: 100vh;" class="bg-gray-50 dark:bg-gray-900">
<!-- Page Header --> <div class="pb-8 w-full">
<div class="flex flex-col md:flex-row items-start md:items-center justify-between mb-6 md:mb-8 gap-4"> <!-- Page Header -->
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center"> <div class="mb-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<i class="fas fa-chart-pie w-6 h-6 mr-2 text-blue-500 dark:text-blue-400"></i> <h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
Dashboard <i class="fas fa-chart-pie w-6 h-6 mr-2 text-blue-500 dark:text-blue-400"></i>
</h1> Dashboard
<div class="bg-white dark:bg-gray-800 p-2 px-4 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700"> </h1>
<span id="current-date" class="font-medium text-gray-700 dark:text-gray-300"></span> <div class="bg-white dark:bg-gray-800 p-2 px-4 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
<script> <span id="current-date" class="font-medium text-gray-700 dark:text-gray-300"></span>
document.getElementById('current-date').textContent = new Date().toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }); <script>
</script> document.getElementById('current-date').textContent = new Date().toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
</div> </script>
</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> </div>
</div> </div>
<!-- Completed Today --> <!-- Stats Overview Cards -->
<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="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 mb-6">
<div class="flex items-center justify-between mb-4"> <!-- Active Transfers -->
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Completed Today</h3> <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="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"> <div class="flex items-center justify-between mb-4">
<i class="fas fa-check-circle w-6 h-6 flex items-center justify-center"></i> <h3 class="text-lg font-semibold text-gray-900 dark:text-white">Active Transfers</h3>
</div> <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">
</div> <i class="fas fa-exchange-alt w-6 h-6 flex items-center justify-center"></i>
<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> </div>
} else { </div>
<div class="flow-root"> <div class="flex items-center">
<ul class="divide-y divide-gray-200 dark:divide-gray-700"> <div class="flex-shrink-0">
for _, job := range data.RecentJobs { <span class="text-4xl font-bold leading-none text-gray-900 dark:text-white">{ strconv.Itoa(data.ActiveTransfers) }</span>
<li class="py-3 sm:py-4 hover:bg-gray-50 dark:hover:bg-gray-700 px-3 rounded-lg transition-colors"> </div>
<div class="flex items-center"> <div class="ml-3 flex items-baseline text-sm font-medium">
<div class="flex-shrink-0"> <span class="text-gray-500 dark:text-gray-400">Currently in progress</span>
if job.Status == "completed" { </div>
<span class="inline-flex items-center justify-center h-12 w-12 rounded-full text-green-600 bg-green-100 dark:bg-green-900"> </div>
<i class="fas fa-check-circle w-6 h-6 flex items-center justify-center"></i> </div>
</span>
} else if job.Status == "failed" { <!-- Completed Today -->
<span class="inline-flex items-center justify-center h-12 w-12 rounded-full text-red-600 bg-red-100 dark:bg-red-900"> <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">
<i class="fas fa-exclamation-circle w-6 h-6 flex items-center justify-center"></i> <div class="flex items-center justify-between mb-4">
</span> <h3 class="text-lg font-semibold text-gray-900 dark:text-white">Completed Today</h3>
} else { <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">
<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-check-circle w-6 h-6 flex items-center justify-center"></i>
<i class="fas fa-tasks w-6 h-6 flex items-center justify-center"></i> </div>
</span> </div>
} <div class="flex items-center">
</div> <div class="flex-shrink-0">
<div class="flex-1 min-w-0 ms-4"> <span class="text-4xl font-bold leading-none text-gray-900 dark:text-white">{ strconv.Itoa(data.CompletedToday) }</span>
<p class="text-sm font-medium text-gray-900 truncate dark:text-white"> </div>
{ getConfigNameForHistory(job, data.Configs) } <div class="ml-3 flex items-baseline text-sm font-medium">
</p> <span class="text-gray-500 dark:text-gray-400">Successfully completed</span>
<div class="flex items-center mt-1"> </div>
<i class="fas fa-clock w-4 h-4 text-gray-500 dark:text-gray-400 mr-1"></i> </div>
<p class="text-sm text-gray-500 truncate dark:text-gray-400"> </div>
Started: { job.StartTime.Format("Jan 02, 2006 15:04:05") }
</p> <!-- 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>
<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> </div>
<a href={ templ.SafeURL(fmt.Sprintf("/job-runs/%d", job.ID)) } </li>
class="inline-flex items-center text-sm font-medium text-blue-600 hover:underline dark:text-blue-500"> }
View </ul>
<i class="fas fa-arrow-right w-3 h-3 ms-2"></i> </div>
</a> <div class="pt-4 border-t border-gray-200 dark:border-gray-700 mt-4">
</div> <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">
</li> <i class="fas fa-list w-4 h-4 mr-2"></i>
} View all jobs
</ul> </a>
</div> </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"> </div>
<i class="fas fa-list w-4 h-4 mr-2"></i>
View all jobs
</a>
</div>
}
</div> </div>
</div>
<!-- Quick Actions Card -->
<!-- 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="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">
<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">
<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>
<i class="fas fa-bolt w-6 h-6 mr-2 text-blue-500 dark:text-blue-400 flex-shrink-0"></i> Quick Actions
Quick Actions </h3>
</h3> </div>
</div> <div class="p-4 space-y-4">
<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">
<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>
<i class="fas fa-plus w-5 h-5 mr-2"></i> Create New Config
Create New Config </a>
</a> <a href="/jobs/new" class="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">
<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>
<i class="fas fa-tasks w-5 h-5 mr-2"></i> Create New Job
Create New Job </a>
</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">
<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>
<i class="fas fa-history w-5 h-5 mr-2"></i> View Transfer History
View Transfer History </a>
</a>
<!-- System Status Card with Flowbite design -->
<!-- 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="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">
<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>
<h4 class="text-sm font-medium text-gray-900 dark:text-white">System Status</h4> </div>
</div> <div class="px-4 py-3">
<div class="px-4 py-3"> <div class="space-y-3">
<div class="space-y-3"> <div class="flex items-center justify-between">
<div class="flex items-center justify-between"> <span class="text-sm text-gray-700 dark:text-gray-300">Server</span>
<span class="text-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="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>
<span class="w-2 h-2 mr-1 bg-green-500 rounded-full"></span> Online
Online </span>
</span> </div>
</div> <div class="flex items-center justify-between">
<div class="flex items-center justify-between"> <span class="text-sm text-gray-700 dark:text-gray-300">Scheduler</span>
<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="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>
<span class="w-2 h-2 mr-1 bg-green-500 rounded-full"></span> Running
Running </span>
</span> </div>
</div> <div class="flex items-center justify-between">
<div class="flex items-center justify-between"> <span class="text-sm text-gray-700 dark:text-gray-300">Database</span>
<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="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>
<span class="w-2 h-2 mr-1 bg-green-500 rounded-full"></span> Connected
Connected </span>
</span> </div>
</div> <div class="flex items-center justify-between">
<div class="flex items-center justify-between"> <span class="text-sm text-gray-700 dark:text-gray-300">Rclone</span>
<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">
<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>
<i class="fas fa-terminal w-3 h-3 mr-1"></i> if data.RcloneVersion != "" {
if data.RcloneVersion != "" { { data.RcloneVersion }
{ data.RcloneVersion } } else {
} else { Unknown
Unknown }
} </span>
</span> </div>
</div> </div>
</div> </div>
</div> </div>
@@ -258,6 +260,26 @@ templ Dashboard(ctx context.Context, data DashboardData) {
</div> </div>
</div> </div>
</div> </div>
<script>
// Set dark background color if in dark mode
if (document.documentElement.classList.contains('dark')) {
document.getElementById('dashboard-container').style.backgroundColor = '#111827';
}
// Add event listener for theme changes
document.addEventListener('DOMContentLoaded', function() {
const themeToggle = document.getElementById('theme-toggle');
if (themeToggle) {
themeToggle.addEventListener('click', function() {
setTimeout(function() {
const isDark = document.documentElement.classList.contains('dark');
document.getElementById('dashboard-container').style.backgroundColor = isDark ? '#111827' : 'rgb(249, 250, 251)';
}, 50);
});
}
});
</script>
</div> </div>
} }
} }
+343 -273
View File
@@ -341,17 +341,15 @@ templ FileMetadataListPartial(data FileMetadataListData) {
// Update the main FileMetadataList template to use the partial template // Update the main FileMetadataList template to use the partial template
templ FileMetadataList(ctx context.Context, data FileMetadataListData) { templ FileMetadataList(ctx context.Context, data FileMetadataListData) {
@LayoutWithContext("File Metadata", ctx) { @LayoutWithContext("File Metadata", ctx) {
<div class="px-2 py-4 sm:px-4 w-full"> <div id="file-metadata-container" style="min-height: 100vh;" class="bg-gray-50 dark:bg-gray-900">
<div class="mb-6"> <div class="pb-8 w-full">
<div class="flex items-center justify-between mb-4"> <div class="mb-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<h1 class="text-2xl font-semibold text-gray-900 dark:text-white"> <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 { if data.Job != nil {
Files for Job: { data.Job.Name } Files for Job: { data.Job.Name }
} else { } else {
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center"> File Metadata
<i class="fas fa-file-alt w-6 h-6 mr-2 text-blue-500"></i>
File Metadata
</h1>
} }
</h1> </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"> <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> </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> </div>
} }
} }
@@ -432,213 +450,239 @@ templ FileMetadataList(ctx context.Context, data FileMetadataListData) {
// FileMetadataDetails renders detailed information about a file // FileMetadataDetails renders detailed information about a file
templ FileMetadataDetails(ctx context.Context, data FileMetadataDetailsData) { templ FileMetadataDetails(ctx context.Context, data FileMetadataDetailsData) {
@LayoutWithContext("File Details", ctx) { @LayoutWithContext("File Details", ctx) {
<div class="px-2 py-4 sm:px-4 w-full"> <div id="file-details-container" style="min-height: 100vh;" class="bg-gray-50 dark:bg-gray-900">
<div class="mb-6"> <div class="pb-8 w-full">
<a href="/files" class="inline-flex items-center text-blue-600 hover:underline dark:text-blue-500"> <div class="mb-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<i class="fas fa-arrow-left mr-2"></i> Back to Files <h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
</a> <i class="fas fa-file-alt w-6 h-6 mr-2 text-blue-500"></i>
</div> File Details: { data.File.FileName }
</h1>
<div class="bg-white border border-gray-200 rounded-lg shadow dark:bg-gray-800 dark:border-gray-700 w-full"> <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">
<!-- Card header --> <i class="fas fa-arrow-left mr-2"></i> Back to Files
<div class="p-4 md:p-5 border-b border-gray-200 dark:border-gray-700"> </a>
<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> </div>
<!-- Card content --> <div class="bg-white border border-gray-200 rounded-lg shadow dark:bg-gray-800 dark:border-gray-700 w-full">
<div class="p-4 md:p-5"> <!-- Card header -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6"> <div class="p-4 md:p-5 border-b border-gray-200 dark:border-gray-700">
<!-- File Information --> <h5 class="text-xl font-bold leading-none text-gray-900 dark:text-white">
<div> { data.File.FileName }
<h6 class="text-lg font-semibold mb-4 text-gray-900 dark:text-white flex items-center"> </h5>
<i class="fas fa-file-alt mr-2 text-gray-500 dark:text-gray-400"></i> File Information <p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
</h6> File ID: { strconv.FormatUint(uint64(data.File.ID), 10) }
<div class="overflow-x-auto relative shadow-md sm:rounded-lg"> </p>
<table class="w-full text-sm text-left text-gray-500 dark:text-gray-400"> </div>
<tbody>
<tr class="border-b dark:border-gray-700"> <!-- Card content -->
<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"> <div class="p-4 md:p-5">
Filename <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
</th> <!-- File Information -->
<td class="py-3 px-4 bg-white dark:bg-gray-800"> <div>
{ data.File.FileName } <h6 class="text-lg font-semibold mb-4 text-gray-900 dark:text-white flex items-center">
</td> <i class="fas fa-file-alt mr-2 text-gray-500 dark:text-gray-400"></i> File Information
</tr> </h6>
<tr class="border-b dark:border-gray-700"> <div class="overflow-x-auto relative shadow-md sm:rounded-lg">
<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"> <table class="w-full text-sm text-left text-gray-500 dark:text-gray-400">
Size <tbody>
</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 != "" {
<tr class="border-b dark:border-gray-700"> <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"> <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> </th>
<td class="py-3 px-4 break-all bg-white dark:bg-gray-800"> <td class="py-3 px-4 break-all bg-white dark:bg-gray-800">
<div class="flex items-start"> if data.File.FileHash != "" {
<i class="fas fa-exclamation-triangle mt-1 mr-2 text-red-500"></i> <span class="font-mono">{ data.File.FileHash }</span>
<span class="text-red-600 dark:text-red-400">{ data.File.ErrorMessage }</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> </div>
</td> </td>
</tr> </tr>
} <tr class="border-b dark:border-gray-700">
<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">
<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
Record Created </th>
</th> <td class="py-3 px-4 break-all bg-white dark:bg-gray-800">
<td class="py-3 px-4 bg-white dark:bg-gray-800"> <div class="flex items-center">
{ data.File.CreatedAt.Format("2006-01-02 15:04:05") } <i class="fas fa-folder-open mr-2 text-blue-500"></i>
</td> <span>{ data.File.DestinationPath }</span>
</tr> </div>
<tr class="border-b dark:border-gray-700"> </td>
<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"> </tr>
Record Updated </tbody>
</th> </table>
<td class="py-3 px-4 bg-white dark:bg-gray-800"> </div>
{ data.File.UpdatedAt.Format("2006-01-02 15:04:05") } </div>
</td>
</tr> <!-- Processing Information -->
</tbody> <div>
</table> <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> </div>
</div>
<!-- Delete dialog -->
<!-- Delete dialog --> @FileMetadataDialog(
@FileMetadataDialog( fmt.Sprintf("delete-file-dialog-%d", data.File.ID),
fmt.Sprintf("delete-file-dialog-%d", data.File.ID), "Delete File Metadata",
"Delete File Metadata", fmt.Sprintf("Are you sure you want to delete the metadata for '%s'? This cannot be undone.", data.File.FileName),
fmt.Sprintf("Are you sure you want to delete the metadata for '%s'? This cannot be undone.", data.File.FileName), "btn-danger",
"btn-danger", "Delete",
"Delete", "delete",
"delete", data.File.ID,
data.File.ID, data.File.FileName,
data.File.FileName, "details",
"details", )
)
<!-- Action buttons -->
<!-- Action buttons --> <div class="mt-6 flex flex-wrap justify-end gap-3">
<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">
<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
<i class="fas fa-list mr-2"></i> Back to Files </a>
</a> <button
<button type="button"
type="button" onclick={ showFileDialog(fmt.Sprintf("delete-file-dialog-%d", data.File.ID)) }
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">
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
<i class="fas fa-trash mr-2"></i> Delete Record </button>
</button> </div>
</div> </div>
</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> </div>
} }
} }
@@ -646,82 +690,108 @@ templ FileMetadataDetails(ctx context.Context, data FileMetadataDetailsData) {
// FileMetadataSearch renders an advanced search form for file metadata // FileMetadataSearch renders an advanced search form for file metadata
templ FileMetadataSearch(ctx context.Context, data FileMetadataSearchData) { templ FileMetadataSearch(ctx context.Context, data FileMetadataSearchData) {
@LayoutWithContext("Search Files", ctx) { @LayoutWithContext("Search Files", ctx) {
<div class="px-2 py-4 sm:px-4 w-full"> <div id="file-search-container" style="min-height: 100vh;" class="bg-gray-50 dark:bg-gray-900">
<div class="mb-6"> <div class="pb-8 w-full">
<a href="/files" class="inline-flex items-center text-blue-600 hover:underline dark:text-blue-500"> <div class="mb-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<i class="fas fa-arrow-left mr-2"></i> Back to Files <h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
</a> <i class="fas fa-search w-6 h-6 mr-2 text-blue-500"></i>
</div> File Metadata Search
</h1>
<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"> <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">
<h5 class="mb-4 text-lg font-semibold text-gray-900 dark:text-white">Advanced File Search</h5> <i class="fas fa-arrow-left mr-2"></i> Back to Files
<form </a>
hx-get="/files/search/partial" </div>
hx-target="#search-results-container"
hx-swap="innerHTML" <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">
hx-indicator="#search-form-loading" <h5 class="mb-4 text-lg font-semibold text-gray-900 dark:text-white">Advanced File Search</h5>
hx-headers='{"X-HX-Request": "true"}' <form
hx-boost="false" hx-get="/files/search/partial"
class="grid grid-cols-1 md:grid-cols-2 gap-4"> hx-target="#search-results-container"
<div> hx-swap="innerHTML"
<label for="job_id" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Job ID</label> hx-indicator="#search-form-loading"
<input type="text" id="job_id" name="job_id" value={ data.Filter.JobID } placeholder="Job ID" hx-headers='{"X-HX-Request": "true"}'
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" /> hx-boost="false"
</div> class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div> <div>
<label for="status" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Status</label> <label for="job_id" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Job ID</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"> <input type="text" id="job_id" name="job_id" value={ data.Filter.JobID } placeholder="Job ID"
<option value="">All Statuses</option> class="bg-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="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>
<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> </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>
</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> </div>
} }
} }
+100 -78
View File
@@ -264,91 +264,113 @@ templ HistoryContent(ctx context.Context, data HistoryData) {
templ History(ctx context.Context, data HistoryData) { templ History(ctx context.Context, data HistoryData) {
@LayoutWithContext("Transfer History", ctx) { @LayoutWithContext("Transfer History", ctx) {
<div class="py-6 px-4 mx-auto max-w-7xl lg:px-8"> <div id="history-container" style="min-height: 100vh;" class="bg-gray-50 dark:bg-gray-900">
<!-- Page Header --> <div class="pb-8 w-full">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between mb-8"> <!-- Page Header -->
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center"> <div class="mb-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<i class="fas fa-history w-6 h-6 mr-2 text-blue-500"></i> <h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
Transfer History <i class="fas fa-history w-6 h-6 mr-2 text-blue-500"></i>
</h1> Transfer History
<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"> </h1>
<i class="fas fa-info-circle mr-2"></i> <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">
<span class="font-medium">Total: { fmt.Sprint(data.Total) } transfers</span> <i class="fas fa-info-circle mr-2"></i>
</div> <span class="font-medium">Total: { fmt.Sprint(data.Total) } transfers</span>
</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>
<div class="flex gap-2"> </div>
<button
type="submit" <!-- Search and Pagination Controls -->
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" <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">
<i class="fas fa-search mr-2 -ml-1"></i> <div class="w-full sm:w-auto relative flex-grow">
Search <div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<span id="search-indicator" class="htmx-indicator ml-2"> <i class="fas fa-search text-gray-400 dark:text-gray-500"></i>
<i class="fas fa-spinner fa-spin"></i> </div>
</span> <input
</button> type="text"
if data.SearchTerm != "" { name="search"
value={ data.SearchTerm }
placeholder="Search by job name or status..."
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full pl-10 pr-3 py-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
/>
<input type="hidden" name="page" value="1" />
<input type="hidden" name="pageSize" value={ fmt.Sprint(data.PageSize) } />
</div>
<div class="flex gap-2">
<button <button
hx-get="/history" type="submit"
hx-target="#history-content" 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"
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> <i class="fas fa-search mr-2 -ml-1"></i>
Clear Search
<span id="clear-indicator" class="htmx-indicator ml-2"> <span id="search-indicator" class="htmx-indicator ml-2">
<i class="fas fa-spinner fa-spin"></i> <i class="fas fa-spinner fa-spin"></i>
</span> </span>
</button> </button>
} if data.SearchTerm != "" {
</div> <button
<div class="ml-auto"> hx-get="/history"
<label class="inline-flex items-center text-sm text-gray-700 dark:text-gray-300"> hx-target="#history-content"
Show hx-indicator="#clear-indicator"
<select hx-vals={ fmt.Sprintf(`{"pageSize": %d}`, data.PageSize) }
name="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"
hx-get="/history" >
hx-target="#history-content" <i class="fas fa-times mr-2"></i>
hx-include="[name='search']" Clear
hx-indicator="#size-indicator" <span id="clear-indicator" class="htmx-indicator ml-2">
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" <i class="fas fa-spinner fa-spin"></i>
> </span>
<option value="10" selected?={ data.PageSize == 10 }>10</option> </button>
<option value="25" selected?={ data.PageSize == 25 }>25</option> }
<option value="50" selected?={ data.PageSize == 50 }>50</option> </div>
<option value="100" selected?={ data.PageSize == 100 }>100</option> <div class="ml-auto">
</select> <label class="inline-flex items-center text-sm text-gray-700 dark:text-gray-300">
entries Show
<span id="size-indicator" class="htmx-indicator ml-2"> <select
<i class="fas fa-spinner fa-spin"></i> name="pageSize"
</span> hx-get="/history"
</label> hx-target="#history-content"
</div> hx-include="[name='search']"
</form> hx-indicator="#size-indicator"
</div> 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"
>
<div id="history-content" class="mt-6"> <option value="10" selected?={ data.PageSize == 10 }>10</option>
@HistoryContent(ctx, data) <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> </div>
<script>
// Set dark background color if in dark mode
if (document.documentElement.classList.contains('dark')) {
document.getElementById('history-container').style.backgroundColor = '#111827';
}
// Add event listener for theme changes
document.addEventListener('DOMContentLoaded', function() {
const themeToggle = document.getElementById('theme-toggle');
if (themeToggle) {
themeToggle.addEventListener('click', function() {
setTimeout(function() {
const isDark = document.documentElement.classList.contains('dark');
document.getElementById('history-container').style.backgroundColor = isDark ? '#111827' : 'rgb(249, 250, 251)';
}, 50);
});
}
});
</script>
</div> </div>
} }
} }
+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) }> <div class={ "w-11 h-11 rounded-full flex items-center justify-center", GetNotificationBgColor(notification.Type) }>
<i class={ GetNotificationIcon(notification.Type) }></i> <i class={ GetNotificationIcon(notification.Type) }></i>
</div> </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) }> // <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> // <i class={ "text-white text-xs", GetNotificationBadgeIcon(notification.Type) }></i>
</div> // </div>
</div> </div>
<div class="pl-3 w-full"> <div class="pl-3 w-full">
<div class="text-gray-500 font-normal text-sm mb-1.5 dark:text-gray-400"> <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) { templ Settings(ctx context.Context, data SettingsData) {
@LayoutWithContext("Application Settings", ctx) { @LayoutWithContext("Application Settings", ctx) {
<div class="p-4 md:p-6 2xl:p-10"> <div id="settings-container" style="min-height: 100vh;" class="bg-gray-50 dark:bg-gray-900">
<div class="mb-6"> <div class="pb-8 w-full">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center"> <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> <i class="fas fa-cog w-6 h-6 mr-2 text-blue-500"></i>
Application Settings Application Settings
</h1> </h1>
@@ -67,16 +68,16 @@ templ Settings(ctx context.Context, data SettingsData) {
<i class="fas fa-bell mr-2"></i>Notifications <i class="fas fa-bell mr-2"></i>Notifications
</button> </button>
</li> </li>
<li class="mr-2" role="presentation"> // <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"> // <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 // <i class="fas fa-sliders-h mr-2"></i>General
</button> // </button>
</li> // </li>
<li role="presentation"> // <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"> // <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 // <i class="fas fa-shield-alt mr-2"></i>Security
</button> // </button>
</li> // </li>
</ul> </ul>
</div> </div>
@@ -435,6 +436,7 @@ templ Settings(ctx context.Context, data SettingsData) {
</div> </div>
</div> </div>
</div> </div>
</div>
} }
@toggleNotificationFields() @toggleNotificationFields()
} }
+70 -76
View File
@@ -7,17 +7,26 @@ import (
) )
type UserManagementData struct { type UserManagementData struct {
Users []db.User Users []db.User
Roles []db.Role Roles []db.Role
ActiveTab string // "list", "create", or "edit" ActiveTab string // "list", "create", or "edit"
EditUser *db.User EditUser *db.User
UserRoles []db.Role UserRoles []db.Role
ErrorMessage string ErrorMessage string
SuccessMessage string
} }
templ UserManagement(ctx context.Context, data UserManagementData) { templ UserManagement(ctx context.Context, data UserManagementData) {
@LayoutWithContext("User Management", ctx) { @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 --> <!-- Header -->
<div class="mb-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4"> <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"> <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 User Management
</h1> </h1>
<div class="flex items-center space-x-2"> <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" 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-get="/admin/users/new"
hx-target="#user-management-content" hx-target="#userManagementContainer"
hx-push-url="true" hx-push-url="true"
> >
<i class="fas fa-user-plus w-4 h-4 mr-2"></i> <i class="fas fa-user-plus w-4 h-4 mr-2"></i>
Add User Add User
</button> </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>
</div> </div>
<!-- Status and Error Messages --> <!-- 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 != "" { 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 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 class="font-medium">{ data.ErrorMessage }</div>
</div> </div>
} }
<!-- Main Content Container -->
<!-- Content --> <div class="bg-white border border-gray-200 rounded-lg shadow-sm 2xl:col-span-2 dark:border-gray-700 dark:bg-gray-800">
<div id="user-management-content"> <!-- Include the appropriate tab -->
if data.ActiveTab == "list" || data.ActiveTab == "" { if data.ActiveTab == "list" || data.ActiveTab == "" {
@userList(data.Users) @userList(data.Users)
} else if data.ActiveTab == "create" { } else if data.ActiveTab == "create" {
@@ -65,7 +69,6 @@ templ UserManagement(ctx context.Context, data UserManagementData) {
@userForm(data.EditUser, false, data.Roles) @userForm(data.EditUser, false, data.Roles)
} }
</div> </div>
<!-- Help Notice --> <!-- Help Notice -->
<div class="mt-8"> <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"> <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>
</div> </div>
} </div>
} }
// User listing component // User listing component
@@ -149,17 +152,16 @@ templ userList(users []db.User) {
<button <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" 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-get={ "/admin/users/" + strconv.Itoa(int(user.ID)) + "/edit" }
hx-target="#user-management-content" hx-target="#userManagementContainer"
hx-push-url="true" hx-push-url="true"
> >
<i class="fas fa-edit w-3.5 h-3.5 mr-1.5"></i> Edit <i class="fas fa-edit w-3.5 h-3.5 mr-1.5"></i> Edit
</button> </button>
<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" 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-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-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 <i class="fas fa-trash-alt w-3.5 h-3.5 mr-1.5"></i> Delete
</button> </button>
@@ -193,15 +195,14 @@ templ userForm(user *db.User, isNew bool, availableRoles []db.Role) {
} }
</p> </p>
</div> </div>
<form
<form class="space-y-4 md:space-y-6"
class="space-y-6"
if isNew { if isNew {
hx-post="/admin/users" hx-post="/admin/users"
} else { } else {
hx-put={ "/admin/users/" + strconv.Itoa(int(user.ID)) } hx-put={ "/admin/users/" + strconv.Itoa(int(user.ID)) }
} }
hx-target="#user-management-content" hx-target="#userManagementContainer"
x-data="{ x-data="{
password: '', password: '',
confirmPassword: '', confirmPassword: '',
@@ -214,19 +215,18 @@ templ userForm(user *db.User, isNew bool, availableRoles []db.Role) {
<div class="space-y-4"> <div class="space-y-4">
<div> <div>
<label for="email" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Email</label> <label for="email" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Email</label>
<input <input
type="email" type="email"
name="email" name="email"
id="email" id="email"
required 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" class="bg-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" placeholder="user@example.com"
if !isNew { if !isNew {
value={ user.Email } value={ user.Email }
} }
/> />
</div> </div>
<div> <div>
<label for="password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white"> <label for="password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
if isNew { if isNew {
@@ -235,28 +235,27 @@ templ userForm(user *db.User, isNew bool, availableRoles []db.Role) {
New Password (leave blank to keep current) New Password (leave blank to keep current)
} }
</label> </label>
<input <input
type="password" type="password"
name="password" name="password"
id="password" id="password"
x-model="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" class="bg-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="••••••••" placeholder="••••••••"
if isNew { if isNew {
required="true" required="true"
} }
/> />
</div> </div>
<div> <div>
<label for="password_confirm" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Confirm Password</label> <label for="password_confirm" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Confirm Password</label>
<input <input
type="password" type="password"
name="password_confirm" name="password_confirm"
id="password_confirm" id="password_confirm"
x-model="confirmPassword" 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" class="bg-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="••••••••" placeholder="••••••••"
if isNew { if isNew {
required="true" required="true"
} }
@@ -265,15 +264,14 @@ templ userForm(user *db.User, isNew bool, availableRoles []db.Role) {
Passwords do not match Passwords do not match
</p> </p>
</div> </div>
<div class="pt-2"> <div class="pt-2">
<div class="flex items-start"> <div class="flex items-start">
<div class="flex items-center h-5"> <div class="flex items-center h-5">
<input <input
id="is_admin" id="is_admin"
name="is_admin" name="is_admin"
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" 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() { if !isNew && user.GetIsAdmin() {
checked="true" checked="true"
} }
@@ -285,18 +283,16 @@ templ userForm(user *db.User, isNew bool, availableRoles []db.Role) {
</div> </div>
</div> </div>
</div> </div>
if !isNew { if !isNew {
<div class="border-t border-gray-200 dark:border-gray-700 pt-4 mt-4"> <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> <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-start">
<div class="flex items-center h-5"> <div class="flex items-center h-5">
<input <input
id="account_locked" id="account_locked"
name="account_locked" name="account_locked"
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" 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() { if user.GetAccountLocked() {
checked="true" checked="true"
} }
@@ -309,7 +305,6 @@ templ userForm(user *db.User, isNew bool, availableRoles []db.Role) {
</div> </div>
</div> </div>
} }
if len(availableRoles) > 0 { if len(availableRoles) > 0 {
<div class="border-t border-gray-200 dark:border-gray-700 pt-4 mt-4"> <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> <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 { for _, role := range availableRoles {
<div class="flex items-start"> <div class="flex items-start">
<div class="flex items-center h-5"> <div class="flex items-center h-5">
<input <input
id={ "role_" + strconv.Itoa(int(role.ID)) } id={ "role_" + strconv.Itoa(int(role.ID)) }
name="roles[]" name="roles[]"
value={ strconv.Itoa(int(role.ID)) } 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" 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) { if !isNew && hasRole(user, role.ID) {
checked="true" checked="true"
@@ -338,20 +333,19 @@ templ userForm(user *db.User, isNew bool, availableRoles []db.Role) {
</div> </div>
} }
</div> </div>
<div class="flex items-center justify-between pt-4 border-t border-gray-200 dark:border-gray-700"> <div class="flex items-center justify-between pt-4 border-t border-gray-200 dark:border-gray-700">
<button <button
type="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" 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-get="/admin/users"
hx-target="#user-management-content" hx-target="#userManagementContainer"
hx-push-url="true" hx-push-url="true"
> >
<i class="fas fa-arrow-left mr-2"></i> <i class="fas fa-arrow-left mr-2"></i>
Cancel Cancel
</button> </button>
<button <button
type="submit" 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" 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 !== ''" x-bind:disabled="!passwordsMatch() && confirmPassword !== ''"
> >
@@ -376,4 +370,4 @@ func hasRole(user *db.User, roleID uint) bool {
} }
} }
return false return false
} }
+26
View File
@@ -14,6 +14,7 @@ import (
"github.com/glebarez/sqlite" "github.com/glebarez/sqlite"
"github.com/starfleetcptn/gomft/internal/db/migrations" "github.com/starfleetcptn/gomft/internal/db/migrations"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm" "gorm.io/gorm"
) )
@@ -1681,3 +1682,28 @@ func (db *DB) UnassignRoleFromUser(roleID, userID, unassignedByID uint) error {
return tx.Commit().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 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/http"
"net/url" "net/url"
"strconv" "strconv"
"time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/starfleetcptn/gomft/components" "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) h.DB.Model(&db.JobHistory{}).Where("job_histories.status = 'running' AND job_histories.end_time IS NULL").Count(&totalJobs)
var completedJobs int64 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 var failedJobs int64
h.DB.Model(&db.JobHistory{}).Where("status = ?", "failed").Count(&failedJobs) 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()) admin.Use(h.AuthMiddleware())
{ {
// Main admin dashboard - requires admin role // Main admin dashboard - requires admin role
admin.GET("", h.AdminMiddleware(), h.HandleAdminDashboard) // admin.GET("", h.AdminMiddleware(), h.HandleAdminDashboard)
// User management routes // User management routes
userGroup := admin.Group("/users") userGroup := admin.Group("/users")
userGroup.Use(h.PermissionMiddleware("users.view")) userGroup.Use(h.PermissionMiddleware("users.view"))
{ {
userGroup.GET("", h.HandleUsers) 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.POST("", h.PermissionMiddleware("users.create"), h.HandleCreateUser)
userGroup.GET("/:id/edit", h.PermissionMiddleware("users.edit"), h.HandleEditUser) 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.DELETE("/:id", h.PermissionMiddleware("users.delete"), h.HandleDeleteUser)
userGroup.PUT("/:id/toggle-lock", h.PermissionMiddleware("users.edit"), h.AdminToggleLockUser) 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 // HandleUsers handles the GET /admin/users route
func (h *Handlers) HandleUsers(c *gin.Context) { // func (h *Handlers) HandleUsers(c *gin.Context) {
var users []db.User // var users []db.User
if err := h.DB.Find(&users).Error; err != nil { // if err := h.DB.Find(&users).Error; err != nil {
c.String(http.StatusInternalServerError, "Failed to retrieve users") // c.String(http.StatusInternalServerError, "Failed to retrieve users")
return // return
} // }
// Create the data for the Users component, not UserManagement // // Create the data for the Users component, not UserManagement
data := components.UsersData{ // data := components.UsersData{
Users: users, // Users: users,
} // }
// Use the Users component from users.templ and ensure context flows through consistently // // Use the Users component from users.templ and ensure context flows through consistently
ctx := h.CreateTemplateContext(c) // ctx := h.CreateTemplateContext(c)
components.Users(ctx, data).Render(ctx, c.Writer) // components.Users(ctx, data).Render(ctx, c.Writer)
} // }
// HandleNewUser handles the GET /admin/users/new route // HandleNewUser handles the GET /admin/users/new route
func (h *Handlers) HandleNewUser(c *gin.Context) { // func (h *Handlers) HandleNewUser(c *gin.Context) {
// Create the data for the UserForm component // // Create the data for the UserForm component
data := components.UserFormData{ // data := components.UserFormData{
IsNew: true, // IsNew: true,
ErrorMessage: "", // ErrorMessage: "",
} // }
// Use consistent context handling // // Use consistent context handling
ctx := h.CreateTemplateContext(c) // ctx := h.CreateTemplateContext(c)
err := components.UserForm(ctx, data).Render(ctx, c.Writer) // err := components.UserForm(ctx, data).Render(ctx, c.Writer)
if err != nil { // if err != nil {
log.Printf("ERROR rendering UserForm: %v", err) // log.Printf("ERROR rendering UserForm: %v", err)
c.String(http.StatusInternalServerError, "Error rendering form: %v", err) // c.String(http.StatusInternalServerError, "Error rendering form: %v", err)
return // return
} // }
} // }
// HandleCreateUser handles the POST /admin/users/new route // HandleCreateUser handles the POST /admin/users/new route
func (h *Handlers) HandleCreateUser(c *gin.Context) { // func (h *Handlers) HandleCreateUser(c *gin.Context) {
email := c.PostForm("email") // email := c.PostForm("email")
password := c.PostForm("password") // password := c.PostForm("password")
isAdmin := c.PostForm("is_admin") == "on" // isAdmin := c.PostForm("is_admin") == "on"
// Check if email already exists // // Check if email already exists
var existingUser db.User // var existingUser db.User
if err := h.DB.Where("email = ?", email).First(&existingUser).Error; err == nil { // if err := h.DB.Where("email = ?", email).First(&existingUser).Error; err == nil {
c.String(http.StatusBadRequest, "Email already exists") // c.String(http.StatusBadRequest, "Email already exists")
return // return
} // }
// Hash the password // // Hash the password
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) // hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil { // if err != nil {
c.String(http.StatusInternalServerError, "Failed to hash password") // c.String(http.StatusInternalServerError, "Failed to hash password")
return // return
} // }
// Create the user // // Create the user
user := db.User{ // user := db.User{
Email: email, // Email: email,
PasswordHash: string(hashedPassword), // PasswordHash: string(hashedPassword),
LastPasswordChange: time.Now(), // LastPasswordChange: time.Now(),
} // }
user.SetIsAdmin(isAdmin) // user.SetIsAdmin(isAdmin)
if err := h.DB.Create(&user).Error; err != nil { // if err := h.DB.Create(&user).Error; err != nil {
c.String(http.StatusInternalServerError, "Failed to create user") // c.String(http.StatusInternalServerError, "Failed to create user")
return // return
} // }
c.Redirect(http.StatusSeeOther, "/admin/users") // c.Redirect(http.StatusSeeOther, "/admin/users")
} // }
// HandleDeleteUser handles the POST /admin/users/delete route // HandleDeleteUser handles the POST /admin/users/delete route
func (h *Handlers) HandleDeleteUser(c *gin.Context) { // func (h *Handlers) HandleDeleteUser(c *gin.Context) {
userID, err := strconv.ParseUint(c.Param("id"), 10, 32) // userID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil { // if err != nil {
c.String(http.StatusBadRequest, "Invalid user ID") // c.String(http.StatusBadRequest, "Invalid user ID")
return // return
} // }
// Don't allow deleting the current user // // Don't allow deleting the current user
currentUserID := c.GetUint("userID") // currentUserID := c.GetUint("userID")
if uint(userID) == currentUserID { // if uint(userID) == currentUserID {
c.String(http.StatusBadRequest, "Cannot delete your own account") // c.String(http.StatusBadRequest, "Cannot delete your own account")
return // return
} // }
// Delete the user // // Delete the user
if err := h.DB.Delete(&db.User{}, userID).Error; err != nil { // if err := h.DB.Delete(&db.User{}, userID).Error; err != nil {
c.String(http.StatusInternalServerError, "Failed to delete user") // c.String(http.StatusInternalServerError, "Failed to delete user")
return // return
} // }
c.Redirect(http.StatusSeeOther, "/admin/users") // c.Redirect(http.StatusSeeOther, "/admin/users")
} // }
// HandleRegisterPage handles the GET /register route // HandleRegisterPage handles the GET /register route
func (h *Handlers) HandleRegisterPage(c *gin.Context) { 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 // HandleEditUser handles the GET /admin/users/:id/edit route
func (h *Handlers) HandleEditUser(c *gin.Context) { // func (h *Handlers) HandleEditUser(c *gin.Context) {
id := c.Param("id") // id := c.Param("id")
var user db.User // var user db.User
if err := h.DB.First(&user, id).Error; err != nil { // if err := h.DB.First(&user, id).Error; err != nil {
c.Redirect(http.StatusFound, "/admin/users") // c.Redirect(http.StatusFound, "/admin/users")
return // return
} // }
// Get user's roles // // Get user's roles
userRoles, err := h.DB.GetUserRoles(user.ID) // userRoles, err := h.DB.GetUserRoles(user.ID)
if err != nil { // if err != nil {
log.Printf("Error fetching user roles: %v", err) // log.Printf("Error fetching user roles: %v", err)
} // }
// Get all available roles // // Get all available roles
var allRoles []db.Role // var allRoles []db.Role
if err := h.DB.Find(&allRoles).Error; err != nil { // if err := h.DB.Find(&allRoles).Error; err != nil {
log.Printf("Error fetching roles: %v", err) // log.Printf("Error fetching roles: %v", err)
} // }
// Use UserEdit component to match the form that's being submitted // // Use UserEdit component to match the form that's being submitted
data := components.UserEditData{ // data := components.UserEditData{
User: &user, // User: &user,
Roles: allRoles, // Roles: allRoles,
UserRoles: userRoles, // UserRoles: userRoles,
IsNew: false, // IsNew: false,
} // }
ctx := h.CreateTemplateContext(c) // ctx := h.CreateTemplateContext(c)
components.UserEdit(ctx, data).Render(ctx, c.Writer) // components.UserEdit(ctx, data).Render(ctx, c.Writer)
} // }
// AdminUpdateUser handles the PUT /admin/users/:id route // AdminUpdateUser handles the PUT /admin/users/:id route
func (h *Handlers) AdminUpdateUser(c *gin.Context) { func (h *Handlers) AdminUpdateUser(c *gin.Context) {