refactor: Improve HTMX partial rendering for file metadata views

- Rename FileMetadataListContent to FileMetadataListPartial for consistency
- Add separate FileMetadataSearchContent template for search results
- Enhance handler logic to support HTMX partial rendering
- Implement more robust HTMX request detection
- Improve error handling and empty state rendering for file lists and search results
This commit is contained in:
StarFleetCPTN
2025-03-09 22:02:02 -07:00
parent 38461c7b7d
commit 4b625b7120
2 changed files with 420 additions and 314 deletions
+289 -311
View File
@@ -148,7 +148,134 @@ script triggerFileDelete(dialogId string, fileID uint, fileName string) {
window.currentlyDeletingFile = true;
}
// FileMetadataList renders a list of file metadata with pagination and filters
// Rename from FileMetadataListContent to FileMetadataListPartial to match what the handler expects
templ FileMetadataListPartial(data FileMetadataListData) {
<!-- 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={ fmt.Sprintf("/files/partial?page=%d&limit=%d&status=%s&filename=%s&job_id=%s", data.Page - 1, data.Limit, data.Filter.Status, data.Filter.FileName, data.Filter.JobID) }
hx-target="#file-list-container"
hx-swap="innerHTML"
hx-push-url="true"
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={ fmt.Sprintf("/files/partial?page=%d&limit=%d&status=%s&filename=%s&job_id=%s", i, data.Limit, data.Filter.Status, data.Filter.FileName, data.Filter.JobID) }
hx-target="#file-list-container"
hx-swap="innerHTML"
hx-push-url="true"
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={ fmt.Sprintf("/files/partial?page=%d&limit=%d&status=%s&filename=%s&job_id=%s", data.Page + 1, data.Limit, data.Filter.Status, data.Filter.FileName, data.Filter.JobID) }
hx-target="#file-list-container"
hx-swap="innerHTML"
hx-push-url="true"
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>
</div>
}
}
// Update the main FileMetadataList template to use the partial template
templ FileMetadataList(ctx context.Context, data FileMetadataListData) {
@LayoutWithContext("File Metadata", ctx) {
<script>
@@ -326,10 +453,11 @@ templ FileMetadataList(ctx context.Context, data FileMetadataListData) {
</div>
<div class="card-body">
<form
hx-get="/files?htmx=true"
hx-target="#file-list-results"
hx-get="/files/partial"
hx-target="#file-list-container"
hx-swap="innerHTML"
hx-indicator="#filter-loading"
hx-headers='{"X-HX-Request": "true"}'
class="grid grid-cols-1 md:grid-cols-3 gap-4">
if data.Job == nil {
<div>
@@ -374,38 +502,21 @@ templ FileMetadataList(ctx context.Context, data FileMetadataListData) {
Page { strconv.Itoa(data.Page) } of { strconv.Itoa(data.TotalPages) }
</div>
</div>
<div id="file-list-results" class="overflow-x-auto">
@FileMetadataListPartial(data)
<div id="file-list-container" class="overflow-x-auto">
if len(data.Files) > 0 {
@FileMetadataListPartial(data)
} 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>
</div>
</div>
}
}
// buildPaginationURL builds a URL for pagination links
func buildPaginationURL(data FileMetadataListData, page int) string {
baseURL := "/files"
if data.Job != nil {
baseURL = fmt.Sprintf("/files/job/%d", data.Job.ID)
}
url := fmt.Sprintf("%s?page=%d&limit=%d&htmx=true", baseURL, page, data.Limit)
if data.Filter.Status != "" {
url += "&status=" + data.Filter.Status
}
if data.Filter.FileName != "" {
url += "&filename=" + data.Filter.FileName
}
if data.Filter.JobID != "" && data.Job == nil {
url += "&job_id=" + data.Filter.JobID
}
return url
}
// FileMetadataDetails renders detailed information about a file
templ FileMetadataDetails(ctx context.Context, data FileMetadataDetailsData) {
@LayoutWithContext("File Details", ctx) {
@@ -714,6 +825,46 @@ templ FileMetadataSearch(ctx context.Context, data FileMetadataSearchData) {
console.log("Notyf initialized:", window.notyf);
}
// HTMX event handler for search requests
document.addEventListener('htmx:afterRequest', function(event) {
// Check if this is a search request
const path = event.detail.pathInfo?.requestPath;
if (path && path.startsWith('/files/search')) {
// Check if the response has HTML content
const content = event.detail.xhr.responseText;
// If the response contains a full HTML page (likely has html, head, body tags)
if (content && (content.includes('<html') || content.includes('<body'))) {
console.error('Received full page instead of partial content');
// Try to extract just the table content
const tempDiv = document.createElement('div');
tempDiv.innerHTML = content;
// Look for the container inside the response
const resultsContainer = tempDiv.querySelector('#search-results-container');
if (resultsContainer) {
// Replace our container with just the content from the response
document.querySelector('#search-results-container').innerHTML = resultsContainer.innerHTML;
event.preventDefault(); // Prevent HTMX from doing the swap
return;
}
// If we couldn't find the search results container, try finding the table directly
const tableContent = tempDiv.querySelector('#search-results-container table');
if (tableContent) {
// Replace our container with just the table from the response
document.querySelector('#search-results-container').innerHTML = tableContent.outerHTML;
event.preventDefault();
return;
}
}
}
});
// HTMX indicator styles
document.addEventListener('DOMContentLoaded', function() {
// Add CSS for HTMX loading indicators
@@ -849,10 +1000,12 @@ templ FileMetadataSearch(ctx context.Context, data FileMetadataSearchData) {
</div>
<div class="card-body">
<form
hx-get="/files/search?htmx=true"
hx-target="#file-search-results"
hx-get="/files/search"
hx-target="#search-results-container"
hx-swap="innerHTML"
hx-indicator="#search-form-loading"
hx-headers='{"X-HX-Request": "true"}'
hx-boost="false"
class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label for="job_id" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Job ID</label>
@@ -903,90 +1056,54 @@ templ FileMetadataSearch(ctx context.Context, data FileMetadataSearchData) {
</div>
<!-- Results -->
if data.TotalCount > 0 || (data.Filter.FileName != "" || data.Filter.JobID != "" || data.Filter.Status != "" ||
data.Filter.Hash != "" || data.Filter.StartDate != "" || data.Filter.EndDate != "") {
<div class="card">
<div class="card-header flex justify-between items-center">
<h2 class="text-lg font-semibold">Search Results ({ strconv.FormatInt(data.TotalCount, 10) })</h2>
<div class="text-sm text-secondary-600 dark:text-secondary-400">
Page { strconv.Itoa(data.Page) } of { strconv.Itoa(data.TotalPages) }
</div>
</div>
<div id="file-search-results" class="overflow-x-auto">
@FileMetadataSearchPartial(data)
<div class="card">
<div class="card-header flex justify-between items-center">
<h2 class="text-lg font-semibold">Search Results ({ strconv.FormatInt(data.TotalCount, 10) })</h2>
<div class="text-sm text-secondary-600 dark:text-secondary-400">
Page { strconv.Itoa(data.Page) } of { strconv.Itoa(data.TotalPages) }
</div>
</div>
}
<div id="search-results-container" class="overflow-x-auto">
@FileMetadataSearchContent(data)
</div>
</div>
</div>
}
}
// buildSearchPaginationURL builds a URL for search pagination links
func buildSearchPaginationURL(data FileMetadataSearchData, page int) string {
url := fmt.Sprintf("/files/search?page=%d&limit=%d&htmx=true", page, data.Limit)
if data.Filter.Status != "" {
url += "&status=" + data.Filter.Status
}
if data.Filter.FileName != "" {
url += "&filename=" + data.Filter.FileName
}
if data.Filter.JobID != "" {
url += "&job_id=" + data.Filter.JobID
}
if data.Filter.Hash != "" {
url += "&hash=" + data.Filter.Hash
}
if data.Filter.StartDate != "" {
url += "&start_date=" + data.Filter.StartDate
}
if data.Filter.EndDate != "" {
url += "&end_date=" + data.Filter.EndDate
}
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>
// Similarly, create a separate template for the search results
templ FileMetadataSearchContent(data FileMetadataSearchData) {
<!-- 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>
if len(data.Files) > 0 {
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>
@@ -1002,13 +1119,15 @@ templ FileMetadataListPartial(data FileMetadataListData) {
{ file.Status }
</span>
</td>
if data.Job == nil && file.Job.ID > 0 {
<td>
<td>
if file.Job.ID > 0 {
<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>
}
} else {
<span class="text-secondary-500 dark:text-secondary-500 italic">N/A</span>
}
</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>
@@ -1023,209 +1142,68 @@ templ FileMetadataListPartial(data FileMetadataListData) {
</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>
} else {
<tr>
<td colspan="7" class="py-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>
</td>
</tr>
}
</tbody>
</table>
<!-- Pagination for search results -->
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={ fmt.Sprintf("/files/search?page=%d&limit=%d&status=%s&filename=%s&job_id=%s&hash=%s&start_date=%s&end_date=%s", data.Page - 1, data.Limit, data.Filter.Status, data.Filter.FileName, data.Filter.JobID, data.Filter.Hash, data.Filter.StartDate, data.Filter.EndDate) }
hx-target="#search-results-container"
hx-swap="innerHTML"
hx-headers='{"X-HX-Request": "true"}'
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>
}
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>
} else if i == 1 || i == data.TotalPages || (i >= data.Page-2 && i <= data.Page+2) {
<a hx-get={ fmt.Sprintf("/files/search?page=%d&limit=%d&status=%s&filename=%s&job_id=%s&hash=%s&start_date=%s&end_date=%s", i, data.Limit, data.Filter.Status, data.Filter.FileName, data.Filter.JobID, data.Filter.Hash, data.Filter.StartDate, data.Filter.EndDate) }
hx-target="#search-results-container"
hx-swap="innerHTML"
hx-headers='{"X-HX-Request": "true"}'
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 {
<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>
} else if i == data.Page-3 || i == data.Page+3 {
<span class="w-10 h-10 flex items-center justify-center">...</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>
}
if data.Page < data.TotalPages {
<a hx-get={ fmt.Sprintf("/files/search?page=%d&limit=%d&status=%s&filename=%s&job_id=%s&hash=%s&start_date=%s&end_date=%s", data.Page + 1, data.Limit, data.Filter.Status, data.Filter.FileName, data.Filter.JobID, data.Filter.Hash, data.Filter.StartDate, data.Filter.EndDate) }
hx-target="#search-results-container"
hx-swap="innerHTML"
hx-headers='{"X-HX-Request": "true"}'
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>
}
} 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>
}
}
+131 -3
View File
@@ -1,6 +1,7 @@
package handlers
import (
"context"
"fmt"
"net/http"
"strconv"
@@ -24,6 +25,7 @@ func (h *FileMetadataHandler) Register(router *gin.RouterGroup) {
fileGroup.GET("/job/:job_id", h.GetFileMetadataForJob)
fileGroup.GET("/search", h.SearchFileMetadata)
fileGroup.DELETE("/:id", h.DeleteFileMetadata)
fileGroup.GET("/partial", h.HandleFileMetadataPartial)
}
// ListFileMetadata displays a list of file metadata with pagination and filtering options
@@ -104,8 +106,18 @@ func (h *FileMetadataHandler) ListFileMetadata(c *gin.Context) {
data.TotalPages++
}
// Check if this is an HTMX request
isHtmxRequest := c.GetHeader("HX-Request") == "true" || c.Query("htmx") == "true"
c.Header("Content-Type", "text/html")
components.FileMetadataList(ctx, data).Render(ctx, c.Writer)
if isHtmxRequest {
// For HTMX requests, render just the partial template
components.FileMetadataListPartial(data).Render(ctx, c.Writer)
} else {
// For full page requests, render the complete template
components.FileMetadataList(ctx, data).Render(ctx, c.Writer)
}
}
// GetFileMetadataDetails displays detailed information about a specific file
@@ -237,8 +249,18 @@ func (h *FileMetadataHandler) GetFileMetadataForJob(c *gin.Context) {
data.TotalPages++
}
// Check if this is an HTMX request
isHtmxRequest := c.GetHeader("HX-Request") == "true" || c.Query("htmx") == "true"
c.Header("Content-Type", "text/html")
components.FileMetadataList(ctx, data).Render(ctx, c.Writer)
if isHtmxRequest {
// For HTMX requests, render just the partial template
components.FileMetadataListPartial(data).Render(ctx, c.Writer)
} else {
// For full page requests, render the complete template
components.FileMetadataList(ctx, data).Render(ctx, c.Writer)
}
}
// SearchFileMetadata searches file metadata based on various criteria
@@ -337,8 +359,21 @@ func (h *FileMetadataHandler) SearchFileMetadata(c *gin.Context) {
data.TotalPages++
}
// Add HTMX request checking and conditional rendering
ctx = context.WithValue(c.Request.Context(), "userID", userID)
// Check if this is an HTMX request
isHtmxRequest := c.GetHeader("HX-Request") == "true" || c.Query("htmx") == "true"
c.Header("Content-Type", "text/html")
components.FileMetadataSearch(ctx, data).Render(ctx, c.Writer)
if isHtmxRequest {
// For HTMX requests, render just the partial template
components.FileMetadataSearchContent(data).Render(ctx, c.Writer)
} else {
// For full page requests, render the complete template
components.FileMetadataSearch(ctx, data).Render(ctx, c.Writer)
}
}
// DeleteFileMetadata deletes a file metadata record
@@ -389,3 +424,96 @@ func (h *FileMetadataHandler) DeleteFileMetadata(c *gin.Context) {
c.Redirect(http.StatusFound, "/files")
}
}
// HandleFileMetadataPartial handles partial updates for file metadata list pagination
func (h *FileMetadataHandler) HandleFileMetadataPartial(c *gin.Context) {
userID := c.GetUint("userID")
// Query parameters for pagination and filtering
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
if page < 1 {
page = 1
}
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
if limit < 1 || limit > 100 {
limit = 50
}
status := c.Query("status")
jobIDStr := c.Query("job_id")
fileName := c.Query("filename")
// Base query
query := h.DB.DB.Model(&db.FileMetadata{}).Joins("JOIN jobs ON file_metadata.job_id = jobs.id")
// Apply filters
if jobIDStr != "" {
jobID, _ := strconv.ParseUint(jobIDStr, 10, 64)
query = query.Where("file_metadata.job_id = ?", jobID)
} else {
// Only show files from jobs created by the current user
query = query.Where("jobs.created_by = ?", userID)
}
if status != "" {
query = query.Where("file_metadata.status = ?", status)
}
if fileName != "" {
query = query.Where("file_metadata.file_name LIKE ?", "%"+fileName+"%")
}
// Count total records for pagination
var totalCount int64
query.Count(&totalCount)
// Retrieve file metadata with pagination
var fileMetadata []db.FileMetadata
offset := (page - 1) * limit
err := query.Preload("Job").Preload("Job.Config").
Order("file_metadata.processed_time DESC").
Offset(offset).Limit(limit).
Find(&fileMetadata).Error
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve file metadata"})
return
}
// Create context for template
ctx := components.CreateTemplateContext(c)
// Prepare job pointer if needed
var job *db.Job
if jobIDStr != "" {
jobID, _ := strconv.ParseUint(jobIDStr, 10, 64)
var jobRecord db.Job
if err := h.DB.DB.First(&jobRecord, jobID).Error; err == nil {
job = &jobRecord
}
}
// Render the file metadata list template
data := components.FileMetadataListData{
Files: fileMetadata,
TotalCount: totalCount,
Page: page,
Limit: limit,
TotalPages: int(totalCount) / limit,
Job: job,
Filter: components.FileMetadataFilter{
Status: status,
JobID: jobIDStr,
FileName: fileName,
},
}
// If total count is not exactly divisible by limit, add one more page
if int(totalCount)%limit > 0 {
data.TotalPages++
}
c.Header("Content-Type", "text/html")
components.FileMetadataListPartial(data).Render(ctx, c.Writer)
}