mirror of
https://github.com/StarFleetCPTN/GoMFT.git
synced 2026-09-08 15:41:20 +02:00
- Implement partial rendering templates for file metadata list and search - Add HTMX attributes for dynamic pagination and filtering - Create loading indicators for HTMX requests - Enhance user experience with smooth, asynchronous content updates - Improve pagination and filtering interactions using HTMX
1231 lines
43 KiB
Templ
1231 lines
43 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;
|
|
}
|
|
|
|
// FileMetadataList renders a list of file metadata with pagination and filters
|
|
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?htmx=true"
|
|
hx-target="#file-list-results"
|
|
hx-swap="innerHTML"
|
|
hx-indicator="#filter-loading"
|
|
class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
if data.Job == nil {
|
|
<div>
|
|
<label for="job_id" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Job</label>
|
|
<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-results" class="overflow-x-auto">
|
|
@FileMetadataListPartial(data)
|
|
</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) {
|
|
<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 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?htmx=true"
|
|
hx-target="#file-search-results"
|
|
hx-swap="innerHTML"
|
|
hx-indicator="#search-form-loading"
|
|
class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
<div>
|
|
<label for="job_id" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Job ID</label>
|
|
<input type="text" id="job_id" name="job_id" value={ data.Filter.JobID } placeholder="Job ID"
|
|
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 -->
|
|
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>
|
|
</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>
|
|
for _, file := range data.Files {
|
|
<tr id={ fmt.Sprintf("file-row-%d", file.ID) } class="hover:bg-secondary-50 dark:hover:bg-secondary-800 transition-colors duration-150">
|
|
<td>{ strconv.FormatUint(uint64(file.ID), 10) }</td>
|
|
<td class="text-primary-600 dark:text-primary-400">
|
|
<a href={ templ.SafeURL(fmt.Sprintf("/files/%d", file.ID)) } class="hover:underline">
|
|
{ file.FileName }
|
|
</a>
|
|
</td>
|
|
<td>{ formatFileSize(file.FileSize) }</td>
|
|
<td>{ file.ProcessedTime.Format("2006-01-02 15:04:05") }</td>
|
|
<td>
|
|
<span class={ "badge", getStatusBadgeClass(file.Status) }>
|
|
{ file.Status }
|
|
</span>
|
|
</td>
|
|
if data.Job == nil && file.Job.ID > 0 {
|
|
<td>
|
|
<a href={ templ.SafeURL(fmt.Sprintf("/files/job/%d", file.Job.ID)) } class="hover:underline text-primary-600 dark:text-primary-400">
|
|
{ file.Job.Name }
|
|
</a>
|
|
</td>
|
|
}
|
|
<td class="flex space-x-2">
|
|
<a href={ templ.SafeURL(fmt.Sprintf("/files/%d", file.ID)) } class="text-primary-600 dark:text-primary-400 hover:text-primary-800 dark:hover:text-primary-300">
|
|
<i class="fas fa-eye"></i>
|
|
</a>
|
|
<!-- Delete button that triggers the dialog -->
|
|
<button
|
|
type="button"
|
|
onclick={ showFileDialog(fmt.Sprintf("delete-file-dialog-%d", file.ID)) }
|
|
class="text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-300">
|
|
<i class="fas fa-trash"></i>
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
}
|
|
</tbody>
|
|
</table>
|
|
|
|
<!-- Pagination -->
|
|
if data.TotalPages > 1 {
|
|
<div class="flex justify-center items-center py-4 bg-secondary-50 dark:bg-secondary-800 rounded-lg">
|
|
<div class="flex gap-2">
|
|
if data.Page > 1 {
|
|
<a
|
|
hx-get={ buildPaginationURL(data, data.Page - 1) }
|
|
hx-target="#file-list-results"
|
|
hx-swap="innerHTML"
|
|
hx-indicator="#pagination-loading"
|
|
class="w-10 h-10 flex items-center justify-center bg-secondary-200 text-secondary-800 hover:bg-secondary-300 rounded dark:bg-secondary-700 dark:text-secondary-200 dark:hover:bg-secondary-600">
|
|
<i class="fas fa-chevron-left"></i>
|
|
</a>
|
|
} else {
|
|
<span class="w-10 h-10 flex items-center justify-center bg-secondary-100 text-secondary-300 rounded dark:bg-secondary-800 dark:text-secondary-600 cursor-not-allowed">
|
|
<i class="fas fa-chevron-left"></i>
|
|
</span>
|
|
}
|
|
|
|
for i := 1; i <= data.TotalPages; i++ {
|
|
if i == data.Page {
|
|
<span class="w-10 h-10 flex items-center justify-center bg-primary-600 text-white rounded">
|
|
{ strconv.Itoa(i) }
|
|
</span>
|
|
} else if i == 1 || i == data.TotalPages || (i >= data.Page-2 && i <= data.Page+2) {
|
|
<a
|
|
hx-get={ buildPaginationURL(data, i) }
|
|
hx-target="#file-list-results"
|
|
hx-swap="innerHTML"
|
|
hx-indicator="#pagination-loading"
|
|
class="w-10 h-10 flex items-center justify-center bg-secondary-200 text-secondary-800 hover:bg-secondary-300 rounded dark:bg-secondary-700 dark:text-secondary-200 dark:hover:bg-secondary-600">
|
|
{ strconv.Itoa(i) }
|
|
</a>
|
|
} else if i == data.Page-3 || i == data.Page+3 {
|
|
<span class="w-10 h-10 flex items-center justify-center">...</span>
|
|
}
|
|
}
|
|
|
|
if data.Page < data.TotalPages {
|
|
<a
|
|
hx-get={ buildPaginationURL(data, data.Page + 1) }
|
|
hx-target="#file-list-results"
|
|
hx-swap="innerHTML"
|
|
hx-indicator="#pagination-loading"
|
|
class="w-10 h-10 flex items-center justify-center bg-secondary-200 text-secondary-800 hover:bg-secondary-300 rounded dark:bg-secondary-700 dark:text-secondary-200 dark:hover:bg-secondary-600">
|
|
<i class="fas fa-chevron-right"></i>
|
|
</a>
|
|
} else {
|
|
<span class="w-10 h-10 flex items-center justify-center bg-secondary-100 text-secondary-300 rounded dark:bg-secondary-800 dark:text-secondary-600 cursor-not-allowed">
|
|
<i class="fas fa-chevron-right"></i>
|
|
</span>
|
|
}
|
|
</div>
|
|
<!-- Add loading indicator for HTMX requests -->
|
|
<div id="pagination-loading" class="htmx-indicator ml-2">
|
|
<i class="fas fa-circle-notch fa-spin text-primary-600"></i>
|
|
</div>
|
|
</div>
|
|
}
|
|
} else {
|
|
<div class="p-6 text-center text-secondary-600 dark:text-secondary-400">
|
|
<i class="fas fa-file-alt text-5xl mb-3"></i>
|
|
<p>No file metadata found matching your criteria.</p>
|
|
</div>
|
|
}
|
|
}
|
|
|
|
// FileMetadataSearchPartial renders just the search results content for HTMX requests
|
|
templ FileMetadataSearchPartial(data FileMetadataSearchData) {
|
|
if len(data.Files) > 0 {
|
|
<!-- Store file IDs for dialogs -->
|
|
<div id="search-dialog-container">
|
|
<!-- Dialogs will be rendered at the bottom of the container, outside the table -->
|
|
for _, file := range data.Files {
|
|
@FileMetadataDialog(
|
|
fmt.Sprintf("delete-file-dialog-%d", file.ID),
|
|
"Delete File Metadata",
|
|
fmt.Sprintf("Are you sure you want to delete the metadata for '%s'? This cannot be undone.", file.FileName),
|
|
"btn-danger",
|
|
"Delete",
|
|
"delete",
|
|
file.ID,
|
|
file.FileName,
|
|
"list",
|
|
)
|
|
}
|
|
</div>
|
|
<table class="table w-full">
|
|
<thead>
|
|
<tr>
|
|
<th>ID</th>
|
|
<th>Filename</th>
|
|
<th>Size</th>
|
|
<th>Processed</th>
|
|
<th>Status</th>
|
|
<th>Job</th>
|
|
<th>Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
for _, file := range data.Files {
|
|
<tr id={ fmt.Sprintf("file-row-%d", file.ID) } class="hover:bg-secondary-50 dark:hover:bg-secondary-800 transition-colors duration-150">
|
|
<td>{ strconv.FormatUint(uint64(file.ID), 10) }</td>
|
|
<td class="text-primary-600 dark:text-primary-400">
|
|
<a href={ templ.SafeURL(fmt.Sprintf("/files/%d", file.ID)) } class="hover:underline">
|
|
{ file.FileName }
|
|
</a>
|
|
</td>
|
|
<td>{ formatFileSize(file.FileSize) }</td>
|
|
<td>{ file.ProcessedTime.Format("2006-01-02 15:04:05") }</td>
|
|
<td>
|
|
<span class={ "badge", getStatusBadgeClass(file.Status) }>
|
|
{ file.Status }
|
|
</span>
|
|
</td>
|
|
<td>
|
|
<a href={ templ.SafeURL(fmt.Sprintf("/files/job/%d", file.Job.ID)) } class="hover:underline text-primary-600 dark:text-primary-400">
|
|
{ file.Job.Name }
|
|
</a>
|
|
</td>
|
|
<td class="flex space-x-2">
|
|
<a href={ templ.SafeURL(fmt.Sprintf("/files/%d", file.ID)) } class="text-primary-600 dark:text-primary-400 hover:text-primary-800 dark:hover:text-primary-300">
|
|
<i class="fas fa-eye"></i>
|
|
</a>
|
|
<!-- Delete button only - dialog moved outside table -->
|
|
<button
|
|
type="button"
|
|
onclick={ showFileDialog(fmt.Sprintf("delete-file-dialog-%d", file.ID)) }
|
|
class="text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-300">
|
|
<i class="fas fa-trash"></i>
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
}
|
|
</tbody>
|
|
</table>
|
|
|
|
<!-- Pagination -->
|
|
if data.TotalPages > 1 {
|
|
<div class="flex justify-center items-center py-4 bg-secondary-50 dark:bg-secondary-800 rounded-lg">
|
|
<div class="flex gap-2">
|
|
if data.Page > 1 {
|
|
<a
|
|
hx-get={ buildSearchPaginationURL(data, data.Page - 1) }
|
|
hx-target="#file-search-results"
|
|
hx-swap="innerHTML"
|
|
hx-indicator="#search-pagination-loading"
|
|
class="w-10 h-10 flex items-center justify-center bg-secondary-200 text-secondary-800 hover:bg-secondary-300 rounded dark:bg-secondary-700 dark:text-secondary-200 dark:hover:bg-secondary-600">
|
|
<i class="fas fa-chevron-left"></i>
|
|
</a>
|
|
} else {
|
|
<span class="w-10 h-10 flex items-center justify-center bg-secondary-100 text-secondary-300 rounded dark:bg-secondary-800 dark:text-secondary-600 cursor-not-allowed">
|
|
<i class="fas fa-chevron-left"></i>
|
|
</span>
|
|
}
|
|
|
|
for i := 1; i <= data.TotalPages; i++ {
|
|
if i == data.Page {
|
|
<span class="w-10 h-10 flex items-center justify-center bg-primary-600 text-white rounded">
|
|
{ strconv.Itoa(i) }
|
|
</span>
|
|
} else if i == 1 || i == data.TotalPages || (i >= data.Page-2 && i <= data.Page+2) {
|
|
<a
|
|
hx-get={ buildSearchPaginationURL(data, i) }
|
|
hx-target="#file-search-results"
|
|
hx-swap="innerHTML"
|
|
hx-indicator="#search-pagination-loading"
|
|
class="w-10 h-10 flex items-center justify-center bg-secondary-200 text-secondary-800 hover:bg-secondary-300 rounded dark:bg-secondary-700 dark:text-secondary-200 dark:hover:bg-secondary-600">
|
|
{ strconv.Itoa(i) }
|
|
</a>
|
|
} else if i == data.Page-3 || i == data.Page+3 {
|
|
<span class="w-10 h-10 flex items-center justify-center">...</span>
|
|
}
|
|
}
|
|
|
|
if data.Page < data.TotalPages {
|
|
<a
|
|
hx-get={ buildSearchPaginationURL(data, data.Page + 1) }
|
|
hx-target="#file-search-results"
|
|
hx-swap="innerHTML"
|
|
hx-indicator="#search-pagination-loading"
|
|
class="w-10 h-10 flex items-center justify-center bg-secondary-200 text-secondary-800 hover:bg-secondary-300 rounded dark:bg-secondary-700 dark:text-secondary-200 dark:hover:bg-secondary-600">
|
|
<i class="fas fa-chevron-right"></i>
|
|
</a>
|
|
} else {
|
|
<span class="w-10 h-10 flex items-center justify-center bg-secondary-100 text-secondary-300 rounded dark:bg-secondary-800 dark:text-secondary-600 cursor-not-allowed">
|
|
<i class="fas fa-chevron-right"></i>
|
|
</span>
|
|
}
|
|
</div>
|
|
<!-- Add loading indicator for HTMX requests -->
|
|
<div id="search-pagination-loading" class="htmx-indicator ml-2">
|
|
<i class="fas fa-circle-notch fa-spin text-primary-600"></i>
|
|
</div>
|
|
</div>
|
|
}
|
|
} else {
|
|
<div class="p-6 text-center text-secondary-600 dark:text-secondary-400">
|
|
<i class="fas fa-search text-5xl mb-3"></i>
|
|
<p>No files found matching your search criteria.</p>
|
|
</div>
|
|
}
|
|
} |