From 1ed56ae599aa140cec6a8cf46fe3ea9a1191c383 Mon Sep 17 00:00:00 2001 From: Petteri Ponsimaa Date: Fri, 15 Nov 2024 18:17:06 +0200 Subject: [PATCH] enh: add sticky headers, fix links --- assets/custom.css | 266 ++++++++++++++---- assets/tailwind.css | 102 ++++--- assets/util.js | 19 +- internal/conf/utils.go | 11 + internal/datastore/interfaces.go | 42 ++- internal/httpcontroller/auth_routes.go | 10 +- .../httpcontroller/handlers/detections.go | 9 +- internal/httpcontroller/routes.go | 6 +- internal/httpcontroller/template_functions.go | 111 +++++--- views/dashboard.html | 6 +- views/fragments/birdsTable.html | 78 +++-- views/fragments/listDetections.html | 12 +- views/index.html | 30 +- 13 files changed, 456 insertions(+), 246 deletions(-) diff --git a/assets/custom.css b/assets/custom.css index 8af9a778..403d724a 100644 --- a/assets/custom.css +++ b/assets/custom.css @@ -1,49 +1,40 @@ .audio-control { - width: 100%; - height: 25px; + width: 100%; + height: 25px; } .confidence-ball { - width: 54px; - height: 25px; - display: flex; - align-items: center; - justify-content: center; - border-radius: 5px; - font-size: 0.75rem; + width: 54px; + height: 25px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 5px; + font-size: 0.75rem; } @media (max-width: 1024px) { - .confidence-ball { - width: 40px; - height: 20px; - font-size: 0.65rem; - margin: auto; - } + .confidence-ball { + width: 40px; + height: 20px; + font-size: 0.65rem; + } } input.invalid { border-color: #dc2626; } -/*@media (max-width: 768px) { - .confidence-ball { - width: 32px; - height: 18px; - font-size: 0.6rem; - } -}*/ - .species-ball { - min-width: 1rem; - height: 1.25rem; - display: inline-flex; - padding: 0.2rem 0.25rem; - align-items: center; - justify-content: center; - border-radius: 1rem; - font-size: 0.75rem; - line-height: 1; + min-width: 1rem; + height: 1.25rem; + display: inline-flex; + padding: 0.2rem 0.25rem; + align-items: center; + justify-content: center; + border-radius: 1rem; + font-size: 0.75rem; + line-height: 1; } @media (max-width: 1024px) { @@ -52,20 +43,88 @@ input.invalid { } } -.hour-header, .hour-data { display: table-cell; } -.hourly-count { display: table-cell; } -.bi-hourly-count, .six-hourly-count { display: none; } +/* Sticky header for the recent detections table */ +thead.sticky-header { + position: sticky; + top: 0; + z-index: 10; + background-image: linear-gradient(to bottom, white 70%, transparent 100%); +} + +[data-theme=dark] thead.sticky-header { + background-image: linear-gradient(to bottom, #1d232a 50%, transparent 100%); +} + +.hour-header, +.hour-data { + display: table-cell; +} + +.hourly-count { + display: table-cell; +} + +.bi-hourly-count, +.six-hourly-count { + display: none; +} + +/* Add borders to hour data cells for light theme */ +[data-theme=light] .hour-data:not(.heatmap-color-0) { + position: relative; + z-index: 1; + padding: 0; + border: 1px solid rgba(255, 255, 255, 0.1); + background-clip: padding-box; + border-collapse: collapse; +} + +.hour-data a { + height: 100%; + width: 100%; + display: flex; + align-items: center; + justify-content: center; +} + +.table :where(thead tr, tbody tr:not(:last-child), tbody tr:first-child:last-child) { + border-bottom-width: 0; +} + +.table :where(thead td, thead th) { + border-bottom: 1px solid var(--fallback-b2, oklch(var(--b2)/var(--tw-border-opacity))); +} @media (max-width: 767px) { - .hour-header:not(.bi-hourly), .hour-data:not(.bi-hourly) { display: none; } - .hourly-count { display: none; } - .bi-hourly-count { display: table-cell; } + + .hour-header:not(.bi-hourly), + .hour-data:not(.bi-hourly) { + display: none; + } + + .hourly-count { + display: none; + } + + .bi-hourly-count { + display: table-cell; + } } @media (max-width: 479px) { - .hour-header:not(.six-hourly), .hour-data:not(.six-hourly) { display: none; } - .bi-hourly-count { display: none; } - .six-hourly-count { display: table-cell; } + + .hour-header:not(.six-hourly), + .hour-data:not(.six-hourly) { + display: none; + } + + .bi-hourly-count { + display: none; + } + + .six-hourly-count { + display: table-cell; + } } .text-2xs { @@ -94,9 +153,9 @@ input.invalid { } .input:focus-visible { - outline: 1px solid transparent; - outline-offset: 0px; - box-shadow: 0 0 0 2px rgba(164, 202, 254, 0.45); + outline: 1px solid transparent; + outline-offset: 0px; + box-shadow: 0 0 0 2px rgba(164, 202, 254, 0.45); } .select:focus-visible { @@ -129,22 +188,23 @@ input.invalid { display: block; } - /* Define your custom background colors here if the default Tailwind classes aren't working */ - .bg-confidence-high { background-color: #10b981; } /* Green for high confidence */ - .bg-confidence-medium { background-color: #f97316; } /* Orange for average confidence */ - .bg-confidence-low { background-color: #ef4444; } /* Red for low confidence */ +/* Define your custom background colors here if the default Tailwind classes aren't working */ +.bg-confidence-high { background-color: #10b981; } /* Green for high confidence */ +.bg-confidence-medium { background-color: #f97316; } /* Orange for average confidence */ +.bg-confidence-low { background-color: #ef4444; } /* Red for low confidence */ .progress { - min-height: 14px; /* Adjust this value as needed */ + min-height: 14px; /* Adjust this value as needed */ } .heatmap-cell { - text-align: center; - font-weight: bold; + text-align: center; + font-weight: bold; } /* Light theme (default) */ :root { + --heatmap-color-0: #f0f9fc; --heatmap-color-1: #e0f3f8; --heatmap-color-2: #ccebf6; --heatmap-color-3: #99d7ed; @@ -158,6 +218,7 @@ input.invalid { /* Dark theme */ [data-theme=dark] { + --heatmap-color-0: #001a20; --heatmap-color-1: #002933; --heatmap-color-2: #004466; --heatmap-color-3: #005c80; @@ -169,17 +230,6 @@ input.invalid { --heatmap-color-9: #cce3f1; } -/* Heatmap cell styles */ -.heatmap-color-1 { background-color: var(--heatmap-color-1); color: var(--heatmap-text-1, #000); } -.heatmap-color-2 { background-color: var(--heatmap-color-2); color: var(--heatmap-text-2, #000); } -.heatmap-color-3 { background-color: var(--heatmap-color-3); color: var(--heatmap-text-3, #000); } -.heatmap-color-4 { background-color: var(--heatmap-color-4); color: var(--heatmap-text-4, #000); } -.heatmap-color-5 { background-color: var(--heatmap-color-5); color: var(--heatmap-text-5, #fff); } -.heatmap-color-6 { background-color: var(--heatmap-color-6); color: var(--heatmap-text-6, #fff); } -.heatmap-color-7 { background-color: var(--heatmap-color-7); color: var(--heatmap-text-7, #fff); } -.heatmap-color-8 { background-color: var(--heatmap-color-8); color: var(--heatmap-text-8, #fff); } -.heatmap-color-9 { background-color: var(--heatmap-color-9); color: var(--heatmap-text-9, #fff); } - /* Text color adjustments for dark theme */ [data-theme=dark] { --heatmap-text-1: #fff; @@ -192,3 +242,95 @@ input.invalid { --heatmap-text-8: #000; --heatmap-text-9: #000; } + +/* Heatmap cell styles for light theme */ +[data-theme=light] .heatmap-color-1 { + background: linear-gradient(-45deg, var(--heatmap-color-1) 45%, var(--heatmap-color-0) 95%); + color: var(--heatmap-text-1, #000); +} + +[data-theme=light] .heatmap-color-2 { + background: linear-gradient(-45deg, var(--heatmap-color-2) 45%, var(--heatmap-color-1) 95%); + color: var(--heatmap-text-2, #000); +} + +[data-theme=light] .heatmap-color-3 { + background: linear-gradient(-45deg, var(--heatmap-color-3) 45%, var(--heatmap-color-2) 95%); + color: var(--heatmap-text-3, #000); +} + +[data-theme=light] .heatmap-color-4 { + background: linear-gradient(-45deg, var(--heatmap-color-4) 45%, var(--heatmap-color-3) 95%); + color: var(--heatmap-text-4, #000); +} + +[data-theme=light] .heatmap-color-5 { + background: linear-gradient(-45deg, var(--heatmap-color-5) 45%, var(--heatmap-color-4) 95%); + color: var(--heatmap-text-5, #fff); +} + +[data-theme=light] .heatmap-color-6 { + background: linear-gradient(-45deg, var(--heatmap-color-6) 45%, var(--heatmap-color-5) 95%); + color: var(--heatmap-text-6, #fff); +} + +[data-theme=light] .heatmap-color-7 { + background: linear-gradient(-45deg, var(--heatmap-color-7) 45%, var(--heatmap-color-6) 95%); + color: var(--heatmap-text-7, #fff); +} + +[data-theme=light] .heatmap-color-8 { + background: linear-gradient(-45deg, var(--heatmap-color-8) 45%, var(--heatmap-color-7) 95%); + color: var(--heatmap-text-8, #fff); +} + +[data-theme=light] .heatmap-color-9 { + background: linear-gradient(-45deg, var(--heatmap-color-9) 45%, var(--heatmap-color-8) 95%); + color: var(--heatmap-text-9, #fff); +} + +/* Heatmap cell styles for dark theme */ +[data-theme=dark] .heatmap-color-1 { + background: linear-gradient(135deg, var(--heatmap-color-1) 45%, var(--heatmap-color-0) 95%); + color: var(--heatmap-text-1, #000); +} + +[data-theme=dark] .heatmap-color-2 { + background: linear-gradient(135deg, var(--heatmap-color-2) 45%, var(--heatmap-color-1) 95%); + color: var(--heatmap-text-2, #000); +} + +[data-theme=dark] .heatmap-color-3 { + background: linear-gradient(135deg, var(--heatmap-color-3) 45%, var(--heatmap-color-2) 95%); + color: var(--heatmap-text-3, #000); +} + +[data-theme=dark] .heatmap-color-4 { + background: linear-gradient(135deg, var(--heatmap-color-4) 66%, var(--heatmap-color-3) 110%); + color: var(--heatmap-text-4, #000); +} + +[data-theme=dark] .heatmap-color-5 { + background: linear-gradient(135deg, var(--heatmap-color-5) 66%, var(--heatmap-color-4) 110%); + color: var(--heatmap-text-5, #fff); +} + +[data-theme=dark] .heatmap-color-6 { + background: linear-gradient(135deg, var(--heatmap-color-6) 66%, var(--heatmap-color-5) 110%); + color: var(--heatmap-text-6, #fff); +} + +[data-theme=dark] .heatmap-color-7 { + background: linear-gradient(135deg, var(--heatmap-color-7) 66%, var(--heatmap-color-6) 110%); + color: var(--heatmap-text-7, #fff); +} + +[data-theme=dark] .heatmap-color-8 { + background: linear-gradient(135deg, var(--heatmap-color-8) 66%, var(--heatmap-color-7) 110%); + color: var(--heatmap-text-8, #fff); +} + +[data-theme=dark] .heatmap-color-9 { + background: linear-gradient(135deg, var(--heatmap-color-9) 66%, var(--heatmap-color-8) 110%); + color: var(--heatmap-text-9, #fff); +} \ No newline at end of file diff --git a/assets/tailwind.css b/assets/tailwind.css index b2e53330..6a5e6803 100644 --- a/assets/tailwind.css +++ b/assets/tailwind.css @@ -107,7 +107,7 @@ } /* -! tailwindcss v3.4.13 | MIT License | https://tailwindcss.com +! tailwindcss v3.4.15 | MIT License | https://tailwindcss.com */ /* @@ -550,7 +550,7 @@ video { /* Make elements with the HTML hidden attribute stay hidden by default */ -[hidden] { +[hidden]:where(:not([hidden="until-found"])) { display: none; } @@ -1411,24 +1411,24 @@ html { transform: translateX(0%); } -.drawer-end .drawer-toggle ~ .drawer-content { +.drawer-end > .drawer-toggle ~ .drawer-content { grid-column-start: 1; } -.drawer-end .drawer-toggle ~ .drawer-side { +.drawer-end > .drawer-toggle ~ .drawer-side { grid-column-start: 2; justify-items: end; } -.drawer-end .drawer-toggle ~ .drawer-side > *:not(.drawer-overlay) { +.drawer-end > .drawer-toggle ~ .drawer-side > *:not(.drawer-overlay) { transform: translateX(100%); } -[dir="rtl"] .drawer-end .drawer-toggle ~ .drawer-side > *:not(.drawer-overlay) { +[dir="rtl"] .drawer-end > .drawer-toggle ~ .drawer-side > *:not(.drawer-overlay) { transform: translateX(-100%); } -.drawer-end .drawer-toggle:checked ~ .drawer-side > *:not(.drawer-overlay) { +.drawer-end > .drawer-toggle:checked ~ .drawer-side > *:not(.drawer-overlay) { transform: translateX(0%); } @@ -4195,6 +4195,10 @@ html:has(.drawer-toggle:checked) { height: 2.5rem; } +.h-11 { + height: 2.75rem; +} + .h-12 { height: 3rem; } @@ -4501,92 +4505,92 @@ html:has(.drawer-toggle:checked) { .border-base-200 { --tw-border-opacity: 1; - border-color: var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity))); + border-color: var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity, 1))); } .border-red-500 { --tw-border-opacity: 1; - border-color: rgb(239 68 68 / var(--tw-border-opacity)); + border-color: rgb(239 68 68 / var(--tw-border-opacity, 1)); } .bg-base-100 { --tw-bg-opacity: 1; - background-color: var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity))); + background-color: var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity, 1))); } .bg-base-200 { --tw-bg-opacity: 1; - background-color: var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity))); + background-color: var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity, 1))); } .bg-base-300 { --tw-bg-opacity: 1; - background-color: var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity))); + background-color: var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity, 1))); } .bg-black { --tw-bg-opacity: 1; - background-color: rgb(0 0 0 / var(--tw-bg-opacity)); + background-color: rgb(0 0 0 / var(--tw-bg-opacity, 1)); } .bg-blue-500 { --tw-bg-opacity: 1; - background-color: rgb(59 130 246 / var(--tw-bg-opacity)); + background-color: rgb(59 130 246 / var(--tw-bg-opacity, 1)); } .bg-blue-600 { --tw-bg-opacity: 1; - background-color: rgb(37 99 235 / var(--tw-bg-opacity)); + background-color: rgb(37 99 235 / var(--tw-bg-opacity, 1)); } .bg-gray-100 { --tw-bg-opacity: 1; - background-color: rgb(243 244 246 / var(--tw-bg-opacity)); + background-color: rgb(243 244 246 / var(--tw-bg-opacity, 1)); } .bg-gray-200 { --tw-bg-opacity: 1; - background-color: rgb(229 231 235 / var(--tw-bg-opacity)); + background-color: rgb(229 231 235 / var(--tw-bg-opacity, 1)); } .bg-gray-400 { --tw-bg-opacity: 1; - background-color: rgb(156 163 175 / var(--tw-bg-opacity)); + background-color: rgb(156 163 175 / var(--tw-bg-opacity, 1)); } .bg-gray-50 { --tw-bg-opacity: 1; - background-color: rgb(249 250 251 / var(--tw-bg-opacity)); + background-color: rgb(249 250 251 / var(--tw-bg-opacity, 1)); } .bg-gray-900 { --tw-bg-opacity: 1; - background-color: rgb(17 24 39 / var(--tw-bg-opacity)); + background-color: rgb(17 24 39 / var(--tw-bg-opacity, 1)); } .bg-green-500 { --tw-bg-opacity: 1; - background-color: rgb(34 197 94 / var(--tw-bg-opacity)); + background-color: rgb(34 197 94 / var(--tw-bg-opacity, 1)); } .bg-orange-400 { --tw-bg-opacity: 1; - background-color: rgb(251 146 60 / var(--tw-bg-opacity)); + background-color: rgb(251 146 60 / var(--tw-bg-opacity, 1)); } .bg-red-100 { --tw-bg-opacity: 1; - background-color: rgb(254 226 226 / var(--tw-bg-opacity)); + background-color: rgb(254 226 226 / var(--tw-bg-opacity, 1)); } .bg-red-500 { --tw-bg-opacity: 1; - background-color: rgb(239 68 68 / var(--tw-bg-opacity)); + background-color: rgb(239 68 68 / var(--tw-bg-opacity, 1)); } .bg-slate-300 { --tw-bg-opacity: 1; - background-color: rgb(203 213 225 / var(--tw-bg-opacity)); + background-color: rgb(203 213 225 / var(--tw-bg-opacity, 1)); } .bg-opacity-25 { @@ -4679,6 +4683,10 @@ html:has(.drawer-toggle:checked) { padding-bottom: 0.5rem; } +.pb-0 { + padding-bottom: 0px; +} + .pb-2 { padding-bottom: 0.5rem; } @@ -4711,6 +4719,10 @@ html:has(.drawer-toggle:checked) { padding-top: 0px; } +.pt-0\.5 { + padding-top: 0.125rem; +} + .pt-4 { padding-top: 1rem; } @@ -4805,7 +4817,7 @@ html:has(.drawer-toggle:checked) { .text-base-content { --tw-text-opacity: 1; - color: var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity))); + color: var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity, 1))); } .text-base-content\/50 { @@ -4814,62 +4826,62 @@ html:has(.drawer-toggle:checked) { .text-black { --tw-text-opacity: 1; - color: rgb(0 0 0 / var(--tw-text-opacity)); + color: rgb(0 0 0 / var(--tw-text-opacity, 1)); } .text-gray-100 { --tw-text-opacity: 1; - color: rgb(243 244 246 / var(--tw-text-opacity)); + color: rgb(243 244 246 / var(--tw-text-opacity, 1)); } .text-gray-400 { --tw-text-opacity: 1; - color: rgb(156 163 175 / var(--tw-text-opacity)); + color: rgb(156 163 175 / var(--tw-text-opacity, 1)); } .text-gray-50 { --tw-text-opacity: 1; - color: rgb(249 250 251 / var(--tw-text-opacity)); + color: rgb(249 250 251 / var(--tw-text-opacity, 1)); } .text-gray-500 { --tw-text-opacity: 1; - color: rgb(107 114 128 / var(--tw-text-opacity)); + color: rgb(107 114 128 / var(--tw-text-opacity, 1)); } .text-gray-600 { --tw-text-opacity: 1; - color: rgb(75 85 99 / var(--tw-text-opacity)); + color: rgb(75 85 99 / var(--tw-text-opacity, 1)); } .text-gray-700 { --tw-text-opacity: 1; - color: rgb(55 65 81 / var(--tw-text-opacity)); + color: rgb(55 65 81 / var(--tw-text-opacity, 1)); } .text-gray-800 { --tw-text-opacity: 1; - color: rgb(31 41 55 / var(--tw-text-opacity)); + color: rgb(31 41 55 / var(--tw-text-opacity, 1)); } .text-primary { --tw-text-opacity: 1; - color: var(--fallback-p,oklch(var(--p)/var(--tw-text-opacity))); + color: var(--fallback-p,oklch(var(--p)/var(--tw-text-opacity, 1))); } .text-red-500 { --tw-text-opacity: 1; - color: rgb(239 68 68 / var(--tw-text-opacity)); + color: rgb(239 68 68 / var(--tw-text-opacity, 1)); } .text-red-700 { --tw-text-opacity: 1; - color: rgb(185 28 28 / var(--tw-text-opacity)); + color: rgb(185 28 28 / var(--tw-text-opacity, 1)); } .text-white { --tw-text-opacity: 1; - color: rgb(255 255 255 / var(--tw-text-opacity)); + color: rgb(255 255 255 / var(--tw-text-opacity, 1)); } .opacity-0 { @@ -5107,17 +5119,17 @@ html { .hover\:bg-blue-600:hover { --tw-bg-opacity: 1; - background-color: rgb(37 99 235 / var(--tw-bg-opacity)); + background-color: rgb(37 99 235 / var(--tw-bg-opacity, 1)); } .hover\:bg-green-600:hover { --tw-bg-opacity: 1; - background-color: rgb(22 163 74 / var(--tw-bg-opacity)); + background-color: rgb(22 163 74 / var(--tw-bg-opacity, 1)); } .hover\:bg-white:hover { --tw-bg-opacity: 1; - background-color: rgb(255 255 255 / var(--tw-bg-opacity)); + background-color: rgb(255 255 255 / var(--tw-bg-opacity, 1)); } .hover\:bg-opacity-20:hover { @@ -5126,7 +5138,7 @@ html { .hover\:text-blue-200:hover { --tw-text-opacity: 1; - color: rgb(191 219 254 / var(--tw-text-opacity)); + color: rgb(191 219 254 / var(--tw-text-opacity, 1)); } .hover\:underline:hover { @@ -5453,12 +5465,12 @@ html { @media (prefers-color-scheme: dark) { .dark\:bg-base-300 { --tw-bg-opacity: 1; - background-color: var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity))); + background-color: var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity, 1))); } .dark\:bg-gray-400 { --tw-bg-opacity: 1; - background-color: rgb(156 163 175 / var(--tw-bg-opacity)); + background-color: rgb(156 163 175 / var(--tw-bg-opacity, 1)); } .dark\:stroke-gray-200 { @@ -5467,6 +5479,6 @@ html { .dark\:text-base-300 { --tw-text-opacity: 1; - color: var(--fallback-b3,oklch(var(--b3)/var(--tw-text-opacity))); + color: var(--fallback-b3,oklch(var(--b3)/var(--tw-text-opacity, 1))); } } diff --git a/assets/util.js b/assets/util.js index f5708b78..badf3938 100644 --- a/assets/util.js +++ b/assets/util.js @@ -1,5 +1,5 @@ function logout() { - window.location.href = `/logout`; + window.location.href = `/logout`; } function moveDatePicker(days) { @@ -23,11 +23,16 @@ function isNotArrowKey(event) { return !['ArrowLeft', 'ArrowRight'].includes(event.key); } +function isLocationDashboard() { + const pathname = window.location.pathname; + return pathname === '/' || pathname.endsWith('/dashboard'); +} + htmx.on('htmx:afterSettle', function (event) { - if (event.detail.target.id.endsWith('-content')) { - // Find all chart containers in the newly loaded content and render them - event.detail.target.querySelectorAll('[id$="-chart"]').forEach(function (chartContainer) { - renderChart(chartContainer.id, chartContainer.dataset.chartOptions); - }); - } + if (event.detail.target.id.endsWith('-content')) { + // Find all chart containers in the newly loaded content and render them + event.detail.target.querySelectorAll('[id$="-chart"]').forEach(function (chartContainer) { + renderChart(chartContainer.id, chartContainer.dataset.chartOptions); + }); + } }); diff --git a/internal/conf/utils.go b/internal/conf/utils.go index 788d7c76..890e1681 100644 --- a/internal/conf/utils.go +++ b/internal/conf/utils.go @@ -393,3 +393,14 @@ func moveFile(src, dst string) error { return nil // Move completed successfully } + +// IsSafePath ensures the given path is internal +func IsSafePath(path string) bool { + return strings.HasPrefix(path, "/") && + !strings.Contains(path, "//") && + !strings.Contains(path, "\\") && + !strings.Contains(path, "://") && + !strings.Contains(path, "..") && + !strings.Contains(path, "\x00") && + len(path) < 512 +} diff --git a/internal/datastore/interfaces.go b/internal/datastore/interfaces.go index fb88f838..14a33c3b 100644 --- a/internal/datastore/interfaces.go +++ b/internal/datastore/interfaces.go @@ -24,7 +24,7 @@ type Interface interface { GetAllNotes() ([]Note, error) GetTopBirdsData(selectedDate string, minConfidenceNormalized float64) ([]Note, error) GetHourlyOccurrences(date, commonName string, minConfidenceNormalized float64) ([24]int, error) - SpeciesDetections(species, date, hour string, sortAscending bool, limit int, offset int) ([]Note, error) + SpeciesDetections(species, date, hour string, duration int, sortAscending bool, limit int, offset int) ([]Note, error) GetLastDetections(numDetections int) ([]Note, error) GetAllDetectedSpecies() ([]Note, error) SearchNotes(query string, sortAscending bool, limit int, offset int) ([]Note, error) @@ -37,8 +37,8 @@ type Interface interface { SaveHourlyWeather(hourlyWeather *HourlyWeather) error GetHourlyWeather(date string) ([]HourlyWeather, error) LatestHourlyWeather() (*HourlyWeather, error) - GetHourlyDetections(date, hour string) ([]Note, error) - CountSpeciesDetections(species, date, hour string) (int64, error) + GetHourlyDetections(date, hour string, duration int) ([]Note, error) + CountSpeciesDetections(species, date, hour string, duration int) (int64, error) CountSearchResults(query string) (int64, error) } @@ -284,17 +284,13 @@ func (ds *DataStore) GetHourlyOccurrences(date, commonName string, minConfidence } // SpeciesDetections retrieves bird species detections for a specific date and time period. -func (ds *DataStore) SpeciesDetections(species, date, hour string, sortAscending bool, limit int, offset int) ([]Note, error) { +func (ds *DataStore) SpeciesDetections(species, date, hour string, duration int, sortAscending bool, limit int, offset int) ([]Note, error) { sortOrder := sortAscendingString(sortAscending) query := ds.DB.Where("common_name = ? AND date = ?", species, date) if hour != "" { - if len(hour) < 2 { - hour = "0" + hour - } - startTime := hour + ":00" - endTime := hour + ":59" - query = query.Where("time >= ? AND time <= ?", startTime, endTime) + startTime, endTime := getHourRange(hour, duration) + query = query.Where("time >= ? AND time < ?", startTime, endTime) } query = query.Order("id " + sortOrder). @@ -448,13 +444,11 @@ func createGormLogger() logger.Interface { } // GetHourlyDetections retrieves bird detections for a specific date and hour. -func (ds *DataStore) GetHourlyDetections(date, hour string) ([]Note, error) { +func (ds *DataStore) GetHourlyDetections(date string, hour string, duration int) ([]Note, error) { var detections []Note - startTime := hour + ":00:00" - endTime := hour + ":59:59" - - err := ds.DB.Where("date = ? AND time >= ? AND time <= ?", date, startTime, endTime). + startTime, endTime := getHourRange(hour, duration) + err := ds.DB.Where("date = ? AND time >= ? AND time < ?", date, startTime, endTime). Order("time ASC"). Find(&detections).Error @@ -462,17 +456,13 @@ func (ds *DataStore) GetHourlyDetections(date, hour string) ([]Note, error) { } // CountSpeciesDetections counts the number of detections for a specific species, date, and hour. -func (ds *DataStore) CountSpeciesDetections(species, date, hour string) (int64, error) { +func (ds *DataStore) CountSpeciesDetections(species, date, hour string, duration int) (int64, error) { var count int64 query := ds.DB.Model(&Note{}).Where("common_name = ? AND date = ?", species, date) if hour != "" { - if len(hour) < 2 { - hour = "0" + hour - } - startTime := hour + ":00" - endTime := hour + ":59" - query = query.Where("time >= ? AND time <= ?", startTime, endTime) + startTime, endTime := getHourRange(hour, duration) + query = query.Where("time >= ? AND time < ?", startTime, endTime) } err := query.Count(&count).Error @@ -496,3 +486,11 @@ func (ds *DataStore) CountSearchResults(query string) (int64, error) { return count, nil } + +func getHourRange(hour string, duration int) (string, string) { + startHour, _ := strconv.Atoi(hour) + endHour := (startHour + duration) % 24 + startTime := fmt.Sprintf("%02d:00:00", startHour) + endTime := fmt.Sprintf("%02d:00:00", endHour) + return startTime, endTime +} diff --git a/internal/httpcontroller/auth_routes.go b/internal/httpcontroller/auth_routes.go index ba333f7d..fcedaa33 100644 --- a/internal/httpcontroller/auth_routes.go +++ b/internal/httpcontroller/auth_routes.go @@ -4,11 +4,11 @@ import ( "crypto/subtle" "fmt" "net/http" - "strings" "github.com/labstack/echo/v4" "github.com/labstack/echo/v4/middleware" "github.com/markbates/goth/gothic" + "github.com/tphakala/birdnet-go/internal/conf" ) // initAuthRoutes initializes all authentication related routes @@ -106,13 +106,7 @@ func (s *Server) handleLoginPage(c echo.Context) error { // isValidRedirect ensures the redirect path is safe and internal func isValidRedirect(redirectPath string) bool { - // Allow only relative paths - return strings.HasPrefix(redirectPath, "/") && - !strings.Contains(redirectPath, "//") && - !strings.Contains(redirectPath, "\\") && - !strings.Contains(redirectPath, "://") && - !strings.Contains(redirectPath, "..") && - len(redirectPath) < 512 + return conf.IsSafePath(redirectPath) } // handleBasicAuthLogin handles password login POST request diff --git a/internal/httpcontroller/handlers/detections.go b/internal/httpcontroller/handlers/detections.go index c3285f72..f563ae8c 100644 --- a/internal/httpcontroller/handlers/detections.go +++ b/internal/httpcontroller/handlers/detections.go @@ -18,6 +18,7 @@ import ( type DetectionRequest struct { Date string `query:"date"` Hour string `query:"hour"` + Duration int `query:"duration"` Species string `query:"species"` Search string `query:"search"` NumResults int `query:"numResults"` @@ -57,14 +58,14 @@ func (h *Handlers) Detections(c echo.Context) error { if req.Date == "" || req.Hour == "" { return h.NewHandlerError(fmt.Errorf("missing date or hour"), "Date and hour parameters are required for hourly detections", http.StatusBadRequest) } - notes, err = h.DS.GetHourlyDetections(req.Date, req.Hour) + notes, err = h.DS.GetHourlyDetections(req.Date, req.Hour, req.Duration) totalResults = int64(len(notes)) case "species": if req.Species == "" { return h.NewHandlerError(fmt.Errorf("missing species"), "Species parameter is required for species detections", http.StatusBadRequest) } - notes, err = h.DS.SpeciesDetections(req.Species, req.Date, req.Hour, false, req.NumResults, req.Offset) - totalResults, _ = h.DS.CountSpeciesDetections(req.Species, req.Date, req.Hour) + notes, err = h.DS.SpeciesDetections(req.Species, req.Date, req.Hour, req.Duration, false, req.NumResults, req.Offset) + totalResults, _ = h.DS.CountSpeciesDetections(req.Species, req.Date, req.Hour, req.Duration) case "search": if req.Search == "" { return h.NewHandlerError(fmt.Errorf("missing search query"), "Search query is required for search detections", http.StatusBadRequest) @@ -107,6 +108,7 @@ func (h *Handlers) Detections(c echo.Context) error { data := struct { Date string Hour string + Duration int Species string Search string Notes []NoteWithWeather @@ -124,6 +126,7 @@ func (h *Handlers) Detections(c echo.Context) error { }{ Date: req.Date, Hour: req.Hour, + Duration: req.Duration, Species: req.Species, Search: req.Search, Notes: notesWithWeather, diff --git a/internal/httpcontroller/routes.go b/internal/httpcontroller/routes.go index 428e8637..71279994 100644 --- a/internal/httpcontroller/routes.go +++ b/internal/httpcontroller/routes.go @@ -4,7 +4,6 @@ package httpcontroller import ( "embed" "fmt" - "html" "html/template" "io/fs" "net/http" @@ -170,11 +169,12 @@ func (s *Server) handlePageRequest(c echo.Context) error { }, } - if isFragment { + fragmentPath := c.Request().RequestURI + if isFragment && conf.IsSafePath(fragmentPath) { // If the route is for a fragment, render it with the dashboard template data.Page = "dashboard" data.Title = partialRoute.Title - data.PreloadFragment = html.EscapeString(c.Request().RequestURI) + data.PreloadFragment = fragmentPath } return c.Render(http.StatusOK, "index", data) diff --git a/internal/httpcontroller/template_functions.go b/internal/httpcontroller/template_functions.go index 2dd4fd5b..01f8bed0 100644 --- a/internal/httpcontroller/template_functions.go +++ b/internal/httpcontroller/template_functions.go @@ -8,6 +8,7 @@ import ( "html/template" "net/url" "path/filepath" + "strconv" "strings" "time" @@ -44,21 +45,30 @@ func (s *Server) GetTemplateFunctions() template.FuncMap { "urlsafe": urlSafe, "ffmpegAvailable": conf.IsFfmpegAvailable, "formatDateTime": formatDateTime, + "getHourlyHeaderData": getHourlyHeaderData, "getHourlyCounts": getHourlyCounts, "sumHourlyCountsRange": sumHourlyCountsRange, } } -/** - * addFunc calculates the sum of the input integers. - * - * @param numbers The integers to be summed up. - * @return The total sum of the input integers. - */ -func addFunc(numbers ...int) int { +// addFunc calculates the sum of the input integers. +// Parameters: +// - numbers: Variadic list of integers or strings representing integers +// +// Returns: +// +// The total sum of all input numbers +func addFunc(numbers ...interface{}) int { sum := 0 for _, num := range numbers { - sum += num + switch v := num.(type) { + case int: + sum += v + case string: + if i, err := strconv.Atoi(v); err == nil { + sum += i + } + } } return sum } @@ -66,13 +76,13 @@ func subFunc(a, b int) int { return a - b } func divFunc(a, b int) int { return a / b } func modFunc(a, b int) int { return a % b } -/** - * dictFunc creates a dictionary from key-value pairs provided as arguments. - * - * @param values A variadic parameter list of key-value pairs. Keys must be strings. - * @return A map[string]interface{} representing the dictionary created. - * An error if the number of arguments is odd or if keys are not strings. - */ +// dictFunc creates a dictionary from key-value pairs. +// Parameters: +// - values: Variadic list of alternating string keys and interface{} values +// +// Returns: +// - map[string]interface{}: Dictionary with provided key-value pairs +// - error: Invalid dict call or non-string keys func dictFunc(values ...interface{}) (map[string]interface{}, error) { if len(values)%2 != 0 { return nil, errors.New("invalid dict call") @@ -183,13 +193,14 @@ func formatDateTime(dateStr string) string { return t.Format("2006-01-02 15:04:05") // Or any other format you prefer } -/** - * seqFunc generates a sequence of integers starting from 'start' to 'end' (inclusive). - * - * @param start The starting integer of the sequence - * @param end The ending integer of the sequence - * @return []int The generated sequence of integers - */ +// seqFunc generates a sequence of integers. +// Parameters: +// - start: First integer in sequence +// - end: Last integer in sequence (inclusive) +// +// Returns: +// +// []int: Generated sequence from start to end func seqFunc(start, end int) []int { seq := make([]int, end-start+1) for i := range seq { @@ -198,13 +209,38 @@ func seqFunc(start, end int) []int { return seq } -/** - * getHourlyCounts returns a map containing hourly counts data for a given element. - * - * @param element handlers.NoteWithIndex - The element for which hourly counts are generated. - * @param hourIndex int - The index representing the hour for which counts are calculated. - * @return map[string]interface{} - A map with HourIndex and Name fields. - */ +// getHourlyHeaderData constructs a map containing metadata for a specific hour. +// Parameters: +// - hourIndex: The index of the hour (0-23) +// - class: CSS class name for styling ("hourly-count", "bi-hourly-count", "six-hourly-count") +// - length: Time period length in hours (1, 2, or 6) +// - date: Date string in YYYY-MM-DD format +// - sunrise: Hour index when sunrise occurs +// - sunset: Hour index when sunset occurs +// +// Returns: +// +// A map containing the hour metadata with keys: +// "Class", "Length", "HourIndex", "Date", "Sunrise", "Sunset" +func getHourlyHeaderData(hourIndex int, class string, length int, date string, sunrise int, sunset int) map[string]interface{} { + baseData := map[string]interface{}{ + "Class": class, + "Length": length, + "HourIndex": hourIndex, + "Date": date, + "Sunrise": sunrise, + "Sunset": sunset, + } + return baseData +} + +// getHourlyCounts returns hourly count data for a detection. +// Parameters: +// - element: NoteWithIndex containing detection data +// - hourIndex: Hour index (0-23) to get counts for +// Returns: +// map[string]interface{} with HourIndex and species Name + func getHourlyCounts(element handlers.NoteWithIndex, hourIndex int) map[string]interface{} { baseData := map[string]interface{}{ "HourIndex": hourIndex, @@ -214,14 +250,15 @@ func getHourlyCounts(element handlers.NoteWithIndex, hourIndex int) map[string]i return baseData } -/** - * sumHourlyCountsRange calculates the sum of counts within a specified range of hours. - * - * @param counts An array containing hourly counts. - * @param start The starting hour index of the range. - * @param length The length of the range in hours. - * @return The sum of counts within the specified range. - */ +// sumHourlyCountsRange calculates sum of counts in hour range. +// Parameters: +// - counts: 24-hour array of detection counts +// - start: Starting hour index +// - length: Number of hours to sum +// +// Returns: +// +// Sum of counts within specified range func sumHourlyCountsRange(counts [24]int, start, length int) int { sum := 0 for i := start; i < start+length; i++ { diff --git a/views/dashboard.html b/views/dashboard.html index daedd50a..3e9acf4f 100644 --- a/views/dashboard.html +++ b/views/dashboard.html @@ -1,7 +1,7 @@ {{define "dashboard"}} -
+
Daily Summary @@ -17,9 +17,9 @@ -