mirror of
https://github.com/StarFleetCPTN/GoMFT.git
synced 2026-09-08 15:41:20 +02:00
feat: Add HTMX-powered dynamic loading for file lists and search results
- Implement partial rendering templates for file metadata list and search - Add HTMX attributes for dynamic pagination and filtering - Create loading indicators for HTMX requests - Enhance user experience with smooth, asynchronous content updates - Improve pagination and filtering interactions using HTMX
This commit is contained in:
+346
-218
@@ -185,6 +185,28 @@ templ FileMetadataList(ctx context.Context, data FileMetadataListData) {
|
||||
console.log("Notyf initialized:", window.notyf);
|
||||
}
|
||||
|
||||
// HTMX indicator styles
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Add CSS for HTMX loading indicators
|
||||
if (!document.getElementById('htmx-indicator-style')) {
|
||||
const style = document.createElement('style');
|
||||
style.id = 'htmx-indicator-style';
|
||||
style.textContent = `
|
||||
.htmx-indicator {
|
||||
opacity: 0;
|
||||
transition: opacity 200ms ease-in;
|
||||
}
|
||||
.htmx-request .htmx-indicator {
|
||||
opacity: 1;
|
||||
}
|
||||
.htmx-request.htmx-indicator {
|
||||
opacity: 1;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
});
|
||||
|
||||
// Track all HTMX events for debugging
|
||||
document.addEventListener('htmx:beforeRequest', function(event) {
|
||||
// Check if this is a DELETE request by examining the URL and method
|
||||
@@ -303,7 +325,12 @@ templ FileMetadataList(ctx context.Context, data FileMetadataListData) {
|
||||
<h2 class="text-lg font-semibold">Filter Files</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="GET" class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<form
|
||||
hx-get="/files?htmx=true"
|
||||
hx-target="#file-list-results"
|
||||
hx-swap="innerHTML"
|
||||
hx-indicator="#filter-loading"
|
||||
class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
if data.Job == nil {
|
||||
<div>
|
||||
<label for="job_id" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Job</label>
|
||||
@@ -331,6 +358,9 @@ templ FileMetadataList(ctx context.Context, data FileMetadataListData) {
|
||||
<button type="submit" class="btn-primary">
|
||||
<i class="fas fa-filter mr-2"></i> Apply Filters
|
||||
</button>
|
||||
<div id="filter-loading" class="htmx-indicator ml-2 flex items-center">
|
||||
<i class="fas fa-circle-notch fa-spin text-primary-600"></i>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -344,115 +374,8 @@ templ FileMetadataList(ctx context.Context, data FileMetadataListData) {
|
||||
Page { strconv.Itoa(data.Page) } of { strconv.Itoa(data.TotalPages) }
|
||||
</div>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
if len(data.Files) > 0 {
|
||||
<!-- Store file IDs for dialogs -->
|
||||
<div id="dialog-container">
|
||||
<!-- Dialogs will be rendered at the bottom of the container, outside the table -->
|
||||
for _, file := range data.Files {
|
||||
@FileMetadataDialog(
|
||||
fmt.Sprintf("delete-file-dialog-%d", file.ID),
|
||||
"Delete File Metadata",
|
||||
fmt.Sprintf("Are you sure you want to delete the metadata for '%s'? This cannot be undone.", file.FileName),
|
||||
"btn-danger",
|
||||
"Delete",
|
||||
"delete",
|
||||
file.ID,
|
||||
file.FileName,
|
||||
"list",
|
||||
)
|
||||
}
|
||||
</div>
|
||||
<table class="table w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Filename</th>
|
||||
<th>Size</th>
|
||||
<th>Processed</th>
|
||||
<th>Status</th>
|
||||
if data.Job == nil {
|
||||
<th>Job</th>
|
||||
}
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
for _, file := range data.Files {
|
||||
<tr id={ fmt.Sprintf("file-row-%d", file.ID) } class="hover:bg-secondary-50 dark:hover:bg-secondary-800 transition-colors duration-150">
|
||||
<td>{ strconv.FormatUint(uint64(file.ID), 10) }</td>
|
||||
<td class="text-primary-600 dark:text-primary-400">
|
||||
<a href={ templ.SafeURL(fmt.Sprintf("/files/%d", file.ID)) } class="hover:underline">
|
||||
{ file.FileName }
|
||||
</a>
|
||||
</td>
|
||||
<td>{ formatFileSize(file.FileSize) }</td>
|
||||
<td>{ file.ProcessedTime.Format("2006-01-02 15:04:05") }</td>
|
||||
<td>
|
||||
<span class={ "badge", getStatusBadgeClass(file.Status) }>
|
||||
{ file.Status }
|
||||
</span>
|
||||
</td>
|
||||
if data.Job == nil && file.Job.ID > 0 {
|
||||
<td>
|
||||
<a href={ templ.SafeURL(fmt.Sprintf("/files/job/%d", file.Job.ID)) } class="hover:underline text-primary-600 dark:text-primary-400">
|
||||
{ file.Job.Name }
|
||||
</a>
|
||||
</td>
|
||||
}
|
||||
<td class="flex space-x-2">
|
||||
<a href={ templ.SafeURL(fmt.Sprintf("/files/%d", file.ID)) } class="text-primary-600 dark:text-primary-400 hover:text-primary-800 dark:hover:text-primary-300">
|
||||
<i class="fas fa-eye"></i>
|
||||
</a>
|
||||
<!-- Delete button that triggers the dialog -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={ showFileDialog(fmt.Sprintf("delete-file-dialog-%d", file.ID)) }
|
||||
class="text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-300">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Pagination -->
|
||||
if data.TotalPages > 1 {
|
||||
<div class="flex justify-center items-center py-4 bg-secondary-50 dark:bg-secondary-800">
|
||||
<div class="flex space-x-1">
|
||||
if data.Page > 1 {
|
||||
<a href={ templ.SafeURL(buildPaginationURL(data, data.Page - 1)) } class="btn-secondary">
|
||||
<i class="fas fa-chevron-left"></i>
|
||||
</a>
|
||||
}
|
||||
|
||||
for i := 1; i <= data.TotalPages; i++ {
|
||||
if i == data.Page {
|
||||
<span class="px-3 py-2 bg-primary-600 text-white rounded">{ strconv.Itoa(i) }</span>
|
||||
} else if i == 1 || i == data.TotalPages || (i >= data.Page-2 && i <= data.Page+2) {
|
||||
<a href={ templ.SafeURL(buildPaginationURL(data, i)) } class="px-3 py-2 bg-secondary-200 text-secondary-800 hover:bg-secondary-300 rounded dark:bg-secondary-700 dark:text-secondary-200 dark:hover:bg-secondary-600">
|
||||
{ strconv.Itoa(i) }
|
||||
</a>
|
||||
} else if i == data.Page-3 || i == data.Page+3 {
|
||||
<span class="px-3 py-2">...</span>
|
||||
}
|
||||
}
|
||||
|
||||
if data.Page < data.TotalPages {
|
||||
<a href={ templ.SafeURL(buildPaginationURL(data, data.Page + 1)) } class="btn-secondary">
|
||||
<i class="fas fa-chevron-right"></i>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
<div class="p-6 text-center text-secondary-600 dark:text-secondary-400">
|
||||
<i class="fas fa-file-alt text-5xl mb-3"></i>
|
||||
<p>No file metadata found matching your criteria.</p>
|
||||
</div>
|
||||
}
|
||||
<div id="file-list-results" class="overflow-x-auto">
|
||||
@FileMetadataListPartial(data)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -466,7 +389,7 @@ func buildPaginationURL(data FileMetadataListData, page int) string {
|
||||
baseURL = fmt.Sprintf("/files/job/%d", data.Job.ID)
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("%s?page=%d&limit=%d", baseURL, page, data.Limit)
|
||||
url := fmt.Sprintf("%s?page=%d&limit=%d&htmx=true", baseURL, page, data.Limit)
|
||||
|
||||
if data.Filter.Status != "" {
|
||||
url += "&status=" + data.Filter.Status
|
||||
@@ -791,6 +714,28 @@ templ FileMetadataSearch(ctx context.Context, data FileMetadataSearchData) {
|
||||
console.log("Notyf initialized:", window.notyf);
|
||||
}
|
||||
|
||||
// HTMX indicator styles
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Add CSS for HTMX loading indicators
|
||||
if (!document.getElementById('htmx-indicator-style')) {
|
||||
const style = document.createElement('style');
|
||||
style.id = 'htmx-indicator-style';
|
||||
style.textContent = `
|
||||
.htmx-indicator {
|
||||
opacity: 0;
|
||||
transition: opacity 200ms ease-in;
|
||||
}
|
||||
.htmx-request .htmx-indicator {
|
||||
opacity: 1;
|
||||
}
|
||||
.htmx-request.htmx-indicator {
|
||||
opacity: 1;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
});
|
||||
|
||||
// Track all HTMX events for debugging
|
||||
document.addEventListener('htmx:beforeRequest', function(event) {
|
||||
// Check if this is a DELETE request by examining the URL and method
|
||||
@@ -903,7 +848,12 @@ templ FileMetadataSearch(ctx context.Context, data FileMetadataSearchData) {
|
||||
</h1>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="GET" class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<form
|
||||
hx-get="/files/search?htmx=true"
|
||||
hx-target="#file-search-results"
|
||||
hx-swap="innerHTML"
|
||||
hx-indicator="#search-form-loading"
|
||||
class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label for="job_id" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Job ID</label>
|
||||
<input type="text" id="job_id" name="job_id" value={ data.Filter.JobID } placeholder="Job ID"
|
||||
@@ -944,6 +894,9 @@ templ FileMetadataSearch(ctx context.Context, data FileMetadataSearchData) {
|
||||
<button type="submit" class="btn-primary">
|
||||
<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-primary-600"></i>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -959,111 +912,8 @@ templ FileMetadataSearch(ctx context.Context, data FileMetadataSearchData) {
|
||||
Page { strconv.Itoa(data.Page) } of { strconv.Itoa(data.TotalPages) }
|
||||
</div>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
if len(data.Files) > 0 {
|
||||
<!-- Store file IDs for dialogs -->
|
||||
<div id="search-dialog-container">
|
||||
<!-- Dialogs will be rendered at the bottom of the container, outside the table -->
|
||||
for _, file := range data.Files {
|
||||
@FileMetadataDialog(
|
||||
fmt.Sprintf("delete-file-dialog-%d", file.ID),
|
||||
"Delete File Metadata",
|
||||
fmt.Sprintf("Are you sure you want to delete the metadata for '%s'? This cannot be undone.", file.FileName),
|
||||
"btn-danger",
|
||||
"Delete",
|
||||
"delete",
|
||||
file.ID,
|
||||
file.FileName,
|
||||
"list",
|
||||
)
|
||||
}
|
||||
</div>
|
||||
<table class="table w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Filename</th>
|
||||
<th>Size</th>
|
||||
<th>Processed</th>
|
||||
<th>Status</th>
|
||||
<th>Job</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
for _, file := range data.Files {
|
||||
<tr id={ fmt.Sprintf("file-row-%d", file.ID) } class="hover:bg-secondary-50 dark:hover:bg-secondary-800 transition-colors duration-150">
|
||||
<td>{ strconv.FormatUint(uint64(file.ID), 10) }</td>
|
||||
<td class="text-primary-600 dark:text-primary-400">
|
||||
<a href={ templ.SafeURL(fmt.Sprintf("/files/%d", file.ID)) } class="hover:underline">
|
||||
{ file.FileName }
|
||||
</a>
|
||||
</td>
|
||||
<td>{ formatFileSize(file.FileSize) }</td>
|
||||
<td>{ file.ProcessedTime.Format("2006-01-02 15:04:05") }</td>
|
||||
<td>
|
||||
<span class={ "badge", getStatusBadgeClass(file.Status) }>
|
||||
{ file.Status }
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href={ templ.SafeURL(fmt.Sprintf("/files/job/%d", file.Job.ID)) } class="hover:underline text-primary-600 dark:text-primary-400">
|
||||
{ file.Job.Name }
|
||||
</a>
|
||||
</td>
|
||||
<td class="flex space-x-2">
|
||||
<a href={ templ.SafeURL(fmt.Sprintf("/files/%d", file.ID)) } class="text-primary-600 dark:text-primary-400 hover:text-primary-800 dark:hover:text-primary-300">
|
||||
<i class="fas fa-eye"></i>
|
||||
</a>
|
||||
<!-- Delete button only - dialog moved outside table -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={ showFileDialog(fmt.Sprintf("delete-file-dialog-%d", file.ID)) }
|
||||
class="text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-300">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Pagination -->
|
||||
if data.TotalPages > 1 {
|
||||
<div class="flex justify-center items-center py-4 bg-secondary-50 dark:bg-secondary-800">
|
||||
<div class="flex space-x-1">
|
||||
if data.Page > 1 {
|
||||
<a href={ templ.SafeURL(buildSearchPaginationURL(data, data.Page - 1)) } class="btn-secondary">
|
||||
<i class="fas fa-chevron-left"></i>
|
||||
</a>
|
||||
}
|
||||
|
||||
for i := 1; i <= data.TotalPages; i++ {
|
||||
if i == data.Page {
|
||||
<span class="px-3 py-2 bg-primary-600 text-white rounded">{ strconv.Itoa(i) }</span>
|
||||
} else if i == 1 || i == data.TotalPages || (i >= data.Page-2 && i <= data.Page+2) {
|
||||
<a href={ templ.SafeURL(buildSearchPaginationURL(data, i)) } class="px-3 py-2 bg-secondary-200 text-secondary-800 hover:bg-secondary-300 rounded dark:bg-secondary-700 dark:text-secondary-200 dark:hover:bg-secondary-600">
|
||||
{ strconv.Itoa(i) }
|
||||
</a>
|
||||
} else if i == data.Page-3 || i == data.Page+3 {
|
||||
<span class="px-3 py-2">...</span>
|
||||
}
|
||||
}
|
||||
|
||||
if data.Page < data.TotalPages {
|
||||
<a href={ templ.SafeURL(buildSearchPaginationURL(data, data.Page + 1)) } class="btn-secondary">
|
||||
<i class="fas fa-chevron-right"></i>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
<div class="p-6 text-center text-secondary-600 dark:text-secondary-400">
|
||||
<i class="fas fa-search text-5xl mb-3"></i>
|
||||
<p>No files found matching your search criteria.</p>
|
||||
</div>
|
||||
}
|
||||
<div id="file-search-results" class="overflow-x-auto">
|
||||
@FileMetadataSearchPartial(data)
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -1073,7 +923,7 @@ templ FileMetadataSearch(ctx context.Context, data FileMetadataSearchData) {
|
||||
|
||||
// buildSearchPaginationURL builds a URL for search pagination links
|
||||
func buildSearchPaginationURL(data FileMetadataSearchData, page int) string {
|
||||
url := fmt.Sprintf("/files/search?page=%d&limit=%d", page, data.Limit)
|
||||
url := fmt.Sprintf("/files/search?page=%d&limit=%d&htmx=true", page, data.Limit)
|
||||
|
||||
if data.Filter.Status != "" {
|
||||
url += "&status=" + data.Filter.Status
|
||||
@@ -1100,4 +950,282 @@ func buildSearchPaginationURL(data FileMetadataSearchData, page int) string {
|
||||
}
|
||||
|
||||
return url
|
||||
}
|
||||
|
||||
// FileMetadataListPartial renders just the list content for HTMX requests
|
||||
templ FileMetadataListPartial(data FileMetadataListData) {
|
||||
if len(data.Files) > 0 {
|
||||
<!-- Store file IDs for dialogs -->
|
||||
<div id="dialog-container">
|
||||
<!-- Dialogs will be rendered at the bottom of the container, outside the table -->
|
||||
for _, file := range data.Files {
|
||||
@FileMetadataDialog(
|
||||
fmt.Sprintf("delete-file-dialog-%d", file.ID),
|
||||
"Delete File Metadata",
|
||||
fmt.Sprintf("Are you sure you want to delete the metadata for '%s'? This cannot be undone.", file.FileName),
|
||||
"btn-danger",
|
||||
"Delete",
|
||||
"delete",
|
||||
file.ID,
|
||||
file.FileName,
|
||||
"list",
|
||||
)
|
||||
}
|
||||
</div>
|
||||
<table class="table w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Filename</th>
|
||||
<th>Size</th>
|
||||
<th>Processed</th>
|
||||
<th>Status</th>
|
||||
if data.Job == nil {
|
||||
<th>Job</th>
|
||||
}
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
for _, file := range data.Files {
|
||||
<tr id={ fmt.Sprintf("file-row-%d", file.ID) } class="hover:bg-secondary-50 dark:hover:bg-secondary-800 transition-colors duration-150">
|
||||
<td>{ strconv.FormatUint(uint64(file.ID), 10) }</td>
|
||||
<td class="text-primary-600 dark:text-primary-400">
|
||||
<a href={ templ.SafeURL(fmt.Sprintf("/files/%d", file.ID)) } class="hover:underline">
|
||||
{ file.FileName }
|
||||
</a>
|
||||
</td>
|
||||
<td>{ formatFileSize(file.FileSize) }</td>
|
||||
<td>{ file.ProcessedTime.Format("2006-01-02 15:04:05") }</td>
|
||||
<td>
|
||||
<span class={ "badge", getStatusBadgeClass(file.Status) }>
|
||||
{ file.Status }
|
||||
</span>
|
||||
</td>
|
||||
if data.Job == nil && file.Job.ID > 0 {
|
||||
<td>
|
||||
<a href={ templ.SafeURL(fmt.Sprintf("/files/job/%d", file.Job.ID)) } class="hover:underline text-primary-600 dark:text-primary-400">
|
||||
{ file.Job.Name }
|
||||
</a>
|
||||
</td>
|
||||
}
|
||||
<td class="flex space-x-2">
|
||||
<a href={ templ.SafeURL(fmt.Sprintf("/files/%d", file.ID)) } class="text-primary-600 dark:text-primary-400 hover:text-primary-800 dark:hover:text-primary-300">
|
||||
<i class="fas fa-eye"></i>
|
||||
</a>
|
||||
<!-- Delete button that triggers the dialog -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={ showFileDialog(fmt.Sprintf("delete-file-dialog-%d", file.ID)) }
|
||||
class="text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-300">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Pagination -->
|
||||
if data.TotalPages > 1 {
|
||||
<div class="flex justify-center items-center py-4 bg-secondary-50 dark:bg-secondary-800 rounded-lg">
|
||||
<div class="flex gap-2">
|
||||
if data.Page > 1 {
|
||||
<a
|
||||
hx-get={ buildPaginationURL(data, data.Page - 1) }
|
||||
hx-target="#file-list-results"
|
||||
hx-swap="innerHTML"
|
||||
hx-indicator="#pagination-loading"
|
||||
class="w-10 h-10 flex items-center justify-center bg-secondary-200 text-secondary-800 hover:bg-secondary-300 rounded dark:bg-secondary-700 dark:text-secondary-200 dark:hover:bg-secondary-600">
|
||||
<i class="fas fa-chevron-left"></i>
|
||||
</a>
|
||||
} else {
|
||||
<span class="w-10 h-10 flex items-center justify-center bg-secondary-100 text-secondary-300 rounded dark:bg-secondary-800 dark:text-secondary-600 cursor-not-allowed">
|
||||
<i class="fas fa-chevron-left"></i>
|
||||
</span>
|
||||
}
|
||||
|
||||
for i := 1; i <= data.TotalPages; i++ {
|
||||
if i == data.Page {
|
||||
<span class="w-10 h-10 flex items-center justify-center bg-primary-600 text-white rounded">
|
||||
{ strconv.Itoa(i) }
|
||||
</span>
|
||||
} else if i == 1 || i == data.TotalPages || (i >= data.Page-2 && i <= data.Page+2) {
|
||||
<a
|
||||
hx-get={ buildPaginationURL(data, i) }
|
||||
hx-target="#file-list-results"
|
||||
hx-swap="innerHTML"
|
||||
hx-indicator="#pagination-loading"
|
||||
class="w-10 h-10 flex items-center justify-center bg-secondary-200 text-secondary-800 hover:bg-secondary-300 rounded dark:bg-secondary-700 dark:text-secondary-200 dark:hover:bg-secondary-600">
|
||||
{ strconv.Itoa(i) }
|
||||
</a>
|
||||
} else if i == data.Page-3 || i == data.Page+3 {
|
||||
<span class="w-10 h-10 flex items-center justify-center">...</span>
|
||||
}
|
||||
}
|
||||
|
||||
if data.Page < data.TotalPages {
|
||||
<a
|
||||
hx-get={ buildPaginationURL(data, data.Page + 1) }
|
||||
hx-target="#file-list-results"
|
||||
hx-swap="innerHTML"
|
||||
hx-indicator="#pagination-loading"
|
||||
class="w-10 h-10 flex items-center justify-center bg-secondary-200 text-secondary-800 hover:bg-secondary-300 rounded dark:bg-secondary-700 dark:text-secondary-200 dark:hover:bg-secondary-600">
|
||||
<i class="fas fa-chevron-right"></i>
|
||||
</a>
|
||||
} else {
|
||||
<span class="w-10 h-10 flex items-center justify-center bg-secondary-100 text-secondary-300 rounded dark:bg-secondary-800 dark:text-secondary-600 cursor-not-allowed">
|
||||
<i class="fas fa-chevron-right"></i>
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
<!-- Add loading indicator for HTMX requests -->
|
||||
<div id="pagination-loading" class="htmx-indicator ml-2">
|
||||
<i class="fas fa-circle-notch fa-spin text-primary-600"></i>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
<div class="p-6 text-center text-secondary-600 dark:text-secondary-400">
|
||||
<i class="fas fa-file-alt text-5xl mb-3"></i>
|
||||
<p>No file metadata found matching your criteria.</p>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
// FileMetadataSearchPartial renders just the search results content for HTMX requests
|
||||
templ FileMetadataSearchPartial(data FileMetadataSearchData) {
|
||||
if len(data.Files) > 0 {
|
||||
<!-- Store file IDs for dialogs -->
|
||||
<div id="search-dialog-container">
|
||||
<!-- Dialogs will be rendered at the bottom of the container, outside the table -->
|
||||
for _, file := range data.Files {
|
||||
@FileMetadataDialog(
|
||||
fmt.Sprintf("delete-file-dialog-%d", file.ID),
|
||||
"Delete File Metadata",
|
||||
fmt.Sprintf("Are you sure you want to delete the metadata for '%s'? This cannot be undone.", file.FileName),
|
||||
"btn-danger",
|
||||
"Delete",
|
||||
"delete",
|
||||
file.ID,
|
||||
file.FileName,
|
||||
"list",
|
||||
)
|
||||
}
|
||||
</div>
|
||||
<table class="table w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Filename</th>
|
||||
<th>Size</th>
|
||||
<th>Processed</th>
|
||||
<th>Status</th>
|
||||
<th>Job</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
for _, file := range data.Files {
|
||||
<tr id={ fmt.Sprintf("file-row-%d", file.ID) } class="hover:bg-secondary-50 dark:hover:bg-secondary-800 transition-colors duration-150">
|
||||
<td>{ strconv.FormatUint(uint64(file.ID), 10) }</td>
|
||||
<td class="text-primary-600 dark:text-primary-400">
|
||||
<a href={ templ.SafeURL(fmt.Sprintf("/files/%d", file.ID)) } class="hover:underline">
|
||||
{ file.FileName }
|
||||
</a>
|
||||
</td>
|
||||
<td>{ formatFileSize(file.FileSize) }</td>
|
||||
<td>{ file.ProcessedTime.Format("2006-01-02 15:04:05") }</td>
|
||||
<td>
|
||||
<span class={ "badge", getStatusBadgeClass(file.Status) }>
|
||||
{ file.Status }
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href={ templ.SafeURL(fmt.Sprintf("/files/job/%d", file.Job.ID)) } class="hover:underline text-primary-600 dark:text-primary-400">
|
||||
{ file.Job.Name }
|
||||
</a>
|
||||
</td>
|
||||
<td class="flex space-x-2">
|
||||
<a href={ templ.SafeURL(fmt.Sprintf("/files/%d", file.ID)) } class="text-primary-600 dark:text-primary-400 hover:text-primary-800 dark:hover:text-primary-300">
|
||||
<i class="fas fa-eye"></i>
|
||||
</a>
|
||||
<!-- Delete button only - dialog moved outside table -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={ showFileDialog(fmt.Sprintf("delete-file-dialog-%d", file.ID)) }
|
||||
class="text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-300">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Pagination -->
|
||||
if data.TotalPages > 1 {
|
||||
<div class="flex justify-center items-center py-4 bg-secondary-50 dark:bg-secondary-800 rounded-lg">
|
||||
<div class="flex gap-2">
|
||||
if data.Page > 1 {
|
||||
<a
|
||||
hx-get={ buildSearchPaginationURL(data, data.Page - 1) }
|
||||
hx-target="#file-search-results"
|
||||
hx-swap="innerHTML"
|
||||
hx-indicator="#search-pagination-loading"
|
||||
class="w-10 h-10 flex items-center justify-center bg-secondary-200 text-secondary-800 hover:bg-secondary-300 rounded dark:bg-secondary-700 dark:text-secondary-200 dark:hover:bg-secondary-600">
|
||||
<i class="fas fa-chevron-left"></i>
|
||||
</a>
|
||||
} else {
|
||||
<span class="w-10 h-10 flex items-center justify-center bg-secondary-100 text-secondary-300 rounded dark:bg-secondary-800 dark:text-secondary-600 cursor-not-allowed">
|
||||
<i class="fas fa-chevron-left"></i>
|
||||
</span>
|
||||
}
|
||||
|
||||
for i := 1; i <= data.TotalPages; i++ {
|
||||
if i == data.Page {
|
||||
<span class="w-10 h-10 flex items-center justify-center bg-primary-600 text-white rounded">
|
||||
{ strconv.Itoa(i) }
|
||||
</span>
|
||||
} else if i == 1 || i == data.TotalPages || (i >= data.Page-2 && i <= data.Page+2) {
|
||||
<a
|
||||
hx-get={ buildSearchPaginationURL(data, i) }
|
||||
hx-target="#file-search-results"
|
||||
hx-swap="innerHTML"
|
||||
hx-indicator="#search-pagination-loading"
|
||||
class="w-10 h-10 flex items-center justify-center bg-secondary-200 text-secondary-800 hover:bg-secondary-300 rounded dark:bg-secondary-700 dark:text-secondary-200 dark:hover:bg-secondary-600">
|
||||
{ strconv.Itoa(i) }
|
||||
</a>
|
||||
} else if i == data.Page-3 || i == data.Page+3 {
|
||||
<span class="w-10 h-10 flex items-center justify-center">...</span>
|
||||
}
|
||||
}
|
||||
|
||||
if data.Page < data.TotalPages {
|
||||
<a
|
||||
hx-get={ buildSearchPaginationURL(data, data.Page + 1) }
|
||||
hx-target="#file-search-results"
|
||||
hx-swap="innerHTML"
|
||||
hx-indicator="#search-pagination-loading"
|
||||
class="w-10 h-10 flex items-center justify-center bg-secondary-200 text-secondary-800 hover:bg-secondary-300 rounded dark:bg-secondary-700 dark:text-secondary-200 dark:hover:bg-secondary-600">
|
||||
<i class="fas fa-chevron-right"></i>
|
||||
</a>
|
||||
} else {
|
||||
<span class="w-10 h-10 flex items-center justify-center bg-secondary-100 text-secondary-300 rounded dark:bg-secondary-800 dark:text-secondary-600 cursor-not-allowed">
|
||||
<i class="fas fa-chevron-right"></i>
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
<!-- Add loading indicator for HTMX requests -->
|
||||
<div id="search-pagination-loading" class="htmx-indicator ml-2">
|
||||
<i class="fas fa-circle-notch fa-spin text-primary-600"></i>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
<div class="p-6 text-center text-secondary-600 dark:text-secondary-400">
|
||||
<i class="fas fa-search text-5xl mb-3"></i>
|
||||
<p>No files found matching your search criteria.</p>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
+40
-46
@@ -108,22 +108,24 @@ templ HistoryContent(ctx context.Context, data HistoryData) {
|
||||
|
||||
<!-- Pagination -->
|
||||
if data.TotalPages > 1 {
|
||||
<div class="mt-6 flex items-center justify-between">
|
||||
<div class="flex-1 flex justify-between sm:hidden">
|
||||
<div class="mt-6 flex flex-col sm:flex-row justify-between items-center py-4 bg-secondary-50 dark:bg-secondary-800 rounded-lg">
|
||||
<div class="flex-1 flex justify-between gap-4 sm:hidden w-full px-4 mb-4 sm:mb-0">
|
||||
if data.CurrentPage > 1 {
|
||||
<button
|
||||
hx-get="/history"
|
||||
hx-target="#history-content"
|
||||
hx-vals={ fmt.Sprintf(`{"page": %d, "pageSize": %d, "search": "%s"}`, data.CurrentPage-1, data.PageSize, data.SearchTerm) }
|
||||
hx-indicator="#mobile-prev-indicator"
|
||||
class="relative inline-flex items-center px-4 py-2 border border-secondary-300 dark:border-secondary-600 text-sm font-medium rounded-md text-secondary-700 dark:text-secondary-200 bg-white dark:bg-secondary-700 hover:bg-secondary-50 dark:hover:bg-secondary-600 transition-colors">
|
||||
<i class="fas fa-chevron-left mr-1"></i> Previous
|
||||
<span id="mobile-prev-indicator" class="htmx-indicator ml-1">
|
||||
<i class="fas fa-spinner fa-spin"></i>
|
||||
class="flex-1 h-10 flex items-center justify-center bg-secondary-200 text-secondary-800 hover:bg-secondary-300 rounded dark:bg-secondary-700 dark:text-secondary-200 dark:hover:bg-secondary-600 relative">
|
||||
<span class="flex items-center">
|
||||
<i class="fas fa-chevron-left mr-1"></i> Previous
|
||||
</span>
|
||||
<span id="mobile-prev-indicator" class="htmx-indicator absolute right-2">
|
||||
<i class="fas fa-spinner fa-spin text-xs"></i>
|
||||
</span>
|
||||
</button>
|
||||
} else {
|
||||
<span class="relative inline-flex items-center px-4 py-2 border border-secondary-300 dark:border-secondary-700 text-sm font-medium rounded-md text-secondary-300 dark:text-secondary-600 bg-secondary-100 dark:bg-secondary-800 cursor-not-allowed">
|
||||
<span class="flex-1 h-10 flex items-center justify-center bg-secondary-100 text-secondary-300 rounded dark:bg-secondary-800 dark:text-secondary-600 cursor-not-allowed">
|
||||
<i class="fas fa-chevron-left mr-1"></i> Previous
|
||||
</span>
|
||||
}
|
||||
@@ -134,19 +136,21 @@ templ HistoryContent(ctx context.Context, data HistoryData) {
|
||||
hx-target="#history-content"
|
||||
hx-vals={ fmt.Sprintf(`{"page": %d, "pageSize": %d, "search": "%s"}`, data.CurrentPage+1, data.PageSize, data.SearchTerm) }
|
||||
hx-indicator="#mobile-next-indicator"
|
||||
class="ml-3 relative inline-flex items-center px-4 py-2 border border-secondary-300 dark:border-secondary-600 text-sm font-medium rounded-md text-secondary-700 dark:text-secondary-200 bg-white dark:bg-secondary-700 hover:bg-secondary-50 dark:hover:bg-secondary-600 transition-colors">
|
||||
Next <i class="fas fa-chevron-right ml-1"></i>
|
||||
<span id="mobile-next-indicator" class="htmx-indicator ml-1">
|
||||
<i class="fas fa-spinner fa-spin"></i>
|
||||
class="flex-1 h-10 flex items-center justify-center bg-secondary-200 text-secondary-800 hover:bg-secondary-300 rounded dark:bg-secondary-700 dark:text-secondary-200 dark:hover:bg-secondary-600 relative">
|
||||
<span class="flex items-center">
|
||||
Next <i class="fas fa-chevron-right ml-1"></i>
|
||||
</span>
|
||||
<span id="mobile-next-indicator" class="htmx-indicator absolute right-2">
|
||||
<i class="fas fa-spinner fa-spin text-xs"></i>
|
||||
</span>
|
||||
</button>
|
||||
} else {
|
||||
<span class="ml-3 relative inline-flex items-center px-4 py-2 border border-secondary-300 dark:border-secondary-700 text-sm font-medium rounded-md text-secondary-300 dark:text-secondary-600 bg-secondary-100 dark:bg-secondary-800 cursor-not-allowed">
|
||||
<span class="flex-1 h-10 flex items-center justify-center bg-secondary-100 text-secondary-300 rounded dark:bg-secondary-800 dark:text-secondary-600 cursor-not-allowed">
|
||||
Next <i class="fas fa-chevron-right ml-1"></i>
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
<div class="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
|
||||
<div class="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between px-4">
|
||||
<div>
|
||||
<p class="text-sm text-secondary-700 dark:text-secondary-300">
|
||||
Showing
|
||||
@@ -158,25 +162,23 @@ templ HistoryContent(ctx context.Context, data HistoryData) {
|
||||
results
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<nav class="relative z-0 inline-flex rounded-md shadow-sm -space-x-px" aria-label="Pagination">
|
||||
<div class="flex justify-center">
|
||||
<nav class="flex gap-2" aria-label="Pagination">
|
||||
if data.CurrentPage > 1 {
|
||||
<button
|
||||
hx-get="/history"
|
||||
hx-target="#history-content"
|
||||
hx-vals={ fmt.Sprintf(`{"page": %d, "pageSize": %d, "search": "%s"}`, data.CurrentPage-1, data.PageSize, data.SearchTerm) }
|
||||
hx-indicator="#prev-indicator"
|
||||
class="relative inline-flex items-center px-2 py-2 rounded-l-md border border-secondary-300 dark:border-secondary-600 bg-white dark:bg-secondary-700 text-sm font-medium text-secondary-500 dark:text-secondary-400 hover:bg-secondary-50 dark:hover:bg-secondary-600 transition-colors">
|
||||
<span class="sr-only">Previous</span>
|
||||
<i class="fas fa-chevron-left h-5 w-5"></i>
|
||||
<span id="prev-indicator" class="htmx-indicator ml-1">
|
||||
<i class="fas fa-spinner fa-spin"></i>
|
||||
class="w-10 h-10 flex items-center justify-center bg-secondary-200 text-secondary-800 hover:bg-secondary-300 rounded dark:bg-secondary-700 dark:text-secondary-200 dark:hover:bg-secondary-600 relative">
|
||||
<i class="fas fa-chevron-left"></i>
|
||||
<span id="prev-indicator" class="htmx-indicator absolute top-0 right-0 w-4 h-4 -mt-1 -mr-1">
|
||||
<i class="fas fa-spinner fa-spin text-xs"></i>
|
||||
</span>
|
||||
</button>
|
||||
} else {
|
||||
<span class="relative inline-flex items-center px-2 py-2 rounded-l-md border border-secondary-300 dark:border-secondary-700 bg-secondary-100 dark:bg-secondary-800 text-sm font-medium text-secondary-300 dark:text-secondary-600 cursor-not-allowed">
|
||||
<span class="sr-only">Previous</span>
|
||||
<i class="fas fa-chevron-left h-5 w-5"></i>
|
||||
<span class="w-10 h-10 flex items-center justify-center bg-secondary-100 text-secondary-300 rounded dark:bg-secondary-800 dark:text-secondary-600 cursor-not-allowed">
|
||||
<i class="fas fa-chevron-left"></i>
|
||||
</span>
|
||||
}
|
||||
|
||||
@@ -189,17 +191,15 @@ templ HistoryContent(ctx context.Context, data HistoryData) {
|
||||
hx-target="#history-content"
|
||||
hx-vals={ fmt.Sprintf(`{"page": %d, "pageSize": %d, "search": "%s"}`, data.CurrentPage+1, data.PageSize, data.SearchTerm) }
|
||||
hx-indicator="#next-indicator"
|
||||
class="relative inline-flex items-center px-2 py-2 rounded-r-md border border-secondary-300 dark:border-secondary-600 bg-white dark:bg-secondary-700 text-sm font-medium text-secondary-500 dark:text-secondary-400 hover:bg-secondary-50 dark:hover:bg-secondary-600 transition-colors">
|
||||
<span class="sr-only">Next</span>
|
||||
<i class="fas fa-chevron-right h-5 w-5"></i>
|
||||
<span id="next-indicator" class="htmx-indicator ml-1">
|
||||
<i class="fas fa-spinner fa-spin"></i>
|
||||
class="w-10 h-10 flex items-center justify-center bg-secondary-200 text-secondary-800 hover:bg-secondary-300 rounded dark:bg-secondary-700 dark:text-secondary-200 dark:hover:bg-secondary-600 relative">
|
||||
<i class="fas fa-chevron-right"></i>
|
||||
<span id="next-indicator" class="htmx-indicator absolute top-0 right-0 w-4 h-4 -mt-1 -mr-1">
|
||||
<i class="fas fa-spinner fa-spin text-xs"></i>
|
||||
</span>
|
||||
</button>
|
||||
} else {
|
||||
<span class="relative inline-flex items-center px-2 py-2 rounded-r-md border border-secondary-300 dark:border-secondary-700 bg-secondary-100 dark:bg-secondary-800 text-sm font-medium text-secondary-300 dark:text-secondary-600 cursor-not-allowed">
|
||||
<span class="sr-only">Next</span>
|
||||
<i class="fas fa-chevron-right h-5 w-5"></i>
|
||||
<span class="w-10 h-10 flex items-center justify-center bg-secondary-100 text-secondary-300 rounded dark:bg-secondary-800 dark:text-secondary-600 cursor-not-allowed">
|
||||
<i class="fas fa-chevron-right"></i>
|
||||
</span>
|
||||
}
|
||||
</nav>
|
||||
@@ -303,31 +303,25 @@ templ History(ctx context.Context, data HistoryData) {
|
||||
|
||||
templ pageNumbers(currentPage int, totalPages int, pageSize int, searchTerm string) {
|
||||
// Show at most 5 page numbers with the current page in the middle when possible
|
||||
{{startPage := max(1, currentPage-2)}}
|
||||
{{endPage := min(totalPages, startPage+4)}}
|
||||
|
||||
// Adjust startPage if we're near the end
|
||||
if endPage - startPage < 4 && startPage > 1 {
|
||||
startPage = max(1, endPage-4)
|
||||
}
|
||||
|
||||
for i := startPage; i <= endPage; i++ {
|
||||
for i := 1; i <= totalPages; i++ {
|
||||
if i == currentPage {
|
||||
<span aria-current="page" class="relative inline-flex items-center px-4 py-2 border border-primary-500 bg-primary-50 dark:bg-primary-900/30 text-sm font-medium text-primary-600 dark:text-primary-400">
|
||||
<span aria-current="page" class="w-10 h-10 flex items-center justify-center bg-primary-600 text-white rounded">
|
||||
{ fmt.Sprint(i) }
|
||||
</span>
|
||||
} else {
|
||||
} else if i == 1 || i == totalPages || (i >= currentPage-2 && i <= currentPage+2) {
|
||||
<button
|
||||
hx-get="/history"
|
||||
hx-target="#history-content"
|
||||
hx-vals={ fmt.Sprintf(`{"page": %d, "pageSize": %d, "search": "%s"}`, i, pageSize, searchTerm) }
|
||||
hx-indicator="#page-indicator"
|
||||
class="relative inline-flex items-center px-4 py-2 border border-secondary-300 dark:border-secondary-600 bg-white dark:bg-secondary-700 text-sm font-medium text-secondary-700 dark:text-secondary-300 hover:bg-secondary-50 dark:hover:bg-secondary-600 transition-colors">
|
||||
hx-indicator={ fmt.Sprintf("#page-indicator-%d", i) }
|
||||
class="w-10 h-10 flex items-center justify-center bg-secondary-200 text-secondary-800 hover:bg-secondary-300 rounded dark:bg-secondary-700 dark:text-secondary-200 dark:hover:bg-secondary-600 relative">
|
||||
{ fmt.Sprint(i) }
|
||||
<span id="page-indicator" class="htmx-indicator ml-1">
|
||||
<i class="fas fa-spinner fa-spin"></i>
|
||||
<span id={ fmt.Sprintf("page-indicator-%d", i) } class="htmx-indicator absolute top-0 right-0 w-4 h-4 -mt-1 -mr-1">
|
||||
<i class="fas fa-spinner fa-spin text-xs"></i>
|
||||
</span>
|
||||
</button>
|
||||
} else if i == currentPage-3 || i == currentPage+3 {
|
||||
<span class="w-10 h-10 flex items-center justify-center">...</span>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user