taggart

Simple golang tagging filesystem webapp
Log | Files | Refs

commit 424ff38442d36652e7ab62693a2256bc5c025ac1
parent b813d418c9e39593113ce882aaf709846b32fc63
Author: breadcat <breadcat@users.noreply.github.com>
Date:   Fri, 28 Nov 2025 13:26:01 +0000

Merge orphans and thumbnails into admin page

First draft, requires heavy CSS work

Diffstat:
Mmain.go | 186++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------
Astatic/admin-tabs.js | 89+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtemplates/admin.html | 337+++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------------
Dtemplates/orphans.html | 16----------------
Dtemplates/thumbnails.html | 117-------------------------------------------------------------------------------
5 files changed, 464 insertions(+), 281 deletions(-)

diff --git a/main.go b/main.go @@ -379,9 +379,6 @@ func main() { http.HandleFunc("/search", searchHandler) http.HandleFunc("/bulk-tag", bulkTagHandler) http.HandleFunc("/admin", adminHandler) - http.HandleFunc("/orphans", orphansHandler) - http.HandleFunc("/thumbnails", thumbnailsHandler) - http.HandleFunc("/thumbnails/generate", generateThumbnailHandler) http.Handle("/uploads/", http.StripPrefix("/uploads/", http.FileServer(http.Dir(config.UploadDir)))) http.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static")))) @@ -1185,25 +1182,35 @@ func validateConfig(newConfig Config) error { } func adminHandler(w http.ResponseWriter, r *http.Request) { + // Get orphaned files + orphans, _ := getOrphanedFiles(config.UploadDir) + + // Get video files for thumbnails + missingThumbnails, _ := getMissingThumbnailVideos() + switch r.Method { case http.MethodPost: action := r.FormValue("action") switch action { case "save", "": - handleSaveSettings(w, r) + handleSaveSettings(w, r, orphans, missingThumbnails) return case "backup": err := backupDatabase(config.DatabasePath) pageData := buildPageData("Admin", struct { - Config Config - Error string - Success string + Config Config + Error string + Success string + Orphans []string + MissingThumbnails []VideoFile }{ - Config: config, - Error: errorString(err), - Success: successString(err, "Database backup created successfully!"), + Config: config, + Error: errorString(err), + Success: successString(err, "Database backup created successfully!"), + Orphans: orphans, + MissingThumbnails: missingThumbnails, }) renderTemplate(w, "admin.html", pageData) return @@ -1211,43 +1218,63 @@ func adminHandler(w http.ResponseWriter, r *http.Request) { case "vacuum": err := vacuumDatabase(config.DatabasePath) pageData := buildPageData("Admin", struct { - Config Config - Error string - Success string + Config Config + Error string + Success string + Orphans []string + MissingThumbnails []VideoFile }{ - Config: config, - Error: errorString(err), - Success: successString(err, "Database vacuum completed successfully!"), + Config: config, + Error: errorString(err), + Success: successString(err, "Database vacuum completed successfully!"), + Orphans: orphans, + MissingThumbnails: missingThumbnails, }) renderTemplate(w, "admin.html", pageData) return case "save_aliases": - handleSaveAliases(w, r) + handleSaveAliases(w, r, orphans, missingThumbnails) return } default: pageData := buildPageData("Admin", struct { - Config Config - Error string - Success string - }{config, "", ""}) + Config Config + Error string + Success string + Orphans []string + MissingThumbnails []VideoFile + }{ + Config: config, + Error: "", + Success: "", + Orphans: orphans, + MissingThumbnails: missingThumbnails, + }) renderTemplate(w, "admin.html", pageData) } } -func handleSaveAliases(w http.ResponseWriter, r *http.Request) { +func handleSaveAliases(w http.ResponseWriter, r *http.Request, orphans []string, missingThumbnails []VideoFile) { aliasesJSON := r.FormValue("aliases_json") var aliases []TagAliasGroup if aliasesJSON != "" { if err := json.Unmarshal([]byte(aliasesJSON), &aliases); err != nil { pageData := buildPageData("Admin", struct { - Config Config - Error string - Success string - }{config, "Invalid aliases JSON: " + err.Error(), ""}) + Config Config + Error string + Success string + Orphans []string + MissingThumbnails []VideoFile + }{ + Config: config, + Error: "Invalid aliases JSON: " + err.Error(), + Success: "", + Orphans: orphans, + MissingThumbnails: missingThumbnails, + }) renderTemplate(w, "admin.html", pageData) return } @@ -1257,23 +1284,39 @@ func handleSaveAliases(w http.ResponseWriter, r *http.Request) { if err := saveConfig(); err != nil { pageData := buildPageData("Admin", struct { - Config Config - Error string - Success string - }{config, "Failed to save configuration: " + err.Error(), ""}) + Config Config + Error string + Success string + Orphans []string + MissingThumbnails []VideoFile + }{ + Config: config, + Error: "Failed to save configuration: " + err.Error(), + Success: "", + Orphans: orphans, + MissingThumbnails: missingThumbnails, + }) renderTemplate(w, "admin.html", pageData) return } pageData := buildPageData("Admin", struct { - Config Config - Error string - Success string - }{config, "", "Tag aliases saved successfully!"}) + Config Config + Error string + Success string + Orphans []string + MissingThumbnails []VideoFile + }{ + Config: config, + Error: "", + Success: "Tag aliases saved successfully!", + Orphans: orphans, + MissingThumbnails: missingThumbnails, + }) renderTemplate(w, "admin.html", pageData) } -func handleSaveSettings(w http.ResponseWriter, r *http.Request) { +func handleSaveSettings(w http.ResponseWriter, r *http.Request, orphans []string, missingThumbnails []VideoFile) { newConfig := Config{ DatabasePath: strings.TrimSpace(r.FormValue("database_path")), UploadDir: strings.TrimSpace(r.FormValue("upload_dir")), @@ -1286,9 +1329,18 @@ func handleSaveSettings(w http.ResponseWriter, r *http.Request) { if err := validateConfig(newConfig); err != nil { pageData := buildPageData("Admin", struct { - Config Config - Error string - }{config, err.Error()}) + Config Config + Error string + Success string + Orphans []string + MissingThumbnails []VideoFile + }{ + Config: config, + Error: err.Error(), + Success: "", + Orphans: orphans, + MissingThumbnails: missingThumbnails, + }) renderTemplate(w, "admin.html", pageData) return } @@ -1299,9 +1351,18 @@ func handleSaveSettings(w http.ResponseWriter, r *http.Request) { config = newConfig if err := saveConfig(); err != nil { pageData := buildPageData("Admin", struct { - Config Config - Error string - }{config, "Failed to save configuration: " + err.Error()}) + Config Config + Error string + Success string + Orphans []string + MissingThumbnails []VideoFile + }{ + Config: config, + Error: "Failed to save configuration: " + err.Error(), + Success: "", + Orphans: orphans, + MissingThumbnails: missingThumbnails, + }) renderTemplate(w, "admin.html", pageData) return } @@ -1314,13 +1375,22 @@ func handleSaveSettings(w http.ResponseWriter, r *http.Request) { } pageData := buildPageData("Admin", struct { - Config Config - Error string - Success string - }{config, "", message}) + Config Config + Error string + Success string + Orphans []string + MissingThumbnails []VideoFile + }{ + Config: config, + Error: "", + Success: message, + Orphans: orphans, + MissingThumbnails: missingThumbnails, + }) renderTemplate(w, "admin.html", pageData) } + func errorString(err error) string { if err != nil { return err.Error() @@ -1995,17 +2065,23 @@ func thumbnailsHandler(w http.ResponseWriter, r *http.Request) { func generateThumbnailHandler(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { - http.Redirect(w, r, "/thumbnails", http.StatusSeeOther) + http.Redirect(w, r, "/admin", http.StatusSeeOther) return } action := r.FormValue("action") + redirectTo := r.FormValue("redirect") + if redirectTo == "" { + redirectTo = "thumbnails" + } + + redirectBase := "/" + redirectTo switch action { case "generate_all": missing, err := getMissingThumbnailVideos() if err != nil { - http.Redirect(w, r, "/thumbnails?error="+url.QueryEscape("Failed to get videos: "+err.Error()), http.StatusSeeOther) + http.Redirect(w, r, redirectBase+"?error="+url.QueryEscape("Failed to get videos: "+err.Error()), http.StatusSeeOther) return } @@ -2022,9 +2098,9 @@ func generateThumbnailHandler(w http.ResponseWriter, r *http.Request) { } if len(errors) > 0 { - http.Redirect(w, r, "/thumbnails?success="+url.QueryEscape(fmt.Sprintf("Generated %d thumbnails", successCount))+"&error="+url.QueryEscape(fmt.Sprintf("Failed: %s", strings.Join(errors, "; "))), http.StatusSeeOther) + http.Redirect(w, r, redirectBase+"?success="+url.QueryEscape(fmt.Sprintf("Generated %d thumbnails", successCount))+"&error="+url.QueryEscape(fmt.Sprintf("Failed: %s", strings.Join(errors, "; "))), http.StatusSeeOther) } else { - http.Redirect(w, r, "/thumbnails?success="+url.QueryEscape(fmt.Sprintf("Successfully generated %d thumbnails", successCount)), http.StatusSeeOther) + http.Redirect(w, r, redirectBase+"?success="+url.QueryEscape(fmt.Sprintf("Successfully generated %d thumbnails", successCount)), http.StatusSeeOther) } case "generate_single": @@ -2038,20 +2114,24 @@ func generateThumbnailHandler(w http.ResponseWriter, r *http.Request) { var filename, path string err := db.QueryRow("SELECT filename, path FROM files WHERE id=?", fileID).Scan(&filename, &path) if err != nil { - http.Redirect(w, r, "/thumbnails?error="+url.QueryEscape("File not found"), http.StatusSeeOther) + http.Redirect(w, r, redirectBase+"?error="+url.QueryEscape("File not found"), http.StatusSeeOther) return } err = generateThumbnailAtTime(path, config.UploadDir, filename, timestamp) if err != nil { - http.Redirect(w, r, "/thumbnails?error="+url.QueryEscape("Failed to generate thumbnail: "+err.Error()), http.StatusSeeOther) + http.Redirect(w, r, redirectBase+"?error="+url.QueryEscape("Failed to generate thumbnail: "+err.Error()), http.StatusSeeOther) return } - http.Redirect(w, r, fmt.Sprintf("/file/%s?success=%s", fileID, url.QueryEscape(fmt.Sprintf("Thumbnail generated at %s", timestamp))), http.StatusSeeOther) + if redirectTo == "admin" { + http.Redirect(w, r, "/admin?success="+url.QueryEscape(fmt.Sprintf("Thumbnail generated for file %s at %s", fileID, timestamp)), http.StatusSeeOther) + } else { + http.Redirect(w, r, fmt.Sprintf("/file/%s?success=%s", fileID, url.QueryEscape(fmt.Sprintf("Thumbnail generated at %s", timestamp))), http.StatusSeeOther) + } default: - http.Redirect(w, r, "/thumbnails", http.StatusSeeOther) + http.Redirect(w, r, redirectBase, http.StatusSeeOther) } } diff --git a/static/admin-tabs.js b/static/admin-tabs.js @@ -0,0 +1,88 @@ +// Admin tab management +function showAdminTab(tabName) { + // Hide all content sections + const contents = ['settings', 'database', 'aliases', 'orphans', 'thumbnails']; + contents.forEach(name => { + const content = document.getElementById(`admin-content-${name}`); + if (content) { + content.style.display = 'none'; + } + }); + + // Remove active styling from all tabs + document.querySelectorAll('.admin-tab-btn').forEach(btn => { + btn.style.borderBottomColor = 'transparent'; + btn.style.fontWeight = 'normal'; + }); + + // Show selected content + const selectedContent = document.getElementById(`admin-content-${tabName}`); + if (selectedContent) { + selectedContent.style.display = 'block'; + } + + // Activate selected tab + const selectedTab = document.getElementById(`admin-tab-${tabName}`); + if (selectedTab) { + selectedTab.style.borderBottomColor = '#007bff'; + selectedTab.style.fontWeight = 'bold'; + } + + // Store active tab in session storage + sessionStorage.setItem('activeAdminTab', tabName); +} + +// Thumbnail sub-tab management +function showThumbnailSubTab(subTabName) { + // Hide all sub-tab contents + const subContents = ['missing', 'regenerate']; + subContents.forEach(name => { + const content = document.getElementById(`thumb-content-${name}`); + if (content) { + content.style.display = 'none'; + } + }); + + // Remove active styling from all sub-tabs + document.querySelectorAll('.thumb-subtab-btn').forEach(btn => { + btn.style.borderBottomColor = 'transparent'; + btn.style.fontWeight = 'normal'; + }); + + // Show selected sub-tab content + const selectedContent = document.getElementById(`thumb-content-${subTabName}`); + if (selectedContent) { + selectedContent.style.display = 'block'; + } + + // Activate selected sub-tab + const selectedTab = document.getElementById(`thumb-subtab-${subTabName}`); + if (selectedTab) { + selectedTab.style.borderBottomColor = '#007bff'; + selectedTab.style.fontWeight = 'bold'; + } + + // Store active sub-tab in session storage + sessionStorage.setItem('activeThumbnailSubTab', subTabName); +} + +// Initialize on page load +document.addEventListener('DOMContentLoaded', function() { + // Restore previous tab selection or default to settings + const savedTab = sessionStorage.getItem('activeAdminTab') || 'settings'; + showAdminTab(savedTab); + + // Restore previous thumbnail sub-tab or default to missing + const savedSubTab = sessionStorage.getItem('activeThumbnailSubTab') || 'missing'; + showThumbnailSubTab(savedSubTab); + + // Auto-hide success messages after 5 seconds + const successDivs = document.querySelectorAll('.auto-hide-success'); + successDivs.forEach(div => { + setTimeout(() => { + div.style.transition = 'opacity 0.5s'; + div.style.opacity = '0'; + setTimeout(() => div.remove(), 500); + }, 5000); + }); +}); +\ No newline at end of file diff --git a/templates/admin.html b/templates/admin.html @@ -1,5 +1,5 @@ {{template "_header" .}} -<h1>Settings</h1> +<h1>Admin</h1> {{if .Data.Error}} <div style="background-color: #f8d7da; color: #721c24; padding: 10px; border: 1px solid #f5c6cb; border-radius: 4px; margin-bottom: 20px;"> @@ -13,119 +13,265 @@ </div> {{end}} -<form method="post" style="max-width: 600px;"> - <div style="margin-bottom: 20px;"> - <label for="database_path" style="display: block; font-weight: bold; margin-bottom: 5px;">Database Path:</label> - <input type="text" id="database_path" name="database_path" value="{{.Data.Config.DatabasePath}}" required - style="width: 100%; padding: 8px; font-size: 14px;" - placeholder="./database.db"> - <small style="color: #666;">Path to SQLite database file (requires restart if changed)</small> - </div> +<!-- Tab Navigation --> +<div style="margin-bottom: 20px; border-bottom: 2px solid #ddd;"> + <button onclick="showAdminTab('settings')" id="admin-tab-settings" class="admin-tab-btn" style="padding: 10px 20px; border: none; background: none; cursor: pointer; border-bottom: 3px solid #007bff; font-weight: bold;"> + Settings + </button> + <button onclick="showAdminTab('database')" id="admin-tab-database" class="admin-tab-btn" style="padding: 10px 20px; border: none; background: none; cursor: pointer; border-bottom: 3px solid transparent;"> + Database + </button> + <button onclick="showAdminTab('aliases')" id="admin-tab-aliases" class="admin-tab-btn" style="padding: 10px 20px; border: none; background: none; cursor: pointer; border-bottom: 3px solid transparent;"> + Aliases + </button> + <button onclick="showAdminTab('orphans')" id="admin-tab-orphans" class="admin-tab-btn" style="padding: 10px 20px; border: none; background: none; cursor: pointer; border-bottom: 3px solid transparent;"> + Orphans + </button> + <button onclick="showAdminTab('thumbnails')" id="admin-tab-thumbnails" class="admin-tab-btn" style="padding: 10px 20px; border: none; background: none; cursor: pointer; border-bottom: 3px solid transparent;"> + Thumbnails + </button> +</div> - <div style="margin-bottom: 20px;"> - <label for="upload_dir" style="display: block; font-weight: bold; margin-bottom: 5px;">Upload Directory:</label> - <input type="text" id="upload_dir" name="upload_dir" value="{{.Data.Config.UploadDir}}" required - style="width: 100%; padding: 8px; font-size: 14px;" - placeholder="uploads"> - <small style="color: #666;">Directory where uploaded files are stored</small> - </div> +<!-- Settings Tab --> +<div id="admin-content-settings"> + <h2>Settings</h2> + <form method="post" style="max-width: 600px;"> + <div style="margin-bottom: 20px;"> + <label for="database_path" style="display: block; font-weight: bold; margin-bottom: 5px;">Database Path:</label> + <input type="text" id="database_path" name="database_path" value="{{.Data.Config.DatabasePath}}" required + style="width: 100%; padding: 8px; font-size: 14px;" + placeholder="./database.db"> + <small style="color: #666;">Path to SQLite database file (requires restart if changed)</small> + </div> - <div style="margin-bottom: 20px;"> - <label for="server_port" style="display: block; font-weight: bold; margin-bottom: 5px;">Server Port:</label> - <input type="text" id="server_port" name="server_port" value="{{.Data.Config.ServerPort}}" required - style="width: 100%; padding: 8px; font-size: 14px;" - placeholder=":8080"> - <small style="color: #666;">Port for web server (format: :8080, requires restart if changed)</small> - </div> + <div style="margin-bottom: 20px;"> + <label for="upload_dir" style="display: block; font-weight: bold; margin-bottom: 5px;">Upload Directory:</label> + <input type="text" id="upload_dir" name="upload_dir" value="{{.Data.Config.UploadDir}}" required + style="width: 100%; padding: 8px; font-size: 14px;" + placeholder="uploads"> + <small style="color: #666;">Directory where uploaded files are stored</small> + </div> - <div style="margin-bottom: 20px;"> - <label for="instance_name" style="display: block; font-weight: bold; margin-bottom: 5px;">Instance Name:</label> - <input type="text" id="instance_name" name="instance_name" value="{{.Data.Config.InstanceName}}" required - style="width: 100%; padding: 8px; font-size: 14px;" - placeholder="Taggart"> - <small style="color: #666;">Instance Name, used in header and title bar</small> - </div> + <div style="margin-bottom: 20px;"> + <label for="server_port" style="display: block; font-weight: bold; margin-bottom: 5px;">Server Port:</label> + <input type="text" id="server_port" name="server_port" value="{{.Data.Config.ServerPort}}" required + style="width: 100%; padding: 8px; font-size: 14px;" + placeholder=":8080"> + <small style="color: #666;">Port for web server (format: :8080, requires restart if changed)</small> + </div> - <div style="margin-bottom: 20px;"> - <label for="gallery_size" style="display: block; font-weight: bold; margin-bottom: 5px;">Gallery Size:</label> - <input type="text" id="gallery_size" name="gallery_size" value="{{.Data.Config.GallerySize}}" required - style="width: 100%; padding: 8px; font-size: 14px;" - placeholder="400px"> - <small style="color: #666;">Size of previews used in galleries</small> - </div> + <div style="margin-bottom: 20px;"> + <label for="instance_name" style="display: block; font-weight: bold; margin-bottom: 5px;">Instance Name:</label> + <input type="text" id="instance_name" name="instance_name" value="{{.Data.Config.InstanceName}}" required + style="width: 100%; padding: 8px; font-size: 14px;" + placeholder="Taggart"> + <small style="color: #666;">Instance Name, used in header and title bar</small> + </div> - <div style="margin-bottom: 20px;"> - <label for="items_per_page" style="display: block; font-weight: bold; margin-bottom: 5px;">Items per Page:</label> - <input type="text" id="items_per_page" name="items_per_page" value="{{.Data.Config.ItemsPerPage}}" required - style="width: 100%; padding: 8px; font-size: 14px;" - placeholder="100"> - <small style="color: #666;">Items per page in galleries</small> - </div> + <div style="margin-bottom: 20px;"> + <label for="gallery_size" style="display: block; font-weight: bold; margin-bottom: 5px;">Gallery Size:</label> + <input type="text" id="gallery_size" name="gallery_size" value="{{.Data.Config.GallerySize}}" required + style="width: 100%; padding: 8px; font-size: 14px;" + placeholder="400px"> + <small style="color: #666;">Size of previews used in galleries</small> + </div> - <button type="submit" style="background-color: #007bff; color: white; padding: 10px 20px; border: none; border-radius: 4px; font-size: 16px; cursor: pointer;"> - Save Settings - </button> -</form> + <div style="margin-bottom: 20px;"> + <label for="items_per_page" style="display: block; font-weight: bold; margin-bottom: 5px;">Items per Page:</label> + <input type="text" id="items_per_page" name="items_per_page" value="{{.Data.Config.ItemsPerPage}}" required + style="width: 100%; padding: 8px; font-size: 14px;" + placeholder="100"> + <small style="color: #666;">Items per page in galleries</small> + </div> -<hr style="margin: 40px 0;"> + <button type="submit" style="background-color: #007bff; color: white; padding: 10px 20px; border: none; border-radius: 4px; font-size: 16px; cursor: pointer;"> + Save Settings + </button> + </form> -<h2>Tag Aliases</h2> -<p style="color: #666; margin-bottom: 20px;"> - Define tag aliases so that multiple tag values are treated as equivalent when searching or filtering. - For example, if you alias "colour/blue" with "colour/navy", searching for either will show files tagged with both. -</p> + <div style="margin-top: 40px; padding: 20px; background-color: #f8f9fa; border-radius: 5px;"> + <h3>Current Configuration:</h3> + <ul> + <li><strong>Database:</strong> {{.Data.Config.DatabasePath}}</li> + <li><strong>Upload Directory:</strong> {{.Data.Config.UploadDir}}</li> + <li><strong>Server Port:</strong> {{.Data.Config.ServerPort}}</li> + <li><strong>Instance Name:</strong> {{.Data.Config.InstanceName}}</li> + <li><strong>Gallery Size:</strong> {{.Data.Config.GallerySize}}</li> + <li><strong>Items per Page:</strong> {{.Data.Config.ItemsPerPage}}</li> + </ul> -<div id="aliases-section" style="max-width: 800px;"> - <div id="alias-groups"></div> + <h4>Configuration File:</h4> + <p>Settings are stored in <code>config.json</code> in the application directory.</p> + </div> +</div> - <button onclick="addAliasGroup()" style="background-color: #28a745; color: white; padding: 8px 16px; border: none; border-radius: 4px; font-size: 14px; cursor: pointer; margin-top: 10px;"> - + Add Alias Group - </button> +<!-- Database Tab --> +<div id="admin-content-database" style="display: none;"> + <h2>Database Maintenance</h2> - <form method="post" id="aliases-form" style="margin-top: 20px;"> - <input type="hidden" name="action" value="save_aliases"> - <input type="hidden" name="aliases_json" id="aliases_json"> - <button type="submit" style="background-color: #007bff; color: white; padding: 10px 20px; border: none; border-radius: 4px; font-size: 16px; cursor: pointer;"> - Save Aliases + <form method="post" style="margin-bottom: 20px;"> + <input type="hidden" name="action" value="backup"> + <button type="submit" style="background-color: #28a745; color: white; padding: 10px 20px; border: none; border-radius: 4px; font-size: 16px; cursor: pointer;"> + Backup Database </button> + <small style="color: #666; margin-left: 10px;">Creates a timestamped backup of the database file</small> + </form> + + <form method="post"> + <input type="hidden" name="action" value="vacuum"> + <button type="submit" style="background-color: #6f42c1; color: white; padding: 10px 20px; border: none; border-radius: 4px; font-size: 16px; cursor: pointer;"> + Vacuum Database + </button> + <small style="color: #666; margin-left: 10px;">Reclaims unused space and optimizes database performance</small> </form> </div> -<script>window.initialAliasGroups = {{.Data.Config.TagAliases}};</script><script src="/static/tag-alias.js" defer></script> - -<div style="margin-top: 40px; padding: 20px; background-color: #f8f9fa; border-radius: 5px;"> - <h3>Current Configuration:</h3> - <ul> - <li><strong>Database:</strong> {{.Data.Config.DatabasePath}}</li> - <li><strong>Upload Directory:</strong> {{.Data.Config.UploadDir}}</li> - <li><strong>Server Port:</strong> {{.Data.Config.ServerPort}}</li> - <li><strong>Instance Name:</strong> {{.Data.Config.InstanceName}}</li> - <li><strong>Gallery Size:</strong> {{.Data.Config.GallerySize}}</li> - <li><strong>Items per Page:</strong> {{.Data.Config.ItemsPerPage}}</li> - </ul> +<!-- Aliases Tab --> +<div id="admin-content-aliases" style="display: none;"> + <h2>Tag Aliases</h2> + <p style="color: #666; margin-bottom: 20px;"> + Define tag aliases so that multiple tag values are treated as equivalent when searching or filtering. + For example, if you alias "colour/blue" with "colour/navy", searching for either will show files tagged with both. + </p> + + <div id="aliases-section" style="max-width: 800px;"> + <div id="alias-groups"></div> + + <button onclick="addAliasGroup()" style="background-color: #28a745; color: white; padding: 8px 16px; border: none; border-radius: 4px; font-size: 14px; cursor: pointer; margin-top: 10px;"> + + Add Alias Group + </button> - <h4>Configuration File:</h4> - <p>Settings are stored in <code>config.json</code> in the application directory.</p> + <form method="post" id="aliases-form" style="margin-top: 20px;"> + <input type="hidden" name="action" value="save_aliases"> + <input type="hidden" name="aliases_json" id="aliases_json"> + <button type="submit" style="background-color: #007bff; color: white; padding: 10px 20px; border: none; border-radius: 4px; font-size: 16px; cursor: pointer;"> + Save Aliases + </button> + </form> + </div> </div> -<hr style="margin: 40px 0;"> +<!-- Orphans Tab --> +<div id="admin-content-orphans" style="display: none;"> + <h2>Orphaned Files</h2> + <p style="color: #666; margin-bottom: 20px;"> + These files exist in the upload directory but are not tracked in the database. + </p> -<h2>Database Maintenance</h2> + {{if .Data.Orphans}} + <ul style="list-style-type: disc; padding-left: 20px;"> + {{range .Data.Orphans}} + <li style="margin-bottom: 5px; font-family: monospace;">{{.}}</li> + {{end}} + </ul> + {{else}} + <div style="padding: 20px; background-color: #d4edda; color: #155724; border: 1px solid #c3e6cb; border-radius: 4px;"> + <strong>✓ No orphaned files found!</strong> + </div> + {{end}} +</div> -<form method="post" style="margin-bottom: 20px;"> - <input type="hidden" name="action" value="backup"> - <button type="submit" style="background-color: #28a745; color: white; padding: 10px 20px; border: none; border-radius: 4px; font-size: 16px; cursor: pointer;"> - Backup Database - </button> - <small style="color: #666; margin-left: 10px;">Creates a timestamped backup of the database file</small> -</form> +<!-- Thumbnails Tab --> +<div id="admin-content-thumbnails" style="display: none;"> + <h2>Thumbnail Management</h2> -<form method="post"> - <input type="hidden" name="action" value="vacuum"> - <button type="submit" style="background-color: #6f42c1; color: white; padding: 10px 20px; border: none; border-radius: 4px; font-size: 16px; cursor: pointer;"> - Vacuum Database - </button> - <small style="color: #666; margin-left: 10px;">Reclaims unused space and optimizes database performance</small> -</form> + <!-- Sub-tab Navigation --> + <div style="margin-bottom: 20px; border-bottom: 1px solid #ddd;"> + <button onclick="showThumbnailSubTab('missing')" id="thumb-subtab-missing" class="thumb-subtab-btn" style="padding: 8px 16px; border: none; background: none; cursor: pointer; border-bottom: 2px solid #007bff; font-weight: bold;"> + Missing ({{len .Data.MissingThumbnails}}) + </button> + <button onclick="showThumbnailSubTab('regenerate')" id="thumb-subtab-regenerate" class="thumb-subtab-btn" style="padding: 8px 16px; border: none; background: none; cursor: pointer; border-bottom: 2px solid transparent;"> + Regenerate + </button> + </div> + + <!-- Missing Thumbnails Sub-tab --> + <div id="thumb-content-missing"> + <h3>Missing Thumbnails ({{len .Data.MissingThumbnails}})</h3> + + {{if .Data.MissingThumbnails}} + <form method="post" action="/thumbnails/generate" style="margin-bottom: 20px;"> + <input type="hidden" name="action" value="generate_all"> + <button type="submit" onclick="return confirm('Generate thumbnails for all {{len .Data.MissingThumbnails}} videos? This may take a while.');" style="background-color: #28a745; color: white; padding: 10px 20px; border: none; border-radius: 4px; font-size: 16px; cursor: pointer;"> + Generate All Missing Thumbnails + </button> + <small style="color: #666; margin-left: 10px;">Uses timestamp 00:00:05 for all videos</small> + </form> + + <div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 20px;"> + {{range .Data.MissingThumbnails}} + <div style="border: 1px solid #ddd; padding: 15px; border-radius: 5px; background-color: #f8f9fa;"> + <h4 style="margin-top: 0; font-size: 14px; word-break: break-word;"> + <a href="/file/{{.ID}}" target="_blank">{{.Filename}}</a> + </h4> + <p style="color: #666; font-size: 12px; margin: 5px 0;">ID: {{.ID}}</p> + + <video width="100%" style="max-height: 200px; background: #000; margin: 10px 0; cursor: pointer;" title="Click to capture frame"> + <source src="/uploads/{{.EscapedFilename}}"> + </video> + + <form method="post" action="/thumbnails/generate" style="margin-top: 10px;"> + <input type="hidden" name="action" value="generate_single"> + <input type="hidden" name="file_id" value="{{.ID}}"> + <input type="hidden" name="redirect" value="admin"> + + <div style="display: flex; gap: 5px; align-items: center; margin-bottom: 10px;"> + <label style="font-size: 13px; white-space: nowrap;">Timestamp:</label> + <input type="text" name="timestamp" value="00:00:05" placeholder="00:00:05" + style="flex: 1; padding: 5px; font-size: 13px; font-family: monospace;"> + </div> + + <button type="submit" style="background-color: #007bff; color: white; padding: 6px 12px; border: none; border-radius: 3px; font-size: 13px; cursor: pointer; width: 100%;"> + Generate Thumbnail + </button> + </form> + </div> + {{end}} + </div> + {{else}} + <div style="padding: 20px; background-color: #d4edda; color: #155724; border: 1px solid #c3e6cb; border-radius: 4px;"> + <strong>✓ All videos have thumbnails!</strong> + </div> + {{end}} + </div> + + <!-- Regenerate Sub-tab --> + <div id="thumb-content-regenerate" style="display: none;"> + <h3>Regenerate Thumbnail</h3> + + <div style="margin-bottom: 20px; padding: 10px; background-color: #e7f3ff; border: 1px solid #b3d9ff; border-radius: 4px;"> + <strong>Tip:</strong> Enter a file ID to regenerate its thumbnail with a custom timestamp. You can find file IDs in the URL when viewing a file (e.g., /file/312). + </div> + + <form method="post" action="/thumbnails/generate" style="max-width: 500px; padding: 20px; background-color: #f8f9fa; border: 1px solid #ddd; border-radius: 5px;"> + <input type="hidden" name="action" value="generate_single"> + <input type="hidden" name="redirect" value="admin"> + + <div style="margin-bottom: 20px;"> + <label for="file_id" style="display: block; font-weight: bold; margin-bottom: 5px;">File ID:</label> + <input type="text" id="file_id" name="file_id" required + style="width: 100%; padding: 8px; font-size: 14px; font-family: monospace;" + placeholder="e.g., 312"> + <small style="color: #666;">Enter the ID of the video file</small> + </div> + + <div style="margin-bottom: 20px;"> + <label for="timestamp" style="display: block; font-weight: bold; margin-bottom: 5px;">Timestamp:</label> + <input type="text" id="timestamp" name="timestamp" value="00:00:05" required + style="width: 100%; padding: 8px; font-size: 14px; font-family: monospace;" + placeholder="00:00:05"> + <small style="color: #666;">Format: HH:MM:SS (e.g., 00:00:05 for 5 seconds)</small> + </div> + + <button type="submit" style="background-color: #007bff; color: white; padding: 10px 20px; border: none; border-radius: 4px; font-size: 16px; cursor: pointer; width: 100%;"> + Generate/Regenerate Thumbnail + </button> + </form> + </div> +</div> + +<script>window.initialAliasGroups = {{.Data.Config.TagAliases}};</script> +<script src="/static/tag-alias.js" defer></script> +<script src="/static/admin-tabs.js" defer></script> -{{template "_footer"}} +{{template "_footer"}} +\ No newline at end of file diff --git a/templates/orphans.html b/templates/orphans.html @@ -1,15 +0,0 @@ -{{template "_header" .}} - -<h1>Orphaned Files</h1> - -{{if .Data}} -<ul> - {{range .Data}} - <li>{{.}}</li> - {{end}} -</ul> -{{else}} -<p>No orphaned files</p> -{{end}} - -{{template "_footer"}} -\ No newline at end of file diff --git a/templates/thumbnails.html b/templates/thumbnails.html @@ -1,116 +0,0 @@ -{{template "_header" .}} -<h1>Thumbnail Management</h1> - -{{if .Data.Error}} -<div style="background-color: #f8d7da; color: #721c24; padding: 10px; border: 1px solid #f5c6cb; border-radius: 4px; margin-bottom: 20px;"> - <strong>Error:</strong> {{.Data.Error}} -</div> -{{end}} - -{{if .Data.Success}} -<div style="background-color: #d4edda; color: #155724; padding: 10px; border: 1px solid #c3e6cb; border-radius: 4px; margin-bottom: 20px;"> - <strong>Success:</strong> {{.Data.Success}} -</div> -{{end}} - -<!-- Tab Navigation --> -<div style="margin-bottom: 20px; border-bottom: 2px solid #ddd;"> - <button onclick="showTab('missing')" id="tab-missing" style="padding: 10px 20px; border: none; background: none; cursor: pointer; border-bottom: 3px solid #007bff; font-weight: bold;"> - Missing ({{len .Data.MissingThumbnails}}) - </button> - <button onclick="showTab('all')" id="tab-all" style="padding: 10px 20px; border: none; background: none; cursor: pointer; border-bottom: 3px solid transparent;"> - All Videos ({{len .Data.AllVideos}}) - </button> -</div> - -<!-- Missing Thumbnails Tab --> -<div id="content-missing" style="margin-bottom: 30px;"> - <h2>Missing Thumbnails ({{len .Data.MissingThumbnails}})</h2> - - {{if .Data.MissingThumbnails}} - <form method="post" action="/thumbnails/generate" style="margin-bottom: 20px;"> - <input type="hidden" name="action" value="generate_all"> - <button type="submit" onclick="return confirm('Generate thumbnails for all {{len .Data.MissingThumbnails}} videos? This may take a while.');" style="background-color: #28a745; color: white; padding: 10px 20px; border: none; border-radius: 4px; font-size: 16px; cursor: pointer;"> - Generate All Missing Thumbnails - </button> - <small style="color: #666; margin-left: 10px;">Uses timestamp 00:00:05 for all videos</small> - </form> - - <div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 20px;"> - {{range .Data.MissingThumbnails}} - <div style="border: 1px solid #ddd; padding: 15px; border-radius: 5px; background-color: #f8f9fa;"> - <h3 style="margin-top: 0; font-size: 14px; word-break: break-word;"> - <a href="/file/{{.ID}}" target="_blank">{{.Filename}}</a> - </h3> - <p style="color: #666; font-size: 12px; margin: 5px 0;">ID: {{.ID}}</p> - - <video width="100%" style="max-height: 200px; background: #000; margin: 10px 0; cursor: pointer;" title="Click to capture frame"> - <source src="/uploads/{{.EscapedFilename}}"> - </video> - - <form method="post" action="/thumbnails/generate" style="margin-top: 10px;"> - <input type="hidden" name="action" value="generate_single"> - <input type="hidden" name="file_id" value="{{.ID}}"> - - <div style="display: flex; gap: 5px; align-items: center; margin-bottom: 10px;"> - <label style="font-size: 13px; white-space: nowrap;">Timestamp:</label> - <input type="text" name="timestamp" value="00:00:05" placeholder="00:00:05" - style="flex: 1; padding: 5px; font-size: 13px; font-family: monospace;"> - </div> - - <button type="submit" style="background-color: #007bff; color: white; padding: 6px 12px; border: none; border-radius: 3px; font-size: 13px; cursor: pointer; width: 100%;"> - Generate Thumbnail - </button> - </form> - </div> - {{end}} - </div> - {{else}} - <div style="padding: 20px; background-color: #d4edda; color: #155724; border: 1px solid #c3e6cb; border-radius: 4px;"> - <strong>✓ All videos have thumbnails!</strong> - </div> - {{end}} -</div> - -<!-- All Videos Tab --> -<div id="content-all" style="margin-bottom: 30px; display: none;"> - <h2>Regenerate Thumbnail</h2> - - <div style="margin-bottom: 20px; padding: 10px; background-color: #e7f3ff; border: 1px solid #b3d9ff; border-radius: 4px;"> - <strong>Tip:</strong> Enter a file ID to regenerate its thumbnail with a custom timestamp. You can find file IDs in the URL when viewing a file (e.g., /file/312). - </div> - - <form method="post" action="/thumbnails/generate" style="max-width: 500px; padding: 20px; background-color: #f8f9fa; border: 1px solid #ddd; border-radius: 5px;"> - <input type="hidden" name="action" value="generate_single"> - - <div style="margin-bottom: 20px;"> - <label for="file_id" style="display: block; font-weight: bold; margin-bottom: 5px;">File ID:</label> - <input type="text" id="file_id" name="file_id" required - style="width: 100%; padding: 8px; font-size: 14px; font-family: monospace;" - placeholder="e.g., 312"> - <small style="color: #666;">Enter the ID of the video file</small> - </div> - - <div style="margin-bottom: 20px;"> - <label for="timestamp" style="display: block; font-weight: bold; margin-bottom: 5px;">Timestamp:</label> - <input type="text" id="timestamp" name="timestamp" value="00:00:05" required - style="width: 100%; padding: 8px; font-size: 14px; font-family: monospace;" - placeholder="00:00:05"> - <small style="color: #666;">Format: HH:MM:SS (e.g., 00:00:05 for 5 seconds)</small> - </div> - - <button type="submit" style="background-color: #007bff; color: white; padding: 10px 20px; border: none; border-radius: 4px; font-size: 16px; cursor: pointer; width: 100%;"> - Generate/Regenerate Thumbnail - </button> - </form> - - {{if .Data.Success}} - <div class="auto-hide-success" style="margin-top: 20px; padding: 15px; background-color: #d4edda; color: #155724; border: 1px solid #c3e6cb; border-radius: 4px;"> - <strong>✓ Success!</strong> {{.Data.Success}} - </div> - {{end}} -</div> - -<script src="/static/thumbnails.js" defer></script> - -{{template "_footer"}} -\ No newline at end of file