Files
GoMFT/components/file_metadata.templ
T
StarFleetCPTN 31410984cb feat: Add partial search results rendering for file metadata
- Implement HandleFileMetadataSearchPartial handler for HTMX-powered search results
- Update file_metadata.templ to use new /files/search/partial endpoint
- Add HTMX request detection and browser redirect for search results
- Enhance search results pagination and filtering with partial rendering
- Improve user experience with dynamic, asynchronous search interactions
2025-03-09 22:10:22 -07:00

1210 lines
44 KiB
Templ

package components
import (
"context"
"strconv"
"fmt"
"github.com/starfleetcptn/gomft/internal/db"
)
// FileMetadataFilter represents filter parameters for file metadata queries
type FileMetadataFilter struct {
Status string
JobID string
FileName string
Hash string
StartDate string
EndDate string
}
// FileMetadataListData contains data for the file metadata list template
type FileMetadataListData struct {
Files []db.FileMetadata
TotalCount int64
Page int
Limit int
TotalPages int
Job *db.Job // Optional: if viewing files for a specific job
Filter FileMetadataFilter
}
// FileMetadataDetailsData contains data for the file metadata details template
type FileMetadataDetailsData struct {
File db.FileMetadata
}
// FileMetadataSearchData contains data for the file metadata search template
type FileMetadataSearchData struct {
Files []db.FileMetadata
TotalCount int64
Page int
Limit int
TotalPages int
Filter FileMetadataFilter
}
// getStatusBadgeClass returns the appropriate CSS class for a file status badge
func getStatusBadgeClass(status string) string {
switch status {
case "processed":
return "badge-success"
case "archived":
return "badge-info"
case "deleted":
return "badge-warning"
case "archived_and_deleted":
return "badge-warning"
case "error":
return "badge-danger"
default:
return "badge-secondary"
}
}
// formatFileSize formats a file size in bytes to a human-readable string
func formatFileSize(size int64) string {
if size < 1024 {
return fmt.Sprintf("%d B", size)
} else if size < 1024*1024 {
return fmt.Sprintf("%.2f KB", float64(size)/1024)
} else if size < 1024*1024*1024 {
return fmt.Sprintf("%.2f MB", float64(size)/(1024*1024))
} else {
return fmt.Sprintf("%.2f GB", float64(size)/(1024*1024*1024))
}
}
// Dialog component for confirmation dialogs
templ FileMetadataDialog(id string, title string, message string, confirmClass string, confirmText string, action string, fileID uint, fileName string, section string) {
<div id={ id } class="hidden fixed inset-0 bg-secondary-900/50 dark:bg-secondary-900/80 backdrop-blur-sm z-50 flex items-center justify-center">
<div class="bg-white dark:bg-secondary-800 rounded-lg shadow-xl max-w-md w-full mx-4 overflow-hidden">
<div class="px-6 pt-5 pb-3 text-center">
<div class="flex justify-center mb-2">
<i class="fas fa-exclamation-triangle text-yellow-400 text-3xl"></i>
</div>
<h3 class="text-xl font-medium text-secondary-900 dark:text-secondary-100">
{ title }
</h3>
</div>
<div class="px-6 py-4 text-center">
<p class="text-secondary-700 dark:text-secondary-300">
{ message }
</p>
</div>
<div class="px-6 py-4 flex justify-end space-x-3">
<button type="button" class="btn-secondary" onclick={ hideFileDialog(id) }>
Cancel
</button>
if section == "list" {
<button
type="button"
class={ confirmClass }
hx-delete={ fmt.Sprintf("/files/%d", fileID) }
hx-target={ fmt.Sprintf("#file-row-%d", fileID) }
hx-swap="delete"
data-file-name={ fileName }
data-file-id={ fmt.Sprint(fileID) }
id={ fmt.Sprintf("delete-file-btn-%d", fileID) }
onclick={ triggerFileDelete(id, fileID, fileName) }>
{ confirmText }
</button>
} else {
<button
type="button"
class={ confirmClass }
hx-delete={ fmt.Sprintf("/files/%d", fileID) }
hx-redirect="/files"
data-file-name={ fileName }
data-file-id={ fmt.Sprint(fileID) }
id={ fmt.Sprintf("delete-file-btn-%d", fileID) }
onclick={ triggerFileDelete(id, fileID, fileName) }>
{ confirmText }
</button>
}
</div>
</div>
</div>
}
script hideFileDialog(id string) {
document.getElementById(id).classList.add("hidden");
}
script showFileDialog(id string) {
document.getElementById(id).classList.remove("hidden");
}
script triggerFileDelete(dialogId string, fileID uint, fileName string) {
// Hide the dialog
document.getElementById(dialogId).classList.add("hidden");
// Store data in a way that's accessible to event handlers
window.lastDeletedFile = {
id: fileID,
name: fileName
};
// Add custom marker to track this deletion
window.currentlyDeletingFile = true;
}
// 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>
// Debug notification system
console.log("File Metadata template loaded, setting up notification system");
// Create a global notyf instance if it doesn't exist yet
if (!window.notyf) {
window.notyf = new Notyf({
duration: 3000,
position: {
x: 'right',
y: 'top',
},
types: [
{
type: 'success',
background: '#38c172',
icon: {
className: 'fas fa-check-circle',
tagName: 'i'
}
},
{
type: 'error',
background: '#e3342f',
icon: {
className: 'fas fa-exclamation-circle',
tagName: 'i'
}
}
]
});
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
const path = event.detail.path;
const method = event.detail.verb;
// Pattern match for file deletions (e.g., /files/123)
if (path && method === 'DELETE' && path.match(/^\/files\/\d+$/)) {
// This is definitely a delete request - store this information
window.isFileDeleteRequest = true;
}
});
// Track HTMX after-request events for file deletion
document.addEventListener('htmx:afterRequest', function(event) {
// Check for file deletion multiple ways
const isDeleteRequest =
// Check global flag from the triggerFileDelete function
window.currentlyDeletingFile ||
// Check flag from beforeRequest handler
window.isFileDeleteRequest ||
// Check URL pattern directly from this event
(event.detail.pathInfo && event.detail.pathInfo.requestPath &&
event.detail.pathInfo.requestPath.match(/^\/files\/\d+$/) &&
event.detail.verb === 'DELETE');
// If this is a successful delete request, show notification
if (isDeleteRequest && event.detail.successful) {
let fileName = "Unknown";
// Try multiple sources for file name
if (event.detail.elt && event.detail.elt.getAttribute) {
fileName = event.detail.elt.getAttribute('data-file-name') || fileName;
}
if (fileName === "Unknown" && window.lastDeletedFile) {
// Fallback to our stored file info
fileName = window.lastDeletedFile.name;
}
window.notyf.success(`File "${fileName}" deleted successfully`);
// Clear flags
window.currentlyDeletingFile = false;
window.isFileDeleteRequest = false;
window.lastDeletedFile = null;
}
});
// Track HTMX error events for file deletion
document.addEventListener('htmx:responseError', function(event) {
// Similar logic as success but for errors
const isDeleteRequest =
window.currentlyDeletingFile ||
window.isFileDeleteRequest ||
(event.detail.pathInfo && event.detail.pathInfo.requestPath &&
event.detail.pathInfo.requestPath.match(/^\/files\/\d+$/) &&
event.detail.verb === 'DELETE');
if (isDeleteRequest) {
let fileName = "Unknown";
// Try multiple sources for file name
if (event.detail.elt && event.detail.elt.getAttribute) {
fileName = event.detail.elt.getAttribute('data-file-name') || fileName;
}
if (fileName === "Unknown" && window.lastDeletedFile) {
// Fallback to our stored file info
fileName = window.lastDeletedFile.name;
}
let errorMsg = `Failed to delete file "${fileName}"`;
if (event.detail.xhr && event.detail.xhr.responseText) {
errorMsg = event.detail.xhr.responseText
// error message is a json object
try {
const error = JSON.parse(errorMsg);
errorMsg = `Error: ${error.error}`;
} catch(e) {
// Not JSON, use as is
}
}
window.notyf.error(errorMsg);
// Clear flags
window.currentlyDeletingFile = false;
window.isFileDeleteRequest = false;
window.lastDeletedFile = null;
}
});
</script>
<div class="container mx-auto px-4 py-8 animate-fadeIn">
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold text-secondary-800 dark:text-secondary-200">
if data.Job != nil {
Files for Job: { data.Job.Name }
} else {
File Metadata
}
</h1>
<div class="flex space-x-2">
<a href="/files/search" class="btn-primary">
<i class="fas fa-search mr-2"></i> Advanced Search
</a>
</div>
</div>
<!-- Filter Form -->
<div class="card mb-6">
<div class="card-header">
<h2 class="text-lg font-semibold">Filter Files</h2>
</div>
<div class="card-body">
<form
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>
<label for="job_id" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Job</label>
<input type="text" id="job_id" name="job_id" value={ data.Filter.JobID } placeholder="Job ID"
class="form-input w-full" />
</div>
}
<div>
<label for="status" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Status</label>
<select id="status" name="status" class="form-input w-full">
<option value="">All Statuses</option>
<option value="processed" selected?={ data.Filter.Status == "processed" }>Processed</option>
<option value="archived" selected?={ data.Filter.Status == "archived" }>Archived</option>
<option value="deleted" selected?={ data.Filter.Status == "deleted" }>Deleted</option>
<option value="archived_and_deleted" selected?={ data.Filter.Status == "archived_and_deleted" }>Archived & Deleted</option>
<option value="error" selected?={ data.Filter.Status == "error" }>Error</option>
</select>
</div>
<div>
<label for="filename" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Filename</label>
<input type="text" id="filename" name="filename" value={ data.Filter.FileName } placeholder="Filename or partial match"
class="form-input w-full" />
</div>
<div class="md:col-span-3 flex justify-end">
<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>
</div>
<!-- Results -->
<div class="card">
<div class="card-header flex justify-between items-center">
<h2 class="text-lg font-semibold">Files ({ 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-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>
}
}
// FileMetadataDetails renders detailed information about a file
templ FileMetadataDetails(ctx context.Context, data FileMetadataDetailsData) {
@LayoutWithContext("File Details", ctx) {
<script>
// Debug notification system
console.log("File Details template loaded, setting up notification system");
// Create a global notyf instance if it doesn't exist yet
if (!window.notyf) {
window.notyf = new Notyf({
duration: 3000,
position: {
x: 'right',
y: 'top',
},
types: [
{
type: 'success',
background: '#38c172',
icon: {
className: 'fas fa-check-circle',
tagName: 'i'
}
},
{
type: 'error',
background: '#e3342f',
icon: {
className: 'fas fa-exclamation-circle',
tagName: 'i'
}
}
]
});
console.log("Notyf initialized:", window.notyf);
}
// 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
const path = event.detail.path;
const method = event.detail.verb;
// Pattern match for file deletions (e.g., /files/123)
if (path && method === 'DELETE' && path.match(/^\/files\/\d+$/)) {
// This is definitely a delete request - store this information
window.isFileDeleteRequest = true;
}
});
// Track HTMX after-request events for file deletion
document.addEventListener('htmx:afterRequest', function(event) {
// Check for file deletion multiple ways
const isDeleteRequest =
// Check global flag from the triggerFileDelete function
window.currentlyDeletingFile ||
// Check flag from beforeRequest handler
window.isFileDeleteRequest ||
// Check URL pattern directly from this event
(event.detail.pathInfo && event.detail.pathInfo.requestPath &&
event.detail.pathInfo.requestPath.match(/^\/files\/\d+$/) &&
event.detail.verb === 'DELETE');
// If this is a successful delete request, show notification and redirect
if (isDeleteRequest && event.detail.successful) {
let fileName = "Unknown";
// Try multiple sources for file name
if (event.detail.elt && event.detail.elt.getAttribute) {
fileName = event.detail.elt.getAttribute('data-file-name') || fileName;
}
if (fileName === "Unknown" && window.lastDeletedFile) {
// Fallback to our stored file info
fileName = window.lastDeletedFile.name;
}
window.notyf.success(`File "${fileName}" deleted successfully`);
// Clear flags
window.currentlyDeletingFile = false;
window.isFileDeleteRequest = false;
window.lastDeletedFile = null;
// Redirect to files list after successful deletion
setTimeout(function() {
window.location.href = "/files";
}, 1000);
}
});
// Track HTMX error events for file deletion
document.addEventListener('htmx:responseError', function(event) {
// Similar logic as success but for errors
const isDeleteRequest =
window.currentlyDeletingFile ||
window.isFileDeleteRequest ||
(event.detail.pathInfo && event.detail.pathInfo.requestPath &&
event.detail.pathInfo.requestPath.match(/^\/files\/\d+$/) &&
event.detail.verb === 'DELETE');
if (isDeleteRequest) {
let fileName = "Unknown";
// Try multiple sources for file name
if (event.detail.elt && event.detail.elt.getAttribute) {
fileName = event.detail.elt.getAttribute('data-file-name') || fileName;
}
if (fileName === "Unknown" && window.lastDeletedFile) {
// Fallback to our stored file info
fileName = window.lastDeletedFile.name;
}
let errorMsg = `Failed to delete file "${fileName}"`;
if (event.detail.xhr && event.detail.xhr.responseText) {
errorMsg = event.detail.xhr.responseText
// error message is a json object
try {
const error = JSON.parse(errorMsg);
errorMsg = `Error: ${error.error}`;
} catch(e) {
// Not JSON, use as is
}
}
window.notyf.error(errorMsg);
// Clear flags
window.currentlyDeletingFile = false;
window.isFileDeleteRequest = false;
window.lastDeletedFile = null;
}
});
</script>
<div class="container mx-auto px-4 py-8 animate-fadeIn">
<div class="mb-6">
<a href="/files" class="text-primary-600 dark:text-primary-400 hover:underline">
<i class="fas fa-arrow-left mr-2"></i> Back to Files
</a>
</div>
<div class="card">
<div class="card-header">
<h1 class="text-2xl font-bold text-secondary-800 dark:text-secondary-200">
File: { data.File.FileName }
</h1>
</div>
<div class="card-body">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<h2 class="text-lg font-semibold mb-4 text-secondary-800 dark:text-secondary-200">File Information</h2>
<table class="w-full">
<tbody>
<tr class="border-b border-secondary-200 dark:border-secondary-700">
<td class="py-2 font-medium text-secondary-700 dark:text-secondary-300">ID</td>
<td class="py-2">{ strconv.FormatUint(uint64(data.File.ID), 10) }</td>
</tr>
<tr class="border-b border-secondary-200 dark:border-secondary-700">
<td class="py-2 font-medium text-secondary-700 dark:text-secondary-300">Filename</td>
<td class="py-2">{ data.File.FileName }</td>
</tr>
<tr class="border-b border-secondary-200 dark:border-secondary-700">
<td class="py-2 font-medium text-secondary-700 dark:text-secondary-300">Size</td>
<td class="py-2">{ formatFileSize(data.File.FileSize) }</td>
</tr>
<tr class="border-b border-secondary-200 dark:border-secondary-700">
<td class="py-2 font-medium text-secondary-700 dark:text-secondary-300">Hash</td>
<td class="py-2 break-all">
if data.File.FileHash != "" {
{ data.File.FileHash }
} else {
<span class="text-secondary-500 dark:text-secondary-500 italic">Not available</span>
}
</td>
</tr>
<tr class="border-b border-secondary-200 dark:border-secondary-700">
<td class="py-2 font-medium text-secondary-700 dark:text-secondary-300">Status</td>
<td class="py-2">
<span class={ "badge", getStatusBadgeClass(data.File.Status) }>
{ data.File.Status }
</span>
</td>
</tr>
<tr class="border-b border-secondary-200 dark:border-secondary-700">
<td class="py-2 font-medium text-secondary-700 dark:text-secondary-300">Original Path</td>
<td class="py-2 break-all">{ data.File.OriginalPath }</td>
</tr>
<tr class="border-b border-secondary-200 dark:border-secondary-700">
<td class="py-2 font-medium text-secondary-700 dark:text-secondary-300">Destination Path</td>
<td class="py-2 break-all">{ data.File.DestinationPath }</td>
</tr>
</tbody>
</table>
</div>
<div>
<h2 class="text-lg font-semibold mb-4 text-secondary-800 dark:text-secondary-200">Processing Information</h2>
<table class="w-full">
<tbody>
<tr class="border-b border-secondary-200 dark:border-secondary-700">
<td class="py-2 font-medium text-secondary-700 dark:text-secondary-300">Job</td>
<td class="py-2">
<a href={ templ.SafeURL(fmt.Sprintf("/files/job/%d", data.File.JobID)) } class="text-primary-600 dark:text-primary-400 hover:underline">
{ data.File.Job.Name }
</a>
</td>
</tr>
<tr class="border-b border-secondary-200 dark:border-secondary-700">
<td class="py-2 font-medium text-secondary-700 dark:text-secondary-300">Processed Time</td>
<td class="py-2">{ data.File.ProcessedTime.Format("2006-01-02 15:04:05") }</td>
</tr>
<tr class="border-b border-secondary-200 dark:border-secondary-700">
<td class="py-2 font-medium text-secondary-700 dark:text-secondary-300">Creation Time</td>
<td class="py-2">{ data.File.CreationTime.Format("2006-01-02 15:04:05") }</td>
</tr>
<tr class="border-b border-secondary-200 dark:border-secondary-700">
<td class="py-2 font-medium text-secondary-700 dark:text-secondary-300">Modification Time</td>
<td class="py-2">{ data.File.ModTime.Format("2006-01-02 15:04:05") }</td>
</tr>
if data.File.Status == "error" && data.File.ErrorMessage != "" {
<tr class="border-b border-secondary-200 dark:border-secondary-700">
<td class="py-2 font-medium text-secondary-700 dark:text-secondary-300">Error</td>
<td class="py-2 text-red-600 dark:text-red-400 break-all">{ data.File.ErrorMessage }</td>
</tr>
}
<tr class="border-b border-secondary-200 dark:border-secondary-700">
<td class="py-2 font-medium text-secondary-700 dark:text-secondary-300">Record Created</td>
<td class="py-2">{ data.File.CreatedAt.Format("2006-01-02 15:04:05") }</td>
</tr>
<tr class="border-b border-secondary-200 dark:border-secondary-700">
<td class="py-2 font-medium text-secondary-700 dark:text-secondary-300">Record Updated</td>
<td class="py-2">{ data.File.UpdatedAt.Format("2006-01-02 15:04:05") }</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="mt-8 flex justify-end space-x-4">
<a href="/files" class="btn-secondary">
<i class="fas fa-list mr-2"></i> Back to Files
</a>
<!-- Add dialog for the file -->
@FileMetadataDialog(
fmt.Sprintf("delete-file-dialog-%d", data.File.ID),
"Delete File Metadata",
fmt.Sprintf("Are you sure you want to delete the metadata for '%s'? This cannot be undone.", data.File.FileName),
"btn-danger",
"Delete",
"delete",
data.File.ID,
data.File.FileName,
"details",
)
<button
type="button"
onclick={ showFileDialog(fmt.Sprintf("delete-file-dialog-%d", data.File.ID)) }
class="btn-danger">
<i class="fas fa-trash mr-2"></i> Delete Record
</button>
</div>
</div>
</div>
</div>
}
}
// FileMetadataSearch renders an advanced search form for file metadata
templ FileMetadataSearch(ctx context.Context, data FileMetadataSearchData) {
@LayoutWithContext("Search Files", ctx) {
<script>
// Debug notification system
console.log("File Search template loaded, setting up notification system");
// Create a global notyf instance if it doesn't exist yet
if (!window.notyf) {
window.notyf = new Notyf({
duration: 3000,
position: {
x: 'right',
y: 'top',
},
types: [
{
type: 'success',
background: '#38c172',
icon: {
className: 'fas fa-check-circle',
tagName: 'i'
}
},
{
type: 'error',
background: '#e3342f',
icon: {
className: 'fas fa-exclamation-circle',
tagName: 'i'
}
}
]
});
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
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
const path = event.detail.path;
const method = event.detail.verb;
// Pattern match for file deletions (e.g., /files/123)
if (path && method === 'DELETE' && path.match(/^\/files\/\d+$/)) {
// This is definitely a delete request - store this information
window.isFileDeleteRequest = true;
}
});
// Track HTMX after-request events for file deletion
document.addEventListener('htmx:afterRequest', function(event) {
// Check for file deletion multiple ways
const isDeleteRequest =
// Check global flag from the triggerFileDelete function
window.currentlyDeletingFile ||
// Check flag from beforeRequest handler
window.isFileDeleteRequest ||
// Check URL pattern directly from this event
(event.detail.pathInfo && event.detail.pathInfo.requestPath &&
event.detail.pathInfo.requestPath.match(/^\/files\/\d+$/) &&
event.detail.verb === 'DELETE');
// If this is a successful delete request, show notification
if (isDeleteRequest && event.detail.successful) {
let fileName = "Unknown";
// Try multiple sources for file name
if (event.detail.elt && event.detail.elt.getAttribute) {
fileName = event.detail.elt.getAttribute('data-file-name') || fileName;
}
if (fileName === "Unknown" && window.lastDeletedFile) {
// Fallback to our stored file info
fileName = window.lastDeletedFile.name;
}
window.notyf.success(`File "${fileName}" deleted successfully`);
// Clear flags
window.currentlyDeletingFile = false;
window.isFileDeleteRequest = false;
window.lastDeletedFile = null;
// Reload the page to update the file list
window.location.reload();
}
});
// Track HTMX error events for file deletion
document.addEventListener('htmx:responseError', function(event) {
// Similar logic as success but for errors
const isDeleteRequest =
window.currentlyDeletingFile ||
window.isFileDeleteRequest ||
(event.detail.pathInfo && event.detail.pathInfo.requestPath &&
event.detail.pathInfo.requestPath.match(/^\/files\/\d+$/) &&
event.detail.verb === 'DELETE');
if (isDeleteRequest) {
let fileName = "Unknown";
// Try multiple sources for file name
if (event.detail.elt && event.detail.elt.getAttribute) {
fileName = event.detail.elt.getAttribute('data-file-name') || fileName;
}
if (fileName === "Unknown" && window.lastDeletedFile) {
// Fallback to our stored file info
fileName = window.lastDeletedFile.name;
}
let errorMsg = `Failed to delete file "${fileName}"`;
if (event.detail.xhr && event.detail.xhr.responseText) {
errorMsg = event.detail.xhr.responseText
// error message is a json object
try {
const error = JSON.parse(errorMsg);
errorMsg = `Error: ${error.error}`;
} catch(e) {
// Not JSON, use as is
}
}
window.notyf.error(errorMsg);
// Clear flags
window.currentlyDeletingFile = false;
window.isFileDeleteRequest = false;
window.lastDeletedFile = null;
}
});
</script>
<div class="container mx-auto px-4 py-8 animate-fadeIn">
<div class="mb-6">
<a href="/files" class="text-primary-600 dark:text-primary-400 hover:underline">
<i class="fas fa-arrow-left mr-2"></i> Back to Files
</a>
</div>
<div class="card mb-6">
<div class="card-header">
<h1 class="text-2xl font-bold text-secondary-800 dark:text-secondary-200">
Advanced File Search
</h1>
</div>
<div class="card-body">
<form
hx-get="/files/search/partial"
hx-target="#search-results-container"
hx-swap="innerHTML"
hx-indicator="#search-form-loading"
hx-headers='{"X-HX-Request": "true"}'
hx-boost="false"
class="grid grid-cols-1 md:grid-cols-2 gap-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"
class="form-input w-full" />
</div>
<div>
<label for="status" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Status</label>
<select id="status" name="status" class="form-input w-full">
<option value="">All Statuses</option>
<option value="processed" selected?={ data.Filter.Status == "processed" }>Processed</option>
<option value="archived" selected?={ data.Filter.Status == "archived" }>Archived</option>
<option value="deleted" selected?={ data.Filter.Status == "deleted" }>Deleted</option>
<option value="archived_and_deleted" selected?={ data.Filter.Status == "archived_and_deleted" }>Archived & Deleted</option>
<option value="error" selected?={ data.Filter.Status == "error" }>Error</option>
</select>
</div>
<div>
<label for="filename" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Filename</label>
<input type="text" id="filename" name="filename" value={ data.Filter.FileName } placeholder="Filename or partial match"
class="form-input w-full" />
</div>
<div>
<label for="hash" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">File Hash</label>
<input type="text" id="hash" name="hash" value={ data.Filter.Hash } placeholder="MD5 hash"
class="form-input w-full" />
</div>
<div>
<label for="start_date" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Processed After</label>
<input type="date" id="start_date" name="start_date" value={ data.Filter.StartDate }
class="form-input w-full" />
</div>
<div>
<label for="end_date" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Processed Before</label>
<input type="date" id="end_date" name="end_date" value={ data.Filter.EndDate }
class="form-input w-full" />
</div>
<div class="md:col-span-2 flex justify-end">
<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>
</div>
<!-- Results -->
<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>
}
}
// 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>
<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>
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>
} 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>
</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>
}
} 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/partial?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>
} else if i == 1 || i == data.TotalPages || (i >= data.Page-2 && i <= data.Page+2) {
<a hx-get={ fmt.Sprintf("/files/search/partial?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 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/search/partial?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>
</div>
}
}