taggart

Simple golang tagging filesystem webapp
Log | Files | Refs

commit 8eece6d161f5c525c3f7340ce7e15da0d9f6ae99
parent 363e7c39b166cf9e9ad8aa13921948e4398534ee
Author: breadcat <breadcat@users.noreply.github.com>
Date:   Thu, 25 Sep 2025 21:11:58 +0100

Replace inline javascript and CSS with external files

Also make description [timestamps] clickable

Diffstat:
Astatic/bulk-tag.js | 61+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Astatic/copy-link.js | 31+++++++++++++++++++++++++++++++
Astatic/description.js | 44++++++++++++++++++++++++++++++++++++++++++++
Astatic/style.css | 32++++++++++++++++++++++++++++++++
Astatic/timestamps.js | 37+++++++++++++++++++++++++++++++++++++
Astatic/toggle-help.js | 8++++++++
Mtemplates/_header.html | 45++-------------------------------------------
Mtemplates/bulk-tag.html | 61+------------------------------------------------------------
Mtemplates/file.html | 89++++++++-----------------------------------------------------------------------
9 files changed, 224 insertions(+), 184 deletions(-)

diff --git a/static/bulk-tag.js b/static/bulk-tag.js @@ -0,0 +1,60 @@ +// Auto-focus the file range input +document.getElementById('file_range').focus(); + +// Update form behavior based on operation selection +function updateValueField() { + const operation = document.querySelector('input[name="operation"]:checked').value; + const valueField = document.getElementById('value'); + const valueLabel = document.querySelector('label[for="value"]'); + + if (operation === 'add') { + valueField.required = true; + valueLabel.innerHTML = 'Value: <span style="color: red;">*</span>'; + } else { + valueField.required = false; + valueLabel.innerHTML = 'Value:'; + } +} + +// Set up event listeners for radio buttons +document.querySelectorAll('input[name="operation"]').forEach(radio => { + radio.addEventListener('change', updateValueField); +}); + +// Initialize on page load +updateValueField(); + +// Add form validation +document.querySelector('form').addEventListener('submit', function(e) { + const fileRange = document.getElementById('file_range').value.trim(); + const category = document.getElementById('category').value.trim(); + const value = document.getElementById('value').value.trim(); + const operation = document.querySelector('input[name="operation"]:checked').value; + + if (!fileRange) { + alert('Please enter a file ID range'); + e.preventDefault(); + return; + } + + if (!category) { + alert('Please enter a category'); + e.preventDefault(); + return; + } + + // Only require value for add operations + if (operation === 'add' && !value) { + alert('Please enter a tag value when adding tags'); + e.preventDefault(); + return; + } + + // Basic validation of range format + const rangePattern = /^[\d\s,-]+$/; + if (!rangePattern.test(fileRange)) { + alert('File range should only contain numbers, commas, dashes, and spaces'); + e.preventDefault(); + return; + } +}); +\ No newline at end of file diff --git a/static/copy-link.js b/static/copy-link.js @@ -0,0 +1,30 @@ +document.getElementById("copy-btn").addEventListener("click", function() { + const text = document.getElementById("raw-url").textContent.trim(); + const status = document.getElementById("copy-status"); + + // Fallback approach using a temporary textarea + const textarea = document.createElement("textarea"); + textarea.value = text; + textarea.style.position = "fixed"; // prevent scrolling + textarea.style.left = "-9999px"; // off-screen + document.body.appendChild(textarea); + textarea.focus(); + textarea.select(); + + let successful = false; + try { + successful = document.execCommand("copy"); + } catch (err) { + successful = false; + } + + document.body.removeChild(textarea); + + if (successful) { + status.textContent = "✓ Copied!"; + status.style.color = "green"; + } else { + status.textContent = "✗ Copy failed"; + status.style.color = "red"; + } +}); +\ No newline at end of file diff --git a/static/description.js b/static/description.js @@ -0,0 +1,43 @@ +function toggleDescriptionEdit() { + const displayDiv = document.getElementById('description-display'); + const editDiv = document.getElementById('description-edit'); + + displayDiv.style.display = 'none'; + editDiv.style.display = 'block'; + + // Focus the textarea and update character count + const textarea = document.getElementById('description-textarea'); + textarea.focus(); + + // Move cursor to end of text if there's existing content + if (textarea.value) { + textarea.setSelectionRange(textarea.value.length, textarea.value.length); + } +} + +function cancelDescriptionEdit() { + const displayDiv = document.getElementById('description-display'); + const editDiv = document.getElementById('description-edit'); + const textarea = document.getElementById('description-textarea'); + + // Reset textarea to original value + const original = displayDiv.dataset.originalDescription || ''; + textarea.value = original; + + displayDiv.style.display = 'block'; + editDiv.style.display = 'none'; +} + +// Auto-resize textarea as content changes +document.addEventListener('DOMContentLoaded', function() { + const textarea = document.getElementById('description-textarea'); + if (textarea) { + textarea.addEventListener('input', function() { + // Reset height to auto to get the correct scrollHeight + this.style.height = 'auto'; + // Set the height to match the content, with a minimum of 6 rows + const minHeight = parseInt(getComputedStyle(this).lineHeight) * 6; + this.style.height = Math.max(minHeight, this.scrollHeight) + 'px'; + }); + } +}); +\ No newline at end of file diff --git a/static/style.css b/static/style.css @@ -0,0 +1,31 @@ +/* main body styling */ +body, div#search-container form input {background: #1a1a1a; color: #cfcfcf; font-family: sans-serif; margin: 0} +a {color: lightblue; text-decoration: none} +a:hover {text-decoration: underline} + +/* nav menu */ +nav {display: flex; border-bottom: 1px solid gray; height: 50px} +nav ul {flex: 1 1 auto; list-style: none; margin: 0; padding: 0} +nav ul li {display: inline-block; margin: 0; padding: 0; border-right: 1px solid gray} +nav ul li a, nav ul li strong {padding: 15px; display: block} + +/* search bar */ +div#search-container form input {border: 1px solid gray; padding: 8px; margin-top: 7px} +span#searchToggle {cursor: pointer; color: lightblue; padding: 8px} +div#searchToggleContainer {display: none; background-color: #2a2a2a} + +/* gallery styling */ +div.gallery {} +div.gallery-item {display: inline-block; width: 250px} +div.gallery-item a {display: inline-block; overflow: hidden; text-overflow:ellipsis; width: 250px} + +/* cascading menu */ +ul.tag-menu,ul.tag-menu ul{list-style:none;margin:0;padding:0} +ul.tag-menu li{position:relative} +ul.tag-menu>li{display:inline-block;margin-right:20px} +ul.tag-menu li a{text-decoration:none;padding:5px 10px;display:block;background:#eee;color:#333} +ul.tag-menu li ul{display:none;position:absolute;top:100%;left:0;min-width:150px;z-index:1000} +ul.tag-menu li ul li,ul.tag-menu li:hover>ul{display:block} +ul.tag-menu li ul li ul{left:100%;top:0} +ul.tag-menu li ul li a{background:#f9f9f9} +ul.tag-menu li ul li a:hover{background:#ddd} +\ No newline at end of file diff --git a/static/timestamps.js b/static/timestamps.js @@ -0,0 +1,36 @@ +function parseTimestamp(ts) { + // Split by ":" and reverse to handle h:m:s flexibly + const parts = ts.split(":").map(Number).reverse(); + let seconds = 0; + if (parts[0]) seconds += parts[0]; // seconds + if (parts[1]) seconds += parts[1] * 60; // minutes + if (parts[2]) seconds += parts[2] * 3600; // hours + return seconds; +} + +function makeTimestampsClickable(containerId, videoId) { + const container = document.getElementById(containerId); + const video = document.getElementById(videoId); + + // Regex: [h:mm:ss] or [mm:ss] or [ss] + const regex = /\[(\d{1,2}(?::\d{2}){0,2})\]/g; + + container.innerHTML = container.innerHTML.replace(regex, (match, ts) => { + const seconds = parseTimestamp(ts); + return `<a href="#" class="timestamp" data-time="${seconds}">${match}</a>`; + }); + + container.addEventListener("click", e => { + if (e.target.classList.contains("timestamp")) { + e.preventDefault(); + const time = Number(e.target.dataset.time); + video.currentTime = time; + video.play(); + } + }); +} + +// Run it +document.addEventListener("DOMContentLoaded", () => { + makeTimestampsClickable("current-description", "videoPlayer"); +}); +\ No newline at end of file diff --git a/static/toggle-help.js b/static/toggle-help.js @@ -0,0 +1,7 @@ +const button = document.getElementById("searchToggle"); +const div = document.getElementById("searchToggleContainer"); +button.addEventListener("click", () => { + div.style.display = (div.style.display === "none" || div.style.display === "") + ? "block" + : "none"; +}); +\ No newline at end of file diff --git a/templates/_header.html b/templates/_header.html @@ -4,39 +4,7 @@ <head> <meta charset="utf-8"> <title>{{if .Title}}{{.Title}} - Taggart{{else}}Taggart{{end}}</title> - <style> -/* main body styling */ -body, div#search-container form input {background: #1a1a1a; color: #cfcfcf; font-family: sans-serif; margin: 0} -a {color: lightblue; text-decoration: none} -a:hover {text-decoration: underline} - -/* nav menu */ -nav {display: flex; border-bottom: 1px solid gray; height: 50px} -nav ul {flex: 1 1 auto; list-style: none; margin: 0; padding: 0} -nav ul li {display: inline-block; margin: 0; padding: 0; border-right: 1px solid gray} -nav ul li a, nav ul li strong {padding: 15px; display: block} - -/* search bar */ -div#search-container form input {border: 1px solid gray; padding: 8px; margin-top: 7px} -span#searchToggle {cursor: pointer; color: lightblue; padding: 8px} -div#searchToggleContainer {display: none; background-color: #2a2a2a} - -/* gallery styling */ -div.gallery {} -div.gallery-item {display: inline-block; width: 250px} -div.gallery-item a {display: inline-block; overflow: hidden; text-overflow:ellipsis; width: 250px} - -/* cascading menu */ -ul.tag-menu,ul.tag-menu ul{list-style:none;margin:0;padding:0} -ul.tag-menu li{position:relative} -ul.tag-menu>li{display:inline-block;margin-right:20px} -ul.tag-menu li a{text-decoration:none;padding:5px 10px;display:block;background:#eee;color:#333} -ul.tag-menu li ul{display:none;position:absolute;top:100%;left:0;min-width:150px;z-index:1000} -ul.tag-menu li ul li,ul.tag-menu li:hover>ul{display:block} -ul.tag-menu li ul li ul{left:100%;top:0} -ul.tag-menu li ul li a{background:#f9f9f9} -ul.tag-menu li ul li a:hover{background:#ddd} - </style> + <link href="/static/style.css" rel="stylesheet"> </head> <body> <nav> @@ -65,14 +33,5 @@ ul.tag-menu li ul li a:hover{background:#ddd} <li><code>*vacation*</code> - finds files containing "vacation" anywhere</li> </ul> </div> - <script> - const button = document.getElementById("searchToggle"); - const div = document.getElementById("searchToggleContainer"); - button.addEventListener("click", () => { - div.style.display = (div.style.display === "none" || div.style.display === "") - ? "block" - : "none"; - }); - </script> - + <script src="/static/toggle-help.js" defer></script> {{end}} \ No newline at end of file diff --git a/templates/bulk-tag.html b/templates/bulk-tag.html @@ -93,66 +93,7 @@ </div> </div> - <script> - // Auto-focus the file range input - document.getElementById('file_range').focus(); - // Update form behavior based on operation selection - function updateValueField() { - const operation = document.querySelector('input[name="operation"]:checked').value; - const valueField = document.getElementById('value'); - const valueLabel = document.querySelector('label[for="value"]'); - if (operation === 'add') { - valueField.required = true; - valueLabel.innerHTML = 'Value: <span style="color: red;">*</span>'; - } else { - valueField.required = false; - valueLabel.innerHTML = 'Value:'; - } - } - - // Set up event listeners for radio buttons - document.querySelectorAll('input[name="operation"]').forEach(radio => { - radio.addEventListener('change', updateValueField); - }); - - // Initialize on page load - updateValueField(); - - // Add form validation - document.querySelector('form').addEventListener('submit', function(e) { - const fileRange = document.getElementById('file_range').value.trim(); - const category = document.getElementById('category').value.trim(); - const value = document.getElementById('value').value.trim(); - const operation = document.querySelector('input[name="operation"]:checked').value; - - if (!fileRange) { - alert('Please enter a file ID range'); - e.preventDefault(); - return; - } - - if (!category) { - alert('Please enter a category'); - e.preventDefault(); - return; - } - - // Only require value for add operations - if (operation === 'add' && !value) { - alert('Please enter a tag value when adding tags'); - e.preventDefault(); - return; - } - - // Basic validation of range format - const rangePattern = /^[\d\s,-]+$/; - if (!rangePattern.test(fileRange)) { - alert('File range should only contain numbers, commas, dashes, and spaces'); - e.preventDefault(); - return; - } - }); - </script> + <script src="/static/bulk-tag.js" defer></script> {{template "_footer"}} diff --git a/templates/file.html b/templates/file.html @@ -4,22 +4,24 @@ {{if hasAnySuffix .Data.File.Filename ".jpg" ".jpeg" ".png" ".gif" ".webp"}} <a href="/uploads/{{.Data.EscapedFilename}}" target="_blank"><img src="/uploads/{{.Data.EscapedFilename}}" style="max-width:400px"></a><br> {{else if hasAnySuffix .Data.File.Filename ".mp4" ".webm" ".mov"}} - <video controls width="400" muted> + <video id="videoPlayer" controls width="600" muted> <source src="/uploads/{{.Data.EscapedFilename}}"> </video><br> {{else}} <a href="/uploads/{{.Data.EscapedFilename}}">Download file</a><br> {{end}} +<script src="/static/timestamps.js" defer></script> + <div class="description-section" style="margin: 20px 0; padding: 15px;"> <h3 style="margin-top: 0;">Description</h3> <!-- Display Mode --> - <div id="description-display"> + <div id="description-display" data-original-description="{{.Data.File.Description}}"> {{if .Data.File.Description}} - <div class="current-description" style="background: #f9f9f9; padding: 10px; border-radius: 3px; margin-bottom: 10px; white-space: pre-wrap; min-height: 20px;">{{.Data.File.Description}}</div> + <div id="current-description" style="background: #f9f9f9; padding: 10px; border-radius: 3px; margin-bottom: 10px; white-space: pre-wrap; min-height: 20px;">{{.Data.File.Description}}</div> {{else}} - <div class="no-description" style="color: #666; font-style: italic; margin-bottom: 10px; padding: 10px;">No description set</div> + <div id="no-description" style="color: #666; font-style: italic; margin-bottom: 10px; padding: 10px;">No description set</div> {{end}} <button id="edit-description-btn" onclick="toggleDescriptionEdit()" style="background: #007cba; color: white; padding: 6px 12px; border: none; border-radius: 3px; cursor: pointer; font-size: 14px;"> {{if .Data.File.Description}}Edit Description{{else}}Add Description{{end}} @@ -48,88 +50,13 @@ </div> </div> -<script> -function toggleDescriptionEdit() { - const displayDiv = document.getElementById('description-display'); - const editDiv = document.getElementById('description-edit'); - - displayDiv.style.display = 'none'; - editDiv.style.display = 'block'; - - // Focus the textarea and update character count - const textarea = document.getElementById('description-textarea'); - textarea.focus(); - - // Move cursor to end of text if there's existing content - if (textarea.value) { - textarea.setSelectionRange(textarea.value.length, textarea.value.length); - } -} - -function cancelDescriptionEdit() { - const displayDiv = document.getElementById('description-display'); - const editDiv = document.getElementById('description-edit'); - const textarea = document.getElementById('description-textarea'); - - // Reset textarea to original value - textarea.value = {{if .Data.File.Description}}`{{.Data.File.Description}}`{{else}}``{{end}}; - - displayDiv.style.display = 'block'; - editDiv.style.display = 'none'; -} - -// Auto-resize textarea as content changes -document.addEventListener('DOMContentLoaded', function() { - const textarea = document.getElementById('description-textarea'); - if (textarea) { - textarea.addEventListener('input', function() { - // Reset height to auto to get the correct scrollHeight - this.style.height = 'auto'; - // Set the height to match the content, with a minimum of 6 rows - const minHeight = parseInt(getComputedStyle(this).lineHeight) * 6; - this.style.height = Math.max(minHeight, this.scrollHeight) + 'px'; - }); - } -}); -</script> +<script src="/static/description.js" defer></script> <h3>Raw URL</h3> <pre id="raw-url">http://{{.IP}}:{{.Port}}/uploads/{{.Data.EscapedFilename}}</pre> <button id="copy-btn" style="margin-top: 5px;">Copy to Clipboard</button> <span id="copy-status" style="margin-left: 10px;"></span> - -<script> -document.getElementById("copy-btn").addEventListener("click", function() { - const text = document.getElementById("raw-url").textContent.trim(); - const status = document.getElementById("copy-status"); - - // Fallback approach using a temporary textarea - const textarea = document.createElement("textarea"); - textarea.value = text; - textarea.style.position = "fixed"; // prevent scrolling - textarea.style.left = "-9999px"; // off-screen - document.body.appendChild(textarea); - textarea.focus(); - textarea.select(); - - let successful = false; - try { - successful = document.execCommand("copy"); - } catch (err) { - successful = false; - } - - document.body.removeChild(textarea); - - if (successful) { - status.textContent = "✓ Copied!"; - status.style.color = "green"; - } else { - status.textContent = "✗ Copy failed"; - status.style.color = "red"; - } -}); -</script> +<script src="/static/copy-link.js" defer></script>