Date: Sun, 1 Dec 2024 20:34:39 +0100
Subject: [PATCH 02/51] Reset z-index on leaflet map
---
app/views/trips/_trip.html.erb | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/app/views/trips/_trip.html.erb b/app/views/trips/_trip.html.erb
index fd43c6cc..e0b14ba8 100644
--- a/app/views/trips/_trip.html.erb
+++ b/app/views/trips/_trip.html.erb
@@ -10,7 +10,7 @@
Date: Mon, 2 Dec 2024 10:23:08 +0100
Subject: [PATCH 03/51] build assets
---
app/assets/builds/tailwind.css | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/app/assets/builds/tailwind.css b/app/assets/builds/tailwind.css
index ab1ff198..e27405e3 100644
--- a/app/assets/builds/tailwind.css
+++ b/app/assets/builds/tailwind.css
@@ -1,6 +1,6 @@
-*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.14 | MIT License | https://tailwindcss.com*/*,:after,:before{border:0 solid #e5e7eb;box-sizing:border-box}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;font-family:Inter var,ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-tap-highlight-color:transparent}body{line-height:inherit;margin:0}hr{border-top-width:1px;color:inherit;height:0}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-size:1em;font-variation-settings:normal}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{border-collapse:collapse;border-color:inherit;text-indent:0}button,input,optgroup,select,textarea{color:inherit;font-family:inherit;font-feature-settings:inherit;font-size:100%;font-variation-settings:inherit;font-weight:inherit;letter-spacing:inherit;line-height:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{color:#9ca3af;opacity:1}input::placeholder,textarea::placeholder{color:#9ca3af;opacity:1}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{height:auto;max-width:100%}[hidden]:where(:not([hidden=until-found])){display:none}:root,[data-theme]{background-color:var(--fallback-b1,oklch(var(--b1)/1));color:var(--fallback-bc,oklch(var(--bc)/1))}@supports not (color:oklch(0 0 0)){:root{color-scheme:light;--fallback-p:#491eff;--fallback-pc:#d4dbff;--fallback-s:#ff41c7;--fallback-sc:#fff9fc;--fallback-a:#00cfbd;--fallback-ac:#00100d;--fallback-n:#2b3440;--fallback-nc:#d7dde4;--fallback-b1:#fff;--fallback-b2:#e5e6e6;--fallback-b3:#e5e6e6;--fallback-bc:#1f2937;--fallback-in:#00b3f0;--fallback-inc:#000;--fallback-su:#00ca92;--fallback-suc:#000;--fallback-wa:#ffc22d;--fallback-wac:#000;--fallback-er:#ff6f70;--fallback-erc:#000}@media (prefers-color-scheme:dark){:root{color-scheme:dark;--fallback-p:#7582ff;--fallback-pc:#050617;--fallback-s:#ff71cf;--fallback-sc:#190211;--fallback-a:#00c7b5;--fallback-ac:#000e0c;--fallback-n:#2a323c;--fallback-nc:#a6adbb;--fallback-b1:#1d232a;--fallback-b2:#191e24;--fallback-b3:#15191e;--fallback-bc:#a6adbb;--fallback-in:#00b3f0;--fallback-inc:#000;--fallback-su:#00ca92;--fallback-suc:#000;--fallback-wa:#ffc22d;--fallback-wac:#000;--fallback-er:#ff6f70;--fallback-erc:#000}}}html{-webkit-tap-highlight-color:transparent}:root{color-scheme:light;--in:0.7206 0.191 231.6;--su:64.8% 0.150 160;--wa:0.8471 0.199 83.87;--er:0.7176 0.221 22.18;--pc:0.89824 0.06192 275.75;--ac:0.15352 0.0368 183.61;--inc:0 0 0;--suc:0 0 0;--wac:0 0 0;--erc:0 0 0;--rounded-box:1rem;--rounded-btn:0.5rem;--rounded-badge:1.9rem;--animation-btn:0.25s;--animation-input:.2s;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--tab-radius:0.5rem;--p:0.4912 0.3096 275.75;--s:0.6971 0.329 342.55;--sc:0.9871 0.0106 342.55;--a:0.7676 0.184 183.61;--n:0.321785 0.02476 255.701624;--nc:0.894994 0.011585 252.096176;--b1:1 0 0;--b2:0.961151 0 0;--b3:0.924169 0.00108 197.137559;--bc:0.278078 0.029596 256.847952}@media (prefers-color-scheme:dark){:root{color-scheme:dark;--in:0.7206 0.191 231.6;--su:64.8% 0.150 160;--wa:0.8471 0.199 83.87;--er:0.7176 0.221 22.18;--pc:0.13138 0.0392 275.75;--sc:0.1496 0.052 342.55;--ac:0.14902 0.0334 183.61;--inc:0 0 0;--suc:0 0 0;--wac:0 0 0;--erc:0 0 0;--rounded-box:1rem;--rounded-btn:0.5rem;--rounded-badge:1.9rem;--animation-btn:0.25s;--animation-input:.2s;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--tab-radius:0.5rem;--p:0.6569 0.196 275.75;--s:0.748 0.26 342.55;--a:0.7451 0.167 183.61;--n:0.313815 0.021108 254.139175;--nc:0.746477 0.0216 264.435964;--b1:0.253267 0.015896 252.417568;--b2:0.232607 0.013807 253.100675;--b3:0.211484 0.01165 254.087939;--bc:0.746477 0.0216 264.435964}}[data-theme=light]{color-scheme:light;--in:0.7206 0.191 231.6;--su:64.8% 0.150 160;--wa:0.8471 0.199 83.87;--er:0.7176 0.221 22.18;--pc:0.89824 0.06192 275.75;--ac:0.15352 0.0368 183.61;--inc:0 0 0;--suc:0 0 0;--wac:0 0 0;--erc:0 0 0;--rounded-box:1rem;--rounded-btn:0.5rem;--rounded-badge:1.9rem;--animation-btn:0.25s;--animation-input:.2s;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--tab-radius:0.5rem;--p:0.4912 0.3096 275.75;--s:0.6971 0.329 342.55;--sc:0.9871 0.0106 342.55;--a:0.7676 0.184 183.61;--n:0.321785 0.02476 255.701624;--nc:0.894994 0.011585 252.096176;--b1:1 0 0;--b2:0.961151 0 0;--b3:0.924169 0.00108 197.137559;--bc:0.278078 0.029596 256.847952}[data-theme=dark]{color-scheme:dark;--in:0.7206 0.191 231.6;--su:64.8% 0.150 160;--wa:0.8471 0.199 83.87;--er:0.7176 0.221 22.18;--pc:0.13138 0.0392 275.75;--sc:0.1496 0.052 342.55;--ac:0.14902 0.0334 183.61;--inc:0 0 0;--suc:0 0 0;--wac:0 0 0;--erc:0 0 0;--rounded-box:1rem;--rounded-btn:0.5rem;--rounded-badge:1.9rem;--animation-btn:0.25s;--animation-input:.2s;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--tab-radius:0.5rem;--p:0.6569 0.196 275.75;--s:0.748 0.26 342.55;--a:0.7451 0.167 183.61;--n:0.313815 0.021108 254.139175;--nc:0.746477 0.0216 264.435964;--b1:0.253267 0.015896 252.417568;--b2:0.232607 0.013807 253.100675;--b3:0.211484 0.01165 254.087939;--bc:0.746477 0.0216 264.435964}[multiple],[type=date],[type=datetime-local],[type=email],[type=month],[type=number],[type=password],[type=search],[type=tel],[type=text],[type=time],[type=url],[type=week],input:where(:not([type])),select,textarea{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:#fff;border-color:#6b7280;border-radius:0;border-width:1px;font-size:1rem;line-height:1.5rem;padding:.5rem .75rem;--tw-shadow:0 0 #0000}[multiple]:focus,[type=date]:focus,[type=datetime-local]:focus,[type=email]:focus,[type=month]:focus,[type=number]:focus,[type=password]:focus,[type=search]:focus,[type=tel]:focus,[type=text]:focus,[type=time]:focus,[type=url]:focus,[type=week]:focus,input:where(:not([type])):focus,select:focus,textarea:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);border-color:#2563eb;box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}input::-moz-placeholder,textarea::-moz-placeholder{color:#6b7280;opacity:1}input::placeholder,textarea::placeholder{color:#6b7280;opacity:1}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-date-and-time-value{min-height:1.5em;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-meridiem-field,::-webkit-datetime-edit-millisecond-field,::-webkit-datetime-edit-minute-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-second-field,::-webkit-datetime-edit-year-field{padding-bottom:0;padding-top:0}select{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='m6 8 4 4 4-4'/%3E%3C/svg%3E");background-position:right .5rem center;background-repeat:no-repeat;background-size:1.5em 1.5em;padding-right:2.5rem;-webkit-print-color-adjust:exact;print-color-adjust:exact}[multiple],[size]:where(select:not([size="1"])){background-image:none;background-position:0 0;background-repeat:unset;background-size:initial;padding-right:.75rem;-webkit-print-color-adjust:unset;print-color-adjust:unset}[type=checkbox],[type=radio]{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:#fff;background-origin:border-box;border-color:#6b7280;border-width:1px;color:#2563eb;display:inline-block;flex-shrink:0;height:1rem;padding:0;-webkit-print-color-adjust:exact;print-color-adjust:exact;-webkit-user-select:none;-moz-user-select:none;user-select:none;vertical-align:middle;width:1rem;--tw-shadow:0 0 #0000}[type=checkbox]{border-radius:0}[type=radio]{border-radius:100%}[type=checkbox]:focus,[type=radio]:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:2px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}[type=checkbox]:checked,[type=radio]:checked{background-color:currentColor;background-position:50%;background-repeat:no-repeat;background-size:100% 100%;border-color:transparent}[type=checkbox]:checked{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 16 16'%3E%3Cpath d='M12.207 4.793a1 1 0 0 1 0 1.414l-5 5a1 1 0 0 1-1.414 0l-2-2a1 1 0 0 1 1.414-1.414L6.5 9.086l4.293-4.293a1 1 0 0 1 1.414 0'/%3E%3C/svg%3E")}@media (forced-colors:active) {[type=checkbox]:checked{-webkit-appearance:auto;-moz-appearance:auto;appearance:auto}}[type=radio]:checked{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 16 16'%3E%3Ccircle cx='8' cy='8' r='3'/%3E%3C/svg%3E")}@media (forced-colors:active) {[type=radio]:checked{-webkit-appearance:auto;-moz-appearance:auto;appearance:auto}}[type=checkbox]:checked:focus,[type=checkbox]:checked:hover,[type=radio]:checked:focus,[type=radio]:checked:hover{background-color:currentColor;border-color:transparent}[type=checkbox]:indeterminate{background-color:currentColor;background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 16 16'%3E%3Cpath stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M4 8h8'/%3E%3C/svg%3E");background-position:50%;background-repeat:no-repeat;background-size:100% 100%;border-color:transparent}@media (forced-colors:active) {[type=checkbox]:indeterminate{-webkit-appearance:auto;-moz-appearance:auto;appearance:auto}}[type=checkbox]:indeterminate:focus,[type=checkbox]:indeterminate:hover{background-color:currentColor;border-color:transparent}[type=file]{background:unset;border-color:inherit;border-radius:0;border-width:0;font-size:unset;line-height:inherit;padding:0}[type=file]:focus{outline:1px solid ButtonText;outline:1px auto -webkit-focus-ring-color}.container{width:100%}@media (min-width:640px){.container{max-width:640px}}@media (min-width:768px){.container{max-width:768px}}@media (min-width:1024px){.container{max-width:1024px}}@media (min-width:1280px){.container{max-width:1280px}}@media (min-width:1536px){.container{max-width:1536px}}.alert{align-content:flex-start;align-items:center;border-radius:var(--rounded-box,1rem);border-width:1px;display:grid;gap:1rem;grid-auto-flow:row;justify-items:center;text-align:center;width:100%;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)));padding:1rem;--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--alert-bg:var(--fallback-b2,oklch(var(--b2)/1));--alert-bg-mix:var(--fallback-b1,oklch(var(--b1)/1));background-color:var(--alert-bg)}@media (min-width:640px){.alert{grid-auto-flow:column;grid-template-columns:auto minmax(auto,1fr);justify-items:start;text-align:start}}.avatar.placeholder>div{align-items:center;display:flex;justify-content:center}.badge{align-items:center;border-radius:var(--rounded-badge,1.9rem);border-width:1px;display:inline-flex;font-size:.875rem;height:1.25rem;justify-content:center;line-height:1.25rem;padding-left:.563rem;padding-right:.563rem;transition-duration:.2s;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(.4,0,.2,1);transition-timing-function:cubic-bezier(0,0,.2,1);width:-moz-fit-content;width:fit-content;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}@media (hover:hover){.label a:hover{--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}.menu li>:not(ul,.menu-title,details,.btn).active,.menu li>:not(ul,.menu-title,details,.btn):active,.menu li>details>summary:active{--tw-bg-opacity:1;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-text-opacity:1;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}.tabs-boxed .tab-active:not(.tab-disabled):not([disabled]):hover,.tabs-boxed :is(input:checked):hover{--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}.table tr.hover:hover,.table tr.hover:nth-child(2n):hover{--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)))}.table-zebra tr.hover:hover,.table-zebra tr.hover:nth-child(2n):hover{--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity)))}}.btn{align-items:center;animation:button-pop var(--animation-btn,.25s) ease-out;border-color:transparent;border-color:oklch(var(--btn-color,var(--b2))/var(--tw-border-opacity));border-radius:var(--rounded-btn,.5rem);border-width:var(--border-btn,1px);cursor:pointer;display:inline-flex;flex-shrink:0;flex-wrap:wrap;font-size:.875rem;font-weight:600;gap:.5rem;height:3rem;justify-content:center;line-height:1em;min-height:3rem;padding-left:1rem;padding-right:1rem;text-align:center;text-decoration-line:none;transition-duration:.2s;transition-property:color,background-color,border-color,opacity,box-shadow,transform;transition-timing-function:cubic-bezier(0,0,.2,1);-webkit-user-select:none;-moz-user-select:none;user-select:none;--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-shadow:0 1px 2px 0 rgba(0,0,0,.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color);background-color:oklch(var(--btn-color,var(--b2))/var(--tw-bg-opacity));box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);outline-color:var(--fallback-bc,oklch(var(--bc)/1));--tw-bg-opacity:1;--tw-border-opacity:1}.btn-disabled,.btn:disabled,.btn[disabled]{pointer-events:none}:where(.btn:is(input[type=checkbox])),:where(.btn:is(input[type=radio])){-webkit-appearance:none;-moz-appearance:none;appearance:none;width:auto}.btn:is(input[type=checkbox]):after,.btn:is(input[type=radio]):after{--tw-content:attr(aria-label);content:var(--tw-content)}.card{border-radius:var(--rounded-box,1rem);display:flex;flex-direction:column;position:relative}.card:focus{outline:2px solid transparent;outline-offset:2px}.card-body{display:flex;flex:1 1 auto;flex-direction:column;gap:.5rem;padding:var(--padding-card,2rem)}.card-body :where(p){flex-grow:1}.card-actions{align-items:flex-start;display:flex;flex-wrap:wrap;gap:.5rem}.card figure{align-items:center;display:flex;justify-content:center}.card.image-full{display:grid}.card.image-full:before{border-radius:var(--rounded-box,1rem);content:"";position:relative;z-index:10;--tw-bg-opacity:1;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));opacity:.75}.card.image-full:before,.card.image-full>*{grid-column-start:1;grid-row-start:1}.card.image-full>figure img{height:100%;-o-object-fit:cover;object-fit:cover}.card.image-full>.card-body{position:relative;z-index:20;--tw-text-opacity:1;color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity)))}.checkbox{flex-shrink:0;--chkbg:var(--fallback-bc,oklch(var(--bc)/1));--chkfg:var(--fallback-b1,oklch(var(--b1)/1));-webkit-appearance:none;-moz-appearance:none;appearance:none;border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));border-radius:var(--rounded-btn,.5rem);border-width:1px;cursor:pointer;height:1.5rem;width:1.5rem;--tw-border-opacity:0.2}.divider{align-items:center;align-self:stretch;display:flex;flex-direction:row;height:1rem;margin-bottom:1rem;margin-top:1rem;white-space:nowrap}.divider:after,.divider:before{flex-grow:1;height:.125rem;width:100%;--tw-content:"";background-color:var(--fallback-bc,oklch(var(--bc)/.1));content:var(--tw-content)}.dropdown{display:inline-block;position:relative}.dropdown>:not(summary):focus{outline:2px solid transparent;outline-offset:2px}.dropdown .dropdown-content{position:absolute}.dropdown:is(:not(details)) .dropdown-content{opacity:0;transform-origin:top;visibility:hidden;--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-duration:.2s;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(.4,0,.2,1);transition-timing-function:cubic-bezier(0,0,.2,1)}.dropdown-end .dropdown-content{inset-inline-end:0}.dropdown-left .dropdown-content{bottom:auto;inset-inline-end:100%;top:0;transform-origin:right}.dropdown-right .dropdown-content{bottom:auto;inset-inline-start:100%;top:0;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-left .dropdown-content,.dropdown-end.dropdown-right .dropdown-content{bottom:0;top:auto}.dropdown.dropdown-open .dropdown-content,.dropdown:focus-within .dropdown-content,.dropdown:not(.dropdown-hover):focus .dropdown-content{opacity:1;visibility:visible}@media (hover:hover){.dropdown.dropdown-hover:hover .dropdown-content{opacity:1;visibility:visible}.btn:hover{--tw-border-opacity:1;border-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity)))}@supports (color:color-mix(in oklab,black,black)){.btn:hover{background-color:color-mix(in oklab,oklch(var(--btn-color,var(--b2))/var(--tw-bg-opacity,1)) 90%,#000);border-color:color-mix(in oklab,oklch(var(--btn-color,var(--b2))/var(--tw-border-opacity,1)) 90%,#000)}}@supports not (color:oklch(0 0 0)){.btn:hover{background-color:var(--btn-color,var(--fallback-b2));border-color:var(--btn-color,var(--fallback-b2))}}.btn.glass:hover{--glass-opacity:25%;--glass-border-opacity:15%}.btn-ghost:hover{border-color:transparent}@supports (color:oklch(0 0 0)){.btn-ghost:hover{background-color:var(--fallback-bc,oklch(var(--bc)/.2))}}.btn-outline.btn-primary:hover{--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.btn-outline.btn-primary:hover{background-color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 90%,#000)}}.btn-outline.btn-success:hover{--tw-text-opacity:1;color:var(--fallback-suc,oklch(var(--suc)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.btn-outline.btn-success:hover{background-color:color-mix(in oklab,var(--fallback-su,oklch(var(--su)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-su,oklch(var(--su)/1)) 90%,#000)}}.btn-outline.btn-info:hover{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.btn-outline.btn-info:hover{background-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000)}}.btn-outline.btn-warning:hover{--tw-text-opacity:1;color:var(--fallback-wac,oklch(var(--wac)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.btn-outline.btn-warning:hover{background-color:color-mix(in oklab,var(--fallback-wa,oklch(var(--wa)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-wa,oklch(var(--wa)/1)) 90%,#000)}}.btn-outline.btn-error:hover{--tw-text-opacity:1;color:var(--fallback-erc,oklch(var(--erc)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.btn-outline.btn-error:hover{background-color:color-mix(in oklab,var(--fallback-er,oklch(var(--er)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-er,oklch(var(--er)/1)) 90%,#000)}}.btn-disabled:hover,.btn:disabled:hover,.btn[disabled]:hover{--tw-border-opacity:0;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-bg-opacity:0.2;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}@supports (color:color-mix(in oklab,black,black)){.btn:is(input[type=checkbox]:checked):hover,.btn:is(input[type=radio]:checked):hover{background-color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 90%,#000)}}.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;outline-offset:2px}@supports (color:oklch(0 0 0)){: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{background-color:var(--fallback-bc,oklch(var(--bc)/.1))}}.tab[disabled],.tab[disabled]:hover{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));cursor:not-allowed;--tw-text-opacity:0.2}}.dropdown:is(details) summary::-webkit-details-marker{display:none}.file-input{border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));border-radius:var(--rounded-btn,.5rem);border-width:1px;flex-shrink:1;font-size:1rem;height:3rem;line-height:2;line-height:1.5rem;overflow:hidden;padding-inline-end:1rem;--tw-border-opacity:0;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)))}.file-input::file-selector-button{align-items:center;border-style:solid;cursor:pointer;display:inline-flex;flex-shrink:0;flex-wrap:wrap;font-size:.875rem;height:100%;justify-content:center;line-height:1.25rem;line-height:1em;margin-inline-end:1rem;padding-left:1rem;padding-right:1rem;text-align:center;transition-duration:.2s;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(.4,0,.2,1);transition-timing-function:cubic-bezier(0,0,.2,1);-webkit-user-select:none;-moz-user-select:none;user-select:none;--tw-border-opacity:1;border-color:var(--fallback-n,oklch(var(--n)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));font-weight:600;text-transform:uppercase;--tw-text-opacity:1;animation:button-pop var(--animation-btn,.25s) ease-out;border-width:var(--border-btn,1px);color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity)));text-decoration-line:none}.footer{-moz-column-gap:1rem;column-gap:1rem;font-size:.875rem;grid-auto-flow:row;line-height:1.25rem;row-gap:2.5rem;width:100%}.footer,.footer>*{display:grid;place-items:start}.footer>*{gap:.5rem}.footer-center{text-align:center}.footer-center,.footer-center>*{place-items:center}@media (min-width:48rem){.footer{grid-auto-flow:column}.footer-center{grid-auto-flow:row dense}}.form-control{flex-direction:column}.form-control,.label{display:flex}.label{align-items:center;justify-content:space-between;padding:.5rem .25rem;-webkit-user-select:none;-moz-user-select:none;user-select:none}.hero{background-position:50%;background-size:cover;display:grid;place-items:center;width:100%}.hero>*{grid-column-start:1;grid-row-start:1}.hero-content{align-items:center;display:flex;gap:1rem;justify-content:center;max-width:80rem;padding:1rem;z-index:0}.input{-webkit-appearance:none;-moz-appearance:none;appearance:none;border-color:transparent;border-radius:var(--rounded-btn,.5rem);border-width:1px;flex-shrink:1;font-size:1rem;height:3rem;line-height:2;line-height:1.5rem;padding-left:1rem;padding-right:1rem;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)))}.input-md[type=number]::-webkit-inner-spin-button,.input[type=number]::-webkit-inner-spin-button{margin-bottom:-1rem;margin-top:-1rem;margin-inline-end:-1rem}.input-xs[type=number]::-webkit-inner-spin-button{margin-bottom:-.25rem;margin-top:-.25rem;margin-inline-end:0}.input-sm[type=number]::-webkit-inner-spin-button{margin-bottom:0;margin-top:0;margin-inline-end:0}.join{align-items:stretch;border-radius:var(--rounded-btn,.5rem);display:inline-flex}.join :where(.join-item){border-end-end-radius:0;border-end-start-radius:0;border-start-end-radius:0;border-start-start-radius:0}.join .join-item:not(:first-child):not(:last-child),.join :not(:first-child):not(:last-child) .join-item{border-end-end-radius:0;border-end-start-radius:0;border-start-end-radius:0;border-start-start-radius:0}.join .join-item:first-child:not(:last-child),.join :first-child:not(:last-child) .join-item{border-end-end-radius:0;border-start-end-radius:0}.join .dropdown .join-item:first-child:not(:last-child),.join :first-child:not(:last-child) .dropdown .join-item{border-end-end-radius:inherit;border-start-end-radius:inherit}.join :where(.join-item:first-child:not(:last-child)),.join :where(:first-child:not(:last-child) .join-item){border-end-start-radius:inherit;border-start-start-radius:inherit}.join .join-item:last-child:not(:first-child),.join :last-child:not(:first-child) .join-item{border-end-start-radius:0;border-start-start-radius:0}.join :where(.join-item:last-child:not(:first-child)),.join :where(:last-child:not(:first-child) .join-item){border-end-end-radius:inherit;border-start-end-radius:inherit}@supports not selector(:has(*)){:where(.join *){border-radius:inherit}}@supports selector(:has(*)){:where(.join :has(.join-item)){border-radius:inherit}}.link{cursor:pointer;text-decoration-line:underline}.menu{display:flex;flex-direction:column;flex-wrap:wrap;font-size:.875rem;line-height:1.25rem;padding:.5rem}.menu :where(li ul){margin-inline-start:1rem;padding-inline-start:.5rem;position:relative;white-space:nowrap}.menu :where(li:not(.menu-title)>:not(ul,details,.menu-title,.btn)),.menu :where(li:not(.menu-title)>details>summary:not(.menu-title)){align-content:flex-start;align-items:center;display:grid;gap:.5rem;grid-auto-columns:minmax(auto,max-content) auto max-content;grid-auto-flow:column;-webkit-user-select:none;-moz-user-select:none;user-select:none}.menu li.disabled{color:var(--fallback-bc,oklch(var(--bc)/.3));cursor:not-allowed;-webkit-user-select:none;-moz-user-select:none;user-select:none}.menu :where(li>.menu-dropdown:not(.menu-dropdown-show)){display:none}:where(.menu li){align-items:stretch;display:flex;flex-direction:column;flex-shrink:0;flex-wrap:wrap;position:relative}:where(.menu li) .badge{justify-self:end}.mockup-code{border-radius:var(--rounded-box,1rem);min-width:18rem;overflow:hidden;overflow-x:auto;position:relative;--tw-bg-opacity:1;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));padding-bottom:1.25rem;padding-top:1.25rem;--tw-text-opacity:1;color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity)));direction:ltr}.mockup-code pre[data-prefix]:before{content:attr(data-prefix);display:inline-block;opacity:.5;text-align:right;width:2rem}.modal{background-color:transparent;color:inherit;display:grid;height:100%;inset:0;justify-items:center;margin:0;max-height:none;max-width:none;opacity:0;overflow-y:hidden;overscroll-behavior:contain;padding:0;pointer-events:none;position:fixed;transition-duration:.2s;transition-property:transform,opacity,visibility;transition-timing-function:cubic-bezier(0,0,.2,1);width:100%;z-index:999}:where(.modal){align-items:center}.modal-box{grid-column-start:1;grid-row-start:1;max-height:calc(100vh - 5em);max-width:32rem;width:91.666667%;--tw-scale-x:.9;--tw-scale-y:.9;border-bottom-left-radius:var(--rounded-box,1rem);border-bottom-right-radius:var(--rounded-box,1rem);border-top-left-radius:var(--rounded-box,1rem);border-top-right-radius:var(--rounded-box,1rem);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));--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));box-shadow:0 25px 50px -12px rgba(0,0,0,.25);overflow-y:auto;overscroll-behavior:contain;padding:1.5rem;transition-duration:.2s;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(.4,0,.2,1);transition-timing-function:cubic-bezier(0,0,.2,1)}.modal-open,.modal-toggle:checked+.modal,.modal:target,.modal[open]{opacity:1;pointer-events:auto;visibility:visible}.modal-toggle{-webkit-appearance:none;-moz-appearance:none;appearance:none;height:0;opacity:0;position:fixed;width:0}:root:has(:is(.modal-open,.modal:target,.modal-toggle:checked+.modal,.modal[open])){overflow:hidden}.navbar{align-items:center;display:flex;min-height:4rem;padding:var(--navbar-padding,.5rem);width:100%}:where(.navbar>:not(script,style)){align-items:center;display:inline-flex}.navbar-start{justify-content:flex-start;width:50%}.navbar-center{flex-shrink:0}.navbar-end{justify-content:flex-end;width:50%}.progress{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:var(--fallback-bc,oklch(var(--bc)/.2));border-radius:var(--rounded-box,1rem);height:.5rem;overflow:hidden;position:relative;width:100%}.radio{flex-shrink:0;--chkbg:var(--bc);-webkit-appearance:none;border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));border-radius:9999px;border-width:1px;width:1.5rem;--tw-border-opacity:0.2}.radio,.range{-moz-appearance:none;appearance:none;cursor:pointer;height:1.5rem}.range{-webkit-appearance:none;width:100%;--range-shdw:var(--fallback-bc,oklch(var(--bc)/1));background-color:transparent;border-radius:var(--rounded-box,1rem);overflow:hidden}.range:focus{outline:none}.select{-webkit-appearance:none;-moz-appearance:none;appearance:none;border-color:transparent;border-radius:var(--rounded-btn,.5rem);border-width:1px;cursor:pointer;display:inline-flex;font-size:.875rem;height:3rem;line-height:1.25rem;line-height:2;min-height:3rem;padding-left:1rem;padding-right:2.5rem;-webkit-user-select:none;-moz-user-select:none;user-select:none;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));background-image:linear-gradient(45deg,transparent 50%,currentColor 0),linear-gradient(135deg,currentColor 50%,transparent 0);background-position:calc(100% - 20px) calc(1px + 50%),calc(100% - 16.1px) calc(1px + 50%);background-repeat:no-repeat;background-size:4px 4px,4px 4px}.select[multiple]{height:auto}.stats{border-radius:var(--rounded-box,1rem);display:inline-grid;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}:where(.stats){grid-auto-flow:column;overflow-x:auto}.stat{border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));-moz-column-gap:1rem;column-gap:1rem;display:inline-grid;grid-template-columns:repeat(1,1fr);width:100%;--tw-border-opacity:0.1;padding:1rem 1.5rem}.stat-title{color:var(--fallback-bc,oklch(var(--bc)/.6))}.stat-title,.stat-value{grid-column-start:1;white-space:nowrap}.stat-value{font-size:2.25rem;font-weight:800;line-height:2.5rem}.steps .step{display:grid;grid-template-columns:repeat(1,minmax(0,1fr));grid-template-columns:auto;grid-template-rows:repeat(2,minmax(0,1fr));grid-template-rows:40px 1fr;min-width:4rem;place-items:center;text-align:center}.tabs{align-items:flex-end;display:grid}.tabs-lifted:has(.tab-content[class*=" rounded-"]) .tab:first-child:not(.tab-active),.tabs-lifted:has(.tab-content[class^=rounded-]) .tab:first-child:not(.tab-active){border-bottom-color:transparent}.tab{align-items:center;-webkit-appearance:none;-moz-appearance:none;appearance:none;cursor:pointer;display:inline-flex;flex-wrap:wrap;font-size:.875rem;grid-row-start:1;height:2rem;justify-content:center;line-height:1.25rem;line-height:2;position:relative;text-align:center;-webkit-user-select:none;-moz-user-select:none;user-select:none;--tab-padding:1rem;--tw-text-opacity:0.5;--tab-color:var(--fallback-bc,oklch(var(--bc)/1));--tab-bg:var(--fallback-b1,oklch(var(--b1)/1));--tab-border-color:var(--fallback-b3,oklch(var(--b3)/1));color:var(--tab-color);padding-inline-end:var(--tab-padding,1rem);padding-inline-start:var(--tab-padding,1rem)}.tab:is(input[type=radio]){border-bottom-left-radius:0;border-bottom-right-radius:0;width:auto}.tab:is(input[type=radio]):after{--tw-content:attr(aria-label);content:var(--tw-content)}.tab:not(input):empty{cursor:default;grid-column-start:span 9999}.tab-content{border-color:transparent;border-width:var(--tab-border,0);display:none;grid-column-end:span 9999;grid-column-start:1;grid-row-start:2;margin-top:calc(var(--tab-border)*-1)}.tab-active+.tab-content:nth-child(2),:checked+.tab-content:nth-child(2){border-start-start-radius:0}.tab-active+.tab-content,input.tab:checked+.tab-content{display:block}.table{border-radius:var(--rounded-box,1rem);font-size:.875rem;line-height:1.25rem;position:relative;text-align:left;width:100%}.table :where(.table-pin-rows thead tr){position:sticky;top:0;z-index:1;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)))}.table :where(.table-pin-rows tfoot tr){bottom:0;position:sticky;z-index:1;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)))}.table :where(.table-pin-cols tr th){left:0;position:sticky;right:0;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)))}.timeline{display:flex;position:relative}:where(.timeline>li){align-items:center;display:grid;flex-shrink:0;grid-template-columns:var(--timeline-col-start,minmax(0,1fr)) auto var(
+*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.14 | MIT License | https://tailwindcss.com*/*,:after,:before{border:0 solid #e5e7eb;box-sizing:border-box}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;font-family:Inter var,ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-tap-highlight-color:transparent}body{line-height:inherit;margin:0}hr{border-top-width:1px;color:inherit;height:0}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-size:1em;font-variation-settings:normal}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{border-collapse:collapse;border-color:inherit;text-indent:0}button,input,optgroup,select,textarea{color:inherit;font-family:inherit;font-feature-settings:inherit;font-size:100%;font-variation-settings:inherit;font-weight:inherit;letter-spacing:inherit;line-height:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{color:#9ca3af;opacity:1}input::placeholder,textarea::placeholder{color:#9ca3af;opacity:1}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{height:auto;max-width:100%}[hidden]:where(:not([hidden=until-found])){display:none}:root,[data-theme]{background-color:var(--fallback-b1,oklch(var(--b1)/1));color:var(--fallback-bc,oklch(var(--bc)/1))}@supports not (color:oklch(0 0 0)){:root{color-scheme:light;--fallback-p:#491eff;--fallback-pc:#d4dbff;--fallback-s:#ff41c7;--fallback-sc:#fff9fc;--fallback-a:#00cfbd;--fallback-ac:#00100d;--fallback-n:#2b3440;--fallback-nc:#d7dde4;--fallback-b1:#fff;--fallback-b2:#e5e6e6;--fallback-b3:#e5e6e6;--fallback-bc:#1f2937;--fallback-in:#00b3f0;--fallback-inc:#000;--fallback-su:#00ca92;--fallback-suc:#000;--fallback-wa:#ffc22d;--fallback-wac:#000;--fallback-er:#ff6f70;--fallback-erc:#000}@media (prefers-color-scheme:dark){:root{color-scheme:dark;--fallback-p:#7582ff;--fallback-pc:#050617;--fallback-s:#ff71cf;--fallback-sc:#190211;--fallback-a:#00c7b5;--fallback-ac:#000e0c;--fallback-n:#2a323c;--fallback-nc:#a6adbb;--fallback-b1:#1d232a;--fallback-b2:#191e24;--fallback-b3:#15191e;--fallback-bc:#a6adbb;--fallback-in:#00b3f0;--fallback-inc:#000;--fallback-su:#00ca92;--fallback-suc:#000;--fallback-wa:#ffc22d;--fallback-wac:#000;--fallback-er:#ff6f70;--fallback-erc:#000}}}html{-webkit-tap-highlight-color:transparent}:root{color-scheme:light;--in:0.7206 0.191 231.6;--su:64.8% 0.150 160;--wa:0.8471 0.199 83.87;--er:0.7176 0.221 22.18;--pc:0.89824 0.06192 275.75;--ac:0.15352 0.0368 183.61;--inc:0 0 0;--suc:0 0 0;--wac:0 0 0;--erc:0 0 0;--rounded-box:1rem;--rounded-btn:0.5rem;--rounded-badge:1.9rem;--animation-btn:0.25s;--animation-input:.2s;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--tab-radius:0.5rem;--p:0.4912 0.3096 275.75;--s:0.6971 0.329 342.55;--sc:0.9871 0.0106 342.55;--a:0.7676 0.184 183.61;--n:0.321785 0.02476 255.701624;--nc:0.894994 0.011585 252.096176;--b1:1 0 0;--b2:0.961151 0 0;--b3:0.924169 0.00108 197.137559;--bc:0.278078 0.029596 256.847952}@media (prefers-color-scheme:dark){:root{color-scheme:dark;--in:0.7206 0.191 231.6;--su:64.8% 0.150 160;--wa:0.8471 0.199 83.87;--er:0.7176 0.221 22.18;--pc:0.13138 0.0392 275.75;--sc:0.1496 0.052 342.55;--ac:0.14902 0.0334 183.61;--inc:0 0 0;--suc:0 0 0;--wac:0 0 0;--erc:0 0 0;--rounded-box:1rem;--rounded-btn:0.5rem;--rounded-badge:1.9rem;--animation-btn:0.25s;--animation-input:.2s;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--tab-radius:0.5rem;--p:0.6569 0.196 275.75;--s:0.748 0.26 342.55;--a:0.7451 0.167 183.61;--n:0.313815 0.021108 254.139175;--nc:0.746477 0.0216 264.435964;--b1:0.253267 0.015896 252.417568;--b2:0.232607 0.013807 253.100675;--b3:0.211484 0.01165 254.087939;--bc:0.746477 0.0216 264.435964}}[data-theme=light]{color-scheme:light;--in:0.7206 0.191 231.6;--su:64.8% 0.150 160;--wa:0.8471 0.199 83.87;--er:0.7176 0.221 22.18;--pc:0.89824 0.06192 275.75;--ac:0.15352 0.0368 183.61;--inc:0 0 0;--suc:0 0 0;--wac:0 0 0;--erc:0 0 0;--rounded-box:1rem;--rounded-btn:0.5rem;--rounded-badge:1.9rem;--animation-btn:0.25s;--animation-input:.2s;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--tab-radius:0.5rem;--p:0.4912 0.3096 275.75;--s:0.6971 0.329 342.55;--sc:0.9871 0.0106 342.55;--a:0.7676 0.184 183.61;--n:0.321785 0.02476 255.701624;--nc:0.894994 0.011585 252.096176;--b1:1 0 0;--b2:0.961151 0 0;--b3:0.924169 0.00108 197.137559;--bc:0.278078 0.029596 256.847952}[data-theme=dark]{color-scheme:dark;--in:0.7206 0.191 231.6;--su:64.8% 0.150 160;--wa:0.8471 0.199 83.87;--er:0.7176 0.221 22.18;--pc:0.13138 0.0392 275.75;--sc:0.1496 0.052 342.55;--ac:0.14902 0.0334 183.61;--inc:0 0 0;--suc:0 0 0;--wac:0 0 0;--erc:0 0 0;--rounded-box:1rem;--rounded-btn:0.5rem;--rounded-badge:1.9rem;--animation-btn:0.25s;--animation-input:.2s;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--tab-radius:0.5rem;--p:0.6569 0.196 275.75;--s:0.748 0.26 342.55;--a:0.7451 0.167 183.61;--n:0.313815 0.021108 254.139175;--nc:0.746477 0.0216 264.435964;--b1:0.253267 0.015896 252.417568;--b2:0.232607 0.013807 253.100675;--b3:0.211484 0.01165 254.087939;--bc:0.746477 0.0216 264.435964}[multiple],[type=date],[type=datetime-local],[type=email],[type=month],[type=number],[type=password],[type=search],[type=tel],[type=text],[type=time],[type=url],[type=week],input:where(:not([type])),select,textarea{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:#fff;border-color:#6b7280;border-radius:0;border-width:1px;font-size:1rem;line-height:1.5rem;padding:.5rem .75rem;--tw-shadow:0 0 #0000}[multiple]:focus,[type=date]:focus,[type=datetime-local]:focus,[type=email]:focus,[type=month]:focus,[type=number]:focus,[type=password]:focus,[type=search]:focus,[type=tel]:focus,[type=text]:focus,[type=time]:focus,[type=url]:focus,[type=week]:focus,input:where(:not([type])):focus,select:focus,textarea:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);border-color:#2563eb;box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}input::-moz-placeholder,textarea::-moz-placeholder{color:#6b7280;opacity:1}input::placeholder,textarea::placeholder{color:#6b7280;opacity:1}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-date-and-time-value{min-height:1.5em;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-meridiem-field,::-webkit-datetime-edit-millisecond-field,::-webkit-datetime-edit-minute-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-second-field,::-webkit-datetime-edit-year-field{padding-bottom:0;padding-top:0}select{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='m6 8 4 4 4-4'/%3E%3C/svg%3E");background-position:right .5rem center;background-repeat:no-repeat;background-size:1.5em 1.5em;padding-right:2.5rem;-webkit-print-color-adjust:exact;print-color-adjust:exact}[multiple],[size]:where(select:not([size="1"])){background-image:none;background-position:0 0;background-repeat:unset;background-size:initial;padding-right:.75rem;-webkit-print-color-adjust:unset;print-color-adjust:unset}[type=checkbox],[type=radio]{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:#fff;background-origin:border-box;border-color:#6b7280;border-width:1px;color:#2563eb;display:inline-block;flex-shrink:0;height:1rem;padding:0;-webkit-print-color-adjust:exact;print-color-adjust:exact;-webkit-user-select:none;-moz-user-select:none;user-select:none;vertical-align:middle;width:1rem;--tw-shadow:0 0 #0000}[type=checkbox]{border-radius:0}[type=radio]{border-radius:100%}[type=checkbox]:focus,[type=radio]:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:2px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}[type=checkbox]:checked,[type=radio]:checked{background-color:currentColor;background-position:50%;background-repeat:no-repeat;background-size:100% 100%;border-color:transparent}[type=checkbox]:checked{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 16 16'%3E%3Cpath d='M12.207 4.793a1 1 0 0 1 0 1.414l-5 5a1 1 0 0 1-1.414 0l-2-2a1 1 0 0 1 1.414-1.414L6.5 9.086l4.293-4.293a1 1 0 0 1 1.414 0'/%3E%3C/svg%3E")}@media (forced-colors:active) {[type=checkbox]:checked{-webkit-appearance:auto;-moz-appearance:auto;appearance:auto}}[type=radio]:checked{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 16 16'%3E%3Ccircle cx='8' cy='8' r='3'/%3E%3C/svg%3E")}@media (forced-colors:active) {[type=radio]:checked{-webkit-appearance:auto;-moz-appearance:auto;appearance:auto}}[type=checkbox]:checked:focus,[type=checkbox]:checked:hover,[type=radio]:checked:focus,[type=radio]:checked:hover{background-color:currentColor;border-color:transparent}[type=checkbox]:indeterminate{background-color:currentColor;background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 16 16'%3E%3Cpath stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M4 8h8'/%3E%3C/svg%3E");background-position:50%;background-repeat:no-repeat;background-size:100% 100%;border-color:transparent}@media (forced-colors:active) {[type=checkbox]:indeterminate{-webkit-appearance:auto;-moz-appearance:auto;appearance:auto}}[type=checkbox]:indeterminate:focus,[type=checkbox]:indeterminate:hover{background-color:currentColor;border-color:transparent}[type=file]{background:unset;border-color:inherit;border-radius:0;border-width:0;font-size:unset;line-height:inherit;padding:0}[type=file]:focus{outline:1px solid ButtonText;outline:1px auto -webkit-focus-ring-color}.container{width:100%}@media (min-width:640px){.container{max-width:640px}}@media (min-width:768px){.container{max-width:768px}}@media (min-width:1024px){.container{max-width:1024px}}@media (min-width:1280px){.container{max-width:1280px}}@media (min-width:1536px){.container{max-width:1536px}}.alert{align-content:flex-start;align-items:center;border-radius:var(--rounded-box,1rem);border-width:1px;display:grid;gap:1rem;grid-auto-flow:row;justify-items:center;text-align:center;width:100%;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)));padding:1rem;--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--alert-bg:var(--fallback-b2,oklch(var(--b2)/1));--alert-bg-mix:var(--fallback-b1,oklch(var(--b1)/1));background-color:var(--alert-bg)}@media (min-width:640px){.alert{grid-auto-flow:column;grid-template-columns:auto minmax(auto,1fr);justify-items:start;text-align:start}}.avatar.placeholder>div{align-items:center;display:flex;justify-content:center}.badge{align-items:center;border-radius:var(--rounded-badge,1.9rem);border-width:1px;display:inline-flex;font-size:.875rem;height:1.25rem;justify-content:center;line-height:1.25rem;padding-left:.563rem;padding-right:.563rem;transition-duration:.2s;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(.4,0,.2,1);transition-timing-function:cubic-bezier(0,0,.2,1);width:-moz-fit-content;width:fit-content;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}@media (hover:hover){.label a:hover{--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}.menu li>:not(ul,.menu-title,details,.btn).active,.menu li>:not(ul,.menu-title,details,.btn):active,.menu li>details>summary:active{--tw-bg-opacity:1;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-text-opacity:1;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}.tabs-boxed .tab-active:not(.tab-disabled):not([disabled]):hover,.tabs-boxed :is(input:checked):hover{--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}.table tr.hover:hover,.table tr.hover:nth-child(2n):hover{--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)))}.table-zebra tr.hover:hover,.table-zebra tr.hover:nth-child(2n):hover{--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity)))}}.btn{align-items:center;animation:button-pop var(--animation-btn,.25s) ease-out;border-color:transparent;border-color:oklch(var(--btn-color,var(--b2))/var(--tw-border-opacity));border-radius:var(--rounded-btn,.5rem);border-width:var(--border-btn,1px);cursor:pointer;display:inline-flex;flex-shrink:0;flex-wrap:wrap;font-size:.875rem;font-weight:600;gap:.5rem;height:3rem;justify-content:center;line-height:1em;min-height:3rem;padding-left:1rem;padding-right:1rem;text-align:center;text-decoration-line:none;transition-duration:.2s;transition-property:color,background-color,border-color,opacity,box-shadow,transform;transition-timing-function:cubic-bezier(0,0,.2,1);-webkit-user-select:none;-moz-user-select:none;user-select:none;--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-shadow:0 1px 2px 0 rgba(0,0,0,.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color);background-color:oklch(var(--btn-color,var(--b2))/var(--tw-bg-opacity));box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);outline-color:var(--fallback-bc,oklch(var(--bc)/1));--tw-bg-opacity:1;--tw-border-opacity:1}.btn-disabled,.btn:disabled,.btn[disabled]{pointer-events:none}:where(.btn:is(input[type=checkbox])),:where(.btn:is(input[type=radio])){-webkit-appearance:none;-moz-appearance:none;appearance:none;width:auto}.btn:is(input[type=checkbox]):after,.btn:is(input[type=radio]):after{--tw-content:attr(aria-label);content:var(--tw-content)}.card{border-radius:var(--rounded-box,1rem);display:flex;flex-direction:column;position:relative}.card:focus{outline:2px solid transparent;outline-offset:2px}.card-body{display:flex;flex:1 1 auto;flex-direction:column;gap:.5rem;padding:var(--padding-card,2rem)}.card-body :where(p){flex-grow:1}.card-actions{align-items:flex-start;display:flex;flex-wrap:wrap;gap:.5rem}.card figure{align-items:center;display:flex;justify-content:center}.card.image-full{display:grid}.card.image-full:before{border-radius:var(--rounded-box,1rem);content:"";position:relative;z-index:10;--tw-bg-opacity:1;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));opacity:.75}.card.image-full:before,.card.image-full>*{grid-column-start:1;grid-row-start:1}.card.image-full>figure img{height:100%;-o-object-fit:cover;object-fit:cover}.card.image-full>.card-body{position:relative;z-index:20;--tw-text-opacity:1;color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity)))}.checkbox{flex-shrink:0;--chkbg:var(--fallback-bc,oklch(var(--bc)/1));--chkfg:var(--fallback-b1,oklch(var(--b1)/1));-webkit-appearance:none;-moz-appearance:none;appearance:none;border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));border-radius:var(--rounded-btn,.5rem);border-width:1px;cursor:pointer;height:1.5rem;width:1.5rem;--tw-border-opacity:0.2}.divider{align-items:center;align-self:stretch;display:flex;flex-direction:row;height:1rem;margin-bottom:1rem;margin-top:1rem;white-space:nowrap}.divider:after,.divider:before{flex-grow:1;height:.125rem;width:100%;--tw-content:"";background-color:var(--fallback-bc,oklch(var(--bc)/.1));content:var(--tw-content)}.dropdown{display:inline-block;position:relative}.dropdown>:not(summary):focus{outline:2px solid transparent;outline-offset:2px}.dropdown .dropdown-content{position:absolute}.dropdown:is(:not(details)) .dropdown-content{opacity:0;transform-origin:top;visibility:hidden;--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-duration:.2s;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(.4,0,.2,1);transition-timing-function:cubic-bezier(0,0,.2,1)}.dropdown-end .dropdown-content{inset-inline-end:0}.dropdown-left .dropdown-content{bottom:auto;inset-inline-end:100%;top:0;transform-origin:right}.dropdown-right .dropdown-content{bottom:auto;inset-inline-start:100%;top:0;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-left .dropdown-content,.dropdown-end.dropdown-right .dropdown-content{bottom:0;top:auto}.dropdown.dropdown-open .dropdown-content,.dropdown:focus-within .dropdown-content,.dropdown:not(.dropdown-hover):focus .dropdown-content{opacity:1;visibility:visible}@media (hover:hover){.dropdown.dropdown-hover:hover .dropdown-content{opacity:1;visibility:visible}.btn:hover{--tw-border-opacity:1;border-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity)))}@supports (color:color-mix(in oklab,black,black)){.btn:hover{background-color:color-mix(in oklab,oklch(var(--btn-color,var(--b2))/var(--tw-bg-opacity,1)) 90%,#000);border-color:color-mix(in oklab,oklch(var(--btn-color,var(--b2))/var(--tw-border-opacity,1)) 90%,#000)}}@supports not (color:oklch(0 0 0)){.btn:hover{background-color:var(--btn-color,var(--fallback-b2));border-color:var(--btn-color,var(--fallback-b2))}}.btn.glass:hover{--glass-opacity:25%;--glass-border-opacity:15%}.btn-ghost:hover{border-color:transparent}@supports (color:oklch(0 0 0)){.btn-ghost:hover{background-color:var(--fallback-bc,oklch(var(--bc)/.2))}}.btn-outline.btn-primary:hover{--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.btn-outline.btn-primary:hover{background-color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 90%,#000)}}.btn-outline.btn-success:hover{--tw-text-opacity:1;color:var(--fallback-suc,oklch(var(--suc)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.btn-outline.btn-success:hover{background-color:color-mix(in oklab,var(--fallback-su,oklch(var(--su)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-su,oklch(var(--su)/1)) 90%,#000)}}.btn-outline.btn-info:hover{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.btn-outline.btn-info:hover{background-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000)}}.btn-outline.btn-warning:hover{--tw-text-opacity:1;color:var(--fallback-wac,oklch(var(--wac)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.btn-outline.btn-warning:hover{background-color:color-mix(in oklab,var(--fallback-wa,oklch(var(--wa)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-wa,oklch(var(--wa)/1)) 90%,#000)}}.btn-outline.btn-error:hover{--tw-text-opacity:1;color:var(--fallback-erc,oklch(var(--erc)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.btn-outline.btn-error:hover{background-color:color-mix(in oklab,var(--fallback-er,oklch(var(--er)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-er,oklch(var(--er)/1)) 90%,#000)}}.btn-disabled:hover,.btn:disabled:hover,.btn[disabled]:hover{--tw-border-opacity:0;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-bg-opacity:0.2;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}@supports (color:color-mix(in oklab,black,black)){.btn:is(input[type=checkbox]:checked):hover,.btn:is(input[type=radio]:checked):hover{background-color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 90%,#000)}}.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;outline-offset:2px}@supports (color:oklch(0 0 0)){: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{background-color:var(--fallback-bc,oklch(var(--bc)/.1))}}.tab[disabled],.tab[disabled]:hover{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));cursor:not-allowed;--tw-text-opacity:0.2}}.dropdown:is(details) summary::-webkit-details-marker{display:none}.file-input{border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));border-radius:var(--rounded-btn,.5rem);border-width:1px;flex-shrink:1;font-size:1rem;height:3rem;line-height:2;line-height:1.5rem;overflow:hidden;padding-inline-end:1rem;--tw-border-opacity:0;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)))}.file-input::file-selector-button{align-items:center;border-style:solid;cursor:pointer;display:inline-flex;flex-shrink:0;flex-wrap:wrap;font-size:.875rem;height:100%;justify-content:center;line-height:1.25rem;line-height:1em;margin-inline-end:1rem;padding-left:1rem;padding-right:1rem;text-align:center;transition-duration:.2s;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(.4,0,.2,1);transition-timing-function:cubic-bezier(0,0,.2,1);-webkit-user-select:none;-moz-user-select:none;user-select:none;--tw-border-opacity:1;border-color:var(--fallback-n,oklch(var(--n)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));font-weight:600;text-transform:uppercase;--tw-text-opacity:1;animation:button-pop var(--animation-btn,.25s) ease-out;border-width:var(--border-btn,1px);color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity)));text-decoration-line:none}.footer{-moz-column-gap:1rem;column-gap:1rem;font-size:.875rem;grid-auto-flow:row;line-height:1.25rem;row-gap:2.5rem;width:100%}.footer,.footer>*{display:grid;place-items:start}.footer>*{gap:.5rem}.footer-center{text-align:center}.footer-center,.footer-center>*{place-items:center}@media (min-width:48rem){.footer{grid-auto-flow:column}.footer-center{grid-auto-flow:row dense}}.form-control{flex-direction:column}.form-control,.label{display:flex}.label{align-items:center;justify-content:space-between;padding:.5rem .25rem;-webkit-user-select:none;-moz-user-select:none;user-select:none}.hero{background-position:50%;background-size:cover;display:grid;place-items:center;width:100%}.hero>*{grid-column-start:1;grid-row-start:1}.hero-content{align-items:center;display:flex;gap:1rem;justify-content:center;max-width:80rem;padding:1rem;z-index:0}.input{-webkit-appearance:none;-moz-appearance:none;appearance:none;border-color:transparent;border-radius:var(--rounded-btn,.5rem);border-width:1px;flex-shrink:1;font-size:1rem;height:3rem;line-height:2;line-height:1.5rem;padding-left:1rem;padding-right:1rem;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)))}.input-md[type=number]::-webkit-inner-spin-button,.input[type=number]::-webkit-inner-spin-button{margin-bottom:-1rem;margin-top:-1rem;margin-inline-end:-1rem}.input-xs[type=number]::-webkit-inner-spin-button{margin-bottom:-.25rem;margin-top:-.25rem;margin-inline-end:0}.input-sm[type=number]::-webkit-inner-spin-button{margin-bottom:0;margin-top:0;margin-inline-end:0}.join{align-items:stretch;border-radius:var(--rounded-btn,.5rem);display:inline-flex}.join :where(.join-item){border-end-end-radius:0;border-end-start-radius:0;border-start-end-radius:0;border-start-start-radius:0}.join .join-item:not(:first-child):not(:last-child),.join :not(:first-child):not(:last-child) .join-item{border-end-end-radius:0;border-end-start-radius:0;border-start-end-radius:0;border-start-start-radius:0}.join .join-item:first-child:not(:last-child),.join :first-child:not(:last-child) .join-item{border-end-end-radius:0;border-start-end-radius:0}.join .dropdown .join-item:first-child:not(:last-child),.join :first-child:not(:last-child) .dropdown .join-item{border-end-end-radius:inherit;border-start-end-radius:inherit}.join :where(.join-item:first-child:not(:last-child)),.join :where(:first-child:not(:last-child) .join-item){border-end-start-radius:inherit;border-start-start-radius:inherit}.join .join-item:last-child:not(:first-child),.join :last-child:not(:first-child) .join-item{border-end-start-radius:0;border-start-start-radius:0}.join :where(.join-item:last-child:not(:first-child)),.join :where(:last-child:not(:first-child) .join-item){border-end-end-radius:inherit;border-start-end-radius:inherit}@supports not selector(:has(*)){:where(.join *){border-radius:inherit}}@supports selector(:has(*)){:where(.join :has(.join-item)){border-radius:inherit}}.link{cursor:pointer;text-decoration-line:underline}.menu{display:flex;flex-direction:column;flex-wrap:wrap;font-size:.875rem;line-height:1.25rem;padding:.5rem}.menu :where(li ul){margin-inline-start:1rem;padding-inline-start:.5rem;position:relative;white-space:nowrap}.menu :where(li:not(.menu-title)>:not(ul,details,.menu-title,.btn)),.menu :where(li:not(.menu-title)>details>summary:not(.menu-title)){align-content:flex-start;align-items:center;display:grid;gap:.5rem;grid-auto-columns:minmax(auto,max-content) auto max-content;grid-auto-flow:column;-webkit-user-select:none;-moz-user-select:none;user-select:none}.menu li.disabled{color:var(--fallback-bc,oklch(var(--bc)/.3));cursor:not-allowed;-webkit-user-select:none;-moz-user-select:none;user-select:none}.menu :where(li>.menu-dropdown:not(.menu-dropdown-show)){display:none}:where(.menu li){align-items:stretch;display:flex;flex-direction:column;flex-shrink:0;flex-wrap:wrap;position:relative}:where(.menu li) .badge{justify-self:end}.mockup-code{border-radius:var(--rounded-box,1rem);min-width:18rem;overflow:hidden;overflow-x:auto;position:relative;--tw-bg-opacity:1;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));padding-bottom:1.25rem;padding-top:1.25rem;--tw-text-opacity:1;color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity)));direction:ltr}.mockup-code pre[data-prefix]:before{content:attr(data-prefix);display:inline-block;opacity:.5;text-align:right;width:2rem}.modal{background-color:transparent;color:inherit;display:grid;height:100%;inset:0;justify-items:center;margin:0;max-height:none;max-width:none;opacity:0;overflow-y:hidden;overscroll-behavior:contain;padding:0;pointer-events:none;position:fixed;transition-duration:.2s;transition-property:transform,opacity,visibility;transition-timing-function:cubic-bezier(0,0,.2,1);width:100%;z-index:999}:where(.modal){align-items:center}.modal-box{grid-column-start:1;grid-row-start:1;max-height:calc(100vh - 5em);max-width:32rem;width:91.666667%;--tw-scale-x:.9;--tw-scale-y:.9;border-bottom-left-radius:var(--rounded-box,1rem);border-bottom-right-radius:var(--rounded-box,1rem);border-top-left-radius:var(--rounded-box,1rem);border-top-right-radius:var(--rounded-box,1rem);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));--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));box-shadow:0 25px 50px -12px rgba(0,0,0,.25);overflow-y:auto;overscroll-behavior:contain;padding:1.5rem;transition-duration:.2s;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(.4,0,.2,1);transition-timing-function:cubic-bezier(0,0,.2,1)}.modal-open,.modal-toggle:checked+.modal,.modal:target,.modal[open]{opacity:1;pointer-events:auto;visibility:visible}.modal-toggle{-webkit-appearance:none;-moz-appearance:none;appearance:none;height:0;opacity:0;position:fixed;width:0}:root:has(:is(.modal-open,.modal:target,.modal-toggle:checked+.modal,.modal[open])){overflow:hidden}.navbar{align-items:center;display:flex;min-height:4rem;padding:var(--navbar-padding,.5rem);width:100%}:where(.navbar>:not(script,style)){align-items:center;display:inline-flex}.navbar-start{justify-content:flex-start;width:50%}.navbar-center{flex-shrink:0}.navbar-end{justify-content:flex-end;width:50%}.progress{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:var(--fallback-bc,oklch(var(--bc)/.2));border-radius:var(--rounded-box,1rem);height:.5rem;overflow:hidden;position:relative;width:100%}.radio{flex-shrink:0;--chkbg:var(--bc);border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));border-radius:9999px;border-width:1px;height:1.5rem;width:1.5rem;--tw-border-opacity:0.2}.radio,.select{-webkit-appearance:none;-moz-appearance:none;appearance:none;cursor:pointer}.select{border-color:transparent;border-radius:var(--rounded-btn,.5rem);border-width:1px;display:inline-flex;font-size:.875rem;height:3rem;line-height:1.25rem;line-height:2;min-height:3rem;padding-left:1rem;padding-right:2.5rem;-webkit-user-select:none;-moz-user-select:none;user-select:none;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));background-image:linear-gradient(45deg,transparent 50%,currentColor 0),linear-gradient(135deg,currentColor 50%,transparent 0);background-position:calc(100% - 20px) calc(1px + 50%),calc(100% - 16.1px) calc(1px + 50%);background-repeat:no-repeat;background-size:4px 4px,4px 4px}.select[multiple]{height:auto}.stats{border-radius:var(--rounded-box,1rem);display:inline-grid;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}:where(.stats){grid-auto-flow:column;overflow-x:auto}.stat{border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));-moz-column-gap:1rem;column-gap:1rem;display:inline-grid;grid-template-columns:repeat(1,1fr);width:100%;--tw-border-opacity:0.1;padding:1rem 1.5rem}.stat-title{color:var(--fallback-bc,oklch(var(--bc)/.6))}.stat-title,.stat-value{grid-column-start:1;white-space:nowrap}.stat-value{font-size:2.25rem;font-weight:800;line-height:2.5rem}.steps .step{display:grid;grid-template-columns:repeat(1,minmax(0,1fr));grid-template-columns:auto;grid-template-rows:repeat(2,minmax(0,1fr));grid-template-rows:40px 1fr;min-width:4rem;place-items:center;text-align:center}.tabs{align-items:flex-end;display:grid}.tabs-lifted:has(.tab-content[class*=" rounded-"]) .tab:first-child:not(.tab-active),.tabs-lifted:has(.tab-content[class^=rounded-]) .tab:first-child:not(.tab-active){border-bottom-color:transparent}.tab{align-items:center;-webkit-appearance:none;-moz-appearance:none;appearance:none;cursor:pointer;display:inline-flex;flex-wrap:wrap;font-size:.875rem;grid-row-start:1;height:2rem;justify-content:center;line-height:1.25rem;line-height:2;position:relative;text-align:center;-webkit-user-select:none;-moz-user-select:none;user-select:none;--tab-padding:1rem;--tw-text-opacity:0.5;--tab-color:var(--fallback-bc,oklch(var(--bc)/1));--tab-bg:var(--fallback-b1,oklch(var(--b1)/1));--tab-border-color:var(--fallback-b3,oklch(var(--b3)/1));color:var(--tab-color);padding-inline-end:var(--tab-padding,1rem);padding-inline-start:var(--tab-padding,1rem)}.tab:is(input[type=radio]){border-bottom-left-radius:0;border-bottom-right-radius:0;width:auto}.tab:is(input[type=radio]):after{--tw-content:attr(aria-label);content:var(--tw-content)}.tab:not(input):empty{cursor:default;grid-column-start:span 9999}.tab-content{border-color:transparent;border-width:var(--tab-border,0);display:none;grid-column-end:span 9999;grid-column-start:1;grid-row-start:2;margin-top:calc(var(--tab-border)*-1)}.tab-active+.tab-content:nth-child(2),:checked+.tab-content:nth-child(2){border-start-start-radius:0}.tab-active+.tab-content,input.tab:checked+.tab-content{display:block}.table{border-radius:var(--rounded-box,1rem);font-size:.875rem;line-height:1.25rem;position:relative;text-align:left;width:100%}.table :where(.table-pin-rows thead tr){position:sticky;top:0;z-index:1;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)))}.table :where(.table-pin-rows tfoot tr){bottom:0;position:sticky;z-index:1;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)))}.table :where(.table-pin-cols tr th){left:0;position:sticky;right:0;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)))}.timeline{display:flex;position:relative}:where(.timeline>li){align-items:center;display:grid;flex-shrink:0;grid-template-columns:var(--timeline-col-start,minmax(0,1fr)) auto var(
--timeline-col-end,minmax(0,1fr)
);grid-template-rows:var(--timeline-row-start,minmax(0,1fr)) auto var(
--timeline-row-end,minmax(0,1fr)
- );position:relative}.timeline>li>hr{border-width:0;width:100%}:where(.timeline>li>hr):first-child{grid-column-start:1;grid-row-start:2}:where(.timeline>li>hr):last-child{grid-column-end:none;grid-column-start:3;grid-row-end:auto;grid-row-start:2}.timeline-start{align-self:flex-end;grid-column-end:4;grid-column-start:1;grid-row-end:2;grid-row-start:1;justify-self:center;margin:.25rem}.timeline-middle{grid-column-start:2;grid-row-start:2}.timeline-end{align-self:flex-start;grid-column-end:4;grid-column-start:1;grid-row-end:4;grid-row-start:3;justify-self:center;margin:.25rem}.toggle{flex-shrink:0;--tglbg:var(--fallback-b1,oklch(var(--b1)/1));--handleoffset:1.5rem;--handleoffsetcalculator:calc(var(--handleoffset)*-1);--togglehandleborder:0 0;-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:currentColor;border-color:currentColor;border-radius:var(--rounded-badge,1.9rem);border-width:1px;box-shadow:var(--handleoffsetcalculator) 0 0 2px var(--tglbg) inset,0 0 0 2px var(--tglbg) inset,var(--togglehandleborder);color:var(--fallback-bc,oklch(var(--bc)/.5));cursor:pointer;height:1.5rem;transition:background,box-shadow var(--animation-input,.2s) ease-out;width:3rem}.alert-info{border-color:var(--fallback-in,oklch(var(--in)/.2));--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)));--alert-bg:var(--fallback-in,oklch(var(--in)/1));--alert-bg-mix:var(--fallback-b1,oklch(var(--b1)/1))}.badge-primary{--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)))}.badge-outline{border-color:currentColor;--tw-border-opacity:0.5;background-color:transparent;color:currentColor}.badge-outline.badge-neutral{--tw-text-opacity:1;color:var(--fallback-n,oklch(var(--n)/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-secondary{--tw-text-opacity:1;color:var(--fallback-s,oklch(var(--s)/var(--tw-text-opacity)))}.badge-outline.badge-accent{--tw-text-opacity:1;color:var(--fallback-a,oklch(var(--a)/var(--tw-text-opacity)))}.badge-outline.badge-info{--tw-text-opacity:1;color:var(--fallback-in,oklch(var(--in)/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-warning{--tw-text-opacity:1;color:var(--fallback-wa,oklch(var(--wa)/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>* .label{font-size:1rem;line-height:1.5rem}.btn:active:focus,.btn:active:hover{animation:button-pop 0s ease-out;transform:scale(var(--btn-focus-scale,.97))}@supports not (color:oklch(0 0 0)){.btn{background-color:var(--btn-color,var(--fallback-b2));border-color:var(--btn-color,var(--fallback-b2))}.btn-primary{--btn-color:var(--fallback-p)}.btn-info{--btn-color:var(--fallback-in)}.btn-success{--btn-color:var(--fallback-su)}.btn-warning{--btn-color:var(--fallback-wa)}.btn-error{--btn-color:var(--fallback-er)}.prose :where(code):not(:where([class~=not-prose] *,pre *)){background-color:var(--fallback-b3,oklch(var(--b3)/1))}}@supports (color:color-mix(in oklab,black,black)){.btn-active{background-color:color-mix(in oklab,oklch(var(--btn-color,var(--b3))/var(--tw-bg-opacity,1)) 90%,#000);border-color:color-mix(in oklab,oklch(var(--btn-color,var(--b3))/var(--tw-border-opacity,1)) 90%,#000)}.btn-outline.btn-primary.btn-active{background-color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 90%,#000)}.btn-outline.btn-secondary.btn-active{background-color:color-mix(in oklab,var(--fallback-s,oklch(var(--s)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-s,oklch(var(--s)/1)) 90%,#000)}.btn-outline.btn-accent.btn-active{background-color:color-mix(in oklab,var(--fallback-a,oklch(var(--a)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-a,oklch(var(--a)/1)) 90%,#000)}.btn-outline.btn-success.btn-active{background-color:color-mix(in oklab,var(--fallback-su,oklch(var(--su)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-su,oklch(var(--su)/1)) 90%,#000)}.btn-outline.btn-info.btn-active{background-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000)}.btn-outline.btn-warning.btn-active{background-color:color-mix(in oklab,var(--fallback-wa,oklch(var(--wa)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-wa,oklch(var(--wa)/1)) 90%,#000)}.btn-outline.btn-error.btn-active{background-color:color-mix(in oklab,var(--fallback-er,oklch(var(--er)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-er,oklch(var(--er)/1)) 90%,#000)}}.btn:focus-visible{outline-offset:2px;outline-style:solid;outline-width:2px}.btn-primary{--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)));outline-color:var(--fallback-p,oklch(var(--p)/1))}@supports (color:oklch(0 0 0)){.btn-primary{--btn-color:var(--p)}.btn-info{--btn-color:var(--in)}.btn-success{--btn-color:var(--su)}.btn-warning{--btn-color:var(--wa)}.btn-error{--btn-color:var(--er)}}.btn-info{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)));outline-color:var(--fallback-in,oklch(var(--in)/1))}.btn-success{--tw-text-opacity:1;color:var(--fallback-suc,oklch(var(--suc)/var(--tw-text-opacity)));outline-color:var(--fallback-su,oklch(var(--su)/1))}.btn-warning{--tw-text-opacity:1;color:var(--fallback-wac,oklch(var(--wac)/var(--tw-text-opacity)));outline-color:var(--fallback-wa,oklch(var(--wa)/1))}.btn-error{--tw-text-opacity:1;color:var(--fallback-erc,oklch(var(--erc)/var(--tw-text-opacity)));outline-color:var(--fallback-er,oklch(var(--er)/1))}.btn.glass{--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);outline-color:currentColor}.btn.glass.btn-active{--glass-opacity:25%;--glass-border-opacity:15%}.btn-ghost{background-color:transparent;border-color:transparent;border-width:1px;color:currentColor;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);outline-color:currentColor}.btn-ghost.btn-active{background-color:var(--fallback-bc,oklch(var(--bc)/.2));border-color:transparent}.btn-link.btn-active{background-color:transparent;border-color:transparent;text-decoration-line:underline}.btn-outline.btn-active{--tw-border-opacity:1;border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-b1,oklch(var(--b1)/var(--tw-text-opacity)))}.btn-outline.btn-primary{--tw-text-opacity:1;color:var(--fallback-p,oklch(var(--p)/var(--tw-text-opacity)))}.btn-outline.btn-primary.btn-active{--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}.btn-outline.btn-secondary.btn-active{--tw-text-opacity:1;color:var(--fallback-sc,oklch(var(--sc)/var(--tw-text-opacity)))}.btn-outline.btn-accent.btn-active{--tw-text-opacity:1;color:var(--fallback-ac,oklch(var(--ac)/var(--tw-text-opacity)))}.btn-outline.btn-success{--tw-text-opacity:1;color:var(--fallback-su,oklch(var(--su)/var(--tw-text-opacity)))}.btn-outline.btn-success.btn-active{--tw-text-opacity:1;color:var(--fallback-suc,oklch(var(--suc)/var(--tw-text-opacity)))}.btn-outline.btn-info{--tw-text-opacity:1;color:var(--fallback-in,oklch(var(--in)/var(--tw-text-opacity)))}.btn-outline.btn-info.btn-active{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)))}.btn-outline.btn-warning{--tw-text-opacity:1;color:var(--fallback-wa,oklch(var(--wa)/var(--tw-text-opacity)))}.btn-outline.btn-warning.btn-active{--tw-text-opacity:1;color:var(--fallback-wac,oklch(var(--wac)/var(--tw-text-opacity)))}.btn-outline.btn-error{--tw-text-opacity:1;color:var(--fallback-er,oklch(var(--er)/var(--tw-text-opacity)))}.btn-outline.btn-error.btn-active{--tw-text-opacity:1;color:var(--fallback-erc,oklch(var(--erc)/var(--tw-text-opacity)))}.btn.btn-disabled,.btn:disabled,.btn[disabled]{--tw-border-opacity:0;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-bg-opacity:0.2;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}.btn:is(input[type=checkbox]:checked),.btn:is(input[type=radio]:checked){--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)))}.btn:is(input[type=checkbox]:checked):focus-visible,.btn:is(input[type=radio]:checked):focus-visible{outline-color:var(--fallback-p,oklch(var(--p)/1))}@keyframes button-pop{0%{transform:scale(var(--btn-focus-scale,.98))}40%{transform:scale(1.02)}to{transform:scale(1)}}.card :where(figure:first-child){border-end-end-radius:unset;border-end-start-radius:unset;border-start-end-radius:inherit;border-start-start-radius:inherit;overflow:hidden}.card :where(figure:last-child){border-end-end-radius:inherit;border-end-start-radius:inherit;border-start-end-radius:unset;border-start-start-radius:unset;overflow:hidden}.card:focus-visible{outline:2px solid currentColor;outline-offset:2px}.card.bordered{border-width:1px;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)))}.card.compact .card-body{font-size:.875rem;line-height:1.25rem;padding:1rem}.card-title{align-items:center;display:flex;font-size:1.25rem;font-weight:600;gap:.5rem;line-height:1.75rem}.card.image-full :where(figure){border-radius:inherit;overflow:hidden}.checkbox:focus{box-shadow:none}.checkbox:focus-visible{outline-color:var(--fallback-bc,oklch(var(--bc)/1));outline-offset:2px;outline-style:solid;outline-width:2px}.checkbox:checked,.checkbox[aria-checked=true],.checkbox[checked=true]{animation:checkmark var(--animation-input,.2s) ease-out;background-color:var(--chkbg);background-image:linear-gradient(-45deg,transparent 65%,var(--chkbg) 65.99%),linear-gradient(45deg,transparent 75%,var(--chkbg) 75.99%),linear-gradient(-45deg,var(--chkbg) 40%,transparent 40.99%),linear-gradient(45deg,var(--chkbg) 30%,var(--chkfg) 30.99%,var(--chkfg) 40%,transparent 40.99%),linear-gradient(-45deg,var(--chkfg) 50%,var(--chkbg) 50.99%);background-repeat:no-repeat}.checkbox:indeterminate{--tw-bg-opacity:1;animation:checkmark var(--animation-input,.2s) ease-out;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));background-image:linear-gradient(90deg,transparent 80%,var(--chkbg) 80%),linear-gradient(-90deg,transparent 80%,var(--chkbg) 80%),linear-gradient(0deg,var(--chkbg) 43%,var(--chkfg) 43%,var(--chkfg) 57%,var(--chkbg) 57%);background-repeat:no-repeat}.checkbox:disabled{border-color:transparent;cursor:not-allowed;--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));opacity:.2}@keyframes checkmark{0%{background-position-y:5px}50%{background-position-y:-2px}to{background-position-y:0}}.divider:not(:empty){gap:1rem}.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))}.file-input-bordered{--tw-border-opacity:0.2}.file-input:focus{outline-color:var(--fallback-bc,oklch(var(--bc)/.2));outline-offset:2px;outline-style:solid;outline-width:2px}.file-input-disabled,.file-input[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)));--tw-text-opacity:0.2}.file-input-disabled::-moz-placeholder,.file-input[disabled]::-moz-placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.file-input-disabled::placeholder,.file-input[disabled]::placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.file-input-disabled::file-selector-button,.file-input[disabled]::file-selector-button{--tw-border-opacity:0;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-bg-opacity:0.2;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}.label-text{font-size:.875rem;line-height:1.25rem;--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}.input input{--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));background-color:transparent}.input input:focus{outline:2px solid transparent;outline-offset:2px}.input[list]::-webkit-calendar-picker-indicator{line-height:1em}.input-bordered{border-color:var(--fallback-bc,oklch(var(--bc)/.2))}.input:focus,.input:focus-within{border-color:var(--fallback-bc,oklch(var(--bc)/.2));box-shadow:none;outline-color:var(--fallback-bc,oklch(var(--bc)/.2));outline-offset:2px;outline-style:solid;outline-width:2px}.input-ghost{--tw-bg-opacity:0.05}.input-ghost:focus,.input-ghost:focus-within{--tw-bg-opacity:1;--tw-text-opacity:1;box-shadow:none;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}.input-disabled,.input:disabled,.input[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)/.4))}.input-disabled::-moz-placeholder,.input:disabled::-moz-placeholder,.input[disabled]::-moz-placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.input-disabled::placeholder,.input:disabled::placeholder,.input[disabled]::placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.input::-webkit-date-and-time-value{text-align:inherit}.join>:where(:not(:first-child)){margin-bottom:0;margin-top:0;margin-inline-start:-1px}.join-item:focus{isolation:isolate}.link:focus{outline:2px solid transparent;outline-offset:2px}.link:focus-visible{outline:2px solid currentColor;outline-offset:2px}.loading{aspect-ratio:1/1;background-color:currentColor;display:inline-block;-webkit-mask-position:center;mask-position:center;-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat;-webkit-mask-size:100%;mask-size:100%;pointer-events:none;width:1.5rem}.loading,.loading-spinner{-webkit-mask-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' stroke='%23000'%3E%3Cstyle%3E@keyframes spinner_zKoa{to{transform:rotate(360deg)}}@keyframes spinner_YpZS{0%25{stroke-dasharray:0 150;stroke-dashoffset:0}47.5%25{stroke-dasharray:42 150;stroke-dashoffset:-16}95%25,to{stroke-dasharray:42 150;stroke-dashoffset:-59}}%3C/style%3E%3Cg style='transform-origin:center;animation:spinner_zKoa 2s linear infinite'%3E%3Ccircle cx='12' cy='12' r='9.5' fill='none' stroke-width='3' class='spinner_V8m1' style='stroke-linecap:round;animation:spinner_YpZS 1.5s ease-out infinite'/%3E%3C/g%3E%3C/svg%3E");mask-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' stroke='%23000'%3E%3Cstyle%3E@keyframes spinner_zKoa{to{transform:rotate(360deg)}}@keyframes spinner_YpZS{0%25{stroke-dasharray:0 150;stroke-dashoffset:0}47.5%25{stroke-dasharray:42 150;stroke-dashoffset:-16}95%25,to{stroke-dasharray:42 150;stroke-dashoffset:-59}}%3C/style%3E%3Cg style='transform-origin:center;animation:spinner_zKoa 2s linear infinite'%3E%3Ccircle cx='12' cy='12' r='9.5' fill='none' stroke-width='3' class='spinner_V8m1' style='stroke-linecap:round;animation:spinner_YpZS 1.5s ease-out infinite'/%3E%3C/g%3E%3C/svg%3E")}:where(.menu li:empty){--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));height:1px;margin:.5rem 1rem;opacity:.1}.menu :where(li ul):before{bottom:.75rem;inset-inline-start:0;position:absolute;top:.75rem;width:1px;--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));content:"";opacity:.1}.menu :where(li:not(.menu-title)>:not(ul,details,.menu-title,.btn)),.menu :where(li:not(.menu-title)>details>summary:not(.menu-title)){border-radius:var(--rounded-btn,.5rem);padding:.5rem 1rem;text-align:start;text-wrap:balance;transition-duration:.2s;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(.4,0,.2,1);transition-timing-function:cubic-bezier(0,0,.2,1)}:where(.menu li:not(.menu-title,.disabled)>:not(ul,details,.menu-title)):is(summary):not(.active,.btn):focus-visible,:where(.menu li:not(.menu-title,.disabled)>:not(ul,details,.menu-title)):not(summary,.active,.btn).focus,:where(.menu li:not(.menu-title,.disabled)>:not(ul,details,.menu-title)):not(summary,.active,.btn):focus,:where(.menu li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):is(summary):not(.active,.btn):focus-visible,:where(.menu li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(summary,.active,.btn).focus,:where(.menu li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(summary,.active,.btn):focus{background-color:var(--fallback-bc,oklch(var(--bc)/.1));cursor:pointer;--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));outline:2px solid transparent;outline-offset:2px}.menu li>:not(ul,.menu-title,details,.btn).active,.menu li>:not(ul,.menu-title,details,.btn):active,.menu li>details>summary:active{--tw-bg-opacity:1;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity)))}.menu :where(li>details>summary)::-webkit-details-marker{display:none}.menu :where(li>.menu-dropdown-toggle):after,.menu :where(li>details>summary):after{box-shadow:2px 2px;content:"";display:block;height:.5rem;justify-self:end;margin-top:-.5rem;pointer-events:none;transform:rotate(45deg);transform-origin:75% 75%;transition-duration:.3s;transition-property:transform,margin-top;transition-timing-function:cubic-bezier(.4,0,.2,1);width:.5rem}.menu :where(li>.menu-dropdown-toggle.menu-dropdown-show):after,.menu :where(li>details[open]>summary):after{margin-top:0;transform:rotate(225deg)}.mockup-code:before{border-radius:9999px;box-shadow:1.4em 0,2.8em 0,4.2em 0;content:"";display:block;height:.75rem;margin-bottom:1rem;opacity:.3;width:.75rem}.mockup-code pre{padding-right:1.25rem}.mockup-code pre:before{content:"";margin-right:2ch}.mockup-phone .display{border-radius:40px;margin-top:-25px;overflow:hidden}.mockup-browser .mockup-browser-toolbar .input{display:block;height:1.75rem;margin-left:auto;margin-right:auto;overflow:hidden;position:relative;text-overflow:ellipsis;white-space:nowrap;width:24rem;--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));direction:ltr;padding-left:2rem}.mockup-browser .mockup-browser-toolbar .input:before{aspect-ratio:1/1;height:.75rem;left:.5rem;--tw-translate-y:-50%;border-color:currentColor;border-radius:9999px;border-width:2px}.mockup-browser .mockup-browser-toolbar .input:after,.mockup-browser .mockup-browser-toolbar .input:before{content:"";opacity:.6;position:absolute;top:50%;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))}.mockup-browser .mockup-browser-toolbar .input:after{height:.5rem;left:1.25rem;--tw-translate-y:25%;--tw-rotate:-45deg;border-color:currentColor;border-radius:9999px;border-width:1px}.modal::backdrop,.modal:not(dialog:not(.modal-open)){animation:modal-pop .2s ease-out;background-color:#0006}.modal-backdrop{align-self:stretch;color:transparent;display:grid;grid-column-start:1;grid-row-start:1;justify-self:stretch;z-index:-1}.modal-open .modal-box,.modal-toggle:checked+.modal .modal-box,.modal:target .modal-box,.modal[open] .modal-box{--tw-translate-y:0px;--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))}@keyframes modal-pop{0%{opacity:0}}.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)))}.progress:indeterminate{--progress-color:var(--fallback-bc,oklch(var(--bc)/1));animation:progress-loading 5s ease-in-out infinite;background-image:repeating-linear-gradient(90deg,var(--progress-color) -1%,var(--progress-color) 10%,transparent 10%,transparent 90%);background-position-x:15%;background-size:200%}.progress::-webkit-progress-bar{background-color:transparent;border-radius:var(--rounded-box,1rem)}.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)))}.progress:indeterminate::-moz-progress-bar{animation:progress-loading 5s ease-in-out infinite;background-color:transparent;background-image:repeating-linear-gradient(90deg,var(--progress-color) -1%,var(--progress-color) 10%,transparent 10%,transparent 90%);background-position-x:15%;background-size:200%}@keyframes progress-loading{50%{background-position-x:-115%}}.radio:focus{box-shadow:none}.radio:focus-visible{outline-color:var(--fallback-bc,oklch(var(--bc)/1));outline-offset:2px;outline-style:solid;outline-width:2px}.radio:checked,.radio[aria-checked=true]{--tw-bg-opacity:1;animation:radiomark var(--animation-input,.2s) ease-out;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));background-image:none;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:.2}@keyframes radiomark{0%{box-shadow:0 0 0 12px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 12px var(--fallback-b1,oklch(var(--b1)/1)) inset}50%{box-shadow:0 0 0 3px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 3px var(--fallback-b1,oklch(var(--b1)/1)) inset}to{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}}.range:focus-visible::-webkit-slider-thumb{--focus-shadow:0 0 0 6px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 2rem var(--range-shdw) inset}.range:focus-visible::-moz-range-thumb{--focus-shadow:0 0 0 6px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 2rem var(--range-shdw) inset}.range::-webkit-slider-runnable-track{background-color:var(--fallback-bc,oklch(var(--bc)/.1));border-radius:var(--rounded-box,1rem);height:.5rem;width:100%}.range::-moz-range-track{background-color:var(--fallback-bc,oklch(var(--bc)/.1));border-radius:var(--rounded-box,1rem);height:.5rem;width:100%}.range::-webkit-slider-thumb{border-radius:var(--rounded-box,1rem);border-style:none;height:1.5rem;position:relative;width:1.5rem;--tw-bg-opacity:1;appearance:none;-webkit-appearance:none;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));color:var(--range-shdw);top:50%;transform:translateY(-50%);--filler-size:100rem;--filler-offset:0.6rem;box-shadow:0 0 0 3px var(--range-shdw) inset,var(--focus-shadow,0 0),calc(var(--filler-size)*-1 - var(--filler-offset)) 0 0 var(--filler-size)}.range::-moz-range-thumb{border-radius:var(--rounded-box,1rem);border-style:none;height:1.5rem;position:relative;width:1.5rem;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));color:var(--range-shdw);top:50%;--filler-size:100rem;--filler-offset:0.5rem;box-shadow:0 0 0 3px var(--range-shdw) inset,var(--focus-shadow,0 0),calc(var(--filler-size)*-1 - var(--filler-offset)) 0 0 var(--filler-size)}@keyframes rating-pop{0%{transform:translateY(-.125em)}40%{transform:translateY(-.125em)}to{transform:translateY(0)}}.select-bordered,.select:focus{border-color:var(--fallback-bc,oklch(var(--bc)/.2))}.select:focus{box-shadow:none;outline-color:var(--fallback-bc,oklch(var(--bc)/.2));outline-offset:2px;outline-style:solid;outline-width:2px}.select-disabled,.select:disabled,.select[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)/var(--tw-text-opacity)));--tw-text-opacity:0.2}.select-disabled::-moz-placeholder,.select:disabled::-moz-placeholder,.select[disabled]::-moz-placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.select-disabled::placeholder,.select:disabled::placeholder,.select[disabled]::placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.select-multiple,.select[multiple],.select[size].select:not([size="1"]){background-image:none;padding-right:1rem}[dir=rtl] .select{background-position:12px calc(1px + 50%),16px calc(1px + 50%)}@keyframes skeleton{0%{background-position:150%}to{background-position:-50%}}:where(.stats)>:not([hidden])~:not([hidden]){--tw-divide-x-reverse:0;--tw-divide-y-reverse:0;border-width:calc(0px*(1 - var(--tw-divide-y-reverse))) calc(1px*var(--tw-divide-x-reverse)) calc(0px*var(--tw-divide-y-reverse)) calc(1px*(1 - var(--tw-divide-x-reverse)))}:is([dir=rtl] .stats>:not([hidden])~:not([hidden])){--tw-divide-x-reverse:1}.steps .step:before{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));content:"";height:.5rem;margin-inline-start:-100%;top:0;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));width:100%}.steps .step:after,.steps .step:before{grid-column-start:1;grid-row-start:1;--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity)));--tw-text-opacity:1}.steps .step:after{border-radius:9999px;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));content:counter(step);counter-increment:step;display:grid;height:2rem;place-items:center;place-self:center;position:relative;width:2rem;z-index:1}.steps .step:first-child:before{content:none}.steps .step[data-content]:after{content:attr(data-content)}.tabs-lifted>.tab:focus-visible{border-end-end-radius:0;border-end-start-radius:0}.tab.tab-active:not(.tab-disabled):not([disabled]),.tab:is(input:checked){border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));--tw-border-opacity:1;--tw-text-opacity:1}.tab:focus{outline:2px solid transparent;outline-offset:2px}.tab:focus-visible{outline:2px solid currentColor;outline-offset:-5px}.tab-disabled,.tab[disabled]{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));cursor:not-allowed;--tw-text-opacity:0.2}.tabs-bordered>.tab{border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));--tw-border-opacity:0.2;border-bottom-width:calc(var(--tab-border, 1px) + 1px);border-style:solid}.tabs-lifted>.tab{border:var(--tab-border,1px) solid transparent;border-bottom-color:var(--tab-border-color);border-start-end-radius:var(--tab-radius,.5rem);border-start-start-radius:var(--tab-radius,.5rem);border-width:0 0 var(--tab-border,1px) 0;padding-inline-end:var(--tab-padding,1rem);padding-inline-start:var(--tab-padding,1rem);padding-top:var(--tab-border,1px)}.tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]),.tabs-lifted>.tab:is(input:checked){background-color:var(--tab-bg);border-inline-end-color:var(--tab-border-color);border-inline-start-color:var(--tab-border-color);border-top-color:var(--tab-border-color);border-width:var(--tab-border,1px) var(--tab-border,1px) 0 var(--tab-border,1px);padding-inline-end:calc(var(--tab-padding, 1rem) - var(--tab-border, 1px));padding-bottom:var(--tab-border,1px);padding-inline-start:calc(var(--tab-padding, 1rem) - var(--tab-border, 1px));padding-top:0}.tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]):before,.tabs-lifted>.tab:is(input:checked):before{background-position:0 0,100% 0;background-repeat:no-repeat;background-size:var(--tab-radius,.5rem);bottom:0;content:"";display:block;height:var(--tab-radius,.5rem);position:absolute;width:calc(100% + var(--tab-radius, .5rem)*2);z-index:1;--tab-grad:calc(69% - var(--tab-border, 1px));--radius-start:radial-gradient(circle at top left,transparent var(--tab-grad),var(--tab-border-color) calc(var(--tab-grad) + 0.25px),var(--tab-border-color) calc(var(--tab-grad) + var(--tab-border, 1px)),var(--tab-bg) calc(var(--tab-grad) + var(--tab-border, 1px) + 0.25px));--radius-end:radial-gradient(circle at top right,transparent var(--tab-grad),var(--tab-border-color) calc(var(--tab-grad) + 0.25px),var(--tab-border-color) calc(var(--tab-grad) + var(--tab-border, 1px)),var(--tab-bg) calc(var(--tab-grad) + var(--tab-border, 1px) + 0.25px));background-image:var(--radius-start),var(--radius-end)}.tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]):first-child:before,.tabs-lifted>.tab:is(input:checked):first-child:before{background-image:var(--radius-end);background-position:100% 0}[dir=rtl] .tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]):first-child:before,[dir=rtl] .tabs-lifted>.tab:is(input:checked):first-child:before{background-image:var(--radius-start);background-position:0 0}.tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]):last-child:before,.tabs-lifted>.tab:is(input:checked):last-child:before{background-image:var(--radius-start);background-position:0 0}[dir=rtl] .tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]):last-child:before,[dir=rtl] .tabs-lifted>.tab:is(input:checked):last-child:before{background-image:var(--radius-end);background-position:100% 0}.tabs-lifted>.tab-active:not(.tab-disabled):not([disabled])+.tabs-lifted .tab-active:not(.tab-disabled):not([disabled]):before,.tabs-lifted>.tab:is(input:checked)+.tabs-lifted .tab:is(input:checked):before{background-image:var(--radius-end);background-position:100% 0}.tabs-boxed{--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));padding:.25rem}.tabs-boxed,.tabs-boxed .tab{border-radius:var(--rounded-btn,.5rem)}.tabs-boxed .tab-active:not(.tab-disabled):not([disabled]),.tabs-boxed :is(input:checked){--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)))}:is([dir=rtl] .table){text-align:right}.table :where(th,td){padding:.75rem 1rem;vertical-align:middle}.table tr.active,.table tr.active:nth-child(2n),.table-zebra tbody tr:nth-child(2n){--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)))}.table :where(thead,tbody) :where(tr:first-child:last-child),.table :where(thead,tbody) :where(tr:not(:last-child)){border-bottom-width:1px;--tw-border-opacity:1;border-bottom-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)))}.table :where(thead,tfoot){color:var(--fallback-bc,oklch(var(--bc)/.6));font-size:.75rem;font-weight:700;line-height:1rem;white-space:nowrap}.timeline hr{height:.25rem}:where(.timeline hr){--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity)))}:where(.timeline:has(.timeline-middle) hr):first-child{border-end-end-radius:var(--rounded-badge,1.9rem);border-end-start-radius:0;border-start-end-radius:var(--rounded-badge,1.9rem);border-start-start-radius:0}:where(.timeline:has(.timeline-middle) hr):last-child{border-end-end-radius:0;border-end-start-radius:var(--rounded-badge,1.9rem);border-start-end-radius:0;border-start-start-radius:var(--rounded-badge,1.9rem)}:where(.timeline:not(:has(.timeline-middle)) :first-child hr:last-child){border-end-end-radius:0;border-end-start-radius:var(--rounded-badge,1.9rem);border-start-end-radius:0;border-start-start-radius:var(--rounded-badge,1.9rem)}:where(.timeline:not(:has(.timeline-middle)) :last-child hr:first-child){border-end-end-radius:var(--rounded-badge,1.9rem);border-end-start-radius:0;border-start-end-radius:var(--rounded-badge,1.9rem);border-start-start-radius:0}.timeline-box{border-radius:var(--rounded-box,1rem);border-width:1px;--tw-border-opacity:1;border-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));padding:.5rem 1rem;--tw-shadow:0 1px 2px 0 rgba(0,0,0,.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}@keyframes toast-pop{0%{opacity:0;transform:scale(.9)}to{opacity:1;transform:scale(1)}}[dir=rtl] .toggle{--handleoffsetcalculator:calc(var(--handleoffset)*1)}.toggle:focus-visible{outline-color:var(--fallback-bc,oklch(var(--bc)/.2));outline-offset:2px;outline-style:solid;outline-width:2px}.toggle:hover{background-color:currentColor}.toggle:checked,.toggle[aria-checked=true],.toggle[checked=true]{background-image:none;--handleoffsetcalculator:var(--handleoffset);--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}[dir=rtl] .toggle:checked,[dir=rtl] .toggle[aria-checked=true],[dir=rtl] .toggle[checked=true]{--handleoffsetcalculator:calc(var(--handleoffset)*-1)}.toggle:indeterminate{--tw-text-opacity:1;box-shadow:calc(var(--handleoffset)/2) 0 0 2px var(--tglbg) inset,calc(var(--handleoffset)/-2) 0 0 2px var(--tglbg) inset,0 0 0 2px var(--tglbg) inset;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}[dir=rtl] .toggle:indeterminate{box-shadow:calc(var(--handleoffset)/2) 0 0 2px var(--tglbg) inset,calc(var(--handleoffset)/-2) 0 0 2px var(--tglbg) inset,0 0 0 2px var(--tglbg) inset}.toggle:disabled{cursor:not-allowed;--tw-border-opacity:1;background-color:transparent;border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));opacity:.3;--togglehandleborder:0 0 0 3px var(--fallback-bc,oklch(var(--bc)/1)) inset,var(--handleoffsetcalculator) 0 0 3px var(--fallback-bc,oklch(var(--bc)/1)) inset}:root .prose{--tw-prose-body:var(--fallback-bc,oklch(var(--bc)/0.8));--tw-prose-headings:var(--fallback-bc,oklch(var(--bc)/1));--tw-prose-lead:var(--fallback-bc,oklch(var(--bc)/1));--tw-prose-links:var(--fallback-bc,oklch(var(--bc)/1));--tw-prose-bold:var(--fallback-bc,oklch(var(--bc)/1));--tw-prose-counters:var(--fallback-bc,oklch(var(--bc)/1));--tw-prose-bullets:var(--fallback-bc,oklch(var(--bc)/0.5));--tw-prose-hr:var(--fallback-bc,oklch(var(--bc)/0.2));--tw-prose-quotes:var(--fallback-bc,oklch(var(--bc)/1));--tw-prose-quote-borders:var(--fallback-bc,oklch(var(--bc)/0.2));--tw-prose-captions:var(--fallback-bc,oklch(var(--bc)/0.5));--tw-prose-code:var(--fallback-bc,oklch(var(--bc)/1));--tw-prose-pre-code:var(--fallback-nc,oklch(var(--nc)/1));--tw-prose-pre-bg:var(--fallback-n,oklch(var(--n)/1));--tw-prose-th-borders:var(--fallback-bc,oklch(var(--bc)/0.5));--tw-prose-td-borders:var(--fallback-bc,oklch(var(--bc)/0.2))}.prose :where(code):not(:where([class~=not-prose] *,pre *)){background-color:var(--fallback-bc,oklch(var(--bc)/.1));border-radius:var(--rounded-badge);font-weight:400;padding:1px 8px}.prose :where(code):not(:where([class~=not-prose],[class~=not-prose] *)):after,.prose :where(code):not(:where([class~=not-prose],[class~=not-prose] *)):before{display:none}.prose pre code{border-radius:0;padding:0}.prose :where(tbody tr,thead):not(:where([class~=not-prose] *)){border-bottom-color:var(--fallback-bc,oklch(var(--bc)/.2))}.glass,.glass.btn-active{-webkit-backdrop-filter:blur(var(--glass-blur,40px));backdrop-filter:blur(var(--glass-blur,40px));background-color:transparent;background-image:linear-gradient(135deg,rgb(255 255 255/var(--glass-opacity,30%)) 0,transparent 100%),linear-gradient(var(--glass-reflex-degree,100deg),rgb(255 255 255/var(--glass-reflex-opacity,10%)) 25%,transparent 25%);border:none;box-shadow:0 0 0 1px rgb(255 255 255/var(--glass-border-opacity,10%)) inset,0 0 0 2px rgb(0 0 0/5%);text-shadow:0 1px rgb(0 0 0/var(--glass-text-shadow-opacity,5%))}@media (hover:hover){.glass.btn-active{-webkit-backdrop-filter:blur(var(--glass-blur,40px));backdrop-filter:blur(var(--glass-blur,40px));background-color:transparent;background-image:linear-gradient(135deg,rgb(255 255 255/var(--glass-opacity,30%)) 0,transparent 100%),linear-gradient(var(--glass-reflex-degree,100deg),rgb(255 255 255/var(--glass-reflex-opacity,10%)) 25%,transparent 25%);border:none;box-shadow:0 0 0 1px rgb(255 255 255/var(--glass-border-opacity,10%)) inset,0 0 0 2px rgb(0 0 0/5%);text-shadow:0 1px rgb(0 0 0/var(--glass-text-shadow-opacity,5%))}}.badge-xs{font-size:.75rem;height:.75rem;line-height:.75rem;padding-left:.313rem;padding-right:.313rem}.badge-sm{font-size:.75rem;height:1rem;line-height:1rem;padding-left:.438rem;padding-right:.438rem}.btn-xs{font-size:.75rem;height:1.5rem;min-height:1.5rem;padding-left:.5rem;padding-right:.5rem}.btn-sm{font-size:.875rem;height:2rem;min-height:2rem;padding-left:.75rem;padding-right:.75rem}.btn-square:where(.btn-xs){height:1.5rem;padding:0;width:1.5rem}.btn-square:where(.btn-sm){height:2rem;padding:0;width:2rem}.btn-circle:where(.btn-xs){border-radius:9999px;height:1.5rem;padding:0;width:1.5rem}.btn-circle:where(.btn-sm){border-radius:9999px;height:2rem;padding:0;width:2rem}[type=checkbox].checkbox-sm{height:1.25rem;width:1.25rem}.input-xs{font-size:.75rem;height:1.5rem;line-height:1rem;line-height:1.625;padding-left:.5rem;padding-right:.5rem}.input-sm{font-size:.875rem;height:2rem;line-height:2rem;padding-left:.75rem;padding-right:.75rem}.join.join-vertical{flex-direction:column}.join.join-vertical .join-item:first-child:not(:last-child),.join.join-vertical :first-child:not(:last-child) .join-item{border-end-end-radius:0;border-end-start-radius:0;border-start-end-radius:inherit;border-start-start-radius:inherit}.join.join-vertical .join-item:last-child:not(:first-child),.join.join-vertical :last-child:not(:first-child) .join-item{border-end-end-radius:inherit;border-end-start-radius:inherit;border-start-end-radius:0;border-start-start-radius:0}.join.join-horizontal{flex-direction:row}.join.join-horizontal .join-item:first-child:not(:last-child),.join.join-horizontal :first-child:not(:last-child) .join-item{border-end-end-radius:0;border-end-start-radius:inherit;border-start-end-radius:0;border-start-start-radius:inherit}.join.join-horizontal .join-item:last-child:not(:first-child),.join.join-horizontal :last-child:not(:first-child) .join-item{border-end-end-radius:inherit;border-end-start-radius:0;border-start-end-radius:inherit;border-start-start-radius:0}.menu-horizontal{display:inline-flex;flex-direction:row}.menu-horizontal>li:not(.menu-title)>details>ul{position:absolute}.stats-vertical{grid-auto-flow:row}.steps-horizontal .step{display:grid;grid-template-columns:repeat(1,minmax(0,1fr));grid-template-rows:repeat(2,minmax(0,1fr));place-items:center;text-align:center}.steps-vertical .step{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));grid-template-rows:repeat(1,minmax(0,1fr))}.tabs-md :where(.tab){font-size:.875rem;height:2rem;line-height:1.25rem;line-height:2;--tab-padding:1rem}.tabs-lg :where(.tab){font-size:1.125rem;height:3rem;line-height:1.75rem;line-height:2;--tab-padding:1.25rem}.tabs-sm :where(.tab){font-size:.875rem;height:1.5rem;line-height:.75rem;--tab-padding:0.75rem}.tabs-xs :where(.tab){font-size:.75rem;height:1.25rem;line-height:.75rem;--tab-padding:0.5rem}.timeline-vertical{flex-direction:column}.timeline-compact .timeline-start,.timeline-horizontal.timeline-compact .timeline-start{align-self:flex-start;grid-column-end:4;grid-column-start:1;grid-row-end:4;grid-row-start:3;justify-self:center;margin:.25rem}.timeline-compact li:has(.timeline-start) .timeline-end,.timeline-horizontal.timeline-compact li:has(.timeline-start) .timeline-end{grid-column-start:none;grid-row-start:auto}.timeline-vertical.timeline-compact>li{--timeline-col-start:0}.timeline-vertical.timeline-compact .timeline-start{align-self:center;grid-column-end:4;grid-column-start:3;grid-row-end:4;grid-row-start:1;justify-self:start}.timeline-vertical.timeline-compact li:has(.timeline-start) .timeline-end{grid-column-start:auto;grid-row-start:none}:where(.timeline-vertical>li){--timeline-row-start:minmax(0,1fr);--timeline-row-end:minmax(0,1fr);justify-items:center}.timeline-vertical>li>hr{height:100%}:where(.timeline-vertical>li>hr):first-child{grid-column-start:2;grid-row-start:1}:where(.timeline-vertical>li>hr):last-child{grid-column-end:auto;grid-column-start:2;grid-row-end:none;grid-row-start:3}.timeline-vertical .timeline-start{align-self:center;grid-column-end:2;grid-column-start:1;grid-row-end:4;grid-row-start:1;justify-self:end}.timeline-vertical .timeline-end{align-self:center;grid-column-end:4;grid-column-start:3;grid-row-end:4;grid-row-start:1;justify-self:start}.timeline-vertical:where(.timeline-snap-icon)>li{--timeline-col-start:minmax(0,1fr);--timeline-row-start:0.5rem}.timeline-horizontal .timeline-start{align-self:flex-end;grid-column-end:4;grid-column-start:1;grid-row-end:2;grid-row-start:1;justify-self:center}.timeline-horizontal .timeline-end{align-self:flex-start;grid-column-end:4;grid-column-start:1;grid-row-end:4;grid-row-start:3;justify-self:center}.timeline-horizontal:where(.timeline-snap-icon)>li,:where(.timeline-snap-icon)>li{--timeline-col-start:0.5rem;--timeline-row-start:minmax(0,1fr)}.tooltip{--tooltip-offset:calc(100% + 1px + var(--tooltip-tail, 0px))}.tooltip:before{content:var(--tw-content);pointer-events:none;position:absolute;z-index:1;--tw-content:attr(data-tip)}.tooltip-top:before,.tooltip:before{bottom:var(--tooltip-offset);left:50%;right:auto;top:auto;transform:translateX(-50%)}.tooltip-bottom:before{bottom:auto;left:50%;right:auto;top:var(--tooltip-offset);transform:translateX(-50%)}.card-compact .card-body{font-size:.875rem;line-height:1.25rem;padding:1rem}.card-compact .card-title{margin-bottom:.25rem}.card-normal .card-body{font-size:1rem;line-height:1.5rem;padding:var(--padding-card,2rem)}.card-normal .card-title{margin-bottom:.75rem}.join.join-vertical>:where(:not(:first-child)){margin-left:0;margin-right:0;margin-top:-1px}.join.join-horizontal>:where(:not(:first-child)){margin-bottom:0;margin-top:0;margin-inline-start:-1px}.menu-horizontal>li:not(.menu-title)>details>ul{margin-inline-start:0;margin-top:1rem;padding-bottom:.5rem;padding-inline-end:.5rem;padding-top:.5rem}.menu-horizontal>li>details>ul:before{content:none}:where(.menu-horizontal>li:not(.menu-title)>details>ul){border-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));--tw-shadow:0 20px 25px -5px rgba(0,0,0,.1),0 8px 10px -6px rgba(0,0,0,.1);--tw-shadow-colored:0 20px 25px -5px var(--tw-shadow-color),0 8px 10px -6px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.menu-sm :where(li:not(.menu-title)>:not(ul,details,.menu-title)),.menu-sm :where(li:not(.menu-title)>details>summary:not(.menu-title)){border-radius:var(--rounded-btn,.5rem);font-size:.875rem;line-height:1.25rem;padding:.25rem .75rem}.menu-sm .menu-title{padding:.5rem .75rem}.modal-top :where(.modal-box){max-width:none;width:100%;--tw-translate-y:-2.5rem;--tw-scale-x:1;--tw-scale-y:1;border-bottom-left-radius:var(--rounded-box,1rem);border-bottom-right-radius:var(--rounded-box,1rem);border-top-left-radius:0;border-top-right-radius:0;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))}.modal-middle :where(.modal-box){max-width:32rem;width:91.666667%;--tw-translate-y:0px;--tw-scale-x:.9;--tw-scale-y:.9;border-bottom-left-radius:var(--rounded-box,1rem);border-bottom-right-radius:var(--rounded-box,1rem);border-top-left-radius:var(--rounded-box,1rem);border-top-right-radius:var(--rounded-box,1rem);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))}.modal-bottom :where(.modal-box){max-width:none;width:100%;--tw-translate-y:2.5rem;--tw-scale-x:1;--tw-scale-y:1;border-bottom-left-radius:0;border-bottom-right-radius:0;border-top-left-radius:var(--rounded-box,1rem);border-top-right-radius:var(--rounded-box,1rem);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))}.stats-vertical>:not([hidden])~:not([hidden]){--tw-divide-x-reverse:0;--tw-divide-y-reverse:0;border-width:calc(1px*(1 - var(--tw-divide-y-reverse))) calc(0px*var(--tw-divide-x-reverse)) calc(1px*var(--tw-divide-y-reverse)) calc(0px*(1 - var(--tw-divide-x-reverse)))}.stats-vertical{overflow-y:auto}.steps-horizontal .step{grid-template-columns:auto;grid-template-rows:40px 1fr;min-width:4rem}.steps-horizontal .step:before{height:.5rem;width:100%;--tw-translate-x:0px;--tw-translate-y:0px;content:"";margin-inline-start:-100%;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))}:is([dir=rtl] .steps-horizontal .step):before{--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))}.steps-vertical .step{gap:.5rem;grid-template-columns:40px 1fr;grid-template-rows:auto;justify-items:start;min-height:4rem}.steps-vertical .step:before{height:100%;width:.5rem;--tw-translate-x:-50%;--tw-translate-y:-50%;margin-inline-start:50%;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))}:is([dir=rtl] .steps-vertical .step):before{--tw-translate-x:50%;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))}.timeline-vertical>li>hr{width:.25rem}:where(.timeline-vertical:has(.timeline-middle)>li>hr):first-child{border-bottom-left-radius:var(--rounded-badge,1.9rem);border-bottom-right-radius:var(--rounded-badge,1.9rem);border-top-left-radius:0;border-top-right-radius:0}:where(.timeline-vertical:has(.timeline-middle)>li>hr):last-child{border-bottom-left-radius:0;border-bottom-right-radius:0;border-top-left-radius:var(--rounded-badge,1.9rem);border-top-right-radius:var(--rounded-badge,1.9rem)}:where(.timeline-vertical:not(:has(.timeline-middle)) :first-child>hr:last-child){border-bottom-left-radius:0;border-bottom-right-radius:0;border-top-left-radius:var(--rounded-badge,1.9rem);border-top-right-radius:var(--rounded-badge,1.9rem)}:where(.timeline-vertical:not(:has(.timeline-middle)) :last-child>hr:first-child){border-bottom-left-radius:var(--rounded-badge,1.9rem);border-bottom-right-radius:var(--rounded-badge,1.9rem);border-top-left-radius:0;border-top-right-radius:0}:where(.timeline-horizontal:has(.timeline-middle)>li>hr):first-child{border-end-end-radius:var(--rounded-badge,1.9rem);border-end-start-radius:0;border-start-end-radius:var(--rounded-badge,1.9rem);border-start-start-radius:0}:where(.timeline-horizontal:has(.timeline-middle)>li>hr):last-child{border-end-end-radius:0;border-end-start-radius:var(--rounded-badge,1.9rem);border-start-end-radius:0;border-start-start-radius:var(--rounded-badge,1.9rem)}.tooltip{display:inline-block;position:relative;text-align:center;--tooltip-tail:0.1875rem;--tooltip-color:var(--fallback-n,oklch(var(--n)/1));--tooltip-text-color:var(--fallback-nc,oklch(var(--nc)/1));--tooltip-tail-offset:calc(100% + 0.0625rem - var(--tooltip-tail))}.tooltip:after,.tooltip:before{opacity:0;transition-delay:.1s;transition-duration:.2s;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(.4,0,.2,1)}.tooltip:after{border-style:solid;border-width:var(--tooltip-tail,0);content:"";display:block;height:0;position:absolute;width:0}.tooltip:before{background-color:var(--tooltip-color);border-radius:.25rem;color:var(--tooltip-text-color);font-size:.875rem;line-height:1.25rem;max-width:20rem;padding:.25rem .5rem;width:-moz-max-content;width:max-content}.tooltip.tooltip-open:after,.tooltip.tooltip-open:before,.tooltip:hover:after,.tooltip:hover:before{opacity:1;transition-delay:75ms}.tooltip:has(:focus-visible):after,.tooltip:has(:focus-visible):before{opacity:1;transition-delay:75ms}.tooltip:not([data-tip]):hover:after,.tooltip:not([data-tip]):hover:before{opacity:0;visibility:hidden}.tooltip-top:after,.tooltip:after{border-color:var(--tooltip-color) transparent transparent transparent;bottom:var(--tooltip-tail-offset);left:50%;right:auto;top:auto;transform:translateX(-50%)}.tooltip-bottom:after{border-color:transparent transparent var(--tooltip-color) transparent;bottom:auto;left:50%;right:auto;top:var(--tooltip-tail-offset);transform:translateX(-50%)}.prose{color:var(--tw-prose-body);max-width:65ch}.prose :where(p):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:1.25em;margin-top:1.25em}.prose :where([class~=lead]):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-lead);font-size:1.25em;line-height:1.6;margin-bottom:1.2em;margin-top:1.2em}.prose :where(a):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-links);font-weight:500;text-decoration:underline}.prose :where(strong):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-bold);font-weight:600}.prose :where(a strong):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(blockquote strong):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(thead th strong):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(ol):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:decimal;margin-bottom:1.25em;margin-top:1.25em;padding-inline-start:1.625em}.prose :where(ol[type=A]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:upper-alpha}.prose :where(ol[type=a]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:lower-alpha}.prose :where(ol[type=A s]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:upper-alpha}.prose :where(ol[type=a s]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:lower-alpha}.prose :where(ol[type=I]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:upper-roman}.prose :where(ol[type=i]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:lower-roman}.prose :where(ol[type=I s]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:upper-roman}.prose :where(ol[type=i s]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:lower-roman}.prose :where(ol[type="1"]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:decimal}.prose :where(ul):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:disc;margin-bottom:1.25em;margin-top:1.25em;padding-inline-start:1.625em}.prose :where(ol>li):not(:where([class~=not-prose],[class~=not-prose] *))::marker{color:var(--tw-prose-counters);font-weight:400}.prose :where(ul>li):not(:where([class~=not-prose],[class~=not-prose] *))::marker{color:var(--tw-prose-bullets)}.prose :where(dt):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-headings);font-weight:600;margin-top:1.25em}.prose :where(hr):not(:where([class~=not-prose],[class~=not-prose] *)){border-color:var(--tw-prose-hr);border-top-width:1px;margin-bottom:3em;margin-top:3em}.prose :where(blockquote):not(:where([class~=not-prose],[class~=not-prose] *)){border-inline-start-color:var(--tw-prose-quote-borders);border-inline-start-width:.25rem;color:var(--tw-prose-quotes);font-style:italic;font-weight:500;margin-bottom:1.6em;margin-top:1.6em;padding-inline-start:1em;quotes:"\201C""\201D""\2018""\2019"}.prose :where(blockquote p:first-of-type):not(:where([class~=not-prose],[class~=not-prose] *)):before{content:open-quote}.prose :where(blockquote p:last-of-type):not(:where([class~=not-prose],[class~=not-prose] *)):after{content:close-quote}.prose :where(h1):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-headings);font-size:2.25em;font-weight:800;line-height:1.1111111;margin-bottom:.8888889em;margin-top:0}.prose :where(h1 strong):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit;font-weight:900}.prose :where(h2):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-headings);font-size:1.5em;font-weight:700;line-height:1.3333333;margin-bottom:1em;margin-top:2em}.prose :where(h2 strong):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit;font-weight:800}.prose :where(h3):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-headings);font-size:1.25em;font-weight:600;line-height:1.6;margin-bottom:.6em;margin-top:1.6em}.prose :where(h3 strong):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit;font-weight:700}.prose :where(h4):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-headings);font-weight:600;line-height:1.5;margin-bottom:.5em;margin-top:1.5em}.prose :where(h4 strong):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit;font-weight:700}.prose :where(img):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:2em;margin-top:2em}.prose :where(picture):not(:where([class~=not-prose],[class~=not-prose] *)){display:block;margin-bottom:2em;margin-top:2em}.prose :where(video):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:2em;margin-top:2em}.prose :where(kbd):not(:where([class~=not-prose],[class~=not-prose] *)){border-radius:.3125rem;box-shadow:0 0 0 1px rgb(var(--tw-prose-kbd-shadows)/10%),0 3px 0 rgb(var(--tw-prose-kbd-shadows)/10%);color:var(--tw-prose-kbd);font-family:inherit;font-size:.875em;font-weight:500;padding-inline-end:.375em;padding-bottom:.1875em;padding-top:.1875em;padding-inline-start:.375em}.prose :where(code):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-code);font-size:.875em;font-weight:600}.prose :where(code):not(:where([class~=not-prose],[class~=not-prose] *)):before{content:"`"}.prose :where(code):not(:where([class~=not-prose],[class~=not-prose] *)):after{content:"`"}.prose :where(a code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(h1 code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(h2 code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit;font-size:.875em}.prose :where(h3 code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit;font-size:.9em}.prose :where(h4 code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(blockquote code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(thead th code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(pre):not(:where([class~=not-prose],[class~=not-prose] *)){background-color:var(--tw-prose-pre-bg);border-radius:.375rem;color:var(--tw-prose-pre-code);font-size:.875em;font-weight:400;line-height:1.7142857;margin-bottom:1.7142857em;margin-top:1.7142857em;overflow-x:auto;padding-inline-end:1.1428571em;padding-bottom:.8571429em;padding-top:.8571429em;padding-inline-start:1.1428571em}.prose :where(pre code):not(:where([class~=not-prose],[class~=not-prose] *)){background-color:transparent;border-radius:0;border-width:0;color:inherit;font-family:inherit;font-size:inherit;font-weight:inherit;line-height:inherit;padding:0}.prose :where(pre code):not(:where([class~=not-prose],[class~=not-prose] *)):before{content:none}.prose :where(pre code):not(:where([class~=not-prose],[class~=not-prose] *)):after{content:none}.prose :where(table):not(:where([class~=not-prose],[class~=not-prose] *)){font-size:.875em;line-height:1.7142857;margin-bottom:2em;margin-top:2em;table-layout:auto;width:100%}.prose :where(thead):not(:where([class~=not-prose],[class~=not-prose] *)){border-bottom-color:var(--tw-prose-th-borders);border-bottom-width:1px}.prose :where(thead th):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-headings);font-weight:600;padding-inline-end:.5714286em;padding-bottom:.5714286em;padding-inline-start:.5714286em;vertical-align:bottom}.prose :where(tbody tr):not(:where([class~=not-prose],[class~=not-prose] *)){border-bottom-color:var(--tw-prose-td-borders);border-bottom-width:1px}.prose :where(tbody tr:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){border-bottom-width:0}.prose :where(tbody td):not(:where([class~=not-prose],[class~=not-prose] *)){vertical-align:baseline}.prose :where(tfoot):not(:where([class~=not-prose],[class~=not-prose] *)){border-top-color:var(--tw-prose-th-borders);border-top-width:1px}.prose :where(tfoot td):not(:where([class~=not-prose],[class~=not-prose] *)){vertical-align:top}.prose :where(th,td):not(:where([class~=not-prose],[class~=not-prose] *)){text-align:start}.prose :where(figure>*):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:0;margin-top:0}.prose :where(figcaption):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-captions);font-size:.875em;line-height:1.4285714;margin-top:.8571429em}.prose{--tw-prose-body:#374151;--tw-prose-headings:#111827;--tw-prose-lead:#4b5563;--tw-prose-links:#111827;--tw-prose-bold:#111827;--tw-prose-counters:#6b7280;--tw-prose-bullets:#d1d5db;--tw-prose-hr:#e5e7eb;--tw-prose-quotes:#111827;--tw-prose-quote-borders:#e5e7eb;--tw-prose-captions:#6b7280;--tw-prose-kbd:#111827;--tw-prose-kbd-shadows:17 24 39;--tw-prose-code:#111827;--tw-prose-pre-code:#e5e7eb;--tw-prose-pre-bg:#1f2937;--tw-prose-th-borders:#d1d5db;--tw-prose-td-borders:#e5e7eb;--tw-prose-invert-body:#d1d5db;--tw-prose-invert-headings:#fff;--tw-prose-invert-lead:#9ca3af;--tw-prose-invert-links:#fff;--tw-prose-invert-bold:#fff;--tw-prose-invert-counters:#9ca3af;--tw-prose-invert-bullets:#4b5563;--tw-prose-invert-hr:#374151;--tw-prose-invert-quotes:#f3f4f6;--tw-prose-invert-quote-borders:#374151;--tw-prose-invert-captions:#9ca3af;--tw-prose-invert-kbd:#fff;--tw-prose-invert-kbd-shadows:255 255 255;--tw-prose-invert-code:#fff;--tw-prose-invert-pre-code:#d1d5db;--tw-prose-invert-pre-bg:rgba(0,0,0,.5);--tw-prose-invert-th-borders:#4b5563;--tw-prose-invert-td-borders:#374151;font-size:1rem;line-height:1.75}.prose :where(picture>img):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:0;margin-top:0}.prose :where(li):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:.5em;margin-top:.5em}.prose :where(ol>li):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-start:.375em}.prose :where(ul>li):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-start:.375em}.prose :where(.prose>ul>li p):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:.75em;margin-top:.75em}.prose :where(.prose>ul>li>p:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.25em}.prose :where(.prose>ul>li>p:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:1.25em}.prose :where(.prose>ol>li>p:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.25em}.prose :where(.prose>ol>li>p:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:1.25em}.prose :where(ul ul,ul ol,ol ul,ol ol):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:.75em;margin-top:.75em}.prose :where(dl):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:1.25em;margin-top:1.25em}.prose :where(dd):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:.5em;padding-inline-start:1.625em}.prose :where(hr+*):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0}.prose :where(h2+*):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0}.prose :where(h3+*):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0}.prose :where(h4+*):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0}.prose :where(thead th:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-start:0}.prose :where(thead th:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-end:0}.prose :where(tbody td,tfoot td):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-end:.5714286em;padding-bottom:.5714286em;padding-top:.5714286em;padding-inline-start:.5714286em}.prose :where(tbody td:first-child,tfoot td:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-start:0}.prose :where(tbody td:last-child,tfoot td:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-end:0}.prose :where(figure):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:2em;margin-top:2em}.prose :where(.prose>:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0}.prose :where(.prose>:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:0}.visible{visibility:visible}.invisible{visibility:hidden}.static{position:static}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.right-0{right:0}.right-5{right:1.25rem}.top-0{top:0}.top-5{top:1.25rem}.z-10{z-index:10}.z-\[10000\]{z-index:10000}.z-\[1\]{z-index:1}.m-0{margin:0}.m-1{margin:.25rem}.m-5{margin:1.25rem}.m-auto{margin:auto}.mx-1{margin-left:.25rem;margin-right:.25rem}.mx-4{margin-left:1rem;margin-right:1rem}.mx-5{margin-left:1.25rem;margin-right:1.25rem}.mx-auto{margin-left:auto;margin-right:auto}.my-10{margin-bottom:2.5rem;margin-top:2.5rem}.my-2{margin-bottom:.5rem;margin-top:.5rem}.my-3{margin-bottom:.75rem;margin-top:.75rem}.my-5{margin-bottom:1.25rem;margin-top:1.25rem}.my-4{margin-bottom:1rem;margin-top:1rem}.mb-10{margin-bottom:2.5rem}.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.mb-5{margin-bottom:1.25rem}.mb-6{margin-bottom:1.5rem}.mb-8{margin-bottom:2rem}.ml-2{margin-left:.5rem}.ml-4{margin-left:1rem}.mr-2{margin-right:.5rem}.mr-4{margin-right:1rem}.mt-10{margin-top:2.5rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-5{margin-top:1.25rem}.mt-6{margin-top:1.5rem}.mt-8{margin-top:2rem}.block{display:block}.inline-block{display:inline-block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.contents{display:contents}.hidden{display:none}.h-32{height:8rem}.h-5{height:1.25rem}.h-6{height:1.5rem}.h-8{height:2rem}.h-96{height:24rem}.h-\[25rem\]{height:25rem}.h-fit{height:-moz-fit-content;height:fit-content}.h-full{height:100%}.min-h-80{min-height:20rem}.min-h-screen{min-height:100vh}.min-h-\[25rem\]{min-height:25rem}.min-h-12{min-height:3rem}.min-h-16{min-height:4rem}.min-h-20{min-height:5rem}.min-h-\[200px\]{min-height:200px}.min-h-10{min-height:2.5rem}.min-h-8{min-height:2rem}.w-10\/12{width:83.333333%}.w-4{width:1rem}.w-4\/12{width:33.333333%}.w-48{width:12rem}.w-5{width:1.25rem}.w-52{width:13rem}.w-6{width:1.5rem}.w-8{width:2rem}.w-96{width:24rem}.w-auto{width:auto}.w-full{width:100%}.min-w-52{min-width:13rem}.min-w-full{min-width:100%}.max-w-2xl{max-width:42rem}.max-w-4xl{max-width:56rem}.max-w-5xl{max-width:64rem}.max-w-md{max-width:28rem}.max-w-sm{max-width:24rem}.max-w-xs{max-width:20rem}.flex-1{flex:1 1 0%}.flex-shrink-0,.shrink-0{flex-shrink:0}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.flex-row{flex-direction:row}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-5{gap:1.25rem}.gap-6{gap:1.5rem}.space-x-3>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(.75rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(.75rem*var(--tw-space-x-reverse))}.space-x-4>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(1rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(1rem*var(--tw-space-x-reverse))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(.5rem*var(--tw-space-y-reverse));margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(1rem*var(--tw-space-y-reverse));margin-top:calc(1rem*(1 - var(--tw-space-y-reverse)))}.justify-self-end{justify-self:end}.justify-self-center{justify-self:center}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.rounded{border-radius:.25rem}.rounded-box{border-radius:var(--rounded-box,1rem)}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.rounded-t-none{border-top-left-radius:0;border-top-right-radius:0}.border{border-width:1px}.border-base-300{--tw-border-opacity:1;border-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-border-opacity)))}.border-blue-300{--tw-border-opacity:1;border-color:rgb(147 197 253/var(--tw-border-opacity))}.border-red-300{--tw-border-opacity:1;border-color:rgb(252 165 165/var(--tw-border-opacity))}.bg-base-100{--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)))}.bg-base-200{--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)))}.bg-base-300{--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity)))}.bg-blue-100{--tw-bg-opacity:1;background-color:rgb(219 234 254/var(--tw-bg-opacity))}.bg-blue-500{--tw-bg-opacity:1;background-color:rgb(59 130 246/var(--tw-bg-opacity))}.bg-blue-600{--tw-bg-opacity:1;background-color:rgb(37 99 235/var(--tw-bg-opacity))}.bg-blue-900{--tw-bg-opacity:1;background-color:rgb(30 58 138/var(--tw-bg-opacity))}.bg-gray-100{--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity))}.bg-gray-500{--tw-bg-opacity:1;background-color:rgb(107 114 128/var(--tw-bg-opacity))}.bg-green-50{--tw-bg-opacity:1;background-color:rgb(240 253 244/var(--tw-bg-opacity))}.bg-green-500{--tw-bg-opacity:1;background-color:rgb(34 197 94/var(--tw-bg-opacity))}.bg-neutral{--tw-bg-opacity:1;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)))}.bg-red-100{--tw-bg-opacity:1;background-color:rgb(254 226 226/var(--tw-bg-opacity))}.bg-red-50{--tw-bg-opacity:1;background-color:rgb(254 242 242/var(--tw-bg-opacity))}.bg-red-500{--tw-bg-opacity:1;background-color:rgb(239 68 68/var(--tw-bg-opacity))}.bg-secondary-content{--tw-bg-opacity:1;background-color:var(--fallback-sc,oklch(var(--sc)/var(--tw-bg-opacity)))}.stroke-current{stroke:currentColor}.stroke-info{stroke:var(--fallback-in,oklch(var(--in)/1))}.object-cover{-o-object-fit:cover;object-fit:cover}.p-0{padding:0}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-4{padding:1rem}.p-5{padding:1.25rem}.p-6{padding:1.5rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-5{padding-left:1.25rem;padding-right:1.25rem}.py-2{padding-bottom:.5rem;padding-top:.5rem}.py-20{padding-bottom:5rem;padding-top:5rem}.py-3{padding-bottom:.75rem;padding-top:.75rem}.py-4{padding-bottom:1rem;padding-top:1rem}.py-5{padding-bottom:1.25rem;padding-top:1.25rem}.py-6{padding-bottom:1.5rem;padding-top:1.5rem}.py-8{padding-bottom:2rem;padding-top:2rem}.text-center{text-align:center}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.text-2xl{font-size:1.5rem;line-height:2rem}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-4xl{font-size:2.25rem;line-height:2.5rem}.text-5xl{font-size:3rem;line-height:1}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-black{font-weight:900}.font-bold{font-weight:700}.font-medium{font-weight:500}.font-semibold{font-weight:600}.normal-case{text-transform:none}.italic{font-style:italic}.text-base-content{--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}.text-base-content\/60{color:var(--fallback-bc,oklch(var(--bc)/.6))}.text-blue-600{--tw-text-opacity:1;color:rgb(37 99 235/var(--tw-text-opacity))}.text-blue-700{--tw-text-opacity:1;color:rgb(29 78 216/var(--tw-text-opacity))}.text-gray-500{--tw-text-opacity:1;color:rgb(107 114 128/var(--tw-text-opacity))}.text-gray-600{--tw-text-opacity:1;color:rgb(75 85 99/var(--tw-text-opacity))}.text-green-500{--tw-text-opacity:1;color:rgb(34 197 94/var(--tw-text-opacity))}.text-neutral-content{--tw-text-opacity:1;color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity)))}.text-primary{--tw-text-opacity:1;color:var(--fallback-p,oklch(var(--p)/var(--tw-text-opacity)))}.text-red-500{--tw-text-opacity:1;color:rgb(239 68 68/var(--tw-text-opacity))}.text-red-700{--tw-text-opacity:1;color:rgb(185 28 28/var(--tw-text-opacity))}.text-secondary{--tw-text-opacity:1;color:var(--fallback-s,oklch(var(--s)/var(--tw-text-opacity)))}.text-success{--tw-text-opacity:1;color:var(--fallback-su,oklch(var(--su)/var(--tw-text-opacity)))}.text-warning{--tw-text-opacity:1;color:var(--fallback-wa,oklch(var(--wa)/var(--tw-text-opacity)))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity))}.underline{text-decoration-line:underline}.decoration-dotted{text-decoration-style:dotted}.opacity-0{opacity:0}.shadow{--tw-shadow:0 1px 3px 0 rgba(0,0,0,.1),0 1px 2px -1px rgba(0,0,0,.1);--tw-shadow-colored:0 1px 3px 0 var(--tw-shadow-color),0 1px 2px -1px var(--tw-shadow-color)}.shadow,.shadow-2xl{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-2xl{--tw-shadow:0 25px 50px -12px rgba(0,0,0,.25);--tw-shadow-colored:0 25px 50px -12px var(--tw-shadow-color)}.shadow-lg{--tw-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color)}.shadow-lg,.shadow-xl{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-xl{--tw-shadow:0 20px 25px -5px rgba(0,0,0,.1),0 8px 10px -6px rgba(0,0,0,.1);--tw-shadow-colored:0 20px 25px -5px var(--tw-shadow-color),0 8px 10px -6px var(--tw-shadow-color)}.blur{--tw-blur:blur(8px)}.blur,.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition{transition-duration:.15s;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(.4,0,.2,1)}.transition-opacity{transition-duration:.15s;transition-property:opacity;transition-timing-function:cubic-bezier(.4,0,.2,1)}.transition-transform{transition-duration:.15s;transition-property:transform;transition-timing-function:cubic-bezier(.4,0,.2,1)}.duration-200{transition-duration:.2s}.duration-300{transition-duration:.3s}@tailwind daisyui;@media (hover:hover){.hover\:btn-ghost:hover:hover{border-color:transparent}@supports (color:oklch(0 0 0)){.hover\:btn-ghost:hover:hover{background-color:var(--fallback-bc,oklch(var(--bc)/.2))}}}.hover\:btn-ghost:hover{background-color:transparent;border-color:transparent;border-width:1px;color:currentColor;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);outline-color:currentColor}.hover\:btn-ghost:hover.btn-active{background-color:var(--fallback-bc,oklch(var(--bc)/.2));border-color:transparent}.focus\:input-ghost:focus{--tw-bg-opacity:0.05}.focus\:input-ghost:focus:focus,.focus\:input-ghost:focus:focus-within{--tw-bg-opacity:1;--tw-text-opacity:1;box-shadow:none;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}@media not all and (min-width:768px){.max-md\:timeline-compact,.max-md\:timeline-compact
-.timeline-horizontal{--timeline-row-start:0}.max-md\:timeline-compact .timeline-horizontal .timeline-start,.max-md\:timeline-compact .timeline-start{align-self:flex-start;grid-column-end:4;grid-column-start:1;grid-row-end:4;grid-row-start:3;justify-self:center;margin:.25rem}.max-md\:timeline-compact .timeline-horizontal li:has(.timeline-start) .timeline-end,.max-md\:timeline-compact li:has(.timeline-start) .timeline-end{grid-column-start:none;grid-row-start:auto}.max-md\:timeline-compact.timeline-vertical>li{--timeline-col-start:0}.max-md\:timeline-compact.timeline-vertical .timeline-start{align-self:center;grid-column-end:4;grid-column-start:3;grid-row-end:4;grid-row-start:1;justify-self:start}.max-md\:timeline-compact.timeline-vertical li:has(.timeline-start) .timeline-end{grid-column-start:auto;grid-row-start:none}}@media (min-width:1024px){.lg\:stats-horizontal{grid-auto-flow:column}.lg\:stats-horizontal>:not([hidden])~:not([hidden]){--tw-divide-x-reverse:0;--tw-divide-y-reverse:0;border-width:calc(0px*(1 - var(--tw-divide-y-reverse))) calc(1px*var(--tw-divide-x-reverse)) calc(0px*var(--tw-divide-y-reverse)) calc(1px*(1 - var(--tw-divide-x-reverse)))}.lg\:stats-horizontal{overflow-x:auto}:is([dir=rtl] .lg\:stats-horizontal){--tw-divide-x-reverse:1}}.hover\:scale-105:hover{--tw-scale-x:1.05;--tw-scale-y:1.05;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))}.hover\:cursor-pointer:hover{cursor:pointer}.hover\:no-underline:hover{text-decoration-line:none}.hover\:shadow-lg:hover{--tw-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.hover\:shadow-blue-500\/50:hover{--tw-shadow-color:rgba(59,130,246,.5);--tw-shadow:var(--tw-shadow-colored)}.group:hover .group-hover\:opacity-100{opacity:1}@media (min-width:640px){.sm\:inline{display:inline}.sm\:w-2\/12{width:16.666667%}.sm\:w-6\/12{width:50%}.sm\:grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:flex-row{flex-direction:row}.sm\:items-end{align-items:flex-end}.sm\:space-x-4>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(1rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(1rem*var(--tw-space-x-reverse))}.sm\:space-y-0>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(0px*var(--tw-space-y-reverse));margin-top:calc(0px*(1 - var(--tw-space-y-reverse)))}}@media (min-width:768px){.md\:w-1\/12{width:8.333333%}.md\:w-2\/12{width:16.666667%}.md\:w-2\/3{width:66.666667%}.md\:w-3\/12{width:25%}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:flex-row{flex-direction:row}.md\:items-end{align-items:flex-end}.md\:space-x-4>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(1rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(1rem*var(--tw-space-x-reverse))}.md\:text-end{text-align:end}}@media (min-width:1024px){.lg\:mt-0{margin-top:0}.lg\:flex{display:flex}.lg\:hidden{display:none}.lg\:w-1\/2{width:50%}.lg\:w-1\/6,.lg\:w-2\/12{width:16.666667%}.lg\:w-3\/12{width:25%}.lg\:w-5\/6{width:83.333333%}.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:flex-row{flex-direction:row}.lg\:flex-row-reverse{flex-direction:row-reverse}.lg\:space-x-4>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(1rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(1rem*var(--tw-space-x-reverse))}.lg\:text-left{text-align:left}}
\ No newline at end of file
+ );position:relative}.timeline>li>hr{border-width:0;width:100%}:where(.timeline>li>hr):first-child{grid-column-start:1;grid-row-start:2}:where(.timeline>li>hr):last-child{grid-column-end:none;grid-column-start:3;grid-row-end:auto;grid-row-start:2}.timeline-start{align-self:flex-end;grid-column-end:4;grid-column-start:1;grid-row-end:2;grid-row-start:1;justify-self:center;margin:.25rem}.timeline-middle{grid-column-start:2;grid-row-start:2}.timeline-end{align-self:flex-start;grid-column-end:4;grid-column-start:1;grid-row-end:4;grid-row-start:3;justify-self:center;margin:.25rem}.toggle{flex-shrink:0;--tglbg:var(--fallback-b1,oklch(var(--b1)/1));--handleoffset:1.5rem;--handleoffsetcalculator:calc(var(--handleoffset)*-1);--togglehandleborder:0 0;-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:currentColor;border-color:currentColor;border-radius:var(--rounded-badge,1.9rem);border-width:1px;box-shadow:var(--handleoffsetcalculator) 0 0 2px var(--tglbg) inset,0 0 0 2px var(--tglbg) inset,var(--togglehandleborder);color:var(--fallback-bc,oklch(var(--bc)/.5));cursor:pointer;height:1.5rem;transition:background,box-shadow var(--animation-input,.2s) ease-out;width:3rem}.alert-info{border-color:var(--fallback-in,oklch(var(--in)/.2));--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)));--alert-bg:var(--fallback-in,oklch(var(--in)/1));--alert-bg-mix:var(--fallback-b1,oklch(var(--b1)/1))}.badge-primary{--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)))}.badge-outline{border-color:currentColor;--tw-border-opacity:0.5;background-color:transparent;color:currentColor}.badge-outline.badge-neutral{--tw-text-opacity:1;color:var(--fallback-n,oklch(var(--n)/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-secondary{--tw-text-opacity:1;color:var(--fallback-s,oklch(var(--s)/var(--tw-text-opacity)))}.badge-outline.badge-accent{--tw-text-opacity:1;color:var(--fallback-a,oklch(var(--a)/var(--tw-text-opacity)))}.badge-outline.badge-info{--tw-text-opacity:1;color:var(--fallback-in,oklch(var(--in)/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-warning{--tw-text-opacity:1;color:var(--fallback-wa,oklch(var(--wa)/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>* .label{font-size:1rem;line-height:1.5rem}.btn:active:focus,.btn:active:hover{animation:button-pop 0s ease-out;transform:scale(var(--btn-focus-scale,.97))}@supports not (color:oklch(0 0 0)){.btn{background-color:var(--btn-color,var(--fallback-b2));border-color:var(--btn-color,var(--fallback-b2))}.btn-primary{--btn-color:var(--fallback-p)}.btn-neutral{--btn-color:var(--fallback-n)}.btn-info{--btn-color:var(--fallback-in)}.btn-success{--btn-color:var(--fallback-su)}.btn-warning{--btn-color:var(--fallback-wa)}.btn-error{--btn-color:var(--fallback-er)}}@supports (color:color-mix(in oklab,black,black)){.btn-active{background-color:color-mix(in oklab,oklch(var(--btn-color,var(--b3))/var(--tw-bg-opacity,1)) 90%,#000);border-color:color-mix(in oklab,oklch(var(--btn-color,var(--b3))/var(--tw-border-opacity,1)) 90%,#000)}.btn-outline.btn-primary.btn-active{background-color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 90%,#000)}.btn-outline.btn-secondary.btn-active{background-color:color-mix(in oklab,var(--fallback-s,oklch(var(--s)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-s,oklch(var(--s)/1)) 90%,#000)}.btn-outline.btn-accent.btn-active{background-color:color-mix(in oklab,var(--fallback-a,oklch(var(--a)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-a,oklch(var(--a)/1)) 90%,#000)}.btn-outline.btn-success.btn-active{background-color:color-mix(in oklab,var(--fallback-su,oklch(var(--su)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-su,oklch(var(--su)/1)) 90%,#000)}.btn-outline.btn-info.btn-active{background-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000)}.btn-outline.btn-warning.btn-active{background-color:color-mix(in oklab,var(--fallback-wa,oklch(var(--wa)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-wa,oklch(var(--wa)/1)) 90%,#000)}.btn-outline.btn-error.btn-active{background-color:color-mix(in oklab,var(--fallback-er,oklch(var(--er)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-er,oklch(var(--er)/1)) 90%,#000)}}.btn:focus-visible{outline-offset:2px;outline-style:solid;outline-width:2px}.btn-primary{--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)));outline-color:var(--fallback-p,oklch(var(--p)/1))}@supports (color:oklch(0 0 0)){.btn-primary{--btn-color:var(--p)}.btn-neutral{--btn-color:var(--n)}.btn-info{--btn-color:var(--in)}.btn-success{--btn-color:var(--su)}.btn-warning{--btn-color:var(--wa)}.btn-error{--btn-color:var(--er)}}.btn-neutral{--tw-text-opacity:1;color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity)));outline-color:var(--fallback-n,oklch(var(--n)/1))}.btn-info{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)));outline-color:var(--fallback-in,oklch(var(--in)/1))}.btn-success{--tw-text-opacity:1;color:var(--fallback-suc,oklch(var(--suc)/var(--tw-text-opacity)));outline-color:var(--fallback-su,oklch(var(--su)/1))}.btn-warning{--tw-text-opacity:1;color:var(--fallback-wac,oklch(var(--wac)/var(--tw-text-opacity)));outline-color:var(--fallback-wa,oklch(var(--wa)/1))}.btn-error{--tw-text-opacity:1;color:var(--fallback-erc,oklch(var(--erc)/var(--tw-text-opacity)));outline-color:var(--fallback-er,oklch(var(--er)/1))}.btn.glass{--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);outline-color:currentColor}.btn.glass.btn-active{--glass-opacity:25%;--glass-border-opacity:15%}.btn-ghost{background-color:transparent;border-color:transparent;border-width:1px;color:currentColor;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);outline-color:currentColor}.btn-ghost.btn-active{background-color:var(--fallback-bc,oklch(var(--bc)/.2));border-color:transparent}.btn-link.btn-active{background-color:transparent;border-color:transparent;text-decoration-line:underline}.btn-outline.btn-active{--tw-border-opacity:1;border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-b1,oklch(var(--b1)/var(--tw-text-opacity)))}.btn-outline.btn-primary{--tw-text-opacity:1;color:var(--fallback-p,oklch(var(--p)/var(--tw-text-opacity)))}.btn-outline.btn-primary.btn-active{--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}.btn-outline.btn-secondary.btn-active{--tw-text-opacity:1;color:var(--fallback-sc,oklch(var(--sc)/var(--tw-text-opacity)))}.btn-outline.btn-accent.btn-active{--tw-text-opacity:1;color:var(--fallback-ac,oklch(var(--ac)/var(--tw-text-opacity)))}.btn-outline.btn-success{--tw-text-opacity:1;color:var(--fallback-su,oklch(var(--su)/var(--tw-text-opacity)))}.btn-outline.btn-success.btn-active{--tw-text-opacity:1;color:var(--fallback-suc,oklch(var(--suc)/var(--tw-text-opacity)))}.btn-outline.btn-info{--tw-text-opacity:1;color:var(--fallback-in,oklch(var(--in)/var(--tw-text-opacity)))}.btn-outline.btn-info.btn-active{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)))}.btn-outline.btn-warning{--tw-text-opacity:1;color:var(--fallback-wa,oklch(var(--wa)/var(--tw-text-opacity)))}.btn-outline.btn-warning.btn-active{--tw-text-opacity:1;color:var(--fallback-wac,oklch(var(--wac)/var(--tw-text-opacity)))}.btn-outline.btn-error{--tw-text-opacity:1;color:var(--fallback-er,oklch(var(--er)/var(--tw-text-opacity)))}.btn-outline.btn-error.btn-active{--tw-text-opacity:1;color:var(--fallback-erc,oklch(var(--erc)/var(--tw-text-opacity)))}.btn.btn-disabled,.btn:disabled,.btn[disabled]{--tw-border-opacity:0;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-bg-opacity:0.2;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}.btn:is(input[type=checkbox]:checked),.btn:is(input[type=radio]:checked){--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)))}.btn:is(input[type=checkbox]:checked):focus-visible,.btn:is(input[type=radio]:checked):focus-visible{outline-color:var(--fallback-p,oklch(var(--p)/1))}@keyframes button-pop{0%{transform:scale(var(--btn-focus-scale,.98))}40%{transform:scale(1.02)}to{transform:scale(1)}}.card :where(figure:first-child){border-end-end-radius:unset;border-end-start-radius:unset;border-start-end-radius:inherit;border-start-start-radius:inherit;overflow:hidden}.card :where(figure:last-child){border-end-end-radius:inherit;border-end-start-radius:inherit;border-start-end-radius:unset;border-start-start-radius:unset;overflow:hidden}.card:focus-visible{outline:2px solid currentColor;outline-offset:2px}.card.bordered{border-width:1px;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)))}.card.compact .card-body{font-size:.875rem;line-height:1.25rem;padding:1rem}.card-title{align-items:center;display:flex;font-size:1.25rem;font-weight:600;gap:.5rem;line-height:1.75rem}.card.image-full :where(figure){border-radius:inherit;overflow:hidden}.checkbox:focus{box-shadow:none}.checkbox:focus-visible{outline-color:var(--fallback-bc,oklch(var(--bc)/1));outline-offset:2px;outline-style:solid;outline-width:2px}.checkbox:checked,.checkbox[aria-checked=true],.checkbox[checked=true]{animation:checkmark var(--animation-input,.2s) ease-out;background-color:var(--chkbg);background-image:linear-gradient(-45deg,transparent 65%,var(--chkbg) 65.99%),linear-gradient(45deg,transparent 75%,var(--chkbg) 75.99%),linear-gradient(-45deg,var(--chkbg) 40%,transparent 40.99%),linear-gradient(45deg,var(--chkbg) 30%,var(--chkfg) 30.99%,var(--chkfg) 40%,transparent 40.99%),linear-gradient(-45deg,var(--chkfg) 50%,var(--chkbg) 50.99%);background-repeat:no-repeat}.checkbox:indeterminate{--tw-bg-opacity:1;animation:checkmark var(--animation-input,.2s) ease-out;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));background-image:linear-gradient(90deg,transparent 80%,var(--chkbg) 80%),linear-gradient(-90deg,transparent 80%,var(--chkbg) 80%),linear-gradient(0deg,var(--chkbg) 43%,var(--chkfg) 43%,var(--chkfg) 57%,var(--chkbg) 57%);background-repeat:no-repeat}.checkbox:disabled{border-color:transparent;cursor:not-allowed;--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));opacity:.2}@keyframes checkmark{0%{background-position-y:5px}50%{background-position-y:-2px}to{background-position-y:0}}.divider:not(:empty){gap:1rem}.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))}.file-input-bordered{--tw-border-opacity:0.2}.file-input:focus{outline-color:var(--fallback-bc,oklch(var(--bc)/.2));outline-offset:2px;outline-style:solid;outline-width:2px}.file-input-disabled,.file-input[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)));--tw-text-opacity:0.2}.file-input-disabled::-moz-placeholder,.file-input[disabled]::-moz-placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.file-input-disabled::placeholder,.file-input[disabled]::placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.file-input-disabled::file-selector-button,.file-input[disabled]::file-selector-button{--tw-border-opacity:0;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-bg-opacity:0.2;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}.label-text{font-size:.875rem;line-height:1.25rem;--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}.input input{--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));background-color:transparent}.input input:focus{outline:2px solid transparent;outline-offset:2px}.input[list]::-webkit-calendar-picker-indicator{line-height:1em}.input-bordered{border-color:var(--fallback-bc,oklch(var(--bc)/.2))}.input:focus,.input:focus-within{border-color:var(--fallback-bc,oklch(var(--bc)/.2));box-shadow:none;outline-color:var(--fallback-bc,oklch(var(--bc)/.2));outline-offset:2px;outline-style:solid;outline-width:2px}.input-ghost{--tw-bg-opacity:0.05}.input-ghost:focus,.input-ghost:focus-within{--tw-bg-opacity:1;--tw-text-opacity:1;box-shadow:none;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}.input-disabled,.input:disabled,.input[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)/.4))}.input-disabled::-moz-placeholder,.input:disabled::-moz-placeholder,.input[disabled]::-moz-placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.input-disabled::placeholder,.input:disabled::placeholder,.input[disabled]::placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.input::-webkit-date-and-time-value{text-align:inherit}.join>:where(:not(:first-child)){margin-bottom:0;margin-top:0;margin-inline-start:-1px}.join-item:focus{isolation:isolate}.link:focus{outline:2px solid transparent;outline-offset:2px}.link:focus-visible{outline:2px solid currentColor;outline-offset:2px}.loading{aspect-ratio:1/1;background-color:currentColor;display:inline-block;-webkit-mask-position:center;mask-position:center;-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat;-webkit-mask-size:100%;mask-size:100%;pointer-events:none;width:1.5rem}.loading,.loading-spinner{-webkit-mask-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' stroke='%23000'%3E%3Cstyle%3E@keyframes spinner_zKoa{to{transform:rotate(360deg)}}@keyframes spinner_YpZS{0%25{stroke-dasharray:0 150;stroke-dashoffset:0}47.5%25{stroke-dasharray:42 150;stroke-dashoffset:-16}95%25,to{stroke-dasharray:42 150;stroke-dashoffset:-59}}%3C/style%3E%3Cg style='transform-origin:center;animation:spinner_zKoa 2s linear infinite'%3E%3Ccircle cx='12' cy='12' r='9.5' fill='none' stroke-width='3' class='spinner_V8m1' style='stroke-linecap:round;animation:spinner_YpZS 1.5s ease-out infinite'/%3E%3C/g%3E%3C/svg%3E");mask-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' stroke='%23000'%3E%3Cstyle%3E@keyframes spinner_zKoa{to{transform:rotate(360deg)}}@keyframes spinner_YpZS{0%25{stroke-dasharray:0 150;stroke-dashoffset:0}47.5%25{stroke-dasharray:42 150;stroke-dashoffset:-16}95%25,to{stroke-dasharray:42 150;stroke-dashoffset:-59}}%3C/style%3E%3Cg style='transform-origin:center;animation:spinner_zKoa 2s linear infinite'%3E%3Ccircle cx='12' cy='12' r='9.5' fill='none' stroke-width='3' class='spinner_V8m1' style='stroke-linecap:round;animation:spinner_YpZS 1.5s ease-out infinite'/%3E%3C/g%3E%3C/svg%3E")}:where(.menu li:empty){--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));height:1px;margin:.5rem 1rem;opacity:.1}.menu :where(li ul):before{bottom:.75rem;inset-inline-start:0;position:absolute;top:.75rem;width:1px;--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));content:"";opacity:.1}.menu :where(li:not(.menu-title)>:not(ul,details,.menu-title,.btn)),.menu :where(li:not(.menu-title)>details>summary:not(.menu-title)){border-radius:var(--rounded-btn,.5rem);padding:.5rem 1rem;text-align:start;text-wrap:balance;transition-duration:.2s;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(.4,0,.2,1);transition-timing-function:cubic-bezier(0,0,.2,1)}:where(.menu li:not(.menu-title,.disabled)>:not(ul,details,.menu-title)):is(summary):not(.active,.btn):focus-visible,:where(.menu li:not(.menu-title,.disabled)>:not(ul,details,.menu-title)):not(summary,.active,.btn).focus,:where(.menu li:not(.menu-title,.disabled)>:not(ul,details,.menu-title)):not(summary,.active,.btn):focus,:where(.menu li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):is(summary):not(.active,.btn):focus-visible,:where(.menu li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(summary,.active,.btn).focus,:where(.menu li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(summary,.active,.btn):focus{background-color:var(--fallback-bc,oklch(var(--bc)/.1));cursor:pointer;--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));outline:2px solid transparent;outline-offset:2px}.menu li>:not(ul,.menu-title,details,.btn).active,.menu li>:not(ul,.menu-title,details,.btn):active,.menu li>details>summary:active{--tw-bg-opacity:1;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity)))}.menu :where(li>details>summary)::-webkit-details-marker{display:none}.menu :where(li>.menu-dropdown-toggle):after,.menu :where(li>details>summary):after{box-shadow:2px 2px;content:"";display:block;height:.5rem;justify-self:end;margin-top:-.5rem;pointer-events:none;transform:rotate(45deg);transform-origin:75% 75%;transition-duration:.3s;transition-property:transform,margin-top;transition-timing-function:cubic-bezier(.4,0,.2,1);width:.5rem}.menu :where(li>.menu-dropdown-toggle.menu-dropdown-show):after,.menu :where(li>details[open]>summary):after{margin-top:0;transform:rotate(225deg)}.mockup-code:before{border-radius:9999px;box-shadow:1.4em 0,2.8em 0,4.2em 0;content:"";display:block;height:.75rem;margin-bottom:1rem;opacity:.3;width:.75rem}.mockup-code pre{padding-right:1.25rem}.mockup-code pre:before{content:"";margin-right:2ch}.mockup-phone .display{border-radius:40px;margin-top:-25px;overflow:hidden}.mockup-browser .mockup-browser-toolbar .input{display:block;height:1.75rem;margin-left:auto;margin-right:auto;overflow:hidden;position:relative;text-overflow:ellipsis;white-space:nowrap;width:24rem;--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));direction:ltr;padding-left:2rem}.mockup-browser .mockup-browser-toolbar .input:before{aspect-ratio:1/1;height:.75rem;left:.5rem;--tw-translate-y:-50%;border-color:currentColor;border-radius:9999px;border-width:2px}.mockup-browser .mockup-browser-toolbar .input:after,.mockup-browser .mockup-browser-toolbar .input:before{content:"";opacity:.6;position:absolute;top:50%;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))}.mockup-browser .mockup-browser-toolbar .input:after{height:.5rem;left:1.25rem;--tw-translate-y:25%;--tw-rotate:-45deg;border-color:currentColor;border-radius:9999px;border-width:1px}.modal::backdrop,.modal:not(dialog:not(.modal-open)){animation:modal-pop .2s ease-out;background-color:#0006}.modal-backdrop{align-self:stretch;color:transparent;display:grid;grid-column-start:1;grid-row-start:1;justify-self:stretch;z-index:-1}.modal-open .modal-box,.modal-toggle:checked+.modal .modal-box,.modal:target .modal-box,.modal[open] .modal-box{--tw-translate-y:0px;--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))}@keyframes modal-pop{0%{opacity:0}}.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)))}.progress:indeterminate{--progress-color:var(--fallback-bc,oklch(var(--bc)/1));animation:progress-loading 5s ease-in-out infinite;background-image:repeating-linear-gradient(90deg,var(--progress-color) -1%,var(--progress-color) 10%,transparent 10%,transparent 90%);background-position-x:15%;background-size:200%}.progress::-webkit-progress-bar{background-color:transparent;border-radius:var(--rounded-box,1rem)}.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)))}.progress:indeterminate::-moz-progress-bar{animation:progress-loading 5s ease-in-out infinite;background-color:transparent;background-image:repeating-linear-gradient(90deg,var(--progress-color) -1%,var(--progress-color) 10%,transparent 10%,transparent 90%);background-position-x:15%;background-size:200%}@keyframes progress-loading{50%{background-position-x:-115%}}.radio:focus{box-shadow:none}.radio:focus-visible{outline-color:var(--fallback-bc,oklch(var(--bc)/1));outline-offset:2px;outline-style:solid;outline-width:2px}.radio:checked,.radio[aria-checked=true]{--tw-bg-opacity:1;animation:radiomark var(--animation-input,.2s) ease-out;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));background-image:none;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:.2}@keyframes radiomark{0%{box-shadow:0 0 0 12px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 12px var(--fallback-b1,oklch(var(--b1)/1)) inset}50%{box-shadow:0 0 0 3px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 3px var(--fallback-b1,oklch(var(--b1)/1)) inset}to{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}}@keyframes rating-pop{0%{transform:translateY(-.125em)}40%{transform:translateY(-.125em)}to{transform:translateY(0)}}.select-bordered,.select:focus{border-color:var(--fallback-bc,oklch(var(--bc)/.2))}.select:focus{box-shadow:none;outline-color:var(--fallback-bc,oklch(var(--bc)/.2));outline-offset:2px;outline-style:solid;outline-width:2px}.select-disabled,.select:disabled,.select[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)/var(--tw-text-opacity)));--tw-text-opacity:0.2}.select-disabled::-moz-placeholder,.select:disabled::-moz-placeholder,.select[disabled]::-moz-placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.select-disabled::placeholder,.select:disabled::placeholder,.select[disabled]::placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.select-multiple,.select[multiple],.select[size].select:not([size="1"]){background-image:none;padding-right:1rem}[dir=rtl] .select{background-position:12px calc(1px + 50%),16px calc(1px + 50%)}@keyframes skeleton{0%{background-position:150%}to{background-position:-50%}}:where(.stats)>:not([hidden])~:not([hidden]){--tw-divide-x-reverse:0;--tw-divide-y-reverse:0;border-width:calc(0px*(1 - var(--tw-divide-y-reverse))) calc(1px*var(--tw-divide-x-reverse)) calc(0px*var(--tw-divide-y-reverse)) calc(1px*(1 - var(--tw-divide-x-reverse)))}:is([dir=rtl] .stats>:not([hidden])~:not([hidden])){--tw-divide-x-reverse:1}.steps .step:before{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));content:"";height:.5rem;margin-inline-start:-100%;top:0;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));width:100%}.steps .step:after,.steps .step:before{grid-column-start:1;grid-row-start:1;--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity)));--tw-text-opacity:1}.steps .step:after{border-radius:9999px;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));content:counter(step);counter-increment:step;display:grid;height:2rem;place-items:center;place-self:center;position:relative;width:2rem;z-index:1}.steps .step:first-child:before{content:none}.steps .step[data-content]:after{content:attr(data-content)}.tabs-lifted>.tab:focus-visible{border-end-end-radius:0;border-end-start-radius:0}.tab.tab-active:not(.tab-disabled):not([disabled]),.tab:is(input:checked){border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));--tw-border-opacity:1;--tw-text-opacity:1}.tab:focus{outline:2px solid transparent;outline-offset:2px}.tab:focus-visible{outline:2px solid currentColor;outline-offset:-5px}.tab-disabled,.tab[disabled]{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));cursor:not-allowed;--tw-text-opacity:0.2}.tabs-bordered>.tab{border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));--tw-border-opacity:0.2;border-bottom-width:calc(var(--tab-border, 1px) + 1px);border-style:solid}.tabs-lifted>.tab{border:var(--tab-border,1px) solid transparent;border-bottom-color:var(--tab-border-color);border-start-end-radius:var(--tab-radius,.5rem);border-start-start-radius:var(--tab-radius,.5rem);border-width:0 0 var(--tab-border,1px) 0;padding-inline-end:var(--tab-padding,1rem);padding-inline-start:var(--tab-padding,1rem);padding-top:var(--tab-border,1px)}.tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]),.tabs-lifted>.tab:is(input:checked){background-color:var(--tab-bg);border-inline-end-color:var(--tab-border-color);border-inline-start-color:var(--tab-border-color);border-top-color:var(--tab-border-color);border-width:var(--tab-border,1px) var(--tab-border,1px) 0 var(--tab-border,1px);padding-inline-end:calc(var(--tab-padding, 1rem) - var(--tab-border, 1px));padding-bottom:var(--tab-border,1px);padding-inline-start:calc(var(--tab-padding, 1rem) - var(--tab-border, 1px));padding-top:0}.tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]):before,.tabs-lifted>.tab:is(input:checked):before{background-position:0 0,100% 0;background-repeat:no-repeat;background-size:var(--tab-radius,.5rem);bottom:0;content:"";display:block;height:var(--tab-radius,.5rem);position:absolute;width:calc(100% + var(--tab-radius, .5rem)*2);z-index:1;--tab-grad:calc(69% - var(--tab-border, 1px));--radius-start:radial-gradient(circle at top left,transparent var(--tab-grad),var(--tab-border-color) calc(var(--tab-grad) + 0.25px),var(--tab-border-color) calc(var(--tab-grad) + var(--tab-border, 1px)),var(--tab-bg) calc(var(--tab-grad) + var(--tab-border, 1px) + 0.25px));--radius-end:radial-gradient(circle at top right,transparent var(--tab-grad),var(--tab-border-color) calc(var(--tab-grad) + 0.25px),var(--tab-border-color) calc(var(--tab-grad) + var(--tab-border, 1px)),var(--tab-bg) calc(var(--tab-grad) + var(--tab-border, 1px) + 0.25px));background-image:var(--radius-start),var(--radius-end)}.tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]):first-child:before,.tabs-lifted>.tab:is(input:checked):first-child:before{background-image:var(--radius-end);background-position:100% 0}[dir=rtl] .tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]):first-child:before,[dir=rtl] .tabs-lifted>.tab:is(input:checked):first-child:before{background-image:var(--radius-start);background-position:0 0}.tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]):last-child:before,.tabs-lifted>.tab:is(input:checked):last-child:before{background-image:var(--radius-start);background-position:0 0}[dir=rtl] .tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]):last-child:before,[dir=rtl] .tabs-lifted>.tab:is(input:checked):last-child:before{background-image:var(--radius-end);background-position:100% 0}.tabs-lifted>.tab-active:not(.tab-disabled):not([disabled])+.tabs-lifted .tab-active:not(.tab-disabled):not([disabled]):before,.tabs-lifted>.tab:is(input:checked)+.tabs-lifted .tab:is(input:checked):before{background-image:var(--radius-end);background-position:100% 0}.tabs-boxed{--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));padding:.25rem}.tabs-boxed,.tabs-boxed .tab{border-radius:var(--rounded-btn,.5rem)}.tabs-boxed .tab-active:not(.tab-disabled):not([disabled]),.tabs-boxed :is(input:checked){--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)))}:is([dir=rtl] .table){text-align:right}.table :where(th,td){padding:.75rem 1rem;vertical-align:middle}.table tr.active,.table tr.active:nth-child(2n),.table-zebra tbody tr:nth-child(2n){--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)))}.table :where(thead,tbody) :where(tr:first-child:last-child),.table :where(thead,tbody) :where(tr:not(:last-child)){border-bottom-width:1px;--tw-border-opacity:1;border-bottom-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)))}.table :where(thead,tfoot){color:var(--fallback-bc,oklch(var(--bc)/.6));font-size:.75rem;font-weight:700;line-height:1rem;white-space:nowrap}.timeline hr{height:.25rem}:where(.timeline hr){--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity)))}:where(.timeline:has(.timeline-middle) hr):first-child{border-end-end-radius:var(--rounded-badge,1.9rem);border-end-start-radius:0;border-start-end-radius:var(--rounded-badge,1.9rem);border-start-start-radius:0}:where(.timeline:has(.timeline-middle) hr):last-child{border-end-end-radius:0;border-end-start-radius:var(--rounded-badge,1.9rem);border-start-end-radius:0;border-start-start-radius:var(--rounded-badge,1.9rem)}:where(.timeline:not(:has(.timeline-middle)) :first-child hr:last-child){border-end-end-radius:0;border-end-start-radius:var(--rounded-badge,1.9rem);border-start-end-radius:0;border-start-start-radius:var(--rounded-badge,1.9rem)}:where(.timeline:not(:has(.timeline-middle)) :last-child hr:first-child){border-end-end-radius:var(--rounded-badge,1.9rem);border-end-start-radius:0;border-start-end-radius:var(--rounded-badge,1.9rem);border-start-start-radius:0}.timeline-box{border-radius:var(--rounded-box,1rem);border-width:1px;--tw-border-opacity:1;border-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));padding:.5rem 1rem;--tw-shadow:0 1px 2px 0 rgba(0,0,0,.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}@keyframes toast-pop{0%{opacity:0;transform:scale(.9)}to{opacity:1;transform:scale(1)}}[dir=rtl] .toggle{--handleoffsetcalculator:calc(var(--handleoffset)*1)}.toggle:focus-visible{outline-color:var(--fallback-bc,oklch(var(--bc)/.2));outline-offset:2px;outline-style:solid;outline-width:2px}.toggle:hover{background-color:currentColor}.toggle:checked,.toggle[aria-checked=true],.toggle[checked=true]{background-image:none;--handleoffsetcalculator:var(--handleoffset);--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}[dir=rtl] .toggle:checked,[dir=rtl] .toggle[aria-checked=true],[dir=rtl] .toggle[checked=true]{--handleoffsetcalculator:calc(var(--handleoffset)*-1)}.toggle:indeterminate{--tw-text-opacity:1;box-shadow:calc(var(--handleoffset)/2) 0 0 2px var(--tglbg) inset,calc(var(--handleoffset)/-2) 0 0 2px var(--tglbg) inset,0 0 0 2px var(--tglbg) inset;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}[dir=rtl] .toggle:indeterminate{box-shadow:calc(var(--handleoffset)/2) 0 0 2px var(--tglbg) inset,calc(var(--handleoffset)/-2) 0 0 2px var(--tglbg) inset,0 0 0 2px var(--tglbg) inset}.toggle:disabled{cursor:not-allowed;--tw-border-opacity:1;background-color:transparent;border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));opacity:.3;--togglehandleborder:0 0 0 3px var(--fallback-bc,oklch(var(--bc)/1)) inset,var(--handleoffsetcalculator) 0 0 3px var(--fallback-bc,oklch(var(--bc)/1)) inset}.glass,.glass.btn-active{-webkit-backdrop-filter:blur(var(--glass-blur,40px));backdrop-filter:blur(var(--glass-blur,40px));background-color:transparent;background-image:linear-gradient(135deg,rgb(255 255 255/var(--glass-opacity,30%)) 0,transparent 100%),linear-gradient(var(--glass-reflex-degree,100deg),rgb(255 255 255/var(--glass-reflex-opacity,10%)) 25%,transparent 25%);border:none;box-shadow:0 0 0 1px rgb(255 255 255/var(--glass-border-opacity,10%)) inset,0 0 0 2px rgb(0 0 0/5%);text-shadow:0 1px rgb(0 0 0/var(--glass-text-shadow-opacity,5%))}@media (hover:hover){.glass.btn-active{-webkit-backdrop-filter:blur(var(--glass-blur,40px));backdrop-filter:blur(var(--glass-blur,40px));background-color:transparent;background-image:linear-gradient(135deg,rgb(255 255 255/var(--glass-opacity,30%)) 0,transparent 100%),linear-gradient(var(--glass-reflex-degree,100deg),rgb(255 255 255/var(--glass-reflex-opacity,10%)) 25%,transparent 25%);border:none;box-shadow:0 0 0 1px rgb(255 255 255/var(--glass-border-opacity,10%)) inset,0 0 0 2px rgb(0 0 0/5%);text-shadow:0 1px rgb(0 0 0/var(--glass-text-shadow-opacity,5%))}}.badge-xs{font-size:.75rem;height:.75rem;line-height:.75rem;padding-left:.313rem;padding-right:.313rem}.badge-sm{font-size:.75rem;height:1rem;line-height:1rem;padding-left:.438rem;padding-right:.438rem}.btn-xs{font-size:.75rem;height:1.5rem;min-height:1.5rem;padding-left:.5rem;padding-right:.5rem}.btn-sm{font-size:.875rem;height:2rem;min-height:2rem;padding-left:.75rem;padding-right:.75rem}.btn-square:where(.btn-xs){height:1.5rem;padding:0;width:1.5rem}.btn-square:where(.btn-sm){height:2rem;padding:0;width:2rem}.btn-circle:where(.btn-xs){border-radius:9999px;height:1.5rem;padding:0;width:1.5rem}.btn-circle:where(.btn-sm){border-radius:9999px;height:2rem;padding:0;width:2rem}[type=checkbox].checkbox-sm{height:1.25rem;width:1.25rem}.input-xs{font-size:.75rem;height:1.5rem;line-height:1rem;line-height:1.625;padding-left:.5rem;padding-right:.5rem}.input-sm{font-size:.875rem;height:2rem;line-height:2rem;padding-left:.75rem;padding-right:.75rem}.join.join-vertical{flex-direction:column}.join.join-vertical .join-item:first-child:not(:last-child),.join.join-vertical :first-child:not(:last-child) .join-item{border-end-end-radius:0;border-end-start-radius:0;border-start-end-radius:inherit;border-start-start-radius:inherit}.join.join-vertical .join-item:last-child:not(:first-child),.join.join-vertical :last-child:not(:first-child) .join-item{border-end-end-radius:inherit;border-end-start-radius:inherit;border-start-end-radius:0;border-start-start-radius:0}.join.join-horizontal{flex-direction:row}.join.join-horizontal .join-item:first-child:not(:last-child),.join.join-horizontal :first-child:not(:last-child) .join-item{border-end-end-radius:0;border-end-start-radius:inherit;border-start-end-radius:0;border-start-start-radius:inherit}.join.join-horizontal .join-item:last-child:not(:first-child),.join.join-horizontal :last-child:not(:first-child) .join-item{border-end-end-radius:inherit;border-end-start-radius:0;border-start-end-radius:inherit;border-start-start-radius:0}.menu-horizontal{display:inline-flex;flex-direction:row}.menu-horizontal>li:not(.menu-title)>details>ul{position:absolute}.stats-vertical{grid-auto-flow:row}.steps-horizontal .step{display:grid;grid-template-columns:repeat(1,minmax(0,1fr));grid-template-rows:repeat(2,minmax(0,1fr));place-items:center;text-align:center}.steps-vertical .step{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));grid-template-rows:repeat(1,minmax(0,1fr))}.tabs-md :where(.tab){font-size:.875rem;height:2rem;line-height:1.25rem;line-height:2;--tab-padding:1rem}.tabs-lg :where(.tab){font-size:1.125rem;height:3rem;line-height:1.75rem;line-height:2;--tab-padding:1.25rem}.tabs-sm :where(.tab){font-size:.875rem;height:1.5rem;line-height:.75rem;--tab-padding:0.75rem}.tabs-xs :where(.tab){font-size:.75rem;height:1.25rem;line-height:.75rem;--tab-padding:0.5rem}.timeline-vertical{flex-direction:column}.timeline-compact .timeline-start,.timeline-horizontal.timeline-compact .timeline-start{align-self:flex-start;grid-column-end:4;grid-column-start:1;grid-row-end:4;grid-row-start:3;justify-self:center;margin:.25rem}.timeline-compact li:has(.timeline-start) .timeline-end,.timeline-horizontal.timeline-compact li:has(.timeline-start) .timeline-end{grid-column-start:none;grid-row-start:auto}.timeline-vertical.timeline-compact>li{--timeline-col-start:0}.timeline-vertical.timeline-compact .timeline-start{align-self:center;grid-column-end:4;grid-column-start:3;grid-row-end:4;grid-row-start:1;justify-self:start}.timeline-vertical.timeline-compact li:has(.timeline-start) .timeline-end{grid-column-start:auto;grid-row-start:none}:where(.timeline-vertical>li){--timeline-row-start:minmax(0,1fr);--timeline-row-end:minmax(0,1fr);justify-items:center}.timeline-vertical>li>hr{height:100%}:where(.timeline-vertical>li>hr):first-child{grid-column-start:2;grid-row-start:1}:where(.timeline-vertical>li>hr):last-child{grid-column-end:auto;grid-column-start:2;grid-row-end:none;grid-row-start:3}.timeline-vertical .timeline-start{align-self:center;grid-column-end:2;grid-column-start:1;grid-row-end:4;grid-row-start:1;justify-self:end}.timeline-vertical .timeline-end{align-self:center;grid-column-end:4;grid-column-start:3;grid-row-end:4;grid-row-start:1;justify-self:start}.timeline-vertical:where(.timeline-snap-icon)>li{--timeline-col-start:minmax(0,1fr);--timeline-row-start:0.5rem}.timeline-horizontal .timeline-start{align-self:flex-end;grid-column-end:4;grid-column-start:1;grid-row-end:2;grid-row-start:1;justify-self:center}.timeline-horizontal .timeline-end{align-self:flex-start;grid-column-end:4;grid-column-start:1;grid-row-end:4;grid-row-start:3;justify-self:center}.timeline-horizontal:where(.timeline-snap-icon)>li,:where(.timeline-snap-icon)>li{--timeline-col-start:0.5rem;--timeline-row-start:minmax(0,1fr)}.tooltip{--tooltip-offset:calc(100% + 1px + var(--tooltip-tail, 0px))}.tooltip:before{content:var(--tw-content);pointer-events:none;position:absolute;z-index:1;--tw-content:attr(data-tip)}.tooltip-top:before,.tooltip:before{bottom:var(--tooltip-offset);left:50%;right:auto;top:auto;transform:translateX(-50%)}.tooltip-bottom:before{bottom:auto;left:50%;right:auto;top:var(--tooltip-offset);transform:translateX(-50%)}.card-compact .card-body{font-size:.875rem;line-height:1.25rem;padding:1rem}.card-compact .card-title{margin-bottom:.25rem}.card-normal .card-body{font-size:1rem;line-height:1.5rem;padding:var(--padding-card,2rem)}.card-normal .card-title{margin-bottom:.75rem}.join.join-vertical>:where(:not(:first-child)){margin-left:0;margin-right:0;margin-top:-1px}.join.join-horizontal>:where(:not(:first-child)){margin-bottom:0;margin-top:0;margin-inline-start:-1px}.menu-horizontal>li:not(.menu-title)>details>ul{margin-inline-start:0;margin-top:1rem;padding-bottom:.5rem;padding-inline-end:.5rem;padding-top:.5rem}.menu-horizontal>li>details>ul:before{content:none}:where(.menu-horizontal>li:not(.menu-title)>details>ul){border-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));--tw-shadow:0 20px 25px -5px rgba(0,0,0,.1),0 8px 10px -6px rgba(0,0,0,.1);--tw-shadow-colored:0 20px 25px -5px var(--tw-shadow-color),0 8px 10px -6px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.menu-sm :where(li:not(.menu-title)>:not(ul,details,.menu-title)),.menu-sm :where(li:not(.menu-title)>details>summary:not(.menu-title)){border-radius:var(--rounded-btn,.5rem);font-size:.875rem;line-height:1.25rem;padding:.25rem .75rem}.menu-sm .menu-title{padding:.5rem .75rem}.modal-top :where(.modal-box){max-width:none;width:100%;--tw-translate-y:-2.5rem;--tw-scale-x:1;--tw-scale-y:1;border-bottom-left-radius:var(--rounded-box,1rem);border-bottom-right-radius:var(--rounded-box,1rem);border-top-left-radius:0;border-top-right-radius:0;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))}.modal-middle :where(.modal-box){max-width:32rem;width:91.666667%;--tw-translate-y:0px;--tw-scale-x:.9;--tw-scale-y:.9;border-bottom-left-radius:var(--rounded-box,1rem);border-bottom-right-radius:var(--rounded-box,1rem);border-top-left-radius:var(--rounded-box,1rem);border-top-right-radius:var(--rounded-box,1rem);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))}.modal-bottom :where(.modal-box){max-width:none;width:100%;--tw-translate-y:2.5rem;--tw-scale-x:1;--tw-scale-y:1;border-bottom-left-radius:0;border-bottom-right-radius:0;border-top-left-radius:var(--rounded-box,1rem);border-top-right-radius:var(--rounded-box,1rem);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))}.stats-vertical>:not([hidden])~:not([hidden]){--tw-divide-x-reverse:0;--tw-divide-y-reverse:0;border-width:calc(1px*(1 - var(--tw-divide-y-reverse))) calc(0px*var(--tw-divide-x-reverse)) calc(1px*var(--tw-divide-y-reverse)) calc(0px*(1 - var(--tw-divide-x-reverse)))}.stats-vertical{overflow-y:auto}.steps-horizontal .step{grid-template-columns:auto;grid-template-rows:40px 1fr;min-width:4rem}.steps-horizontal .step:before{height:.5rem;width:100%;--tw-translate-x:0px;--tw-translate-y:0px;content:"";margin-inline-start:-100%;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))}:is([dir=rtl] .steps-horizontal .step):before{--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))}.steps-vertical .step{gap:.5rem;grid-template-columns:40px 1fr;grid-template-rows:auto;justify-items:start;min-height:4rem}.steps-vertical .step:before{height:100%;width:.5rem;--tw-translate-x:-50%;--tw-translate-y:-50%;margin-inline-start:50%;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))}:is([dir=rtl] .steps-vertical .step):before{--tw-translate-x:50%;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))}.timeline-vertical>li>hr{width:.25rem}:where(.timeline-vertical:has(.timeline-middle)>li>hr):first-child{border-bottom-left-radius:var(--rounded-badge,1.9rem);border-bottom-right-radius:var(--rounded-badge,1.9rem);border-top-left-radius:0;border-top-right-radius:0}:where(.timeline-vertical:has(.timeline-middle)>li>hr):last-child{border-bottom-left-radius:0;border-bottom-right-radius:0;border-top-left-radius:var(--rounded-badge,1.9rem);border-top-right-radius:var(--rounded-badge,1.9rem)}:where(.timeline-vertical:not(:has(.timeline-middle)) :first-child>hr:last-child){border-bottom-left-radius:0;border-bottom-right-radius:0;border-top-left-radius:var(--rounded-badge,1.9rem);border-top-right-radius:var(--rounded-badge,1.9rem)}:where(.timeline-vertical:not(:has(.timeline-middle)) :last-child>hr:first-child){border-bottom-left-radius:var(--rounded-badge,1.9rem);border-bottom-right-radius:var(--rounded-badge,1.9rem);border-top-left-radius:0;border-top-right-radius:0}:where(.timeline-horizontal:has(.timeline-middle)>li>hr):first-child{border-end-end-radius:var(--rounded-badge,1.9rem);border-end-start-radius:0;border-start-end-radius:var(--rounded-badge,1.9rem);border-start-start-radius:0}:where(.timeline-horizontal:has(.timeline-middle)>li>hr):last-child{border-end-end-radius:0;border-end-start-radius:var(--rounded-badge,1.9rem);border-start-end-radius:0;border-start-start-radius:var(--rounded-badge,1.9rem)}.tooltip{display:inline-block;position:relative;text-align:center;--tooltip-tail:0.1875rem;--tooltip-color:var(--fallback-n,oklch(var(--n)/1));--tooltip-text-color:var(--fallback-nc,oklch(var(--nc)/1));--tooltip-tail-offset:calc(100% + 0.0625rem - var(--tooltip-tail))}.tooltip:after,.tooltip:before{opacity:0;transition-delay:.1s;transition-duration:.2s;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(.4,0,.2,1)}.tooltip:after{border-style:solid;border-width:var(--tooltip-tail,0);content:"";display:block;height:0;position:absolute;width:0}.tooltip:before{background-color:var(--tooltip-color);border-radius:.25rem;color:var(--tooltip-text-color);font-size:.875rem;line-height:1.25rem;max-width:20rem;padding:.25rem .5rem;width:-moz-max-content;width:max-content}.tooltip.tooltip-open:after,.tooltip.tooltip-open:before,.tooltip:hover:after,.tooltip:hover:before{opacity:1;transition-delay:75ms}.tooltip:has(:focus-visible):after,.tooltip:has(:focus-visible):before{opacity:1;transition-delay:75ms}.tooltip:not([data-tip]):hover:after,.tooltip:not([data-tip]):hover:before{opacity:0;visibility:hidden}.tooltip-top:after,.tooltip:after{border-color:var(--tooltip-color) transparent transparent transparent;bottom:var(--tooltip-tail-offset);left:50%;right:auto;top:auto;transform:translateX(-50%)}.tooltip-bottom:after{border-color:transparent transparent var(--tooltip-color) transparent;bottom:auto;left:50%;right:auto;top:var(--tooltip-tail-offset);transform:translateX(-50%)}.visible{visibility:visible}.invisible{visibility:hidden}.static{position:static}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.right-0{right:0}.right-5{right:1.25rem}.top-0{top:0}.top-5{top:1.25rem}.z-0{z-index:0}.z-10{z-index:10}.z-\[1\]{z-index:1}.z-\[5000\]{z-index:5000}.m-0{margin:0}.m-5{margin:1.25rem}.m-auto{margin:auto}.mx-1{margin-left:.25rem;margin-right:.25rem}.mx-4{margin-left:1rem;margin-right:1rem}.mx-5{margin-left:1.25rem;margin-right:1.25rem}.mx-auto{margin-left:auto;margin-right:auto}.my-10{margin-bottom:2.5rem;margin-top:2.5rem}.my-2{margin-bottom:.5rem;margin-top:.5rem}.my-3{margin-bottom:.75rem;margin-top:.75rem}.my-4{margin-bottom:1rem;margin-top:1rem}.my-5{margin-bottom:1.25rem;margin-top:1.25rem}.my-8{margin-bottom:2rem;margin-top:2rem}.mb-10{margin-bottom:2.5rem}.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.mb-5{margin-bottom:1.25rem}.mb-6{margin-bottom:1.5rem}.mb-8{margin-bottom:2rem}.ml-2{margin-left:.5rem}.ml-4{margin-left:1rem}.mr-2{margin-right:.5rem}.mr-4{margin-right:1rem}.mt-10{margin-top:2.5rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-5{margin-top:1.25rem}.mt-6{margin-top:1.5rem}.mt-8{margin-top:2rem}.block{display:block}.inline-block{display:inline-block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.contents{display:contents}.hidden{display:none}.h-32{height:8rem}.h-5{height:1.25rem}.h-6{height:1.5rem}.h-8{height:2rem}.h-96{height:24rem}.h-\[25rem\]{height:25rem}.h-fit{height:-moz-fit-content;height:fit-content}.h-full{height:100%}.min-h-80{min-height:20rem}.min-h-screen{min-height:100vh}.w-10\/12{width:83.333333%}.w-4{width:1rem}.w-4\/12{width:33.333333%}.w-48{width:12rem}.w-5{width:1.25rem}.w-52{width:13rem}.w-6{width:1.5rem}.w-8{width:2rem}.w-96{width:24rem}.w-auto{width:auto}.w-full{width:100%}.min-w-52{min-width:13rem}.min-w-full{min-width:100%}.max-w-2xl{max-width:42rem}.max-w-4xl{max-width:56rem}.max-w-5xl{max-width:64rem}.max-w-md{max-width:28rem}.max-w-sm{max-width:24rem}.max-w-xs{max-width:20rem}.flex-1{flex:1 1 0%}.flex-shrink-0,.shrink-0{flex-shrink:0}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.flex-row{flex-direction:row}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-5{gap:1.25rem}.gap-6{gap:1.5rem}.space-x-3>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(.75rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(.75rem*var(--tw-space-x-reverse))}.space-x-4>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(1rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(1rem*var(--tw-space-x-reverse))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(.5rem*var(--tw-space-y-reverse));margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(1rem*var(--tw-space-y-reverse));margin-top:calc(1rem*(1 - var(--tw-space-y-reverse)))}.justify-self-end{justify-self:end}.justify-self-center{justify-self:center}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.rounded{border-radius:.25rem}.rounded-box{border-radius:var(--rounded-box,1rem)}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.rounded-t-none{border-top-left-radius:0;border-top-right-radius:0}.border{border-width:1px}.border-base-300{--tw-border-opacity:1;border-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-border-opacity)))}.border-blue-300{--tw-border-opacity:1;border-color:rgb(147 197 253/var(--tw-border-opacity))}.border-red-300{--tw-border-opacity:1;border-color:rgb(252 165 165/var(--tw-border-opacity))}.bg-base-100{--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)))}.bg-base-200{--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)))}.bg-base-300{--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity)))}.bg-blue-100{--tw-bg-opacity:1;background-color:rgb(219 234 254/var(--tw-bg-opacity))}.bg-blue-500{--tw-bg-opacity:1;background-color:rgb(59 130 246/var(--tw-bg-opacity))}.bg-blue-600{--tw-bg-opacity:1;background-color:rgb(37 99 235/var(--tw-bg-opacity))}.bg-blue-900{--tw-bg-opacity:1;background-color:rgb(30 58 138/var(--tw-bg-opacity))}.bg-gray-100{--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity))}.bg-green-50{--tw-bg-opacity:1;background-color:rgb(240 253 244/var(--tw-bg-opacity))}.bg-green-500{--tw-bg-opacity:1;background-color:rgb(34 197 94/var(--tw-bg-opacity))}.bg-neutral{--tw-bg-opacity:1;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)))}.bg-red-100{--tw-bg-opacity:1;background-color:rgb(254 226 226/var(--tw-bg-opacity))}.bg-red-50{--tw-bg-opacity:1;background-color:rgb(254 242 242/var(--tw-bg-opacity))}.bg-red-500{--tw-bg-opacity:1;background-color:rgb(239 68 68/var(--tw-bg-opacity))}.bg-secondary-content{--tw-bg-opacity:1;background-color:var(--fallback-sc,oklch(var(--sc)/var(--tw-bg-opacity)))}.stroke-current{stroke:currentColor}.stroke-info{stroke:var(--fallback-in,oklch(var(--in)/1))}.object-cover{-o-object-fit:cover;object-fit:cover}.p-0{padding:0}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-4{padding:1rem}.p-5{padding:1.25rem}.p-6{padding:1.5rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-5{padding-left:1.25rem;padding-right:1.25rem}.py-2{padding-bottom:.5rem;padding-top:.5rem}.py-20{padding-bottom:5rem;padding-top:5rem}.py-3{padding-bottom:.75rem;padding-top:.75rem}.py-4{padding-bottom:1rem;padding-top:1rem}.py-5{padding-bottom:1.25rem;padding-top:1.25rem}.py-6{padding-bottom:1.5rem;padding-top:1.5rem}.text-center{text-align:center}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.text-2xl{font-size:1.5rem;line-height:2rem}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-4xl{font-size:2.25rem;line-height:2.5rem}.text-5xl{font-size:3rem;line-height:1}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-black{font-weight:900}.font-bold{font-weight:700}.font-medium{font-weight:500}.font-semibold{font-weight:600}.normal-case{text-transform:none}.italic{font-style:italic}.text-base-content{--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}.text-base-content\/60{color:var(--fallback-bc,oklch(var(--bc)/.6))}.text-blue-600{--tw-text-opacity:1;color:rgb(37 99 235/var(--tw-text-opacity))}.text-blue-700{--tw-text-opacity:1;color:rgb(29 78 216/var(--tw-text-opacity))}.text-gray-500{--tw-text-opacity:1;color:rgb(107 114 128/var(--tw-text-opacity))}.text-gray-600{--tw-text-opacity:1;color:rgb(75 85 99/var(--tw-text-opacity))}.text-green-500{--tw-text-opacity:1;color:rgb(34 197 94/var(--tw-text-opacity))}.text-neutral-content{--tw-text-opacity:1;color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity)))}.text-primary{--tw-text-opacity:1;color:var(--fallback-p,oklch(var(--p)/var(--tw-text-opacity)))}.text-red-500{--tw-text-opacity:1;color:rgb(239 68 68/var(--tw-text-opacity))}.text-red-700{--tw-text-opacity:1;color:rgb(185 28 28/var(--tw-text-opacity))}.text-secondary{--tw-text-opacity:1;color:var(--fallback-s,oklch(var(--s)/var(--tw-text-opacity)))}.text-success{--tw-text-opacity:1;color:var(--fallback-su,oklch(var(--su)/var(--tw-text-opacity)))}.text-warning{--tw-text-opacity:1;color:var(--fallback-wa,oklch(var(--wa)/var(--tw-text-opacity)))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity))}.underline{text-decoration-line:underline}.decoration-dotted{text-decoration-style:dotted}.opacity-0{opacity:0}.shadow{--tw-shadow:0 1px 3px 0 rgba(0,0,0,.1),0 1px 2px -1px rgba(0,0,0,.1);--tw-shadow-colored:0 1px 3px 0 var(--tw-shadow-color),0 1px 2px -1px var(--tw-shadow-color)}.shadow,.shadow-2xl{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-2xl{--tw-shadow:0 25px 50px -12px rgba(0,0,0,.25);--tw-shadow-colored:0 25px 50px -12px var(--tw-shadow-color)}.shadow-lg{--tw-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color)}.shadow-lg,.shadow-xl{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-xl{--tw-shadow:0 20px 25px -5px rgba(0,0,0,.1),0 8px 10px -6px rgba(0,0,0,.1);--tw-shadow-colored:0 20px 25px -5px var(--tw-shadow-color),0 8px 10px -6px var(--tw-shadow-color)}.blur{--tw-blur:blur(8px)}.blur,.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition{transition-duration:.15s;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(.4,0,.2,1)}.transition-opacity{transition-duration:.15s;transition-property:opacity;transition-timing-function:cubic-bezier(.4,0,.2,1)}.transition-shadow{transition-duration:.15s;transition-property:box-shadow;transition-timing-function:cubic-bezier(.4,0,.2,1)}.transition-transform{transition-duration:.15s;transition-property:transform;transition-timing-function:cubic-bezier(.4,0,.2,1)}.duration-200{transition-duration:.2s}.duration-300{transition-duration:.3s}@tailwind daisyui;@media (hover:hover){.hover\:btn-ghost:hover:hover{border-color:transparent}@supports (color:oklch(0 0 0)){.hover\:btn-ghost:hover:hover{background-color:var(--fallback-bc,oklch(var(--bc)/.2))}}.hover\:btn-info:hover.btn-outline:hover{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.hover\:btn-info:hover.btn-outline:hover{background-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000)}}}@supports not (color:oklch(0 0 0)){.hover\:btn-info:hover{--btn-color:var(--fallback-in)}}@supports (color:color-mix(in oklab,black,black)){.hover\:btn-info:hover.btn-outline.btn-active{background-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000)}}@supports (color:oklch(0 0 0)){.hover\:btn-info:hover{--btn-color:var(--in)}}.hover\:btn-info:hover{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)));outline-color:var(--fallback-in,oklch(var(--in)/1))}.hover\:btn-ghost:hover{background-color:transparent;border-color:transparent;border-width:1px;color:currentColor;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);outline-color:currentColor}.hover\:btn-ghost:hover.btn-active{background-color:var(--fallback-bc,oklch(var(--bc)/.2));border-color:transparent}.hover\:btn-info:hover.btn-outline{--tw-text-opacity:1;color:var(--fallback-in,oklch(var(--in)/var(--tw-text-opacity)))}.hover\:btn-info:hover.btn-outline.btn-active{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)))}.hover\:input-primary:hover{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)))}.hover\:input-primary:hover:focus,.hover\:input-primary:hover:focus-within{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)));outline-color:var(--fallback-p,oklch(var(--p)/1))}.focus\:input-ghost:focus{--tw-bg-opacity:0.05}.focus\:input-ghost:focus:focus,.focus\:input-ghost:focus:focus-within{--tw-bg-opacity:1;--tw-text-opacity:1;box-shadow:none;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}@media not all and (min-width:768px){.max-md\:timeline-compact,.max-md\:timeline-compact
+.timeline-horizontal{--timeline-row-start:0}.max-md\:timeline-compact .timeline-horizontal .timeline-start,.max-md\:timeline-compact .timeline-start{align-self:flex-start;grid-column-end:4;grid-column-start:1;grid-row-end:4;grid-row-start:3;justify-self:center;margin:.25rem}.max-md\:timeline-compact .timeline-horizontal li:has(.timeline-start) .timeline-end,.max-md\:timeline-compact li:has(.timeline-start) .timeline-end{grid-column-start:none;grid-row-start:auto}.max-md\:timeline-compact.timeline-vertical>li{--timeline-col-start:0}.max-md\:timeline-compact.timeline-vertical .timeline-start{align-self:center;grid-column-end:4;grid-column-start:3;grid-row-end:4;grid-row-start:1;justify-self:start}.max-md\:timeline-compact.timeline-vertical li:has(.timeline-start) .timeline-end{grid-column-start:auto;grid-row-start:none}}@media (min-width:1024px){.lg\:stats-horizontal{grid-auto-flow:column}.lg\:stats-horizontal>:not([hidden])~:not([hidden]){--tw-divide-x-reverse:0;--tw-divide-y-reverse:0;border-width:calc(0px*(1 - var(--tw-divide-y-reverse))) calc(1px*var(--tw-divide-x-reverse)) calc(0px*var(--tw-divide-y-reverse)) calc(1px*(1 - var(--tw-divide-x-reverse)))}.lg\:stats-horizontal{overflow-x:auto}:is([dir=rtl] .lg\:stats-horizontal){--tw-divide-x-reverse:1}}.hover\:scale-105:hover{--tw-scale-x:1.05;--tw-scale-y:1.05;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))}.hover\:cursor-pointer:hover{cursor:pointer}.hover\:underline:hover{text-decoration-line:underline}.hover\:no-underline:hover{text-decoration-line:none}.hover\:shadow-2xl:hover{--tw-shadow:0 25px 50px -12px rgba(0,0,0,.25);--tw-shadow-colored:0 25px 50px -12px var(--tw-shadow-color)}.hover\:shadow-2xl:hover,.hover\:shadow-lg:hover{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.hover\:shadow-lg:hover{--tw-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color)}.hover\:shadow-blue-500\/50:hover{--tw-shadow-color:rgba(59,130,246,.5);--tw-shadow:var(--tw-shadow-colored)}.group:hover .group-hover\:opacity-100{opacity:1}@media (min-width:640px){.sm\:inline{display:inline}.sm\:w-2\/12{width:16.666667%}.sm\:w-6\/12{width:50%}.sm\:grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:flex-row{flex-direction:row}.sm\:items-end{align-items:flex-end}.sm\:space-x-4>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(1rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(1rem*var(--tw-space-x-reverse))}.sm\:space-y-0>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(0px*var(--tw-space-y-reverse));margin-top:calc(0px*(1 - var(--tw-space-y-reverse)))}}@media (min-width:768px){.md\:w-1\/12{width:8.333333%}.md\:w-2\/12{width:16.666667%}.md\:w-2\/3{width:66.666667%}.md\:w-3\/12{width:25%}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:flex-row{flex-direction:row}.md\:items-end{align-items:flex-end}.md\:space-x-4>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(1rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(1rem*var(--tw-space-x-reverse))}.md\:text-end{text-align:end}}@media (min-width:1024px){.lg\:mt-0{margin-top:0}.lg\:flex{display:flex}.lg\:hidden{display:none}.lg\:w-1\/12{width:8.333333%}.lg\:w-1\/2{width:50%}.lg\:w-1\/6,.lg\:w-2\/12{width:16.666667%}.lg\:w-3\/12{width:25%}.lg\:w-5\/6{width:83.333333%}.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:flex-row{flex-direction:row}.lg\:flex-row-reverse{flex-direction:row-reverse}.lg\:space-x-4>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(1rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(1rem*var(--tw-space-x-reverse))}.lg\:text-left{text-align:left}}
\ No newline at end of file
From e23d4ba3824ec4199f22909fb129bafeac1487c0 Mon Sep 17 00:00:00 2001
From: Alex Barcelo
Date: Mon, 2 Dec 2024 10:54:48 +0100
Subject: [PATCH 04/51] avoid unnecessary initialization for certain scenarios
---
config/initializers/sidekiq.rb | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/config/initializers/sidekiq.rb b/config/initializers/sidekiq.rb
index f242910b..4ae6cc8d 100644
--- a/config/initializers/sidekiq.rb
+++ b/config/initializers/sidekiq.rb
@@ -25,4 +25,6 @@ Sidekiq.configure_client do |config|
config.redis = { url: ENV['REDIS_URL'] }
end
-Sidekiq::Queue['reverse_geocoding'].limit = 1 if PHOTON_API_HOST == 'photon.komoot.io'
+if (defined?(Rails::Server) || Sidekiq.server?) && PHOTON_API_HOST == 'photon.komoot.io'
+ Sidekiq::Queue['reverse_geocoding'].limit = 1
+end
From 1f9e1f2f97e8b64b86999ed8690c9ba8373a7546 Mon Sep 17 00:00:00 2001
From: Eugene Burmakin
Date: Mon, 2 Dec 2024 16:52:05 +0100
Subject: [PATCH 05/51] Add basic Photoprism photos integration
---
app/controllers/api/v1/settings_controller.rb | 16 ++--
app/controllers/settings_controller.rb | 2 +-
app/models/user.rb | 6 ++
app/services/photoprism/request_photos.rb | 80 +++++++++++++++++++
app/views/settings/_navigation.html.erb | 2 +-
app/views/settings/index.html.erb | 12 ++-
6 files changed, 105 insertions(+), 13 deletions(-)
create mode 100644 app/services/photoprism/request_photos.rb
diff --git a/app/controllers/api/v1/settings_controller.rb b/app/controllers/api/v1/settings_controller.rb
index 660f88e0..f87d9df7 100644
--- a/app/controllers/api/v1/settings_controller.rb
+++ b/app/controllers/api/v1/settings_controller.rb
@@ -12,16 +12,11 @@ class Api::V1::SettingsController < ApiController
settings_params.each { |key, value| current_api_user.settings[key] = value }
if current_api_user.save
- render json: {
- message: 'Settings updated',
- settings: current_api_user.settings,
- status: 'success'
- }, status: :ok
+ render json: { message: 'Settings updated', settings: current_api_user.settings, status: 'success' },
+ status: :ok
else
- render json: {
- message: 'Something went wrong',
- errors: current_api_user.errors.full_messages
- }, status: :unprocessable_entity
+ render json: { message: 'Something went wrong', errors: current_api_user.errors.full_messages },
+ status: :unprocessable_entity
end
end
@@ -31,7 +26,8 @@ class Api::V1::SettingsController < ApiController
params.require(:settings).permit(
:meters_between_routes, :minutes_between_routes, :fog_of_war_meters,
:time_threshold_minutes, :merge_threshold_minutes, :route_opacity,
- :preferred_map_layer, :points_rendering_mode, :live_map_enabled
+ :preferred_map_layer, :points_rendering_mode, :live_map_enabled,
+ :immich_url, :immich_api_key, :photoprism_url, :photoprism_api_key
)
end
end
diff --git a/app/controllers/settings_controller.rb b/app/controllers/settings_controller.rb
index 8442bb94..243189cf 100644
--- a/app/controllers/settings_controller.rb
+++ b/app/controllers/settings_controller.rb
@@ -31,7 +31,7 @@ class SettingsController < ApplicationController
params.require(:settings).permit(
:meters_between_routes, :minutes_between_routes, :fog_of_war_meters,
:time_threshold_minutes, :merge_threshold_minutes, :route_opacity,
- :immich_url, :immich_api_key
+ :immich_url, :immich_api_key, :photoprism_url, :photoprism_api_key
)
end
end
diff --git a/app/models/user.rb b/app/models/user.rb
index a102d0b5..e9da779f 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -18,6 +18,7 @@ class User < ApplicationRecord
has_many :trips, dependent: :destroy
after_create :create_api_key
+ before_save :strip_trailing_slashes
def countries_visited
stats.pluck(:toponyms).flatten.map { _1['country'] }.uniq.compact
@@ -60,4 +61,9 @@ class User < ApplicationRecord
save
end
+
+ def strip_trailing_slashes
+ settings['immich_url'].gsub!(%r{/+\z}, '')
+ settings['photoprism_url'].gsub!(%r{/+\z}, '')
+ end
end
diff --git a/app/services/photoprism/request_photos.rb b/app/services/photoprism/request_photos.rb
new file mode 100644
index 00000000..9873c1e7
--- /dev/null
+++ b/app/services/photoprism/request_photos.rb
@@ -0,0 +1,80 @@
+# frozen_string_literal: true
+
+class Photoprism::RequestPhotos
+ attr_reader :user, :photoprism_api_base_url, :photoprism_api_key, :start_date, :end_date
+
+ def initialize(user, start_date: '1970-01-01', end_date: nil)
+ @user = user
+ @photoprism_api_base_url = "#{user.settings['photoprism_url']}/api/v1/photos"
+ @photoprism_api_key = user.settings['photoprism_api_key']
+ @start_date = start_date
+ @end_date = end_date
+ end
+
+ def call
+ raise ArgumentError, 'Photoprism API key is missing' if photoprism_api_key.blank?
+ raise ArgumentError, 'Photoprism URL is missing' if user.settings['photoprism_url'].blank?
+
+ data = retrieve_photoprism_data
+
+ time_framed_data(data)
+ end
+
+ private
+
+ def retrieve_photoprism_data
+ data = []
+ offset = 0
+
+ while offset < 1_000_000
+ response = HTTParty.get(
+ photoprism_api_base_url,
+ headers: headers,
+ query: request_params(offset)
+ )
+
+ break if response.code != 200
+
+ photoprism_data = JSON.parse(response.body)
+
+ data << photoprism_data
+
+ break if photoprism_data.empty?
+
+ offset += 1000
+ end
+
+ data
+ end
+
+ def headers
+ {
+ 'Authorization' => "Bearer #{photoprism_api_key}",
+ 'accept' => 'application/json'
+ }
+ end
+
+ def request_params(offset = 0)
+ params = {
+ q: '',
+ public: true,
+ quality: 3,
+ after: start_date,
+ offset: offset,
+ count: 1000
+ }
+
+ params.delete(:offset) if offset.zero?
+ params[:before] = end_date if end_date.present?
+
+ params
+ end
+
+ def time_framed_data(data)
+ data.flatten.select do |photo|
+ taken_at = DateTime.parse(photo['TakenAtLocal'])
+ end_date ||= Time.current
+ taken_at.between?(start_date.to_datetime, end_date.to_datetime)
+ end
+ end
+end
diff --git a/app/views/settings/_navigation.html.erb b/app/views/settings/_navigation.html.erb
index 7232cc1c..b0b20437 100644
--- a/app/views/settings/_navigation.html.erb
+++ b/app/views/settings/_navigation.html.erb
@@ -1,5 +1,5 @@
- <%= link_to 'Main', settings_path, role: 'tab', class: "tab #{active_tab?(settings_path)}" %>
+ <%= link_to 'Integrations', settings_path, role: 'tab', class: "tab #{active_tab?(settings_path)}" %>
<% if current_user.admin? %>
<%= link_to 'Users', settings_users_path, role: 'tab', class: "tab #{active_tab?(settings_users_path)}" %>
<%= link_to 'Background Jobs', settings_background_jobs_path, role: 'tab', class: "tab #{active_tab?(settings_background_jobs_path)}" %>
diff --git a/app/views/settings/index.html.erb b/app/views/settings/index.html.erb
index eb8e605f..613cfe73 100644
--- a/app/views/settings/index.html.erb
+++ b/app/views/settings/index.html.erb
@@ -5,7 +5,7 @@
-
Edit your Dawarich settings!
+ Edit your Integrations settings!
<%= form_for :settings, url: settings_path, method: :patch, data: { turbo_method: :patch, turbo: false } do |f| %>
<%= f.label :immich_url %>
@@ -15,6 +15,16 @@
<%= f.label :immich_api_key %>
<%= f.text_field :immich_api_key, value: current_user.settings['immich_api_key'], class: "input input-bordered", placeholder: 'xxxxxxxxxxxxxx' %>
+
+
+ <%= f.label :photoprism_url %>
+ <%= f.text_field :photoprism_url, value: current_user.settings['photoprism_url'], class: "input input-bordered", placeholder: 'http://192.168.0.1:2342' %>
+
+
+ <%= f.label :photoprism_api_key %>
+ <%= f.text_field :photoprism_api_key, value: current_user.settings['photoprism_api_key'], class: "input input-bordered", placeholder: 'xxxxxxxxxxxxxx' %>
+
+
<%= f.submit "Update", class: "btn btn-primary" %>
From 360828250f23282776221492e3e78a0ef2063c18 Mon Sep 17 00:00:00 2001
From: Eugene Burmakin
Date: Mon, 2 Dec 2024 17:22:36 +0100
Subject: [PATCH 06/51] Add test for photoprism request photos
---
app/models/user.rb | 4 +-
app/services/photoprism/request_photos.rb | 45 +--
spec/services/immich/request_photos_spec.rb | 6 +-
.../photoprism/request_photos_spec.rb | 278 ++++++++++++++++++
4 files changed, 308 insertions(+), 25 deletions(-)
create mode 100644 spec/services/photoprism/request_photos_spec.rb
diff --git a/app/models/user.rb b/app/models/user.rb
index e9da779f..b7edcb26 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -63,7 +63,7 @@ class User < ApplicationRecord
end
def strip_trailing_slashes
- settings['immich_url'].gsub!(%r{/+\z}, '')
- settings['photoprism_url'].gsub!(%r{/+\z}, '')
+ settings['immich_url']&.gsub!(%r{/+\z}, '')
+ settings['photoprism_url']&.gsub!(%r{/+\z}, '')
end
end
diff --git a/app/services/photoprism/request_photos.rb b/app/services/photoprism/request_photos.rb
index 9873c1e7..a819c9a8 100644
--- a/app/services/photoprism/request_photos.rb
+++ b/app/services/photoprism/request_photos.rb
@@ -1,6 +1,7 @@
# frozen_string_literal: true
class Photoprism::RequestPhotos
+ class Error < StandardError; end
attr_reader :user, :photoprism_api_base_url, :photoprism_api_key, :start_date, :end_date
def initialize(user, start_date: '1970-01-01', end_date: nil)
@@ -12,8 +13,8 @@ class Photoprism::RequestPhotos
end
def call
- raise ArgumentError, 'Photoprism API key is missing' if photoprism_api_key.blank?
raise ArgumentError, 'Photoprism URL is missing' if user.settings['photoprism_url'].blank?
+ raise ArgumentError, 'Photoprism API key is missing' if photoprism_api_key.blank?
data = retrieve_photoprism_data
@@ -27,19 +28,11 @@ class Photoprism::RequestPhotos
offset = 0
while offset < 1_000_000
- response = HTTParty.get(
- photoprism_api_base_url,
- headers: headers,
- query: request_params(offset)
- )
+ response_data = fetch_page(offset)
+ break unless response_data
- break if response.code != 200
-
- photoprism_data = JSON.parse(response.body)
-
- data << photoprism_data
-
- break if photoprism_data.empty?
+ data << response_data
+ break if response_data.empty?
offset += 1000
end
@@ -47,6 +40,18 @@ class Photoprism::RequestPhotos
data
end
+ def fetch_page(offset)
+ response = HTTParty.get(
+ photoprism_api_base_url,
+ headers: headers,
+ query: request_params(offset)
+ )
+
+ raise Error, "Photoprism API returned #{response.code}: #{response.body}" if response.code != 200
+
+ JSON.parse(response.body)
+ end
+
def headers
{
'Authorization' => "Bearer #{photoprism_api_key}",
@@ -55,19 +60,19 @@ class Photoprism::RequestPhotos
end
def request_params(offset = 0)
- params = {
+ params = offset.zero? ? default_params : default_params.merge(offset: offset)
+ params[:before] = end_date if end_date.present?
+ params
+ end
+
+ def default_params
+ {
q: '',
public: true,
quality: 3,
after: start_date,
- offset: offset,
count: 1000
}
-
- params.delete(:offset) if offset.zero?
- params[:before] = end_date if end_date.present?
-
- params
end
def time_framed_data(data)
diff --git a/spec/services/immich/request_photos_spec.rb b/spec/services/immich/request_photos_spec.rb
index 255e6cd0..041ed4fd 100644
--- a/spec/services/immich/request_photos_spec.rb
+++ b/spec/services/immich/request_photos_spec.rb
@@ -9,7 +9,7 @@ RSpec.describe Immich::RequestPhotos do
let(:user) do
create(:user, settings: { 'immich_url' => 'http://immich.app', 'immich_api_key' => '123456' })
end
- let(:immich_data) do
+ let(:mock_immich_data) do
{
"albums": {
"total": 0,
@@ -134,11 +134,11 @@ RSpec.describe Immich::RequestPhotos do
stub_request(
:any,
'http://immich.app/api/search/metadata'
- ).to_return(status: 200, body: immich_data, headers: {})
+ ).to_return(status: 200, body: mock_immich_data, headers: {})
end
it 'returns images and videos' do
- expect(service.map { _1['type'] }.uniq).to eq(['IMAGE', 'VIDEO'])
+ expect(service.map { _1['type'] }.uniq).to eq(%w[IMAGE VIDEO])
end
end
diff --git a/spec/services/photoprism/request_photos_spec.rb b/spec/services/photoprism/request_photos_spec.rb
new file mode 100644
index 00000000..6dc79e6d
--- /dev/null
+++ b/spec/services/photoprism/request_photos_spec.rb
@@ -0,0 +1,278 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe Photoprism::RequestPhotos do
+ let(:user) do
+ create(:user,
+ settings: {
+ 'photoprism_url' => 'http://photoprism.local',
+ 'photoprism_api_key' => 'test_api_key'
+ })
+ end
+
+ let(:start_date) { '2023-01-01' }
+ let(:end_date) { '2023-12-31' }
+ let(:service) { described_class.new(user, start_date: start_date, end_date: end_date) }
+
+ let(:mock_photo_response) do
+ [
+ {
+ 'ID' => '82',
+ 'UID' => 'psnveqq089xhy1c3',
+ 'Type' => 'image',
+ 'TypeSrc' => '',
+ 'TakenAt' => '2024-08-18T14:11:05Z',
+ 'TakenAtLocal' => '2024-08-18T16:11:05Z',
+ 'TakenSrc' => 'meta',
+ 'TimeZone' => 'Europe/Prague',
+ 'Path' => '2024/08',
+ 'Name' => '20240818_141105_44E61AED',
+ 'OriginalName' => 'PXL_20240818_141105789',
+ 'Title' => 'Moment / Karlovy Vary / 2024',
+ 'Description' => '',
+ 'Year' => 2024,
+ 'Month' => 8,
+ 'Day' => 18,
+ 'Country' => 'cz',
+ 'Stack' => 0,
+ 'Favorite' => false,
+ 'Private' => false,
+ 'Iso' => 37,
+ 'FocalLength' => 21,
+ 'FNumber' => 2.2,
+ 'Exposure' => '1/347',
+ 'Quality' => 4,
+ 'Resolution' => 10,
+ 'Color' => 2,
+ 'Scan' => false,
+ 'Panorama' => false,
+ 'CameraID' => 8,
+ 'CameraSrc' => 'meta',
+ 'CameraMake' => 'Google',
+ 'CameraModel' => 'Pixel 7 Pro',
+ 'LensID' => 11,
+ 'LensMake' => 'Google',
+ 'LensModel' => 'Pixel 7 Pro front camera 2.74mm f/2.2',
+ 'Altitude' => 423,
+ 'Lat' => 50.11,
+ 'Lng' => 12.12,
+ 'CellID' => 's2:47a09944f33c',
+ 'PlaceID' => 'cz:ciNqTjWuq6NN',
+ 'PlaceSrc' => 'meta',
+ 'PlaceLabel' => 'Karlovy Vary, Severozápad, Czech Republic',
+ 'PlaceCity' => 'Karlovy Vary',
+ 'PlaceState' => 'Severozápad',
+ 'PlaceCountry' => 'cz',
+ 'InstanceID' => '',
+ 'FileUID' => 'fsnveqqeusn692qo',
+ 'FileRoot' => '/',
+ 'FileName' => '2024/08/20240818_141105_44E61AED.jpg',
+ 'Hash' => 'cc5d0f544e52b288d7c8460d2e1bb17fa66e6089',
+ 'Width' => 2736,
+ 'Height' => 3648,
+ 'Portrait' => true,
+ 'Merged' => false,
+ 'CreatedAt' => '2024-12-02T14:25:38Z',
+ 'UpdatedAt' => '2024-12-02T14:25:38Z',
+ 'EditedAt' => '0001-01-01T00:00:00Z',
+ 'CheckedAt' => '2024-12-02T14:36:45Z',
+ 'Files' => nil
+ },
+ {
+ 'ID' => '81',
+ 'UID' => 'psnveqpl96gcfdzf',
+ 'Type' => 'image',
+ 'TypeSrc' => '',
+ 'TakenAt' => '2024-08-18T14:11:04Z',
+ 'TakenAtLocal' => '2024-08-18T16:11:04Z',
+ 'TakenSrc' => 'meta',
+ 'TimeZone' => 'Europe/Prague',
+ 'Path' => '2024/08',
+ 'Name' => '20240818_141104_E9949CD4',
+ 'OriginalName' => 'PXL_20240818_141104633',
+ 'Title' => 'Portrait / Karlovy Vary / 2024',
+ 'Description' => '',
+ 'Year' => 2024,
+ 'Month' => 8,
+ 'Day' => 18,
+ 'Country' => 'cz',
+ 'Stack' => 0,
+ 'Favorite' => false,
+ 'Private' => false,
+ 'Iso' => 43,
+ 'FocalLength' => 21,
+ 'FNumber' => 2.2,
+ 'Exposure' => '1/356',
+ 'Faces' => 1,
+ 'Quality' => 4,
+ 'Resolution' => 10,
+ 'Color' => 2,
+ 'Scan' => false,
+ 'Panorama' => false,
+ 'CameraID' => 8,
+ 'CameraSrc' => 'meta',
+ 'CameraMake' => 'Google',
+ 'CameraModel' => 'Pixel 7 Pro',
+ 'LensID' => 11,
+ 'LensMake' => 'Google',
+ 'LensModel' => 'Pixel 7 Pro front camera 2.74mm f/2.2',
+ 'Altitude' => 423,
+ 'Lat' => 50.21,
+ 'Lng' => 12.85,
+ 'CellID' => 's2:47a09944f33c',
+ 'PlaceID' => 'cz:ciNqTjWuq6NN',
+ 'PlaceSrc' => 'meta',
+ 'PlaceLabel' => 'Karlovy Vary, Severozápad, Czech Republic',
+ 'PlaceCity' => 'Karlovy Vary',
+ 'PlaceState' => 'Severozápad',
+ 'PlaceCountry' => 'cz',
+ 'InstanceID' => '',
+ 'FileUID' => 'fsnveqp9xsl7onsv',
+ 'FileRoot' => '/',
+ 'FileName' => '2024/08/20240818_141104_E9949CD4.jpg',
+ 'Hash' => 'd5dfadc56a0b63051dfe0b5dec55ff1d81f033b7',
+ 'Width' => 2736,
+ 'Height' => 3648,
+ 'Portrait' => true,
+ 'Merged' => false,
+ 'CreatedAt' => '2024-12-02T14:25:37Z',
+ 'UpdatedAt' => '2024-12-02T14:25:37Z',
+ 'EditedAt' => '0001-01-01T00:00:00Z',
+ 'CheckedAt' => '2024-12-02T14:36:45Z',
+ 'Files' => nil
+ }
+ ]
+ end
+
+ describe '#call' do
+ context 'with valid credentials' do
+ before do
+ stub_request(
+ :any,
+ "#{user.settings['photoprism_url']}/api/v1/photos?after=2023-01-01&before=2023-12-31&count=1000&public=true&q=&quality=3"
+ ).with(
+ headers: {
+ 'Accept' => 'application/json',
+ 'Accept-Encoding' => 'gzip;q=1.0,deflate;q=0.6,identity;q=0.3',
+ 'Authorization' => 'Bearer test_api_key',
+ 'User-Agent' => 'Ruby'
+ }
+ ).to_return(
+ status: 200,
+ body: mock_photo_response.to_json,
+ headers: { 'Content-Type' => 'application/json' }
+ )
+
+ stub_request(
+ :any,
+ "#{user.settings['photoprism_url']}/api/v1/photos?after=2023-01-01&before=2023-12-31&count=1000&public=true&q=&quality=3&offset=1000"
+ ).to_return(status: 200, body: [].to_json)
+ end
+
+ it 'returns photos within the specified date range' do
+ result = service.call
+
+ expect(result).to be_an(Array)
+ expect(result.first['Title']).to eq('Moment / Karlovy Vary / 2024')
+ end
+ end
+
+ context 'with missing credentials' do
+ let(:user) { create(:user, settings: {}) }
+
+ it 'raises error when Photoprism URL is missing' do
+ expect { service.call }.to raise_error(ArgumentError, 'Photoprism URL is missing')
+ end
+
+ it 'raises error when API key is missing' do
+ user.update(settings: { 'photoprism_url' => 'http://photoprism.local' })
+
+ expect { service.call }.to raise_error(ArgumentError, 'Photoprism API key is missing')
+ end
+ end
+
+ context 'when API returns an error' do
+ before do
+ stub_request(
+ :get,
+ "#{user.settings['photoprism_url']}/api/v1/photos?after=2023-01-01&before=2023-12-31&count=1000&public=true&q=&quality=3"
+ )
+ .to_return(status: 401, body: 'Unauthorized')
+ end
+
+ it 'raises an error' do
+ expect do
+ service.call
+ end.to raise_error(Photoprism::RequestPhotos::Error, 'Photoprism API returned 401: Unauthorized')
+ end
+ end
+
+ context 'with pagination' do
+ let(:first_page) { [{ 'TakenAtLocal' => '2023-06-15T14:30:00Z' }] }
+ let(:second_page) { [{ 'TakenAtLocal' => '2023-06-16T14:30:00Z' }] }
+ let(:empty_page) { [] }
+
+ before do
+ common_headers = {
+ 'Accept' => 'application/json',
+ 'Accept-Encoding' => 'gzip;q=1.0,deflate;q=0.6,identity;q=0.3',
+ 'Authorization' => 'Bearer test_api_key',
+ 'User-Agent' => 'Ruby'
+ }
+
+ # First page
+ stub_request(:any, "#{user.settings['photoprism_url']}/api/v1/photos")
+ .with(
+ headers: common_headers,
+ query: {
+ after: '2023-01-01',
+ before: '2023-12-31',
+ count: '1000',
+ public: 'true',
+ q: '',
+ quality: '3'
+ }
+ )
+ .to_return(status: 200, body: first_page.to_json)
+
+ # Second page
+ stub_request(:any, "#{user.settings['photoprism_url']}/api/v1/photos")
+ .with(
+ headers: common_headers,
+ query: {
+ after: '2023-01-01',
+ before: '2023-12-31',
+ count: '1000',
+ public: 'true',
+ q: '',
+ quality: '3',
+ offset: '1000'
+ }
+ )
+ .to_return(status: 200, body: second_page.to_json)
+
+ # Last page (empty)
+ stub_request(:any, "#{user.settings['photoprism_url']}/api/v1/photos")
+ .with(
+ headers: common_headers,
+ query: {
+ after: '2023-01-01',
+ before: '2023-12-31',
+ count: '1000',
+ public: 'true',
+ q: '',
+ quality: '3',
+ offset: '2000'
+ }
+ )
+ .to_return(status: 200, body: empty_page.to_json)
+ end
+
+ it 'fetches all pages until empty result' do
+ result = service.call
+ expect(result.length).to eq(2)
+ end
+ end
+ end
+end
From 202396a93db4e1f995672fdcd44417fd393160b9 Mon Sep 17 00:00:00 2001
From: Eugene Burmakin
Date: Mon, 2 Dec 2024 17:34:16 +0100
Subject: [PATCH 07/51] Implement photos request for both immich and photoprism
in single service class
---
app/controllers/api/v1/photos_controller.rb | 6 +--
app/models/user.rb | 8 ++++
app/services/photoprism/request_photos.rb | 3 +-
app/services/photos/request.rb | 38 +++++++++++++++++++
.../photoprism/request_photos_spec.rb | 9 +++--
5 files changed, 55 insertions(+), 9 deletions(-)
create mode 100644 app/services/photos/request.rb
diff --git a/app/controllers/api/v1/photos_controller.rb b/app/controllers/api/v1/photos_controller.rb
index 88baf2d7..c023a2d6 100644
--- a/app/controllers/api/v1/photos_controller.rb
+++ b/app/controllers/api/v1/photos_controller.rb
@@ -3,11 +3,7 @@
class Api::V1::PhotosController < ApiController
def index
@photos = Rails.cache.fetch("photos_#{params[:start_date]}_#{params[:end_date]}", expires_in: 1.day) do
- Immich::RequestPhotos.new(
- current_api_user,
- start_date: params[:start_date],
- end_date: params[:end_date]
- ).call.reject { |asset| asset['type'].downcase == 'video' }
+ Photos::Request.new(current_api_user, start_date: params[:start_date], end_date: params[:end_date]).call
end
render json: @photos, status: :ok
diff --git a/app/models/user.rb b/app/models/user.rb
index b7edcb26..53adfa2d 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -54,6 +54,14 @@ class User < ApplicationRecord
tracked_points.select(:id).where.not(geodata: {}).count
end
+ def immich_integration_configured?
+ settings['immich_url'].present? && settings['immich_api_key'].present?
+ end
+
+ def photoprism_integration_configured?
+ settings['photoprism_url'].present? && settings['photoprism_api_key'].present?
+ end
+
private
def create_api_key
diff --git a/app/services/photoprism/request_photos.rb b/app/services/photoprism/request_photos.rb
index a819c9a8..2eb89bab 100644
--- a/app/services/photoprism/request_photos.rb
+++ b/app/services/photoprism/request_photos.rb
@@ -71,7 +71,8 @@ class Photoprism::RequestPhotos
public: true,
quality: 3,
after: start_date,
- count: 1000
+ count: 1000,
+ photo: 'yes'
}
end
diff --git a/app/services/photos/request.rb b/app/services/photos/request.rb
new file mode 100644
index 00000000..7c490651
--- /dev/null
+++ b/app/services/photos/request.rb
@@ -0,0 +1,38 @@
+# frozen_string_literal: true
+
+class Photos::Request
+ attr_reader :user, :start_date, :end_date
+
+ def initialize(user, start_date: '1970-01-01', end_date: nil)
+ @user = user
+ @start_date = start_date
+ @end_date = end_date
+ end
+
+ def call
+ photos = []
+
+ photos << request_immich if user.immich_integration_configured?
+ photos << request_photoprism if user.photoprism_integration_configured?
+
+ photos
+ end
+
+ private
+
+ def request_immich
+ Immich::RequestPhotos.new(
+ user,
+ start_date: start_date,
+ end_date: end_date
+ ).call.reject { |asset| asset['type'].downcase == 'video' }
+ end
+
+ def request_photoprism
+ Photoprism::RequestPhotos.new(
+ user,
+ start_date: start_date,
+ end_date: end_date
+ ).call.select { |asset| asset['Type'].downcase == 'image' }
+ end
+end
diff --git a/spec/services/photoprism/request_photos_spec.rb b/spec/services/photoprism/request_photos_spec.rb
index 6dc79e6d..fb09fd51 100644
--- a/spec/services/photoprism/request_photos_spec.rb
+++ b/spec/services/photoprism/request_photos_spec.rb
@@ -231,7 +231,8 @@ RSpec.describe Photoprism::RequestPhotos do
count: '1000',
public: 'true',
q: '',
- quality: '3'
+ quality: '3',
+ photo: 'yes'
}
)
.to_return(status: 200, body: first_page.to_json)
@@ -247,7 +248,8 @@ RSpec.describe Photoprism::RequestPhotos do
public: 'true',
q: '',
quality: '3',
- offset: '1000'
+ offset: '1000',
+ photo: 'yes'
}
)
.to_return(status: 200, body: second_page.to_json)
@@ -263,7 +265,8 @@ RSpec.describe Photoprism::RequestPhotos do
public: 'true',
q: '',
quality: '3',
- offset: '2000'
+ offset: '2000',
+ photo: 'yes'
}
)
.to_return(status: 200, body: empty_page.to_json)
From be45af95fb9b6e2549d0f8968c47f63ca4fc5d25 Mon Sep 17 00:00:00 2001
From: Eugene Burmakin
Date: Mon, 2 Dec 2024 18:21:12 +0100
Subject: [PATCH 08/51] Implement photos serializer
---
app/controllers/api/v1/photos_controller.rb | 18 +++---
app/javascript/controllers/maps_controller.js | 9 ++-
app/serializers/api/photo_serializer.rb | 61 +++++++++++++++++++
app/services/immich/request_photos.rb | 2 +-
app/services/photoprism/request_photos.rb | 22 ++++---
app/services/photos/request.rb | 2 +-
.../photoprism/request_photos_spec.rb | 8 +--
7 files changed, 96 insertions(+), 26 deletions(-)
create mode 100644 app/serializers/api/photo_serializer.rb
diff --git a/app/controllers/api/v1/photos_controller.rb b/app/controllers/api/v1/photos_controller.rb
index c023a2d6..a31e03ea 100644
--- a/app/controllers/api/v1/photos_controller.rb
+++ b/app/controllers/api/v1/photos_controller.rb
@@ -10,7 +10,14 @@ class Api::V1::PhotosController < ApiController
end
def thumbnail
- response = Rails.cache.fetch("photo_thumbnail_#{params[:id]}", expires_in: 1.day) do
+ response = fetch_cached_thumbnail
+ handle_thumbnail_response(response)
+ end
+
+ private
+
+ def fetch_cached_thumbnail
+ Rails.cache.fetch("photo_thumbnail_#{params[:id]}", expires_in: 1.day) do
HTTParty.get(
"#{current_api_user.settings['immich_url']}/api/assets/#{params[:id]}/thumbnail?size=preview",
headers: {
@@ -19,14 +26,11 @@ class Api::V1::PhotosController < ApiController
}
)
end
+ end
+ def handle_thumbnail_response(response)
if response.success?
- send_data(
- response.body,
- type: 'image/jpeg',
- disposition: 'inline',
- status: :ok
- )
+ send_data(response.body, type: 'image/jpeg', disposition: 'inline', status: :ok)
else
render json: { error: 'Failed to fetch thumbnail' }, status: response.code
end
diff --git a/app/javascript/controllers/maps_controller.js b/app/javascript/controllers/maps_controller.js
index 20e058ee..338444cf 100644
--- a/app/javascript/controllers/maps_controller.js
+++ b/app/javascript/controllers/maps_controller.js
@@ -137,10 +137,13 @@ export default class extends Controller {
this.map.addControl(this.drawControl);
}
if (e.name === 'Photos') {
- if (!this.userSettings.immich_url || !this.userSettings.immich_api_key) {
+ if (
+ (!this.userSettings.immich_url || !this.userSettings.immich_api_key) &&
+ (!this.userSettings.photoprism_url || !this.userSettings.photoprism_api_key)
+ ) {
showFlashMessage(
'error',
- 'Immich integration is not configured. Please check your settings.'
+ 'Photos integration is not configured. Please check your integrations settings.'
);
return;
}
@@ -836,7 +839,7 @@ export default class extends Controller {
${photo.originalFileName}
Taken: ${new Date(photo.localDateTime).toLocaleString()}
Location: ${photo.exifInfo.city}, ${photo.exifInfo.state}, ${photo.exifInfo.country}
- ${photo.type === 'VIDEO' ? '🎥 Video' : '📷 Photo'}
+ ${photo.type === 'video' ? '🎥 Video' : '📷 Photo'}
`;
marker.bindPopup(popupContent);
diff --git a/app/serializers/api/photo_serializer.rb b/app/serializers/api/photo_serializer.rb
new file mode 100644
index 00000000..b063c541
--- /dev/null
+++ b/app/serializers/api/photo_serializer.rb
@@ -0,0 +1,61 @@
+# frozen_string_literal: true
+
+class Api::PhotoSerializer
+ def initialize(photo)
+ @photo = photo
+ end
+
+ def call
+ {
+ id: id,
+ latitude: latitude,
+ longitude: longitude,
+ localDateTime: local_date_time,
+ originalFileName: original_file_name,
+ city: city,
+ state: state,
+ country: country,
+ type: type
+ }
+ end
+
+ private
+
+ attr_reader :photo
+
+ def id
+ photo['id'] || photo['ID']
+ end
+
+ def latitude
+ photo.dig('exifInfo', 'latitude') || photo['Lat']
+ end
+
+ def longitude
+ photo.dig('exifInfo', 'longitude') || photo['Lng']
+ end
+
+ def local_date_time
+ photo['localDateTime'] || photo['TakenAtLocal']
+ end
+
+ def original_file_name
+ photo['originalFileName'] || photo['OriginalName']
+ end
+
+ def city
+ photo.dig('exifInfo', 'city') || photo['PlaceCity']
+ end
+
+ def state
+ photo.dig('exifInfo', 'state') || photo['PlaceState']
+ end
+
+ def country
+ photo.dig('exifInfo', 'country') || photo['PlaceCountry']
+ end
+
+ def type
+ (photo['type'] || photo['Type']).downcase
+ end
+end
diff --git a/app/services/immich/request_photos.rb b/app/services/immich/request_photos.rb
index 0f1eabc7..034a6452 100644
--- a/app/services/immich/request_photos.rb
+++ b/app/services/immich/request_photos.rb
@@ -5,7 +5,7 @@ class Immich::RequestPhotos
def initialize(user, start_date: '1970-01-01', end_date: nil)
@user = user
- @immich_api_base_url = "#{user.settings['immich_url']}/api/search/metadata"
+ @immich_api_base_url = URI.parse("#{user.settings['immich_url']}/api/search/metadata")
@immich_api_key = user.settings['immich_api_key']
@start_date = start_date
@end_date = end_date
diff --git a/app/services/photoprism/request_photos.rb b/app/services/photoprism/request_photos.rb
index 2eb89bab..4cf84810 100644
--- a/app/services/photoprism/request_photos.rb
+++ b/app/services/photoprism/request_photos.rb
@@ -1,12 +1,11 @@
# frozen_string_literal: true
class Photoprism::RequestPhotos
- class Error < StandardError; end
attr_reader :user, :photoprism_api_base_url, :photoprism_api_key, :start_date, :end_date
def initialize(user, start_date: '1970-01-01', end_date: nil)
@user = user
- @photoprism_api_base_url = "#{user.settings['photoprism_url']}/api/v1/photos"
+ @photoprism_api_base_url = URI.parse("#{user.settings['photoprism_url']}/api/v1/photos")
@photoprism_api_key = user.settings['photoprism_api_key']
@start_date = start_date
@end_date = end_date
@@ -18,7 +17,9 @@ class Photoprism::RequestPhotos
data = retrieve_photoprism_data
- time_framed_data(data)
+ return [] if data[0]['error'].present?
+
+ time_framed_data(data, start_date, end_date)
end
private
@@ -29,15 +30,14 @@ class Photoprism::RequestPhotos
while offset < 1_000_000
response_data = fetch_page(offset)
- break unless response_data
+ break if response_data.blank? || response_data[0]['error'].present?
data << response_data
- break if response_data.empty?
offset += 1000
end
- data
+ data.flatten
end
def fetch_page(offset)
@@ -47,7 +47,10 @@ class Photoprism::RequestPhotos
query: request_params(offset)
)
- raise Error, "Photoprism API returned #{response.code}: #{response.body}" if response.code != 200
+ if response.code != 200
+ Rails.logger.info "Photoprism API returned #{response.code}: #{response.body}"
+ Rails.logger.debug "Photoprism API request params: #{request_params(offset).inspect}"
+ end
JSON.parse(response.body)
end
@@ -71,12 +74,11 @@ class Photoprism::RequestPhotos
public: true,
quality: 3,
after: start_date,
- count: 1000,
- photo: 'yes'
+ count: 1000
}
end
- def time_framed_data(data)
+ def time_framed_data(data, start_date, end_date)
data.flatten.select do |photo|
taken_at = DateTime.parse(photo['TakenAtLocal'])
end_date ||= Time.current
diff --git a/app/services/photos/request.rb b/app/services/photos/request.rb
index 7c490651..5e0fe828 100644
--- a/app/services/photos/request.rb
+++ b/app/services/photos/request.rb
@@ -15,7 +15,7 @@ class Photos::Request
photos << request_immich if user.immich_integration_configured?
photos << request_photoprism if user.photoprism_integration_configured?
- photos
+ photos.flatten.map { |photo| Api::PhotoSerializer.new(photo).call }
end
private
diff --git a/spec/services/photoprism/request_photos_spec.rb b/spec/services/photoprism/request_photos_spec.rb
index fb09fd51..e8bcaaeb 100644
--- a/spec/services/photoprism/request_photos_spec.rb
+++ b/spec/services/photoprism/request_photos_spec.rb
@@ -201,10 +201,10 @@ RSpec.describe Photoprism::RequestPhotos do
.to_return(status: 401, body: 'Unauthorized')
end
- it 'raises an error' do
- expect do
- service.call
- end.to raise_error(Photoprism::RequestPhotos::Error, 'Photoprism API returned 401: Unauthorized')
+ it 'logs the error' do
+ expect(Rails.logger).to receive(:error).with('Photoprism API returned 401: Unauthorized')
+
+ service.call
end
end
From 8849a5e0a5ce97c141723a6adc89b4defc103438 Mon Sep 17 00:00:00 2001
From: Eugene Burmakin
Date: Tue, 3 Dec 2024 13:50:05 +0100
Subject: [PATCH 09/51] Add source to photos
---
app/controllers/api/v1/photos_controller.rb | 23 +++++++++++++++++----
app/javascript/maps/helpers.js | 4 ++--
app/serializers/api/photo_serializer.rb | 8 ++++---
app/services/photoprism/request_photos.rb | 5 +++--
app/services/photos/request.rb | 13 +++++++++---
5 files changed, 39 insertions(+), 14 deletions(-)
diff --git a/app/controllers/api/v1/photos_controller.rb b/app/controllers/api/v1/photos_controller.rb
index a31e03ea..32fd75ad 100644
--- a/app/controllers/api/v1/photos_controller.rb
+++ b/app/controllers/api/v1/photos_controller.rb
@@ -10,18 +10,23 @@ class Api::V1::PhotosController < ApiController
end
def thumbnail
- response = fetch_cached_thumbnail
+ return unauthorized_integration unless integration_configured?
+
+ response = fetch_cached_thumbnail(params[:source])
handle_thumbnail_response(response)
end
private
- def fetch_cached_thumbnail
+ def fetch_cached_thumbnail(source)
Rails.cache.fetch("photo_thumbnail_#{params[:id]}", expires_in: 1.day) do
+ source_url = current_api_user.settings["#{source}_url"]
+ source_api_key = current_api_user.settings["#{source}_api_key"]
+
HTTParty.get(
- "#{current_api_user.settings['immich_url']}/api/assets/#{params[:id]}/thumbnail?size=preview",
+ "#{source_url}/api/assets/#{params[:id]}/thumbnail?size=preview",
headers: {
- 'x-api-key' => current_api_user.settings['immich_api_key'],
+ 'x-api-key' => source_api_key,
'accept' => 'application/octet-stream'
}
)
@@ -35,4 +40,14 @@ class Api::V1::PhotosController < ApiController
render json: { error: 'Failed to fetch thumbnail' }, status: response.code
end
end
+
+ def integration_configured?
+ (params[:source] == 'immich' && current_api_user.immich_integration_configured?) ||
+ (params[:source] == 'photoprism' && current_api_user.photoprism_integration_configured?)
+ end
+
+ def unauthorized_integration
+ render json: { error: "#{params[:source].capitalize} integration not configured" },
+ status: :unauthorized
+ end
end
diff --git a/app/javascript/maps/helpers.js b/app/javascript/maps/helpers.js
index 9103f122..e21ca626 100644
--- a/app/javascript/maps/helpers.js
+++ b/app/javascript/maps/helpers.js
@@ -159,10 +159,10 @@ export async function fetchAndDisplayPhotos({ map, photoMarkers, apiKey, startDa
start_date: startDate,
end_date: endDate
});
-
+ console.log(startDate, endDate);
const response = await fetch(`/api/v1/photos?${params}`);
if (!response.ok) {
- throw new Error(`HTTP error! status: ${response.status}`);
+ throw new Error(`HTTP error! status: ${response.status}, response: ${response.body}`);
}
const photos = await response.json();
diff --git a/app/serializers/api/photo_serializer.rb b/app/serializers/api/photo_serializer.rb
index b063c541..169eff1d 100644
--- a/app/serializers/api/photo_serializer.rb
+++ b/app/serializers/api/photo_serializer.rb
@@ -1,8 +1,9 @@
# frozen_string_literal: true
class Api::PhotoSerializer
- def initialize(photo)
+ def initialize(photo, source)
@photo = photo
+ @source = source
end
def call
@@ -15,13 +16,14 @@ class Api::PhotoSerializer
city: city,
state: state,
country: country,
- type: type
+ type: type,
+ source: source
}
end
private
- attr_reader :photo
+ attr_reader :photo, :source
def id
photo['id'] || photo['ID']
diff --git a/app/services/photoprism/request_photos.rb b/app/services/photoprism/request_photos.rb
index 4cf84810..f3efa61b 100644
--- a/app/services/photoprism/request_photos.rb
+++ b/app/services/photoprism/request_photos.rb
@@ -17,7 +17,7 @@ class Photoprism::RequestPhotos
data = retrieve_photoprism_data
- return [] if data[0]['error'].present?
+ return [] if data.blank? || data[0]['error'].present?
time_framed_data(data, start_date, end_date)
end
@@ -30,7 +30,8 @@ class Photoprism::RequestPhotos
while offset < 1_000_000
response_data = fetch_page(offset)
- break if response_data.blank? || response_data[0]['error'].present?
+
+ break if response_data.blank? || (response_data.is_a?(Hash) && response_data.try(:[], 'error').present?)
data << response_data
diff --git a/app/services/photos/request.rb b/app/services/photos/request.rb
index 5e0fe828..3bb5d059 100644
--- a/app/services/photos/request.rb
+++ b/app/services/photos/request.rb
@@ -15,7 +15,7 @@ class Photos::Request
photos << request_immich if user.immich_integration_configured?
photos << request_photoprism if user.photoprism_integration_configured?
- photos.flatten.map { |photo| Api::PhotoSerializer.new(photo).call }
+ photos.flatten.map { |photo| Api::PhotoSerializer.new(photo, photo[:source]).call }
end
private
@@ -25,7 +25,7 @@ class Photos::Request
user,
start_date: start_date,
end_date: end_date
- ).call.reject { |asset| asset['type'].downcase == 'video' }
+ ).call.map { |asset| transform_asset(asset, 'immich') }.compact
end
def request_photoprism
@@ -33,6 +33,13 @@ class Photos::Request
user,
start_date: start_date,
end_date: end_date
- ).call.select { |asset| asset['Type'].downcase == 'image' }
+ ).call.map { |asset| transform_asset(asset, 'photoprism') }.compact
+ end
+
+ def transform_asset(asset, source)
+ asset_type = asset['type'] || asset['Type']
+ return if asset_type.downcase == 'video'
+
+ asset.merge(source: source)
end
end
From bf569da9210f899ce16413c76b75afa2d7e5fa9c Mon Sep 17 00:00:00 2001
From: Eugene Burmakin
Date: Tue, 3 Dec 2024 14:44:24 +0100
Subject: [PATCH 10/51] Implement thumbnail fetching for photoprism
---
app/controllers/api/v1/photos_controller.rb | 11 +---
app/serializers/api/photo_serializer.rb | 2 +-
.../photoprism/cache_preview_token.rb | 16 ++++++
app/services/photoprism/request_photos.rb | 8 +++
app/services/photos/thumbnail.rb | 54 +++++++++++++++++++
5 files changed, 80 insertions(+), 11 deletions(-)
create mode 100644 app/services/photoprism/cache_preview_token.rb
create mode 100644 app/services/photos/thumbnail.rb
diff --git a/app/controllers/api/v1/photos_controller.rb b/app/controllers/api/v1/photos_controller.rb
index 32fd75ad..e2edec86 100644
--- a/app/controllers/api/v1/photos_controller.rb
+++ b/app/controllers/api/v1/photos_controller.rb
@@ -20,16 +20,7 @@ class Api::V1::PhotosController < ApiController
def fetch_cached_thumbnail(source)
Rails.cache.fetch("photo_thumbnail_#{params[:id]}", expires_in: 1.day) do
- source_url = current_api_user.settings["#{source}_url"]
- source_api_key = current_api_user.settings["#{source}_api_key"]
-
- HTTParty.get(
- "#{source_url}/api/assets/#{params[:id]}/thumbnail?size=preview",
- headers: {
- 'x-api-key' => source_api_key,
- 'accept' => 'application/octet-stream'
- }
- )
+ Photos::Thumbnail.new(current_api_user, source, params[:id]).call
end
end
diff --git a/app/serializers/api/photo_serializer.rb b/app/serializers/api/photo_serializer.rb
index 169eff1d..97e972c4 100644
--- a/app/serializers/api/photo_serializer.rb
+++ b/app/serializers/api/photo_serializer.rb
@@ -26,7 +26,7 @@ class Api::PhotoSerializer
attr_reader :photo, :source
def id
- photo['id'] || photo['ID']
+ photo['id'] || photo['Hash']
end
def latitude
diff --git a/app/services/photoprism/cache_preview_token.rb b/app/services/photoprism/cache_preview_token.rb
new file mode 100644
index 00000000..da16166c
--- /dev/null
+++ b/app/services/photoprism/cache_preview_token.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+class Photoprism::CachePreviewToken
+ attr_reader :user, :preview_token
+
+ TOKEN_CACHE_KEY = 'dawarich/photoprism_preview_token'
+
+ def initialize(user, preview_token)
+ @user = user
+ @preview_token = preview_token
+ end
+
+ def call
+ Rails.cache.write("#{TOKEN_CACHE_KEY}_#{user.id}", preview_token)
+ end
+end
diff --git a/app/services/photoprism/request_photos.rb b/app/services/photoprism/request_photos.rb
index f3efa61b..7bc40025 100644
--- a/app/services/photoprism/request_photos.rb
+++ b/app/services/photoprism/request_photos.rb
@@ -53,6 +53,8 @@ class Photoprism::RequestPhotos
Rails.logger.debug "Photoprism API request params: #{request_params(offset).inspect}"
end
+ cache_preview_token(response.headers)
+
JSON.parse(response.body)
end
@@ -86,4 +88,10 @@ class Photoprism::RequestPhotos
taken_at.between?(start_date.to_datetime, end_date.to_datetime)
end
end
+
+ def cache_preview_token(headers)
+ preview_token = headers['X-Preview-Token']
+
+ Photoprism::CachePreviewToken.new(user, preview_token).call
+ end
end
diff --git a/app/services/photos/thumbnail.rb b/app/services/photos/thumbnail.rb
new file mode 100644
index 00000000..be3063a1
--- /dev/null
+++ b/app/services/photos/thumbnail.rb
@@ -0,0 +1,54 @@
+# frozen_string_literal: true
+
+class Photos::Thumbnail
+ def initialize(user, source, id)
+ @user = user
+ @source = source
+ @id = id
+ end
+
+ def call
+ fetch_thumbnail_from_source
+ end
+
+ private
+
+ attr_reader :user, :source, :id
+
+ def source_url
+ user.settings["#{source}_url"]
+ end
+
+ def source_api_key
+ user.settings["#{source}_api_key"]
+ end
+
+ def source_path
+ case source
+ when 'immich'
+ "/api/assets/#{id}/thumbnail?size=preview"
+ when 'photoprism'
+ preview_token = Rails.cache.read("#{Photoprism::CachePreviewToken::TOKEN_CACHE_KEY}_#{user.id}")
+ "/api/v1/t/#{id}/#{preview_token}/tile_500"
+ else
+ raise "Unsupported source: #{source}"
+ end
+ end
+
+ def headers
+ request_headers = {
+ 'accept' => 'application/octet-stream'
+ }
+
+ request_headers['X-Api-Key'] = source_api_key if source == 'immich'
+
+ request_headers
+ end
+
+ def fetch_thumbnail_from_source
+ url = "#{source_url}#{source_path}"
+ a = HTTParty.get(url, headers: headers)
+ pp url
+ a
+ end
+end
From 0a201d74ac1c76a54a0051f308b04b37be58f635 Mon Sep 17 00:00:00 2001
From: Eugene Burmakin
Date: Tue, 3 Dec 2024 14:53:10 +0100
Subject: [PATCH 11/51] Update marker rendering code to adapt to new photo
format
---
app/javascript/maps/helpers.js | 13 +++++++------
1 file changed, 7 insertions(+), 6 deletions(-)
diff --git a/app/javascript/maps/helpers.js b/app/javascript/maps/helpers.js
index e21ca626..24ec501d 100644
--- a/app/javascript/maps/helpers.js
+++ b/app/javascript/maps/helpers.js
@@ -171,7 +171,7 @@ export async function fetchAndDisplayPhotos({ map, photoMarkers, apiKey, startDa
const photoLoadPromises = photos.map(photo => {
return new Promise((resolve) => {
const img = new Image();
- const thumbnailUrl = `/api/v1/photos/${photo.id}/thumbnail.jpg?api_key=${apiKey}`;
+ const thumbnailUrl = `/api/v1/photos/${photo.id}/thumbnail.jpg?api_key=${apiKey}&source=${photo.source}`;
img.onload = () => {
createPhotoMarker(photo, userSettings.immich_url, photoMarkers, apiKey);
@@ -217,10 +217,10 @@ export async function fetchAndDisplayPhotos({ map, photoMarkers, apiKey, startDa
}
-export function createPhotoMarker(photo, immichUrl, photoMarkers,apiKey) {
- if (!photo.exifInfo?.latitude || !photo.exifInfo?.longitude) return;
+export function createPhotoMarker(photo, immichUrl, photoMarkers, apiKey) {
+ if (!photo.latitude || !photo.longitude) return;
- const thumbnailUrl = `/api/v1/photos/${photo.id}/thumbnail.jpg?api_key=${apiKey}`;
+ const thumbnailUrl = `/api/v1/photos/${photo.id}/thumbnail.jpg?api_key=${apiKey}&source=${photo.source}`;
const icon = L.divIcon({
className: 'photo-marker',
@@ -229,7 +229,7 @@ export function createPhotoMarker(photo, immichUrl, photoMarkers,apiKey) {
});
const marker = L.marker(
- [photo.exifInfo.latitude, photo.exifInfo.longitude],
+ [photo.latitude, photo.longitude],
{ icon }
);
@@ -256,7 +256,8 @@ export function createPhotoMarker(photo, immichUrl, photoMarkers,apiKey) {
${photo.originalFileName}
Taken: ${new Date(photo.localDateTime).toLocaleString()}
- Location: ${photo.exifInfo.city}, ${photo.exifInfo.state}, ${photo.exifInfo.country}
+ Location: ${photo.city}, ${photo.state}, ${photo.country}
+ Source: ${photo.source}
${photo.type === 'VIDEO' ? '🎥 Video' : '📷 Photo'}
`;
From bea7f281729cf03ff08b9c04b216edd24bb22550 Mon Sep 17 00:00:00 2001
From: Eugene Burmakin
Date: Tue, 3 Dec 2024 15:02:11 +0100
Subject: [PATCH 12/51] Update link to photos in maps photo popup
---
app/javascript/maps/helpers.js | 17 ++++++++++++-----
1 file changed, 12 insertions(+), 5 deletions(-)
diff --git a/app/javascript/maps/helpers.js b/app/javascript/maps/helpers.js
index 24ec501d..89dbd3f1 100644
--- a/app/javascript/maps/helpers.js
+++ b/app/javascript/maps/helpers.js
@@ -174,7 +174,7 @@ export async function fetchAndDisplayPhotos({ map, photoMarkers, apiKey, startDa
const thumbnailUrl = `/api/v1/photos/${photo.id}/thumbnail.jpg?api_key=${apiKey}&source=${photo.source}`;
img.onload = () => {
- createPhotoMarker(photo, userSettings.immich_url, photoMarkers, apiKey);
+ createPhotoMarker(photo, userSettings, photoMarkers, apiKey);
resolve();
};
@@ -217,7 +217,7 @@ export async function fetchAndDisplayPhotos({ map, photoMarkers, apiKey, startDa
}
-export function createPhotoMarker(photo, immichUrl, photoMarkers, apiKey) {
+export function createPhotoMarker(photo, userSettings, photoMarkers, apiKey) {
if (!photo.latitude || !photo.longitude) return;
const thumbnailUrl = `/api/v1/photos/${photo.id}/thumbnail.jpg?api_key=${apiKey}&source=${photo.source}`;
@@ -244,10 +244,17 @@ export function createPhotoMarker(photo, immichUrl, photoMarkers, apiKey) {
takenBefore: endOfDay.toISOString()
};
const encodedQuery = encodeURIComponent(JSON.stringify(queryParams));
- const immich_photo_link = `${immichUrl}/search?query=${encodedQuery}`;
+ console.log(userSettings);
+ let photo_link;
+ if (photo.source === 'immich') {
+ photo_link = `${userSettings.immich_url}/search?query=${encodedQuery}`;
+ } else if (photo.source === 'photoprism') {
+ photo_link = `${userSettings.photoprism_url}/library/browse?view=cards&year=${photo.localDateTime.split('-')[0]}&month=${photo.localDateTime.split('-')[1]}&order=newest&public=true&quality=3`;
+ }
+ const source_url = photo.source === 'photoprism' ? userSettings.photoprism_url : userSettings.immich_url;
const popupContent = `
`;
From 83078c5b290f07d0ae4b97b9533fffa37b400305 Mon Sep 17 00:00:00 2001
From: Eugene Burmakin
Date: Tue, 3 Dec 2024 15:05:40 +0100
Subject: [PATCH 13/51] Refactor photo links code
---
app/javascript/maps/helpers.js | 53 ++++++++++++++++++++++------------
1 file changed, 35 insertions(+), 18 deletions(-)
diff --git a/app/javascript/maps/helpers.js b/app/javascript/maps/helpers.js
index 89dbd3f1..446122a8 100644
--- a/app/javascript/maps/helpers.js
+++ b/app/javascript/maps/helpers.js
@@ -216,6 +216,39 @@ export async function fetchAndDisplayPhotos({ map, photoMarkers, apiKey, startDa
}
}
+function getPhotoLink(photo, userSettings) {
+ switch (photo.source) {
+ case 'immich':
+ const startOfDay = new Date(photo.localDateTime);
+ startOfDay.setHours(0, 0, 0, 0);
+
+ const endOfDay = new Date(photo.localDateTime);
+ endOfDay.setHours(23, 59, 59, 999);
+
+ const queryParams = {
+ takenAfter: startOfDay.toISOString(),
+ takenBefore: endOfDay.toISOString()
+ };
+ const encodedQuery = encodeURIComponent(JSON.stringify(queryParams));
+
+ return `${userSettings.immich_url}/search?query=${encodedQuery}`;
+ case 'photoprism':
+ return `${userSettings.photoprism_url}/library/browse?view=cards&year=${photo.localDateTime.split('-')[0]}&month=${photo.localDateTime.split('-')[1]}&order=newest&public=true&quality=3`;
+ default:
+ return '#'; // Default or error case
+ }
+}
+
+function getSourceUrl(photo, userSettings) {
+ switch (photo.source) {
+ case 'photoprism':
+ return userSettings.photoprism_url;
+ case 'immich':
+ return userSettings.immich_url;
+ default:
+ return '#'; // Default or error case
+ }
+}
export function createPhotoMarker(photo, userSettings, photoMarkers, apiKey) {
if (!photo.latitude || !photo.longitude) return;
@@ -233,25 +266,9 @@ export function createPhotoMarker(photo, userSettings, photoMarkers, apiKey) {
{ icon }
);
- const startOfDay = new Date(photo.localDateTime);
- startOfDay.setHours(0, 0, 0, 0);
+ const photo_link = getPhotoLink(photo, userSettings);
+ const source_url = getSourceUrl(photo, userSettings);
- const endOfDay = new Date(photo.localDateTime);
- endOfDay.setHours(23, 59, 59, 999);
-
- const queryParams = {
- takenAfter: startOfDay.toISOString(),
- takenBefore: endOfDay.toISOString()
- };
- const encodedQuery = encodeURIComponent(JSON.stringify(queryParams));
- console.log(userSettings);
- let photo_link;
- if (photo.source === 'immich') {
- photo_link = `${userSettings.immich_url}/search?query=${encodedQuery}`;
- } else if (photo.source === 'photoprism') {
- photo_link = `${userSettings.photoprism_url}/library/browse?view=cards&year=${photo.localDateTime.split('-')[0]}&month=${photo.localDateTime.split('-')[1]}&order=newest&public=true&quality=3`;
- }
- const source_url = photo.source === 'photoprism' ? userSettings.photoprism_url : userSettings.immich_url;
const popupContent = `
Date: Tue, 3 Dec 2024 15:12:20 +0100
Subject: [PATCH 14/51] Consider both Immich and Photoprism integrations in
trips controller
---
app/javascript/controllers/trips_controller.js | 4 ++--
app/javascript/maps/helpers.js | 2 +-
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/app/javascript/controllers/trips_controller.js b/app/javascript/controllers/trips_controller.js
index 00a2b497..497fe5e3 100644
--- a/app/javascript/controllers/trips_controller.js
+++ b/app/javascript/controllers/trips_controller.js
@@ -80,10 +80,10 @@ export default class extends Controller {
this.map.on('overlayadd', (e) => {
if (e.name !== 'Photos') return;
- if (!this.userSettings.immich_url || !this.userSettings.immich_api_key) {
+ if ((!this.userSettings.immich_url || !this.userSettings.immich_api_key) && (!this.userSettings.photoprism_url || !this.userSettings.photoprism_api_key)) {
showFlashMessage(
'error',
- 'Immich integration is not configured. Please check your settings.'
+ 'Photos integration is not configured. Please check your integrations settings.'
);
return;
}
diff --git a/app/javascript/maps/helpers.js b/app/javascript/maps/helpers.js
index 446122a8..7fdcdbca 100644
--- a/app/javascript/maps/helpers.js
+++ b/app/javascript/maps/helpers.js
@@ -159,7 +159,7 @@ export async function fetchAndDisplayPhotos({ map, photoMarkers, apiKey, startDa
start_date: startDate,
end_date: endDate
});
- console.log(startDate, endDate);
+
const response = await fetch(`/api/v1/photos?${params}`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}, response: ${response.body}`);
From e17b671c9ca206615fe792480121a8bdfaed8e1b Mon Sep 17 00:00:00 2001
From: Eugene Burmakin
Date: Tue, 3 Dec 2024 15:40:21 +0100
Subject: [PATCH 15/51] Add a button to import Photoprism geodata
---
app/jobs/enqueue_background_job.rb | 2 +
app/jobs/import/photoprism_geodata_job.rb | 12 ++++
app/models/import.rb | 2 +-
app/services/immich/import_geodata.rb | 2 +-
app/services/photoprism/import_geodata.rb | 80 +++++++++++++++++++++++
app/views/imports/index.html.erb | 5 ++
spec/requests/api/v1/photos_spec.rb | 20 ++++--
7 files changed, 116 insertions(+), 7 deletions(-)
create mode 100644 app/jobs/import/photoprism_geodata_job.rb
create mode 100644 app/services/photoprism/import_geodata.rb
diff --git a/app/jobs/enqueue_background_job.rb b/app/jobs/enqueue_background_job.rb
index aa5cdccf..61e103c3 100644
--- a/app/jobs/enqueue_background_job.rb
+++ b/app/jobs/enqueue_background_job.rb
@@ -7,6 +7,8 @@ class EnqueueBackgroundJob < ApplicationJob
case job_name
when 'start_immich_import'
Import::ImmichGeodataJob.perform_later(user_id)
+ when 'start_photoprism_import'
+ Import::PhotoprismGeodataJob.perform_later(user_id)
when 'start_reverse_geocoding', 'continue_reverse_geocoding'
Jobs::Create.new(job_name, user_id).call
else
diff --git a/app/jobs/import/photoprism_geodata_job.rb b/app/jobs/import/photoprism_geodata_job.rb
new file mode 100644
index 00000000..7aa2d27e
--- /dev/null
+++ b/app/jobs/import/photoprism_geodata_job.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+class Import::PhotoprismGeodataJob < ApplicationJob
+ queue_as :imports
+ sidekiq_options retry: false
+
+ def perform(user_id)
+ user = User.find(user_id)
+
+ Photoprism::ImportGeodata.new(user).call
+ end
+end
diff --git a/app/models/import.rb b/app/models/import.rb
index c6e5a8a6..067baf12 100644
--- a/app/models/import.rb
+++ b/app/models/import.rb
@@ -10,7 +10,7 @@ class Import < ApplicationRecord
enum :source, {
google_semantic_history: 0, owntracks: 1, google_records: 2,
- google_phone_takeout: 3, gpx: 4, immich_api: 5, geojson: 6
+ google_phone_takeout: 3, gpx: 4, immich_api: 5, geojson: 6, photoprism_api: 7
}
def process!
diff --git a/app/services/immich/import_geodata.rb b/app/services/immich/import_geodata.rb
index 766643a7..469761d6 100644
--- a/app/services/immich/import_geodata.rb
+++ b/app/services/immich/import_geodata.rb
@@ -57,7 +57,7 @@ class Immich::ImportGeodata
end
def log_no_data
- Rails.logger.info 'No data found'
+ Rails.logger.info 'No geodata found for Immich'
end
def create_import_failed_notification(import_name)
diff --git a/app/services/photoprism/import_geodata.rb b/app/services/photoprism/import_geodata.rb
new file mode 100644
index 00000000..a24f2542
--- /dev/null
+++ b/app/services/photoprism/import_geodata.rb
@@ -0,0 +1,80 @@
+# frozen_string_literal: true
+
+class Photoprism::ImportGeodata
+ attr_reader :user, :start_date, :end_date
+
+ def initialize(user, start_date: '1970-01-01', end_date: nil)
+ @user = user
+ @start_date = start_date
+ @end_date = end_date
+ end
+
+ def call
+ photoprism_data = retrieve_photoprism_data
+
+ log_no_data and return if photoprism_data.empty?
+
+ photoprism_data_json = parse_photoprism_data(photoprism_data)
+ file_name = file_name(photoprism_data_json)
+ import = user.imports.find_or_initialize_by(name: file_name, source: :photoprism_api)
+
+ create_import_failed_notification(import.name) and return unless import.new_record?
+
+ import.raw_data = photoprism_data_json
+ import.save!
+
+ ImportJob.perform_later(user.id, import.id)
+ end
+
+ private
+
+ def retrieve_photoprism_data
+ Photoprism::RequestPhotos.new(user, start_date:, end_date:).call
+ end
+
+ def parse_photoprism_data(photoprism_data)
+ geodata = photoprism_data.map do |asset|
+ next unless valid?(asset)
+
+ extract_geodata(asset)
+ end
+
+ geodata.compact.sort_by { |data| data[:timestamp] }
+ end
+
+ def valid?(asset)
+ asset['Lat'] &&
+ asset['Lat'] != 0 &&
+ asset['Lng'] &&
+ asset['Lng'] != 0 &&
+ asset['TakenAt']
+ end
+
+ def extract_geodata(asset)
+ {
+ latitude: asset.dig('exifInfo', 'latitude'),
+ longitude: asset.dig('exifInfo', 'longitude'),
+ timestamp: Time.zone.parse(asset.dig('exifInfo', 'dateTimeOriginal')).to_i
+ }
+ end
+
+ def log_no_data
+ Rails.logger.info 'No geodata found for Photoprism'
+ end
+
+ def create_import_failed_notification(import_name)
+ Notifications::Create.new(
+ user:,
+ kind: :info,
+ title: 'Import was not created',
+ content: "Import with the same name (#{import_name}) already exists. If you want to proceed, delete the existing import and try again."
+ ).call
+ end
+
+ def file_name(photoprism_data_json)
+ from = Time.zone.at(photoprism_data_json.first[:timestamp]).to_date
+ to = Time.zone.at(photoprism_data_json.last[:timestamp]).to_date
+
+ "photoprism-geodata-#{user.email}-from-#{from}-to-#{to}.json"
+ end
+end
diff --git a/app/views/imports/index.html.erb b/app/views/imports/index.html.erb
index 32ab69ff..b3f8cbfc 100644
--- a/app/views/imports/index.html.erb
+++ b/app/views/imports/index.html.erb
@@ -10,6 +10,11 @@
<% else %>
Import Immich data
<% end %>
+ <% if current_user.settings['photoprism_url'] && current_user.settings['photoprism_api_key'] %>
+ <%= link_to 'Import Photoprism data', settings_background_jobs_path(job_name: 'start_photoprism_import'), method: :post, data: { confirm: 'Are you sure?', turbo_confirm: 'Are you sure?', turbo_method: :post }, class: 'rounded-lg py-3 px-5 bg-blue-600 text-white block font-medium' %>
+ <% else %>
+ Import Photoprism data
+ <% end %>
diff --git a/spec/requests/api/v1/photos_spec.rb b/spec/requests/api/v1/photos_spec.rb
index d15a5342..c24a5360 100644
--- a/spec/requests/api/v1/photos_spec.rb
+++ b/spec/requests/api/v1/photos_spec.rb
@@ -9,25 +9,35 @@ RSpec.describe 'Api::V1::Photos', type: :request do
let(:photo_data) do
[
{
- 'id' => '123',
+ 'id' => 1,
'latitude' => 35.6762,
'longitude' => 139.6503,
'localDateTime' => '2024-01-01T00:00:00.000Z',
- 'type' => 'photo'
+ 'originalFileName' => 'photo1.jpg',
+ 'city' => 'Tokyo',
+ 'state' => 'Tokyo',
+ 'country' => 'Japan',
+ 'type' => 'photo',
+ 'source' => 'photoprism'
},
{
- 'id' => '456',
+ 'id' => 2,
'latitude' => 40.7128,
'longitude' => -74.0060,
'localDateTime' => '2024-01-02T00:00:00.000Z',
- 'type' => 'photo'
+ 'originalFileName' => 'photo2.jpg',
+ 'city' => 'New York',
+ 'state' => 'New York',
+ 'country' => 'USA',
+ 'type' => 'photo',
+ 'source' => 'immich'
}
]
end
context 'when the request is successful' do
before do
- allow_any_instance_of(Immich::RequestPhotos).to receive(:call).and_return(photo_data)
+ allow_any_instance_of(Photos::Request).to receive(:call).and_return(photo_data)
get '/api/v1/photos', params: { api_key: user.api_key }
end
From ba2a95233c2b02cee6872d2d222dbf7e31d15820 Mon Sep 17 00:00:00 2001
From: Eugene Burmakin
Date: Tue, 3 Dec 2024 15:59:34 +0100
Subject: [PATCH 16/51] Implement importing geodata from photoprism
---
app/services/imports/create.rb | 12 +++---
app/services/photoprism/import_geodata.rb | 40 +++++++++++--------
.../{immich => photos}/import_parser.rb | 2 +-
.../{immich => photos}/import_parser_spec.rb | 2 +-
4 files changed, 31 insertions(+), 25 deletions(-)
rename app/services/{immich => photos}/import_parser.rb (97%)
rename spec/services/{immich => photos}/import_parser_spec.rb (97%)
diff --git a/app/services/imports/create.rb b/app/services/imports/create.rb
index 4ce3e7c2..7c34cc1f 100644
--- a/app/services/imports/create.rb
+++ b/app/services/imports/create.rb
@@ -24,12 +24,12 @@ class Imports::Create
def parser(source)
# Bad classes naming by the way, they are not parsers, they are point creators
case source
- when 'google_semantic_history' then GoogleMaps::SemanticHistoryParser
- when 'google_phone_takeout' then GoogleMaps::PhoneTakeoutParser
- when 'owntracks' then OwnTracks::ExportParser
- when 'gpx' then Gpx::TrackParser
- when 'immich_api' then Immich::ImportParser
- when 'geojson' then Geojson::ImportParser
+ when 'google_semantic_history' then GoogleMaps::SemanticHistoryParser
+ when 'google_phone_takeout' then GoogleMaps::PhoneTakeoutParser
+ when 'owntracks' then OwnTracks::ExportParser
+ when 'gpx' then Gpx::TrackParser
+ when 'geojson' then Geojson::ImportParser
+ when 'immich_api', 'photoprism_api' then Photos::ImportParser
end
end
diff --git a/app/services/photoprism/import_geodata.rb b/app/services/photoprism/import_geodata.rb
index a24f2542..182681e6 100644
--- a/app/services/photoprism/import_geodata.rb
+++ b/app/services/photoprism/import_geodata.rb
@@ -11,23 +11,29 @@ class Photoprism::ImportGeodata
def call
photoprism_data = retrieve_photoprism_data
+ return log_no_data if photoprism_data.empty?
- log_no_data and return if photoprism_data.empty?
-
- photoprism_data_json = parse_photoprism_data(photoprism_data)
- file_name = file_name(photoprism_data_json)
- import = user.imports.find_or_initialize_by(name: file_name, source: :photoprism_api)
-
- create_import_failed_notification(import.name) and return unless import.new_record?
-
- import.raw_data = photoprism_data_json
- import.save!
-
- ImportJob.perform_later(user.id, import.id)
+ json_data = parse_photoprism_data(photoprism_data)
+ create_and_process_import(json_data)
end
private
+ def create_and_process_import(json_data)
+ import = find_or_create_import(json_data)
+ return create_import_failed_notification(import.name) unless import.new_record?
+
+ import.update!(raw_data: json_data)
+ ImportJob.perform_later(user.id, import.id)
+ end
+
+ def find_or_create_import(json_data)
+ user.imports.find_or_initialize_by(
+ name: file_name(json_data),
+ source: :photoprism_api
+ )
+ end
+
def retrieve_photoprism_data
Photoprism::RequestPhotos.new(user, start_date:, end_date:).call
end
@@ -52,9 +58,9 @@ class Photoprism::ImportGeodata
def extract_geodata(asset)
{
- latitude: asset.dig('exifInfo', 'latitude'),
- longitude: asset.dig('exifInfo', 'longitude'),
- timestamp: Time.zone.parse(asset.dig('exifInfo', 'dateTimeOriginal')).to_i
+ latitude: asset['Lat'],
+ longitude: asset['Lng'],
+ timestamp: Time.zone.parse(asset['TakenAt']).to_i
}
end
@@ -72,8 +78,8 @@ class Photoprism::ImportGeodata
end
def file_name(photoprism_data_json)
- from = Time.zone.at(photoprism_data_json.first[:timestamp]).to_date
- to = Time.zone.at(photoprism_data_json.last[:timestamp]).to_date
+ from = Time.zone.at(photoprism_data_json.first[:timestamp]).to_date
+ to = Time.zone.at(photoprism_data_json.last[:timestamp]).to_date
"photoprism-geodata-#{user.email}-from-#{from}-to-#{to}.json"
end
diff --git a/app/services/immich/import_parser.rb b/app/services/photos/import_parser.rb
similarity index 97%
rename from app/services/immich/import_parser.rb
rename to app/services/photos/import_parser.rb
index b0a2a38c..97b9c9d4 100644
--- a/app/services/immich/import_parser.rb
+++ b/app/services/photos/import_parser.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class Immich::ImportParser
+class Photos::ImportParser
include Imports::Broadcaster
attr_reader :import, :json, :user_id
diff --git a/spec/services/immich/import_parser_spec.rb b/spec/services/photos/import_parser_spec.rb
similarity index 97%
rename from spec/services/immich/import_parser_spec.rb
rename to spec/services/photos/import_parser_spec.rb
index cefa4dc6..33460398 100644
--- a/spec/services/immich/import_parser_spec.rb
+++ b/spec/services/photos/import_parser_spec.rb
@@ -2,7 +2,7 @@
require 'rails_helper'
-RSpec.describe Immich::ImportParser do
+RSpec.describe Photos::ImportParser do
describe '#call' do
subject(:service) { described_class.new(import, user.id).call }
From 93e91e7944b3313c80b89c2c3ecfda9987da577d Mon Sep 17 00:00:00 2001
From: Eugene Burmakin
Date: Tue, 3 Dec 2024 16:05:38 +0100
Subject: [PATCH 17/51] Fix swagger docs for /api/v1/photos/{id}/thumbnail
---
app/controllers/api/v1/photos_controller.rb | 2 +-
spec/models/import_spec.rb | 3 +-
spec/swagger/api/v1/photos_controller_spec.rb | 129 +++------
swagger/v1/swagger.yaml | 249 +++---------------
4 files changed, 75 insertions(+), 308 deletions(-)
diff --git a/app/controllers/api/v1/photos_controller.rb b/app/controllers/api/v1/photos_controller.rb
index e2edec86..b3bb0e6a 100644
--- a/app/controllers/api/v1/photos_controller.rb
+++ b/app/controllers/api/v1/photos_controller.rb
@@ -38,7 +38,7 @@ class Api::V1::PhotosController < ApiController
end
def unauthorized_integration
- render json: { error: "#{params[:source].capitalize} integration not configured" },
+ render json: { error: "#{params[:source]&.capitalize} integration not configured" },
status: :unauthorized
end
end
diff --git a/spec/models/import_spec.rb b/spec/models/import_spec.rb
index 3ac6130d..2e04a91d 100644
--- a/spec/models/import_spec.rb
+++ b/spec/models/import_spec.rb
@@ -17,7 +17,8 @@ RSpec.describe Import, type: :model do
google_phone_takeout: 3,
gpx: 4,
immich_api: 5,
- geojson: 6
+ geojson: 6,
+ photoprism_api: 7
)
end
end
diff --git a/spec/swagger/api/v1/photos_controller_spec.rb b/spec/swagger/api/v1/photos_controller_spec.rb
index eb3cb737..5b63d307 100644
--- a/spec/swagger/api/v1/photos_controller_spec.rb
+++ b/spec/swagger/api/v1/photos_controller_spec.rb
@@ -102,60 +102,29 @@ RSpec.describe 'Api::V1::PhotosController', type: :request do
items: {
type: :object,
properties: {
+ # {
+ # id: id,
+ # latitude: latitude,
+ # longitude: longitude,
+ # localDateTime: local_date_time,
+ # originalFileName: original_file_name,
+ # city: city,
+ # state: state,
+ # country: country,
+ # type: type,
+ # source: source
id: { type: :string },
- deviceAssetId: { type: :string },
- ownerId: { type: :string },
- type: { type: :string },
- originalPath: { type: :string },
- originalFileName: { type: :string },
- originalMimeType: { type: :string },
- thumbhash: { type: :string },
- fileCreatedAt: { type: :string, format: 'date-time' },
- fileModifiedAt: { type: :string, format: 'date-time' },
+ latitude: { type: :number, format: :float },
+ longitude: { type: :number, format: :float },
localDateTime: { type: :string, format: 'date-time' },
- updatedAt: { type: :string, format: 'date-time' },
- isFavorite: { type: :boolean },
- isArchived: { type: :boolean },
- isTrashed: { type: :boolean },
- duration: { type: :string },
- exifInfo: {
- type: :object,
- properties: {
- make: { type: :string },
- model: { type: :string },
- exifImageWidth: { type: :integer },
- exifImageHeight: { type: :integer },
- fileSizeInByte: { type: :integer },
- orientation: { type: :string },
- dateTimeOriginal: { type: :string, format: 'date-time' },
- modifyDate: { type: :string, format: 'date-time' },
- timeZone: { type: :string },
- lensModel: { type: :string },
- fNumber: { type: :number, format: :float },
- focalLength: { type: :number, format: :float },
- iso: { type: :integer },
- exposureTime: { type: :string },
- latitude: { type: :number, format: :float },
- longitude: { type: :number, format: :float },
- city: { type: :string },
- state: { type: :string },
- country: { type: :string },
- description: { type: :string },
- projectionType: { type: %i[string null] },
- rating: { type: %i[integer null] }
- }
- },
- checksum: { type: :string },
- isOffline: { type: :boolean },
- hasMetadata: { type: :boolean },
- duplicateId: { type: :string },
- resized: { type: :boolean }
+ originalFileName: { type: :string },
+ city: { type: :string },
+ state: { type: :string },
+ country: { type: :string },
+ type: { type: :string },
+ source: { type: :string }
},
- required: %w[id deviceAssetId ownerId type originalPath
- originalFileName originalMimeType thumbhash
- fileCreatedAt fileModifiedAt localDateTime
- updatedAt isFavorite isArchived isTrashed duration
- exifInfo checksum isOffline hasMetadata duplicateId resized]
+ required: %w[id latitude longitude localDateTime originalFileName city state country type source]
}
run_test! do |response|
@@ -172,61 +141,24 @@ RSpec.describe 'Api::V1::PhotosController', type: :request do
produces 'application/json'
parameter name: :id, in: :path, type: :string, required: true
parameter name: :api_key, in: :query, type: :string, required: true
-
+ parameter name: :source, in: :query, type: :string, required: true
response '200', 'photo found' do
schema type: :object,
properties: {
id: { type: :string },
- deviceAssetId: { type: :string },
- ownerId: { type: :string },
- type: { type: :string },
- originalPath: { type: :string },
- originalFileName: { type: :string },
- originalMimeType: { type: :string },
- thumbhash: { type: :string },
- fileCreatedAt: { type: :string, format: 'date-time' },
- fileModifiedAt: { type: :string, format: 'date-time' },
+ latitude: { type: :number, format: :float },
+ longitude: { type: :number, format: :float },
localDateTime: { type: :string, format: 'date-time' },
- updatedAt: { type: :string, format: 'date-time' },
- isFavorite: { type: :boolean },
- isArchived: { type: :boolean },
- isTrashed: { type: :boolean },
- duration: { type: :string },
- exifInfo: {
- type: :object,
- properties: {
- make: { type: :string },
- model: { type: :string },
- exifImageWidth: { type: :integer },
- exifImageHeight: { type: :integer },
- fileSizeInByte: { type: :integer },
- orientation: { type: :string },
- dateTimeOriginal: { type: :string, format: 'date-time' },
- modifyDate: { type: :string, format: 'date-time' },
- timeZone: { type: :string },
- lensModel: { type: :string },
- fNumber: { type: :number, format: :float },
- focalLength: { type: :number, format: :float },
- iso: { type: :integer },
- exposureTime: { type: :string },
- latitude: { type: :number, format: :float },
- longitude: { type: :number, format: :float },
- city: { type: :string },
- state: { type: :string },
- country: { type: :string },
- description: { type: :string },
- projectionType: { type: %i[string null] },
- rating: { type: %i[integer null] }
- }
- },
- checksum: { type: :string },
- isOffline: { type: :boolean },
- hasMetadata: { type: :boolean },
- duplicateId: { type: :string },
- resized: { type: :boolean }
+ originalFileName: { type: :string },
+ city: { type: :string },
+ state: { type: :string },
+ country: { type: :string },
+ type: { type: :string },
+ source: { type: :string }
}
let(:id) { '7fe486e3-c3ba-4b54-bbf9-1281b39ed15c' }
+ let(:source) { 'immich' }
run_test! do |response|
data = JSON.parse(response.body)
@@ -238,6 +170,7 @@ RSpec.describe 'Api::V1::PhotosController', type: :request do
response '404', 'photo not found' do
let(:id) { 'nonexistent' }
let(:api_key) { user.api_key }
+ let(:source) { 'immich' }
run_test! do |response|
data = JSON.parse(response.body)
diff --git a/swagger/v1/swagger.yaml b/swagger/v1/swagger.yaml
index 3ecfb855..6657aebf 100644
--- a/swagger/v1/swagger.yaml
+++ b/swagger/v1/swagger.yaml
@@ -347,130 +347,38 @@ paths:
properties:
id:
type: string
- deviceAssetId:
- type: string
- ownerId:
- type: string
- type:
- type: string
- originalPath:
- type: string
- originalFileName:
- type: string
- originalMimeType:
- type: string
- thumbhash:
- type: string
- fileCreatedAt:
- type: string
- format: date-time
- fileModifiedAt:
- type: string
- format: date-time
+ latitude:
+ type: number
+ format: float
+ longitude:
+ type: number
+ format: float
localDateTime:
type: string
format: date-time
- updatedAt:
+ originalFileName:
type: string
- format: date-time
- isFavorite:
- type: boolean
- isArchived:
- type: boolean
- isTrashed:
- type: boolean
- duration:
+ city:
type: string
- exifInfo:
- type: object
- properties:
- make:
- type: string
- model:
- type: string
- exifImageWidth:
- type: integer
- exifImageHeight:
- type: integer
- fileSizeInByte:
- type: integer
- orientation:
- type: string
- dateTimeOriginal:
- type: string
- format: date-time
- modifyDate:
- type: string
- format: date-time
- timeZone:
- type: string
- lensModel:
- type: string
- fNumber:
- type: number
- format: float
- focalLength:
- type: number
- format: float
- iso:
- type: integer
- exposureTime:
- type: string
- latitude:
- type: number
- format: float
- longitude:
- type: number
- format: float
- city:
- type: string
- state:
- type: string
- country:
- type: string
- description:
- type: string
- projectionType:
- type:
- - string
- - 'null'
- rating:
- type:
- - integer
- - 'null'
- checksum:
+ state:
type: string
- isOffline:
- type: boolean
- hasMetadata:
- type: boolean
- duplicateId:
+ country:
+ type: string
+ type:
+ type: string
+ source:
type: string
- resized:
- type: boolean
required:
- id
- - deviceAssetId
- - ownerId
- - type
- - originalPath
- - originalFileName
- - originalMimeType
- - thumbhash
- - fileCreatedAt
- - fileModifiedAt
+ - latitude
+ - longitude
- localDateTime
- - updatedAt
- - isFavorite
- - isArchived
- - isTrashed
- - duration
- - exifInfo
- - checksum
- - isOffline
- - hasMetadata
- - duplicateId
- - resized
+ - originalFileName
+ - city
+ - state
+ - country
+ - type
+ - source
"/api/v1/photos/{id}/thumbnail":
get:
summary: Retrieves a photo
@@ -487,6 +395,11 @@ paths:
required: true
schema:
type: string
+ - name: source
+ in: query
+ required: true
+ schema:
+ type: string
responses:
'200':
description: photo found
@@ -497,107 +410,27 @@ paths:
properties:
id:
type: string
- deviceAssetId:
- type: string
- ownerId:
- type: string
- type:
- type: string
- originalPath:
- type: string
- originalFileName:
- type: string
- originalMimeType:
- type: string
- thumbhash:
- type: string
- fileCreatedAt:
- type: string
- format: date-time
- fileModifiedAt:
- type: string
- format: date-time
+ latitude:
+ type: number
+ format: float
+ longitude:
+ type: number
+ format: float
localDateTime:
type: string
format: date-time
- updatedAt:
+ originalFileName:
type: string
- format: date-time
- isFavorite:
- type: boolean
- isArchived:
- type: boolean
- isTrashed:
- type: boolean
- duration:
+ city:
type: string
- exifInfo:
- type: object
- properties:
- make:
- type: string
- model:
- type: string
- exifImageWidth:
- type: integer
- exifImageHeight:
- type: integer
- fileSizeInByte:
- type: integer
- orientation:
- type: string
- dateTimeOriginal:
- type: string
- format: date-time
- modifyDate:
- type: string
- format: date-time
- timeZone:
- type: string
- lensModel:
- type: string
- fNumber:
- type: number
- format: float
- focalLength:
- type: number
- format: float
- iso:
- type: integer
- exposureTime:
- type: string
- latitude:
- type: number
- format: float
- longitude:
- type: number
- format: float
- city:
- type: string
- state:
- type: string
- country:
- type: string
- description:
- type: string
- projectionType:
- type:
- - string
- - 'null'
- rating:
- type:
- - integer
- - 'null'
- checksum:
+ state:
type: string
- isOffline:
- type: boolean
- hasMetadata:
- type: boolean
- duplicateId:
+ country:
+ type: string
+ type:
+ type: string
+ source:
type: string
- resized:
- type: boolean
'404':
description: photo not found
"/api/v1/points":
From e32ad54f35012d21c4244779a987f35d9e36746d Mon Sep 17 00:00:00 2001
From: Eugene Burmakin
Date: Tue, 3 Dec 2024 16:26:28 +0100
Subject: [PATCH 18/51] Fix failing tests
---
app/services/photoprism/request_photos.rb | 2 +-
.../photoprism/request_photos_spec.rb | 68 ++++++++++---------
2 files changed, 38 insertions(+), 32 deletions(-)
diff --git a/app/services/photoprism/request_photos.rb b/app/services/photoprism/request_photos.rb
index 7bc40025..a7fb000d 100644
--- a/app/services/photoprism/request_photos.rb
+++ b/app/services/photoprism/request_photos.rb
@@ -49,7 +49,7 @@ class Photoprism::RequestPhotos
)
if response.code != 200
- Rails.logger.info "Photoprism API returned #{response.code}: #{response.body}"
+ Rails.logger.error "Photoprism API returned #{response.code}: #{response.body}"
Rails.logger.debug "Photoprism API request params: #{request_params(offset).inspect}"
end
diff --git a/spec/services/photoprism/request_photos_spec.rb b/spec/services/photoprism/request_photos_spec.rb
index e8bcaaeb..a4461151 100644
--- a/spec/services/photoprism/request_photos_spec.rb
+++ b/spec/services/photoprism/request_photos_spec.rb
@@ -4,15 +4,17 @@ require 'rails_helper'
RSpec.describe Photoprism::RequestPhotos do
let(:user) do
- create(:user,
- settings: {
- 'photoprism_url' => 'http://photoprism.local',
- 'photoprism_api_key' => 'test_api_key'
- })
+ create(
+ :user,
+ settings: {
+ 'photoprism_url' => 'http://photoprism.local',
+ 'photoprism_api_key' => 'test_api_key'
+ }
+ )
end
- let(:start_date) { '2023-01-01' }
- let(:end_date) { '2023-12-31' }
+ let(:start_date) { '2024-01-01' }
+ let(:end_date) { '2024-12-31' }
let(:service) { described_class.new(user, start_date: start_date, end_date: end_date) }
let(:mock_photo_response) do
@@ -150,7 +152,7 @@ RSpec.describe Photoprism::RequestPhotos do
before do
stub_request(
:any,
- "#{user.settings['photoprism_url']}/api/v1/photos?after=2023-01-01&before=2023-12-31&count=1000&public=true&q=&quality=3"
+ "#{user.settings['photoprism_url']}/api/v1/photos?after=#{start_date}&before=#{end_date}&count=1000&public=true&q=&quality=3"
).with(
headers: {
'Accept' => 'application/json',
@@ -166,7 +168,7 @@ RSpec.describe Photoprism::RequestPhotos do
stub_request(
:any,
- "#{user.settings['photoprism_url']}/api/v1/photos?after=2023-01-01&before=2023-12-31&count=1000&public=true&q=&quality=3&offset=1000"
+ "#{user.settings['photoprism_url']}/api/v1/photos?after=#{start_date}&before=#{end_date}&count=1000&public=true&q=&quality=3&offset=1000"
).to_return(status: 200, body: [].to_json)
end
@@ -196,43 +198,48 @@ RSpec.describe Photoprism::RequestPhotos do
before do
stub_request(
:get,
- "#{user.settings['photoprism_url']}/api/v1/photos?after=2023-01-01&before=2023-12-31&count=1000&public=true&q=&quality=3"
- )
- .to_return(status: 401, body: 'Unauthorized')
+ "#{user.settings['photoprism_url']}/api/v1/photos?after=#{start_date}&before=#{end_date}&count=1000&public=true&q=&quality=3"
+ ).to_return(status: 400, body: { status: 400, error: 'Unable to do that' }.to_json)
end
it 'logs the error' do
- expect(Rails.logger).to receive(:error).with('Photoprism API returned 401: Unauthorized')
+ expect(Rails.logger).to \
+ receive(:error).with('Photoprism API returned 400: {"status":400,"error":"Unable to do that"}')
+ expect(Rails.logger).to \
+ receive(:debug).with(
+ "Photoprism API request params: #{{ q: '', public: true, quality: 3, after: start_date, count: 1000,
+before: end_date }}"
+ )
service.call
end
end
context 'with pagination' do
- let(:first_page) { [{ 'TakenAtLocal' => '2023-06-15T14:30:00Z' }] }
- let(:second_page) { [{ 'TakenAtLocal' => '2023-06-16T14:30:00Z' }] }
+ let(:first_page) { [{ 'TakenAtLocal' => "#{start_date}T14:30:00Z" }] }
+ let(:second_page) { [{ 'TakenAtLocal' => "#{start_date}T14:30:00Z" }] }
let(:empty_page) { [] }
-
- before do
- common_headers = {
+ let(:common_headers) do
+ {
'Accept' => 'application/json',
'Accept-Encoding' => 'gzip;q=1.0,deflate;q=0.6,identity;q=0.3',
'Authorization' => 'Bearer test_api_key',
'User-Agent' => 'Ruby'
}
+ end
+ before do
# First page
stub_request(:any, "#{user.settings['photoprism_url']}/api/v1/photos")
.with(
headers: common_headers,
query: {
- after: '2023-01-01',
- before: '2023-12-31',
+ after: start_date,
+ before: end_date,
count: '1000',
public: 'true',
q: '',
- quality: '3',
- photo: 'yes'
+ quality: '3'
}
)
.to_return(status: 200, body: first_page.to_json)
@@ -242,14 +249,13 @@ RSpec.describe Photoprism::RequestPhotos do
.with(
headers: common_headers,
query: {
- after: '2023-01-01',
- before: '2023-12-31',
+ after: start_date,
+ before: end_date,
count: '1000',
public: 'true',
q: '',
quality: '3',
- offset: '1000',
- photo: 'yes'
+ offset: '1000'
}
)
.to_return(status: 200, body: second_page.to_json)
@@ -259,14 +265,13 @@ RSpec.describe Photoprism::RequestPhotos do
.with(
headers: common_headers,
query: {
- after: '2023-01-01',
- before: '2023-12-31',
+ after: start_date,
+ before: end_date,
count: '1000',
public: 'true',
q: '',
quality: '3',
- offset: '2000',
- photo: 'yes'
+ offset: '2000'
}
)
.to_return(status: 200, body: empty_page.to_json)
@@ -274,7 +279,8 @@ RSpec.describe Photoprism::RequestPhotos do
it 'fetches all pages until empty result' do
result = service.call
- expect(result.length).to eq(2)
+
+ expect(result.size).to eq(2)
end
end
end
From b22a13282ee9c9193f75ae427b472952217b8c2a Mon Sep 17 00:00:00 2001
From: Eugene Burmakin
Date: Tue, 3 Dec 2024 16:32:45 +0100
Subject: [PATCH 19/51] Update changelog and version
---
.app_version | 2 +-
CHANGELOG.md | 9 +++++++++
README.md | 4 ++++
3 files changed, 14 insertions(+), 1 deletion(-)
diff --git a/.app_version b/.app_version
index 503a21de..1cf0537c 100644
--- a/.app_version
+++ b/.app_version
@@ -1 +1 @@
-0.18.2
+0.19.0
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6c1cd353..6a29907c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,15 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/)
and this project adheres to [Semantic Versioning](http://semver.org/).
+# 0.19.0 - 2024-12-03
+
+## The Photoprism integration release
+
+### Added
+
+- Photos from Photoprism are now can be shown on the map. To enable this feature, you need to provide your Photoprism instance URL and API key in the Settings page. Then you need to enable "Photos" layer on the map (top right corner).
+- Geodata is now can be imported from Photoprism to Dawarich.
+
# 0.18.2 - 2024-11-29
### Added
diff --git a/README.md b/README.md
index 9b88431a..16878306 100644
--- a/README.md
+++ b/README.md
@@ -99,6 +99,10 @@ Simply install one of the supported apps on your device and configure it to send
### 📊 Statistics
- Analyze your travel history: number of countries/cities visited, distance traveled, and time spent, broken down by year and month.
+### 📸 Integrations
+- Provide credentials for Immich or Photoprism (or both!) and Dawarich will automatically import geodata from your photos.
+- You'll also be able to visualize your photos on the map!
+
### 📥 Import Your Data
- Import from various sources:
- Google Maps Timeline
From 7bcdff58685a14bee4ec468d41e9d02fb6922244 Mon Sep 17 00:00:00 2001
From: Eugene Burmakin
Date: Tue, 3 Dec 2024 16:39:09 +0100
Subject: [PATCH 20/51] Update changelog
---
CHANGELOG.md | 19 +++++++++++++++++++
spec/swagger/api/v1/photos_controller_spec.rb | 11 -----------
2 files changed, 19 insertions(+), 11 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6a29907c..c0bfa4f9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,25 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
## The Photoprism integration release
+⚠️ This release introduces a breaking change. The `GET /api/v1/photos` endpoint now returns following structure of the response:
+
+```json
+[
+ {
+ "id": id,
+ "latitude": latitude,
+ "longitude": longitude,
+ "localDateTime": local_date_time,
+ "originalFileName": original_file_name,
+ "city": city,
+ "state": state,
+ "country": country,
+ "type": type, // "image" or "video"
+ "source": source // "photoprism" or "immich"
+ }
+]
+```
+
### Added
- Photos from Photoprism are now can be shown on the map. To enable this feature, you need to provide your Photoprism instance URL and API key in the Settings page. Then you need to enable "Photos" layer on the map (top right corner).
diff --git a/spec/swagger/api/v1/photos_controller_spec.rb b/spec/swagger/api/v1/photos_controller_spec.rb
index 5b63d307..f441c307 100644
--- a/spec/swagger/api/v1/photos_controller_spec.rb
+++ b/spec/swagger/api/v1/photos_controller_spec.rb
@@ -102,17 +102,6 @@ RSpec.describe 'Api::V1::PhotosController', type: :request do
items: {
type: :object,
properties: {
- # {
- # id: id,
- # latitude: latitude,
- # longitude: longitude,
- # localDateTime: local_date_time,
- # originalFileName: original_file_name,
- # city: city,
- # state: state,
- # country: country,
- # type: type,
- # source: source
id: { type: :string },
latitude: { type: :number, format: :float },
longitude: { type: :number, format: :float },
From 16817718aab08477b6af2395208122a43babda48 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jo=C3=A3o=20Duarte?=
Date: Tue, 3 Dec 2024 22:05:05 +0000
Subject: [PATCH 21/51] fix imports of owntracks .rec files containing events
other than locations
---
app/services/own_tracks/rec_parser.rb | 9 +++++++--
spec/fixtures/files/owntracks/2024-03.rec | 3 +++
2 files changed, 10 insertions(+), 2 deletions(-)
diff --git a/app/services/own_tracks/rec_parser.rb b/app/services/own_tracks/rec_parser.rb
index 2b07a9a8..7e502263 100644
--- a/app/services/own_tracks/rec_parser.rb
+++ b/app/services/own_tracks/rec_parser.rb
@@ -9,7 +9,12 @@ class OwnTracks::RecParser
def call
file.split("\n").map do |line|
- JSON.parse(line.split("\t* \t")[1])
- end
+ parts = line.split("\t")
+ if parts.size > 2 && parts[1].strip == '*'
+ JSON.parse(parts[2])
+ else
+ nil
+ end
+ end.compact
end
end
diff --git a/spec/fixtures/files/owntracks/2024-03.rec b/spec/fixtures/files/owntracks/2024-03.rec
index 2f2508b1..473591f7 100644
--- a/spec/fixtures/files/owntracks/2024-03.rec
+++ b/spec/fixtures/files/owntracks/2024-03.rec
@@ -6,5 +6,8 @@
2024-03-01T18:33:03Z * {"cog":18,"batt":85,"lon":13.337,"acc":5,"bs":1,"p":100.347,"vel":4,"vac":3,"lat":52.230,"topic":"owntracks/test/iPhone 12 Pro","conn":"m","m":1,"tst":1709317983,"alt":36,"_type":"location","tid":"RO","_http":true}
2024-03-01T18:40:11Z * {"cog":43,"batt":85,"lon":13.338,"acc":5,"bs":1,"p":100.348,"vel":6,"vac":3,"lat":52.231,"topic":"owntracks/test/iPhone 12 Pro","conn":"m","m":1,"tst":1709318411,"alt":37,"_type":"location","tid":"RO","_http":true}
2024-03-01T18:42:57Z * {"cog":320,"batt":85,"lon":13.339,"acc":5,"bs":1,"p":100.353,"vel":3,"vac":3,"lat":52.232,"topic":"owntracks/test/iPhone 12 Pro","t":"v","conn":"m","m":1,"tst":1709318577,"alt":37,"_type":"location","tid":"RO","_http":true}
+2024-03-01T18:40:08Z lwt {"_type":"lwt","tst":1717459208}
+2024-03-01T18:40:09Z waypoints {"_type":"waypoint","desc":"Home","lat":52.232,"lon":13.339,"rad":50,"tst":1717459768}
+2024-03-01T18:40:10Z event {"_type":"transition","acc":5,"desc":"Home","event":"enter","lat":52.232,"lon":13.339,"t":"l","tid":"s8","tst":1717460098,"wtst":1717459768}
2024-03-01T18:40:11Z * {"cog":43,"batt":85,"lon":13.338,"acc":5,"bs":1,"p":100.348,"vel":6,"vac":3,"lat":52.231,"topic":"owntracks/test/iPhone 12 Pro","conn":"m","m":1,"tst":1709318411,"alt":37,"_type":"location","tid":"RO","_http":true}
2024-03-01T18:40:11Z * {"cog":43,"batt":85,"lon":13.341,"acc":5,"bs":1,"p":100.348,"created_at":1709318940,"vel":6,"vac":3,"lat":52.234,"topic":"owntracks/test/iPhone 12 Pro","conn":"m","m":1,"tst":1709318411,"alt":37,"_type":"location","tid":"RO","_http":true}
From 955f8946ad83bc327af4fcb7e0b04c71467235fc Mon Sep 17 00:00:00 2001
From: Eugene Burmakin
Date: Wed, 4 Dec 2024 12:32:13 +0100
Subject: [PATCH 22/51] Add test for photos integration not being configured
---
app/controllers/api/v1/photos_controller.rb | 16 +++-
spec/factories/users.rb | 11 ++-
spec/requests/api/v1/photos_spec.rb | 90 +++++++++++--------
spec/swagger/api/v1/photos_controller_spec.rb | 2 +-
4 files changed, 77 insertions(+), 42 deletions(-)
diff --git a/app/controllers/api/v1/photos_controller.rb b/app/controllers/api/v1/photos_controller.rb
index b3bb0e6a..b2930888 100644
--- a/app/controllers/api/v1/photos_controller.rb
+++ b/app/controllers/api/v1/photos_controller.rb
@@ -1,6 +1,9 @@
# frozen_string_literal: true
class Api::V1::PhotosController < ApiController
+ before_action :check_integration_configured, only: %i[index thumbnail]
+ before_action :check_source, only: %i[thumbnail]
+
def index
@photos = Rails.cache.fetch("photos_#{params[:start_date]}_#{params[:end_date]}", expires_in: 1.day) do
Photos::Request.new(current_api_user, start_date: params[:start_date], end_date: params[:end_date]).call
@@ -10,8 +13,6 @@ class Api::V1::PhotosController < ApiController
end
def thumbnail
- return unauthorized_integration unless integration_configured?
-
response = fetch_cached_thumbnail(params[:source])
handle_thumbnail_response(response)
end
@@ -33,8 +34,15 @@ class Api::V1::PhotosController < ApiController
end
def integration_configured?
- (params[:source] == 'immich' && current_api_user.immich_integration_configured?) ||
- (params[:source] == 'photoprism' && current_api_user.photoprism_integration_configured?)
+ current_api_user.immich_integration_configured? || current_api_user.photoprism_integration_configured?
+ end
+
+ def check_integration_configured
+ unauthorized_integration unless integration_configured?
+ end
+
+ def check_source
+ unauthorized_integration unless params[:source] == 'immich' || params[:source] == 'photoprism'
end
def unauthorized_integration
diff --git a/spec/factories/users.rb b/spec/factories/users.rb
index 2d4e654f..f3fe9d7b 100644
--- a/spec/factories/users.rb
+++ b/spec/factories/users.rb
@@ -23,7 +23,7 @@ FactoryBot.define do
admin { true }
end
- trait :with_immich_credentials do
+ trait :with_immich_integration do
settings do
{
immich_url: 'https://immich.example.com',
@@ -31,5 +31,14 @@ FactoryBot.define do
}
end
end
+
+ trait :with_photoprism_integration do
+ settings do
+ {
+ photoprism_url: 'https://photoprism.example.com',
+ photoprism_api_key: '1234567890'
+ }
+ end
+ end
end
end
diff --git a/spec/requests/api/v1/photos_spec.rb b/spec/requests/api/v1/photos_spec.rb
index c24a5360..c1e440bc 100644
--- a/spec/requests/api/v1/photos_spec.rb
+++ b/spec/requests/api/v1/photos_spec.rb
@@ -4,50 +4,68 @@ require 'rails_helper'
RSpec.describe 'Api::V1::Photos', type: :request do
describe 'GET /index' do
- let(:user) { create(:user) }
+ context 'when the integration is configured' do
+ let(:user) { create(:user, :with_photoprism_integration) }
- let(:photo_data) do
- [
- {
- 'id' => 1,
- 'latitude' => 35.6762,
- 'longitude' => 139.6503,
- 'localDateTime' => '2024-01-01T00:00:00.000Z',
- 'originalFileName' => 'photo1.jpg',
- 'city' => 'Tokyo',
- 'state' => 'Tokyo',
- 'country' => 'Japan',
- 'type' => 'photo',
- 'source' => 'photoprism'
- },
- {
- 'id' => 2,
- 'latitude' => 40.7128,
- 'longitude' => -74.0060,
- 'localDateTime' => '2024-01-02T00:00:00.000Z',
- 'originalFileName' => 'photo2.jpg',
- 'city' => 'New York',
- 'state' => 'New York',
- 'country' => 'USA',
- 'type' => 'photo',
- 'source' => 'immich'
- }
- ]
+ let(:photo_data) do
+ [
+ {
+ 'id' => 1,
+ 'latitude' => 35.6762,
+ 'longitude' => 139.6503,
+ 'localDateTime' => '2024-01-01T00:00:00.000Z',
+ 'originalFileName' => 'photo1.jpg',
+ 'city' => 'Tokyo',
+ 'state' => 'Tokyo',
+ 'country' => 'Japan',
+ 'type' => 'photo',
+ 'source' => 'photoprism'
+ },
+ {
+ 'id' => 2,
+ 'latitude' => 40.7128,
+ 'longitude' => -74.0060,
+ 'localDateTime' => '2024-01-02T00:00:00.000Z',
+ 'originalFileName' => 'photo2.jpg',
+ 'city' => 'New York',
+ 'state' => 'New York',
+ 'country' => 'USA',
+ 'type' => 'photo',
+ 'source' => 'immich'
+ }
+ ]
+ end
+
+ context 'when the request is successful' do
+ before do
+ allow_any_instance_of(Photos::Request).to receive(:call).and_return(photo_data)
+
+ get '/api/v1/photos', params: { api_key: user.api_key }
+ end
+
+ it 'returns http success' do
+ expect(response).to have_http_status(:success)
+ end
+
+ it 'returns photos data as JSON' do
+ expect(JSON.parse(response.body)).to eq(photo_data)
+ end
+ end
end
- context 'when the request is successful' do
+ context 'when the integration is not configured' do
+ let(:user) { create(:user) }
+
before do
- allow_any_instance_of(Photos::Request).to receive(:call).and_return(photo_data)
-
- get '/api/v1/photos', params: { api_key: user.api_key }
+ get '/api/v1/photos', params: { api_key: user.api_key, source: 'immich' }
end
- it 'returns http success' do
- expect(response).to have_http_status(:success)
+ it 'returns http unauthorized' do
+ expect(response).to have_http_status(:unauthorized)
end
- it 'returns photos data as JSON' do
- expect(JSON.parse(response.body)).to eq(photo_data)
+ it 'returns an error message' do
+ expect(JSON.parse(response.body)).to eq({ 'error' => 'Immich integration not configured' })
end
end
end
diff --git a/spec/swagger/api/v1/photos_controller_spec.rb b/spec/swagger/api/v1/photos_controller_spec.rb
index f441c307..eef5d9a5 100644
--- a/spec/swagger/api/v1/photos_controller_spec.rb
+++ b/spec/swagger/api/v1/photos_controller_spec.rb
@@ -3,7 +3,7 @@
require 'swagger_helper'
RSpec.describe 'Api::V1::PhotosController', type: :request do
- let(:user) { create(:user, :with_immich_credentials) }
+ let(:user) { create(:user, :with_immich_integration) }
let(:api_key) { user.api_key }
let(:start_date) { '2024-01-01' }
let(:end_date) { '2024-01-02' }
From 9d573d90f3d14b5e2c29b1b26479c1186866177e Mon Sep 17 00:00:00 2001
From: Eugene Burmakin
Date: Wed, 4 Dec 2024 13:17:15 +0100
Subject: [PATCH 23/51] Add spec for photo serializer
---
app/serializers/api/photo_serializer.rb | 2 +-
spec/serializers/api/photo_serializer_spec.rb | 160 ++++++++++++++++++
2 files changed, 161 insertions(+), 1 deletion(-)
create mode 100644 spec/serializers/api/photo_serializer_spec.rb
diff --git a/app/serializers/api/photo_serializer.rb b/app/serializers/api/photo_serializer.rb
index 97e972c4..5e3ce9a5 100644
--- a/app/serializers/api/photo_serializer.rb
+++ b/app/serializers/api/photo_serializer.rb
@@ -2,7 +2,7 @@
class Api::PhotoSerializer
def initialize(photo, source)
- @photo = photo
+ @photo = photo.with_indifferent_access
@source = source
end
diff --git a/spec/serializers/api/photo_serializer_spec.rb b/spec/serializers/api/photo_serializer_spec.rb
new file mode 100644
index 00000000..3dad077a
--- /dev/null
+++ b/spec/serializers/api/photo_serializer_spec.rb
@@ -0,0 +1,160 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe Api::PhotoSerializer do
+ describe '#call' do
+ subject(:serialized_photo) { described_class.new(photo, source).call }
+
+ context 'when photo is from immich' do
+ let(:source) { 'immich' }
+ let(:photo) do
+ {
+ "id": '7fe486e3-c3ba-4b54-bbf9-1281b39ed15c',
+ "deviceAssetId": 'IMG_9913.jpeg-1168914',
+ "ownerId": 'f579f328-c355-438c-a82c-fe3390bd5f08',
+ "deviceId": 'CLI',
+ "libraryId": nil,
+ "type": 'IMAGE',
+ "originalPath": 'upload/library/admin/2023/2023-06-08/IMG_9913.jpeg',
+ "originalFileName": 'IMG_9913.jpeg',
+ "originalMimeType": 'image/jpeg',
+ "thumbhash": '4RgONQaZqYaH93g3h3p3d6RfPPrG',
+ "fileCreatedAt": '2023-06-08T07:58:45.637Z',
+ "fileModifiedAt": '2023-06-08T09:58:45.000Z',
+ "localDateTime": '2023-06-08T09:58:45.637Z',
+ "updatedAt": '2024-08-24T18:20:47.965Z',
+ "isFavorite": false,
+ "isArchived": false,
+ "isTrashed": false,
+ "duration": '0:00:00.00000',
+ "exifInfo": {
+ "make": 'Apple',
+ "model": 'iPhone 12 Pro',
+ "exifImageWidth": 4032,
+ "exifImageHeight": 3024,
+ "fileSizeInByte": 1_168_914,
+ "orientation": '6',
+ "dateTimeOriginal": '2023-06-08T07:58:45.637Z',
+ "modifyDate": '2023-06-08T07:58:45.000Z',
+ "timeZone": 'Europe/Berlin',
+ "lensModel": 'iPhone 12 Pro back triple camera 4.2mm f/1.6',
+ "fNumber": 1.6,
+ "focalLength": 4.2,
+ "iso": 320,
+ "exposureTime": '1/60',
+ "latitude": 52.11,
+ "longitude": 13.22,
+ "city": 'Johannisthal',
+ "state": 'Berlin',
+ "country": 'Germany',
+ "description": '',
+ "projectionType": nil,
+ "rating": nil
+ },
+ "livePhotoVideoId": nil,
+ "people": [],
+ "checksum": 'aL1edPVg4ZpEnS6xCRWNUY0pUS8=',
+ "isOffline": false,
+ "hasMetadata": true,
+ "duplicateId": '88a34bee-783d-46e4-aa52-33b75ffda375',
+ "resized": true
+ }
+ end
+
+ it 'serializes the photo correctly' do
+ expect(serialized_photo).to eq(
+ id: '7fe486e3-c3ba-4b54-bbf9-1281b39ed15c',
+ latitude: 52.11,
+ longitude: 13.22,
+ localDateTime: '2023-06-08T09:58:45.637Z',
+ originalFileName: 'IMG_9913.jpeg',
+ city: 'Johannisthal',
+ state: 'Berlin',
+ country: 'Germany',
+ type: 'image',
+ source: 'immich'
+ )
+ end
+ end
+
+ context 'when photo is from photoprism' do
+ let(:source) { 'photoprism' }
+ let(:photo) do
+ {
+ 'ID' => '102',
+ 'UID' => 'psnver0s3x7wxfnh',
+ 'Type' => 'image',
+ 'TypeSrc' => '',
+ 'TakenAt' => '2023-10-10T16:04:33Z',
+ 'TakenAtLocal' => '2023-10-10T16:04:33Z',
+ 'TakenSrc' => 'name',
+ 'TimeZone' => '',
+ 'Path' => '2023/10',
+ 'Name' => '20231010_160433_91981432',
+ 'OriginalName' => 'photo_2023-10-10 16.04.33',
+ 'Title' => 'Photo / 2023',
+ 'Description' => '',
+ 'Year' => 2023,
+ 'Month' => 10,
+ 'Day' => 10,
+ 'Country' => 'zz',
+ 'Stack' => 0,
+ 'Favorite' => false,
+ 'Private' => false,
+ 'Iso' => 0,
+ 'FocalLength' => 0,
+ 'FNumber' => 0,
+ 'Exposure' => '',
+ 'Quality' => 1,
+ 'Resolution' => 1,
+ 'Color' => 4,
+ 'Scan' => false,
+ 'Panorama' => false,
+ 'CameraID' => 1,
+ 'CameraModel' => 'Unknown',
+ 'LensID' => 1,
+ 'LensModel' => 'Unknown',
+ 'Lat' => 11,
+ 'Lng' => 22,
+ 'CellID' => 'zz',
+ 'PlaceID' => 'zz',
+ 'PlaceSrc' => '',
+ 'PlaceLabel' => 'Unknown',
+ 'PlaceCity' => 'Unknown',
+ 'PlaceState' => 'Unknown',
+ 'PlaceCountry' => 'zz',
+ 'InstanceID' => '',
+ 'FileUID' => 'fsnver0clrfzatmz',
+ 'FileRoot' => '/',
+ 'FileName' => '2023/10/20231010_160433_91981432.jpeg',
+ 'Hash' => 'ce1849fd7cf6a50eb201fbb669ab78c7ac13263b',
+ 'Width' => 1280,
+ 'Height' => 908,
+ 'Portrait' => false,
+ 'Merged' => false,
+ 'CreatedAt' => '2024-12-02T14:25:48Z',
+ 'UpdatedAt' => '2024-12-02T14:36:45Z',
+ 'EditedAt' => '0001-01-01T00:00:00Z',
+ 'CheckedAt' => '2024-12-02T14:36:45Z',
+ 'Files' => nil
+ }
+ end
+
+ it 'serializes the photo correctly' do
+ expect(serialized_photo).to eq(
+ id: 'ce1849fd7cf6a50eb201fbb669ab78c7ac13263b',
+ latitude: 11,
+ longitude: 22,
+ localDateTime: '2023-10-10T16:04:33Z',
+ originalFileName: 'photo_2023-10-10 16.04.33',
+ city: 'Unknown',
+ state: 'Unknown',
+ country: 'zz',
+ type: 'image',
+ source: 'photoprism'
+ )
+ end
+ end
+ end
+end
From 243a85ed4e385c0fc5d9d802a041cb5494917450 Mon Sep 17 00:00:00 2001
From: Eugene Burmakin
Date: Wed, 4 Dec 2024 13:33:15 +0100
Subject: [PATCH 24/51] Add specs for Imports::Create
---
spec/services/imports/create_spec.rb | 105 +++++++++++++++++++++++++++
1 file changed, 105 insertions(+)
create mode 100644 spec/services/imports/create_spec.rb
diff --git a/spec/services/imports/create_spec.rb b/spec/services/imports/create_spec.rb
new file mode 100644
index 00000000..d35e1898
--- /dev/null
+++ b/spec/services/imports/create_spec.rb
@@ -0,0 +1,105 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe Imports::Create do
+ let(:user) { create(:user) }
+ let(:service) { described_class.new(user, import) }
+
+ describe '#call' do
+ context 'when source is google_semantic_history' do
+ let(:import) { create(:import, source: 'google_semantic_history') }
+
+ it 'calls the GoogleMaps::SemanticHistoryParser' do
+ expect(GoogleMaps::SemanticHistoryParser).to receive(:new).with(import, user.id).and_return(double(call: true))
+ service.call
+ end
+ end
+
+ context 'when source is google_phone_takeout' do
+ let(:import) { create(:import, source: 'google_phone_takeout') }
+
+ it 'calls the GoogleMaps::PhoneTakeoutParser' do
+ expect(GoogleMaps::PhoneTakeoutParser).to receive(:new).with(import, user.id).and_return(double(call: true))
+ service.call
+ end
+ end
+
+ context 'when source is owntracks' do
+ let(:import) { create(:import, source: 'owntracks') }
+
+ it 'calls the OwnTracks::ExportParser' do
+ expect(OwnTracks::ExportParser).to receive(:new).with(import, user.id).and_return(double(call: true))
+ service.call
+ end
+
+ context 'when import is successful' do
+ it 'creates a finished notification' do
+ service.call
+
+ expect(user.notifications.last.kind).to eq('info')
+ end
+
+ it 'schedules stats creating' do
+ Sidekiq::Testing.inline! do
+ expect { service.call }.to have_enqueued_job(Stats::CalculatingJob)
+ end
+ end
+
+ it 'schedules visit suggesting' do
+ Sidekiq::Testing.inline! do
+ expect { service.call }.to have_enqueued_job(VisitSuggestingJob)
+ end
+ end
+ end
+
+ context 'when import fails' do
+ before do
+ allow(OwnTracks::ExportParser).to receive(:new).with(import, user.id).and_return(double(call: false))
+ end
+
+ it 'creates a failed notification' do
+ service.call
+
+ expect(user.notifications.last.kind).to eq('error')
+ end
+ end
+ end
+
+ context 'when source is gpx' do
+ let(:import) { create(:import, source: 'gpx') }
+
+ it 'calls the Gpx::TrackParser' do
+ expect(Gpx::TrackParser).to receive(:new).with(import, user.id).and_return(double(call: true))
+ service.call
+ end
+ end
+
+ context 'when source is geojson' do
+ let(:import) { create(:import, source: 'geojson') }
+
+ it 'calls the Geojson::ImportParser' do
+ expect(Geojson::ImportParser).to receive(:new).with(import, user.id).and_return(double(call: true))
+ service.call
+ end
+ end
+
+ context 'when source is immich_api' do
+ let(:import) { create(:import, source: 'immich_api') }
+
+ it 'calls the Photos::ImportParser' do
+ expect(Photos::ImportParser).to receive(:new).with(import, user.id).and_return(double(call: true))
+ service.call
+ end
+ end
+
+ context 'when source is photoprism_api' do
+ let(:import) { create(:import, source: 'photoprism_api') }
+
+ it 'calls the Photos::ImportParser' do
+ expect(Photos::ImportParser).to receive(:new).with(import, user.id).and_return(double(call: true))
+ service.call
+ end
+ end
+ end
+end
From 4c9357989067e6f29e3d34628cca4203f3fcf8bc Mon Sep 17 00:00:00 2001
From: Eugene Burmakin
Date: Wed, 4 Dec 2024 13:40:20 +0100
Subject: [PATCH 25/51] Add specs for Photoprism::CachePreviewToken and
Photoprism::ImportGeodata
---
.../photoprism/cache_preview_token_spec.rb | 19 ++
.../photoprism/import_geodata_spec.rb | 177 ++++++++++++++++++
2 files changed, 196 insertions(+)
create mode 100644 spec/services/photoprism/cache_preview_token_spec.rb
create mode 100644 spec/services/photoprism/import_geodata_spec.rb
diff --git a/spec/services/photoprism/cache_preview_token_spec.rb b/spec/services/photoprism/cache_preview_token_spec.rb
new file mode 100644
index 00000000..298aee98
--- /dev/null
+++ b/spec/services/photoprism/cache_preview_token_spec.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe Photoprism::CachePreviewToken, type: :service do
+ let(:user) { double('User', id: 1) }
+ let(:preview_token) { 'sample_token' }
+ let(:service) { described_class.new(user, preview_token) }
+
+ describe '#call' do
+ it 'writes the preview token to the cache with the correct key' do
+ expect(Rails.cache).to receive(:write).with(
+ "dawarich/photoprism_preview_token_#{user.id}", preview_token
+ )
+
+ service.call
+ end
+ end
+end
diff --git a/spec/services/photoprism/import_geodata_spec.rb b/spec/services/photoprism/import_geodata_spec.rb
new file mode 100644
index 00000000..341348fc
--- /dev/null
+++ b/spec/services/photoprism/import_geodata_spec.rb
@@ -0,0 +1,177 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe Photoprism::ImportGeodata do
+ describe '#call' do
+ subject(:service) { described_class.new(user).call }
+
+ let(:user) do
+ create(:user, settings: { 'photoprism_url' => 'http://photoprism.app', 'photoprism_api_key' => '123456' })
+ end
+ let(:photoprism_data) do
+ [
+ {
+ 'ID' => '82',
+ 'UID' => 'psnveqq089xhy1c3',
+ 'Type' => 'image',
+ 'TypeSrc' => '',
+ 'TakenAt' => '2024-08-18T14:11:05Z',
+ 'TakenAtLocal' => '2024-08-18T16:11:05Z',
+ 'TakenSrc' => 'meta',
+ 'TimeZone' => 'Europe/Prague',
+ 'Path' => '2024/08',
+ 'Name' => '20240818_141105_44E61AED',
+ 'OriginalName' => 'PXL_20240818_141105789',
+ 'Title' => 'Moment / Karlovy Vary / 2024',
+ 'Description' => '',
+ 'Year' => 2024,
+ 'Month' => 8,
+ 'Day' => 18,
+ 'Country' => 'cz',
+ 'Stack' => 0,
+ 'Favorite' => false,
+ 'Private' => false,
+ 'Iso' => 37,
+ 'FocalLength' => 21,
+ 'FNumber' => 2.2,
+ 'Exposure' => '1/347',
+ 'Quality' => 4,
+ 'Resolution' => 10,
+ 'Color' => 2,
+ 'Scan' => false,
+ 'Panorama' => false,
+ 'CameraID' => 8,
+ 'CameraSrc' => 'meta',
+ 'CameraMake' => 'Google',
+ 'CameraModel' => 'Pixel 7 Pro',
+ 'LensID' => 11,
+ 'LensMake' => 'Google',
+ 'LensModel' => 'Pixel 7 Pro front camera 2.74mm f/2.2',
+ 'Altitude' => 423,
+ 'Lat' => 50.11,
+ 'Lng' => 12.12,
+ 'CellID' => 's2:47a09944f33c',
+ 'PlaceID' => 'cz:ciNqTjWuq6NN',
+ 'PlaceSrc' => 'meta',
+ 'PlaceLabel' => 'Karlovy Vary, Severozápad, Czech Republic',
+ 'PlaceCity' => 'Karlovy Vary',
+ 'PlaceState' => 'Severozápad',
+ 'PlaceCountry' => 'cz',
+ 'InstanceID' => '',
+ 'FileUID' => 'fsnveqqeusn692qo',
+ 'FileRoot' => '/',
+ 'FileName' => '2024/08/20240818_141105_44E61AED.jpg',
+ 'Hash' => 'cc5d0f544e52b288d7c8460d2e1bb17fa66e6089',
+ 'Width' => 2736,
+ 'Height' => 3648,
+ 'Portrait' => true,
+ 'Merged' => false,
+ 'CreatedAt' => '2024-12-02T14:25:38Z',
+ 'UpdatedAt' => '2024-12-02T14:25:38Z',
+ 'EditedAt' => '0001-01-01T00:00:00Z',
+ 'CheckedAt' => '2024-12-02T14:36:45Z',
+ 'Files' => nil
+ },
+ {
+ 'ID' => '81',
+ 'UID' => 'psnveqpl96gcfdzf',
+ 'Type' => 'image',
+ 'TypeSrc' => '',
+ 'TakenAt' => '2024-08-18T14:11:04Z',
+ 'TakenAtLocal' => '2024-08-18T16:11:04Z',
+ 'TakenSrc' => 'meta',
+ 'TimeZone' => 'Europe/Prague',
+ 'Path' => '2024/08',
+ 'Name' => '20240818_141104_E9949CD4',
+ 'OriginalName' => 'PXL_20240818_141104633',
+ 'Title' => 'Portrait / Karlovy Vary / 2024',
+ 'Description' => '',
+ 'Year' => 2024,
+ 'Month' => 8,
+ 'Day' => 18,
+ 'Country' => 'cz',
+ 'Stack' => 0,
+ 'Favorite' => false,
+ 'Private' => false,
+ 'Iso' => 43,
+ 'FocalLength' => 21,
+ 'FNumber' => 2.2,
+ 'Exposure' => '1/356',
+ 'Faces' => 1,
+ 'Quality' => 4,
+ 'Resolution' => 10,
+ 'Color' => 2,
+ 'Scan' => false,
+ 'Panorama' => false,
+ 'CameraID' => 8,
+ 'CameraSrc' => 'meta',
+ 'CameraMake' => 'Google',
+ 'CameraModel' => 'Pixel 7 Pro',
+ 'LensID' => 11,
+ 'LensMake' => 'Google',
+ 'LensModel' => 'Pixel 7 Pro front camera 2.74mm f/2.2',
+ 'Altitude' => 423,
+ 'Lat' => 50.21,
+ 'Lng' => 12.85,
+ 'CellID' => 's2:47a09944f33c',
+ 'PlaceID' => 'cz:ciNqTjWuq6NN',
+ 'PlaceSrc' => 'meta',
+ 'PlaceLabel' => 'Karlovy Vary, Severozápad, Czech Republic',
+ 'PlaceCity' => 'Karlovy Vary',
+ 'PlaceState' => 'Severozápad',
+ 'PlaceCountry' => 'cz',
+ 'InstanceID' => '',
+ 'FileUID' => 'fsnveqp9xsl7onsv',
+ 'FileRoot' => '/',
+ 'FileName' => '2024/08/20240818_141104_E9949CD4.jpg',
+ 'Hash' => 'd5dfadc56a0b63051dfe0b5dec55ff1d81f033b7',
+ 'Width' => 2736,
+ 'Height' => 3648,
+ 'Portrait' => true,
+ 'Merged' => false,
+ 'CreatedAt' => '2024-12-02T14:25:37Z',
+ 'UpdatedAt' => '2024-12-02T14:25:37Z',
+ 'EditedAt' => '0001-01-01T00:00:00Z',
+ 'CheckedAt' => '2024-12-02T14:36:45Z',
+ 'Files' => nil
+ }
+ ].to_json
+ end
+
+ before do
+ stub_request(:get, %r{http://photoprism\.app/api/v1/photos}).with(
+ headers: {
+ 'Accept' => 'application/json',
+ 'Accept-Encoding' => 'gzip;q=1.0,deflate;q=0.6,identity;q=0.3',
+ 'Authorization' => 'Bearer 123456',
+ 'User-Agent' => 'Ruby'
+ }
+ ).to_return(status: 200, body: photoprism_data, headers: {})
+ end
+
+ it 'creates import' do
+ expect { service }.to change { Import.count }.by(1)
+ end
+
+ it 'enqueues ImportJob' do
+ expect(ImportJob).to receive(:perform_later)
+
+ service
+ end
+
+ context 'when import already exists' do
+ before { service }
+
+ it 'does not create new import' do
+ expect { service }.not_to(change { Import.count })
+ end
+
+ it 'does not enqueue ImportJob' do
+ expect(ImportJob).to_not receive(:perform_later)
+
+ service
+ end
+ end
+ end
+end
From 1030bd5c37f9e9ef977cc8aa514ad26f182c9e84 Mon Sep 17 00:00:00 2001
From: Eugene Burmakin
Date: Wed, 4 Dec 2024 13:45:19 +0100
Subject: [PATCH 26/51] Rename Photos::Request to Photos::Search and add test
for it
---
app/controllers/api/v1/photos_controller.rb | 2 +-
app/services/photos/{request.rb => search.rb} | 2 +-
spec/requests/api/v1/photos_spec.rb | 2 +-
spec/services/photos/search_spec.rb | 147 ++++++++++++++++++
4 files changed, 150 insertions(+), 3 deletions(-)
rename app/services/photos/{request.rb => search.rb} (97%)
create mode 100644 spec/services/photos/search_spec.rb
diff --git a/app/controllers/api/v1/photos_controller.rb b/app/controllers/api/v1/photos_controller.rb
index b2930888..5eee82c0 100644
--- a/app/controllers/api/v1/photos_controller.rb
+++ b/app/controllers/api/v1/photos_controller.rb
@@ -6,7 +6,7 @@ class Api::V1::PhotosController < ApiController
def index
@photos = Rails.cache.fetch("photos_#{params[:start_date]}_#{params[:end_date]}", expires_in: 1.day) do
- Photos::Request.new(current_api_user, start_date: params[:start_date], end_date: params[:end_date]).call
+ Photos::Search.new(current_api_user, start_date: params[:start_date], end_date: params[:end_date]).call
end
render json: @photos, status: :ok
diff --git a/app/services/photos/request.rb b/app/services/photos/search.rb
similarity index 97%
rename from app/services/photos/request.rb
rename to app/services/photos/search.rb
index 3bb5d059..20046268 100644
--- a/app/services/photos/request.rb
+++ b/app/services/photos/search.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class Photos::Request
+class Photos::Search
attr_reader :user, :start_date, :end_date
def initialize(user, start_date: '1970-01-01', end_date: nil)
diff --git a/spec/requests/api/v1/photos_spec.rb b/spec/requests/api/v1/photos_spec.rb
index c1e440bc..8c8811b6 100644
--- a/spec/requests/api/v1/photos_spec.rb
+++ b/spec/requests/api/v1/photos_spec.rb
@@ -38,7 +38,7 @@ RSpec.describe 'Api::V1::Photos', type: :request do
context 'when the request is successful' do
before do
- allow_any_instance_of(Photos::Request).to receive(:call).and_return(photo_data)
+ allow_any_instance_of(Photos::Search).to receive(:call).and_return(photo_data)
get '/api/v1/photos', params: { api_key: user.api_key }
end
diff --git a/spec/services/photos/search_spec.rb b/spec/services/photos/search_spec.rb
new file mode 100644
index 00000000..0ce34613
--- /dev/null
+++ b/spec/services/photos/search_spec.rb
@@ -0,0 +1,147 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe Photos::Search do
+ let(:user) { create(:user) }
+ let(:start_date) { '2024-01-01' }
+ let(:end_date) { '2024-03-01' }
+ let(:service) { described_class.new(user, start_date: start_date, end_date: end_date) }
+
+ describe '#call' do
+ context 'when user has no integrations configured' do
+ before do
+ allow(user).to receive(:immich_integration_configured?).and_return(false)
+ allow(user).to receive(:photoprism_integration_configured?).and_return(false)
+ end
+
+ it 'returns an empty array' do
+ expect(service.call).to eq([])
+ end
+ end
+
+ context 'when user has Immich integration configured' do
+ let(:immich_photo) { { 'type' => 'image', 'id' => '1' } }
+ let(:serialized_photo) { { id: '1', source: 'immich' } }
+
+ before do
+ allow(user).to receive(:immich_integration_configured?).and_return(true)
+ allow(user).to receive(:photoprism_integration_configured?).and_return(false)
+
+ allow_any_instance_of(Immich::RequestPhotos).to receive(:call)
+ .and_return([immich_photo])
+
+ allow_any_instance_of(Api::PhotoSerializer).to receive(:call)
+ .and_return(serialized_photo)
+ end
+
+ it 'fetches and transforms Immich photos' do
+ expect(service.call).to eq([serialized_photo])
+ end
+ end
+
+ context 'when user has Photoprism integration configured' do
+ let(:photoprism_photo) { { 'Type' => 'image', 'id' => '2' } }
+ let(:serialized_photo) { { id: '2', source: 'photoprism' } }
+
+ before do
+ allow(user).to receive(:immich_integration_configured?).and_return(false)
+ allow(user).to receive(:photoprism_integration_configured?).and_return(true)
+
+ allow_any_instance_of(Photoprism::RequestPhotos).to receive(:call)
+ .and_return([photoprism_photo])
+
+ allow_any_instance_of(Api::PhotoSerializer).to receive(:call)
+ .and_return(serialized_photo)
+ end
+
+ it 'fetches and transforms Photoprism photos' do
+ expect(service.call).to eq([serialized_photo])
+ end
+ end
+
+ context 'when user has both integrations configured' do
+ let(:immich_photo) { { 'type' => 'image', 'id' => '1' } }
+ let(:photoprism_photo) { { 'Type' => 'image', 'id' => '2' } }
+ let(:serialized_immich) do
+ {
+ id: '1',
+ latitude: nil,
+ longitude: nil,
+ localDateTime: nil,
+ originalFileName: nil,
+ city: nil,
+ state: nil,
+ country: nil,
+ type: 'image',
+ source: 'immich'
+ }
+ end
+ let(:serialized_photoprism) do
+ {
+ id: '2',
+ latitude: nil,
+ longitude: nil,
+ localDateTime: nil,
+ originalFileName: nil,
+ city: nil,
+ state: nil,
+ country: nil,
+ type: 'image',
+ source: 'photoprism'
+ }
+ end
+
+ before do
+ allow(user).to receive(:immich_integration_configured?).and_return(true)
+ allow(user).to receive(:photoprism_integration_configured?).and_return(true)
+
+ allow_any_instance_of(Immich::RequestPhotos).to receive(:call)
+ .and_return([immich_photo])
+ allow_any_instance_of(Photoprism::RequestPhotos).to receive(:call)
+ .and_return([photoprism_photo])
+ end
+
+ it 'fetches and transforms photos from both services' do
+ expect(service.call).to eq([serialized_immich, serialized_photoprism])
+ end
+ end
+
+ context 'when filtering out videos' do
+ let(:immich_photo) { { 'type' => 'video', 'id' => '1' } }
+
+ before do
+ allow(user).to receive(:immich_integration_configured?).and_return(true)
+ allow(user).to receive(:photoprism_integration_configured?).and_return(false)
+
+ allow_any_instance_of(Immich::RequestPhotos).to receive(:call)
+ .and_return([immich_photo])
+ end
+
+ it 'excludes video assets' do
+ expect(service.call).to eq([])
+ end
+ end
+ end
+
+ describe '#initialize' do
+ context 'with default parameters' do
+ let(:service_default) { described_class.new(user) }
+
+ it 'sets default start_date' do
+ expect(service_default.start_date).to eq('1970-01-01')
+ end
+
+ it 'sets default end_date to nil' do
+ expect(service_default.end_date).to be_nil
+ end
+ end
+
+ context 'with custom parameters' do
+ it 'sets custom dates' do
+ expect(service.start_date).to eq(start_date)
+ expect(service.end_date).to eq(end_date)
+ end
+ end
+ end
+end
From d2bffdf1f13e30c7e14f5e354666c6048a83ec13 Mon Sep 17 00:00:00 2001
From: Eugene Burmakin
Date: Wed, 4 Dec 2024 13:50:41 +0100
Subject: [PATCH 27/51] Add spec for Photos::Thumbnail
---
app/services/photos/thumbnail.rb | 13 ++---
spec/services/photos/thumbnail_spec.rb | 77 ++++++++++++++++++++++++++
2 files changed, 82 insertions(+), 8 deletions(-)
create mode 100644 spec/services/photos/thumbnail_spec.rb
diff --git a/app/services/photos/thumbnail.rb b/app/services/photos/thumbnail.rb
index be3063a1..6bdb7fd5 100644
--- a/app/services/photos/thumbnail.rb
+++ b/app/services/photos/thumbnail.rb
@@ -8,7 +8,7 @@ class Photos::Thumbnail
end
def call
- fetch_thumbnail_from_source
+ HTTParty.get(request_url, headers: headers)
end
private
@@ -35,6 +35,10 @@ class Photos::Thumbnail
end
end
+ def request_url
+ "#{source_url}#{source_path}"
+ end
+
def headers
request_headers = {
'accept' => 'application/octet-stream'
@@ -44,11 +48,4 @@ class Photos::Thumbnail
request_headers
end
-
- def fetch_thumbnail_from_source
- url = "#{source_url}#{source_path}"
- a = HTTParty.get(url, headers: headers)
- pp url
- a
- end
end
diff --git a/spec/services/photos/thumbnail_spec.rb b/spec/services/photos/thumbnail_spec.rb
new file mode 100644
index 00000000..c687e370
--- /dev/null
+++ b/spec/services/photos/thumbnail_spec.rb
@@ -0,0 +1,77 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe Photos::Thumbnail do
+ let(:user) { create(:user) }
+ let(:id) { 'photo123' }
+
+ describe '#call' do
+ subject { described_class.new(user, source, id).call }
+
+ context 'with immich source' do
+ let(:source) { 'immich' }
+ let(:api_key) { 'immich_key_123' }
+ let(:base_url) { 'https://photos.example.com' }
+ let(:expected_url) { "#{base_url}/api/assets/#{id}/thumbnail?size=preview" }
+ let(:expected_headers) do
+ {
+ 'accept' => 'application/octet-stream',
+ 'X-Api-Key' => api_key
+ }
+ end
+
+ before do
+ allow(user).to receive(:settings).and_return(
+ 'immich_url' => base_url,
+ 'immich_api_key' => api_key
+ )
+ end
+
+ it 'fetches thumbnail with correct parameters' do
+ expect(HTTParty).to receive(:get)
+ .with(expected_url, headers: expected_headers)
+ .and_return('thumbnail_data')
+
+ expect(subject).to eq('thumbnail_data')
+ end
+ end
+
+ context 'with photoprism source' do
+ let(:source) { 'photoprism' }
+ let(:base_url) { 'https://photoprism.example.com' }
+ let(:preview_token) { 'preview_token_123' }
+ let(:expected_url) { "#{base_url}/api/v1/t/#{id}/#{preview_token}/tile_500" }
+ let(:expected_headers) do
+ {
+ 'accept' => 'application/octet-stream'
+ }
+ end
+
+ before do
+ allow(user).to receive(:settings).and_return(
+ 'photoprism_url' => base_url
+ )
+ allow(Rails.cache).to receive(:read)
+ .with("#{Photoprism::CachePreviewToken::TOKEN_CACHE_KEY}_#{user.id}")
+ .and_return(preview_token)
+ end
+
+ it 'fetches thumbnail with correct parameters' do
+ expect(HTTParty).to receive(:get)
+ .with(expected_url, headers: expected_headers)
+ .and_return('thumbnail_data')
+
+ expect(subject).to eq('thumbnail_data')
+ end
+ end
+
+ context 'with unsupported source' do
+ let(:source) { 'unsupported' }
+
+ it 'raises an error' do
+ expect { subject }.to raise_error(RuntimeError, 'Unsupported source: unsupported')
+ end
+ end
+ end
+end
From cabce29ee2e46948933258529f2b36666149efb0 Mon Sep 17 00:00:00 2001
From: Eugene Burmakin
Date: Wed, 4 Dec 2024 13:59:49 +0100
Subject: [PATCH 28/51] Update changelog
---
CHANGELOG.md | 25 ++++++++++++-----------
app/services/photoprism/request_photos.rb | 4 ++++
2 files changed, 17 insertions(+), 12 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c0bfa4f9..c58f2615 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,21 +9,22 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
## The Photoprism integration release
-⚠️ This release introduces a breaking change. The `GET /api/v1/photos` endpoint now returns following structure of the response:
+⚠️ This release introduces a breaking change. ⚠️
+The `GET /api/v1/photos` endpoint now returns following structure of the response:
```json
[
{
- "id": id,
- "latitude": latitude,
- "longitude": longitude,
- "localDateTime": local_date_time,
- "originalFileName": original_file_name,
- "city": city,
- "state": state,
- "country": country,
- "type": type, // "image" or "video"
- "source": source // "photoprism" or "immich"
+ "id": "1",
+ "latitude": "11.22",
+ "longitude": "12.33",
+ "localDateTime": "2024-01-01T00:00:00Z",
+ "originalFileName": "photo.jpg",
+ "city": "Berlin",
+ "state": "Berlin",
+ "country": "Germany",
+ "type": "image", // "image" or "video"
+ "source": "photoprism" // "photoprism" or "immich"
}
]
```
@@ -31,7 +32,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
### Added
- Photos from Photoprism are now can be shown on the map. To enable this feature, you need to provide your Photoprism instance URL and API key in the Settings page. Then you need to enable "Photos" layer on the map (top right corner).
-- Geodata is now can be imported from Photoprism to Dawarich.
+- Geodata is now can be imported from Photoprism to Dawarich. The "Import Photoprism data" button on the Imports page will start the import process.
# 0.18.2 - 2024-11-29
diff --git a/app/services/photoprism/request_photos.rb b/app/services/photoprism/request_photos.rb
index a7fb000d..276e7e5c 100644
--- a/app/services/photoprism/request_photos.rb
+++ b/app/services/photoprism/request_photos.rb
@@ -1,5 +1,9 @@
# frozen_string_literal: true
+# This integration built based on
+# [September 15, 2024](https://github.com/photoprism/photoprism/releases/tag/240915-e1280b2fb)
+# release of Photoprism.
+
class Photoprism::RequestPhotos
attr_reader :user, :photoprism_api_base_url, :photoprism_api_key, :start_date, :end_date
From 49fc333f034265b195e3367cda281febaceb64e4 Mon Sep 17 00:00:00 2001
From: Eugene Burmakin
Date: Wed, 4 Dec 2024 14:01:26 +0100
Subject: [PATCH 29/51] Fix changelog
---
CHANGELOG.md | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c58f2615..23150af8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -16,8 +16,8 @@ The `GET /api/v1/photos` endpoint now returns following structure of the respons
[
{
"id": "1",
- "latitude": "11.22",
- "longitude": "12.33",
+ "latitude": 11.22,
+ "longitude": 12.33,
"localDateTime": "2024-01-01T00:00:00Z",
"originalFileName": "photo.jpg",
"city": "Berlin",
From 2b77a58dbfa0a6748d30aef09f7c43bfd5405b04 Mon Sep 17 00:00:00 2001
From: Eugene Burmakin
Date: Wed, 4 Dec 2024 14:08:08 +0100
Subject: [PATCH 30/51] Update changelog
---
CHANGELOG.md | 5 +++++
1 file changed, 5 insertions(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 23150af8..c6e923d2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -34,6 +34,11 @@ The `GET /api/v1/photos` endpoint now returns following structure of the respons
- Photos from Photoprism are now can be shown on the map. To enable this feature, you need to provide your Photoprism instance URL and API key in the Settings page. Then you need to enable "Photos" layer on the map (top right corner).
- Geodata is now can be imported from Photoprism to Dawarich. The "Import Photoprism data" button on the Imports page will start the import process.
+### Fixed
+
+- z-index on maps so they won't overlay notifications dropdown
+- Redis connectivity where it's not required
+
# 0.18.2 - 2024-11-29
### Added
From 0236e81f74f15b10ced1f6c9b9d2bed42954c538 Mon Sep 17 00:00:00 2001
From: Eugene Burmakin
Date: Wed, 4 Dec 2024 15:00:28 +0100
Subject: [PATCH 31/51] Fix Prometheus exporter for Sidekiq
---
CHANGELOG.md | 8 +++++++-
config/initializers/prometheus.rb | 2 +-
config/initializers/sidekiq.rb | 8 ++++----
3 files changed, 12 insertions(+), 6 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c6e923d2..b4cb6251 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,7 +5,13 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/)
and this project adheres to [Semantic Versioning](http://semver.org/).
-# 0.19.0 - 2024-12-03
+# 0.19.1 - 2024-12-04
+
+### Fixed
+
+- Sidekiq is now being correctly exported to Prometheus with `PROMETHEUS_EXPORTER_ENABLED=true` env var in `dawarich_sidekiq` service.
+
+# 0.19.0 - 2024-12-04
## The Photoprism integration release
diff --git a/config/initializers/prometheus.rb b/config/initializers/prometheus.rb
index 0a3e1b37..3573fb84 100644
--- a/config/initializers/prometheus.rb
+++ b/config/initializers/prometheus.rb
@@ -7,7 +7,7 @@ if !Rails.env.test? && ENV['PROMETHEUS_EXPORTER_ENABLED'].to_s == 'true'
# This reports stats per request like HTTP status and timings
Rails.application.middleware.unshift PrometheusExporter::Middleware
- # this reports basic process stats like RSS and GC info
+ # This reports basic process stats like RSS and GC info
PrometheusExporter::Instrumentation::Process.start(type: 'web')
# Add ActiveRecord instrumentation
diff --git a/config/initializers/sidekiq.rb b/config/initializers/sidekiq.rb
index 4ae6cc8d..9e54f2ab 100644
--- a/config/initializers/sidekiq.rb
+++ b/config/initializers/sidekiq.rb
@@ -6,14 +6,16 @@ Sidekiq.configure_server do |config|
if ENV['PROMETHEUS_EXPORTER_ENABLED'].to_s == 'true'
require 'prometheus_exporter/instrumentation'
+ # Add middleware for collecting job-level metrics
config.server_middleware do |chain|
chain.add PrometheusExporter::Instrumentation::Sidekiq
end
+ # Capture metrics for failed jobs
config.death_handlers << PrometheusExporter::Instrumentation::Sidekiq.death_handler
+ # Start Prometheus instrumentation
config.on :startup do
- PrometheusExporter::Instrumentation::Process.start type: 'sidekiq'
PrometheusExporter::Instrumentation::SidekiqProcess.start
PrometheusExporter::Instrumentation::SidekiqQueue.start
PrometheusExporter::Instrumentation::SidekiqStats.start
@@ -25,6 +27,4 @@ Sidekiq.configure_client do |config|
config.redis = { url: ENV['REDIS_URL'] }
end
-if (defined?(Rails::Server) || Sidekiq.server?) && PHOTON_API_HOST == 'photon.komoot.io'
- Sidekiq::Queue['reverse_geocoding'].limit = 1
-end
+Sidekiq::Queue['reverse_geocoding'].limit = 1 if Sidekiq.server? && PHOTON_API_HOST == 'photon.komoot.io'
From 95706bc5b5dee29187bfe588ed50dc9440c85f64 Mon Sep 17 00:00:00 2001
From: Eugene Burmakin
Date: Thu, 5 Dec 2024 11:10:00 +0100
Subject: [PATCH 32/51] Update app version to 0.19.1
---
.app_version | 2 +-
config/initializers/02_version_cache.rb | 3 +++
2 files changed, 4 insertions(+), 1 deletion(-)
create mode 100644 config/initializers/02_version_cache.rb
diff --git a/.app_version b/.app_version
index 1cf0537c..41915c79 100644
--- a/.app_version
+++ b/.app_version
@@ -1 +1 @@
-0.19.0
+0.19.1
diff --git a/config/initializers/02_version_cache.rb b/config/initializers/02_version_cache.rb
new file mode 100644
index 00000000..c6fed3b3
--- /dev/null
+++ b/config/initializers/02_version_cache.rb
@@ -0,0 +1,3 @@
+# frozen_string_literal: true
+
+Rails.cache.delete('dawarich/app-version-check')
From f10f78999dc001d10145bcb8c2f9f3685ed7d541 Mon Sep 17 00:00:00 2001
From: Eugene Burmakin
Date: Thu, 5 Dec 2024 17:12:35 +0100
Subject: [PATCH 33/51] Add basic telemetry
---
app/jobs/telemetry_sending_job.rb | 11 +++++
app/services/telemetry/gather.rb | 32 +++++++++++++
app/services/telemetry/send.rb | 44 ++++++++++++++++++
config/initializers/01_constants.rb | 2 +
...5_add_devise_trackable_columns_to_users.rb | 13 ++++++
db/schema.rb | 9 +++-
spec/jobs/telemetry_sending_job_spec.rb | 5 +++
spec/services/telemetry/gather_spec.rb | 45 +++++++++++++++++++
8 files changed, 160 insertions(+), 1 deletion(-)
create mode 100644 app/jobs/telemetry_sending_job.rb
create mode 100644 app/services/telemetry/gather.rb
create mode 100644 app/services/telemetry/send.rb
create mode 100644 db/migrate/20241205160055_add_devise_trackable_columns_to_users.rb
create mode 100644 spec/jobs/telemetry_sending_job_spec.rb
create mode 100644 spec/services/telemetry/gather_spec.rb
diff --git a/app/jobs/telemetry_sending_job.rb b/app/jobs/telemetry_sending_job.rb
new file mode 100644
index 00000000..fe9b74dd
--- /dev/null
+++ b/app/jobs/telemetry_sending_job.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+class TelemetrySendingJob < ApplicationJob
+ queue_as :default
+
+ def perform
+ data = Telemetry::Gather.new.call
+
+ Telemetry::Send.new(data).call
+ end
+end
diff --git a/app/services/telemetry/gather.rb b/app/services/telemetry/gather.rb
new file mode 100644
index 00000000..90b7ee01
--- /dev/null
+++ b/app/services/telemetry/gather.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+class Telemetry::Gather
+ def initialize(measurement: 'dawarich_usage_metrics')
+ @measurement = measurement
+ end
+
+ def call
+ {
+ measurement:,
+ timestamp: Time.current.to_i,
+ tags: { instance_id: },
+ fields: { dau:, app_version: }
+ }
+ end
+
+ private
+
+ attr_reader :measurement
+
+ def instance_id
+ @instance_id ||= Digest::SHA2.hexdigest(User.first.api_key)
+ end
+
+ def app_version
+ "\"#{APP_VERSION}\""
+ end
+
+ def dau
+ User.where(last_sign_in_at: Time.zone.today.beginning_of_day..Time.zone.today.end_of_day).count
+ end
+end
diff --git a/app/services/telemetry/send.rb b/app/services/telemetry/send.rb
new file mode 100644
index 00000000..c3cce833
--- /dev/null
+++ b/app/services/telemetry/send.rb
@@ -0,0 +1,44 @@
+# frozen_string_literal: true
+
+class Telemetry::Send
+ BUCKET = 'dawarich_metrics'
+ ORG = 'monitoring'
+
+ def initialize(payload)
+ @payload = payload
+ end
+
+ def call
+ line_protocol = build_line_protocol
+ response = send_request(line_protocol)
+ handle_response(response)
+ end
+
+ private
+
+ attr_reader :payload
+
+ def build_line_protocol
+ tag_string = payload[:tags].map { |k, v| "#{k}=#{v}" }.join(',')
+ field_string = payload[:fields].map { |k, v| "#{k}=#{v}" }.join(',')
+
+ "#{payload[:measurement]},#{tag_string} #{field_string} #{payload[:timestamp].to_i}"
+ end
+
+ def send_request(line_protocol)
+ HTTParty.post(
+ "#{TELEMETRY_URL}?org=#{ORG}&bucket=#{BUCKET}&precision=s",
+ body: line_protocol,
+ headers: {
+ 'Authorization' => "Token #{Base64.decode64(TELEMETRY_STRING)}",
+ 'Content-Type' => 'text/plain'
+ }
+ )
+ end
+
+ def handle_response(response)
+ Rails.logger.error("InfluxDB write failed: #{response.body}") unless response.success?
+
+ response
+ end
+end
diff --git a/config/initializers/01_constants.rb b/config/initializers/01_constants.rb
index 5065345f..d8cc2d81 100644
--- a/config/initializers/01_constants.rb
+++ b/config/initializers/01_constants.rb
@@ -6,3 +6,5 @@ PHOTON_API_HOST = ENV.fetch('PHOTON_API_HOST', nil)
PHOTON_API_USE_HTTPS = ENV.fetch('PHOTON_API_USE_HTTPS', 'true') == 'true'
DISTANCE_UNIT = ENV.fetch('DISTANCE_UNIT', 'km').to_sym
APP_VERSION = File.read('.app_version').strip
+TELEMETRY_STRING = Base64.encode64('IjVFvb8j3P9-ArqhSGav9j8YcJaQiuNIzkfOPKQDk2lvKXqb8t1NSRv50oBkaKtlrB_ZRzO9NdurpMtncV_HYQ==')
+TELEMETRY_URL = 'https://influxdb2.frey.today/api/v2/write'
diff --git a/db/migrate/20241205160055_add_devise_trackable_columns_to_users.rb b/db/migrate/20241205160055_add_devise_trackable_columns_to_users.rb
new file mode 100644
index 00000000..80cccf4a
--- /dev/null
+++ b/db/migrate/20241205160055_add_devise_trackable_columns_to_users.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+class AddDeviseTrackableColumnsToUsers < ActiveRecord::Migration[7.2]
+ def change
+ change_table :users, bulk: true do |t|
+ t.integer :sign_in_count, default: 0, null: false
+ t.datetime :current_sign_in_at
+ t.datetime :last_sign_in_at
+ t.string :current_sign_in_ip
+ t.string :last_sign_in_ip
+ end
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 3e1a538f..2927e2d5 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[7.2].define(version: 2024_11_28_095325) do
+ActiveRecord::Schema[7.2].define(version: 2024_12_05_160055) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
@@ -155,6 +155,7 @@ ActiveRecord::Schema[7.2].define(version: 2024_11_28_095325) do
t.bigint "user_id"
t.jsonb "geodata", default: {}, null: false
t.bigint "visit_id"
+ t.datetime "reverse_geocoded_at"
t.index ["altitude"], name: "index_points_on_altitude"
t.index ["battery"], name: "index_points_on_battery"
t.index ["battery_status"], name: "index_points_on_battery_status"
@@ -164,6 +165,7 @@ ActiveRecord::Schema[7.2].define(version: 2024_11_28_095325) do
t.index ["geodata"], name: "index_points_on_geodata", using: :gin
t.index ["import_id"], name: "index_points_on_import_id"
t.index ["latitude", "longitude"], name: "index_points_on_latitude_and_longitude"
+ t.index ["reverse_geocoded_at"], name: "index_points_on_reverse_geocoded_at"
t.index ["timestamp"], name: "index_points_on_timestamp"
t.index ["trigger"], name: "index_points_on_trigger"
t.index ["user_id"], name: "index_points_on_user_id"
@@ -208,6 +210,11 @@ ActiveRecord::Schema[7.2].define(version: 2024_11_28_095325) do
t.string "theme", default: "dark", null: false
t.jsonb "settings", default: {"fog_of_war_meters"=>"100", "meters_between_routes"=>"1000", "minutes_between_routes"=>"60"}
t.boolean "admin", default: false
+ t.integer "sign_in_count", default: 0, null: false
+ t.datetime "current_sign_in_at"
+ t.datetime "last_sign_in_at"
+ t.string "current_sign_in_ip"
+ t.string "last_sign_in_ip"
t.index ["email"], name: "index_users_on_email", unique: true
t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true
end
diff --git a/spec/jobs/telemetry_sending_job_spec.rb b/spec/jobs/telemetry_sending_job_spec.rb
new file mode 100644
index 00000000..2e227710
--- /dev/null
+++ b/spec/jobs/telemetry_sending_job_spec.rb
@@ -0,0 +1,5 @@
+require 'rails_helper'
+
+RSpec.describe TelemetrySendingJob, type: :job do
+ pending "add some examples to (or delete) #{__FILE__}"
+end
diff --git a/spec/services/telemetry/gather_spec.rb b/spec/services/telemetry/gather_spec.rb
new file mode 100644
index 00000000..9b962113
--- /dev/null
+++ b/spec/services/telemetry/gather_spec.rb
@@ -0,0 +1,45 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe Telemetry::Gather do
+ let!(:user) { create(:user, last_sign_in_at: Time.zone.today) }
+
+ describe '#call' do
+ subject(:gather) { described_class.new.call }
+
+ it 'returns a hash with measurement, timestamp, tags, and fields' do
+ expect(gather).to include(:measurement, :timestamp, :tags, :fields)
+ end
+
+ it 'includes the correct measurement' do
+ expect(gather[:measurement]).to eq('dawarich_usage_metrics')
+ end
+
+ it 'includes the current timestamp' do
+ expect(gather[:timestamp]).to be_within(1).of(Time.current.to_i)
+ end
+
+ it 'includes the correct instance_id in tags' do
+ expect(gather[:tags][:instance_id]).to eq(Digest::SHA2.hexdigest(user.api_key))
+ end
+
+ it 'includes the correct app_version in fields' do
+ expect(gather[:fields][:app_version]).to eq("\"#{APP_VERSION}\"")
+ end
+
+ it 'includes the correct dau in fields' do
+ expect(gather[:fields][:dau]).to eq(1)
+ end
+
+ context 'with a custom measurement' do
+ let(:measurement) { 'custom_measurement' }
+
+ subject(:gather) { described_class.new(measurement:).call }
+
+ it 'includes the correct measurement' do
+ expect(gather[:measurement]).to eq('custom_measurement')
+ end
+ end
+ end
+end
From c8e910343c22e05e09f8773a685731278451fd2a Mon Sep 17 00:00:00 2001
From: Eugene Burmakin
Date: Thu, 5 Dec 2024 17:37:50 +0100
Subject: [PATCH 34/51] Fix test fixtures and add telemetry sending job
---
.app_version | 2 +-
CHANGELOG.md | 22 +++++++++++++++++++
app/jobs/telemetry_sending_job.rb | 2 ++
app/models/user.rb | 4 ++--
config/schedule.yml | 5 +++++
.../files/geojson/export_same_points.json | 2 +-
spec/jobs/telemetry_sending_job_spec.rb | 22 ++++++++++++++++++-
7 files changed, 54 insertions(+), 5 deletions(-)
diff --git a/.app_version b/.app_version
index 41915c79..61e6e92d 100644
--- a/.app_version
+++ b/.app_version
@@ -1 +1 @@
-0.19.1
+0.19.2
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b4cb6251..876b90cb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,28 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/)
and this project adheres to [Semantic Versioning](http://semver.org/).
+# 0.19.2 - 2024-12-04
+
+## The Telemetry release
+
+Dawarich now can collect usage metrics and send them to InfluxDB. Before this release, the only metrics that could be somehow tracked by developers (only @Freika, as of now) were the number of stars on GitHub and the overall number of docker images being pulled, across all versions of Dawarich, non-splittable by version. New in-app telemetry will allow us to track more granular metrics, allowing me to make decisions based on facts, not just guesses.
+
+I'm aware about the privacy concerns, so I want to be very transparent about what data is being sent and how it's used.
+
+Data being sent:
+
+- Number of DAU (Daily Active Users)
+- App version
+- Instance ID
+
+Basically this set of metrics allows me to see how many people are using Dawarich and what versions they are using. No other data is being sent, nor it gives me any knowledge about individual users or their data or activity.
+
+The telemetry is enabled by default, but it **can be disabled** by setting `DISABLE_TELEMETRY` env var to `true`. The dataset might change in the future, but any changes will be documented here in the changelog and in every release as well as on the [telemetry page](https://dawarich.app/docs/tutorials/telemetry) of the website docs.
+
+### Added
+
+- Telemetry feature. It's now collecting usage metrics and sending them to InfluxDB.
+
# 0.19.1 - 2024-12-04
### Fixed
diff --git a/app/jobs/telemetry_sending_job.rb b/app/jobs/telemetry_sending_job.rb
index fe9b74dd..bdbe96d8 100644
--- a/app/jobs/telemetry_sending_job.rb
+++ b/app/jobs/telemetry_sending_job.rb
@@ -4,6 +4,8 @@ class TelemetrySendingJob < ApplicationJob
queue_as :default
def perform
+ return if ENV['DISABLE_TELEMETRY'] == 'true'
+
data = Telemetry::Gather.new.call
Telemetry::Send.new(data).call
diff --git a/app/models/user.rb b/app/models/user.rb
index 53adfa2d..58ce091d 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -2,9 +2,9 @@
class User < ApplicationRecord
# Include default devise modules. Others available are:
- # :confirmable, :lockable, :timeoutable, :trackable and :omniauthable
+ # :confirmable, :lockable, :timeoutable, and :omniauthable
devise :database_authenticatable, :registerable,
- :recoverable, :rememberable, :validatable
+ :recoverable, :rememberable, :validatable, :trackable
has_many :tracked_points, class_name: 'Point', dependent: :destroy
has_many :imports, dependent: :destroy
diff --git a/config/schedule.yml b/config/schedule.yml
index 1b9a4f59..0b99f8c1 100644
--- a/config/schedule.yml
+++ b/config/schedule.yml
@@ -25,3 +25,8 @@ app_version_checking_job:
cron: "0 */6 * * *" # every 6 hours
class: "AppVersionCheckingJob"
queue: default
+
+telemetry_sending_job:
+ cron: "0 */1 * * *" # every 1 hour
+ class: "TelemetrySendingJob"
+ queue: default
diff --git a/spec/fixtures/files/geojson/export_same_points.json b/spec/fixtures/files/geojson/export_same_points.json
index 2ecfb883..45fbe6a2 100644
--- a/spec/fixtures/files/geojson/export_same_points.json
+++ b/spec/fixtures/files/geojson/export_same_points.json
@@ -1 +1 @@
-{"type":"FeatureCollection","features":[{"type":"Feature","geometry":{"type":"Point","coordinates":["37.6173","55.755826"]},"properties":{"battery_status":"unplugged","ping":"MyString","battery":1,"tracker_id":"MyString","topic":"MyString","altitude":1,"longitude":"37.6173","velocity":"0","trigger":"background_event","bssid":"MyString","ssid":"MyString","connection":"wifi","vertical_accuracy":1,"accuracy":1,"timestamp":1609459200,"latitude":"55.755826","mode":1,"inrids":[],"in_regions":[],"city":null,"country":null,"geodata":{}}},{"type":"Feature","geometry":{"type":"Point","coordinates":["37.6173","55.755826"]},"properties":{"battery_status":"unplugged","ping":"MyString","battery":1,"tracker_id":"MyString","topic":"MyString","altitude":1,"longitude":"37.6173","velocity":"0","trigger":"background_event","bssid":"MyString","ssid":"MyString","connection":"wifi","vertical_accuracy":1,"accuracy":1,"timestamp":1609459200,"latitude":"55.755826","mode":1,"inrids":[],"in_regions":[],"city":null,"country":null,"geodata":{}}},{"type":"Feature","geometry":{"type":"Point","coordinates":["37.6173","55.755826"]},"properties":{"battery_status":"unplugged","ping":"MyString","battery":1,"tracker_id":"MyString","topic":"MyString","altitude":1,"longitude":"37.6173","velocity":"0","trigger":"background_event","bssid":"MyString","ssid":"MyString","connection":"wifi","vertical_accuracy":1,"accuracy":1,"timestamp":1609459200,"latitude":"55.755826","mode":1,"inrids":[],"in_regions":[],"city":null,"country":null,"geodata":{}}},{"type":"Feature","geometry":{"type":"Point","coordinates":["37.6173","55.755826"]},"properties":{"battery_status":"unplugged","ping":"MyString","battery":1,"tracker_id":"MyString","topic":"MyString","altitude":1,"longitude":"37.6173","velocity":"0","trigger":"background_event","bssid":"MyString","ssid":"MyString","connection":"wifi","vertical_accuracy":1,"accuracy":1,"timestamp":1609459200,"latitude":"55.755826","mode":1,"inrids":[],"in_regions":[],"city":null,"country":null,"geodata":{}}},{"type":"Feature","geometry":{"type":"Point","coordinates":["37.6173","55.755826"]},"properties":{"battery_status":"unplugged","ping":"MyString","battery":1,"tracker_id":"MyString","topic":"MyString","altitude":1,"longitude":"37.6173","velocity":"0","trigger":"background_event","bssid":"MyString","ssid":"MyString","connection":"wifi","vertical_accuracy":1,"accuracy":1,"timestamp":1609459200,"latitude":"55.755826","mode":1,"inrids":[],"in_regions":[],"city":null,"country":null,"geodata":{}}},{"type":"Feature","geometry":{"type":"Point","coordinates":["37.6173","55.755826"]},"properties":{"battery_status":"unplugged","ping":"MyString","battery":1,"tracker_id":"MyString","topic":"MyString","altitude":1,"longitude":"37.6173","velocity":"0","trigger":"background_event","bssid":"MyString","ssid":"MyString","connection":"wifi","vertical_accuracy":1,"accuracy":1,"timestamp":1609459200,"latitude":"55.755826","mode":1,"inrids":[],"in_regions":[],"city":null,"country":null,"geodata":{}}},{"type":"Feature","geometry":{"type":"Point","coordinates":["37.6173","55.755826"]},"properties":{"battery_status":"unplugged","ping":"MyString","battery":1,"tracker_id":"MyString","topic":"MyString","altitude":1,"longitude":"37.6173","velocity":"0","trigger":"background_event","bssid":"MyString","ssid":"MyString","connection":"wifi","vertical_accuracy":1,"accuracy":1,"timestamp":1609459200,"latitude":"55.755826","mode":1,"inrids":[],"in_regions":[],"city":null,"country":null,"geodata":{}}},{"type":"Feature","geometry":{"type":"Point","coordinates":["37.6173","55.755826"]},"properties":{"battery_status":"unplugged","ping":"MyString","battery":1,"tracker_id":"MyString","topic":"MyString","altitude":1,"longitude":"37.6173","velocity":"0","trigger":"background_event","bssid":"MyString","ssid":"MyString","connection":"wifi","vertical_accuracy":1,"accuracy":1,"timestamp":1609459200,"latitude":"55.755826","mode":1,"inrids":[],"in_regions":[],"city":null,"country":null,"geodata":{}}},{"type":"Feature","geometry":{"type":"Point","coordinates":["37.6173","55.755826"]},"properties":{"battery_status":"unplugged","ping":"MyString","battery":1,"tracker_id":"MyString","topic":"MyString","altitude":1,"longitude":"37.6173","velocity":"0","trigger":"background_event","bssid":"MyString","ssid":"MyString","connection":"wifi","vertical_accuracy":1,"accuracy":1,"timestamp":1609459200,"latitude":"55.755826","mode":1,"inrids":[],"in_regions":[],"city":null,"country":null,"geodata":{}}},{"type":"Feature","geometry":{"type":"Point","coordinates":["37.6173","55.755826"]},"properties":{"battery_status":"unplugged","ping":"MyString","battery":1,"tracker_id":"MyString","topic":"MyString","altitude":1,"longitude":"37.6173","velocity":"0","trigger":"background_event","bssid":"MyString","ssid":"MyString","connection":"wifi","vertical_accuracy":1,"accuracy":1,"timestamp":1609459200,"latitude":"55.755826","mode":1,"inrids":[],"in_regions":[],"city":null,"country":null,"geodata":{}}}]}
+{"type":"FeatureCollection","features":[{"type":"Feature","geometry":{"type":"Point","coordinates":["37.6173","55.755826"]},"properties":{"battery_status":"unplugged","ping":"MyString","battery":1,"tracker_id":"MyString","topic":"MyString","altitude":1,"longitude":"37.6173","velocity":"0","trigger":"background_event","bssid":"MyString","ssid":"MyString","connection":"wifi","vertical_accuracy":1,"accuracy":1,"timestamp":1609459200,"latitude":"55.755826","mode":1,"inrids":[],"in_regions":[],"city":null,"country":null,"geodata":{},"reverse_geocoded_at":null}},{"type":"Feature","geometry":{"type":"Point","coordinates":["37.6173","55.755826"]},"properties":{"battery_status":"unplugged","ping":"MyString","battery":1,"tracker_id":"MyString","topic":"MyString","altitude":1,"longitude":"37.6173","velocity":"0","trigger":"background_event","bssid":"MyString","ssid":"MyString","connection":"wifi","vertical_accuracy":1,"accuracy":1,"timestamp":1609459200,"latitude":"55.755826","mode":1,"inrids":[],"in_regions":[],"city":null,"country":null,"geodata":{},"reverse_geocoded_at":null}},{"type":"Feature","geometry":{"type":"Point","coordinates":["37.6173","55.755826"]},"properties":{"battery_status":"unplugged","ping":"MyString","battery":1,"tracker_id":"MyString","topic":"MyString","altitude":1,"longitude":"37.6173","velocity":"0","trigger":"background_event","bssid":"MyString","ssid":"MyString","connection":"wifi","vertical_accuracy":1,"accuracy":1,"timestamp":1609459200,"latitude":"55.755826","mode":1,"inrids":[],"in_regions":[],"city":null,"country":null,"geodata":{},"reverse_geocoded_at":null}},{"type":"Feature","geometry":{"type":"Point","coordinates":["37.6173","55.755826"]},"properties":{"battery_status":"unplugged","ping":"MyString","battery":1,"tracker_id":"MyString","topic":"MyString","altitude":1,"longitude":"37.6173","velocity":"0","trigger":"background_event","bssid":"MyString","ssid":"MyString","connection":"wifi","vertical_accuracy":1,"accuracy":1,"timestamp":1609459200,"latitude":"55.755826","mode":1,"inrids":[],"in_regions":[],"city":null,"country":null,"geodata":{},"reverse_geocoded_at":null}},{"type":"Feature","geometry":{"type":"Point","coordinates":["37.6173","55.755826"]},"properties":{"battery_status":"unplugged","ping":"MyString","battery":1,"tracker_id":"MyString","topic":"MyString","altitude":1,"longitude":"37.6173","velocity":"0","trigger":"background_event","bssid":"MyString","ssid":"MyString","connection":"wifi","vertical_accuracy":1,"accuracy":1,"timestamp":1609459200,"latitude":"55.755826","mode":1,"inrids":[],"in_regions":[],"city":null,"country":null,"geodata":{},"reverse_geocoded_at":null}},{"type":"Feature","geometry":{"type":"Point","coordinates":["37.6173","55.755826"]},"properties":{"battery_status":"unplugged","ping":"MyString","battery":1,"tracker_id":"MyString","topic":"MyString","altitude":1,"longitude":"37.6173","velocity":"0","trigger":"background_event","bssid":"MyString","ssid":"MyString","connection":"wifi","vertical_accuracy":1,"accuracy":1,"timestamp":1609459200,"latitude":"55.755826","mode":1,"inrids":[],"in_regions":[],"city":null,"country":null,"geodata":{},"reverse_geocoded_at":null}},{"type":"Feature","geometry":{"type":"Point","coordinates":["37.6173","55.755826"]},"properties":{"battery_status":"unplugged","ping":"MyString","battery":1,"tracker_id":"MyString","topic":"MyString","altitude":1,"longitude":"37.6173","velocity":"0","trigger":"background_event","bssid":"MyString","ssid":"MyString","connection":"wifi","vertical_accuracy":1,"accuracy":1,"timestamp":1609459200,"latitude":"55.755826","mode":1,"inrids":[],"in_regions":[],"city":null,"country":null,"geodata":{},"reverse_geocoded_at":null}},{"type":"Feature","geometry":{"type":"Point","coordinates":["37.6173","55.755826"]},"properties":{"battery_status":"unplugged","ping":"MyString","battery":1,"tracker_id":"MyString","topic":"MyString","altitude":1,"longitude":"37.6173","velocity":"0","trigger":"background_event","bssid":"MyString","ssid":"MyString","connection":"wifi","vertical_accuracy":1,"accuracy":1,"timestamp":1609459200,"latitude":"55.755826","mode":1,"inrids":[],"in_regions":[],"city":null,"country":null,"geodata":{},"reverse_geocoded_at":null}},{"type":"Feature","geometry":{"type":"Point","coordinates":["37.6173","55.755826"]},"properties":{"battery_status":"unplugged","ping":"MyString","battery":1,"tracker_id":"MyString","topic":"MyString","altitude":1,"longitude":"37.6173","velocity":"0","trigger":"background_event","bssid":"MyString","ssid":"MyString","connection":"wifi","vertical_accuracy":1,"accuracy":1,"timestamp":1609459200,"latitude":"55.755826","mode":1,"inrids":[],"in_regions":[],"city":null,"country":null,"geodata":{},"reverse_geocoded_at":null}},{"type":"Feature","geometry":{"type":"Point","coordinates":["37.6173","55.755826"]},"properties":{"battery_status":"unplugged","ping":"MyString","battery":1,"tracker_id":"MyString","topic":"MyString","altitude":1,"longitude":"37.6173","velocity":"0","trigger":"background_event","bssid":"MyString","ssid":"MyString","connection":"wifi","vertical_accuracy":1,"accuracy":1,"timestamp":1609459200,"latitude":"55.755826","mode":1,"inrids":[],"in_regions":[],"city":null,"country":null,"geodata":{},"reverse_geocoded_at":null}}]}
diff --git a/spec/jobs/telemetry_sending_job_spec.rb b/spec/jobs/telemetry_sending_job_spec.rb
index 2e227710..dc58ff24 100644
--- a/spec/jobs/telemetry_sending_job_spec.rb
+++ b/spec/jobs/telemetry_sending_job_spec.rb
@@ -1,5 +1,25 @@
+# frozen_string_literal: true
+
require 'rails_helper'
RSpec.describe TelemetrySendingJob, type: :job do
- pending "add some examples to (or delete) #{__FILE__}"
+ describe '#perform' do
+ let(:gather_service) { instance_double(Telemetry::Gather) }
+ let(:send_service) { instance_double(Telemetry::Send) }
+ let(:telemetry_data) { { some: 'data' } }
+
+ before do
+ allow(Telemetry::Gather).to receive(:new).and_return(gather_service)
+ allow(gather_service).to receive(:call).and_return(telemetry_data)
+ allow(Telemetry::Send).to receive(:new).with(telemetry_data).and_return(send_service)
+ allow(send_service).to receive(:call)
+ end
+
+ it 'gathers telemetry data and sends it' do
+ described_class.perform_now
+
+ expect(gather_service).to have_received(:call)
+ expect(send_service).to have_received(:call)
+ end
+ end
end
From 81e34f9943d9960576ddd88b49ee1421f723cf41 Mon Sep 17 00:00:00 2001
From: Eugene Burmakin
Date: Thu, 5 Dec 2024 17:40:29 +0100
Subject: [PATCH 35/51] Add a detail to changelog
---
CHANGELOG.md | 2 ++
1 file changed, 2 insertions(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 876b90cb..afde608a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -19,6 +19,8 @@ Data being sent:
- App version
- Instance ID
+The data is being sent to a InfluxDB instance hosted by me and won't be shared with anyone.
+
Basically this set of metrics allows me to see how many people are using Dawarich and what versions they are using. No other data is being sent, nor it gives me any knowledge about individual users or their data or activity.
The telemetry is enabled by default, but it **can be disabled** by setting `DISABLE_TELEMETRY` env var to `true`. The dataset might change in the future, but any changes will be documented here in the changelog and in every release as well as on the [telemetry page](https://dawarich.app/docs/tutorials/telemetry) of the website docs.
From 82b3e26bd342a32b50e464587ec9718bb528330d Mon Sep 17 00:00:00 2001
From: Eugene Burmakin
Date: Thu, 5 Dec 2024 17:46:24 +0100
Subject: [PATCH 36/51] Update readme and log telemetry data
---
CHANGELOG.md | 2 +-
app/jobs/telemetry_sending_job.rb | 1 +
app/services/telemetry/send.rb | 2 ++
spec/jobs/telemetry_sending_job_spec.rb | 12 ++++++++++++
4 files changed, 16 insertions(+), 1 deletion(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index afde608a..88b0a520 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -17,7 +17,7 @@ Data being sent:
- Number of DAU (Daily Active Users)
- App version
-- Instance ID
+- Instance ID (unique identifier of the Dawarich instance built by hashing the api key of the first user in the database)
The data is being sent to a InfluxDB instance hosted by me and won't be shared with anyone.
diff --git a/app/jobs/telemetry_sending_job.rb b/app/jobs/telemetry_sending_job.rb
index bdbe96d8..5b84f11a 100644
--- a/app/jobs/telemetry_sending_job.rb
+++ b/app/jobs/telemetry_sending_job.rb
@@ -7,6 +7,7 @@ class TelemetrySendingJob < ApplicationJob
return if ENV['DISABLE_TELEMETRY'] == 'true'
data = Telemetry::Gather.new.call
+ Rails.logger.info("Telemetry data: #{data}")
Telemetry::Send.new(data).call
end
diff --git a/app/services/telemetry/send.rb b/app/services/telemetry/send.rb
index c3cce833..46401294 100644
--- a/app/services/telemetry/send.rb
+++ b/app/services/telemetry/send.rb
@@ -9,6 +9,8 @@ class Telemetry::Send
end
def call
+ return if ENV['DISABLE_TELEMETRY'] == 'true'
+
line_protocol = build_line_protocol
response = send_request(line_protocol)
handle_response(response)
diff --git a/spec/jobs/telemetry_sending_job_spec.rb b/spec/jobs/telemetry_sending_job_spec.rb
index dc58ff24..0acef0ee 100644
--- a/spec/jobs/telemetry_sending_job_spec.rb
+++ b/spec/jobs/telemetry_sending_job_spec.rb
@@ -21,5 +21,17 @@ RSpec.describe TelemetrySendingJob, type: :job do
expect(gather_service).to have_received(:call)
expect(send_service).to have_received(:call)
end
+
+ context 'when DISABLE_TELEMETRY is set to true' do
+ before do
+ stub_const('ENV', ENV.to_h.merge('DISABLE_TELEMETRY' => 'true'))
+ end
+
+ it 'does not send telemetry data' do
+ described_class.perform_now
+
+ expect(send_service).not_to have_received(:call)
+ end
+ end
end
end
From 32224628e7dd17747243d8093fbf3f9ab9614556 Mon Sep 17 00:00:00 2001
From: whimsical-c4lic0
Date: Thu, 5 Dec 2024 16:14:56 -0600
Subject: [PATCH 37/51] Update map popup to use configured distance unit
---
app/javascript/controllers/maps_controller.js | 7 ++++---
app/javascript/maps/polylines.js | 8 ++++----
2 files changed, 8 insertions(+), 7 deletions(-)
diff --git a/app/javascript/controllers/maps_controller.js b/app/javascript/controllers/maps_controller.js
index 338444cf..880f6e9d 100644
--- a/app/javascript/controllers/maps_controller.js
+++ b/app/javascript/controllers/maps_controller.js
@@ -62,7 +62,7 @@ export default class extends Controller {
this.markersLayer = L.layerGroup(this.markersArray);
this.heatmapMarkers = this.markersArray.map((element) => [element._latlng.lat, element._latlng.lng, 0.2]);
- this.polylinesLayer = createPolylinesLayer(this.markers, this.map, this.timezone, this.routeOpacity, this.userSettings);
+ this.polylinesLayer = createPolylinesLayer(this.markers, this.map, this.timezone, this.routeOpacity, this.userSettings, this.distanceUnit);
this.heatmapLayer = L.heatLayer(this.heatmapMarkers, { radius: 20 }).addTo(this.map);
this.fogOverlay = L.layerGroup(); // Initialize fog layer
this.areasLayer = L.layerGroup(); // Initialize areas layer
@@ -215,7 +215,8 @@ export default class extends Controller {
this.map,
this.timezone,
this.routeOpacity,
- this.userSettings
+ this.userSettings,
+ this.distanceUnit
);
// Pan map to new location
@@ -687,7 +688,7 @@ export default class extends Controller {
// Recreate layers only if they don't exist
this.markersLayer = preserveLayers.Points || L.layerGroup(createMarkersArray(this.markers, newSettings));
- this.polylinesLayer = preserveLayers.Polylines || createPolylinesLayer(this.markers, this.map, this.timezone, this.routeOpacity, this.userSettings);
+ this.polylinesLayer = preserveLayers.Polylines || createPolylinesLayer(this.markers, this.map, this.timezone, this.routeOpacity, this.userSettings, this.distanceUnit);
this.heatmapLayer = preserveLayers.Heatmap || L.heatLayer(this.markers.map((element) => [element[0], element[1], 0.2]), { radius: 20 });
this.fogOverlay = preserveLayers["Fog of War"] || L.layerGroup();
this.areasLayer = preserveLayers.Areas || L.layerGroup();
diff --git a/app/javascript/maps/polylines.js b/app/javascript/maps/polylines.js
index 2bcaa428..fcdcc329 100644
--- a/app/javascript/maps/polylines.js
+++ b/app/javascript/maps/polylines.js
@@ -3,7 +3,7 @@ import { getUrlParameter } from "../maps/helpers";
import { minutesToDaysHoursMinutes } from "../maps/helpers";
import { haversineDistance } from "../maps/helpers";
-export function addHighlightOnHover(polyline, map, polylineCoordinates, userSettings) {
+export function addHighlightOnHover(polyline, map, polylineCoordinates, userSettings, distanceUnit) {
const originalStyle = { color: "blue", opacity: userSettings.routeOpacity, weight: 3 };
const highlightStyle = { color: "yellow", opacity: 1, weight: 5 };
@@ -33,7 +33,7 @@ export function addHighlightOnHover(polyline, map, polylineCoordinates, userSett
Start: ${firstTimestamp}
End: ${lastTimestamp}
Duration: ${timeOnRoute}
- Total Distance: ${formatDistance(totalDistance, userSettings.distanceUnit)}
+ Total Distance: ${formatDistance(totalDistance, distanceUnit)}
`;
if (isDebugMode) {
@@ -90,7 +90,7 @@ export function addHighlightOnHover(polyline, map, polylineCoordinates, userSett
});
}
-export function createPolylinesLayer(markers, map, userSettings) {
+export function createPolylinesLayer(markers, map, timezone, routeOpacity, userSettings, distanceUnit) {
const splitPolylines = [];
let currentPolyline = [];
const distanceThresholdMeters = parseInt(userSettings.meters_between_routes) || 500;
@@ -123,7 +123,7 @@ export function createPolylinesLayer(markers, map, userSettings) {
const latLngs = polylineCoordinates.map((point) => [point[0], point[1]]);
const polyline = L.polyline(latLngs, { color: "blue", opacity: 0.6, weight: 3 });
- addHighlightOnHover(polyline, map, polylineCoordinates, userSettings);
+ addHighlightOnHover(polyline, map, polylineCoordinates, userSettings, distanceUnit);
return polyline;
})
From b7e4a017b8d8d9f210c5c3613e1a627db112947c Mon Sep 17 00:00:00 2001
From: Eugene Burmakin
Date: Fri, 6 Dec 2024 16:52:36 +0100
Subject: [PATCH 38/51] Calculate only necessary stats
---
app/controllers/stats_controller.rb | 6 +-
app/jobs/bulk_stats_calculating_job.rb | 2 +-
app/jobs/cache/preheating_job.rb | 13 ++++
app/jobs/stats/calculating_job.rb | 4 +-
app/models/import.rb | 6 ++
app/models/stat.rb | 61 ++++++++--------
app/models/user.rb | 11 +++
app/services/distance_calculator.rb | 18 +++++
app/services/imports/create.rb | 7 +-
app/services/stats/bulk_calculator.rb | 40 +++++++++++
app/services/stats/calculate.rb | 69 -------------------
app/services/stats/calculate_month.rb | 69 +++++++++++++++++++
config/routes.rb | 2 +-
config/schedule.yml | 5 ++
...imestamp]_add_index_to_points_timestamp.rb | 9 +++
db/schema.rb | 16 +++++
spec/jobs/bulk_stats_calculating_job_spec.rb | 17 +++--
spec/jobs/cache/preheating_job_spec.rb | 5 ++
spec/jobs/import_job_spec.rb | 6 +-
spec/jobs/stats/calculating_job_spec.rb | 30 +++++---
spec/requests/stats_spec.rb | 10 ++-
spec/services/imports/create_spec.rb | 24 ++++---
...culate_spec.rb => calculate_month_spec.rb} | 12 ++--
23 files changed, 301 insertions(+), 141 deletions(-)
create mode 100644 app/jobs/cache/preheating_job.rb
create mode 100644 app/services/distance_calculator.rb
create mode 100644 app/services/stats/bulk_calculator.rb
delete mode 100644 app/services/stats/calculate.rb
create mode 100644 app/services/stats/calculate_month.rb
create mode 100644 db/migrate/[timestamp]_add_index_to_points_timestamp.rb
create mode 100644 spec/jobs/cache/preheating_job_spec.rb
rename spec/services/stats/{calculate_spec.rb => calculate_month_spec.rb} (87%)
diff --git a/app/controllers/stats_controller.rb b/app/controllers/stats_controller.rb
index 6ce83808..4a1f0622 100644
--- a/app/controllers/stats_controller.rb
+++ b/app/controllers/stats_controller.rb
@@ -13,7 +13,11 @@ class StatsController < ApplicationController
end
def update
- Stats::CalculatingJob.perform_later(current_user.id)
+ current_user.years_tracked.each do |year|
+ (1..12).each do |month|
+ Stats::CalculatingJob.perform_later(current_user.id, year, month)
+ end
+ end
redirect_to stats_path, notice: 'Stats are being updated', status: :see_other
end
diff --git a/app/jobs/bulk_stats_calculating_job.rb b/app/jobs/bulk_stats_calculating_job.rb
index a118aa9b..8cc2ba46 100644
--- a/app/jobs/bulk_stats_calculating_job.rb
+++ b/app/jobs/bulk_stats_calculating_job.rb
@@ -7,7 +7,7 @@ class BulkStatsCalculatingJob < ApplicationJob
user_ids = User.pluck(:id)
user_ids.each do |user_id|
- Stats::CalculatingJob.perform_later(user_id)
+ Stats::BulkCalculator.new(user_id).call
end
end
end
diff --git a/app/jobs/cache/preheating_job.rb b/app/jobs/cache/preheating_job.rb
new file mode 100644
index 00000000..75353ed8
--- /dev/null
+++ b/app/jobs/cache/preheating_job.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+class Cache::PreheatingJob < ApplicationJob
+ queue_as :default
+
+ def perform
+ User.find_each do |user|
+ Rails.cache.fetch("dawarich/user_#{user.id}_years_tracked", expires_in: 1.day) do
+ user.years_tracked
+ end
+ end
+ end
+end
diff --git a/app/jobs/stats/calculating_job.rb b/app/jobs/stats/calculating_job.rb
index a0faa50c..26f4756e 100644
--- a/app/jobs/stats/calculating_job.rb
+++ b/app/jobs/stats/calculating_job.rb
@@ -3,8 +3,8 @@
class Stats::CalculatingJob < ApplicationJob
queue_as :stats
- def perform(user_id, start_at: nil, end_at: nil)
- Stats::Calculate.new(user_id, start_at:, end_at:).call
+ def perform(user_id, year, month)
+ Stats::CalculateMonth.new(user_id, year, month).call
create_stats_updated_notification(user_id)
rescue StandardError => e
diff --git a/app/models/import.rb b/app/models/import.rb
index 067baf12..2040d738 100644
--- a/app/models/import.rb
+++ b/app/models/import.rb
@@ -16,4 +16,10 @@ class Import < ApplicationRecord
def process!
Imports::Create.new(user, self).call
end
+
+ def years_and_months_tracked
+ points.order(:timestamp).map do |point|
+ [Time.zone.at(point.timestamp).year, Time.zone.at(point.timestamp).month]
+ end.uniq
+ end
end
diff --git a/app/models/stat.rb b/app/models/stat.rb
index ee3081a7..8bfe5b36 100644
--- a/app/models/stat.rb
+++ b/app/models/stat.rb
@@ -6,39 +6,16 @@ class Stat < ApplicationRecord
belongs_to :user
def distance_by_day
- timespan.to_a.map.with_index(1) do |day, index|
- beginning_of_day = day.beginning_of_day.to_i
- end_of_day = day.end_of_day.to_i
-
- # We have to filter by user as well
- points = user
- .tracked_points
- .without_raw_data
- .order(timestamp: :asc)
- .where(timestamp: beginning_of_day..end_of_day)
-
- data = { day: index, distance: 0 }
-
- points.each_cons(2) do |point1, point2|
- distance = Geocoder::Calculations.distance_between(
- point1.to_coordinates, point2.to_coordinates, units: ::DISTANCE_UNIT
- )
-
- data[:distance] += distance
- end
-
- [data[:day], data[:distance].round(2)]
- end
+ monthly_points = points
+ calculate_daily_distances(monthly_points)
end
def self.year_distance(year, user)
- stats = where(year:, user:).order(:month)
-
- (1..12).to_a.map do |month|
- month_stat = stats.select { |stat| stat.month == month }.first
+ stats_by_month = where(year:, user:).order(:month).index_by(&:month)
+ (1..12).map do |month|
month_name = Date::MONTHNAMES[month]
- distance = month_stat&.distance || 0
+ distance = stats_by_month[month]&.distance || 0
[month_name, distance]
end
@@ -64,9 +41,37 @@ class Stat < ApplicationRecord
(starting_year..Time.current.year).to_a.reverse
end
+ def points
+ user.tracked_points
+ .without_raw_data
+ .where(timestamp: timespan)
+ .order(timestamp: :asc)
+ end
+
private
def timespan
DateTime.new(year, month).beginning_of_month..DateTime.new(year, month).end_of_month
end
+
+ def calculate_daily_distances(monthly_points)
+ timespan.to_a.map.with_index(1) do |day, index|
+ daily_points = filter_points_for_day(monthly_points, day)
+ distance = calculate_distance(daily_points)
+ [index, distance.round(2)]
+ end
+ end
+
+ def filter_points_for_day(points, day)
+ beginning_of_day = day.beginning_of_day.to_i
+ end_of_day = day.end_of_day.to_i
+
+ points.select { |p| p.timestamp.between?(beginning_of_day, end_of_day) }
+ end
+
+ def calculate_distance(points)
+ points.each_cons(2).sum do |point1, point2|
+ DistanceCalculator.new(point1, point2).call
+ end
+ end
end
diff --git a/app/models/user.rb b/app/models/user.rb
index 58ce091d..2060b2c3 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -62,6 +62,17 @@ class User < ApplicationRecord
settings['photoprism_url'].present? && settings['photoprism_api_key'].present?
end
+ def years_tracked
+ Rails.cache.fetch("dawarich/user_#{id}_years_tracked", expires_in: 1.day) do
+ tracked_points
+ .pluck(:timestamp)
+ .map { |ts| Time.zone.at(ts).year }
+ .uniq
+ .sort
+ .reverse
+ end
+ end
+
private
def create_api_key
diff --git a/app/services/distance_calculator.rb b/app/services/distance_calculator.rb
new file mode 100644
index 00000000..d00d070b
--- /dev/null
+++ b/app/services/distance_calculator.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+class DistanceCalculator
+ def initialize(point1, point2)
+ @point1 = point1
+ @point2 = point2
+ end
+
+ def call
+ Geocoder::Calculations.distance_between(
+ point1.to_coordinates, point2.to_coordinates, units: ::DISTANCE_UNIT
+ )
+ end
+
+ private
+
+ attr_reader :point1, :point2
+end
diff --git a/app/services/imports/create.rb b/app/services/imports/create.rb
index 7c34cc1f..af9b0d0c 100644
--- a/app/services/imports/create.rb
+++ b/app/services/imports/create.rb
@@ -34,10 +34,9 @@ class Imports::Create
end
def schedule_stats_creating(user_id)
- start_at = import.points.order(:timestamp).first.recorded_at
- end_at = import.points.order(:timestamp).last.recorded_at
-
- Stats::CalculatingJob.perform_later(user_id, start_at:, end_at:)
+ import.years_and_months_tracked.each do |year, month|
+ Stats::CalculatingJob.perform_later(user_id, year, month)
+ end
end
def schedule_visit_suggesting(user_id, import)
diff --git a/app/services/stats/bulk_calculator.rb b/app/services/stats/bulk_calculator.rb
new file mode 100644
index 00000000..aa74d60c
--- /dev/null
+++ b/app/services/stats/bulk_calculator.rb
@@ -0,0 +1,40 @@
+# frozen_string_literal: true
+
+module Stats
+ class BulkCalculator
+ def initialize(user_id)
+ @user_id = user_id
+ end
+
+ def call
+ months = extract_months(fetch_timestamps)
+
+ schedule_calculations(months)
+ end
+
+ private
+
+ attr_reader :user_id
+
+ def fetch_timestamps
+ last_calculated_at = Stat.where(user_id:).maximum(:updated_at)
+ last_calculated_at ||= DateTime.new(1970, 1, 1)
+
+ time_diff = last_calculated_at.to_i..Time.current.to_i
+ Point.where(user_id:, timestamp: time_diff).pluck(:timestamp)
+ end
+
+ def extract_months(timestamps)
+ timestamps.group_by do |timestamp|
+ time = Time.zone.at(timestamp)
+ [time.year, time.month]
+ end.keys
+ end
+
+ def schedule_calculations(months)
+ months.each do |year, month|
+ Stats::CalculatingJob.perform_later(user_id, year, month)
+ end
+ end
+ end
+end
diff --git a/app/services/stats/calculate.rb b/app/services/stats/calculate.rb
deleted file mode 100644
index 5f7c127f..00000000
--- a/app/services/stats/calculate.rb
+++ /dev/null
@@ -1,69 +0,0 @@
-# frozen_string_literal: true
-
-class Stats::Calculate
- def initialize(user_id, start_at: nil, end_at: nil)
- @user = User.find(user_id)
- @start_at = start_at || DateTime.new(1970, 1, 1)
- @end_at = end_at || Time.current
- end
-
- def call
- points = points(start_timestamp, end_timestamp)
- points_by_month = points.group_by_month(&:recorded_at)
-
- points_by_month.each do |month, month_points|
- update_month_stats(month_points, month.year, month.month)
- end
- rescue StandardError => e
- create_stats_update_failed_notification(user, e)
- end
-
- private
-
- attr_reader :user, :start_at, :end_at
-
- def start_timestamp = start_at.to_i
- def end_timestamp = end_at.to_i
-
- def update_month_stats(month_points, year, month)
- return if month_points.empty?
-
- stat = current_stat(year, month)
- distance_by_day = stat.distance_by_day
-
- stat.daily_distance = distance_by_day
- stat.distance = distance(distance_by_day)
- stat.toponyms = toponyms(month_points)
- stat.save
- end
-
- def points(start_at, end_at)
- user
- .tracked_points
- .without_raw_data
- .where(timestamp: start_at..end_at)
- .order(:timestamp)
- .select(:latitude, :longitude, :timestamp, :city, :country)
- end
-
- def distance(distance_by_day)
- distance_by_day.sum { |day| day[1] }
- end
-
- def toponyms(points)
- CountriesAndCities.new(points).call
- end
-
- def current_stat(year, month)
- Stat.find_or_initialize_by(year:, month:, user:)
- end
-
- def create_stats_update_failed_notification(user, error)
- Notifications::Create.new(
- user:,
- kind: :error,
- title: 'Stats update failed',
- content: "#{error.message}, stacktrace: #{error.backtrace.join("\n")}"
- ).call
- end
-end
diff --git a/app/services/stats/calculate_month.rb b/app/services/stats/calculate_month.rb
new file mode 100644
index 00000000..b99b2603
--- /dev/null
+++ b/app/services/stats/calculate_month.rb
@@ -0,0 +1,69 @@
+# frozen_string_literal: true
+
+class Stats::CalculateMonth
+ def initialize(user_id, year, month)
+ @user = User.find(user_id)
+ @year = year
+ @month = month
+ end
+
+ def call
+ return if points.empty?
+
+ update_month_stats(year, month)
+ rescue StandardError => e
+ create_stats_update_failed_notification(user, e)
+ end
+
+ private
+
+ attr_reader :user, :year, :month
+
+ def start_timestamp = DateTime.new(year, month, 1).to_i
+
+ def end_timestamp
+ DateTime.new(year, month, -1).to_i # -1 returns last day of month
+ end
+
+ def update_month_stats(year, month)
+ Stat.transaction do
+ stat = Stat.find_or_initialize_by(year:, month:, user:)
+ distance_by_day = stat.distance_by_day
+
+ stat.assign_attributes(
+ daily_distance: distance_by_day,
+ distance: distance(distance_by_day),
+ toponyms: toponyms
+ )
+ stat.save
+ end
+ end
+
+ def points
+ return @points if defined?(@points)
+
+ @points = user
+ .tracked_points
+ .without_raw_data
+ .where(timestamp: start_timestamp..end_timestamp)
+ .select(:latitude, :longitude, :timestamp, :city, :country)
+ .order(timestamp: :asc)
+ end
+
+ def distance(distance_by_day)
+ distance_by_day.sum { |day| day[1] }
+ end
+
+ def toponyms
+ CountriesAndCities.new(points).call
+ end
+
+ def create_stats_update_failed_notification(user, error)
+ Notifications::Create.new(
+ user:,
+ kind: :error,
+ title: 'Stats update failed',
+ content: "#{error.message}, stacktrace: #{error.backtrace.join("\n")}"
+ ).call
+ end
+end
diff --git a/config/routes.rb b/config/routes.rb
index 2c40e93d..7639ce4b 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -40,7 +40,7 @@ Rails.application.routes.draw do
post 'notifications/mark_as_read', to: 'notifications#mark_as_read', as: :mark_notifications_as_read
resources :stats, only: :index do
collection do
- post :update
+ post :update, constraints: { year: /\d{4}/, month: /\d{1,2}/ }
end
end
get 'stats/:year', to: 'stats#show', constraints: { year: /\d{4}/ }
diff --git a/config/schedule.yml b/config/schedule.yml
index 0b99f8c1..08de79bd 100644
--- a/config/schedule.yml
+++ b/config/schedule.yml
@@ -30,3 +30,8 @@ telemetry_sending_job:
cron: "0 */1 * * *" # every 1 hour
class: "TelemetrySendingJob"
queue: default
+
+cache_preheating_job:
+ cron: "0 0 * * *" # every day at 0:00
+ class: "Cache::PreheatingJob"
+ queue: default
diff --git a/db/migrate/[timestamp]_add_index_to_points_timestamp.rb b/db/migrate/[timestamp]_add_index_to_points_timestamp.rb
new file mode 100644
index 00000000..8e4bc3fa
--- /dev/null
+++ b/db/migrate/[timestamp]_add_index_to_points_timestamp.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+class AddIndexToPointsTimestamp < ActiveRecord::Migration[7.2]
+ disable_ddl_transaction!
+
+ def change
+ add_index :points, %i[user_id timestamp], algorithm: :concurrently
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 2927e2d5..fea44510 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -198,6 +198,21 @@ ActiveRecord::Schema[7.2].define(version: 2024_12_05_160055) do
t.index ["user_id"], name: "index_trips_on_user_id"
end
+ create_table "user_digests", force: :cascade do |t|
+ t.bigint "user_id", null: false
+ t.integer "kind", default: 0, null: false
+ t.datetime "start_at", null: false
+ t.datetime "end_at"
+ t.integer "distance", default: 0, null: false
+ t.text "countries", default: [], array: true
+ t.text "cities", default: [], array: true
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["distance"], name: "index_user_digests_on_distance"
+ t.index ["kind"], name: "index_user_digests_on_kind"
+ t.index ["user_id"], name: "index_user_digests_on_user_id"
+ end
+
create_table "users", force: :cascade do |t|
t.string "email", default: "", null: false
t.string "encrypted_password", default: "", null: false
@@ -245,6 +260,7 @@ ActiveRecord::Schema[7.2].define(version: 2024_12_05_160055) do
add_foreign_key "points", "visits"
add_foreign_key "stats", "users"
add_foreign_key "trips", "users"
+ add_foreign_key "user_digests", "users"
add_foreign_key "visits", "areas"
add_foreign_key "visits", "places"
add_foreign_key "visits", "users"
diff --git a/spec/jobs/bulk_stats_calculating_job_spec.rb b/spec/jobs/bulk_stats_calculating_job_spec.rb
index be3cc1b4..15bbc9fb 100644
--- a/spec/jobs/bulk_stats_calculating_job_spec.rb
+++ b/spec/jobs/bulk_stats_calculating_job_spec.rb
@@ -4,14 +4,17 @@ require 'rails_helper'
RSpec.describe BulkStatsCalculatingJob, type: :job do
describe '#perform' do
- it 'enqueues Stats::CalculatingJob for each user' do
- user1 = create(:user)
- user2 = create(:user)
- user3 = create(:user)
+ let(:user1) { create(:user) }
+ let(:user2) { create(:user) }
- expect(Stats::CalculatingJob).to receive(:perform_later).with(user1.id)
- expect(Stats::CalculatingJob).to receive(:perform_later).with(user2.id)
- expect(Stats::CalculatingJob).to receive(:perform_later).with(user3.id)
+ let(:timestamp) { DateTime.new(2024, 1, 1).to_i }
+
+ let!(:points1) { create_list(:point, 10, user_id: user1.id, timestamp:) }
+ let!(:points2) { create_list(:point, 10, user_id: user2.id, timestamp:) }
+
+ it 'enqueues Stats::CalculatingJob for each user' do
+ expect(Stats::CalculatingJob).to receive(:perform_later).with(user1.id, 2024, 1)
+ expect(Stats::CalculatingJob).to receive(:perform_later).with(user2.id, 2024, 1)
BulkStatsCalculatingJob.perform_now
end
diff --git a/spec/jobs/cache/preheating_job_spec.rb b/spec/jobs/cache/preheating_job_spec.rb
new file mode 100644
index 00000000..3180c856
--- /dev/null
+++ b/spec/jobs/cache/preheating_job_spec.rb
@@ -0,0 +1,5 @@
+require 'rails_helper'
+
+RSpec.describe Cache::PreheatingJob, type: :job do
+ pending "add some examples to (or delete) #{__FILE__}"
+end
diff --git a/spec/jobs/import_job_spec.rb b/spec/jobs/import_job_spec.rb
index 45532506..aa6d3e22 100644
--- a/spec/jobs/import_job_spec.rb
+++ b/spec/jobs/import_job_spec.rb
@@ -8,16 +8,14 @@ RSpec.describe ImportJob, type: :job do
let(:user) { create(:user) }
let!(:import) { create(:import, user:, name: 'owntracks_export.json') }
- let!(:import_points) { create_list(:point, 9, import: import) }
- let(:start_at) { Time.zone.at(1_709_283_789) } # Timestamp of the first point in the "2024-03.rec" fixture file
- let(:end_at) { import.points.reload.order(:timestamp).last.recorded_at }
it 'creates points' do
expect { perform }.to change { Point.count }.by(9)
end
it 'calls Stats::CalculatingJob' do
- expect(Stats::CalculatingJob).to receive(:perform_later).with(user.id, start_at:, end_at:)
+ # Timestamp of the first point in the "2024-03.rec" fixture file
+ expect(Stats::CalculatingJob).to receive(:perform_later).with(user.id, 2024, 3)
perform
end
diff --git a/spec/jobs/stats/calculating_job_spec.rb b/spec/jobs/stats/calculating_job_spec.rb
index a8b95de5..fdab7593 100644
--- a/spec/jobs/stats/calculating_job_spec.rb
+++ b/spec/jobs/stats/calculating_job_spec.rb
@@ -5,24 +5,36 @@ require 'rails_helper'
RSpec.describe Stats::CalculatingJob, type: :job do
describe '#perform' do
let!(:user) { create(:user) }
- let(:start_at) { nil }
- let(:end_at) { nil }
- subject { described_class.perform_now(user.id) }
+ subject { described_class.perform_now(user.id, 2024, 1) }
before do
- allow(Stats::Calculate).to receive(:new).and_call_original
- allow_any_instance_of(Stats::Calculate).to receive(:call)
+ allow(Stats::CalculateMonth).to receive(:new).and_call_original
+ allow_any_instance_of(Stats::CalculateMonth).to receive(:call)
end
- it 'calls Stats::Calculate service' do
+ it 'calls Stats::CalculateMonth service' do
subject
- expect(Stats::Calculate).to have_received(:new).with(user.id, { start_at:, end_at: })
+ expect(Stats::CalculateMonth).to have_received(:new).with(user.id, 2024, 1)
end
- it 'created notifications' do
- expect { subject }.to change { Notification.count }.by(1)
+ context 'when Stats::CalculateMonth raises an error' do
+ before do
+ allow_any_instance_of(Stats::CalculateMonth).to receive(:call).and_raise(StandardError)
+ end
+
+ it 'creates an error notification' do
+ expect { subject }.to change { Notification.count }.by(1)
+ expect(Notification.last.kind).to eq('error')
+ end
+ end
+
+ context 'when Stats::CalculateMonth does not raise an error' do
+ it 'creates an info notification' do
+ expect { subject }.to change { Notification.count }.by(1)
+ expect(Notification.last.kind).to eq('info')
+ end
end
end
end
diff --git a/spec/requests/stats_spec.rb b/spec/requests/stats_spec.rb
index 3afb9516..40c19823 100644
--- a/spec/requests/stats_spec.rb
+++ b/spec/requests/stats_spec.rb
@@ -54,8 +54,14 @@ RSpec.describe '/stats', type: :request do
describe 'POST /update' do
let(:stat) { create(:stat, user:, year: 2024) }
- it 'enqueues Stats::CalculatingJob' do
- expect { post stats_url(stat.year) }.to have_enqueued_job(Stats::CalculatingJob)
+ it 'enqueues Stats::CalculatingJob for each tracked year and month' do
+ allow(user).to receive(:years_tracked).and_return([2024])
+
+ post stats_url
+
+ (1..12).each do |month|
+ expect(Stats::CalculatingJob).to have_been_enqueued.with(user.id, 2024, month)
+ end
end
end
end
diff --git a/spec/services/imports/create_spec.rb b/spec/services/imports/create_spec.rb
index d35e1898..85f2131a 100644
--- a/spec/services/imports/create_spec.rb
+++ b/spec/services/imports/create_spec.rb
@@ -11,7 +11,8 @@ RSpec.describe Imports::Create do
let(:import) { create(:import, source: 'google_semantic_history') }
it 'calls the GoogleMaps::SemanticHistoryParser' do
- expect(GoogleMaps::SemanticHistoryParser).to receive(:new).with(import, user.id).and_return(double(call: true))
+ expect(GoogleMaps::SemanticHistoryParser).to \
+ receive(:new).with(import, user.id).and_return(double(call: true))
service.call
end
end
@@ -20,7 +21,8 @@ RSpec.describe Imports::Create do
let(:import) { create(:import, source: 'google_phone_takeout') }
it 'calls the GoogleMaps::PhoneTakeoutParser' do
- expect(GoogleMaps::PhoneTakeoutParser).to receive(:new).with(import, user.id).and_return(double(call: true))
+ expect(GoogleMaps::PhoneTakeoutParser).to \
+ receive(:new).with(import, user.id).and_return(double(call: true))
service.call
end
end
@@ -29,7 +31,8 @@ RSpec.describe Imports::Create do
let(:import) { create(:import, source: 'owntracks') }
it 'calls the OwnTracks::ExportParser' do
- expect(OwnTracks::ExportParser).to receive(:new).with(import, user.id).and_return(double(call: true))
+ expect(OwnTracks::ExportParser).to \
+ receive(:new).with(import, user.id).and_return(double(call: true))
service.call
end
@@ -42,7 +45,8 @@ RSpec.describe Imports::Create do
it 'schedules stats creating' do
Sidekiq::Testing.inline! do
- expect { service.call }.to have_enqueued_job(Stats::CalculatingJob)
+ expect { service.call }.to \
+ have_enqueued_job(Stats::CalculatingJob).with(user.id, 2024, 3)
end
end
@@ -70,7 +74,8 @@ RSpec.describe Imports::Create do
let(:import) { create(:import, source: 'gpx') }
it 'calls the Gpx::TrackParser' do
- expect(Gpx::TrackParser).to receive(:new).with(import, user.id).and_return(double(call: true))
+ expect(Gpx::TrackParser).to \
+ receive(:new).with(import, user.id).and_return(double(call: true))
service.call
end
end
@@ -79,7 +84,8 @@ RSpec.describe Imports::Create do
let(:import) { create(:import, source: 'geojson') }
it 'calls the Geojson::ImportParser' do
- expect(Geojson::ImportParser).to receive(:new).with(import, user.id).and_return(double(call: true))
+ expect(Geojson::ImportParser).to \
+ receive(:new).with(import, user.id).and_return(double(call: true))
service.call
end
end
@@ -88,7 +94,8 @@ RSpec.describe Imports::Create do
let(:import) { create(:import, source: 'immich_api') }
it 'calls the Photos::ImportParser' do
- expect(Photos::ImportParser).to receive(:new).with(import, user.id).and_return(double(call: true))
+ expect(Photos::ImportParser).to \
+ receive(:new).with(import, user.id).and_return(double(call: true))
service.call
end
end
@@ -97,7 +104,8 @@ RSpec.describe Imports::Create do
let(:import) { create(:import, source: 'photoprism_api') }
it 'calls the Photos::ImportParser' do
- expect(Photos::ImportParser).to receive(:new).with(import, user.id).and_return(double(call: true))
+ expect(Photos::ImportParser).to \
+ receive(:new).with(import, user.id).and_return(double(call: true))
service.call
end
end
diff --git a/spec/services/stats/calculate_spec.rb b/spec/services/stats/calculate_month_spec.rb
similarity index 87%
rename from spec/services/stats/calculate_spec.rb
rename to spec/services/stats/calculate_month_spec.rb
index b02e9f7d..a5ec4383 100644
--- a/spec/services/stats/calculate_spec.rb
+++ b/spec/services/stats/calculate_month_spec.rb
@@ -2,11 +2,13 @@
require 'rails_helper'
-RSpec.describe Stats::Calculate do
+RSpec.describe Stats::CalculateMonth do
describe '#call' do
- subject(:calculate_stats) { described_class.new(user.id).call }
+ subject(:calculate_stats) { described_class.new(user.id, year, month).call }
let(:user) { create(:user) }
+ let(:year) { 2021 }
+ let(:month) { 1 }
context 'when there are no points' do
it 'does not create stats' do
@@ -15,9 +17,9 @@ RSpec.describe Stats::Calculate do
end
context 'when there are points' do
- let(:timestamp1) { DateTime.new(2021, 1, 1, 12).to_i }
- let(:timestamp2) { DateTime.new(2021, 1, 1, 13).to_i }
- let(:timestamp3) { DateTime.new(2021, 1, 1, 14).to_i }
+ let(:timestamp1) { DateTime.new(year, month, 1, 12).to_i }
+ let(:timestamp2) { DateTime.new(year, month, 1, 13).to_i }
+ let(:timestamp3) { DateTime.new(year, month, 1, 14).to_i }
let!(:import) { create(:import, user:) }
let!(:point1) do
create(:point,
From 3b115a85b1376127634788e21da4df082aaeb999 Mon Sep 17 00:00:00 2001
From: Eugene Burmakin
Date: Fri, 6 Dec 2024 17:32:45 +0100
Subject: [PATCH 39/51] Add missing tests and process reverse geocoding in
batches
---
.app_version | 2 +-
CHANGELOG.md | 9 ++++++
app/jobs/cache/preheating_job.rb | 4 +--
app/models/stat.rb | 6 ----
app/services/jobs/create.rb | 2 +-
app/views/shared/_right_sidebar.html.erb | 2 +-
config/routes.rb | 2 +-
config/schedule.yml | 2 +-
db/schema.rb | 16 -----------
spec/jobs/cache/preheating_job_spec.rb | 5 ----
spec/models/import_spec.rb | 10 +++++++
spec/models/stat_spec.rb | 36 ++++++++----------------
spec/models/user_spec.rb | 10 +++++++
13 files changed, 47 insertions(+), 59 deletions(-)
delete mode 100644 spec/jobs/cache/preheating_job_spec.rb
diff --git a/.app_version b/.app_version
index 61e6e92d..b72b05ed 100644
--- a/.app_version
+++ b/.app_version
@@ -1 +1 @@
-0.19.2
+0.19.3
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 88b0a520..b67dd939 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,15 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/)
and this project adheres to [Semantic Versioning](http://semver.org/).
+# 0.19.3 - 2024-12-06
+
+### Changed
+
+- Refactored stats calculation to calculate only necessary stats, instead of calculating all stats
+- Stats are now being calculated every 1 hour instead of 6 hours
+- List of years on the Map page is now being calculated based on user's points instead of stats. It's also being cached for 1 day due to the fact that it's usually a heavy operation based on the number of points.
+- Reverse-geocoding points is now being performed in batches of 1,000 points to prevent memory exhaustion.
+
# 0.19.2 - 2024-12-04
## The Telemetry release
diff --git a/app/jobs/cache/preheating_job.rb b/app/jobs/cache/preheating_job.rb
index 75353ed8..bdf3ea99 100644
--- a/app/jobs/cache/preheating_job.rb
+++ b/app/jobs/cache/preheating_job.rb
@@ -5,9 +5,7 @@ class Cache::PreheatingJob < ApplicationJob
def perform
User.find_each do |user|
- Rails.cache.fetch("dawarich/user_#{user.id}_years_tracked", expires_in: 1.day) do
- user.years_tracked
- end
+ Rails.cache.write("dawarich/user_#{user.id}_years_tracked", user.years_tracked, expires_in: 1.day)
end
end
end
diff --git a/app/models/stat.rb b/app/models/stat.rb
index 8bfe5b36..9376c991 100644
--- a/app/models/stat.rb
+++ b/app/models/stat.rb
@@ -35,12 +35,6 @@ class Stat < ApplicationRecord
}
end
- def self.years
- starting_year = select(:year).min&.year || Time.current.year
-
- (starting_year..Time.current.year).to_a.reverse
- end
-
def points
user.tracked_points
.without_raw_data
diff --git a/app/services/jobs/create.rb b/app/services/jobs/create.rb
index fdefe62d..ff8466be 100644
--- a/app/services/jobs/create.rb
+++ b/app/services/jobs/create.rb
@@ -21,6 +21,6 @@ class Jobs::Create
raise InvalidJobName, 'Invalid job name'
end
- points.each(&:async_reverse_geocode)
+ points.find_each(batch_size: 1_000, &:async_reverse_geocode)
end
end
diff --git a/app/views/shared/_right_sidebar.html.erb b/app/views/shared/_right_sidebar.html.erb
index 87c99c04..797adaeb 100644
--- a/app/views/shared/_right_sidebar.html.erb
+++ b/app/views/shared/_right_sidebar.html.erb
@@ -4,7 +4,7 @@
Select year
- <% current_user.stats.years.each do |year| %>
+ <% current_user.years_tracked.each do |year| %>
- <%= link_to year, map_url(year_timespan(year).merge(year: year, import_id: params[:import_id])) %>
<% end %>
diff --git a/config/routes.rb b/config/routes.rb
index 7639ce4b..2c40e93d 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -40,7 +40,7 @@ Rails.application.routes.draw do
post 'notifications/mark_as_read', to: 'notifications#mark_as_read', as: :mark_notifications_as_read
resources :stats, only: :index do
collection do
- post :update, constraints: { year: /\d{4}/, month: /\d{1,2}/ }
+ post :update
end
end
get 'stats/:year', to: 'stats#show', constraints: { year: /\d{4}/ }
diff --git a/config/schedule.yml b/config/schedule.yml
index 08de79bd..e27337d6 100644
--- a/config/schedule.yml
+++ b/config/schedule.yml
@@ -1,7 +1,7 @@
# config/schedule.yml
bulk_stats_calculating_job:
- cron: "0 */6 * * *" # every 6 hour
+ cron: "0 */1 * * *" # every 1 hour
class: "BulkStatsCalculatingJob"
queue: stats
diff --git a/db/schema.rb b/db/schema.rb
index fea44510..2927e2d5 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -198,21 +198,6 @@ ActiveRecord::Schema[7.2].define(version: 2024_12_05_160055) do
t.index ["user_id"], name: "index_trips_on_user_id"
end
- create_table "user_digests", force: :cascade do |t|
- t.bigint "user_id", null: false
- t.integer "kind", default: 0, null: false
- t.datetime "start_at", null: false
- t.datetime "end_at"
- t.integer "distance", default: 0, null: false
- t.text "countries", default: [], array: true
- t.text "cities", default: [], array: true
- t.datetime "created_at", null: false
- t.datetime "updated_at", null: false
- t.index ["distance"], name: "index_user_digests_on_distance"
- t.index ["kind"], name: "index_user_digests_on_kind"
- t.index ["user_id"], name: "index_user_digests_on_user_id"
- end
-
create_table "users", force: :cascade do |t|
t.string "email", default: "", null: false
t.string "encrypted_password", default: "", null: false
@@ -260,7 +245,6 @@ ActiveRecord::Schema[7.2].define(version: 2024_12_05_160055) do
add_foreign_key "points", "visits"
add_foreign_key "stats", "users"
add_foreign_key "trips", "users"
- add_foreign_key "user_digests", "users"
add_foreign_key "visits", "areas"
add_foreign_key "visits", "places"
add_foreign_key "visits", "users"
diff --git a/spec/jobs/cache/preheating_job_spec.rb b/spec/jobs/cache/preheating_job_spec.rb
deleted file mode 100644
index 3180c856..00000000
--- a/spec/jobs/cache/preheating_job_spec.rb
+++ /dev/null
@@ -1,5 +0,0 @@
-require 'rails_helper'
-
-RSpec.describe Cache::PreheatingJob, type: :job do
- pending "add some examples to (or delete) #{__FILE__}"
-end
diff --git a/spec/models/import_spec.rb b/spec/models/import_spec.rb
index 2e04a91d..d6c7efc8 100644
--- a/spec/models/import_spec.rb
+++ b/spec/models/import_spec.rb
@@ -22,4 +22,14 @@ RSpec.describe Import, type: :model do
)
end
end
+
+ describe '#years_and_months_tracked' do
+ let(:import) { create(:import) }
+ let(:timestamp) { Time.zone.local(2024, 11, 1) }
+ let!(:points) { create_list(:point, 3, import:, timestamp:) }
+
+ it 'returns years and months tracked' do
+ expect(import.years_and_months_tracked).to eq([[2024, 11]])
+ end
+ end
end
diff --git a/spec/models/stat_spec.rb b/spec/models/stat_spec.rb
index 369a8e87..ae65afd2 100644
--- a/spec/models/stat_spec.rb
+++ b/spec/models/stat_spec.rb
@@ -51,30 +51,6 @@ RSpec.describe Stat, type: :model do
end
end
- describe '.years' do
- subject { described_class.years }
-
- context 'when there are no stats' do
- it 'returns years' do
- expect(subject).to eq([Time.current.year])
- end
- end
-
- context 'when there are stats' do
- let(:user) { create(:user) }
- let(:expected_years) { (year..Time.current.year).to_a.reverse }
-
- before do
- create(:stat, year: 2021, user:)
- create(:stat, year: 2020, user:)
- end
-
- it 'returns years' do
- expect(subject).to eq(expected_years)
- end
- end
- end
-
describe '#distance_by_day' do
subject { stat.distance_by_day }
@@ -146,5 +122,17 @@ RSpec.describe Stat, type: :model do
end
end
end
+
+ describe '#points' do
+ subject { stat.points.to_a }
+
+ let(:stat) { create(:stat, year:, month: 1, user:) }
+ let(:timestamp) { DateTime.new(year, 1, 1, 5, 0, 0) }
+ let!(:points) { create_list(:point, 3, user:, timestamp:) }
+
+ it 'returns points' do
+ expect(subject).to eq(points)
+ end
+ end
end
end
diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb
index c42d969f..adaf50cd 100644
--- a/spec/models/user_spec.rb
+++ b/spec/models/user_spec.rb
@@ -104,5 +104,15 @@ RSpec.describe User, type: :model do
expect(subject).to eq(1)
end
end
+
+ describe '#years_tracked' do
+ let!(:points) { create_list(:point, 3, user:, timestamp: DateTime.new(2024, 1, 1, 5, 0, 0)) }
+
+ subject { user.years_tracked }
+
+ it 'returns years tracked' do
+ expect(subject).to eq([2024])
+ end
+ end
end
end
From fc542abed3315781d23a4e26be71ffe482aa0be2 Mon Sep 17 00:00:00 2001
From: Eugene Burmakin
Date: Fri, 6 Dec 2024 17:47:08 +0100
Subject: [PATCH 40/51] Add a notification about telemetry being enabled
---
...206163450_create_telemetry_notification.rb | 43 +++++++++++++++++++
1 file changed, 43 insertions(+)
create mode 100644 db/data/20241206163450_create_telemetry_notification.rb
diff --git a/db/data/20241206163450_create_telemetry_notification.rb b/db/data/20241206163450_create_telemetry_notification.rb
new file mode 100644
index 00000000..bd5f6dd2
--- /dev/null
+++ b/db/data/20241206163450_create_telemetry_notification.rb
@@ -0,0 +1,43 @@
+# frozen_string_literal: true
+
+class CreateTelemetryNotification < ActiveRecord::Migration[7.2]
+ def up
+ User.find_each do |user|
+ Notifications::Create.new(
+ user:, kind: :info, title: 'Telemetry enabled', content: notification_content
+ ).call
+ end
+ end
+
+ def down
+ raise ActiveRecord::IrreversibleMigration
+ end
+
+ private
+
+ def notification_content
+ <<~CONTENT
+ With the release 0.19.2, Dawarich now can collect usage some metrics and send them to InfluxDB.
+
+ Before this release, the only metrics that could be somehow tracked by developers (only Freika, as of now) were the number of stars on GitHub and the overall number of docker images being pulled, across all versions of Dawarich, non-splittable by version. New in-app telemetry will allow us to track more granular metrics, allowing me to make decisions based on facts, not just guesses.
+
+ I'm aware about the privacy concerns, so I want to be very transparent about what data is being sent and how it's used.
+
+ Data being sent:
+
+
+ - Number of DAU (Daily Active Users)
+ - App version
+ - Instance ID (unique identifier of the Dawarich instance built by hashing the api key of the first user in the database)
+
+
+ The data is being sent to a InfluxDB instance hosted by me and won't be shared with anyone.
+
+ Basically this set of metrics allows me to see how many people are using Dawarich and what versions they are using. No other data is being sent, nor it gives me any knowledge about individual users or their data or activity.
+
+ The telemetry is enabled by default, but it can be disabled by setting DISABLE_TELEMETRY env var to true. The dataset might change in the future, but any changes will be documented here in the changelog and in every release as well as on the telemetry page of the website docs.
+
+ You can read more about it in the release page.
+ CONTENT
+ end
+end
From 542190fd631c208b7ea61169c148f45bff7773eb Mon Sep 17 00:00:00 2001
From: Eugene Burmakin
Date: Fri, 6 Dec 2024 17:48:15 +0100
Subject: [PATCH 41/51] Update changelog
---
CHANGELOG.md | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b67dd939..16b6f86d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -14,6 +14,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- List of years on the Map page is now being calculated based on user's points instead of stats. It's also being cached for 1 day due to the fact that it's usually a heavy operation based on the number of points.
- Reverse-geocoding points is now being performed in batches of 1,000 points to prevent memory exhaustion.
+### Added
+
+- In-app notification about telemetry being enabled.
+
# 0.19.2 - 2024-12-04
## The Telemetry release
From 8a5f5883baffaef061237e8b66394ba0d407f709 Mon Sep 17 00:00:00 2001
From: Sven Anders
Date: Mon, 9 Dec 2024 07:58:14 +0100
Subject: [PATCH 42/51] Add Source to thumbnail image. Perhabs a fix to #506
untested.
---
app/javascript/controllers/maps_controller.js | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/app/javascript/controllers/maps_controller.js b/app/javascript/controllers/maps_controller.js
index 338444cf..7c03fc47 100644
--- a/app/javascript/controllers/maps_controller.js
+++ b/app/javascript/controllers/maps_controller.js
@@ -802,7 +802,7 @@ export default class extends Controller {
createPhotoMarker(photo) {
if (!photo.exifInfo?.latitude || !photo.exifInfo?.longitude) return;
- const thumbnailUrl = `/api/v1/photos/${photo.id}/thumbnail.jpg?api_key=${this.apiKey}`;
+ const thumbnailUrl = `/api/v1/photos/${photo.id}/thumbnail.jpg?api_key=${this.apiKey}&source=${photo.source}`;
const icon = L.divIcon({
className: 'photo-marker',
From cc9cac685268bc6323699f416c2e76be4e171499 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Mon, 9 Dec 2024 14:43:15 +0000
Subject: [PATCH 43/51] Bump prometheus_exporter from 2.1.1 to 2.2.0
Bumps [prometheus_exporter](https://github.com/discourse/prometheus_exporter) from 2.1.1 to 2.2.0.
- [Release notes](https://github.com/discourse/prometheus_exporter/releases)
- [Changelog](https://github.com/discourse/prometheus_exporter/blob/main/CHANGELOG)
- [Commits](https://github.com/discourse/prometheus_exporter/compare/v2.1.1...v2.2.0)
---
updated-dependencies:
- dependency-name: prometheus_exporter
dependency-type: direct:production
update-type: version-update:semver-minor
...
Signed-off-by: dependabot[bot]
---
Gemfile.lock | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/Gemfile.lock b/Gemfile.lock
index 4d733069..cdff83dc 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -236,7 +236,7 @@ GEM
patience_diff (1.2.0)
optimist (~> 3.0)
pg (1.5.9)
- prometheus_exporter (2.1.1)
+ prometheus_exporter (2.2.0)
webrick
pry (0.14.2)
coderay (~> 1.1)
@@ -417,7 +417,7 @@ GEM
addressable (>= 2.8.0)
crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0)
- webrick (1.9.0)
+ webrick (1.9.1)
websocket-driver (0.7.6)
websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5)
From 47291db420a4a088e45fa89528012241a38be42b Mon Sep 17 00:00:00 2001
From: Volodymyr Dombrovskyi <9673665+rebelvg@users.noreply.github.com>
Date: Tue, 10 Dec 2024 01:42:50 +0200
Subject: [PATCH 44/51] Update How_to_install_Dawarich_using_Docker.md
updated docker compose setup readme
---
docs/How_to_install_Dawarich_using_Docker.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/docs/How_to_install_Dawarich_using_Docker.md b/docs/How_to_install_Dawarich_using_Docker.md
index 4a947f89..28a6c9c0 100644
--- a/docs/How_to_install_Dawarich_using_Docker.md
+++ b/docs/How_to_install_Dawarich_using_Docker.md
@@ -8,4 +8,4 @@ This command use [docker-compose.yml](../docker-compose.yml) to build your local
When this command done successfully and all services in containers will start you can open Dawarich web UI by this link [http://127.0.0.1:3000](http://127.0.0.1:3000).
-Default credentials for first login in are `user@domain.com` and `password`.
+Default credentials for first login in are `demo@dawarich.app` and `password`.
From b336172b31fce5157bed8055c46ff837a87e5afc Mon Sep 17 00:00:00 2001
From: Eugene Burmakin
Date: Tue, 10 Dec 2024 18:49:37 +0100
Subject: [PATCH 45/51] Show photoprism photos on a trip page
---
app/helpers/application_helper.rb | 13 +++++++
app/models/import.rb | 5 ++-
app/models/trip.rb | 52 +++++++++++++++++--------
app/serializers/api/photo_serializer.rb | 10 +++++
app/views/trips/show.html.erb | 10 +++--
5 files changed, 68 insertions(+), 22 deletions(-)
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index 5495369b..8cc09c1c 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -120,4 +120,17 @@ module ApplicationHelper
encoded_query = URI.encode_www_form_component(query.to_json)
"#{base_url}/search?query=#{encoded_query}"
end
+
+ def photoprism_search_url(base_url, start_date, _end_date)
+ "#{base_url}/library/browse?view=cards&year=#{start_date.year}&month=#{start_date.month}&order=newest&public=true&quality=3"
+ end
+
+ def photo_search_url(source, settings, start_date, end_date)
+ case source
+ when 'immich'
+ immich_search_url(settings['immich_url'], start_date, end_date)
+ when 'photoprism'
+ photoprism_search_url(settings['photoprism_url'], start_date, end_date)
+ end
+ end
end
diff --git a/app/models/import.rb b/app/models/import.rb
index 2040d738..a0fbc870 100644
--- a/app/models/import.rb
+++ b/app/models/import.rb
@@ -18,8 +18,9 @@ class Import < ApplicationRecord
end
def years_and_months_tracked
- points.order(:timestamp).map do |point|
- [Time.zone.at(point.timestamp).year, Time.zone.at(point.timestamp).month]
+ points.order(:timestamp).pluck(:timestamp).map do |timestamp|
+ time = Time.zone.at(timestamp)
+ [time.year, time.month]
end.uniq
end
end
diff --git a/app/models/trip.rb b/app/models/trip.rb
index 52948bf4..7a0fdaba 100644
--- a/app/models/trip.rb
+++ b/app/models/trip.rb
@@ -18,25 +18,15 @@ class Trip < ApplicationRecord
end
def photos
- return [] if user.settings['immich_url'].blank? || user.settings['immich_api_key'].blank?
+ return [] unless can_fetch_photos?
- immich_photos = Immich::RequestPhotos.new(
- user,
- start_date: started_at.to_date.to_s,
- end_date: ended_at.to_date.to_s
- ).call.reject { |asset| asset['type'].downcase == 'video' }
+ filtered_photos.sample(12)
+ .sort_by { |photo| photo['localDateTime'] }
+ .map { |asset| photo_thumbnail(asset) }
+ end
- # let's count what photos are more: vertical or horizontal and select the ones that are more
- vertical_photos = immich_photos.select { _1['exifInfo']['orientation'] == '6' }
- horizontal_photos = immich_photos.select { _1['exifInfo']['orientation'] == '3' }
-
- # this is ridiculous, but I couldn't find my way around frontend
- # to show all photos in the same height
- photos = vertical_photos.count > horizontal_photos.count ? vertical_photos : horizontal_photos
-
- photos.sample(12).sort_by { _1['localDateTime'] }.map do |asset|
- { url: "/api/v1/photos/#{asset['id']}/thumbnail.jpg?api_key=#{user.api_key}" }
- end
+ def photos_sources
+ filtered_photos.map { _1[:source] }.uniq
end
private
@@ -54,4 +44,32 @@ class Trip < ApplicationRecord
self.distance = distance.round
end
+
+ def can_fetch_photos?
+ user.immich_integration_configured? || user.photoprism_integration_configured?
+ end
+
+ def filtered_photos
+ return @filtered_photos if defined?(@filtered_photos)
+
+ photos = Photos::Search.new(
+ user,
+ start_date: started_at.to_date.to_s,
+ end_date: ended_at.to_date.to_s
+ ).call
+
+ @filtered_photos = select_dominant_orientation(photos)
+ end
+
+ def select_dominant_orientation(photos)
+ vertical_photos = photos.select { |photo| photo[:orientation] == 'portrait' }
+ horizontal_photos = photos.select { |photo| photo[:orientation] == 'landscape' }
+
+ vertical_photos.count > horizontal_photos.count ? vertical_photos : horizontal_photos
+ end
+
+ def photo_thumbnail(asset)
+ { url: "/api/v1/photos/#{asset[:id]}/thumbnail.jpg?api_key=#{user.api_key}&source=#{asset[:source]}" }
+ end
end
+
diff --git a/app/serializers/api/photo_serializer.rb b/app/serializers/api/photo_serializer.rb
index 5e3ce9a5..c0a1119a 100644
--- a/app/serializers/api/photo_serializer.rb
+++ b/app/serializers/api/photo_serializer.rb
@@ -17,6 +17,7 @@ class Api::PhotoSerializer
state: state,
country: country,
type: type,
+ orientation: orientation,
source: source
}
end
@@ -60,4 +61,13 @@ class Api::PhotoSerializer
def type
(photo['type'] || photo['Type']).downcase
end
+
+ def orientation
+ case source
+ when 'immich'
+ photo.dig('exifInfo', 'orientation') == '6' ? 'portrait' : 'landscape'
+ when 'photoprism'
+ photo['Portrait'] ? 'portrait' : 'landscape'
+ end
+ end
end
diff --git a/app/views/trips/show.html.erb b/app/views/trips/show.html.erb
index 5cc00ce6..d0b265fc 100644
--- a/app/views/trips/show.html.erb
+++ b/app/views/trips/show.html.erb
@@ -52,9 +52,13 @@
<% end %>
<% end %>
-
- <%= link_to "More photos on Immich", immich_search_url(current_user.settings['immich_url'], @trip.started_at, @trip.ended_at), class: "btn btn-primary", target: '_blank' %>
-
+ <% if @trip.photos_sources.any? %>
+
+ <% @trip.photos_sources.each do |source| %>
+ <%= link_to "More photos on #{source}", photo_search_url(source, current_user.settings, @trip.started_at, @trip.ended_at), class: "btn btn-primary mt-2", target: '_blank' %>
+ <% end %>
+
+ <% end %>
From d6b88ae9cb5f873fa44bb39904e322d040b088ea Mon Sep 17 00:00:00 2001
From: Eugene Burmakin
Date: Tue, 10 Dec 2024 19:31:52 +0100
Subject: [PATCH 46/51] Move photos fetching for trips to a separate service
---
.app_version | 2 +-
CHANGELOG.md | 12 ++++
app/controllers/trips_controller.rb | 5 +-
app/helpers/application_helper.rb | 23 ------
app/helpers/trips_helper.rb | 26 +++++++
app/models/trip.rb | 51 ++++---------
app/services/immich/request_photos.rb | 10 +--
app/services/trips/photos.rb | 43 +++++++++++
app/views/trips/show.html.erb | 8 +--
spec/models/trip_spec.rb | 20 ++++--
spec/requests/trips_spec.rb | 2 +-
spec/serializers/api/photo_serializer_spec.rb | 2 +
spec/services/photos/search_spec.rb | 6 +-
spec/services/trips/photos_spec.rb | 72 +++++++++++++++++++
spec/swagger/api/v1/photos_controller_spec.rb | 4 +-
swagger/v1/swagger.yaml | 7 ++
16 files changed, 209 insertions(+), 84 deletions(-)
create mode 100644 app/helpers/trips_helper.rb
create mode 100644 app/services/trips/photos.rb
create mode 100644 spec/services/trips/photos_spec.rb
diff --git a/.app_version b/.app_version
index b72b05ed..c0b8d590 100644
--- a/.app_version
+++ b/.app_version
@@ -1 +1 @@
-0.19.3
+0.19.4
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 16b6f86d..19dcf086 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,18 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/)
and this project adheres to [Semantic Versioning](http://semver.org/).
+# 0.19.4 - 2024-12-10
+
+### Fixed
+
+- Fixed a bug where the Photoprism photos were not being shown on the trip page.
+- Fixed a bug where the Immich photos were not being shown on the trip page.
+
+### Added
+
+- A link to the Photoprism photos on the trip page if there are any.
+- A `orientation` field in the Api::PhotoSerializer, hence the `GET /api/v1/photos` endpoint now includes the orientation of the photo. Valid values are `portrait` and `landscape`.
+
# 0.19.3 - 2024-12-06
### Changed
diff --git a/app/controllers/trips_controller.rb b/app/controllers/trips_controller.rb
index 97492e74..2a9a26d2 100644
--- a/app/controllers/trips_controller.rb
+++ b/app/controllers/trips_controller.rb
@@ -15,9 +15,10 @@ class TripsController < ApplicationController
:country
).map { [_1.to_f, _2.to_f, _3.to_s, _4.to_s, _5.to_s, _6.to_s, _7.to_s, _8.to_s] }
- @photos = Rails.cache.fetch("trip_photos_#{@trip.id}", expires_in: 1.day) do
- @trip.photos
+ @photo_previews = Rails.cache.fetch("trip_photos_#{@trip.id}", expires_in: 1.day) do
+ @trip.photo_previews
end
+ @photo_sources = @trip.photo_sources
end
def new
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index 8cc09c1c..3fe89204 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -110,27 +110,4 @@ module ApplicationHelper
def human_date(date)
date.strftime('%e %B %Y')
end
-
- def immich_search_url(base_url, start_date, end_date)
- query = {
- takenAfter: "#{start_date.to_date}T00:00:00.000Z",
- takenBefore: "#{end_date.to_date}T23:59:59.999Z"
- }
-
- encoded_query = URI.encode_www_form_component(query.to_json)
- "#{base_url}/search?query=#{encoded_query}"
- end
-
- def photoprism_search_url(base_url, start_date, _end_date)
- "#{base_url}/library/browse?view=cards&year=#{start_date.year}&month=#{start_date.month}&order=newest&public=true&quality=3"
- end
-
- def photo_search_url(source, settings, start_date, end_date)
- case source
- when 'immich'
- immich_search_url(settings['immich_url'], start_date, end_date)
- when 'photoprism'
- photoprism_search_url(settings['photoprism_url'], start_date, end_date)
- end
- end
end
diff --git a/app/helpers/trips_helper.rb b/app/helpers/trips_helper.rb
new file mode 100644
index 00000000..fa0b77ae
--- /dev/null
+++ b/app/helpers/trips_helper.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+module TripsHelper
+ def immich_search_url(base_url, start_date, end_date)
+ query = {
+ takenAfter: "#{start_date.to_date}T00:00:00.000Z",
+ takenBefore: "#{end_date.to_date}T23:59:59.999Z"
+ }
+
+ encoded_query = URI.encode_www_form_component(query.to_json)
+ "#{base_url}/search?query=#{encoded_query}"
+ end
+
+ def photoprism_search_url(base_url, start_date, _end_date)
+ "#{base_url}/library/browse?view=cards&year=#{start_date.year}&month=#{start_date.month}&order=newest&public=true&quality=3"
+ end
+
+ def photo_search_url(source, settings, start_date, end_date)
+ case source
+ when 'immich'
+ immich_search_url(settings['immich_url'], start_date, end_date)
+ when 'photoprism'
+ photoprism_search_url(settings['photoprism_url'], start_date, end_date)
+ end
+ end
+end
diff --git a/app/models/trip.rb b/app/models/trip.rb
index 7a0fdaba..e75103c2 100644
--- a/app/models/trip.rb
+++ b/app/models/trip.rb
@@ -17,20 +17,27 @@ class Trip < ApplicationRecord
points.pluck(:country).uniq.compact
end
- def photos
- return [] unless can_fetch_photos?
-
- filtered_photos.sample(12)
- .sort_by { |photo| photo['localDateTime'] }
- .map { |asset| photo_thumbnail(asset) }
+ def photo_previews
+ @photo_previews ||= select_dominant_orientation(photos).sample(12)
end
- def photos_sources
- filtered_photos.map { _1[:source] }.uniq
+ def photo_sources
+ @photo_sources ||= photos.map { _1[:source] }.uniq
end
private
+ def photos
+ @photos ||= Trips::Photos.new(self, user).call
+ end
+
+ def select_dominant_orientation(photos)
+ vertical_photos = photos.select { |photo| photo[:orientation] == 'portrait' }
+ horizontal_photos = photos.select { |photo| photo[:orientation] == 'landscape' }
+
+ vertical_photos.count > horizontal_photos.count ? vertical_photos : horizontal_photos
+ end
+
def calculate_distance
distance = 0
@@ -44,32 +51,4 @@ class Trip < ApplicationRecord
self.distance = distance.round
end
-
- def can_fetch_photos?
- user.immich_integration_configured? || user.photoprism_integration_configured?
- end
-
- def filtered_photos
- return @filtered_photos if defined?(@filtered_photos)
-
- photos = Photos::Search.new(
- user,
- start_date: started_at.to_date.to_s,
- end_date: ended_at.to_date.to_s
- ).call
-
- @filtered_photos = select_dominant_orientation(photos)
- end
-
- def select_dominant_orientation(photos)
- vertical_photos = photos.select { |photo| photo[:orientation] == 'portrait' }
- horizontal_photos = photos.select { |photo| photo[:orientation] == 'landscape' }
-
- vertical_photos.count > horizontal_photos.count ? vertical_photos : horizontal_photos
- end
-
- def photo_thumbnail(asset)
- { url: "/api/v1/photos/#{asset[:id]}/thumbnail.jpg?api_key=#{user.api_key}&source=#{asset[:source]}" }
- end
end
-
diff --git a/app/services/immich/request_photos.rb b/app/services/immich/request_photos.rb
index 034a6452..59baa496 100644
--- a/app/services/immich/request_photos.rb
+++ b/app/services/immich/request_photos.rb
@@ -37,15 +37,7 @@ class Immich::RequestPhotos
items = response.dig('assets', 'items')
- if items.blank?
- Rails.logger.debug('==== IMMICH RESPONSE WITH NO ITEMS ====')
- Rails.logger.debug("START_DATE: #{start_date}")
- Rails.logger.debug("END_DATE: #{end_date}")
- Rails.logger.debug(response)
- Rails.logger.debug('==== IMMICH RESPONSE WITH NO ITEMS ====')
-
- break
- end
+ break if items.blank?
data << items
diff --git a/app/services/trips/photos.rb b/app/services/trips/photos.rb
new file mode 100644
index 00000000..33442833
--- /dev/null
+++ b/app/services/trips/photos.rb
@@ -0,0 +1,43 @@
+# frozen_string_literal: true
+
+class Trips::Photos
+ def initialize(trip, user)
+ @trip = trip
+ @user = user
+ end
+
+ def call
+ return [] unless can_fetch_photos?
+
+ photos
+ end
+
+ private
+
+ attr_reader :trip, :user
+
+ def can_fetch_photos?
+ user.immich_integration_configured? || user.photoprism_integration_configured?
+ end
+
+ def photos
+ return @photos if defined?(@photos)
+
+ photos = Photos::Search.new(
+ user,
+ start_date: trip.started_at.to_date.to_s,
+ end_date: trip.ended_at.to_date.to_s
+ ).call
+
+ @photos = photos.map { |photo| photo_thumbnail(photo) }
+ end
+
+ def photo_thumbnail(asset)
+ {
+ id: asset[:id],
+ url: "/api/v1/photos/#{asset[:id]}/thumbnail.jpg?api_key=#{user.api_key}&source=#{asset[:source]}",
+ source: asset[:source],
+ orientation: asset[:orientation]
+ }
+ end
+end
diff --git a/app/views/trips/show.html.erb b/app/views/trips/show.html.erb
index d0b265fc..f399eb3f 100644
--- a/app/views/trips/show.html.erb
+++ b/app/views/trips/show.html.erb
@@ -36,8 +36,8 @@
- <% if @photos.any? %>
- <% @photos.each_slice(4) do |slice| %>
+ <% if @photo_previews.any? %>
+ <% @photo_previews.each_slice(4) do |slice| %>