diff --git a/Taskfile.yml b/Taskfile.yml index 0ac98370..50cd5434 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -78,7 +78,7 @@ tasks: - mkdir -p assets - curl -sL https://unpkg.com/leaflet/dist/leaflet.js -o assets/leaflet.js - curl -sL https://unpkg.com/leaflet/dist/leaflet.css -o assets/leaflet.css - - curl -sL https://unpkg.com/htmx.org -o assets/htmx.min.js + - curl -sL https://unpkg.com/htmx.org@2.0 -o assets/htmx.min.js - curl -sL https://unpkg.com/alpinejs -o assets/alpinejs.min.js generate-tailwindcss: diff --git a/assets/custom.css b/assets/custom.css index 45b37433..94430e5a 100644 --- a/assets/custom.css +++ b/assets/custom.css @@ -14,7 +14,13 @@ html, body { height: 25px; } -.confidence-ball { +.confidence-container { + display: inline-flex; + gap: 4px; + align-items: center; +} + +.confidence-badge { width: 54px; height: 25px; display: flex; @@ -24,12 +30,38 @@ html, body { font-size: 0.75rem; } +.review-badge { + width: 18px; + height: 18px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 9999px; + font-size: 0.8rem; + font-weight: bold; + color: white; +} + +.review-badge.correct { + background-color: hsl(142, 76%, 36%); +} + +.review-badge.false_positive { + background-color: hsl(0, 74%, 42%); +} + @media (max-width: 1024px) { - .confidence-ball { + .confidence-badge { width: 40px; height: 20px; font-size: 0.65rem; } + + .review-badge { + width: 26px; + height: 26px; + font-size: 0.75rem; + } } input.invalid { diff --git a/assets/tailwind.css b/assets/tailwind.css index e6e3fb5e..500d474d 100644 --- a/assets/tailwind.css +++ b/assets/tailwind.css @@ -995,6 +995,11 @@ html { color: var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity))); } + .radio-primary:hover { + --tw-border-opacity: 1; + border-color: var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity))); + } + .tab:hover { --tw-text-opacity: 1; } @@ -1171,7 +1176,7 @@ html { position: relative; display: grid; overflow: hidden; - grid-template-rows: auto 0fr; + grid-template-rows: max-content 0fr; transition: grid-template-rows 0.2s; width: 100%; border-radius: var(--rounded-box, 1rem); @@ -1193,6 +1198,13 @@ html { opacity: 0; } +:where(.collapse > input[type="checkbox"]), +:where(.collapse > input[type="radio"]) { + height: 100%; + width: 100%; + z-index: 1; +} + .collapse-content { visibility: hidden; grid-column-start: 1; @@ -1209,12 +1221,12 @@ html { .collapse[open], .collapse-open, .collapse:focus:not(.collapse-close) { - grid-template-rows: auto 1fr; + grid-template-rows: max-content 1fr; } .collapse:not(.collapse-close):has(> input[type="checkbox"]:checked), .collapse:not(.collapse-close):has(> input[type="radio"]:checked) { - grid-template-rows: auto 1fr; + grid-template-rows: max-content 1fr; } .collapse[open] > .collapse-content, @@ -1331,28 +1343,109 @@ 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%); } +.dropdown { + position: relative; + display: inline-block; +} + +.dropdown > *:not(summary):focus { + outline: 2px solid transparent; + outline-offset: 2px; +} + +.dropdown .dropdown-content { + position: absolute; +} + +.dropdown:is(:not(details)) .dropdown-content { + visibility: hidden; + opacity: 0; + transform-origin: top; + --tw-scale-x: .95; + --tw-scale-y: .95; + transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); + transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, -webkit-backdrop-filter; + transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter; + transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter, -webkit-backdrop-filter; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-timing-function: cubic-bezier(0, 0, 0.2, 1); + transition-duration: 200ms; +} + +.dropdown-end .dropdown-content { + inset-inline-end: 0px; +} + +.dropdown-left .dropdown-content { + bottom: auto; + inset-inline-end: 100%; + top: 0px; + transform-origin: right; +} + +.dropdown-right .dropdown-content { + bottom: auto; + inset-inline-start: 100%; + top: 0px; + transform-origin: left; +} + +.dropdown-bottom .dropdown-content { + bottom: auto; + top: 100%; + transform-origin: top; +} + +.dropdown-top .dropdown-content { + bottom: 100%; + top: auto; + transform-origin: bottom; +} + +.dropdown-end.dropdown-right .dropdown-content { + bottom: 0px; + top: auto; +} + +.dropdown-end.dropdown-left .dropdown-content { + bottom: 0px; + top: auto; +} + +.dropdown.dropdown-open .dropdown-content, +.dropdown:not(.dropdown-hover):focus .dropdown-content, +.dropdown:focus-within .dropdown-content { + visibility: visible; + opacity: 1; +} + @media (hover: hover) { + .dropdown.dropdown-hover:hover .dropdown-content { + visibility: visible; + opacity: 1; + } + .btm-nav > *.disabled:hover, .btm-nav > *[disabled]:hover { pointer-events: none; @@ -1517,6 +1610,12 @@ html { } } + .dropdown.dropdown-hover:hover .dropdown-content { + --tw-scale-x: 1; + --tw-scale-y: 1; + transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); + } + :where(.menu li:not(.menu-title, .disabled) > *:not(ul, details, .menu-title)):not(.active, .btn):hover, :where(.menu li:not(.menu-title, .disabled) > details > summary:not(.menu-title)):not(.active, .btn):hover { cursor: pointer; outline: 2px solid transparent; @@ -1537,6 +1636,10 @@ html { } } +.dropdown:is(details) summary::-webkit-details-marker { + display: none; +} + .form-control { display: flex; flex-direction: column; @@ -1831,6 +1934,21 @@ html { background-color: var(--fallback-bc,oklch(var(--bc)/0.2)); } +.radio { + flex-shrink: 0; + --chkbg: var(--bc); + height: 1.5rem; + width: 1.5rem; + cursor: pointer; + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + border-radius: 9999px; + border-width: 1px; + border-color: var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity))); + --tw-border-opacity: 0.2; +} + .range { height: 1.5rem; width: 100%; @@ -2070,6 +2188,23 @@ input.tab:checked + .tab-content, background-color: var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity))); } +.textarea { + min-height: 3rem; + flex-shrink: 1; + padding-left: 1rem; + padding-right: 1rem; + padding-top: 0.5rem; + padding-bottom: 0.5rem; + font-size: 0.875rem; + line-height: 1.25rem; + line-height: 2; + border-radius: var(--rounded-btn, 0.5rem); + border-width: 1px; + border-color: transparent; + --tw-bg-opacity: 1; + background-color: var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity))); +} + .toggle { flex-shrink: 0; --tglbg: var(--fallback-b1,oklch(var(--b1)/1)); @@ -2127,11 +2262,46 @@ input.tab:checked + .tab-content, color: var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity))); } +.badge-success { + border-color: transparent; + --tw-bg-opacity: 1; + background-color: var(--fallback-su,oklch(var(--su)/var(--tw-bg-opacity))); + --tw-text-opacity: 1; + color: var(--fallback-suc,oklch(var(--suc)/var(--tw-text-opacity))); +} + +.badge-error { + border-color: transparent; + --tw-bg-opacity: 1; + background-color: var(--fallback-er,oklch(var(--er)/var(--tw-bg-opacity))); + --tw-text-opacity: 1; + color: var(--fallback-erc,oklch(var(--erc)/var(--tw-text-opacity))); +} + +.badge-ghost { + --tw-border-opacity: 1; + border-color: var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity))); + --tw-bg-opacity: 1; + background-color: var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity))); + --tw-text-opacity: 1; + color: var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity))); +} + .badge-outline.badge-primary { --tw-text-opacity: 1; color: var(--fallback-p,oklch(var(--p)/var(--tw-text-opacity))); } +.badge-outline.badge-success { + --tw-text-opacity: 1; + color: var(--fallback-su,oklch(var(--su)/var(--tw-text-opacity))); +} + +.badge-outline.badge-error { + --tw-text-opacity: 1; + color: var(--fallback-er,oklch(var(--er)/var(--tw-text-opacity))); +} + .btm-nav > *:where(.active) { border-top-width: 2px; --tw-bg-opacity: 1; @@ -2625,15 +2795,9 @@ details.collapse summary::-webkit-details-marker { position: relative; } -:where(.collapse > input[type="checkbox"]), -:where(.collapse > input[type="radio"]) { - z-index: 1; -} - .collapse-title, :where(.collapse > input[type="checkbox"]), :where(.collapse > input[type="radio"]) { - width: 100%; padding: 1rem; padding-inline-end: 3rem; min-height: 3.75rem; @@ -2682,6 +2846,14 @@ details.collapse summary::-webkit-details-marker { outline-offset: 2px; } +.dropdown.dropdown-open .dropdown-content, +.dropdown:focus .dropdown-content, +.dropdown:focus-within .dropdown-content { + --tw-scale-x: 1; + --tw-scale-y: 1; + transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); +} + .label-text { font-size: 0.875rem; line-height: 1.25rem; @@ -2892,13 +3064,13 @@ details.collapse summary::-webkit-details-marker { mask-repeat: no-repeat; -webkit-mask-position: center; mask-position: center; - -webkit-mask-image: url("data:image/svg+xml,%3Csvg width='24' height='24' stroke='%23000' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cstyle%3E.spinner_V8m1%7Btransform-origin:center;animation:spinner_zKoa 2s linear infinite%7D.spinner_V8m1 circle%7Bstroke-linecap:round;animation:spinner_YpZS 1.5s ease-out infinite%7D%40keyframes spinner_zKoa%7B100%25%7Btransform:rotate(360deg)%7D%7D%40keyframes spinner_YpZS%7B0%25%7Bstroke-dasharray:0 150;stroke-dashoffset:0%7D47.5%25%7Bstroke-dasharray:42 150;stroke-dashoffset:-16%7D95%25%2C100%25%7Bstroke-dasharray:42 150;stroke-dashoffset:-59%7D%7D%3C%2Fstyle%3E%3Cg class='spinner_V8m1'%3E%3Ccircle cx='12' cy='12' r='9.5' fill='none' stroke-width='3'%3E%3C%2Fcircle%3E%3C%2Fg%3E%3C%2Fsvg%3E"); - mask-image: url("data:image/svg+xml,%3Csvg width='24' height='24' stroke='%23000' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cstyle%3E.spinner_V8m1%7Btransform-origin:center;animation:spinner_zKoa 2s linear infinite%7D.spinner_V8m1 circle%7Bstroke-linecap:round;animation:spinner_YpZS 1.5s ease-out infinite%7D%40keyframes spinner_zKoa%7B100%25%7Btransform:rotate(360deg)%7D%7D%40keyframes spinner_YpZS%7B0%25%7Bstroke-dasharray:0 150;stroke-dashoffset:0%7D47.5%25%7Bstroke-dasharray:42 150;stroke-dashoffset:-16%7D95%25%2C100%25%7Bstroke-dasharray:42 150;stroke-dashoffset:-59%7D%7D%3C%2Fstyle%3E%3Cg class='spinner_V8m1'%3E%3Ccircle cx='12' cy='12' r='9.5' fill='none' stroke-width='3'%3E%3C%2Fcircle%3E%3C%2Fg%3E%3C%2Fsvg%3E"); + -webkit-mask-image: url("data:image/svg+xml,%3Csvg width='24' height='24' stroke='black' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cg transform-origin='center'%3E%3Ccircle cx='12' cy='12' r='9.5' fill='none' stroke-width='3' stroke-linecap='round'%3E%3CanimateTransform attributeName='transform' type='rotate' from='0 12 12' to='360 12 12' dur='2s' repeatCount='indefinite'/%3E%3Canimate attributeName='stroke-dasharray' values='0,150;42,150;42,150' keyTimes='0;0.475;1' dur='1.5s' repeatCount='indefinite'/%3E%3Canimate attributeName='stroke-dashoffset' values='0;-16;-59' keyTimes='0;0.475;1' dur='1.5s' repeatCount='indefinite'/%3E%3C/circle%3E%3C/g%3E%3C/svg%3E"); + mask-image: url("data:image/svg+xml,%3Csvg width='24' height='24' stroke='black' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cg transform-origin='center'%3E%3Ccircle cx='12' cy='12' r='9.5' fill='none' stroke-width='3' stroke-linecap='round'%3E%3CanimateTransform attributeName='transform' type='rotate' from='0 12 12' to='360 12 12' dur='2s' repeatCount='indefinite'/%3E%3Canimate attributeName='stroke-dasharray' values='0,150;42,150;42,150' keyTimes='0;0.475;1' dur='1.5s' repeatCount='indefinite'/%3E%3Canimate attributeName='stroke-dashoffset' values='0;-16;-59' keyTimes='0;0.475;1' dur='1.5s' repeatCount='indefinite'/%3E%3C/circle%3E%3C/g%3E%3C/svg%3E"); } .loading-spinner { - -webkit-mask-image: url("data:image/svg+xml,%3Csvg width='24' height='24' stroke='%23000' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cstyle%3E.spinner_V8m1%7Btransform-origin:center;animation:spinner_zKoa 2s linear infinite%7D.spinner_V8m1 circle%7Bstroke-linecap:round;animation:spinner_YpZS 1.5s ease-out infinite%7D%40keyframes spinner_zKoa%7B100%25%7Btransform:rotate(360deg)%7D%7D%40keyframes spinner_YpZS%7B0%25%7Bstroke-dasharray:0 150;stroke-dashoffset:0%7D47.5%25%7Bstroke-dasharray:42 150;stroke-dashoffset:-16%7D95%25%2C100%25%7Bstroke-dasharray:42 150;stroke-dashoffset:-59%7D%7D%3C%2Fstyle%3E%3Cg class='spinner_V8m1'%3E%3Ccircle cx='12' cy='12' r='9.5' fill='none' stroke-width='3'%3E%3C%2Fcircle%3E%3C%2Fg%3E%3C%2Fsvg%3E"); - mask-image: url("data:image/svg+xml,%3Csvg width='24' height='24' stroke='%23000' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cstyle%3E.spinner_V8m1%7Btransform-origin:center;animation:spinner_zKoa 2s linear infinite%7D.spinner_V8m1 circle%7Bstroke-linecap:round;animation:spinner_YpZS 1.5s ease-out infinite%7D%40keyframes spinner_zKoa%7B100%25%7Btransform:rotate(360deg)%7D%7D%40keyframes spinner_YpZS%7B0%25%7Bstroke-dasharray:0 150;stroke-dashoffset:0%7D47.5%25%7Bstroke-dasharray:42 150;stroke-dashoffset:-16%7D95%25%2C100%25%7Bstroke-dasharray:42 150;stroke-dashoffset:-59%7D%7D%3C%2Fstyle%3E%3Cg class='spinner_V8m1'%3E%3Ccircle cx='12' cy='12' r='9.5' fill='none' stroke-width='3'%3E%3C%2Fcircle%3E%3C%2Fg%3E%3C%2Fsvg%3E"); + -webkit-mask-image: url("data:image/svg+xml,%3Csvg width='24' height='24' stroke='black' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cg transform-origin='center'%3E%3Ccircle cx='12' cy='12' r='9.5' fill='none' stroke-width='3' stroke-linecap='round'%3E%3CanimateTransform attributeName='transform' type='rotate' from='0 12 12' to='360 12 12' dur='2s' repeatCount='indefinite'/%3E%3Canimate attributeName='stroke-dasharray' values='0,150;42,150;42,150' keyTimes='0;0.475;1' dur='1.5s' repeatCount='indefinite'/%3E%3Canimate attributeName='stroke-dashoffset' values='0;-16;-59' keyTimes='0;0.475;1' dur='1.5s' repeatCount='indefinite'/%3E%3C/circle%3E%3C/g%3E%3C/svg%3E"); + mask-image: url("data:image/svg+xml,%3Csvg width='24' height='24' stroke='black' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cg transform-origin='center'%3E%3Ccircle cx='12' cy='12' r='9.5' fill='none' stroke-width='3' stroke-linecap='round'%3E%3CanimateTransform attributeName='transform' type='rotate' from='0 12 12' to='360 12 12' dur='2s' repeatCount='indefinite'/%3E%3Canimate attributeName='stroke-dasharray' values='0,150;42,150;42,150' keyTimes='0;0.475;1' dur='1.5s' repeatCount='indefinite'/%3E%3Canimate attributeName='stroke-dashoffset' values='0;-16;-59' keyTimes='0;0.475;1' dur='1.5s' repeatCount='indefinite'/%3E%3C/circle%3E%3C/g%3E%3C/svg%3E"); } :where(.menu li:empty) { @@ -3113,6 +3285,10 @@ details.collapse summary::-webkit-details-marker { margin-left: calc(0.5rem * calc(1 - var(--tw-space-x-reverse))); } +.modal-action:where([dir="rtl"], [dir="rtl"] *) > :not([hidden]) ~ :not([hidden]) { + --tw-space-x-reverse: 1; +} + @keyframes modal-pop { 0% { opacity: 0; @@ -3121,8 +3297,7 @@ details.collapse summary::-webkit-details-marker { .progress::-moz-progress-bar { border-radius: var(--rounded-box, 1rem); - --tw-bg-opacity: 1; - background-color: var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity))); + background-color: currentColor; } .progress-primary::-moz-progress-bar { @@ -3156,8 +3331,7 @@ details.collapse summary::-webkit-details-marker { .progress::-webkit-progress-value { border-radius: var(--rounded-box, 1rem); - --tw-bg-opacity: 1; - background-color: var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity))); + background-color: currentColor; } .progress-primary::-webkit-progress-value { @@ -3185,6 +3359,52 @@ details.collapse summary::-webkit-details-marker { } } +.radio:focus { + box-shadow: none; +} + +.radio:focus-visible { + outline-style: solid; + outline-width: 2px; + outline-offset: 2px; + outline-color: var(--fallback-bc,oklch(var(--bc)/1)); +} + +.radio:checked, + .radio[aria-checked="true"] { + --tw-bg-opacity: 1; + background-color: var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity))); + background-image: none; + animation: radiomark var(--animation-input, 0.2s) ease-out; + box-shadow: 0 0 0 4px var(--fallback-b1,oklch(var(--b1)/1)) inset, + 0 0 0 4px var(--fallback-b1,oklch(var(--b1)/1)) inset; +} + +.radio-primary { + --chkbg: var(--p); + --tw-border-opacity: 1; + border-color: var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity))); +} + +.radio-primary:focus-visible { + outline-color: var(--fallback-p,oklch(var(--p)/1)); +} + +.radio-primary:checked, + .radio-primary[aria-checked="true"] { + --tw-border-opacity: 1; + border-color: var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity))); + --tw-bg-opacity: 1; + background-color: var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity))); + --tw-text-opacity: 1; + color: var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity))); +} + +.radio:disabled { + cursor: not-allowed; + opacity: 0.2; +} + @keyframes radiomark { 0% { box-shadow: 0 0 0 12px var(--fallback-b1,oklch(var(--b1)/1)) inset, @@ -3597,6 +3817,42 @@ details.collapse summary::-webkit-details-marker { border-top-color: var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity))); } +.textarea-bordered { + border-color: var(--fallback-bc,oklch(var(--bc)/0.2)); +} + +.textarea:focus { + box-shadow: none; + border-color: var(--fallback-bc,oklch(var(--bc)/0.2)); + outline-style: solid; + outline-width: 2px; + outline-offset: 2px; + outline-color: var(--fallback-bc,oklch(var(--bc)/0.2)); +} + +.textarea-disabled, + .textarea:disabled, + .textarea[disabled] { + cursor: not-allowed; + --tw-border-opacity: 1; + border-color: var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity))); + --tw-bg-opacity: 1; + background-color: var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity))); + color: var(--fallback-bc,oklch(var(--bc)/0.4)); +} + +.textarea-disabled::-moz-placeholder, .textarea:disabled::-moz-placeholder, .textarea[disabled]::-moz-placeholder { + color: var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity))); + --tw-placeholder-opacity: 0.2; +} + +.textarea-disabled::placeholder, + .textarea:disabled::placeholder, + .textarea[disabled]::placeholder { + color: var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity))); + --tw-placeholder-opacity: 0.2; +} + @keyframes toast-pop { 0% { transform: scale(0.9); @@ -3904,6 +4160,11 @@ html:has(.drawer-toggle:checked) { place-items: end; } +[type="radio"].radio-xs { + height: 1rem; + width: 1rem; +} + .range-xs { height: 1rem; } @@ -4263,6 +4524,10 @@ html:has(.drawer-toggle:checked) { pointer-events: none; } +.pointer-events-auto { + pointer-events: auto; +} + .invisible { visibility: hidden; } @@ -4283,10 +4548,6 @@ html:has(.drawer-toggle:checked) { position: relative; } -.sticky { - position: sticky; -} - .inset-0 { inset: 0px; } @@ -4356,6 +4617,10 @@ html:has(.drawer-toggle:checked) { z-index: 50; } +.z-\[1\] { + z-index: 1; +} + .col-span-1 { grid-column: span 1 / span 1; } @@ -4387,6 +4652,10 @@ html:has(.drawer-toggle:checked) { margin-bottom: 0.125rem; } +.mb-1 { + margin-bottom: 0.25rem; +} + .mb-2 { margin-bottom: 0.5rem; } @@ -4399,6 +4668,10 @@ html:has(.drawer-toggle:checked) { margin-bottom: 1rem; } +.mb-6 { + margin-bottom: 1.5rem; +} + .mb-8 { margin-bottom: 2rem; } @@ -4419,12 +4692,12 @@ html:has(.drawer-toggle:checked) { margin-left: 2rem; } -.mr-2 { - margin-right: 0.5rem; +.ml-auto { + margin-left: auto; } -.mt-0\.5 { - margin-top: 0.125rem; +.mr-2 { + margin-right: 0.5rem; } .mt-2 { @@ -4492,6 +4765,14 @@ html:has(.drawer-toggle:checked) { height: 3rem; } +.h-24 { + height: 6rem; +} + +.h-3 { + height: 0.75rem; +} + .h-4 { height: 1rem; } @@ -4504,6 +4785,10 @@ html:has(.drawer-toggle:checked) { height: 1.5rem; } +.h-8 { + height: 2rem; +} + .h-\[100dvh\] { height: 100dvh; } @@ -4556,6 +4841,10 @@ html:has(.drawer-toggle:checked) { width: 6rem; } +.w-3 { + width: 0.75rem; +} + .w-4 { width: 1rem; } @@ -4564,6 +4853,10 @@ html:has(.drawer-toggle:checked) { width: 1.25rem; } +.w-52 { + width: 13rem; +} + .w-6 { width: 1.5rem; } @@ -4572,6 +4865,10 @@ html:has(.drawer-toggle:checked) { width: 16rem; } +.w-8 { + width: 2rem; +} + .w-auto { width: auto; } @@ -4584,6 +4881,10 @@ html:has(.drawer-toggle:checked) { min-width: 50px; } +.max-w-3xl { + max-width: 48rem; +} + .max-w-4xl { max-width: 56rem; } @@ -4612,6 +4913,10 @@ html:has(.drawer-toggle:checked) { flex-shrink: 0; } +.shrink-0 { + flex-shrink: 0; +} + .flex-grow { flex-grow: 1; } @@ -4634,11 +4939,6 @@ html:has(.drawer-toggle:checked) { transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); } -.-translate-y-2 { - --tw-translate-y: -0.5rem; - transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); -} - .translate-x-0 { --tw-translate-x: 0px; transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); @@ -4649,13 +4949,18 @@ html:has(.drawer-toggle:checked) { transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); } -.translate-y-0 { - --tw-translate-y: 0px; +.transform { transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); } -.transform { - transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +.animate-spin { + animation: spin 1s linear infinite; } .cursor-not-allowed { @@ -4724,6 +5029,10 @@ html:has(.drawer-toggle:checked) { justify-content: space-between; } +.gap-1 { + gap: 0.25rem; +} + .gap-2 { gap: 0.5rem; } @@ -4751,6 +5060,12 @@ html:has(.drawer-toggle:checked) { margin-left: calc(0.5rem * calc(1 - var(--tw-space-x-reverse))); } +.space-x-3 > :not([hidden]) ~ :not([hidden]) { + --tw-space-x-reverse: 0; + margin-right: calc(0.75rem * var(--tw-space-x-reverse)); + margin-left: calc(0.75rem * calc(1 - var(--tw-space-x-reverse))); +} + .space-x-4 > :not([hidden]) ~ :not([hidden]) { --tw-space-x-reverse: 0; margin-right: calc(1rem * var(--tw-space-x-reverse)); @@ -4787,6 +5102,10 @@ html:has(.drawer-toggle:checked) { border-radius: 0.25rem; } +.rounded-box { + border-radius: var(--rounded-box, 1rem); +} + .rounded-full { border-radius: 9999px; } @@ -4799,10 +5118,6 @@ html:has(.drawer-toggle:checked) { border-radius: 0.375rem; } -.rounded-sm { - border-radius: 0.125rem; -} - .rounded-b-md { border-bottom-right-radius: 0.375rem; border-bottom-left-radius: 0.375rem; @@ -4812,8 +5127,8 @@ html:has(.drawer-toggle:checked) { border-width: 1px; } -.border-2 { - border-width: 2px; +.border-b-2 { + border-bottom-width: 2px; } .border-l-4 { @@ -4825,8 +5140,14 @@ html:has(.drawer-toggle:checked) { border-color: var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity))); } -.border-base-content\/30 { - border-color: var(--fallback-bc,oklch(var(--bc)/0.3)); +.border-base-300 { + --tw-border-opacity: 1; + border-color: var(--fallback-b3,oklch(var(--b3)/var(--tw-border-opacity))); +} + +.border-primary { + --tw-border-opacity: 1; + border-color: var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity))); } .border-red-500 { @@ -4920,6 +5241,10 @@ html:has(.drawer-toggle:checked) { stroke: currentColor; } +.stroke-info { + stroke: var(--fallback-in,oklch(var(--in)/1)); +} + .stroke-white { stroke: #fff; } @@ -4998,6 +5323,11 @@ html:has(.drawer-toggle:checked) { padding-bottom: 0.5rem; } +.py-4 { + padding-top: 1rem; + padding-bottom: 1rem; +} + .pb-0 { padding-bottom: 0px; } @@ -5126,10 +5456,6 @@ html:has(.drawer-toggle:checked) { text-transform: capitalize; } -.normal-case { - text-transform: none; -} - .italic { font-style: italic; } @@ -5147,6 +5473,10 @@ html:has(.drawer-toggle:checked) { color: var(--fallback-bc,oklch(var(--bc)/0.5)); } +.text-base-content\/60 { + color: var(--fallback-bc,oklch(var(--bc)/0.6)); +} + .text-base-content\/70 { color: var(--fallback-bc,oklch(var(--bc)/0.7)); } @@ -5156,9 +5486,9 @@ html:has(.drawer-toggle:checked) { color: rgb(0 0 0 / var(--tw-text-opacity)); } -.text-error-content { +.text-error { --tw-text-opacity: 1; - color: var(--fallback-erc,oklch(var(--erc)/var(--tw-text-opacity))); + color: var(--fallback-er,oklch(var(--er)/var(--tw-text-opacity))); } .text-gray-100 { @@ -5232,6 +5562,12 @@ html:has(.drawer-toggle:checked) { opacity: 0.5; } +.shadow { + --tw-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1); + --tw-shadow-colored: 0 1px 3px 0 var(--tw-shadow-color), 0 1px 2px -1px var(--tw-shadow-color); + box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); +} + .shadow-lg { --tw-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1); --tw-shadow-colored: 0 10px 15px -3px var(--tw-shadow-color), 0 4px 6px -4px var(--tw-shadow-color); @@ -5267,26 +5603,12 @@ html:has(.drawer-toggle:checked) { transition-duration: 150ms; } -.transition-all { - transition-property: all; - transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); - transition-duration: 150ms; -} - .transition-opacity { transition-property: opacity; transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); transition-duration: 150ms; } -.duration-150 { - transition-duration: 150ms; -} - -.duration-200 { - transition-duration: 200ms; -} - .duration-300 { transition-duration: 300ms; } @@ -5318,13 +5640,13 @@ html { mask-repeat: no-repeat; -webkit-mask-position: center; mask-position: center; - -webkit-mask-image: url("data:image/svg+xml,%3Csvg width='24' height='24' stroke='%23000' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cstyle%3E.spinner_V8m1%7Btransform-origin:center;animation:spinner_zKoa 2s linear infinite%7D.spinner_V8m1 circle%7Bstroke-linecap:round;animation:spinner_YpZS 1.5s ease-out infinite%7D%40keyframes spinner_zKoa%7B100%25%7Btransform:rotate(360deg)%7D%7D%40keyframes spinner_YpZS%7B0%25%7Bstroke-dasharray:0 150;stroke-dashoffset:0%7D47.5%25%7Bstroke-dasharray:42 150;stroke-dashoffset:-16%7D95%25%2C100%25%7Bstroke-dasharray:42 150;stroke-dashoffset:-59%7D%7D%3C%2Fstyle%3E%3Cg class='spinner_V8m1'%3E%3Ccircle cx='12' cy='12' r='9.5' fill='none' stroke-width='3'%3E%3C%2Fcircle%3E%3C%2Fg%3E%3C%2Fsvg%3E"); - mask-image: url("data:image/svg+xml,%3Csvg width='24' height='24' stroke='%23000' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cstyle%3E.spinner_V8m1%7Btransform-origin:center;animation:spinner_zKoa 2s linear infinite%7D.spinner_V8m1 circle%7Bstroke-linecap:round;animation:spinner_YpZS 1.5s ease-out infinite%7D%40keyframes spinner_zKoa%7B100%25%7Btransform:rotate(360deg)%7D%7D%40keyframes spinner_YpZS%7B0%25%7Bstroke-dasharray:0 150;stroke-dashoffset:0%7D47.5%25%7Bstroke-dasharray:42 150;stroke-dashoffset:-16%7D95%25%2C100%25%7Bstroke-dasharray:42 150;stroke-dashoffset:-59%7D%7D%3C%2Fstyle%3E%3Cg class='spinner_V8m1'%3E%3Ccircle cx='12' cy='12' r='9.5' fill='none' stroke-width='3'%3E%3C%2Fcircle%3E%3C%2Fg%3E%3C%2Fsvg%3E"); + -webkit-mask-image: url("data:image/svg+xml,%3Csvg width='24' height='24' stroke='black' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cg transform-origin='center'%3E%3Ccircle cx='12' cy='12' r='9.5' fill='none' stroke-width='3' stroke-linecap='round'%3E%3CanimateTransform attributeName='transform' type='rotate' from='0 12 12' to='360 12 12' dur='2s' repeatCount='indefinite'/%3E%3Canimate attributeName='stroke-dasharray' values='0,150;42,150;42,150' keyTimes='0;0.475;1' dur='1.5s' repeatCount='indefinite'/%3E%3Canimate attributeName='stroke-dashoffset' values='0;-16;-59' keyTimes='0;0.475;1' dur='1.5s' repeatCount='indefinite'/%3E%3C/circle%3E%3C/g%3E%3C/svg%3E"); + mask-image: url("data:image/svg+xml,%3Csvg width='24' height='24' stroke='black' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cg transform-origin='center'%3E%3Ccircle cx='12' cy='12' r='9.5' fill='none' stroke-width='3' stroke-linecap='round'%3E%3CanimateTransform attributeName='transform' type='rotate' from='0 12 12' to='360 12 12' dur='2s' repeatCount='indefinite'/%3E%3Canimate attributeName='stroke-dasharray' values='0,150;42,150;42,150' keyTimes='0;0.475;1' dur='1.5s' repeatCount='indefinite'/%3E%3Canimate attributeName='stroke-dashoffset' values='0;-16;-59' keyTimes='0;0.475;1' dur='1.5s' repeatCount='indefinite'/%3E%3C/circle%3E%3C/g%3E%3C/svg%3E"); } .xs\:loading-spinner { - -webkit-mask-image: url("data:image/svg+xml,%3Csvg width='24' height='24' stroke='%23000' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cstyle%3E.spinner_V8m1%7Btransform-origin:center;animation:spinner_zKoa 2s linear infinite%7D.spinner_V8m1 circle%7Bstroke-linecap:round;animation:spinner_YpZS 1.5s ease-out infinite%7D%40keyframes spinner_zKoa%7B100%25%7Btransform:rotate(360deg)%7D%7D%40keyframes spinner_YpZS%7B0%25%7Bstroke-dasharray:0 150;stroke-dashoffset:0%7D47.5%25%7Bstroke-dasharray:42 150;stroke-dashoffset:-16%7D95%25%2C100%25%7Bstroke-dasharray:42 150;stroke-dashoffset:-59%7D%7D%3C%2Fstyle%3E%3Cg class='spinner_V8m1'%3E%3Ccircle cx='12' cy='12' r='9.5' fill='none' stroke-width='3'%3E%3C%2Fcircle%3E%3C%2Fg%3E%3C%2Fsvg%3E"); - mask-image: url("data:image/svg+xml,%3Csvg width='24' height='24' stroke='%23000' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cstyle%3E.spinner_V8m1%7Btransform-origin:center;animation:spinner_zKoa 2s linear infinite%7D.spinner_V8m1 circle%7Bstroke-linecap:round;animation:spinner_YpZS 1.5s ease-out infinite%7D%40keyframes spinner_zKoa%7B100%25%7Btransform:rotate(360deg)%7D%7D%40keyframes spinner_YpZS%7B0%25%7Bstroke-dasharray:0 150;stroke-dashoffset:0%7D47.5%25%7Bstroke-dasharray:42 150;stroke-dashoffset:-16%7D95%25%2C100%25%7Bstroke-dasharray:42 150;stroke-dashoffset:-59%7D%7D%3C%2Fstyle%3E%3Cg class='spinner_V8m1'%3E%3Ccircle cx='12' cy='12' r='9.5' fill='none' stroke-width='3'%3E%3C%2Fcircle%3E%3C%2Fg%3E%3C%2Fsvg%3E"); + -webkit-mask-image: url("data:image/svg+xml,%3Csvg width='24' height='24' stroke='black' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cg transform-origin='center'%3E%3Ccircle cx='12' cy='12' r='9.5' fill='none' stroke-width='3' stroke-linecap='round'%3E%3CanimateTransform attributeName='transform' type='rotate' from='0 12 12' to='360 12 12' dur='2s' repeatCount='indefinite'/%3E%3Canimate attributeName='stroke-dasharray' values='0,150;42,150;42,150' keyTimes='0;0.475;1' dur='1.5s' repeatCount='indefinite'/%3E%3Canimate attributeName='stroke-dashoffset' values='0;-16;-59' keyTimes='0;0.475;1' dur='1.5s' repeatCount='indefinite'/%3E%3C/circle%3E%3C/g%3E%3C/svg%3E"); + mask-image: url("data:image/svg+xml,%3Csvg width='24' height='24' stroke='black' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cg transform-origin='center'%3E%3Ccircle cx='12' cy='12' r='9.5' fill='none' stroke-width='3' stroke-linecap='round'%3E%3CanimateTransform attributeName='transform' type='rotate' from='0 12 12' to='360 12 12' dur='2s' repeatCount='indefinite'/%3E%3Canimate attributeName='stroke-dasharray' values='0,150;42,150;42,150' keyTimes='0;0.475;1' dur='1.5s' repeatCount='indefinite'/%3E%3Canimate attributeName='stroke-dashoffset' values='0;-16;-59' keyTimes='0;0.475;1' dur='1.5s' repeatCount='indefinite'/%3E%3C/circle%3E%3C/g%3E%3C/svg%3E"); } } @@ -5537,10 +5859,6 @@ html { margin-left: 0px; } - .xs\:block { - display: block; - } - .xs\:flex { display: flex; } @@ -5575,6 +5893,10 @@ html { margin-right: 0px; } + .sm\:mb-0 { + margin-bottom: 0px; + } + .sm\:block { display: block; } diff --git a/assets/util.js b/assets/util.js index 3d6f11ef..9aca3e19 100644 --- a/assets/util.js +++ b/assets/util.js @@ -58,17 +58,23 @@ function initializeDatePicker() { } 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); - }); - } - - // Initialize date picker if we're on the dashboard - if (isLocationDashboard()) { - initializeDatePicker(); - } + // Skip if target or id is not available + if (!event.detail?.target) return; + + // Get the target id, ensuring it's a string + const targetId = String(event.detail.target?.id || ''); + + if (targetId.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); + }); + } + + // Initialize date picker if we're on the dashboard + if (isLocationDashboard()) { + initializeDatePicker(); + } }); // Add document ready listener to handle initial page load diff --git a/internal/datastore/interfaces.go b/internal/datastore/interfaces.go index e6d049a0..4a857898 100644 --- a/internal/datastore/interfaces.go +++ b/internal/datastore/interfaces.go @@ -33,6 +33,7 @@ type Interface interface { GetNoteClipPath(noteID string) (string, error) DeleteNoteClipPath(noteID string) error GetClipsQualifyingForRemoval(minHours int, minClips int) ([]ClipForRemoval, error) + UpdateNote(id string, updates map[string]interface{}) error // weather data SaveDailyEvents(dailyEvents *DailyEvents) error GetDailyEvents(date string) (DailyEvents, error) @@ -550,3 +551,24 @@ func getHourRange(hour string, duration int) (startTime, endTime string) { endTime = fmt.Sprintf("%02d:00:00", endHour) return startTime, endTime } + +// UpdateNote updates specific fields of a note. It validates the input parameters +// and returns appropriate errors if the note doesn't exist or if the update fails. +func (ds *DataStore) UpdateNote(id string, updates map[string]interface{}) error { + if id == "" { + return fmt.Errorf("invalid id: must not be empty") + } + if len(updates) == 0 { + return fmt.Errorf("no updates provided") + } + + result := ds.DB.Model(&Note{}).Where("id = ?", id).Updates(updates) + if result.Error != nil { + return fmt.Errorf("failed to update note: %w", result.Error) + } + if result.RowsAffected == 0 { + return fmt.Errorf("note with id %s not found", id) + } + + return nil +} diff --git a/internal/datastore/model.go b/internal/datastore/model.go index af33bac8..53d02bd1 100644 --- a/internal/datastore/model.go +++ b/internal/datastore/model.go @@ -22,6 +22,7 @@ type Note struct { Threshold float64 Sensitivity float64 ClipName string + Verified string `gorm:"type:enum('unverified','correct','false_positive');default:'unverified'"` Comment string `gorm:"type:text"` ProcessingTime time.Duration Results []Results `gorm:"foreignKey:NoteID"` diff --git a/internal/datastore/mysql.go b/internal/datastore/mysql.go index 8bdcdf11..58d9b840 100644 --- a/internal/datastore/mysql.go +++ b/internal/datastore/mysql.go @@ -69,3 +69,10 @@ func (store *MySQLStore) Close() error { return nil } + +// UpdateNote updates specific fields of a note in MySQL +func (m *MySQLStore) UpdateNote(id string, updates map[string]interface{}) error { + return m.DB.Model(&Note{}).Where("id = ?", id).Updates(updates).Error +} + +// Save stores a note and its associated results as a single transaction in the database. diff --git a/internal/datastore/sqlite.go b/internal/datastore/sqlite.go index f819ef18..7b4cbb83 100644 --- a/internal/datastore/sqlite.go +++ b/internal/datastore/sqlite.go @@ -86,3 +86,10 @@ func (store *SQLiteStore) Close() error { return nil } + +// UpdateNote updates specific fields of a note in SQLite +func (s *SQLiteStore) UpdateNote(id string, updates map[string]interface{}) error { + return s.DB.Model(&Note{}).Where("id = ?", id).Updates(updates).Error +} + +// Save stores a note and its associated results as a single transaction in the database. diff --git a/internal/httpcontroller/handlers/dashboard.go b/internal/httpcontroller/handlers/dashboard.go index 3b11916f..e9c3a2a0 100644 --- a/internal/httpcontroller/handlers/dashboard.go +++ b/internal/httpcontroller/handlers/dashboard.go @@ -2,10 +2,8 @@ package handlers import ( - "fmt" "log" "net/http" - "os" "sort" "strconv" "time" @@ -145,36 +143,3 @@ func (h *Handlers) GetAllNotes(c echo.Context) error { return c.JSON(http.StatusOK, notes) } - -// deleteNoteHandler deletes note object from database and its associated audio file -func (h *Handlers) DeleteNote(c echo.Context) error { - noteID := c.QueryParam("id") - if noteID == "" { - return h.NewHandlerError(fmt.Errorf("empty note ID"), "Note ID is required", http.StatusBadRequest) - } - - // Retrieve the path to the audio file before deleting the note - clipPath, err := h.DS.GetNoteClipPath(noteID) - if err != nil { - return h.NewHandlerError(err, "Failed to retrieve audio clip path", http.StatusInternalServerError) - } - - // Delete the note from the database - err = h.DS.Delete(noteID) - if err != nil { - return h.NewHandlerError(err, "Failed to delete note", http.StatusInternalServerError) - } - - // If there's an associated clip, delete the file - if clipPath != "" { - err = os.Remove(clipPath) - if err != nil { - h.logError(&HandlerError{Err: err, Message: "Failed to delete audio clip", Code: http.StatusInternalServerError}) - } else { - h.logInfo(fmt.Sprintf("Deleted audio clip: %s", clipPath)) - } - } - - // Pass this struct to the template or return a success message - return c.HTML(http.StatusOK, `
Delete successful!
`) -} diff --git a/internal/httpcontroller/handlers/detections.go b/internal/httpcontroller/handlers/detections.go index 83745fc1..cdd5e87b 100644 --- a/internal/httpcontroller/handlers/detections.go +++ b/internal/httpcontroller/handlers/detections.go @@ -5,13 +5,14 @@ import ( "log" "math" "net/http" + "os" "runtime" + "strings" "time" "github.com/labstack/echo/v4" "github.com/tphakala/birdnet-go/internal/conf" "github.com/tphakala/birdnet-go/internal/datastore" - "github.com/tphakala/birdnet-go/internal/suncalc" "github.com/tphakala/birdnet-go/internal/weather" ) @@ -195,34 +196,36 @@ func (h *Handlers) DetectionDetails(c echo.Context) error { } // RecentDetections handles requests for the latest detections. -// It retrieves the last set of detections based on the specified count and view type. func (h *Handlers) RecentDetections(c echo.Context) error { - numDetections := parseNumDetections(c.QueryParam("numDetections"), 10) // Default value is 10 + h.Debug("RecentDetections: Starting handler") - var data interface{} - var templateName string + numDetections := parseNumDetections(c.QueryParam("numDetections"), 10) + h.Debug("RecentDetections: Fetching %d detections", numDetections) - // Use the existing detailed view notes, err := h.DS.GetLastDetections(numDetections) if err != nil { + h.Debug("RecentDetections: Error fetching detections: %v", err) return h.NewHandlerError(err, "Failed to fetch recent detections", http.StatusInternalServerError) } - data = struct { + h.Debug("RecentDetections: Found %d detections", len(notes)) + + data := struct { Notes []datastore.Note DashboardSettings conf.Dashboard }{ Notes: notes, DashboardSettings: *h.DashboardSettings, } - templateName = "recentDetections" - // Render the appropriate template with the data - err = c.Render(http.StatusOK, templateName, data) + h.Debug("RecentDetections: Rendering template") + err = c.Render(http.StatusOK, "recentDetections", data) if err != nil { - log.Printf("Failed to render %s template: %v", templateName, err) + h.Debug("RecentDetections: Error rendering template: %v", err) return h.NewHandlerError(err, "Failed to render template", http.StatusInternalServerError) } + + h.Debug("RecentDetections: Successfully completed") return nil } @@ -293,54 +296,165 @@ func (h *Handlers) addWeatherAndTimeOfDay(notes []datastore.Note) ([]NoteWithWea return notesWithWeather, nil } -// getSunEvents calculates sun events for a given date -func (h *Handlers) getSunEvents(date string, loc *time.Location) (suncalc.SunEventTimes, error) { - // Parse the input date string into a time.Time object using the provided location - dateTime, err := time.ParseInLocation("2006-01-02", date, loc) - if err != nil { - // If parsing fails, return an empty SunEventTimes and the error - return suncalc.SunEventTimes{}, err +// DeleteDetection handles the deletion of a detection and its associated files +func (h *Handlers) DeleteDetection(c echo.Context) error { + id := c.QueryParam("id") + + if id == "" { + h.SSE.SendNotification(Notification{ + Message: "Missing detection ID", + Type: "error", + }) + return h.NewHandlerError(fmt.Errorf("no ID provided"), "Missing detection ID", http.StatusBadRequest) } - // Attempt to get sun event times using the SunCalc - sunEvents, err := h.SunCalc.GetSunEventTimes(dateTime) + // Get the clip path before deletion + clipPath, err := h.DS.GetNoteClipPath(id) if err != nil { - // If sun events are not available, use default values - return suncalc.SunEventTimes{ - CivilDawn: dateTime.Add(5 * time.Hour), // Set civil dawn to 5:00 AM - Sunrise: dateTime.Add(6 * time.Hour), // Set sunrise to 6:00 AM - Sunset: dateTime.Add(18 * time.Hour), // Set sunset to 6:00 PM - CivilDusk: dateTime.Add(19 * time.Hour), // Set civil dusk to 7:00 PM - }, nil + h.Debug("Failed to get clip path: %v", err) + h.SSE.SendNotification(Notification{ + Message: fmt.Sprintf("Failed to get clip path: %v", err), + Type: "error", + }) + return h.NewHandlerError(err, "Failed to get clip path", http.StatusInternalServerError) } - // Return the calculated sun events - return sunEvents, nil + // Delete the note from the database + if err := h.DS.Delete(id); err != nil { + h.Debug("Failed to delete note %s: %v", id, err) + h.SSE.SendNotification(Notification{ + Message: fmt.Sprintf("Failed to delete note: %v", err), + Type: "error", + }) + return h.NewHandlerError(err, "Failed to delete note", http.StatusInternalServerError) + } + + // If there was a clip associated, delete the audio file and spectrogram + if clipPath != "" { + // Delete audio file + audioPath := fmt.Sprintf("%s/%s", h.Settings.Realtime.Audio.Export.Path, clipPath) + if err := os.Remove(audioPath); err != nil && !os.IsNotExist(err) { + h.Debug("Failed to delete audio file %s: %v", audioPath, err) + } + + // Delete spectrogram file + spectrogramPath := fmt.Sprintf("%s/%s.png", h.Settings.Realtime.Audio.Export.Path, strings.TrimSuffix(clipPath, ".wav")) + if err := os.Remove(spectrogramPath); err != nil && !os.IsNotExist(err) { + h.Debug("Failed to delete spectrogram file %s: %v", spectrogramPath, err) + } + } + + // Log the successful deletion + h.Debug("Successfully deleted detection %s", id) + + // Send success notification + h.SSE.SendNotification(Notification{ + Message: "Detection deleted successfully", + Type: "success", + }) + + // Set response header to refresh list + c.Response().Header().Set("HX-Trigger", "refreshListEvent") + + return c.NoContent(http.StatusOK) } -// findClosestWeather finds the closest hourly weather data to the given time -func findClosestWeather(noteTime time.Time, hourlyWeather []datastore.HourlyWeather) *datastore.HourlyWeather { - // If there's no weather data, return nil - if len(hourlyWeather) == 0 { - return nil +// ReviewDetection handles the verification of a detection as either correct or false positive +func (h *Handlers) ReviewDetection(c echo.Context) error { + id := c.FormValue("id") + if id == "" { + return h.NewHandlerError(fmt.Errorf("no ID provided"), "Missing detection ID", http.StatusBadRequest) } - // Initialize variables to track the closest weather data - var closestWeather *datastore.HourlyWeather - minDiff := time.Duration(math.MaxInt64) + verified := c.FormValue("verified") + if verified != "correct" && verified != "false_positive" { + return h.NewHandlerError(fmt.Errorf("invalid verification status"), "Invalid verification status", http.StatusBadRequest) + } - // Iterate through all hourly weather data - for i := range hourlyWeather { - // Calculate the absolute time difference between the note time and weather time - diff := noteTime.Sub(hourlyWeather[i].Time).Abs() + comment := c.FormValue("comment") + ignoreSpecies := c.FormValue("ignore_species") - // If this difference is smaller than the current minimum, update the closest weather - if diff < minDiff { - minDiff = diff - closestWeather = &hourlyWeather[i] + // Verify that the note exists and get its data + note, err := h.DS.Get(id) + if err != nil { + return h.NewHandlerError(err, "Failed to retrieve note", http.StatusInternalServerError) + } + + // Handle ignore species if it's set and the detection is marked as false positive + if verified == "false_positive" && ignoreSpecies != "" { + // Get settings instance + settings := conf.Setting() + + // Check if species is already in the excluded list + isExcluded := false + for _, s := range settings.Realtime.Species.Exclude { + if s == ignoreSpecies { + isExcluded = true + break + } + } + + // Add to excluded list if not already there + if !isExcluded { + settings.Realtime.Species.Exclude = append(settings.Realtime.Species.Exclude, ignoreSpecies) + + // Save the settings + if err := conf.SaveSettings(); err != nil { + h.SSE.SendNotification(Notification{ + Message: fmt.Sprintf("Failed to update ignore list: %v", err), + Type: "error", + }) + return h.NewHandlerError(err, "Failed to save settings", http.StatusInternalServerError) + } + + h.SSE.SendNotification(Notification{ + Message: fmt.Sprintf("%s added to ignore list", ignoreSpecies), + Type: "success", + }) + } + } else if verified == "correct" && note.Verified == "false_positive" { + // If changing from false positive to correct, check if species is in ignore list + settings := conf.Setting() + + // Check if species is in the excluded list + isExcluded := false + for _, s := range settings.Realtime.Species.Exclude { + if s == note.CommonName { + isExcluded = true + break + } + } + + // If species is in exclude list, ask user if they want to remove it + if isExcluded { + // Send notification to inform user + h.SSE.SendNotification(Notification{ + Message: fmt.Sprintf("%s is currently in ignore list. You may want to remove it from Settings.", note.CommonName), + Type: "warning", + }) } } - // Return the weather data closest to the note time - return closestWeather + // Update the verification status and comment + if err := h.DS.UpdateNote(id, map[string]interface{}{ + "verified": verified, + "comment": comment, + }); err != nil { + h.SSE.SendNotification(Notification{ + Message: fmt.Sprintf("Failed to save review: %v", err), + Type: "error", + }) + return h.NewHandlerError(err, "Failed to save note", http.StatusInternalServerError) + } + + // Send success notification + h.SSE.SendNotification(Notification{ + Message: "Detection review saved successfully", + Type: "success", + }) + + // Set response header to refresh list + c.Response().Header().Set("HX-Trigger", "refreshListEvent") + + return c.NoContent(http.StatusOK) } diff --git a/internal/httpcontroller/handlers/handlers.go b/internal/httpcontroller/handlers/handlers.go index f0085f3c..95d0c806 100644 --- a/internal/httpcontroller/handlers/handlers.go +++ b/internal/httpcontroller/handlers/handlers.go @@ -8,6 +8,7 @@ import ( "net/http" "os" "runtime/debug" + "sync" "github.com/labstack/echo/v4" "github.com/tphakala/birdnet-go/internal/conf" @@ -18,6 +19,8 @@ import ( "github.com/tphakala/birdnet-go/internal/suncalc" ) +var settingsMutex sync.RWMutex + // Handlers embeds baseHandler and includes all the dependencies needed for the application handlers. type Handlers struct { baseHandler diff --git a/internal/httpcontroller/handlers/species.go b/internal/httpcontroller/handlers/species.go new file mode 100644 index 00000000..4b6469ac --- /dev/null +++ b/internal/httpcontroller/handlers/species.go @@ -0,0 +1,65 @@ +package handlers + +import ( + "fmt" + "net/http" + + "github.com/labstack/echo/v4" + "github.com/tphakala/birdnet-go/internal/conf" +) + +// IgnoreSpecies adds or removes a species from the excluded species list +func (h *Handlers) IgnoreSpecies(c echo.Context) error { + commonName := c.QueryParam("common_name") + if commonName == "" { + h.SSE.SendNotification(Notification{ + Message: "Missing species name", + Type: "error", + }) + return h.NewHandlerError(fmt.Errorf("missing species name"), "Missing species name", http.StatusBadRequest) + } + + // Get settings instance + settings := conf.Setting() + + // Check if species is already in the excluded list + isExcluded := false + for _, s := range settings.Realtime.Species.Exclude { + if s == commonName { + isExcluded = true + break + } + } + + if isExcluded { + // Remove from excluded list + newExcludeList := make([]string, 0) + for _, s := range settings.Realtime.Species.Exclude { + if s != commonName { + newExcludeList = append(newExcludeList, s) + } + } + settings.Realtime.Species.Exclude = newExcludeList + } else { + // Add to excluded list + settings.Realtime.Species.Exclude = append(settings.Realtime.Species.Exclude, commonName) + } + + // Save the settings + if err := conf.SaveSettings(); err != nil { + h.SSE.SendNotification(Notification{ + Message: fmt.Sprintf("Failed to save settings: %v", err), + Type: "error", + }) + return h.NewHandlerError(err, "Failed to save settings", http.StatusInternalServerError) + } + + // Send success notification + message := fmt.Sprintf("%s %s excluded species list", commonName, map[bool]string{true: "removed from", false: "added to"}[isExcluded]) + h.SSE.SendNotification(Notification{ + Message: message, + Type: "success", + }) + + return c.NoContent(http.StatusOK) +} diff --git a/internal/httpcontroller/handlers/weather.go b/internal/httpcontroller/handlers/weather.go index 86957df0..42e5f2a8 100644 --- a/internal/httpcontroller/handlers/weather.go +++ b/internal/httpcontroller/handlers/weather.go @@ -3,8 +3,10 @@ package handlers import ( "html/template" + "math" "time" + "github.com/tphakala/birdnet-go/internal/datastore" "github.com/tphakala/birdnet-go/internal/suncalc" "github.com/tphakala/birdnet-go/internal/weather" ) @@ -48,3 +50,55 @@ func (h *Handlers) GetWeatherDescriptionFunc() func(weatherCode string) string { return weather.GetIconDescription(iconCode) } } + +// getSunEvents calculates sun events for a given date +func (h *Handlers) getSunEvents(date string, loc *time.Location) (suncalc.SunEventTimes, error) { + // Parse the input date string into a time.Time object using the provided location + dateTime, err := time.ParseInLocation("2006-01-02", date, loc) + if err != nil { + // If parsing fails, return an empty SunEventTimes and the error + return suncalc.SunEventTimes{}, err + } + + // Attempt to get sun event times using the SunCalc + sunEvents, err := h.SunCalc.GetSunEventTimes(dateTime) + if err != nil { + // If sun events are not available, use default values + return suncalc.SunEventTimes{ + CivilDawn: dateTime.Add(5 * time.Hour), // Set civil dawn to 5:00 AM + Sunrise: dateTime.Add(6 * time.Hour), // Set sunrise to 6:00 AM + Sunset: dateTime.Add(18 * time.Hour), // Set sunset to 6:00 PM + CivilDusk: dateTime.Add(19 * time.Hour), // Set civil dusk to 7:00 PM + }, nil + } + + // Return the calculated sun events + return sunEvents, nil +} + +// findClosestWeather finds the closest hourly weather data to the given time +func findClosestWeather(noteTime time.Time, hourlyWeather []datastore.HourlyWeather) *datastore.HourlyWeather { + // If there's no weather data, return nil + if len(hourlyWeather) == 0 { + return nil + } + + // Initialize variables to track the closest weather data + var closestWeather *datastore.HourlyWeather + minDiff := time.Duration(math.MaxInt64) + + // Iterate through all hourly weather data + for i := range hourlyWeather { + // Calculate the absolute time difference between the note time and weather time + diff := noteTime.Sub(hourlyWeather[i].Time).Abs() + + // If this difference is smaller than the current minimum, update the closest weather + if diff < minDiff { + minDiff = diff + closestWeather = &hourlyWeather[i] + } + } + + // Return the weather data closest to the note time + return closestWeather +} diff --git a/internal/httpcontroller/middleware.go b/internal/httpcontroller/middleware.go index b560b2e4..b57e4b18 100644 --- a/internal/httpcontroller/middleware.go +++ b/internal/httpcontroller/middleware.go @@ -28,6 +28,11 @@ func (s *Server) configureMiddleware() { func (s *Server) CacheControlMiddleware() echo.MiddlewareFunc { return func(next echo.HandlerFunc) echo.HandlerFunc { return func(c echo.Context) error { + // Skip cache control for HTMX requests + if c.Request().Header.Get("HX-Request") != "" { + return next(c) + } + path := c.Request().URL.Path s.Debug("CacheControlMiddleware: Processing request for path: %s", path) @@ -73,10 +78,16 @@ func (s *Server) CacheControlMiddleware() echo.MiddlewareFunc { func VaryHeaderMiddleware() echo.MiddlewareFunc { return func(next echo.HandlerFunc) echo.HandlerFunc { return func(c echo.Context) error { + // Always set Vary header for HTMX requests + c.Response().Header().Set("Vary", "HX-Request") + + // Ensure HTMX headers are preserved if c.Request().Header.Get("HX-Request") != "" { - c.Response().Header().Set("Vary", "HX-Request") + c.Response().Header().Set("Cache-Control", "no-store") } - return next(c) + + err := next(c) + return err } } } diff --git a/internal/httpcontroller/routes.go b/internal/httpcontroller/routes.go index a49a386f..735852b3 100644 --- a/internal/httpcontroller/routes.go +++ b/internal/httpcontroller/routes.go @@ -116,10 +116,18 @@ func (s *Server) initRoutes() { // Special routes s.Echo.GET("/sse", s.Handlers.SSE.ServeSSE) s.Echo.GET("/audio-level", s.Handlers.WithErrorHandling(s.Handlers.AudioLevelSSE)) - s.Echo.DELETE("/note", h.WithErrorHandling(h.DeleteNote)) s.Echo.POST("/settings/save", h.WithErrorHandling(h.SaveSettings), s.AuthMiddleware) s.Echo.GET("/settings/audio/get", h.WithErrorHandling(h.GetAudioDevices), s.AuthMiddleware) + // Add DELETE method for detection deletion + s.Echo.DELETE("/detections/delete", h.WithErrorHandling(h.DeleteDetection)) + + // Add POST method for ignoring species + s.Echo.POST("/detections/ignore", h.WithErrorHandling(h.IgnoreSpecies)) + + // Add POST method for reviewing detections + s.Echo.POST("/detections/review", h.WithErrorHandling(h.ReviewDetection)) + // Setup Error handler s.Echo.HTTPErrorHandler = func(err error, c echo.Context) { if handleErr := s.Handlers.HandleError(err, c); handleErr != nil { diff --git a/internal/httpcontroller/template_functions.go b/internal/httpcontroller/template_functions.go index 2e02b4e4..8759d452 100644 --- a/internal/httpcontroller/template_functions.go +++ b/internal/httpcontroller/template_functions.go @@ -54,6 +54,15 @@ func (s *Server) GetTemplateFunctions() template.FuncMap { "weatherDescription": s.Handlers.GetWeatherDescriptionFunc(), "getAllSpecies": s.GetAllSpecies, "getIncludedSpecies": s.GetIncludedSpecies, + "isSpeciesExcluded": func(commonName string) bool { + settings := conf.Setting() + for _, s := range settings.Realtime.Species.Exclude { + if s == commonName { + return true + } + } + return false + }, } } diff --git a/views/dashboard.html b/views/dashboard.html index 37d39018..d87641e2 100644 --- a/views/dashboard.html +++ b/views/dashboard.html @@ -47,9 +47,11 @@
Recent Detections - - @@ -58,9 +60,10 @@
- -
- + +
diff --git a/views/elements/actionMenu.html b/views/elements/actionMenu.html new file mode 100644 index 00000000..62b8b239 --- /dev/null +++ b/views/elements/actionMenu.html @@ -0,0 +1,102 @@ +{{define "actionMenu"}} + +{{end}} \ No newline at end of file diff --git a/views/elements/confirmModal.html b/views/elements/confirmModal.html new file mode 100644 index 00000000..2b2b5a4a --- /dev/null +++ b/views/elements/confirmModal.html @@ -0,0 +1,17 @@ +{{define "confirmModal"}} + + + + +{{end}} \ No newline at end of file diff --git a/views/elements/reviewModal.html b/views/elements/reviewModal.html new file mode 100644 index 00000000..c5de4d87 --- /dev/null +++ b/views/elements/reviewModal.html @@ -0,0 +1,99 @@ +{{define "reviewModal"}} + + + + +{{end}} \ No newline at end of file diff --git a/views/fragments/detectionDetails.html b/views/fragments/detectionDetails.html index 4f03de35..5b87e017 100644 --- a/views/fragments/detectionDetails.html +++ b/views/fragments/detectionDetails.html @@ -1,65 +1,69 @@ {{define "detectionDetails"}} - -
-
-
-

- Detection Details for {{.Note.CommonName}} on {{.Note.Date}} at {{.Note.Time}} + +
+
+ +
+

+ {{.Note.CommonName}} + + on {{.Note.Date}} at {{.Note.Time}} +

- -
- Confidence Level: -
- {{confidence .Note.Confidence}} -
-
+ +
+ Confidence Level +
+ {{confidence .Note.Confidence}} +
+
- -
+ +
Spectrogram + class="w-full h-auto rounded-lg shadow-sm">
+ style="left: 0; transition: left 0.1s linear; opacity: 0;">
- -
- -
-
+ class="absolute bottom-0 left-0 right-0 bg-black bg-opacity-25 p-3 rounded-b-md transition-opacity duration-300 group-hover:opacity-100 sm:block"> + +
+ +
+
+
+ 0:00 + + + + +
- 0:00 - - - - - -
-
-
+
{{end}} diff --git a/views/fragments/listDetections.html b/views/fragments/listDetections.html index ceb1b1ed..d94a2aaa 100644 --- a/views/fragments/listDetections.html +++ b/views/fragments/listDetections.html @@ -1,26 +1,33 @@ {{define "listDetections"}}
-
-
- - - {{if eq .QueryType "hourly"}} - {{if gt .Duration 1}} - Hourly Results from {{.Hour}}:00 to {{add .Hour .Duration}}:00 on {{.Date}} + +
+ +
+
+ + + {{if eq .QueryType "hourly"}} + {{if gt .Duration 1}} + Hourly Results from {{.Hour}}:00 to {{add .Hour .Duration}}:00 on {{.Date}} + {{else}} + Hourly Results for {{.Hour}}:00 on {{.Date}} + {{end}} + {{else if eq .QueryType "species"}} + Results for {{.Species}} on {{.Date}} + {{else if eq .QueryType "search"}} + Search Results for "{{.Search}}" {{else}} - Hourly Results for {{.Hour}}:00 on {{.Date}} + All Detections for {{.Date}} {{end}} - {{else if eq .QueryType "species"}} - Results for {{.Species}} on {{.Date}} - {{else if eq .QueryType "search"}} - Search Results for "{{.Search}}" - {{else}} - All Detections for {{.Date}} - {{end}} - + +
-
@@ -63,6 +70,9 @@ + + + @@ -103,10 +113,19 @@ {{if ne $.QueryType "species"}} {{end}} @@ -128,11 +147,20 @@ @@ -201,6 +229,11 @@ + + + {{end}} @@ -215,7 +248,7 @@ {{if gt .CurrentPage 1}} {{else}} @@ -226,7 +259,7 @@ {{if lt .CurrentPage .TotalPages}} {{else}} @@ -243,5 +276,7 @@
Recording
- - {{.CommonName}} - +
+ + {{.CommonName}} + + {{if .Verified}} + {{if eq .Verified "correct"}} + + {{else if eq .Verified "false_positive"}} + + {{end}} + {{end}} +
-
- - {{confidence .Confidence}} - +
+ + {{if .Verified}} + {{if eq .Verified "correct"}} +
+ {{else if eq .Verified "false_positive"}} +
+ {{end}} + {{end}}
+
+ {{end}} \ No newline at end of file diff --git a/views/fragments/recentDetections.html b/views/fragments/recentDetections.html index 47b2a501..4482ca3a 100644 --- a/views/fragments/recentDetections.html +++ b/views/fragments/recentDetections.html @@ -1,204 +1,215 @@ {{define "recentDetections"}} - - - - - - - {{if .DashboardSettings.Thumbnails.Recent}} - - {{end}} - - - - - - {{range .Notes}} - - - - - - - - - - - - {{if $.DashboardSettings.Thumbnails.Recent}} - + + + + + {{end}} + + - - {{if $.DashboardSettings.Thumbnails.Recent}} -
+ +
+ {{range .Notes}} + - - - -
- - Spectrogram - - -
- - -
- -
- -
-
+ +
+
+
+ {{confidence .Confidence}} +
+ {{if .Verified}} + {{if eq .Verified "correct"}} +
+ {{else if eq .Verified "false_positive"}} +
+ {{end}} + {{end}}
- 0:00 - - - - -
+ + + +
+ + Spectrogram + + +
+ + +
+ +
+ +
+
+
+ 0:00 + + + + + +
+
+ +
+ {{end}}
- {{end}} -
{{end}} \ No newline at end of file diff --git a/views/index.html b/views/index.html index f4c340fc..cb49387a 100644 --- a/views/index.html +++ b/views/index.html @@ -52,10 +52,12 @@ hx-get="{{.PreloadFragment}}" hx-target="#loginModal" hx-on::after-request="loginModal.showModal()" - {{else if .PreloadFragment}} hx-trigger="load" + {{else if .PreloadFragment}} + hx-trigger="load" hx-get="{{.PreloadFragment}}" hx-target="this" {{end}}> + {{ RenderContent . }}
@@ -67,6 +69,16 @@ + + +
+
+
+ Loading... +
+
+