From 12ac078205e0031df91a2618e58df591e6d46625 Mon Sep 17 00:00:00 2001 From: Nils <695449+poldixd@users.noreply.github.com> Date: Sun, 1 Dec 2024 13:57:17 +0100 Subject: [PATCH 01/51] Reset z-index on leaflet map --- app/views/map/index.html.erb | 2 +- app/views/trips/show.html.erb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/views/map/index.html.erb b/app/views/map/index.html.erb index ee3ae13f..75788972 100644 --- a/app/views/map/index.html.erb +++ b/app/views/map/index.html.erb @@ -44,7 +44,7 @@
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 @@
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| %>
<% slice.each do |photo| %>
@@ -52,9 +52,9 @@ <% end %> <% end %> - <% if @trip.photos_sources.any? %> + <% if @photo_sources.any? %>
- <% @trip.photos_sources.each do |source| %> + <% @photo_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 %>
diff --git a/spec/models/trip_spec.rb b/spec/models/trip_spec.rb index b19f348c..0638d781 100644 --- a/spec/models/trip_spec.rb +++ b/spec/models/trip_spec.rb @@ -41,7 +41,7 @@ RSpec.describe Trip, type: :model do end end - describe '#photos' do + describe '#photo_previews' do let(:photo_data) do [ { @@ -80,8 +80,18 @@ RSpec.describe Trip, type: :model do let(:trip) { create(:trip, user:) } let(:expected_photos) do [ - { url: "/api/v1/photos/456/thumbnail.jpg?api_key=#{user.api_key}" }, - { url: "/api/v1/photos/789/thumbnail.jpg?api_key=#{user.api_key}" } + { + id: '456', + url: "/api/v1/photos/456/thumbnail.jpg?api_key=#{user.api_key}&source=immich", + source: 'immich', + orientation: 'portrait' + }, + { + id: '789', + url: "/api/v1/photos/789/thumbnail.jpg?api_key=#{user.api_key}&source=immich", + source: 'immich', + orientation: 'portrait' + } ] end @@ -93,7 +103,7 @@ RSpec.describe Trip, type: :model do let(:settings) { {} } it 'returns an empty array' do - expect(trip.photos).to eq([]) + expect(trip.photo_previews).to eq([]) end end @@ -106,7 +116,7 @@ RSpec.describe Trip, type: :model do end it 'returns the photos' do - expect(trip.photos).to eq(expected_photos) + expect(trip.photo_previews).to eq(expected_photos) end end end diff --git a/spec/requests/trips_spec.rb b/spec/requests/trips_spec.rb index 20daa652..d0e1e794 100644 --- a/spec/requests/trips_spec.rb +++ b/spec/requests/trips_spec.rb @@ -25,7 +25,7 @@ RSpec.describe '/trips', type: :request do stub_request(:any, 'https://api.github.com/repos/Freika/dawarich/tags') .to_return(status: 200, body: '[{"name": "1.0.0"}]', headers: {}) - allow_any_instance_of(Trip).to receive(:photos).and_return([]) + allow_any_instance_of(Trip).to receive(:photo_previews).and_return([]) sign_in user end diff --git a/spec/serializers/api/photo_serializer_spec.rb b/spec/serializers/api/photo_serializer_spec.rb index 3dad077a..7d354d16 100644 --- a/spec/serializers/api/photo_serializer_spec.rb +++ b/spec/serializers/api/photo_serializer_spec.rb @@ -73,6 +73,7 @@ RSpec.describe Api::PhotoSerializer do state: 'Berlin', country: 'Germany', type: 'image', + orientation: 'portrait', source: 'immich' ) end @@ -152,6 +153,7 @@ RSpec.describe Api::PhotoSerializer do state: 'Unknown', country: 'zz', type: 'image', + orientation: 'landscape', source: 'photoprism' ) end diff --git a/spec/services/photos/search_spec.rb b/spec/services/photos/search_spec.rb index 0ce34613..ee451597 100644 --- a/spec/services/photos/search_spec.rb +++ b/spec/services/photos/search_spec.rb @@ -74,7 +74,8 @@ RSpec.describe Photos::Search do state: nil, country: nil, type: 'image', - source: 'immich' + source: 'immich', + orientation: 'landscape' } end let(:serialized_photoprism) do @@ -88,7 +89,8 @@ RSpec.describe Photos::Search do state: nil, country: nil, type: 'image', - source: 'photoprism' + source: 'photoprism', + orientation: 'landscape' } end diff --git a/spec/services/trips/photos_spec.rb b/spec/services/trips/photos_spec.rb new file mode 100644 index 00000000..abe9f52b --- /dev/null +++ b/spec/services/trips/photos_spec.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Trips::Photos do + let(:user) { instance_double('User') } + let(:trip) { instance_double('Trip', started_at: Date.new(2024, 1, 1), ended_at: Date.new(2024, 1, 7)) } + let(:service) { described_class.new(trip, user) } + + describe '#call' do + context 'when user has no photo 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 photo integrations configured' do + let(:photo_search) { instance_double('Photos::Search') } + let(:raw_photos) do + [ + { + id: 1, + url: '/api/v1/photos/1/thumbnail.jpg?api_key=test-api-key&source=immich', + source: 'immich', + orientation: 'landscape' + }, + { + id: 2, + url: '/api/v1/photos/2/thumbnail.jpg?api_key=test-api-key&source=photoprism', + source: 'photoprism', + orientation: 'portrait' + } + ] + end + + before do + allow(user).to receive(:immich_integration_configured?).and_return(true) + allow(user).to receive(:photoprism_integration_configured?).and_return(false) + allow(user).to receive(:api_key).and_return('test-api-key') + + allow(Photos::Search).to receive(:new) + .with(user, start_date: '2024-01-01', end_date: '2024-01-07') + .and_return(photo_search) + allow(photo_search).to receive(:call).and_return(raw_photos) + end + + it 'returns formatted photo thumbnails' do + expected_result = [ + { + id: 1, + url: '/api/v1/photos/1/thumbnail.jpg?api_key=test-api-key&source=immich', + source: 'immich', + orientation: 'landscape' + }, + { + id: 2, + url: '/api/v1/photos/2/thumbnail.jpg?api_key=test-api-key&source=photoprism', + source: 'photoprism', + orientation: 'portrait' + } + ] + + expect(service.call).to eq(expected_result) + end + end + end +end diff --git a/spec/swagger/api/v1/photos_controller_spec.rb b/spec/swagger/api/v1/photos_controller_spec.rb index eef5d9a5..2c375ef1 100644 --- a/spec/swagger/api/v1/photos_controller_spec.rb +++ b/spec/swagger/api/v1/photos_controller_spec.rb @@ -111,6 +111,7 @@ RSpec.describe 'Api::V1::PhotosController', type: :request do state: { type: :string }, country: { type: :string }, type: { type: :string }, + orientation: { type: :string }, source: { type: :string } }, required: %w[id latitude longitude localDateTime originalFileName city state country type source] @@ -143,7 +144,8 @@ RSpec.describe 'Api::V1::PhotosController', type: :request do state: { type: :string }, country: { type: :string }, type: { type: :string }, - source: { type: :string } + source: { type: :string }, + orientation: { type: :string, enum: %w[portrait landscape] } } let(:id) { '7fe486e3-c3ba-4b54-bbf9-1281b39ed15c' } diff --git a/swagger/v1/swagger.yaml b/swagger/v1/swagger.yaml index 6657aebf..49d8ef24 100644 --- a/swagger/v1/swagger.yaml +++ b/swagger/v1/swagger.yaml @@ -366,6 +366,8 @@ paths: type: string type: type: string + orientation: + type: string source: type: string required: @@ -431,6 +433,11 @@ paths: type: string source: type: string + orientation: + type: string + enum: + - portrait + - landscape '404': description: photo not found "/api/v1/points": From dbb737a0c45b2edb55c1af2accde44714f43df7f Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Tue, 10 Dec 2024 19:43:52 +0100 Subject: [PATCH 47/51] Update swagger docs and changelog --- CHANGELOG.md | 21 +++++++++++++++++++ app/controllers/map_controller.rb | 4 +--- app/models/trip.rb | 8 +++---- app/models/visit.rb | 4 +++- spec/models/trip_spec.rb | 4 +++- spec/swagger/api/v1/photos_controller_spec.rb | 12 +++++------ swagger/v1/swagger.yaml | 19 +++++++++++++++-- 7 files changed, 54 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 19dcf086..9b5c7252 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](http://semver.org/). # 0.19.4 - 2024-12-10 +⚠️ This release introduces a breaking change. ⚠️ + +The `GET /api/v1/trips/:id/photos` endpoint now returns a different structure of the response: + +```diff +{ + id: 1, + latitude: 10, + longitude: 10, + localDateTime: "2024-01-01T00:00:00Z", + originalFileName: "photo.jpg", + city: "Berlin", + state: "Berlin", + country: "Germany", + type: "image", ++ orientation: "portrait", + source: "photoprism" +} +``` + ### Fixed - Fixed a bug where the Photoprism photos were not being shown on the trip page. @@ -16,6 +36,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - 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`. +- Examples for the `type`, `orientation` and `source` fields in the `GET /api/v1/photos` endpoint in the Swagger UI. # 0.19.3 - 2024-12-06 diff --git a/app/controllers/map_controller.rb b/app/controllers/map_controller.rb index a0eb6e08..93657bd4 100644 --- a/app/controllers/map_controller.rb +++ b/app/controllers/map_controller.rb @@ -36,9 +36,7 @@ class MapController < ApplicationController @distance ||= 0 @coordinates.each_cons(2) do - @distance += Geocoder::Calculations.distance_between( - [_1[0], _1[1]], [_2[0], _2[1]], units: DISTANCE_UNIT - ) + @distance += DistanceCalculator.new([_1[0], _1[1]], [_2[0], _2[1]]).call end @distance.round(1) diff --git a/app/models/trip.rb b/app/models/trip.rb index e75103c2..4a2b0302 100644 --- a/app/models/trip.rb +++ b/app/models/trip.rb @@ -35,6 +35,8 @@ class Trip < ApplicationRecord vertical_photos = photos.select { |photo| photo[:orientation] == 'portrait' } horizontal_photos = photos.select { |photo| photo[:orientation] == 'landscape' } + # this is ridiculous, but I couldn't find my way around frontend + # to show all photos in the same height vertical_photos.count > horizontal_photos.count ? vertical_photos : horizontal_photos end @@ -42,11 +44,7 @@ class Trip < ApplicationRecord distance = 0 points.each_cons(2) do |point1, point2| - distance_between = Geocoder::Calculations.distance_between( - point1.to_coordinates, point2.to_coordinates, units: ::DISTANCE_UNIT - ) - - distance += distance_between + distance += DistanceCalculator.new(point1, point2).call end self.distance = distance.round diff --git a/app/models/visit.rb b/app/models/visit.rb index 2ca496ab..bfd5b3d0 100644 --- a/app/models/visit.rb +++ b/app/models/visit.rb @@ -28,7 +28,9 @@ class Visit < ApplicationRecord def default_radius return area&.radius if area.present? - radius = points.map { Geocoder::Calculations.distance_between(center, [_1.latitude, _1.longitude]) }.max + radius = points.map do |point| + DistanceCalculator.new(center, [point.latitude, point.longitude]).call + end.max radius && radius >= 15 ? radius : 15 end diff --git a/spec/models/trip_spec.rb b/spec/models/trip_spec.rb index 0638d781..032185bd 100644 --- a/spec/models/trip_spec.rb +++ b/spec/models/trip_spec.rb @@ -116,7 +116,9 @@ RSpec.describe Trip, type: :model do end it 'returns the photos' do - expect(trip.photo_previews).to eq(expected_photos) + expect(trip.photo_previews).to include(expected_photos[0]) + expect(trip.photo_previews).to include(expected_photos[1]) + expect(trip.photo_previews.size).to eq(2) end end end diff --git a/spec/swagger/api/v1/photos_controller_spec.rb b/spec/swagger/api/v1/photos_controller_spec.rb index 2c375ef1..d7c4de4c 100644 --- a/spec/swagger/api/v1/photos_controller_spec.rb +++ b/spec/swagger/api/v1/photos_controller_spec.rb @@ -110,9 +110,9 @@ RSpec.describe 'Api::V1::PhotosController', type: :request do city: { type: :string }, state: { type: :string }, country: { type: :string }, - type: { type: :string }, - orientation: { type: :string }, - source: { type: :string } + type: { type: :string, enum: %w[image video] }, + orientation: { type: :string, enum: %w[portrait landscape] }, + source: { type: :string, enum: %w[immich photoprism] } }, required: %w[id latitude longitude localDateTime originalFileName city state country type source] } @@ -143,9 +143,9 @@ RSpec.describe 'Api::V1::PhotosController', type: :request do city: { type: :string }, state: { type: :string }, country: { type: :string }, - type: { type: :string }, - source: { type: :string }, - orientation: { type: :string, enum: %w[portrait landscape] } + type: { type: :string, enum: %w[image video] }, + orientation: { type: :string, enum: %w[portrait landscape] }, + source: { type: :string, enum: %w[immich photoprism] } } let(:id) { '7fe486e3-c3ba-4b54-bbf9-1281b39ed15c' } diff --git a/swagger/v1/swagger.yaml b/swagger/v1/swagger.yaml index 49d8ef24..f6072149 100644 --- a/swagger/v1/swagger.yaml +++ b/swagger/v1/swagger.yaml @@ -366,10 +366,19 @@ paths: type: string type: type: string + enum: + - image + - video orientation: type: string + enum: + - portrait + - landscape source: type: string + enum: + - immich + - photoprism required: - id - latitude @@ -431,13 +440,19 @@ paths: type: string type: type: string - source: - type: string + enum: + - image + - video orientation: type: string enum: - portrait - landscape + source: + type: string + enum: + - immich + - photoprism '404': description: photo not found "/api/v1/points": From 14caeb61878130af094f7cfadb9f747e5ecf20e7 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Tue, 10 Dec 2024 19:45:00 +0100 Subject: [PATCH 48/51] Update changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b5c7252..efa3cfdb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,7 +22,7 @@ The `GET /api/v1/trips/:id/photos` endpoint now returns a different structure of state: "Berlin", country: "Germany", type: "image", -+ orientation: "portrait", ++ orientation: "portrait", source: "photoprism" } ``` From 58062f652198045d48e45bdd0c98293ac99b502c Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Tue, 10 Dec 2024 19:55:59 +0100 Subject: [PATCH 49/51] Include raw, live and animated photos in the Swagger UI --- spec/swagger/api/v1/photos_controller_spec.rb | 2 +- swagger/v1/swagger.yaml | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/spec/swagger/api/v1/photos_controller_spec.rb b/spec/swagger/api/v1/photos_controller_spec.rb index d7c4de4c..16a9e85b 100644 --- a/spec/swagger/api/v1/photos_controller_spec.rb +++ b/spec/swagger/api/v1/photos_controller_spec.rb @@ -143,7 +143,7 @@ RSpec.describe 'Api::V1::PhotosController', type: :request do city: { type: :string }, state: { type: :string }, country: { type: :string }, - type: { type: :string, enum: %w[image video] }, + type: { type: :string, enum: %w[IMAGE VIDEO image video raw live animated] }, orientation: { type: :string, enum: %w[portrait landscape] }, source: { type: :string, enum: %w[immich photoprism] } } diff --git a/swagger/v1/swagger.yaml b/swagger/v1/swagger.yaml index f6072149..9e6a57dd 100644 --- a/swagger/v1/swagger.yaml +++ b/swagger/v1/swagger.yaml @@ -441,8 +441,13 @@ paths: type: type: string enum: + - IMAGE + - VIDEO - image - video + - raw + - live + - animated orientation: type: string enum: From 6dd4ab3c160a2887a44d036005e135c737e71722 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Tue, 10 Dec 2024 20:06:34 +0100 Subject: [PATCH 50/51] Add DISABLE_TELEMETRY env var to disable telemetry --- CHANGELOG.md | 1 + docker-compose.yml | 2 ++ 2 files changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index efa3cfdb..a21d993e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,7 @@ The `GET /api/v1/trips/:id/photos` endpoint now returns a different structure of - 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`. - Examples for the `type`, `orientation` and `source` fields in the `GET /api/v1/photos` endpoint in the Swagger UI. +- `DISABLE_TELEMETRY` env var to disable telemetry. More on telemetry: https://dawarich.app/docs/tutorials/telemetry # 0.19.3 - 2024-12-06 diff --git a/docker-compose.yml b/docker-compose.yml index 034172a5..c04194f0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -69,6 +69,7 @@ services: PROMETHEUS_EXPORTER_ENABLED: false PROMETHEUS_EXPORTER_HOST: 0.0.0.0 PROMETHEUS_EXPORTER_PORT: 9394 + DISABLE_TELEMETRY: false # More on telemetry: https://dawarich.app/docs/tutorials/telemetry logging: driver: "json-file" options: @@ -123,6 +124,7 @@ services: PROMETHEUS_EXPORTER_ENABLED: false PROMETHEUS_EXPORTER_HOST: dawarich_app PROMETHEUS_EXPORTER_PORT: 9394 + DISABLE_TELEMETRY: false # More on telemetry: https://dawarich.app/docs/tutorials/telemetry logging: driver: "json-file" options: From 72ebcda68287cb2799d1cba4702f3e48a8f2aee4 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Tue, 10 Dec 2024 20:11:02 +0100 Subject: [PATCH 51/51] Update CHANGELOG --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a21d993e..c907d7c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,7 @@ The `GET /api/v1/trips/:id/photos` endpoint now returns a different structure of - 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. +- Fixed a bug where the route popup was showing distance in kilometers instead of miles. #490 ### Added