mirror of
https://github.com/StarFleetCPTN/GoMFT.git
synced 2026-09-08 15:41:20 +02:00
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:
+289
-311
@@ -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>
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user