diff --git a/.app_version b/.app_version index 48b91fd8..d21d277b 100644 --- a/.app_version +++ b/.app_version @@ -1 +1 @@ -0.24.1 +0.25.0 diff --git a/.gitignore b/.gitignore index 9583fb0a..b3a85915 100644 --- a/.gitignore +++ b/.gitignore @@ -63,3 +63,5 @@ .ash_history .cache/ .dotnet/ +.cursorrules +.cursormemory.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 0aab7b37..90fa2c7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,70 @@ 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.25.0 - 2025-03-09 + +This release is focused on improving the visits experience. + +Since previous implementation of visits was not working as expected, this release introduces a new approach. It is recommended to remove all _non-confirmed_ visits before or after updating to this version. + +There is a known issue when data migrations are not being run automatically on some systems. If you're experiencing issues when opening map page, trips page or when trying to see visits, try executing the following command in the [Console](https://dawarich.app/docs/FAQ/#how-to-enter-dawarich-console): + +```ruby +User.includes(:tracked_points, visits: :places).find_each do |user| + places_to_update = user.places.where(lonlat: nil) + + # For each place, set the lonlat value based on longitude and latitude + places_to_update.find_each do |place| + next if place.longitude.nil? || place.latitude.nil? + + # Set the lonlat to a PostGIS point with the proper SRID + # rubocop:disable Rails/SkipsModelValidations + place.update_column(:lonlat, "SRID=4326;POINT(#{place.longitude} #{place.latitude})") + # rubocop:enable Rails/SkipsModelValidations + end + + user.tracked_points.update_all('lonlat = ST_SetSRID(ST_MakePoint(longitude, latitude), 4326)') +end +``` + +With any errors, don't hesitate to ask for help in the [Discord server](https://discord.gg/pHsBjpt5J8). + + + +## Added + +- A new button to open the visits drawer. +- User can now confirm or decline visits directly from the visits drawer. +- Visits are now being shown on the map: orange circles for suggested visits and slightly bigger blue circles for confirmed visits. +- User can click on a visit circle to rename it and select a place for it. +- User can click on a visit card in the drawer panel to move to it on the map. +- User can select click on the "Select area" button in the top right corner of the map to select an area on the map. Once area is selected, visits for all times in that area will be shown on the map, regardless of whether they are in the selected time range or not. +- User can now select two or more visits in the visits drawer and merge them into a single visit. This operation is not reversible. +- User can now select two or more visits in the visits drawer and confirm or decline them at once. This operation is not reversible. +- Status field to the User model. Inactive users are now being restricted from accessing some of the functionality, which is mostly about writing data to the database. Reading is remaining unrestricted. +- After user is created, a sample import is being created for them to demonstrate how to use the app. + + +## Changed + +- Links to Points, Visits & Places, Imports and Exports were moved under "My data" section in the navbar. +- Restrict access to Sidekiq in non self-hosted mode. +- Restrict access to background jobs in non self-hosted mode. +- Restrict access to users management in non self-hosted mode. +- Restrict access to API for inactive users. +- All users in self-hosted mode are active by default. +- Points are now using `lonlat` column for storing longitude and latitude. +- Semantic history points are now being imported much faster. +- GPX files are now being imported much faster. +- Trips, places and points are now using PostGIS' database attributes for storing longitude and latitude. +- Distance calculation are now using Postgis functions and expected to be more accurate. + +## Fixed + +- Fixed a bug where non-admin users could not import Immich and Photoprism geolocation data. +- Fixed a bug where upon point deletion it was not being removed from the map, while it was actually deleted from the database. #883 +- Fixed a bug where upon import deletion stats were not being recalculated. #824 + # 0.24.1 - 2025-02-13 ## Custom map tiles diff --git a/Gemfile b/Gemfile index 592c2fd3..4ed5dad3 100644 --- a/Gemfile +++ b/Gemfile @@ -9,7 +9,7 @@ gem 'bootsnap', require: false gem 'chartkick' gem 'data_migrate' gem 'devise' -gem 'geocoder', git: 'https://github.com/alexreisner/geocoder.git', ref: '04ee293' +gem 'geocoder' gem 'gpx' gem 'groupdate' gem 'httparty' @@ -19,11 +19,12 @@ gem 'lograge' gem 'oj' gem 'pg' gem 'prometheus_exporter' -gem 'activerecord-postgis-adapter', github: 'StoneGod/activerecord-postgis-adapter', branch: 'rails-8' +gem 'activerecord-postgis-adapter' gem 'puma' gem 'pundit' gem 'rails', '~> 8.0' gem 'rgeo' +gem 'rgeo-activerecord' gem 'rswag-api' gem 'rswag-ui' gem 'shrine', '~> 3.6' diff --git a/Gemfile.lock b/Gemfile.lock index dcd64546..adff6149 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,21 +1,3 @@ -GIT - remote: https://github.com/StoneGod/activerecord-postgis-adapter.git - revision: 147fd43191ef703e2a1b3654f31d9139201a87e8 - branch: rails-8 - specs: - activerecord-postgis-adapter (10.0.1) - activerecord (~> 8.0.0) - rgeo-activerecord (~> 8.0.0) - -GIT - remote: https://github.com/alexreisner/geocoder.git - revision: 04ee2936a30b30a23ded5231d7faf6cf6c27c099 - ref: 04ee293 - specs: - geocoder (1.8.3) - base64 (>= 0.1.0) - csv (>= 3.0.0) - GEM remote: https://rubygems.org/ specs: @@ -71,6 +53,9 @@ GEM activemodel (= 8.0.1) activesupport (= 8.0.1) timeout (>= 0.4.0) + activerecord-postgis-adapter (11.0.0) + activerecord (~> 8.0.0) + rgeo-activerecord (~> 8.0.0) activestorage (8.0.1) actionpack (= 8.0.1) activejob (= 8.0.1) @@ -153,6 +138,9 @@ GEM fugit (1.11.1) et-orbi (~> 1, >= 1.2.11) raabro (~> 1.4) + geocoder (1.8.5) + base64 (>= 0.1.0) + csv (>= 3.0.0) globalid (1.2.1) activesupport (>= 6.1) gpx (1.2.0) @@ -464,7 +452,7 @@ PLATFORMS x86_64-linux DEPENDENCIES - activerecord-postgis-adapter! + activerecord-postgis-adapter bootsnap chartkick data_migrate @@ -476,7 +464,7 @@ DEPENDENCIES fakeredis ffaker foreman - geocoder! + geocoder gpx groupdate httparty @@ -493,6 +481,7 @@ DEPENDENCIES rails (~> 8.0) redis rgeo + rgeo-activerecord rspec-rails rswag-api rswag-specs diff --git a/app/assets/builds/tailwind.css b/app/assets/builds/tailwind.css index a062f2a9..9c97e9b9 100644 --- a/app/assets/builds/tailwind.css +++ b/app/assets/builds/tailwind.css @@ -1,6 +1 @@ -*,: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.17 | 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%!important}.container{width:100%}@media (min-width:640px){.\!container{max-width:640px!important}.container{max-width:640px}}@media (min-width:768px){.\!container{max-width:768px!important}.container{max-width:768px}}@media (min-width:1024px){.\!container{max-width:1024px!important}.container{max-width:1024px}}@media (min-width:1280px){.\!container{max-width:1280px!important}.container{max-width:1280px}}@media (min-width:1536px){.\!container{max-width:1536px!important}.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}.btm-nav>.disabled:hover,.btm-nav>[disabled]:hover{pointer-events:none;--tw-border-opacity:0;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-bg-opacity:0.1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}.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}.indicator{display:inline-flex;position:relative;width:-moz-max-content;width:max-content}.indicator :where(.indicator-item){position:absolute;white-space:nowrap;z-index:1}.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}.stack{display:inline-grid;place-items:center;align-items:flex-end}.stack>*{grid-column-start:1;grid-row-start:1;opacity:.6;transform:translateY(10%) scale(.9);width:100%;z-index:1}.stack>:nth-child(2){opacity:.8;transform:translateY(5%) scale(.95);z-index:2}.stack>:first-child{opacity:1;transform:translateY(0) scale(1);z-index:3}.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>:where(.active){border-top-width:2px;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)))}.btm-nav>.disabled,.btm-nav>[disabled]{pointer-events:none;--tw-border-opacity:0;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-bg-opacity:0.1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}.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")}.loading-dots{-webkit-mask-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24'%3E%3Cstyle%3E@keyframes spinner_8HQG{0%25,57.14%25{animation-timing-function:cubic-bezier(.33,.66,.66,1);transform:translate(0)}28.57%25{animation-timing-function:cubic-bezier(.33,0,.66,.33);transform:translateY(-6px)}to{transform:translate(0)}}.spinner_qM83{animation:spinner_8HQG 1.05s infinite}%3C/style%3E%3Ccircle cx='4' cy='12' r='3' class='spinner_qM83'/%3E%3Ccircle cx='12' cy='12' r='3' class='spinner_qM83' style='animation-delay:.1s'/%3E%3Ccircle cx='20' cy='12' r='3' class='spinner_qM83' style='animation-delay:.2s'/%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'%3E%3Cstyle%3E@keyframes spinner_8HQG{0%25,57.14%25{animation-timing-function:cubic-bezier(.33,.66,.66,1);transform:translate(0)}28.57%25{animation-timing-function:cubic-bezier(.33,0,.66,.33);transform:translateY(-6px)}to{transform:translate(0)}}.spinner_qM83{animation:spinner_8HQG 1.05s infinite}%3C/style%3E%3Ccircle cx='4' cy='12' r='3' class='spinner_qM83'/%3E%3Ccircle cx='12' cy='12' r='3' class='spinner_qM83' style='animation-delay:.1s'/%3E%3Ccircle cx='20' cy='12' r='3' class='spinner_qM83' style='animation-delay:.2s'/%3E%3C/svg%3E")}.loading-md{width:1.5rem}.loading-lg{width:2.5rem}: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-zebra tr.active,.table-zebra tr.active:nth-child(2n),.table-zebra-zebra tbody tr:nth-child(2n){--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/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}.btm-nav-xs>:where(.active){border-top-width:1px}.btm-nav-sm>:where(.active){border-top-width:2px}.btm-nav-md>:where(.active){border-top-width:2px}.btm-nav-lg>:where(.active){border-top-width:4px}.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}.indicator :where(.indicator-item){bottom:auto;inset-inline-end:0;inset-inline-start:auto;top:0;--tw-translate-y:-50%;--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))}:is([dir=rtl] .indicator :where(.indicator-item)){--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))}.indicator :where(.indicator-item.indicator-start){inset-inline-end:auto;inset-inline-start:0;--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))}:is([dir=rtl] .indicator :where(.indicator-item.indicator-start)){--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))}.indicator :where(.indicator-item.indicator-center){inset-inline-end:50%;inset-inline-start:50%;--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))}:is([dir=rtl] .indicator :where(.indicator-item.indicator-center)){--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))}.indicator :where(.indicator-item.indicator-end){inset-inline-end:0;inset-inline-start:auto;--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))}:is([dir=rtl] .indicator :where(.indicator-item.indicator-end)){--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))}.indicator :where(.indicator-item.indicator-bottom){bottom:0;top:auto;--tw-translate-y: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))}.indicator :where(.indicator-item.indicator-middle){bottom:50%;top:50%;--tw-translate-y:-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))}.indicator :where(.indicator-item.indicator-top){bottom:auto;top:0;--tw-translate-y:-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))}.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%)}.fade-out{opacity:0;transition:opacity .15s ease-in-out}.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}.z-\[6000\]{z-index:6000}.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-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-1\/2{width:50%}.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}.resize{resize:both}.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-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(.25rem*var(--tw-space-y-reverse));margin-top:calc(.25rem*(1 - var(--tw-space-y-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}.whitespace-nowrap{white-space:nowrap}.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,1)))}.border-blue-300{--tw-border-opacity:1;border-color:rgb(147 197 253/var(--tw-border-opacity,1))}.border-red-300{--tw-border-opacity:1;border-color:rgb(252 165 165/var(--tw-border-opacity,1))}.bg-base-100{--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity,1)))}.bg-base-200{--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity,1)))}.bg-base-300{--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity,1)))}.bg-blue-100{--tw-bg-opacity:1;background-color:rgb(219 234 254/var(--tw-bg-opacity,1))}.bg-blue-500{--tw-bg-opacity:1;background-color:rgb(59 130 246/var(--tw-bg-opacity,1))}.bg-blue-600{--tw-bg-opacity:1;background-color:rgb(37 99 235/var(--tw-bg-opacity,1))}.bg-blue-900{--tw-bg-opacity:1;background-color:rgb(30 58 138/var(--tw-bg-opacity,1))}.bg-gray-100{--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity,1))}.bg-green-50{--tw-bg-opacity:1;background-color:rgb(240 253 244/var(--tw-bg-opacity,1))}.bg-green-500{--tw-bg-opacity:1;background-color:rgb(34 197 94/var(--tw-bg-opacity,1))}.bg-neutral{--tw-bg-opacity:1;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity,1)))}.bg-red-100{--tw-bg-opacity:1;background-color:rgb(254 226 226/var(--tw-bg-opacity,1))}.bg-red-50{--tw-bg-opacity:1;background-color:rgb(254 242 242/var(--tw-bg-opacity,1))}.bg-red-500{--tw-bg-opacity:1;background-color:rgb(239 68 68/var(--tw-bg-opacity,1))}.bg-secondary-content{--tw-bg-opacity:1;background-color:var(--fallback-sc,oklch(var(--sc)/var(--tw-bg-opacity,1)))}.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,1)))}.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,1))}.text-blue-700{--tw-text-opacity:1;color:rgb(29 78 216/var(--tw-text-opacity,1))}.text-gray-500{--tw-text-opacity:1;color:rgb(107 114 128/var(--tw-text-opacity,1))}.text-gray-600{--tw-text-opacity:1;color:rgb(75 85 99/var(--tw-text-opacity,1))}.text-green-500{--tw-text-opacity:1;color:rgb(34 197 94/var(--tw-text-opacity,1))}.text-neutral-content{--tw-text-opacity:1;color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity,1)))}.text-primary{--tw-text-opacity:1;color:var(--fallback-p,oklch(var(--p)/var(--tw-text-opacity,1)))}.text-red-500{--tw-text-opacity:1;color:rgb(239 68 68/var(--tw-text-opacity,1))}.text-red-700{--tw-text-opacity:1;color:rgb(185 28 28/var(--tw-text-opacity,1))}.text-secondary{--tw-text-opacity:1;color:var(--fallback-s,oklch(var(--s)/var(--tw-text-opacity,1)))}.text-success{--tw-text-opacity:1;color:var(--fallback-su,oklch(var(--su)/var(--tw-text-opacity,1)))}.text-warning{--tw-text-opacity:1;color:var(--fallback-wa,oklch(var(--wa)/var(--tw-text-opacity,1)))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.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)}.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\:flex{display:flex}.lg\:hidden{display:none}.lg\:w-1\/12{width:8.333333%}.lg\:w-1\/2{width:50%}.lg\:w-2\/12{width:16.666667%}.lg\:w-3\/12{width:25%}.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 +*,: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.17 | 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%!important}.container{width:100%}@media (min-width:640px){.\!container{max-width:640px!important}.container{max-width:640px}}@media (min-width:768px){.\!container{max-width:768px!important}.container{max-width:768px}}@media (min-width:1024px){.\!container{max-width:1024px!important}.container{max-width:1024px}}@media (min-width:1280px){.\!container{max-width:1280px!important}.container{max-width:1280px}}@media (min-width:1536px){.\!container{max-width:1536px!important}.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)}.\!drawer{display:grid!important;grid-auto-columns:max-content auto!important;position:relative!important;width:100%!important}.drawer{display:grid;grid-auto-columns:max-content auto;position:relative;width:100%}.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}.btm-nav>.disabled:hover,.btm-nav>[disabled]:hover{pointer-events:none;--tw-border-opacity:0;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-bg-opacity:0.1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}.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:hover{--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: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-secondary:hover{--tw-text-opacity:1;color:var(--fallback-sc,oklch(var(--sc)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.btn-outline.btn-secondary:hover{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:hover{--tw-text-opacity:1;color:var(--fallback-ac,oklch(var(--ac)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.btn-outline.btn-accent:hover{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: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}.indicator{display:inline-flex;position:relative;width:-moz-max-content;width:max-content}.indicator :where(.indicator-item){position:absolute;white-space:nowrap;z-index:1}.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}.stack{display:inline-grid;place-items:center;align-items:flex-end}.stack>*{grid-column-start:1;grid-row-start:1;opacity:.6;transform:translateY(10%) scale(.9);width:100%;z-index:1}.stack>:nth-child(2){opacity:.8;transform:translateY(5%) scale(.95);z-index:2}.stack>:first-child{opacity:1;transform:translateY(0) scale(1);z-index:3}.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)))}.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-success{background-color:var(--fallback-su,oklch(var(--su)/var(--tw-bg-opacity)));color:var(--fallback-suc,oklch(var(--suc)/var(--tw-text-opacity)))}.badge-success,.badge-warning{border-color:transparent;--tw-bg-opacity:1;--tw-text-opacity:1}.badge-warning{background-color:var(--fallback-wa,oklch(var(--wa)/var(--tw-bg-opacity)));color:var(--fallback-wac,oklch(var(--wac)/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>:where(.active){border-top-width:2px;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)))}.btm-nav>.disabled,.btm-nav>[disabled]{pointer-events:none;--tw-border-opacity:0;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-bg-opacity:0.1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}.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{background-color:transparent;border-color:currentColor;--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--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)}.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{--tw-text-opacity:1;color:var(--fallback-s,oklch(var(--s)/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{--tw-text-opacity:1;color:var(--fallback-a,oklch(var(--a)/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}.drawer-toggle:focus-visible~.drawer-content label.drawer-button{outline-offset:2px;outline-style:solid;outline-width:2px}.dropdown.dropdown-open .dropdown-content,.dropdown:focus .dropdown-content,.dropdown:focus-within .dropdown-content{--tw-scale-x:1;--tw-scale-y:1;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.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-error{--tw-border-opacity:1;border-color:var(--fallback-er,oklch(var(--er)/var(--tw-border-opacity)))}.input-error:focus,.input-error:focus-within{--tw-border-opacity:1;border-color:var(--fallback-er,oklch(var(--er)/var(--tw-border-opacity)));outline-color:var(--fallback-er,oklch(var(--er)/1))}.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")}.loading-dots{-webkit-mask-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24'%3E%3Cstyle%3E@keyframes spinner_8HQG{0%25,57.14%25{animation-timing-function:cubic-bezier(.33,.66,.66,1);transform:translate(0)}28.57%25{animation-timing-function:cubic-bezier(.33,0,.66,.33);transform:translateY(-6px)}to{transform:translate(0)}}.spinner_qM83{animation:spinner_8HQG 1.05s infinite}%3C/style%3E%3Ccircle cx='4' cy='12' r='3' class='spinner_qM83'/%3E%3Ccircle cx='12' cy='12' r='3' class='spinner_qM83' style='animation-delay:.1s'/%3E%3Ccircle cx='20' cy='12' r='3' class='spinner_qM83' style='animation-delay:.2s'/%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'%3E%3Cstyle%3E@keyframes spinner_8HQG{0%25,57.14%25{animation-timing-function:cubic-bezier(.33,.66,.66,1);transform:translate(0)}28.57%25{animation-timing-function:cubic-bezier(.33,0,.66,.33);transform:translateY(-6px)}to{transform:translate(0)}}.spinner_qM83{animation:spinner_8HQG 1.05s infinite}%3C/style%3E%3Ccircle cx='4' cy='12' r='3' class='spinner_qM83'/%3E%3Ccircle cx='12' cy='12' r='3' class='spinner_qM83' style='animation-delay:.1s'/%3E%3Ccircle cx='20' cy='12' r='3' class='spinner_qM83' style='animation-delay:.2s'/%3E%3C/svg%3E")}.loading-md{width:1.5rem}.loading-lg{width:2.5rem}: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-zebra tr.active,.table-zebra tr.active:nth-child(2n),.table-zebra-zebra tbody tr:nth-child(2n){--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/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-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}.btm-nav-xs>:where(.active){border-top-width:1px}.btm-nav-sm>:where(.active){border-top-width:2px}.btm-nav-md>:where(.active){border-top-width:2px}.btm-nav-lg>:where(.active){border-top-width:4px}.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}.indicator :where(.indicator-item){bottom:auto;inset-inline-end:0;inset-inline-start:auto;top:0;--tw-translate-y:-50%;--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))}:is([dir=rtl] .indicator :where(.indicator-item)){--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))}.indicator :where(.indicator-item.indicator-start){inset-inline-end:auto;inset-inline-start:0;--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))}:is([dir=rtl] .indicator :where(.indicator-item.indicator-start)){--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))}.indicator :where(.indicator-item.indicator-center){inset-inline-end:50%;inset-inline-start:50%;--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))}:is([dir=rtl] .indicator :where(.indicator-item.indicator-center)){--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))}.indicator :where(.indicator-item.indicator-end){inset-inline-end:0;inset-inline-start:auto;--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))}:is([dir=rtl] .indicator :where(.indicator-item.indicator-end)){--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))}.indicator :where(.indicator-item.indicator-bottom){bottom:0;top:auto;--tw-translate-y: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))}.indicator :where(.indicator-item.indicator-middle){bottom:50%;top:50%;--tw-translate-y:-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))}.indicator :where(.indicator-item.indicator-top){bottom:auto;top:0;--tw-translate-y:-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))}.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}.select-sm{font-size:.875rem;height:2rem;line-height:2rem;min-height:2rem;padding-left:.75rem;padding-right:2rem}[dir=rtl] .select-sm{padding-left:2rem;padding-right:.75rem}.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}.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))}.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%)}.fade-out{opacity:0;transition:opacity .15s ease-in-out}.visible{visibility:visible}.invisible{visibility:hidden}.static{position:static}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.left-2{left:.5rem}.right-0{right:0}.right-5{right:1.25rem}.top-0{top:0}.top-2{top:.5rem}.top-5{top:1.25rem}.z-0{z-index:0}.z-10{z-index:10}.z-40{z-index:40}.z-50{z-index:50}.z-\[1\]{z-index:1}.z-\[5000\]{z-index:5000}.z-\[6000\]{z-index:6000}.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-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}.ml-auto{margin-left:auto}.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%}.h-screen{height:100vh}.min-h-80{min-height:20rem}.min-h-screen{min-height:100vh}.w-1\/2{width:50%}.w-1\/3{width:33.333333%}.w-10\/12{width:83.333333%}.w-2\/3{width:66.666667%}.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-64{width:16rem}.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}.translate-x-full{--tw-translate-x:100%}.transform,.translate-x-full{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))}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.resize{resize:both}.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-col-reverse{flex-direction:column-reverse}.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-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(.25rem*var(--tw-space-y-reverse));margin-top:calc(.25rem*(1 - var(--tw-space-y-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}.overflow-y-auto{overflow-y:auto}.truncate{overflow:hidden;white-space:nowrap}.text-ellipsis,.truncate{text-overflow:ellipsis}.whitespace-nowrap{white-space:nowrap}.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-2{border-width:2px}.border-dashed{border-style:dashed}.border-base-300{--tw-border-opacity:1;border-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-border-opacity,1)))}.border-blue-300{--tw-border-opacity:1;border-color:rgb(147 197 253/var(--tw-border-opacity,1))}.border-red-300{--tw-border-opacity:1;border-color:rgb(252 165 165/var(--tw-border-opacity,1))}.border-sky-500{--tw-border-opacity:1;border-color:rgb(14 165 233/var(--tw-border-opacity,1))}.bg-base-100{--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity,1)))}.bg-base-200{--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity,1)))}.bg-base-300{--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity,1)))}.bg-blue-100{--tw-bg-opacity:1;background-color:rgb(219 234 254/var(--tw-bg-opacity,1))}.bg-blue-500{--tw-bg-opacity:1;background-color:rgb(59 130 246/var(--tw-bg-opacity,1))}.bg-blue-600{--tw-bg-opacity:1;background-color:rgb(37 99 235/var(--tw-bg-opacity,1))}.bg-blue-900{--tw-bg-opacity:1;background-color:rgb(30 58 138/var(--tw-bg-opacity,1))}.bg-gray-100{--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity,1))}.bg-green-50{--tw-bg-opacity:1;background-color:rgb(240 253 244/var(--tw-bg-opacity,1))}.bg-green-500{--tw-bg-opacity:1;background-color:rgb(34 197 94/var(--tw-bg-opacity,1))}.bg-neutral{--tw-bg-opacity:1;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity,1)))}.bg-red-100{--tw-bg-opacity:1;background-color:rgb(254 226 226/var(--tw-bg-opacity,1))}.bg-red-50{--tw-bg-opacity:1;background-color:rgb(254 242 242/var(--tw-bg-opacity,1))}.bg-red-500{--tw-bg-opacity:1;background-color:rgb(239 68 68/var(--tw-bg-opacity,1))}.bg-secondary-content{--tw-bg-opacity:1;background-color:var(--fallback-sc,oklch(var(--sc)/var(--tw-bg-opacity,1)))}.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}.pl-6{padding-left:1.5rem}.text-center{text-align:center}.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}.text-accent-content{--tw-text-opacity:1;color:var(--fallback-ac,oklch(var(--ac)/var(--tw-text-opacity,1)))}.text-base-content{--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity,1)))}.text-base-content\/60{color:var(--fallback-bc,oklch(var(--bc)/.6))}.text-base-content\/70{color:var(--fallback-bc,oklch(var(--bc)/.7))}.text-blue-600{--tw-text-opacity:1;color:rgb(37 99 235/var(--tw-text-opacity,1))}.text-blue-700{--tw-text-opacity:1;color:rgb(29 78 216/var(--tw-text-opacity,1))}.text-gray-500{--tw-text-opacity:1;color:rgb(107 114 128/var(--tw-text-opacity,1))}.text-gray-600{--tw-text-opacity:1;color:rgb(75 85 99/var(--tw-text-opacity,1))}.text-green-500{--tw-text-opacity:1;color:rgb(34 197 94/var(--tw-text-opacity,1))}.text-neutral-content{--tw-text-opacity:1;color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity,1)))}.text-primary{--tw-text-opacity:1;color:var(--fallback-p,oklch(var(--p)/var(--tw-text-opacity,1)))}.text-red-500{--tw-text-opacity:1;color:rgb(239 68 68/var(--tw-text-opacity,1))}.text-red-700{--tw-text-opacity:1;color:rgb(185 28 28/var(--tw-text-opacity,1))}.text-secondary{--tw-text-opacity:1;color:var(--fallback-s,oklch(var(--s)/var(--tw-text-opacity,1)))}.text-success{--tw-text-opacity:1;color:var(--fallback-su,oklch(var(--su)/var(--tw-text-opacity,1)))}.text-warning{--tw-text-opacity:1;color:var(--fallback-wa,oklch(var(--wa)/var(--tw-text-opacity,1)))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.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-md{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-md{--tw-shadow:0 4px 6px -1px rgba(0,0,0,.1),0 2px 4px -2px rgba(0,0,0,.1);--tw-shadow-colored:0 4px 6px -1px var(--tw-shadow-color),0 2px 4px -2px var(--tw-shadow-color)}.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);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.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-colors{transition-duration:.15s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;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}.ease-in-out{transition-timing-function:cubic-bezier(.4,0,.2,1)}@tailwind daisyui;.leaflet-right-panel{background:#fff;border-radius:4px;box-shadow:0 1px 4px rgba(0,0,0,.3);margin-right:10px;margin-top:80px;transform:none;transition:right .3s ease-in-out;z-index:400}.leaflet-right-panel.controls-shifted{right:310px}.leaflet-control-button{background-color:#fff!important;color:#374151!important}.leaflet-control-button:hover{background-color:#f3f4f6!important}.leaflet-drawer{background:hsla(0,0%,100%,.5);box-shadow:-2px 0 5px rgba(0,0,0,.1);height:100%;position:absolute;right:0;top:0;transform:translateX(100%);transition:transform .3s ease-in-out;width:320px;z-index:450}.leaflet-drawer.open{transform:translateX(0)}.leaflet-control-button,.leaflet-control-layers,.toggle-panel-button{transition:right .3s ease-in-out;z-index:500}.controls-shifted{right:320px!important}@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 (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\:bg-base-300:hover{--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity,1)))}.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))}}@media (min-width:1024px){.lg\:flex{display:flex}.lg\:hidden{display:none}.lg\:w-1\/12{width:8.333333%}.lg\:w-1\/2{width:50%}.lg\:w-2\/12{width:16.666667%}.lg\:w-3\/12{width:25%}.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 diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index 982d94b0..bd822bce 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -14,7 +14,7 @@ *= require_self */ -.emoji-icon { + .emoji-icon { font-size: 36px; /* Adjust size as needed */ text-align: center; line-height: 36px; /* Same as font-size for perfect centering */ @@ -101,9 +101,3 @@ content: '✅'; animation: none; } - -@keyframes spinner { - to { - transform: rotate(360deg); - } -} diff --git a/app/assets/stylesheets/application.tailwind.css b/app/assets/stylesheets/application.tailwind.css index 48e213d2..257a1910 100644 --- a/app/assets/stylesheets/application.tailwind.css +++ b/app/assets/stylesheets/application.tailwind.css @@ -20,3 +20,86 @@ transition: opacity 150ms ease-in-out; } } + +/* Leaflet Panel Styles */ +.leaflet-right-panel { + margin-top: 80px; /* Give space for controls above */ + margin-right: 10px; + transform: none; + transition: right 0.3s ease-in-out; + z-index: 400; + background: white; + border-radius: 4px; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3); +} + +.leaflet-right-panel.controls-shifted { + right: 310px; +} + +.leaflet-control-button { + background-color: white !important; + color: #374151 !important; +} + +.leaflet-control-button:hover { + background-color: #f3f4f6 !important; +} + +/* Drawer Panel Styles */ +.leaflet-drawer { + position: absolute; + top: 0; + right: 0; + width: 338px; + height: 100%; + background: rgba(255, 255, 255, 0.5); + transform: translateX(100%); + transition: transform 0.3s ease-in-out; + z-index: 450; + box-shadow: -2px 0 5px rgba(0, 0, 0, 0.1); +} + +.leaflet-drawer.open { + transform: translateX(0); +} + +/* Controls transition */ +.leaflet-control-layers, +.leaflet-control-button, +.toggle-panel-button { + transition: right 0.3s ease-in-out; + z-index: 500; +} + +.controls-shifted { + right: 338px !important; +} + +/* Selection Tool Styles */ +.leaflet-control-custom { + background-color: white; + border-radius: 4px; + width: 30px; + height: 30px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3); +} + +.leaflet-control-custom:hover { + background-color: #f3f4f6; +} + +#selection-tool-button.active { + background-color: #60a5fa; + color: white; +} + +/* Cancel Selection Button */ +#cancel-selection-button { + margin-bottom: 1rem; + width: 100%; +} diff --git a/app/controllers/api/v1/overland/batches_controller.rb b/app/controllers/api/v1/overland/batches_controller.rb index 530b7eab..db7c4ade 100644 --- a/app/controllers/api/v1/overland/batches_controller.rb +++ b/app/controllers/api/v1/overland/batches_controller.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class Api::V1::Overland::BatchesController < ApiController + before_action :authenticate_active_api_user!, only: %i[create] + def create Overland::BatchCreatingJob.perform_later(batch_params, current_api_user.id) diff --git a/app/controllers/api/v1/owntracks/points_controller.rb b/app/controllers/api/v1/owntracks/points_controller.rb index e1f8bb9a..26c53c2f 100644 --- a/app/controllers/api/v1/owntracks/points_controller.rb +++ b/app/controllers/api/v1/owntracks/points_controller.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class Api::V1::Owntracks::PointsController < ApiController + before_action :authenticate_active_api_user!, only: %i[create] + def create Owntracks::PointCreatingJob.perform_later(point_params, current_api_user.id) diff --git a/app/controllers/api/v1/points_controller.rb b/app/controllers/api/v1/points_controller.rb index f09340b8..dc34387c 100644 --- a/app/controllers/api/v1/points_controller.rb +++ b/app/controllers/api/v1/points_controller.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class Api::V1::PointsController < ApiController + before_action :authenticate_active_api_user!, only: %i[create update destroy] + def index start_at = params[:start_at]&.to_datetime&.to_i end_at = params[:end_at]&.to_datetime&.to_i || Time.zone.now.to_i diff --git a/app/controllers/api/v1/settings_controller.rb b/app/controllers/api/v1/settings_controller.rb index 316c201e..7d7e123d 100644 --- a/app/controllers/api/v1/settings_controller.rb +++ b/app/controllers/api/v1/settings_controller.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class Api::V1::SettingsController < ApiController + before_action :authenticate_active_api_user!, only: %i[update] + def index render json: { settings: current_api_user.settings, diff --git a/app/controllers/api/v1/visits/possible_places_controller.rb b/app/controllers/api/v1/visits/possible_places_controller.rb new file mode 100644 index 00000000..add04ef1 --- /dev/null +++ b/app/controllers/api/v1/visits/possible_places_controller.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class Api::V1::Visits::PossiblePlacesController < ApiController + def index + visit = current_api_user.visits.find(params[:id]) + possible_places = visit.suggested_places.map do |place| + Api::PlaceSerializer.new(place).call + end + + render json: possible_places + rescue ActiveRecord::RecordNotFound + render json: { error: 'Visit not found' }, status: :not_found + end +end diff --git a/app/controllers/api/v1/visits_controller.rb b/app/controllers/api/v1/visits_controller.rb index 6a6b51fb..9832d6b4 100644 --- a/app/controllers/api/v1/visits_controller.rb +++ b/app/controllers/api/v1/visits_controller.rb @@ -1,17 +1,79 @@ # frozen_string_literal: true class Api::V1::VisitsController < ApiController + def index + visits = Visits::Finder.new(current_api_user, params).call + serialized_visits = visits.map do |visit| + Api::VisitSerializer.new(visit).call + end + + render json: serialized_visits + end + def update visit = current_api_user.visits.find(params[:id]) visit = update_visit(visit) - render json: visit + render json: Api::VisitSerializer.new(visit).call + end + + def merge + # Validate that we have at least 2 visit IDs + visit_ids = params[:visit_ids] + if visit_ids.blank? || visit_ids.length < 2 + return render json: { error: 'At least 2 visits must be selected for merging' }, status: :unprocessable_entity + end + + # Find all visits that belong to the current user + visits = current_api_user.visits.where(id: visit_ids).order(started_at: :asc) + + # Ensure we found all the visits + if visits.length != visit_ids.length + return render json: { error: 'One or more visits not found' }, status: :not_found + end + + # Use the service to merge the visits + service = Visits::MergeService.new(visits) + merged_visit = service.call + + if merged_visit&.persisted? + render json: Api::VisitSerializer.new(merged_visit).call, status: :ok + else + render json: { error: service.errors.join(', ') }, status: :unprocessable_entity + end + end + + def bulk_update + service = Visits::BulkUpdate.new( + current_api_user, + params[:visit_ids], + params[:status] + ) + + result = service.call + + if result + render json: { + message: "#{result[:count]} visits updated successfully", + updated_count: result[:count] + }, status: :ok + else + render json: { error: service.errors.join(', ') }, status: :unprocessable_entity + end end private def visit_params - params.require(:visit).permit(:name, :place_id) + params.require(:visit).permit(:name, :place_id, :status) + end + + def merge_params + params.permit(visit_ids: []) + end + + def bulk_update_params + params.permit(:status, visit_ids: []) end def update_visit(visit) diff --git a/app/controllers/api_controller.rb b/app/controllers/api_controller.rb index c193148e..868c72c0 100644 --- a/app/controllers/api_controller.rb +++ b/app/controllers/api_controller.rb @@ -12,6 +12,12 @@ class ApiController < ApplicationController true end + def authenticate_active_api_user! + render json: { error: 'User is not active' }, status: :unauthorized unless current_api_user&.active? + + true + end + def current_api_user @current_api_user ||= User.find_by(api_key:) end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 6d104eab..78071582 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -3,7 +3,7 @@ class ApplicationController < ActionController::Base include Pundit::Authorization - before_action :unread_notifications + before_action :unread_notifications, :set_self_hosted_status protected @@ -18,4 +18,22 @@ class ApplicationController < ActionController::Base redirect_to root_path, notice: 'You are not authorized to perform this action.', status: :see_other end + + def authenticate_self_hosted! + return if DawarichSettings.self_hosted? + + redirect_to root_path, notice: 'You are not authorized to perform this action.', status: :see_other + end + + def authenticate_active_user! + return if current_user&.active? + + redirect_to root_path, notice: 'Your account is not active.', status: :see_other + end + + private + + def set_self_hosted_status + @self_hosted = DawarichSettings.self_hosted? + end end diff --git a/app/controllers/imports_controller.rb b/app/controllers/imports_controller.rb index ca3c34c8..a6359e67 100644 --- a/app/controllers/imports_controller.rb +++ b/app/controllers/imports_controller.rb @@ -2,6 +2,7 @@ class ImportsController < ApplicationController before_action :authenticate_user! + before_action :authenticate_active_user!, only: %i[new create] before_action :set_import, only: %i[show destroy] def index @@ -53,7 +54,7 @@ class ImportsController < ApplicationController end def destroy - @import.destroy! + Imports::Destroy.new(current_user, @import).call redirect_to imports_url, notice: 'Import was successfully destroyed.', status: :see_other end diff --git a/app/controllers/map_controller.rb b/app/controllers/map_controller.rb index bad160d5..11082b6e 100644 --- a/app/controllers/map_controller.rb +++ b/app/controllers/map_controller.rb @@ -7,8 +7,8 @@ class MapController < ApplicationController @points = points.where('timestamp >= ? AND timestamp <= ?', start_at, end_at) @coordinates = - @points.pluck(:latitude, :longitude, :battery, :altitude, :timestamp, :velocity, :id, :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] } + @points.pluck(:lonlat, :battery, :altitude, :timestamp, :velocity, :id, :country) + .map { |lonlat, *rest| [lonlat.y, lonlat.x, *rest.map(&:to_s)] } @distance = distance @start_at = Time.zone.at(start_at) @end_at = Time.zone.at(end_at) diff --git a/app/controllers/settings/background_jobs_controller.rb b/app/controllers/settings/background_jobs_controller.rb index 8079b7e5..6eafb4c7 100644 --- a/app/controllers/settings/background_jobs_controller.rb +++ b/app/controllers/settings/background_jobs_controller.rb @@ -1,8 +1,10 @@ # frozen_string_literal: true class Settings::BackgroundJobsController < ApplicationController - before_action :authenticate_user! - before_action :authenticate_admin! + before_action :authenticate_self_hosted! + before_action :authenticate_admin!, unless: lambda { + %w[start_immich_import start_photoprism_import].include?(params[:job_name]) + } def index @queues = Sidekiq::Queue.all @@ -13,7 +15,15 @@ class Settings::BackgroundJobsController < ApplicationController flash.now[:notice] = 'Job was successfully created.' - redirect_to settings_background_jobs_path, notice: 'Job was successfully created.' + redirect_path = + case params[:job_name] + when 'start_immich_import', 'start_photoprism_import' + imports_path + else + settings_background_jobs_path + end + + redirect_to redirect_path, notice: 'Job was successfully created.' end def destroy diff --git a/app/controllers/settings/users_controller.rb b/app/controllers/settings/users_controller.rb index 529785db..a3be28c6 100644 --- a/app/controllers/settings/users_controller.rb +++ b/app/controllers/settings/users_controller.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class Settings::UsersController < ApplicationController - before_action :authenticate_user! + before_action :authenticate_self_hosted! before_action :authenticate_admin! def index diff --git a/app/controllers/settings_controller.rb b/app/controllers/settings_controller.rb index 243189cf..82a934af 100644 --- a/app/controllers/settings_controller.rb +++ b/app/controllers/settings_controller.rb @@ -2,7 +2,7 @@ class SettingsController < ApplicationController before_action :authenticate_user! - + before_action :authenticate_active_user!, only: %i[update] def index; end def update diff --git a/app/controllers/stats_controller.rb b/app/controllers/stats_controller.rb index b7e68f41..045772f3 100644 --- a/app/controllers/stats_controller.rb +++ b/app/controllers/stats_controller.rb @@ -2,6 +2,7 @@ class StatsController < ApplicationController before_action :authenticate_user! + before_action :authenticate_active_user!, only: %i[update update_all] def index @stats = current_user.stats.group_by(&:year).sort.reverse diff --git a/app/controllers/trips_controller.rb b/app/controllers/trips_controller.rb index 038d4842..f9e57e1d 100644 --- a/app/controllers/trips_controller.rb +++ b/app/controllers/trips_controller.rb @@ -2,6 +2,7 @@ class TripsController < ApplicationController before_action :authenticate_user! + before_action :authenticate_active_user!, only: %i[new create] before_action :set_trip, only: %i[show edit update destroy] before_action :set_coordinates, only: %i[show edit] diff --git a/app/controllers/visits_controller.rb b/app/controllers/visits_controller.rb index a8469831..0ce00b10 100644 --- a/app/controllers/visits_controller.rb +++ b/app/controllers/visits_controller.rb @@ -11,11 +11,10 @@ class VisitsController < ApplicationController visits = current_user .visits .where(status:) - .includes(%i[suggested_places area]) + .includes(%i[suggested_places area points]) .order(started_at: order_by) @suggested_visits_count = current_user.visits.suggested.count - @visits = visits.page(params[:page]).per(10) end diff --git a/app/javascript/controllers/base_controller.js b/app/javascript/controllers/base_controller.js new file mode 100644 index 00000000..ab6f12f7 --- /dev/null +++ b/app/javascript/controllers/base_controller.js @@ -0,0 +1,23 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static values = { + selfHosted: Boolean + } + + // Every controller that extends BaseController and uses initialize() + // should call super.initialize() + // Example: + // export default class extends BaseController { + // initialize() { + // super.initialize() + // } + // } + initialize() { + // Get the self-hosted value from the HTML root element + if (!this.hasSelfHostedValue) { + const selfHosted = document.documentElement.dataset.selfHosted === 'true' + this.selfHostedValue = selfHosted + } + } +} diff --git a/app/javascript/controllers/checkbox_select_all_controller.js b/app/javascript/controllers/checkbox_select_all_controller.js index 5e77773f..1b542f84 100644 --- a/app/javascript/controllers/checkbox_select_all_controller.js +++ b/app/javascript/controllers/checkbox_select_all_controller.js @@ -1,7 +1,7 @@ -import { Controller } from "@hotwired/stimulus" +import BaseController from "./base_controller" // Connects to data-controller="checkbox-select-all" -export default class extends Controller { +export default class extends BaseController { static targets = ["parent", "child"] connect() { diff --git a/app/javascript/controllers/datetime_controller.js b/app/javascript/controllers/datetime_controller.js index b56f07e3..b03df4ca 100644 --- a/app/javascript/controllers/datetime_controller.js +++ b/app/javascript/controllers/datetime_controller.js @@ -2,9 +2,9 @@ // - trips/new // - trips/edit -import { Controller } from "@hotwired/stimulus" +import BaseController from "./base_controller" -export default class extends Controller { +export default class extends BaseController { static targets = ["startedAt", "endedAt", "apiKey"] static values = { tripsId: String } diff --git a/app/javascript/controllers/imports_controller.js b/app/javascript/controllers/imports_controller.js index fd00d5c9..d39455a0 100644 --- a/app/javascript/controllers/imports_controller.js +++ b/app/javascript/controllers/imports_controller.js @@ -1,7 +1,7 @@ -import { Controller } from "@hotwired/stimulus"; +import BaseController from "./base_controller"; import consumer from "../channels/consumer"; -export default class extends Controller { +export default class extends BaseController { static targets = ["index"]; connect() { diff --git a/app/javascript/controllers/map_preview_controller.js b/app/javascript/controllers/map_preview_controller.js index 3b610a33..e55f2b83 100644 --- a/app/javascript/controllers/map_preview_controller.js +++ b/app/javascript/controllers/map_preview_controller.js @@ -1,8 +1,8 @@ -import { Controller } from "@hotwired/stimulus" +import BaseController from "./base_controller" import L from "leaflet" import { showFlashMessage } from "../maps/helpers" -export default class extends Controller { +export default class extends BaseController { static targets = ["urlInput", "mapContainer", "saveButton"] DEFAULT_TILE_URL = 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png' diff --git a/app/javascript/controllers/maps_controller.js b/app/javascript/controllers/maps_controller.js index d2f59dbb..a74aaac3 100644 --- a/app/javascript/controllers/maps_controller.js +++ b/app/javascript/controllers/maps_controller.js @@ -13,34 +13,27 @@ import { import { fetchAndDrawAreas, handleAreaCreated } from "../maps/areas"; -import { showFlashMessage, fetchAndDisplayPhotos, debounce } from "../maps/helpers"; - -import { - osmMapLayer, - osmHotMapLayer, - OPNVMapLayer, - openTopoMapLayer, - cyclOsmMapLayer, - esriWorldStreetMapLayer, - esriWorldTopoMapLayer, - esriWorldImageryMapLayer, - esriWorldGrayCanvasMapLayer -} from "../maps/layers"; +import { showFlashMessage, fetchAndDisplayPhotos } from "../maps/helpers"; import { countryCodesMap } from "../maps/country_codes"; +import { VisitsManager } from "../maps/visits"; import "leaflet-draw"; import { initializeFogCanvas, drawFogCanvas, createFogOverlay } from "../maps/fog_of_war"; import { TileMonitor } from "../maps/tile_monitor"; +import BaseController from "./base_controller"; +import { createAllMapLayers } from "../maps/layers"; -export default class extends Controller { +export default class extends BaseController { static targets = ["container"]; settingsButtonAdded = false; layerControl = null; visitedCitiesCache = new Map(); trackedMonthsCache = null; + currentPopup = null; connect() { + super.connect(); console.log("Map controller connected"); this.apiKey = this.element.dataset.api_key; @@ -110,6 +103,21 @@ export default class extends Controller { this.map.getPane('areasPane').style.zIndex = 650; this.map.getPane('areasPane').style.pointerEvents = 'all'; + // Create custom panes for visits + // Note: We'll still create visitsPane for backward compatibility + this.map.createPane('visitsPane'); + this.map.getPane('visitsPane').style.zIndex = 600; + this.map.getPane('visitsPane').style.pointerEvents = 'all'; + + // Create separate panes for confirmed and suggested visits + this.map.createPane('confirmedVisitsPane'); + this.map.getPane('confirmedVisitsPane').style.zIndex = 450; + this.map.getPane('confirmedVisitsPane').style.pointerEvents = 'all'; + + this.map.createPane('suggestedVisitsPane'); + this.map.getPane('suggestedVisitsPane').style.zIndex = 460; + this.map.getPane('suggestedVisitsPane').style.pointerEvents = 'all'; + // Initialize areasLayer as a feature group and add it to the map immediately this.areasLayer = new L.FeatureGroup(); this.photoMarkers = L.layerGroup(); @@ -120,6 +128,9 @@ export default class extends Controller { this.addSettingsButton(); } + // Initialize the visits manager + this.visitsManager = new VisitsManager(this.map, this.apiKey); + // Initialize layers for the layer control const controlsLayer = { Points: this.markersLayer, @@ -128,7 +139,9 @@ export default class extends Controller { "Fog of War": new this.fogOverlay(), "Scratch map": this.scratchLayer, Areas: this.areasLayer, - Photos: this.photoMarkers + Photos: this.photoMarkers, + "Suggested Visits": this.visitsManager.getVisitCirclesLayer(), + "Confirmed Visits": this.visitsManager.getConfirmedVisitCirclesLayer() }; // Initialize layer control first @@ -257,6 +270,12 @@ export default class extends Controller { // Start monitoring this.tileMonitor.startMonitoring(); + + // Add the drawer button for visits + this.visitsManager.addDrawerButton(); + + // Fetch and display visits when map loads + this.visitsManager.fetchAndDisplayVisits(); } disconnect() { @@ -402,17 +421,7 @@ export default class extends Controller { baseMaps() { let selectedLayerName = this.userSettings.preferred_map_layer || "OpenStreetMap"; - let maps = { - OpenStreetMap: osmMapLayer(this.map, selectedLayerName), - "OpenStreetMap.HOT": osmHotMapLayer(this.map, selectedLayerName), - OPNV: OPNVMapLayer(this.map, selectedLayerName), - openTopo: openTopoMapLayer(this.map, selectedLayerName), - cyclOsm: cyclOsmMapLayer(this.map, selectedLayerName), - esriWorldStreet: esriWorldStreetMapLayer(this.map, selectedLayerName), - esriWorldTopo: esriWorldTopoMapLayer(this.map, selectedLayerName), - esriWorldImagery: esriWorldImageryMapLayer(this.map, selectedLayerName), - esriWorldGrayCanvas: esriWorldGrayCanvasMapLayer(this.map, selectedLayerName) - }; + let maps = createAllMapLayers(this.map, selectedLayerName); // Add custom map if it exists in settings if (this.userSettings.maps && this.userSettings.maps.url) { @@ -536,13 +545,13 @@ export default class extends Controller { if (this.layerControl) { this.map.removeControl(this.layerControl); const controlsLayer = { - Points: this.markersLayer, - Routes: this.polylinesLayer, - Heatmap: this.heatmapLayer, - "Fog of War": this.fogOverlay, - "Scratch map": this.scratchLayer, - Areas: this.areasLayer, - Photos: this.photoMarkers + Points: this.markersLayer || L.layerGroup(), + Routes: this.polylinesLayer || L.layerGroup(), + Heatmap: this.heatmapLayer || L.layerGroup(), + "Fog of War": new this.fogOverlay(), + "Scratch map": this.scratchLayer || L.layerGroup(), + Areas: this.areasLayer || L.layerGroup(), + Photos: this.photoMarkers || L.layerGroup() }; this.layerControl = L.control.layers(this.baseMaps(), controlsLayer).addTo(this.map); } @@ -978,12 +987,17 @@ export default class extends Controller { const button = L.DomUtil.create('button', 'toggle-panel-button'); button.innerHTML = '📅'; - button.style.backgroundColor = 'white'; button.style.width = '48px'; button.style.height = '48px'; button.style.border = 'none'; button.style.cursor = 'pointer'; button.style.boxShadow = '0 1px 4px rgba(0,0,0,0.3)'; + button.style.backgroundColor = 'white'; + button.style.borderRadius = '4px'; + button.style.padding = '0'; + button.style.lineHeight = '48px'; + button.style.fontSize = '18px'; + button.style.textAlign = 'center'; // Disable map interactions when clicking the button L.DomEvent.disableClickPropagation(button); @@ -1337,15 +1351,4 @@ export default class extends Controller { container.innerHTML = html; } - - formatDuration(seconds) { - const days = Math.floor(seconds / (24 * 60 * 60)); - const hours = Math.floor((seconds % (24 * 60 * 60)) / (60 * 60)); - - if (days > 0) { - return `${days}d ${hours}h`; - } - return `${hours}h`; - } } - diff --git a/app/javascript/controllers/notifications_controller.js b/app/javascript/controllers/notifications_controller.js index 6ba44514..c40a4db5 100644 --- a/app/javascript/controllers/notifications_controller.js +++ b/app/javascript/controllers/notifications_controller.js @@ -1,11 +1,12 @@ -import { Controller } from "@hotwired/stimulus" +import BaseController from "./base_controller" import consumer from "../channels/consumer" -export default class extends Controller { +export default class extends BaseController { static targets = ["badge", "list"] static values = { userId: Number } initialize() { + super.initialize() this.subscription = null } diff --git a/app/javascript/controllers/removals_controller.js b/app/javascript/controllers/removals_controller.js index cf487d07..c5f30b32 100644 --- a/app/javascript/controllers/removals_controller.js +++ b/app/javascript/controllers/removals_controller.js @@ -1,6 +1,6 @@ -import { Controller } from "@hotwired/stimulus" +import BaseController from "./base_controller" -export default class extends Controller { +export default class extends BaseController { static values = { timeout: Number } diff --git a/app/javascript/controllers/trip_map_controller.js b/app/javascript/controllers/trip_map_controller.js index 1bbdc207..01b4a9e5 100644 --- a/app/javascript/controllers/trip_map_controller.js +++ b/app/javascript/controllers/trip_map_controller.js @@ -1,10 +1,10 @@ // This controller is being used on: // - trips/index -import { Controller } from "@hotwired/stimulus" +import BaseController from "./base_controller" import L from "leaflet" -export default class extends Controller { +export default class extends BaseController { static values = { tripId: Number, path: String, diff --git a/app/javascript/controllers/trips_controller.js b/app/javascript/controllers/trips_controller.js index 974feb30..6dc0c544 100644 --- a/app/javascript/controllers/trips_controller.js +++ b/app/javascript/controllers/trips_controller.js @@ -3,7 +3,7 @@ // - trips/edit // - trips/new -import { Controller } from "@hotwired/stimulus" +import BaseController from "./base_controller" import L from "leaflet" import { osmMapLayer, @@ -22,7 +22,7 @@ import { showFlashMessage } from '../maps/helpers'; -export default class extends Controller { +export default class extends BaseController { static targets = ["container", "startedAt", "endedAt"] static values = { } diff --git a/app/javascript/controllers/visit_modal_map_controller.js b/app/javascript/controllers/visit_modal_map_controller.js index 5fcb0547..f9b164f6 100644 --- a/app/javascript/controllers/visit_modal_map_controller.js +++ b/app/javascript/controllers/visit_modal_map_controller.js @@ -1,12 +1,12 @@ -import { Controller } from "@hotwired/stimulus" -import L, { latLng } from "leaflet"; -import { osmMapLayer } from "../maps/layers"; +import BaseController from "./base_controller" +import L from "leaflet" +import { osmMapLayer } from "../maps/layers" // This controller is used to display a map of all coordinates for a visit // on the "Map" modal of a visit on the Visits page -export default class extends Controller { - static targets = ["container"]; +export default class extends BaseController { + static targets = ["container"] connect() { this.coordinates = JSON.parse(this.element.dataset.coordinates); diff --git a/app/javascript/controllers/visit_modal_places_controller.js b/app/javascript/controllers/visit_modal_places_controller.js index ad6259f2..08ef8b37 100644 --- a/app/javascript/controllers/visit_modal_places_controller.js +++ b/app/javascript/controllers/visit_modal_places_controller.js @@ -1,10 +1,13 @@ -import { Controller } from "@hotwired/stimulus"; +import BaseController from "./base_controller" -export default class extends Controller { +export default class extends BaseController { + static targets = ["name", "input"] connect() { - this.visitId = this.element.dataset.id; this.apiKey = this.element.dataset.api_key; + this.visitId = this.element.dataset.id; + + this.element.addEventListener("visit-name:updated", this.updateAll.bind(this)); } // Action to handle selection change @@ -43,4 +46,9 @@ export default class extends Controller { element.textContent = newName; }); } + + updateAll(event) { + const newName = event.detail.name; + this.updateVisitNameOnPage(newName); + } } diff --git a/app/javascript/controllers/visit_name_controller.js b/app/javascript/controllers/visit_name_controller.js index 70af33b2..24b33273 100644 --- a/app/javascript/controllers/visit_name_controller.js +++ b/app/javascript/controllers/visit_name_controller.js @@ -1,7 +1,7 @@ -import { Controller } from "@hotwired/stimulus"; +import BaseController from "./base_controller" // This controller is used to handle the updating of visit names on the Visits page -export default class extends Controller { +export default class extends BaseController { static targets = ["name", "input"]; connect() { diff --git a/app/javascript/controllers/visits_map_controller.js b/app/javascript/controllers/visits_map_controller.js new file mode 100644 index 00000000..b1784dd3 --- /dev/null +++ b/app/javascript/controllers/visits_map_controller.js @@ -0,0 +1,110 @@ +import BaseController from "./base_controller" +import L from "leaflet" +import { osmMapLayer } from "../maps/layers" + +export default class extends BaseController { + static targets = ["container"] + + connect() { + this.initializeMap(); + this.visits = new Map(); + this.highlightedVisit = null; + } + + initializeMap() { + // Initialize the map with a default center (will be updated when visits are added) + this.map = L.map(this.containerTarget).setView([0, 0], 2); + osmMapLayer(this.map, "OpenStreetMap"); + + // Add all visits to the map + const visitElements = document.querySelectorAll('[data-visit-id]'); + if (visitElements.length > 0) { + const bounds = L.latLngBounds([]); + + visitElements.forEach(element => { + const visitId = element.dataset.visitId; + const lat = parseFloat(element.dataset.centerLat); + const lon = parseFloat(element.dataset.centerLon); + + if (!isNaN(lat) && !isNaN(lon)) { + const marker = L.circleMarker([lat, lon], { + radius: 8, + fillColor: this.getVisitColor(element), + color: '#fff', + weight: 2, + opacity: 1, + fillOpacity: 0.8 + }).addTo(this.map); + + // Store the marker reference + this.visits.set(visitId, { + marker, + element + }); + + bounds.extend([lat, lon]); + } + }); + + // Fit the map to show all visits + if (!bounds.isEmpty()) { + this.map.fitBounds(bounds, { + padding: [50, 50] + }); + } + } + } + + getVisitColor(element) { + // Check if the visit has a status badge + const badge = element.querySelector('.badge'); + if (badge) { + if (badge.classList.contains('badge-success')) { + return '#2ecc71'; // Green for confirmed + } else if (badge.classList.contains('badge-warning')) { + return '#f1c40f'; // Yellow for suggested + } + } + return '#e74c3c'; // Red for declined or unknown + } + + highlightVisit(event) { + const visitId = event.currentTarget.dataset.visitId; + const visit = this.visits.get(visitId); + + if (visit) { + // Reset previous highlight if any + if (this.highlightedVisit) { + this.highlightedVisit.marker.setStyle({ + radius: 8, + fillOpacity: 0.8 + }); + } + + // Highlight the current visit + visit.marker.setStyle({ + radius: 12, + fillOpacity: 1 + }); + visit.marker.bringToFront(); + + // Center the map on the visit + this.map.panTo(visit.marker.getLatLng()); + + this.highlightedVisit = visit; + } + } + + unhighlightVisit(event) { + const visitId = event.currentTarget.dataset.visitId; + const visit = this.visits.get(visitId); + + if (visit && this.highlightedVisit === visit) { + visit.marker.setStyle({ + radius: 8, + fillOpacity: 0.8 + }); + this.highlightedVisit = null; + } + } +} diff --git a/app/javascript/maps/helpers.js b/app/javascript/maps/helpers.js index 7c850f03..590bc190 100644 --- a/app/javascript/maps/helpers.js +++ b/app/javascript/maps/helpers.js @@ -87,10 +87,19 @@ export function haversineDistance(lat1, lon1, lat2, lon2, unit = 'km') { } export function showFlashMessage(type, message) { - // Create the outer flash container div + // Get or create the flash container + let flashContainer = document.getElementById('flash-messages'); + if (!flashContainer) { + flashContainer = document.createElement('div'); + flashContainer.id = 'flash-messages'; + flashContainer.className = 'fixed top-5 right-5 flex flex-col-reverse gap-2 z-50'; + document.body.appendChild(flashContainer); + } + + // Create the flash message div const flashDiv = document.createElement('div'); flashDiv.setAttribute('data-controller', 'removals'); - flashDiv.className = `flex items-center fixed top-5 right-5 ${classesForFlash(type)} py-3 px-5 rounded-lg`; + flashDiv.className = `flex items-center justify-between ${classesForFlash(type)} py-3 px-5 rounded-lg z-50`; // Create the message div const messageDiv = document.createElement('div'); @@ -101,6 +110,7 @@ export function showFlashMessage(type, message) { const closeButton = document.createElement('button'); closeButton.setAttribute('type', 'button'); closeButton.setAttribute('data-action', 'click->removals#remove'); + closeButton.className = 'ml-auto'; // Ensures button stays on the right // Create the SVG icon for the close button const closeIcon = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); @@ -116,21 +126,22 @@ export function showFlashMessage(type, message) { closeIconPath.setAttribute('stroke-width', '2'); closeIconPath.setAttribute('d', 'M6 18L18 6M6 6l12 12'); - // Append the path to the SVG + // Append all elements closeIcon.appendChild(closeIconPath); - // Append the SVG to the close button closeButton.appendChild(closeIcon); - - // Append the message and close button to the flash div flashDiv.appendChild(messageDiv); flashDiv.appendChild(closeButton); + flashContainer.appendChild(flashDiv); - // Append the flash message to the body or a specific flash container - document.body.appendChild(flashDiv); - - // Optional: Automatically remove the flash message after 5 seconds + // Automatically remove after 5 seconds setTimeout(() => { - flashDiv.remove(); + if (flashDiv && flashDiv.parentNode) { + flashDiv.remove(); + // Remove container if empty + if (flashContainer && !flashContainer.hasChildNodes()) { + flashContainer.remove(); + } + } }, 5000); } diff --git a/app/javascript/maps/layers.js b/app/javascript/maps/layers.js index c32200cc..6acf3d77 100644 --- a/app/javascript/maps/layers.js +++ b/app/javascript/maps/layers.js @@ -1,4 +1,38 @@ -// Yeah I know it should be DRY but this is me doing a KISS at 21:00 on a Sunday night +// Import the maps configuration +// In non-self-hosted mode, we need to mount external maps_config.js to the container +import { mapsConfig } from './maps_config'; + +export function createMapLayer(map, selectedLayerName, layerKey) { + const config = mapsConfig[layerKey]; + + if (!config) { + console.warn(`No configuration found for layer: ${layerKey}`); + return null; + } + + let layer = L.tileLayer(config.url, { + maxZoom: config.maxZoom, + attribution: config.attribution, + // Add any other config properties that might be needed + }); + + if (selectedLayerName === layerKey) { + return layer.addTo(map); + } else { + return layer; + } +} + +// Helper function to create all map layers +export function createAllMapLayers(map, selectedLayerName) { + const layers = {}; + + Object.keys(mapsConfig).forEach(layerKey => { + layers[layerKey] = createMapLayer(map, selectedLayerName, layerKey); + }); + + return layers; +} export function osmMapLayer(map, selectedLayerName) { let layerName = 'OpenStreetMap'; @@ -57,166 +91,6 @@ export function openTopoMapLayer(map, selectedLayerName) { } } -// export function stadiaAlidadeSmoothMapLayer(map, selectedLayerName) { -// let layerName = 'stadiaAlidadeSmooth'; -// let layer = L.tileLayer('https://tiles.stadiamaps.com/tiles/alidade_smooth/{z}/{x}/{y}{r}.{ext}', { -// minZoom: 0, -// maxZoom: 20, -// attribution: '© Stadia Maps © OpenMapTiles © OpenStreetMap contributors', -// ext: 'png' -// }); - -// if (selectedLayerName === layerName) { -// return layer.addTo(map); -// } else { -// return layer; -// } -// } - -// export function stadiaAlidadeSmoothDarkMapLayer(map, selectedLayerName) { -// let layerName = 'stadiaAlidadeSmoothDark'; -// let layer = L.tileLayer('https://tiles.stadiamaps.com/tiles/alidade_smooth_dark/{z}/{x}/{y}{r}.{ext}', { -// minZoom: 0, -// maxZoom: 20, -// attribution: '© Stadia Maps © OpenMapTiles © OpenStreetMap contributors', -// ext: 'png' -// }); - -// if (selectedLayerName === layerName) { -// return layer.addTo(map); -// } else { -// return layer; -// } -// } - -// export function stadiaAlidadeSatelliteMapLayer(map, selectedLayerName) { -// let layerName = 'stadiaAlidadeSatellite'; -// let layer = L.tileLayer('https://tiles.stadiamaps.com/tiles/alidade_satellite/{z}/{x}/{y}{r}.{ext}', { -// minZoom: 0, -// maxZoom: 20, -// attribution: '© CNES, Distribution Airbus DS, © Airbus DS, © PlanetObserver (Contains Copernicus Data) | © Stadia Maps © OpenMapTiles © OpenStreetMap contributors', -// ext: 'jpg' -// }); - -// if (selectedLayerName === layerName) { -// return layer.addTo(map); -// } else { -// return layer; -// } -// } - -// export function stadiaOsmBrightMapLayer(map, selectedLayerName) { -// let layerName = 'stadiaOsmBright'; -// let layer = L.tileLayer('https://tiles.stadiamaps.com/tiles/osm_bright/{z}/{x}/{y}{r}.{ext}', { -// minZoom: 0, -// maxZoom: 20, -// attribution: '© Stadia Maps © OpenMapTiles © OpenStreetMap contributors', -// ext: 'png' -// }); - -// if (selectedLayerName === layerName) { -// return layer.addTo(map); -// } else { -// return layer; -// } -// } - -// export function stadiaOutdoorMapLayer(map, selectedLayerName) { -// let layerName = 'stadiaOutdoor'; -// let layer = L.tileLayer('https://tiles.stadiamaps.com/tiles/outdoors/{z}/{x}/{y}{r}.{ext}', { -// minZoom: 0, -// maxZoom: 20, -// attribution: '© Stadia Maps © OpenMapTiles © OpenStreetMap contributors', -// ext: 'png' -// }); - -// if (selectedLayerName === layerName) { -// return layer.addTo(map); -// } else { -// return layer; -// } -// } - -// export function stadiaStamenTonerMapLayer(map, selectedLayerName) { -// let layerName = 'stadiaStamenToner'; -// let layer = L.tileLayer('https://tiles.stadiamaps.com/tiles/stamen_toner/{z}/{x}/{y}{r}.{ext}', { -// minZoom: 0, -// maxZoom: 20, -// attribution: '© Stadia Maps © Stamen Design © OpenMapTiles © OpenStreetMap contributors', -// ext: 'png' -// }); - -// if (selectedLayerName === layerName) { -// return layer.addTo(map); -// } else { -// return layer; -// } -// } - -// export function stadiaStamenTonerBackgroundMapLayer(map, selectedLayerName) { -// let layerName = 'stadiaStamenTonerBackground'; -// let layer = L.tileLayer('https://tiles.stadiamaps.com/tiles/stamen_toner_background/{z}/{x}/{y}{r}.{ext}', { -// minZoom: 0, -// maxZoom: 20, -// attribution: '© Stadia Maps © Stamen Design © OpenMapTiles © OpenStreetMap contributors', -// ext: 'png' -// }); - -// if (selectedLayerName === layerName) { -// return layer.addTo(map); -// } else { -// return layer; -// } -// } - -// export function stadiaStamenTonerLiteMapLayer(map, selectedLayerName) { -// let layerName = 'stadiaStamenTonerLite'; -// let layer = L.tileLayer('https://tiles.stadiamaps.com/tiles/stamen_toner_lite/{z}/{x}/{y}{r}.{ext}', { -// minZoom: 0, -// maxZoom: 20, -// attribution: '© Stadia Maps © Stamen Design © OpenMapTiles © OpenStreetMap contributors', -// ext: 'png' -// }); - -// if (selectedLayerName === layerName) { -// return layer.addTo(map); -// } else { -// return layer; -// } -// } - -// export function stadiaStamenWatercolorMapLayer(map, selectedLayerName) { -// let layerName = 'stadiaStamenWatercolor'; -// let layer = L.tileLayer('https://tiles.stadiamaps.com/tiles/stamen_watercolor/{z}/{x}/{y}.{ext}', { -// minZoom: 1, -// maxZoom: 16, -// attribution: '© Stadia Maps © Stamen Design © OpenMapTiles © OpenStreetMap contributors', -// ext: 'jpg' -// }); - -// if (selectedLayerName === layerName) { -// return layer.addTo(map); -// } else { -// return layer; -// } -// } - -// export function stadiaStamenTerrainMapLayer(map, selectedLayerName) { -// let layerName = 'stadiaStamenTerrain'; -// let layer = L.tileLayer('https://tiles.stadiamaps.com/tiles/stamen_terrain/{z}/{x}/{y}{r}.{ext}', { -// minZoom: 0, -// maxZoom: 18, -// attribution: '© Stadia Maps © Stamen Design © OpenMapTiles © OpenStreetMap contributors', -// ext: 'png' -// }); - -// if (selectedLayerName === layerName) { -// return layer.addTo(map); -// } else { -// return layer; -// } -// } - export function cyclOsmMapLayer(map, selectedLayerName) { let layerName = 'cyclOsm'; let layer = L.tileLayer('https://{s}.tile-cyclosm.openstreetmap.fr/cyclosm/{z}/{x}/{y}.png', { diff --git a/app/javascript/maps/maps_config.js b/app/javascript/maps/maps_config.js new file mode 100644 index 00000000..c0017df6 --- /dev/null +++ b/app/javascript/maps/maps_config.js @@ -0,0 +1,44 @@ +export const mapsConfig = { + "OpenStreetMap": { + url: "https://tile.openstreetmap.org/{z}/{x}/{y}.png", + maxZoom: 19, + attribution: "© OpenStreetMap" + }, + "OpenStreetMap.HOT": { + url: "https://{s}.tile.openstreetmap.fr/hot/{z}/{x}/{y}.png", + maxZoom: 19, + attribution: "© OpenStreetMap contributors, Tiles style by Humanitarian OpenStreetMap Team hosted by OpenStreetMap France" + }, + "OPNV": { + url: "https://tileserver.memomaps.de/tilegen/{z}/{x}/{y}.png", + maxZoom: 18, + attribution: "Map memomaps.de CC-BY-SA, map data © OpenStreetMap contributors" + }, + "openTopo": { + url: "https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png", + maxZoom: 17, + attribution: "Map data: © OpenStreetMap contributors, SRTM | Map style: © OpenTopoMap (CC-BY-SA)" + }, + "cyclOsm": { + url: "https://{s}.tile-cyclosm.openstreetmap.fr/cyclosm/{z}/{x}/{y}.png", + maxZoom: 20, + attribution: "CyclOSM | Map data: © OpenStreetMap contributors" + }, + "esriWorldStreet": { + url: "https://server.arcgisonline.com/ArcGIS/rest/services/World_Street_Map/MapServer/tile/{z}/{y}/{x}", + attribution: "Tiles © Esri — Source: Esri, DeLorme, NAVTEQ, USGS, Intermap, iPC, NRCAN, Esri Japan, METI, Esri China (Hong Kong), Esri (Thailand), TomTom, 2012" + }, + "esriWorldTopo": { + url: "https://server.arcgisonline.com/ArcGIS/rest/services/World_Topo_Map/MapServer/tile/{z}/{y}/{x}", + attribution: "Tiles © Esri — Esri, DeLorme, NAVTEQ, TomTom, Intermap, iPC, USGS, FAO, NPS, NRCAN, GeoBase, Kadaster NL, Ordnance Survey, Esri Japan, METI, Esri China (Hong Kong), and the GIS User Community" + }, + "esriWorldImagery": { + url: "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}", + attribution: "Tiles © Esri — Source: Esri, i-cubed, USDA, USGS, AEX, GeoEye, Getmapping, Aerogrid, IGN, IGP, UPR-EGP, and the GIS User Community" + }, + "esriWorldGrayCanvas": { + url: "https://server.arcgisonline.com/ArcGIS/rest/services/Canvas/World_Light_Gray_Base/MapServer/tile/{z}/{y}/{x}", + attribution: "Tiles © Esri — Esri, DeLorme, NAVTEQ", + maxZoom: 16 + } +}; diff --git a/app/javascript/maps/polylines.js b/app/javascript/maps/polylines.js index e48479d3..f1d9656f 100644 --- a/app/javascript/maps/polylines.js +++ b/app/javascript/maps/polylines.js @@ -3,46 +3,6 @@ import { formatDistance } from "../maps/helpers"; import { minutesToDaysHoursMinutes } from "../maps/helpers"; import { haversineDistance } from "../maps/helpers"; -function pointToLineDistance(point, lineStart, lineEnd) { - const x = point.lat; - const y = point.lng; - const x1 = lineStart.lat; - const y1 = lineStart.lng; - const x2 = lineEnd.lat; - const y2 = lineEnd.lng; - - const A = x - x1; - const B = y - y1; - const C = x2 - x1; - const D = y2 - y1; - - const dot = A * C + B * D; - const lenSq = C * C + D * D; - let param = -1; - - if (lenSq !== 0) { - param = dot / lenSq; - } - - let xx, yy; - - if (param < 0) { - xx = x1; - yy = y1; - } else if (param > 1) { - xx = x2; - yy = y2; - } else { - xx = x1 + param * C; - yy = y1 + param * D; - } - - const dx = x - xx; - const dy = y - yy; - - return Math.sqrt(dx * dx + dy * dy); -} - export function calculateSpeed(point1, point2) { if (!point1 || !point2 || !point1[4] || !point2[4]) { console.warn('Invalid points for speed calculation:', { point1, point2 }); diff --git a/app/javascript/maps/visits.js b/app/javascript/maps/visits.js new file mode 100644 index 00000000..3deea05d --- /dev/null +++ b/app/javascript/maps/visits.js @@ -0,0 +1,1497 @@ +import L from "leaflet"; +import { showFlashMessage } from "./helpers"; + +/** + * Manages visits functionality including displaying, fetching, and interacting with visits + */ +export class VisitsManager { + constructor(map, apiKey) { + this.map = map; + this.apiKey = apiKey; + + // Create custom panes for different visit types + if (!map.getPane('confirmedVisitsPane')) { + map.createPane('confirmedVisitsPane'); + map.getPane('confirmedVisitsPane').style.zIndex = 450; // Above default overlay pane (400) + } + + if (!map.getPane('suggestedVisitsPane')) { + map.createPane('suggestedVisitsPane'); + map.getPane('suggestedVisitsPane').style.zIndex = 460; // Below confirmed visits but above base layers + } + + this.visitCircles = L.layerGroup(); + this.confirmedVisitCircles = L.layerGroup().addTo(map); // Always visible layer for confirmed visits + this.currentPopup = null; + this.drawerOpen = false; + this.selectionMode = false; + this.selectionRect = null; + this.isSelectionActive = false; + this.selectedPoints = []; + this.highlightedVisitId = null; + this.highlightedCircles = []; // Track multiple circles instead of just one + + // Add CSS for visit highlighting + const style = document.createElement('style'); + style.textContent = ` + .visit-highlighted { + transition: all 0.3s ease-in-out; + } + `; + document.head.appendChild(style); + } + + /** + * Formats a duration in seconds to a human-readable string + * @param {number} seconds - Duration in seconds + * @returns {string} Formatted duration string + */ + formatDuration(seconds) { + const days = Math.floor(seconds / (24 * 60 * 60)); + const hours = Math.floor((seconds % (24 * 60 * 60)) / (60 * 60)); + const minutes = Math.floor((seconds % (60 * 60)) / 60); + + const parts = []; + if (days > 0) parts.push(`${days}d`); + if (hours > 0) parts.push(`${hours}h`); + if (minutes > 0 && days === 0) parts.push(`${minutes}m`); // Only show minutes if less than a day + + return parts.join(' ') || '< 1m'; + } + + /** + * Adds a button to toggle the visits drawer + */ + addDrawerButton() { + const DrawerControl = L.Control.extend({ + onAdd: (map) => { + const button = L.DomUtil.create('button', 'leaflet-control-button drawer-button'); + button.innerHTML = '⬅️'; // Left arrow icon + button.style.width = '48px'; + button.style.height = '48px'; + button.style.border = 'none'; + button.style.cursor = 'pointer'; + button.style.boxShadow = '0 1px 4px rgba(0,0,0,0.3)'; + button.style.backgroundColor = 'white'; + button.style.borderRadius = '4px'; + button.style.padding = '0'; + button.style.lineHeight = '48px'; + button.style.fontSize = '18px'; + button.style.textAlign = 'center'; + + L.DomEvent.disableClickPropagation(button); + L.DomEvent.on(button, 'click', () => { + this.toggleDrawer(); + }); + + return button; + } + }); + + this.map.addControl(new DrawerControl({ position: 'topright' })); + + // Add the selection tool button + this.addSelectionButton(); + } + + /** + * Adds a button to enable/disable the area selection tool + */ + addSelectionButton() { + const SelectionControl = L.Control.extend({ + onAdd: (map) => { + const button = L.DomUtil.create('button', 'leaflet-bar leaflet-control leaflet-control-custom'); + button.innerHTML = '⚓️'; + button.title = 'Select Area'; + button.id = 'selection-tool-button'; + button.style.width = '48px'; + button.style.height = '48px'; + button.style.border = 'none'; + button.style.cursor = 'pointer'; + button.style.boxShadow = '0 1px 4px rgba(0,0,0,0.3)'; + button.style.backgroundColor = 'white'; + button.style.borderRadius = '4px'; + button.style.padding = '0'; + button.style.lineHeight = '48px'; + button.style.fontSize = '18px'; + button.style.textAlign = 'center'; + button.onclick = () => this.toggleSelectionMode(); + return button; + } + }); + + new SelectionControl({ position: 'topright' }).addTo(this.map); + + // Add CSS for selection button active state + const style = document.createElement('style'); + style.textContent = ` + #selection-tool-button.active { + border: 2px dashed #3388ff !important; + box-shadow: 0 0 8px rgba(51, 136, 255, 0.5) !important; + } + `; + document.head.appendChild(style); + } + + /** + * Toggles the area selection mode + */ + toggleSelectionMode() { + // Clear any existing highlight + this.clearVisitHighlight(); + + this.isSelectionActive = !this.isSelectionActive; + if (this.selectionMode) { + // Disable selection mode + this.selectionMode = false; + this.map.dragging.enable(); + document.getElementById('selection-tool-button').classList.remove('active'); + this.map.off('mousedown', this.onMouseDown, this); + } else { + // Enable selection mode + this.selectionMode = true; + document.getElementById('selection-tool-button').classList.add('active'); + this.map.dragging.disable(); + this.map.on('mousedown', this.onMouseDown, this); + + showFlashMessage('info', 'Selection mode enabled. Click and drag to select an area.'); + } + } + + /** + * Handles the mousedown event to start the selection + */ + onMouseDown(e) { + // Clear any existing selection + this.clearSelection(); + + // Store start point and create rectangle + this.startPoint = e.latlng; + + // Add mousemove and mouseup listeners + this.map.on('mousemove', this.onMouseMove, this); + this.map.on('mouseup', this.onMouseUp, this); + } + + /** + * Handles the mousemove event to update the selection rectangle + */ + onMouseMove(e) { + if (!this.startPoint) return; + + // If we already have a rectangle, update its bounds + if (this.selectionRect) { + const bounds = L.latLngBounds(this.startPoint, e.latlng); + this.selectionRect.setBounds(bounds); + } else { + // Create a new rectangle + this.selectionRect = L.rectangle( + L.latLngBounds(this.startPoint, e.latlng), + { color: '#3388ff', weight: 2, fillOpacity: 0.1 } + ).addTo(this.map); + } + } + + /** + * Handles the mouseup event to complete the selection + */ + onMouseUp(e) { + // Remove the mouse event listeners + this.map.off('mousemove', this.onMouseMove, this); + this.map.off('mouseup', this.onMouseUp, this); + + if (!this.selectionRect) return; + + // Finalize the selection + this.isSelectionActive = true; + + // Re-enable map dragging + this.map.dragging.enable(); + + // Disable selection mode + this.selectionMode = false; + document.getElementById('selection-tool-button').classList.remove('active'); + this.map.off('mousedown', this.onMouseDown, this); + + // Fetch visits within the selection + this.fetchVisitsInSelection(); + } + + /** + * Clears the selection rectangle and resets selection state + */ + clearSelection() { + if (this.selectionRect) { + this.map.removeLayer(this.selectionRect); + this.selectionRect = null; + } + this.isSelectionActive = false; + this.startPoint = null; + this.selectedPoints = []; + + // Clear all visit circles immediately + this.visitCircles.clearLayers(); + this.confirmedVisitCircles.clearLayers(); + + // If the drawer is open, refresh with time-based visits + if (this.drawerOpen) { + this.fetchAndDisplayVisits(); + } else { + // If drawer is closed, we should hide all visits + if (this.map.hasLayer(this.visitCircles)) { + this.map.removeLayer(this.visitCircles); + } + } + + // Reset drawer title + const drawerTitle = document.querySelector('#visits-drawer .drawer h2'); + if (drawerTitle) { + drawerTitle.textContent = 'Recent Visits'; + } + } + + /** + * Fetches visits within the selected area + */ + async fetchVisitsInSelection() { + if (!this.selectionRect) return; + + const bounds = this.selectionRect.getBounds(); + const sw = bounds.getSouthWest(); + const ne = bounds.getNorthEast(); + + try { + const response = await fetch( + `/api/v1/visits?selection=true&sw_lat=${sw.lat}&sw_lng=${sw.lng}&ne_lat=${ne.lat}&ne_lng=${ne.lng}`, + { + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${this.apiKey}`, + } + } + ); + + if (!response.ok) { + throw new Error('Network response was not ok'); + } + + const visits = await response.json(); + + // Filter points in the selected area from DOM data + this.filterPointsInSelection(bounds); + + // Set selection as active to ensure date summary is displayed + this.isSelectionActive = true; + + this.displayVisits(visits); + + // Make sure the drawer is open + if (!this.drawerOpen) { + this.toggleDrawer(); + } + + // Add cancel selection button to the drawer + this.addSelectionCancelButton(); + + } catch (error) { + console.error('Error fetching visits in selection:', error); + showFlashMessage('error', 'Failed to load visits in selected area'); + } + } + + /** + * Filters points from DOM data that are within the selection bounds + * @param {L.LatLngBounds} bounds - The bounds of the selection rectangle + */ + filterPointsInSelection(bounds) { + if (!bounds) { + this.selectedPoints = []; + return; + } + + // Get points from the DOM + const allPoints = this.getPointsData(); + if (!allPoints || !allPoints.length) { + this.selectedPoints = []; + return; + } + + // Filter points that are within the bounds + this.selectedPoints = allPoints.filter(point => { + // Point format is expected to be [lat, lng, ...other data] + const lat = parseFloat(point[0]); + const lng = parseFloat(point[1]); + + if (isNaN(lat) || isNaN(lng)) return false; + + return bounds.contains([lat, lng]); + }); + } + + /** + * Gets points data from the DOM + * @returns {Array} Array of points with coordinates and timestamps + */ + getPointsData() { + const mapElement = document.getElementById('map'); + if (!mapElement) return []; + + // Get coordinates data from the data attribute + const coordinatesAttr = mapElement.getAttribute('data-coordinates'); + if (!coordinatesAttr) return []; + + try { + return JSON.parse(coordinatesAttr); + } catch (e) { + console.error('Error parsing coordinates data:', e); + return []; + } + } + + /** + * Groups visits by date + * @param {Array} visits - Array of visit objects + * @returns {Object} Object with dates as keys and counts as values + */ + groupVisitsByDate(visits) { + const dateGroups = {}; + + visits.forEach(visit => { + const startDate = new Date(visit.started_at); + const dateStr = startDate.toLocaleDateString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric' + }); + + if (!dateGroups[dateStr]) { + dateGroups[dateStr] = { + count: 0, + points: 0, + date: startDate + }; + } + + dateGroups[dateStr].count++; + }); + + // If we have selected points, count them by date + if (this.selectedPoints && this.selectedPoints.length > 0) { + this.selectedPoints.forEach(point => { + // Point timestamp is at index 4 + const timestamp = point[4]; + if (!timestamp) return; + + // Convert timestamp to date string + const pointDate = new Date(parseInt(timestamp) * 1000); + const dateStr = pointDate.toLocaleDateString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric' + }); + + if (!dateGroups[dateStr]) { + dateGroups[dateStr] = { + count: 0, + points: 0, + date: pointDate + }; + } + + dateGroups[dateStr].points++; + }); + } + + return dateGroups; + } + + /** + * Creates HTML for date summary panel + * @param {Object} dateGroups - Object with dates as keys and count/points values + * @returns {string} HTML string for date summary panel + */ + createDateSummaryHtml(dateGroups) { + // If there are no date groups, return empty string + if (Object.keys(dateGroups).length === 0) { + return ''; + } + + // Sort dates chronologically + const sortedDates = Object.keys(dateGroups).sort((a, b) => { + return dateGroups[a].date - dateGroups[b].date; + }); + + // Create HTML for each date group + const dateItems = sortedDates.map(dateStr => { + const pointsCount = dateGroups[dateStr].points || 0; + const visitsCount = dateGroups[dateStr].count || 0; + + return ` +
+
${dateStr}
+
+ ${pointsCount > 0 ? `
${pointsCount} pts
` : ''} + ${visitsCount > 0 ? `
${visitsCount} visits
` : ''} +
+
+ `; + }).join(''); + + // Create the whole panel + return ` +
+

Data in Selected Area

+
+ ${dateItems} +
+
+ `; + } + + /** + * Adds a cancel button to the drawer to clear the selection + */ + addSelectionCancelButton() { + const container = document.getElementById('visits-list'); + if (!container) return; + + // Add cancel button at the top of the drawer if it doesn't exist + if (!document.getElementById('cancel-selection-button')) { + const cancelButton = document.createElement('button'); + cancelButton.id = 'cancel-selection-button'; + cancelButton.className = 'btn btn-sm btn-warning mb-4 w-full'; + cancelButton.textContent = 'Cancel Area Selection'; + cancelButton.onclick = () => this.clearSelection(); + + // Insert at the beginning of the container + container.insertBefore(cancelButton, container.firstChild); + } + } + + /** + * Toggles the visibility of the visits drawer + */ + toggleDrawer() { + // Clear any existing highlight when drawer is toggled + this.clearVisitHighlight(); + + this.drawerOpen = !this.drawerOpen; + let drawer = document.getElementById('visits-drawer'); + + if (!drawer) { + drawer = this.createDrawer(); + } + + drawer.classList.toggle('open'); + + const drawerButton = document.querySelector('.drawer-button'); + if (drawerButton) { + drawerButton.innerHTML = this.drawerOpen ? '➡️' : '⬅️'; + } + + const controls = document.querySelectorAll('.leaflet-control-layers, .toggle-panel-button, .leaflet-right-panel, .drawer-button, #selection-tool-button'); + controls.forEach(control => { + control.classList.toggle('controls-shifted'); + }); + + // Update the drawer content if it's being opened + if (this.drawerOpen) { + this.fetchAndDisplayVisits(); + // Show the suggested visits layer when drawer is open + if (!this.map.hasLayer(this.visitCircles)) { + this.map.addLayer(this.visitCircles); + } + } else { + // Hide the suggested visits layer when drawer is closed + if (this.map.hasLayer(this.visitCircles)) { + this.map.removeLayer(this.visitCircles); + } + } + } + + /** + * Creates the drawer element for displaying visits + * @returns {HTMLElement} The created drawer element + */ + createDrawer() { + const drawer = document.createElement('div'); + drawer.id = 'visits-drawer'; + drawer.className = 'fixed top-0 right-0 h-full w-64 bg-base-100 shadow-lg transform translate-x-full transition-transform duration-300 ease-in-out z-39 overflow-y-auto leaflet-drawer'; + + // Add styles to make the drawer scrollable + drawer.style.overflowY = 'auto'; + drawer.style.maxHeight = '100vh'; + + drawer.innerHTML = ` +
+

Recent Visits

+
+

Loading visits...

+
+
+ `; + + // Prevent map zoom when scrolling the drawer + L.DomEvent.disableScrollPropagation(drawer); + // Prevent map pan/interaction when interacting with drawer + L.DomEvent.disableClickPropagation(drawer); + + this.map.getContainer().appendChild(drawer); + return drawer; + } + + /** + * Fetches visits data from the API and displays them + */ + async fetchAndDisplayVisits() { + try { + // Clear any existing highlight before fetching new visits + this.clearVisitHighlight(); + + // If there's an active selection, don't perform time-based fetch + if (this.isSelectionActive && this.selectionRect) { + this.fetchVisitsInSelection(); + return; + } + + // Get current timeframe from URL parameters + const urlParams = new URLSearchParams(window.location.search); + const startAt = urlParams.get('start_at') || new Date().toISOString(); + const endAt = urlParams.get('end_at') || new Date().toISOString(); + + console.log('Fetching visits for:', startAt, endAt); + const response = await fetch( + `/api/v1/visits?start_at=${encodeURIComponent(startAt)}&end_at=${encodeURIComponent(endAt)}`, + { + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${this.apiKey}`, + } + } + ); + + if (!response.ok) { + throw new Error('Network response was not ok'); + } + + const visits = await response.json(); + this.displayVisits(visits); + + // Ensure the suggested visits layer visibility matches the drawer state + if (this.drawerOpen) { + if (!this.map.hasLayer(this.visitCircles)) { + this.map.addLayer(this.visitCircles); + } + } else { + if (this.map.hasLayer(this.visitCircles)) { + this.map.removeLayer(this.visitCircles); + } + } + } catch (error) { + console.error('Error fetching visits:', error); + const container = document.getElementById('visits-list'); + if (container) { + container.innerHTML = '

Error loading visits

'; + } + } + } + + /** + * Displays visits on the map and in the drawer + * @param {Array} visits - Array of visit objects + */ + displayVisits(visits) { + const container = document.getElementById('visits-list'); + if (!container) return; + + // Update the drawer title if selection is active + if (this.isSelectionActive && this.selectionRect) { + const visitsCount = visits ? visits.filter(visit => visit.status !== 'declined').length : 0; + const drawerTitle = document.querySelector('#visits-drawer .drawer h2'); + if (drawerTitle) { + drawerTitle.textContent = `${visitsCount} visits found`; + } + } else { + // Reset title to default when not in selection mode + const drawerTitle = document.querySelector('#visits-drawer .drawer h2'); + if (drawerTitle) { + drawerTitle.textContent = 'Recent Visits'; + } + } + + // Group visits by date and count + const dateGroups = this.groupVisitsByDate(visits || []); + + // If we have points data and are in selection mode, calculate points per date + let dateGroupsHtml = ''; + if (this.isSelectionActive && this.selectionRect) { + // Create a date summary panel + dateGroupsHtml = this.createDateSummaryHtml(dateGroups); + } + + if (!visits || visits.length === 0) { + let noVisitsHtml = '

No visits found in selected timeframe

'; + container.innerHTML = dateGroupsHtml + noVisitsHtml; + return; + } + + // Clear existing visit circles + this.visitCircles.clearLayers(); + this.confirmedVisitCircles.clearLayers(); + + // Draw circles for all visits + visits + .filter(visit => visit.status !== 'declined') + .forEach(visit => { + if (visit.place?.latitude && visit.place?.longitude) { + const isConfirmed = visit.status === 'confirmed'; + const isSuggested = visit.status === 'suggested'; + + const circle = L.circle([visit.place.latitude, visit.place.longitude], { + color: isSuggested ? '#FFA500' : '#4A90E2', // Border color + fillColor: isSuggested ? '#FFD700' : '#4A90E2', // Fill color + fillOpacity: isSuggested ? 0.3 : 0.5, + radius: isConfirmed ? 110 : 80, // Increased size for confirmed visits + weight: 2, + interactive: true, + bubblingMouseEvents: false, + pane: isConfirmed ? 'confirmedVisitsPane' : 'suggestedVisitsPane', // Use appropriate pane + dashArray: isSuggested ? '4' : null // Dotted border for suggested + }); + + // Add the circle to the appropriate layer + if (isConfirmed) { + this.confirmedVisitCircles.addLayer(circle); + } else { + this.visitCircles.addLayer(circle); + } + + // Attach click event to the circle + circle.on('click', () => this.fetchPossiblePlaces(visit)); + } + }); + + const visitsHtml = visits + // Filter out declined visits + .filter(visit => visit.status !== 'declined') + .map(visit => { + const startDate = new Date(visit.started_at); + const endDate = new Date(visit.ended_at); + const isSameDay = startDate.toDateString() === endDate.toDateString(); + + let timeDisplay; + if (isSameDay) { + timeDisplay = ` + ${startDate.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}, + ${startDate.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', hour12: false })} - + ${endDate.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', hour12: false })} + `; + } else { + timeDisplay = ` + ${startDate.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}, + ${startDate.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', hour12: false })} - + ${endDate.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}, + ${endDate.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', hour12: false })} + `; + } + + const durationText = this.formatDuration(visit.duration * 60); + + // Add opacity class for suggested visits + const bgClass = visit.status === 'suggested' ? 'bg-neutral border-dashed border-2 border-sky-500' : 'bg-base-200'; + const visitStyle = visit.status === 'suggested' ? 'border: 2px dashed #60a5fa;' : ''; + + return ` +
+
+ +
+
${this.truncateText(visit.name, 30)}
+
+ ${timeDisplay.trim()} +
(${durationText})
+
+ ${visit.place?.city ? `
${visit.place.city}, ${visit.place.country}
` : ''} + ${visit.status !== 'confirmed' ? ` +
+ + +
+ ` : ''} +
+ `; + }).join(''); + + // Combine date summary and visits HTML + container.innerHTML = dateGroupsHtml + visitsHtml; + + // Add the circles layer to the map + this.visitCircles.addTo(this.map); + + // Add click handlers to visit items and buttons + this.addVisitItemEventListeners(container); + + // Add merge functionality + this.setupMergeFunctionality(container); + + // Ensure all checkboxes are hidden by default + container.querySelectorAll('.visit-checkbox-container').forEach(checkboxContainer => { + checkboxContainer.style.opacity = '0'; + checkboxContainer.style.pointerEvents = 'none'; + }); + } + + /** + * Sets up the merge functionality for visits + * @param {HTMLElement} container - The container with visit items + */ + setupMergeFunctionality(container) { + const visitItems = container.querySelectorAll('.visit-item'); + + // Add hover event to show checkboxes + visitItems.forEach(item => { + // Show checkbox on hover only if no checkboxes are currently checked + item.addEventListener('mouseenter', () => { + const allChecked = container.querySelectorAll('.visit-checkbox:checked'); + if (allChecked.length === 0) { + const checkbox = item.querySelector('.visit-checkbox-container'); + if (checkbox) { + checkbox.style.opacity = '1'; + checkbox.style.pointerEvents = 'auto'; + } + } + }); + + // Hide checkbox on mouse leave if not checked and if no other checkboxes are checked + item.addEventListener('mouseleave', () => { + const allChecked = container.querySelectorAll('.visit-checkbox:checked'); + if (allChecked.length === 0) { + const checkbox = item.querySelector('.visit-checkbox-container'); + const checkboxInput = item.querySelector('.visit-checkbox'); + if (checkbox && checkboxInput && !checkboxInput.checked) { + checkbox.style.opacity = '0'; + checkbox.style.pointerEvents = 'none'; + } + } + }); + }); + + // Add change event to checkboxes + const checkboxes = container.querySelectorAll('.visit-checkbox'); + checkboxes.forEach(checkbox => { + checkbox.addEventListener('change', () => { + this.updateMergeUI(container); + }); + }); + } + + /** + * Updates the merge UI based on selected checkboxes + * @param {HTMLElement} container - The container with visit items + */ + updateMergeUI(container) { + // Remove any existing action buttons + const existingActionButtons = container.querySelector('.visit-bulk-actions'); + if (existingActionButtons) { + existingActionButtons.remove(); + } + + // Get all checked checkboxes + const checkedBoxes = container.querySelectorAll('.visit-checkbox:checked'); + + // Hide all checkboxes first + container.querySelectorAll('.visit-checkbox-container').forEach(checkboxContainer => { + checkboxContainer.style.opacity = '0'; + checkboxContainer.style.pointerEvents = 'none'; + }); + + // If no checkboxes are checked, we're done + if (checkedBoxes.length === 0) { + return; + } + + // Get all visit items and their data + const visitItems = Array.from(container.querySelectorAll('.visit-item')); + + // For each checked visit, show checkboxes for adjacent visits + Array.from(checkedBoxes).forEach(checkbox => { + const visitItem = checkbox.closest('.visit-item'); + const visitId = checkbox.dataset.id; + const index = visitItems.indexOf(visitItem); + + // Show checkbox for the current visit + const currentCheckbox = visitItem.querySelector('.visit-checkbox-container'); + if (currentCheckbox) { + currentCheckbox.style.opacity = '1'; + currentCheckbox.style.pointerEvents = 'auto'; + } + + // Show checkboxes for visits above and below + // Above visit + if (index > 0) { + const aboveVisitItem = visitItems[index - 1]; + const aboveCheckbox = aboveVisitItem.querySelector('.visit-checkbox-container'); + if (aboveCheckbox) { + aboveCheckbox.style.opacity = '1'; + aboveCheckbox.style.pointerEvents = 'auto'; + } + } + + // Below visit + if (index < visitItems.length - 1) { + const belowVisitItem = visitItems[index + 1]; + const belowCheckbox = belowVisitItem.querySelector('.visit-checkbox-container'); + if (belowCheckbox) { + belowCheckbox.style.opacity = '1'; + belowCheckbox.style.pointerEvents = 'auto'; + } + } + }); + + // If 2 or more checkboxes are checked, show action buttons + if (checkedBoxes.length >= 2) { + // Find the lowest checked visit item + let lowestVisitItem = null; + let lowestPosition = -1; + + checkedBoxes.forEach(checkbox => { + const visitItem = checkbox.closest('.visit-item'); + const position = visitItems.indexOf(visitItem); + + if (lowestPosition === -1 || position > lowestPosition) { + lowestPosition = position; + lowestVisitItem = visitItem; + } + }); + + // Create action buttons container + if (lowestVisitItem) { + // Create a container for the action buttons to ensure proper spacing + const actionsContainer = document.createElement('div'); + actionsContainer.className = 'w-full p-2 visit-bulk-actions'; + + // Create button grid + const buttonGrid = document.createElement('div'); + buttonGrid.className = 'grid grid-cols-3 gap-2'; + + // Merge button + const mergeButton = document.createElement('button'); + mergeButton.className = 'btn btn-xs btn-primary'; + mergeButton.textContent = 'Merge'; + mergeButton.addEventListener('click', () => { + this.mergeVisits(Array.from(checkedBoxes).map(cb => cb.dataset.id)); + }); + + // Confirm button + const confirmButton = document.createElement('button'); + confirmButton.className = 'btn btn-xs btn-success'; + confirmButton.textContent = 'Confirm'; + confirmButton.addEventListener('click', () => { + this.bulkUpdateVisitStatus(Array.from(checkedBoxes).map(cb => cb.dataset.id), 'confirmed'); + }); + + // Decline button + const declineButton = document.createElement('button'); + declineButton.className = 'btn btn-xs btn-error'; + declineButton.textContent = 'Decline'; + declineButton.addEventListener('click', () => { + this.bulkUpdateVisitStatus(Array.from(checkedBoxes).map(cb => cb.dataset.id), 'declined'); + }); + + // Add buttons to grid + buttonGrid.appendChild(mergeButton); + buttonGrid.appendChild(confirmButton); + buttonGrid.appendChild(declineButton); + + // Add selection count text + const selectionText = document.createElement('div'); + selectionText.className = 'text-sm text-center mt-1 text-gray-500'; + selectionText.textContent = `${checkedBoxes.length} visits selected`; + + // Add cancel selection button + const cancelButton = document.createElement('button'); + cancelButton.className = 'btn btn-xs btn-neutral w-full mt-2'; + cancelButton.textContent = 'Cancel Selection'; + cancelButton.addEventListener('click', () => { + // Uncheck all checkboxes + checkedBoxes.forEach(checkbox => { + checkbox.checked = false; + }); + // Update UI to remove action buttons + this.updateMergeUI(container); + }); + + // Add elements to container + actionsContainer.appendChild(buttonGrid); + actionsContainer.appendChild(selectionText); + actionsContainer.appendChild(cancelButton); + + // Insert after the lowest visit item + lowestVisitItem.insertAdjacentElement('afterend', actionsContainer); + } + } + + // Show all checkboxes when at least one is checked + const checkboxContainers = container.querySelectorAll('.visit-checkbox-container'); + checkboxContainers.forEach(checkboxContainer => { + checkboxContainer.style.opacity = '1'; + checkboxContainer.style.pointerEvents = 'auto'; + }); + } + + /** + * Sends a request to merge the selected visits + * @param {Array} visitIds - Array of visit IDs to merge + */ + async mergeVisits(visitIds) { + if (!visitIds || visitIds.length < 2) { + showFlashMessage('error', 'At least 2 visits must be selected for merging'); + return; + } + + try { + const response = await fetch('/api/v1/visits/merge', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${this.apiKey}`, + }, + body: JSON.stringify({ + visit_ids: visitIds + }) + }); + + if (!response.ok) { + throw new Error('Failed to merge visits'); + } + + showFlashMessage('notice', 'Visits merged successfully'); + + // Refresh the visits list + this.fetchAndDisplayVisits(); + } catch (error) { + console.error('Error merging visits:', error); + showFlashMessage('error', 'Failed to merge visits'); + } + } + + /** + * Sends a request to update status for multiple visits + * @param {Array} visitIds - Array of visit IDs to update + * @param {string} status - The new status ('confirmed' or 'declined') + */ + async bulkUpdateVisitStatus(visitIds, status) { + if (!visitIds || visitIds.length === 0) { + showFlashMessage('error', 'No visits selected'); + return; + } + + try { + const response = await fetch('/api/v1/visits/bulk_update', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${this.apiKey}`, + }, + body: JSON.stringify({ + visit_ids: visitIds, + status: status + }) + }); + + if (!response.ok) { + throw new Error(`Failed to ${status} visits`); + } + + showFlashMessage('notice', `${visitIds.length} visits ${status === 'confirmed' ? 'confirmed' : 'declined'} successfully`); + + // Refresh the visits list + this.fetchAndDisplayVisits(); + } catch (error) { + console.error(`Error ${status}ing visits:`, error); + showFlashMessage('error', `Failed to ${status} visits`); + } + } + + /** + * Adds event listeners to visit items in the drawer + * @param {HTMLElement} container - The container element with visit items + */ + addVisitItemEventListeners(container) { + const visitItems = container.querySelectorAll('.visit-item'); + + // Remove existing highlight if any + this.clearVisitHighlight(); + + visitItems.forEach(item => { + // Location click handler + item.addEventListener('click', (event) => { + // Don't trigger if clicking on buttons or checkboxes + if (event.target.classList.contains('btn') || + event.target.classList.contains('checkbox') || + event.target.closest('.visit-checkbox-container')) { + return; + } + + const visitId = item.dataset.id; + const lat = parseFloat(item.dataset.lat); + const lng = parseFloat(item.dataset.lng); + + // Highlight the clicked visit + this.highlightVisit(visitId, item, [lat, lng]); + + if (!isNaN(lat) && !isNaN(lng)) { + this.map.setView([lat, lng], 15, { + animate: true, + duration: 1 + }); + } + }); + + // Confirm button handler + const confirmBtn = item.querySelector('.confirm-visit'); + confirmBtn?.addEventListener('click', async (event) => { + event.stopPropagation(); + const visitId = event.target.dataset.id; + try { + const response = await fetch(`/api/v1/visits/${visitId}`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${this.apiKey}`, + }, + body: JSON.stringify({ + visit: { + status: 'confirmed' + } + }) + }); + + if (!response.ok) throw new Error('Failed to confirm visit'); + + // Refresh visits list + this.fetchAndDisplayVisits(); + showFlashMessage('notice', 'Visit confirmed successfully'); + } catch (error) { + console.error('Error confirming visit:', error); + showFlashMessage('error', 'Failed to confirm visit'); + } + }); + + // Decline button handler + const declineBtn = item.querySelector('.decline-visit'); + declineBtn?.addEventListener('click', async (event) => { + event.stopPropagation(); + const visitId = event.target.dataset.id; + try { + const response = await fetch(`/api/v1/visits/${visitId}`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${this.apiKey}`, + }, + body: JSON.stringify({ + visit: { + status: 'declined' + } + }) + }); + + if (!response.ok) throw new Error('Failed to decline visit'); + + // Refresh visits list + this.fetchAndDisplayVisits(); + showFlashMessage('notice', 'Visit declined successfully'); + } catch (error) { + console.error('Error declining visit:', error); + showFlashMessage('error', 'Failed to decline visit'); + } + }); + }); + } + + /** + * Highlights a visit both in the panel and on the map + * @param {string} visitId - The ID of the visit to highlight + * @param {HTMLElement} item - The visit item element in the drawer + * @param {Array} coords - The coordinates [lat, lng] of the visit + */ + highlightVisit(visitId, item, coords) { + // Clear existing highlight + this.clearVisitHighlight(); + + // Store the current highlighted visit ID + this.highlightedVisitId = visitId; + + // Highlight in the drawer panel + if (item) { + item.classList.add('visit-highlighted'); + item.style.border = '2px solid #60a5fa'; + item.style.boxShadow = '0 0 0 2px #60a5fa'; + } + + // Find and highlight the circle on the map + if (coords && !isNaN(coords[0]) && !isNaN(coords[1])) { + console.log(`Highlighting visit ID: ${visitId} at coordinates [${coords[0]}, ${coords[1]}]`); + + // Create a Leaflet LatLng object from the coords + const targetLatLng = L.latLng(coords[0], coords[1]); + + // Helper function to find and highlight circles that are very close to the coords + const findAndHighlightCircles = (layerGroup) => { + layerGroup.eachLayer(layer => { + if (layer instanceof L.Circle) { + // Calculate the distance between circle center and target coordinates + const distance = targetLatLng.distanceTo(layer.getLatLng()); + + // Use a small distance threshold (2 meters) + if (distance < 2) { + console.log(`Found matching circle at distance: ${distance.toFixed(2)}m`); + + // Store original style for restoration + const originalStyle = { + color: layer.options.color, + weight: layer.options.weight, + fillOpacity: layer.options.fillOpacity + }; + + layer._originalStyle = originalStyle; + + // Apply highlighting + layer.setStyle({ + color: '#f59e0b', // Amber color for highlighting + weight: 4, + fillOpacity: 0.7 + }); + + // Add to the tracked highlights + this.highlightedCircles.push(layer); + } + } + }); + }; + + // Check in both layer groups + findAndHighlightCircles(this.visitCircles); + findAndHighlightCircles(this.confirmedVisitCircles); + + console.log(`Found ${this.highlightedCircles.length} circles to highlight`); + } + } + + /** + * Clears any existing visit highlight + */ + clearVisitHighlight() { + // Clear panel highlight + const highlightedItems = document.querySelectorAll('.visit-highlighted'); + highlightedItems.forEach(el => { + el.classList.remove('visit-highlighted'); + el.style.border = ''; + el.style.boxShadow = ''; + }); + + // Restore original circle styles for all highlighted circles + console.log(`Clearing ${this.highlightedCircles.length} highlighted circles`); + this.highlightedCircles.forEach(circle => { + if (circle && circle._originalStyle) { + circle.setStyle(circle._originalStyle); + } else if (circle) { + console.warn('Circle missing original style during cleanup'); + } + }); + + // Clear the array of highlighted circles + this.highlightedCircles = []; + this.highlightedVisitId = null; + } + + /** + * Fetches possible places for a visit and displays them in a popup + * @param {Object} visit - The visit object + */ + async fetchPossiblePlaces(visit) { + try { + // Close any existing popup before opening a new one + if (this.currentPopup) { + this.map.closePopup(this.currentPopup); + this.currentPopup = null; + } + + // Find and highlight the corresponding visit item in the drawer + if (visit.id) { + const visitItem = document.querySelector(`.visit-item[data-id="${visit.id}"]`); + if (visitItem && visit.place?.latitude && visit.place?.longitude) { + this.highlightVisit(visit.id, visitItem, [visit.place.latitude, visit.place.longitude]); + } + } + + const response = await fetch(`/api/v1/visits/${visit.id}/possible_places`, { + headers: { + 'Accept': 'application/json', + 'Authorization': `Bearer ${this.apiKey}`, + } + }); + + if (!response.ok) throw new Error('Failed to fetch possible places'); + + const possiblePlaces = await response.json(); + + // Format date and time + const startDate = new Date(visit.started_at); + const endDate = new Date(visit.ended_at); + const isSameDay = startDate.toDateString() === endDate.toDateString(); + + let dateTimeDisplay; + if (isSameDay) { + dateTimeDisplay = ` + ${startDate.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })}, + ${startDate.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', hour12: false })} - + ${endDate.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', hour12: false })} + `; + } else { + dateTimeDisplay = ` + ${startDate.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })}, + ${startDate.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', hour12: false })} - + ${endDate.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })}, + ${endDate.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', hour12: false })} + `; + } + + // Format duration + const durationText = this.formatDuration(visit.duration * 60); + + // Status with color coding + const statusColorClass = visit.status === 'confirmed' ? 'text-success' : 'text-warning'; + + // Create popup content with form and dropdown + const defaultName = visit.name; + const popupContent = ` +
+
+
+ ${dateTimeDisplay.trim()} +
+
+ + Duration: ${durationText}, + + + status: ${visit.status.charAt(0).toUpperCase() + visit.status.slice(1)} + + ${visit.place.latitude}, ${visit.place.longitude} +
+
+
+
+ +
+
+ +
+
+ + ${visit.status !== 'confirmed' ? ` + + + ` : ''} +
+
+
+ `; + + // Create and store the popup + const popup = L.popup({ + closeButton: true, + closeOnClick: true, + autoClose: true, + closeOnEscapeKey: true, + maxWidth: 450, // Set maximum width + minWidth: 300 // Set minimum width + }) + .setLatLng([visit.place.latitude, visit.place.longitude]) + .setContent(popupContent); + + // Store the current popup + this.currentPopup = popup; + + // Open the popup + popup.openOn(this.map); + + // Add form submit handler + this.addPopupFormEventListeners(visit); + } catch (error) { + console.error('Error fetching possible places:', error); + showFlashMessage('error', 'Failed to load possible places'); + } + } + + /** + * Adds event listeners to the popup form + * @param {Object} visit - The visit object + */ + addPopupFormEventListeners(visit) { + const form = document.querySelector(`.visit-name-form[data-visit-id="${visit.id}"]`); + if (form) { + form.addEventListener('submit', async (event) => { + event.preventDefault(); // Prevent form submission + event.stopPropagation(); // Stop event bubbling + const newName = event.target.querySelector('input').value; + const selectedPlaceId = event.target.querySelector('select[name="place"]').value; + + // Get the selected place name from the dropdown + const selectedOption = event.target.querySelector(`select[name="place"] option[value="${selectedPlaceId}"]`); + const selectedPlaceName = selectedOption ? selectedOption.textContent.trim() : ''; + + console.log('Selected new place:', selectedPlaceName); + + try { + const response = await fetch(`/api/v1/visits/${visit.id}`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${this.apiKey}`, + }, + body: JSON.stringify({ + visit: { + name: newName, + place_id: selectedPlaceId + } + }) + }); + + if (!response.ok) throw new Error('Failed to update visit'); + + // Get the updated visit data from the response + const updatedVisit = await response.json(); + + // Update the local visit object with the latest data + // This ensures that if the popup is opened again, it will show the updated values + visit.name = updatedVisit.name || newName; + visit.place = updatedVisit.place; + + // Use the selected place name for the update + const updatedName = selectedPlaceName || newName; + console.log('Updating visit name in drawer to:', updatedName); + + // Update the visit name in the drawer panel + const drawerVisitItem = document.querySelector(`.drawer .visit-item[data-id="${visit.id}"]`); + if (drawerVisitItem) { + const nameElement = drawerVisitItem.querySelector('.font-semibold'); + if (nameElement) { + console.log('Previous name in drawer:', nameElement.textContent); + nameElement.textContent = updatedName; + + // Add a highlight effect to make the change visible + nameElement.style.backgroundColor = 'rgba(255, 255, 0, 0.3)'; + setTimeout(() => { + nameElement.style.backgroundColor = ''; + }, 2000); + + console.log('Updated name in drawer to:', nameElement.textContent); + } + } + + // Close the popup + this.map.closePopup(this.currentPopup); + this.currentPopup = null; + showFlashMessage('notice', 'Visit updated successfully'); + } catch (error) { + console.error('Error updating visit:', error); + showFlashMessage('error', 'Failed to update visit'); + } + }); + + // Add event listeners for confirm and decline buttons + const confirmBtn = form.querySelector('.confirm-visit'); + const declineBtn = form.querySelector('.decline-visit'); + + confirmBtn?.addEventListener('click', (event) => this.handleStatusChange(event, visit.id, 'confirmed')); + declineBtn?.addEventListener('click', (event) => this.handleStatusChange(event, visit.id, 'declined')); + } + } + + /** + * Handles status change for a visit (confirm/decline) + * @param {Event} event - The click event + * @param {string} visitId - The visit ID + * @param {string} status - The new status ('confirmed' or 'declined') + */ + async handleStatusChange(event, visitId, status) { + event.preventDefault(); + event.stopPropagation(); + try { + const response = await fetch(`/api/v1/visits/${visitId}`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${this.apiKey}`, + }, + body: JSON.stringify({ + visit: { + status: status + } + }) + }); + + if (!response.ok) throw new Error(`Failed to ${status} visit`); + + if (this.currentPopup) { + this.map.closePopup(this.currentPopup); + this.currentPopup = null; + } + + this.fetchAndDisplayVisits(); + showFlashMessage('notice', `Visit ${status}d successfully`); + } catch (error) { + console.error(`Error ${status}ing visit:`, error); + showFlashMessage('error', `Failed to ${status} visit`); + } + } + + /** + * Truncates text to a specified length and adds ellipsis if needed + * @param {string} text - The text to truncate + * @param {number} maxLength - The maximum length + * @returns {string} Truncated text + */ + truncateText(text, maxLength) { + if (text.length <= maxLength) return text; + return text.substring(0, maxLength) + '...'; + } + + /** + * Gets the visits layer group for adding to the map controls + * @returns {L.LayerGroup} The visits layer group + */ + getVisitCirclesLayer() { + return this.visitCircles; + } + + /** + * Gets the confirmed visits layer group that's always visible + * @returns {L.LayerGroup} The confirmed visits layer group + */ + getConfirmedVisitCirclesLayer() { + return this.confirmedVisitCircles; + } +} diff --git a/app/javascript/styles/visits.css b/app/javascript/styles/visits.css new file mode 100644 index 00000000..c43cb036 --- /dev/null +++ b/app/javascript/styles/visits.css @@ -0,0 +1,17 @@ +.visit-checkbox-container { + z-index: 10; + opacity: 0; + transition: opacity 0.2s ease-in-out; +} +.visit-item { + position: relative; +} +.visit-item:hover .visit-checkbox-container { + opacity: 1 !important; +} +.leaflet-drawer.open { + transform: translateX(0); +} +.merge-visits-button { + margin: 8px 0; +} diff --git a/app/jobs/bulk_visits_suggesting_job.rb b/app/jobs/bulk_visits_suggesting_job.rb new file mode 100644 index 00000000..54174bca --- /dev/null +++ b/app/jobs/bulk_visits_suggesting_job.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +# This job is being run on daily basis at 00:05 to suggest visits for all users +# with the default timespan of 1 day. +class BulkVisitsSuggestingJob < ApplicationJob + queue_as :visit_suggesting + sidekiq_options retry: false + + # Passing timespan of more than 3 years somehow results in duplicated Places + def perform(start_at: 1.day.ago.beginning_of_day, end_at: 1.day.ago.end_of_day, user_ids: []) + return unless DawarichSettings.reverse_geocoding_enabled? + + users = user_ids.any? ? User.active.where(id: user_ids) : User.active + start_at = start_at.to_datetime + end_at = end_at.to_datetime + + time_chunks = Visits::TimeChunks.new(start_at:, end_at:).call + + users.active.find_each do |user| + next if user.tracked_points.empty? + + schedule_chunked_jobs(user, time_chunks) + end + end + + private + + def schedule_chunked_jobs(user, time_chunks) + time_chunks.each do |time_chunk| + VisitSuggestingJob.perform_later( + user_id: user.id, start_at: time_chunk.first, end_at: time_chunk.last + ) + end + end +end diff --git a/app/jobs/data_migrations/migrate_places_lonlat_job.rb b/app/jobs/data_migrations/migrate_places_lonlat_job.rb new file mode 100644 index 00000000..b71f0f55 --- /dev/null +++ b/app/jobs/data_migrations/migrate_places_lonlat_job.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +class DataMigrations::MigratePlacesLonlatJob < ApplicationJob + queue_as :default + + def perform(user_id) + user = User.find(user_id) + + # Find all places with nil lonlat + places_to_update = user.places.where(lonlat: nil) + + # For each place, set the lonlat value based on longitude and latitude + places_to_update.find_each do |place| + next if place.longitude.nil? || place.latitude.nil? + + # Set the lonlat to a PostGIS point with the proper SRID + # rubocop:disable Rails/SkipsModelValidations + place.update_column(:lonlat, "SRID=4326;POINT(#{place.longitude} #{place.latitude})") + # rubocop:enable Rails/SkipsModelValidations + end + + # Double check if there are any remaining places without lonlat + remaining = user.places.where(lonlat: nil) + return unless remaining.exists? + + # Log an error for these places + Rails.logger.error("Places with ID #{remaining.pluck(:id).join(', ')} for user #{user.id} could not be updated with lonlat values") + end +end diff --git a/app/jobs/data_migrations/migrate_points_latlon_job.rb b/app/jobs/data_migrations/migrate_points_latlon_job.rb new file mode 100644 index 00000000..6a831e34 --- /dev/null +++ b/app/jobs/data_migrations/migrate_points_latlon_job.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class DataMigrations::MigratePointsLatlonJob < ApplicationJob + queue_as :default + + def perform(user_id) + user = User.find(user_id) + + # rubocop:disable Rails/SkipsModelValidations + user.tracked_points.update_all('lonlat = ST_SetSRID(ST_MakePoint(longitude, latitude), 4326)') + # rubocop:enable Rails/SkipsModelValidations + end +end diff --git a/app/jobs/overland/batch_creating_job.rb b/app/jobs/overland/batch_creating_job.rb index 6c6ca3ea..7d1f0832 100644 --- a/app/jobs/overland/batch_creating_job.rb +++ b/app/jobs/overland/batch_creating_job.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class Overland::BatchCreatingJob < ApplicationJob + include PointValidation + queue_as :default def perform(params, user_id) @@ -12,15 +14,4 @@ class Overland::BatchCreatingJob < ApplicationJob Point.create!(location.merge(user_id:)) end end - - private - - def point_exists?(params, user_id) - Point.exists?( - latitude: params[:latitude], - longitude: params[:longitude], - timestamp: params[:timestamp], - user_id: - ) - end end diff --git a/app/jobs/owntracks/point_creating_job.rb b/app/jobs/owntracks/point_creating_job.rb index 56425396..9dfcc83e 100644 --- a/app/jobs/owntracks/point_creating_job.rb +++ b/app/jobs/owntracks/point_creating_job.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class Owntracks::PointCreatingJob < ApplicationJob + include PointValidation + queue_as :default def perform(point_params, user_id) @@ -10,13 +12,4 @@ class Owntracks::PointCreatingJob < ApplicationJob Point.create!(parsed_params.merge(user_id:)) end - - def point_exists?(params, user_id) - Point.exists?( - latitude: params[:latitude], - longitude: params[:longitude], - timestamp: params[:timestamp], - user_id: - ) - end end diff --git a/app/jobs/points/create_job.rb b/app/jobs/points/create_job.rb index 964c50f7..7dc3d261 100644 --- a/app/jobs/points/create_job.rb +++ b/app/jobs/points/create_job.rb @@ -9,7 +9,7 @@ class Points::CreateJob < ApplicationJob data.each_slice(1000) do |location_batch| Point.upsert_all( location_batch, - unique_by: %i[latitude longitude timestamp user_id], + unique_by: %i[lonlat timestamp user_id], returning: false ) end diff --git a/app/jobs/visit_suggesting_job.rb b/app/jobs/visit_suggesting_job.rb index b1a3e13d..2659d2d3 100644 --- a/app/jobs/visit_suggesting_job.rb +++ b/app/jobs/visit_suggesting_job.rb @@ -4,13 +4,25 @@ class VisitSuggestingJob < ApplicationJob queue_as :visit_suggesting sidekiq_options retry: false - def perform(user_ids: [], start_at: 1.day.ago, end_at: Time.current) - users = user_ids.any? ? User.where(id: user_ids) : User.all + # Passing timespan of more than 3 years somehow results in duplicated Places + def perform(user_id:, start_at:, end_at:) + user = User.find(user_id) - users.find_each do |user| - next if user.tracked_points.empty? + start_time = parse_date(start_at) + end_time = parse_date(end_at) - Visits::Suggest.new(user, start_at:, end_at:).call + # Create one-day chunks + current_time = start_time + while current_time < end_time + chunk_end = [current_time + 1.day, end_time].min + Visits::Suggest.new(user, start_at: current_time, end_at: chunk_end).call + current_time += 1.day end end + + private + + def parse_date(date) + date.is_a?(String) ? Time.zone.parse(date) : date.to_datetime + end end diff --git a/app/models/area.rb b/app/models/area.rb index c12ba451..589ccd9d 100644 --- a/app/models/area.rb +++ b/app/models/area.rb @@ -8,5 +8,8 @@ class Area < ApplicationRecord validates :name, :latitude, :longitude, :radius, presence: true + alias_attribute :lon, :longitude + alias_attribute :lat, :latitude + def center = [latitude.to_f, longitude.to_f] end diff --git a/app/models/concerns/distanceable.rb b/app/models/concerns/distanceable.rb new file mode 100644 index 00000000..6b2d1546 --- /dev/null +++ b/app/models/concerns/distanceable.rb @@ -0,0 +1,110 @@ +# frozen_string_literal: true + +module Distanceable + extend ActiveSupport::Concern + + DISTANCE_UNITS = { + km: 1000, # to meters + mi: 1609.34, # to meters + m: 1, # already in meters + ft: 0.3048, # to meters + yd: 0.9144 # to meters + }.freeze + + module ClassMethods + def total_distance(points = nil, unit = :km) + # Handle method being called directly on relation vs with array + if points.nil? + calculate_distance_for_relation(unit) + else + calculate_distance_for_array(points, unit) + end + end + + private + + def calculate_distance_for_relation(unit) + unless DISTANCE_UNITS.key?(unit.to_sym) + raise ArgumentError, "Invalid unit. Supported units are: #{DISTANCE_UNITS.keys.join(', ')}" + end + + distance_in_meters = connection.select_value(<<-SQL.squish) + WITH points_with_previous AS ( + SELECT + lonlat, + LAG(lonlat) OVER (ORDER BY timestamp) as prev_lonlat + FROM (#{to_sql}) AS points + ) + SELECT COALESCE( + SUM( + ST_Distance( + lonlat::geography, + prev_lonlat::geography + ) + ), + 0 + ) + FROM points_with_previous + WHERE prev_lonlat IS NOT NULL + SQL + + distance_in_meters.to_f / DISTANCE_UNITS[unit.to_sym] + end + + def calculate_distance_for_array(points, unit = :km) + unless DISTANCE_UNITS.key?(unit.to_sym) + raise ArgumentError, "Invalid unit. Supported units are: #{DISTANCE_UNITS.keys.join(', ')}" + end + + return 0 if points.length < 2 + + total_meters = points.each_cons(2).sum do |point1, point2| + connection.select_value(<<-SQL.squish) + SELECT ST_Distance( + ST_GeomFromEWKT('#{point1.lonlat}')::geography, + ST_GeomFromEWKT('#{point2.lonlat}')::geography + ) + SQL + end + + total_meters.to_f / DISTANCE_UNITS[unit.to_sym] + end + end + + def distance_to(other_point, unit = :km) + unless DISTANCE_UNITS.key?(unit.to_sym) + raise ArgumentError, "Invalid unit. Supported units are: #{DISTANCE_UNITS.keys.join(', ')}" + end + + # Extract coordinates based on what type other_point is + other_lonlat = extract_point(other_point) + return nil if other_lonlat.nil? + + # Calculate distance in meters using PostGIS + distance_in_meters = self.class.connection.select_value(<<-SQL.squish) + SELECT ST_Distance( + ST_GeomFromEWKT('#{lonlat}')::geography, + ST_GeomFromEWKT('#{other_lonlat}')::geography + ) + SQL + + # Convert to requested unit + distance_in_meters.to_f / DISTANCE_UNITS[unit.to_sym] + end + + private + + def extract_point(point) + case point + when Array + unless point.length == 2 + raise ArgumentError, + 'Coordinates array must contain exactly 2 elements [latitude, longitude]' + end + + RGeo::Geographic.spherical_factory(srid: 4326).point(point[1], point[0]) + when self.class + point.lonlat + end + end +end diff --git a/app/models/concerns/nearable.rb b/app/models/concerns/nearable.rb new file mode 100644 index 00000000..270e2b9c --- /dev/null +++ b/app/models/concerns/nearable.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +module Nearable + extend ActiveSupport::Concern + + DISTANCE_UNITS = { + km: 1000, # to meters + mi: 1609.34, # to meters + m: 1, # already in meters + ft: 0.3048, # to meters + yd: 0.9144 # to meters + }.freeze + + class_methods do + # It accepts an array of coordinates [latitude, longitude] + # and an optional radius and distance unit + + # rubocop:disable Metrics/MethodLength + def near(*args) + latitude, longitude, radius, unit = extract_coordinates_and_options(*args) + + unless DISTANCE_UNITS.key?(unit.to_sym) + raise ArgumentError, "Invalid unit. Supported units are: #{DISTANCE_UNITS.keys.join(', ')}" + end + + # Convert radius to meters for ST_DWithin + radius_in_meters = radius * DISTANCE_UNITS[unit.to_sym] + + # Create a point from the given coordinates + point = "SRID=4326;POINT(#{longitude} #{latitude})" + + where(<<-SQL.squish) + ST_DWithin( + lonlat::geography, + ST_GeomFromEWKT('#{point}')::geography, + #{radius_in_meters} + ) + SQL + end + + def with_distance(*args) + latitude, longitude, unit = extract_coordinates_and_options(*args) + + unless DISTANCE_UNITS.key?(unit.to_sym) + raise ArgumentError, "Invalid unit. Supported units are: #{DISTANCE_UNITS.keys.join(', ')}" + end + + point = "SRID=4326;POINT(#{longitude} #{latitude})" + conversion_factor = 1.0 / DISTANCE_UNITS[unit.to_sym] + + select(<<-SQL.squish) + #{table_name}.*, + ST_Distance( + lonlat::geography, + ST_GeomFromEWKT('#{point}')::geography + ) * #{conversion_factor} as distance_in_#{unit} + SQL + end + # rubocop:enable Metrics/MethodLength + + private + + def extract_coordinates_and_options(*args) + coords = args.first + if !coords.is_a?(Array) || coords.length != 2 + raise ArgumentError, + 'First argument must be coordinates array containing exactly 2 elements [latitude, longitude]' + end + + [coords[0], coords[1], *args[1..]].tap do |extracted| + # Set default values for missing options + extracted[2] ||= 1 if extracted.length < 3 # default radius + extracted[3] ||= :km if extracted.length < 4 # default unit + end + end + end +end diff --git a/app/models/concerns/point_validation.rb b/app/models/concerns/point_validation.rb new file mode 100644 index 00000000..b57d0ba6 --- /dev/null +++ b/app/models/concerns/point_validation.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module PointValidation + extend ActiveSupport::Concern + + # Check if a point with the same coordinates, timestamp, and user_id already exists + def point_exists?(params, user_id) + # Ensure the coordinates are valid + longitude = params[:longitude].to_f + latitude = params[:latitude].to_f + + # Check if longitude and latitude are valid values + return false if longitude.zero? && latitude.zero? + return false if longitude.abs > 180 || latitude.abs > 90 + + # Use where with parameter binding and then exists? + Point.where( + 'ST_SetSRID(ST_MakePoint(?, ?), 4326) = lonlat AND timestamp = ? AND user_id = ?', + longitude, latitude, params[:timestamp].to_i, user_id + ).exists? + end +end diff --git a/app/models/place.rb b/app/models/place.rb index 2ed0aa2d..836358c8 100644 --- a/app/models/place.rb +++ b/app/models/place.rb @@ -1,17 +1,27 @@ # frozen_string_literal: true class Place < ApplicationRecord - DEFAULT_NAME = 'Suggested place' - reverse_geocoded_by :latitude, :longitude + include Nearable + include Distanceable - validates :name, :longitude, :latitude, presence: true + DEFAULT_NAME = 'Suggested place' + + validates :name, :lonlat, presence: true has_many :visits, dependent: :destroy has_many :place_visits, dependent: :destroy - has_many :suggested_visits, through: :place_visits, source: :visit + has_many :suggested_visits, -> { distinct }, through: :place_visits, source: :visit enum :source, { manual: 0, photon: 1 } + def lon + lonlat.x + end + + def lat + lonlat.y + end + def async_reverse_geocode return unless DawarichSettings.reverse_geocoding_enabled? @@ -21,4 +31,20 @@ class Place < ApplicationRecord def reverse_geocoded? geodata.present? end + + def osm_id + geodata['properties']['osm_id'] + end + + def osm_key + geodata['properties']['osm_key'] + end + + def osm_value + geodata['properties']['osm_value'] + end + + def osm_type + geodata['properties']['osm_type'] + end end diff --git a/app/models/point.rb b/app/models/point.rb index f28b8043..38970d91 100644 --- a/app/models/point.rb +++ b/app/models/point.rb @@ -1,18 +1,20 @@ # frozen_string_literal: true class Point < ApplicationRecord - reverse_geocoded_by :latitude, :longitude + include Nearable + include Distanceable belongs_to :import, optional: true, counter_cache: true belongs_to :visit, optional: true belongs_to :user - validates :latitude, :longitude, :timestamp, presence: true - validates :timestamp, uniqueness: { - scope: %i[latitude longitude user_id], + validates :timestamp, :lonlat, presence: true + validates :lonlat, uniqueness: { + scope: %i[timestamp user_id], message: 'already has a point at this location and time for this user', index: true } + enum :battery_status, { unknown: 0, unplugged: 1, charging: 2, full: 3 }, suffix: true enum :trigger, { unknown: 0, background_event: 1, circular_region_event: 2, beacon_event: 3, @@ -34,7 +36,7 @@ class Point < ApplicationRecord end def recorded_at - Time.zone.at(timestamp) + @recorded_at ||= Time.zone.at(timestamp) end def async_reverse_geocode @@ -47,14 +49,23 @@ class Point < ApplicationRecord reverse_geocoded_at.present? end + def lon + lonlat.x + end + + def lat + lonlat.y + end + private + # rubocop:disable Metrics/MethodLength Metrics/AbcSize def broadcast_coordinates PointsChannel.broadcast_to( user, [ - latitude.to_f, - longitude.to_f, + lat, + lon, battery.to_s, altitude.to_s, timestamp.to_s, @@ -64,4 +75,5 @@ class Point < ApplicationRecord ] ) end + # rubocop:enable Metrics/MethodLength end diff --git a/app/models/stat.rb b/app/models/stat.rb index 6b2d56dd..36bb0be3 100644 --- a/app/models/stat.rb +++ b/app/models/stat.rb @@ -37,7 +37,7 @@ class Stat < ApplicationRecord 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) + distance = Point.total_distance(daily_points, DISTANCE_UNIT) [index, distance.round(2)] end end @@ -48,10 +48,4 @@ class Stat < ApplicationRecord 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/trip.rb b/app/models/trip.rb index 5e094078..098feb82 100644 --- a/app/models/trip.rb +++ b/app/models/trip.rb @@ -14,7 +14,6 @@ class Trip < ApplicationRecord calculate_distance end - def points user.tracked_points.where(timestamp: started_at.to_i..ended_at.to_i).order(:timestamp) end @@ -47,18 +46,13 @@ class Trip < ApplicationRecord end def calculate_path - trip_path = Tracks::BuildPath.new(points.pluck(:latitude, :longitude)).call + trip_path = Tracks::BuildPath.new(points.pluck(:lonlat)).call self.path = trip_path end - def calculate_distance - distance = 0 - - points.each_cons(2) do |point1, point2| - distance += DistanceCalculator.new(point1, point2).call - end + distance = Point.total_distance(points, DISTANCE_UNIT) self.distance = distance.round end diff --git a/app/models/user.rb b/app/models/user.rb index 97fb9fe0..ee4d84f8 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -16,6 +16,8 @@ class User < ApplicationRecord has_many :trips, dependent: :destroy after_create :create_api_key + after_create :import_sample_points + after_commit :activate, on: :create, if: -> { DawarichSettings.self_hosted? } before_save :sanitize_input validates :email, presence: true @@ -24,6 +26,8 @@ class User < ApplicationRecord attribute :admin, :boolean, default: false + enum :status, { inactive: 0, active: 1 } + def safe_settings Users::SafeSettings.new(settings) end @@ -104,9 +108,31 @@ class User < ApplicationRecord save end + def activate + update(status: :active) + end + def sanitize_input settings['immich_url']&.gsub!(%r{/+\z}, '') settings['photoprism_url']&.gsub!(%r{/+\z}, '') settings.try(:[], 'maps')&.try(:[], 'url')&.strip! end + + def import_sample_points + return unless Rails.env.development? || + Rails.env.production? || + (Rails.env.test? && ENV['IMPORT_SAMPLE_POINTS']) + + raw_data = Hash.from_xml( + File.read(Rails.root.join('lib/assets/sample_points.gpx')) + ) + + import = imports.create( + name: 'DELETE_ME_this_is_a_demo_import_DELETE_ME', + source: 'gpx', + raw_data: + ) + + ImportJob.perform_later(id, import.id) + end end diff --git a/app/models/visit.rb b/app/models/visit.rb index f46d219b..2ca3faf8 100644 --- a/app/models/visit.rb +++ b/app/models/visit.rb @@ -29,14 +29,30 @@ class Visit < ApplicationRecord return area&.radius if area.present? radius = points.map do |point| - Geocoder::Calculations.distance_between(center, [point.latitude, point.longitude]) + Geocoder::Calculations.distance_between(center, [point.lat, point.lon]) end.max radius && radius >= 15 ? radius : 15 end def center - area.present? ? area.to_coordinates : place.to_coordinates + if area.present? + [area.lat, area.lon] + elsif place.present? + [place.lat, place.lon] + else + center_from_points + end + end + + def center_from_points + return [0, 0] if points.empty? + + lat_sum = points.sum(&:lat) + lon_sum = points.sum(&:lon) + count = points.size.to_f + + [lat_sum / count, lon_sum / count] end def async_reverse_geocode diff --git a/app/serializers/api/place_serializer.rb b/app/serializers/api/place_serializer.rb new file mode 100644 index 00000000..2857379a --- /dev/null +++ b/app/serializers/api/place_serializer.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +class Api::PlaceSerializer + def initialize(place) + @place = place + end + + def call + { + id: place.id, + name: place.name, + longitude: place.lon, + latitude: place.lat, + city: place.city, + country: place.country, + source: place.source, + geodata: place.geodata, + reverse_geocoded_at: place.reverse_geocoded_at + } + end + + private + + attr_reader :place +end diff --git a/app/serializers/api/slim_point_serializer.rb b/app/serializers/api/slim_point_serializer.rb index 76436116..dfe224e0 100644 --- a/app/serializers/api/slim_point_serializer.rb +++ b/app/serializers/api/slim_point_serializer.rb @@ -8,8 +8,8 @@ class Api::SlimPointSerializer def call { id: point.id, - latitude: point.latitude, - longitude: point.longitude, + latitude: point.lat.to_s, + longitude: point.lon.to_s, timestamp: point.timestamp } end diff --git a/app/serializers/api/visit_serializer.rb b/app/serializers/api/visit_serializer.rb new file mode 100644 index 00000000..8bf8ff71 --- /dev/null +++ b/app/serializers/api/visit_serializer.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +class Api::VisitSerializer + def initialize(visit) + @visit = visit + end + + def call + { + id: visit.id, + area_id: visit.area_id, + user_id: visit.user_id, + started_at: visit.started_at, + ended_at: visit.ended_at, + duration: visit.duration, + name: visit.name, + status: visit.status, + place: { + latitude: visit.place&.lat || visit.area&.latitude, + longitude: visit.place&.lon || visit.area&.longitude, + id: visit.place&.id + } + } + end + + private + + attr_reader :visit +end diff --git a/app/serializers/export_serializer.rb b/app/serializers/export_serializer.rb index 15f5f948..b7351314 100644 --- a/app/serializers/export_serializer.rb +++ b/app/serializers/export_serializer.rb @@ -22,8 +22,8 @@ class ExportSerializer def export_point(point) { - lat: point.latitude, - lon: point.longitude, + lat: point.lat.to_s, + lon: point.lon.to_s, bs: battery_status(point), batt: point.battery, p: point.ping, diff --git a/app/serializers/point_serializer.rb b/app/serializers/point_serializer.rb index 270e3e25..6dfe5502 100644 --- a/app/serializers/point_serializer.rb +++ b/app/serializers/point_serializer.rb @@ -1,14 +1,20 @@ # frozen_string_literal: true class PointSerializer - EXCLUDED_ATTRIBUTES = %w[created_at updated_at visit_id id import_id user_id raw_data].freeze + EXCLUDED_ATTRIBUTES = %w[ + created_at updated_at visit_id id import_id user_id raw_data lonlat + reverse_geocoded_at + ].freeze def initialize(point) @point = point end def call - point.attributes.except(*EXCLUDED_ATTRIBUTES) + point.attributes.except(*EXCLUDED_ATTRIBUTES).tap do |attributes| + attributes['latitude'] = point.lat.to_s + attributes['longitude'] = point.lon.to_s + end end private diff --git a/app/serializers/points/geojson_serializer.rb b/app/serializers/points/geojson_serializer.rb index 40c1048f..1fd9a810 100644 --- a/app/serializers/points/geojson_serializer.rb +++ b/app/serializers/points/geojson_serializer.rb @@ -14,7 +14,7 @@ class Points::GeojsonSerializer type: 'Feature', geometry: { type: 'Point', - coordinates: [point.longitude, point.latitude] + coordinates: [point.lon.to_s, point.lat.to_s] }, properties: PointSerializer.new(point).call } diff --git a/app/serializers/points/gpx_serializer.rb b/app/serializers/points/gpx_serializer.rb index d4fd2929..fa088ecd 100644 --- a/app/serializers/points/gpx_serializer.rb +++ b/app/serializers/points/gpx_serializer.rb @@ -17,8 +17,8 @@ class Points::GpxSerializer points.each do |point| track_segment.points << GPX::TrackPoint.new( - lat: point.latitude.to_f, - lon: point.longitude.to_f, + lat: point.lat, + lon: point.lon, elevation: point.altitude.to_f, time: point.recorded_at ) diff --git a/app/services/areas/visits/create.rb b/app/services/areas/visits/create.rb index 768f5f9f..16efd3ef 100644 --- a/app/services/areas/visits/create.rb +++ b/app/services/areas/visits/create.rb @@ -38,7 +38,7 @@ class Areas::Visits::Create end points = Point.where(user_id: user.id) - .near([area.latitude, area.longitude], area_radius, units: DISTANCE_UNIT) + .near([area.latitude, area.longitude], area_radius, DISTANCE_UNIT) .order(timestamp: :asc) # check if all points within the area are assigned to a visit diff --git a/app/services/distance_calculator.rb b/app/services/distance_calculator.rb deleted file mode 100644 index d00d070b..00000000 --- a/app/services/distance_calculator.rb +++ /dev/null @@ -1,18 +0,0 @@ -# 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/geojson/import_parser.rb b/app/services/geojson/import_parser.rb index ff78e6f6..13b8651c 100644 --- a/app/services/geojson/import_parser.rb +++ b/app/services/geojson/import_parser.rb @@ -27,8 +27,7 @@ class Geojson::ImportParser def point_exists?(params, user_id) Point.exists?( - latitude: params[:latitude], - longitude: params[:longitude], + lonlat: params[:lonlat], timestamp: params[:timestamp], user_id: ) diff --git a/app/services/geojson/params.rb b/app/services/geojson/params.rb index 21faf941..8ae4fb41 100644 --- a/app/services/geojson/params.rb +++ b/app/services/geojson/params.rb @@ -33,8 +33,7 @@ class Geojson::Params def build_point(feature) { - latitude: feature[:geometry][:coordinates][1], - longitude: feature[:geometry][:coordinates][0], + lonlat: "POINT(#{feature[:geometry][:coordinates][0]} #{feature[:geometry][:coordinates][1]})", battery_status: feature[:properties][:battery_state], battery: battery_level(feature[:properties][:battery_level]), timestamp: timestamp(feature), @@ -64,8 +63,7 @@ class Geojson::Params def build_line_point(point) { - latitude: point[1], - longitude: point[0], + lonlat: "POINT(#{point[0]} #{point[1]})", timestamp: timestamp(point), raw_data: point } diff --git a/app/services/google_maps/phone_takeout_parser.rb b/app/services/google_maps/phone_takeout_parser.rb index 8721f8d5..a30b34d3 100644 --- a/app/services/google_maps/phone_takeout_parser.rb +++ b/app/services/google_maps/phone_takeout_parser.rb @@ -16,14 +16,12 @@ class GoogleMaps::PhoneTakeoutParser points_data.compact.each.with_index(1) do |point_data, index| next if Point.exists?( timestamp: point_data[:timestamp], - latitude: point_data[:latitude], - longitude: point_data[:longitude], + lonlat: point_data[:lonlat], user_id: ) Point.create( - latitude: point_data[:latitude], - longitude: point_data[:longitude], + lonlat: point_data[:lonlat], timestamp: point_data[:timestamp], raw_data: point_data[:raw_data], accuracy: point_data[:accuracy], @@ -72,8 +70,7 @@ class GoogleMaps::PhoneTakeoutParser def point_hash(lat, lon, timestamp, raw_data) { - latitude: lat.to_f, - longitude: lon.to_f, + lonlat: "POINT(#{lon.to_f} #{lat.to_f})", timestamp:, raw_data:, accuracy: raw_data['accuracyMeters'], diff --git a/app/services/google_maps/records_importer.rb b/app/services/google_maps/records_importer.rb index c7edfb1f..ec9555f7 100644 --- a/app/services/google_maps/records_importer.rb +++ b/app/services/google_maps/records_importer.rb @@ -25,8 +25,7 @@ class GoogleMaps::RecordsImporter # rubocop:disable Metrics/MethodLength def prepare_location_data(location) { - latitude: location['latitudeE7'].to_f / 10**7, - longitude: location['longitudeE7'].to_f / 10**7, + lonlat: "POINT(#{location['longitudeE7'].to_f / 10**7} #{location['latitudeE7'].to_f / 10**7})", timestamp: parse_timestamp(location), altitude: location['altitude'], velocity: location['velocity'], @@ -47,7 +46,7 @@ class GoogleMaps::RecordsImporter # rubocop:disable Rails/SkipsModelValidations Point.upsert_all( unique_batch, - unique_by: %i[latitude longitude timestamp user_id], + unique_by: %i[lonlat timestamp user_id], returning: false, on_duplicate: :skip ) @@ -59,8 +58,7 @@ class GoogleMaps::RecordsImporter def deduplicate_batch(batch) batch.uniq do |record| [ - record[:latitude].round(7), - record[:longitude].round(7), + record[:lonlat], record[:timestamp], record[:user_id] ] diff --git a/app/services/google_maps/semantic_history_parser.rb b/app/services/google_maps/semantic_history_parser.rb index 83d2486b..77984c09 100644 --- a/app/services/google_maps/semantic_history_parser.rb +++ b/app/services/google_maps/semantic_history_parser.rb @@ -3,87 +3,135 @@ class GoogleMaps::SemanticHistoryParser include Imports::Broadcaster + BATCH_SIZE = 1000 attr_reader :import, :user_id def initialize(import, user_id) @import = import @user_id = user_id + @current_index = 0 end def call points_data = parse_json - points_data.each.with_index(1) do |point_data, index| - next if Point.exists?( - timestamp: point_data[:timestamp], - latitude: point_data[:latitude], - longitude: point_data[:longitude], - user_id: - ) - - Point.create( - latitude: point_data[:latitude], - longitude: point_data[:longitude], - timestamp: point_data[:timestamp], - raw_data: point_data[:raw_data], - topic: 'Google Maps Timeline Export', - tracker_id: 'google-maps-timeline-export', - import_id: import.id, - user_id: - ) - - broadcast_import_progress(import, index) + points_data.each_slice(BATCH_SIZE) do |batch| + @current_index += batch.size + process_batch(batch) + broadcast_import_progress(import, @current_index) end end private + def process_batch(batch) + records = batch.map { |point_data| prepare_point_data(point_data) } + + # rubocop:disable Rails/SkipsModelValidations + Point.upsert_all( + records, + unique_by: %i[lonlat timestamp user_id], + returning: false, + on_duplicate: :skip + ) + # rubocop:enable Rails/SkipsModelValidations + rescue StandardError => e + create_notification("Failed to process location batch: #{e.message}") + end + + def prepare_point_data(point_data) + { + lonlat: point_data[:lonlat], + timestamp: point_data[:timestamp], + raw_data: point_data[:raw_data], + topic: 'Google Maps Timeline Export', + tracker_id: 'google-maps-timeline-export', + import_id: import.id, + user_id: user_id, + created_at: Time.current, + updated_at: Time.current + } + end + + def create_notification(message) + Notification.create!( + user_id: user_id, + title: 'Google Maps Timeline Import Error', + content: message, + kind: :error + ) + end + def parse_json import.raw_data['timelineObjects'].flat_map do |timeline_object| - if timeline_object['activitySegment'].present? - if timeline_object['activitySegment']['startLocation'].blank? - next if timeline_object['activitySegment']['waypointPath'].blank? + parse_timeline_object(timeline_object) + end.compact + end - timeline_object['activitySegment']['waypointPath']['waypoints'].map do |waypoint| - { - latitude: waypoint['latE7'].to_f / 10**7, - longitude: waypoint['lngE7'].to_f / 10**7, - timestamp: Timestamps.parse_timestamp(timeline_object['activitySegment']['duration']['startTimestamp'] || timeline_object['activitySegment']['duration']['startTimestampMs']), - raw_data: timeline_object - } - end - else - { - latitude: timeline_object['activitySegment']['startLocation']['latitudeE7'].to_f / 10**7, - longitude: timeline_object['activitySegment']['startLocation']['longitudeE7'].to_f / 10**7, - timestamp: Timestamps.parse_timestamp(timeline_object['activitySegment']['duration']['startTimestamp'] || timeline_object['activitySegment']['duration']['startTimestampMs']), - raw_data: timeline_object - } - end - elsif timeline_object['placeVisit'].present? - if timeline_object.dig('placeVisit', 'location', 'latitudeE7').present? && - timeline_object.dig('placeVisit', 'location', 'longitudeE7').present? - { - latitude: timeline_object['placeVisit']['location']['latitudeE7'].to_f / 10**7, - longitude: timeline_object['placeVisit']['location']['longitudeE7'].to_f / 10**7, - timestamp: Timestamps.parse_timestamp(timeline_object['placeVisit']['duration']['startTimestamp'] || timeline_object['placeVisit']['duration']['startTimestampMs']), - raw_data: timeline_object - } - elsif timeline_object.dig('placeVisit', 'otherCandidateLocations')&.any? - point = timeline_object['placeVisit']['otherCandidateLocations'][0] + def parse_timeline_object(timeline_object) + if timeline_object['activitySegment'].present? + parse_activity_segment(timeline_object['activitySegment']) + elsif timeline_object['placeVisit'].present? + parse_place_visit(timeline_object['placeVisit']) + end + end - next unless point['latitudeE7'].present? && point['longitudeE7'].present? + def parse_activity_segment(activity) + if activity['startLocation'].blank? + parse_waypoints(activity) + else + build_point_from_location( + longitude: activity['startLocation']['longitudeE7'], + latitude: activity['startLocation']['latitudeE7'], + timestamp: activity['duration']['startTimestamp'] || activity['duration']['startTimestampMs'], + raw_data: activity + ) + end + end - { - latitude: point['latitudeE7'].to_f / 10**7, - longitude: point['longitudeE7'].to_f / 10**7, - timestamp: Timestamps.parse_timestamp(timeline_object['placeVisit']['duration']['startTimestamp'] || timeline_object['placeVisit']['duration']['startTimestampMs']), - raw_data: timeline_object - } - else - next - end - end - end.reject(&:blank?) + def parse_waypoints(activity) + return if activity['waypointPath'].blank? + + activity['waypointPath']['waypoints'].map do |waypoint| + build_point_from_location( + longitude: waypoint['lngE7'], + latitude: waypoint['latE7'], + timestamp: activity['duration']['startTimestamp'] || activity['duration']['startTimestampMs'], + raw_data: activity + ) + end + end + + def parse_place_visit(place_visit) + if place_visit.dig('location', 'latitudeE7').present? && + place_visit.dig('location', 'longitudeE7').present? + build_point_from_location( + longitude: place_visit['location']['longitudeE7'], + latitude: place_visit['location']['latitudeE7'], + timestamp: place_visit['duration']['startTimestamp'] || place_visit['duration']['startTimestampMs'], + raw_data: place_visit + ) + elsif (candidate = place_visit.dig('otherCandidateLocations', 0)) + parse_candidate_location(candidate, place_visit) + end + end + + def parse_candidate_location(candidate, place_visit) + return unless candidate['latitudeE7'].present? && candidate['longitudeE7'].present? + + build_point_from_location( + longitude: candidate['longitudeE7'], + latitude: candidate['latitudeE7'], + timestamp: place_visit['duration']['startTimestamp'] || place_visit['duration']['startTimestampMs'], + raw_data: place_visit + ) + end + + def build_point_from_location(longitude:, latitude:, timestamp:, raw_data:) + { + lonlat: "POINT(#{longitude.to_f / 10**7} #{latitude.to_f / 10**7})", + timestamp: Timestamps.parse_timestamp(timestamp), + raw_data: raw_data + } end end diff --git a/app/services/gpx/track_importer.rb b/app/services/gpx/track_importer.rb new file mode 100644 index 00000000..62f327cc --- /dev/null +++ b/app/services/gpx/track_importer.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +class Gpx::TrackImporter + include Imports::Broadcaster + + attr_reader :import, :json, :user_id + + def initialize(import, user_id) + @import = import + @json = import.raw_data + @user_id = user_id + end + + def call + tracks = json['gpx']['trk'] + tracks_arr = tracks.is_a?(Array) ? tracks : [tracks] + + points = tracks_arr.map { parse_track(_1) }.flatten.compact + points_data = points.map.with_index(1) { |point, index| prepare_point(point, index) }.compact + + bulk_insert_points(points_data) + end + + private + + def parse_track(track) + return if track['trkseg'].blank? + + segments = track['trkseg'] + segments_array = segments.is_a?(Array) ? segments : [segments] + + segments_array.compact.map { |segment| segment['trkpt'] } + end + + def prepare_point(point, index) + return if point['lat'].blank? || point['lon'].blank? || point['time'].blank? + + { + lonlat: "POINT(#{point['lon'].to_d} #{point['lat'].to_d})", + altitude: point['ele'].to_i, + timestamp: Time.parse(point['time']).to_i, + import_id: import.id, + velocity: speed(point), + raw_data: point, + user_id: user_id, + created_at: Time.current, + updated_at: Time.current + } + end + + def bulk_insert_points(batch) + unique_batch = batch.uniq { |record| [record[:lonlat], record[:timestamp], record[:user_id]] } + + # rubocop:disable Rails/SkipsModelValidations + Point.upsert_all( + unique_batch, + unique_by: %i[lonlat timestamp user_id], + returning: false, + on_duplicate: :skip + ) + # rubocop:enable Rails/SkipsModelValidations + + broadcast_import_progress(import, unique_batch.size) + rescue StandardError => e + create_notification("Failed to process GPX track: #{e.message}") + end + + def create_notification(message) + Notification.create!( + user_id: user_id, + title: 'GPX Import Error', + content: message, + kind: :error + ) + end + + def speed(point) + return if point['extensions'].blank? + + ( + point.dig('extensions', 'speed') || point.dig('extensions', 'TrackPointExtension', 'speed') + ).to_f.round(1) + end +end diff --git a/app/services/gpx/track_parser.rb b/app/services/gpx/track_parser.rb deleted file mode 100644 index 20c2837a..00000000 --- a/app/services/gpx/track_parser.rb +++ /dev/null @@ -1,68 +0,0 @@ -# frozen_string_literal: true - -class Gpx::TrackParser - include Imports::Broadcaster - - attr_reader :import, :json, :user_id - - def initialize(import, user_id) - @import = import - @json = import.raw_data - @user_id = user_id - end - - def call - tracks = json['gpx']['trk'] - tracks_arr = tracks.is_a?(Array) ? tracks : [tracks] - - tracks_arr.map { parse_track(_1) }.flatten.compact.each.with_index(1) do |point, index| - create_point(point, index) - end - end - - private - - def parse_track(track) - return if track['trkseg'].blank? - - segments = track['trkseg'] - segments_array = segments.is_a?(Array) ? segments : [segments] - - segments_array.compact.map { |segment| segment['trkpt'] } - end - - def create_point(point, index) - return if point['lat'].blank? || point['lon'].blank? || point['time'].blank? - return if point_exists?(point) - - Point.create( - latitude: point['lat'].to_d, - longitude: point['lon'].to_d, - altitude: point['ele'].to_i, - timestamp: Time.parse(point['time']).to_i, - import_id: import.id, - velocity: speed(point), - raw_data: point, - user_id: - ) - - broadcast_import_progress(import, index) - end - - def point_exists?(point) - Point.exists?( - latitude: point['lat'].to_d, - longitude: point['lon'].to_d, - timestamp: Time.parse(point['time']).to_i, - user_id: - ) - end - - def speed(point) - return if point['extensions'].blank? - - ( - point.dig('extensions', 'speed') || point.dig('extensions', 'TrackPointExtension', 'speed') - ).to_f.round(1) - end -end diff --git a/app/services/imports/create.rb b/app/services/imports/create.rb index 16374170..e34661b1 100644 --- a/app/services/imports/create.rb +++ b/app/services/imports/create.rb @@ -26,8 +26,8 @@ class Imports::Create 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 'owntracks' then OwnTracks::Importer + when 'gpx' then Gpx::TrackImporter when 'geojson' then Geojson::ImportParser when 'immich_api', 'photoprism_api' then Photos::ImportParser end diff --git a/app/services/imports/destroy.rb b/app/services/imports/destroy.rb new file mode 100644 index 00000000..55efb008 --- /dev/null +++ b/app/services/imports/destroy.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class Imports::Destroy + attr_reader :user, :import + + def initialize(user, import) + @user = user + @import = import + end + + def call + @import.destroy! + + BulkStatsCalculatingJob.perform_later(@user.id) + end +end diff --git a/app/services/overland/params.rb b/app/services/overland/params.rb index b712ffce..40c33599 100644 --- a/app/services/overland/params.rb +++ b/app/services/overland/params.rb @@ -13,8 +13,7 @@ class Overland::Params next if point[:geometry].nil? || point.dig(:properties, :timestamp).nil? { - latitude: point[:geometry][:coordinates][1], - longitude: point[:geometry][:coordinates][0], + lonlat: "POINT(#{point[:geometry][:coordinates][0]} #{point[:geometry][:coordinates][1]})", battery_status: point[:properties][:battery_state], battery: battery_level(point[:properties][:battery_level]), timestamp: DateTime.parse(point[:properties][:timestamp]), diff --git a/app/services/own_tracks/export_parser.rb b/app/services/own_tracks/export_parser.rb deleted file mode 100644 index 5f4d9613..00000000 --- a/app/services/own_tracks/export_parser.rb +++ /dev/null @@ -1,35 +0,0 @@ -# frozen_string_literal: true - -class OwnTracks::ExportParser - include Imports::Broadcaster - - attr_reader :import, :data, :user_id - - def initialize(import, user_id) - @import = import - @data = import.raw_data - @user_id = user_id - end - - def call - points_data = data.map { |point| OwnTracks::Params.new(point).call } - - points_data.each.with_index(1) do |point_data, index| - next if Point.exists?( - timestamp: point_data[:timestamp], - latitude: point_data[:latitude], - longitude: point_data[:longitude], - user_id: - ) - - point = Point.new(point_data).tap do |p| - p.user_id = user_id - p.import_id = import.id - end - - point.save - - broadcast_import_progress(import, index) - end - end -end diff --git a/app/services/own_tracks/importer.rb b/app/services/own_tracks/importer.rb new file mode 100644 index 00000000..20dbc706 --- /dev/null +++ b/app/services/own_tracks/importer.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +class OwnTracks::Importer + include Imports::Broadcaster + + attr_reader :import, :data, :user_id + + def initialize(import, user_id) + @import = import + @data = import.raw_data + @user_id = user_id + end + + def call + points_data = data.map.with_index(1) do |point, index| + OwnTracks::Params.new(point).call.merge( + import_id: import.id, + user_id: user_id, + created_at: Time.current, + updated_at: Time.current + ) + end + + bulk_insert_points(points_data) + end + + private + + def bulk_insert_points(batch) + unique_batch = batch.uniq { |record| [record[:lonlat], record[:timestamp], record[:user_id]] } + + # rubocop:disable Rails/SkipsModelValidations + Point.upsert_all( + unique_batch, + unique_by: %i[lonlat timestamp user_id], + returning: false, + on_duplicate: :skip + ) + # rubocop:enable Rails/SkipsModelValidations + rescue StandardError => e + create_notification("Failed to process OwnTracks data: #{e.message}") + end + + def create_notification(message) + Notification.create!( + user_id: user_id, + title: 'OwnTracks Import Error', + content: message, + kind: :error + ) + end +end diff --git a/app/services/own_tracks/params.rb b/app/services/own_tracks/params.rb index e5319893..68f8c751 100644 --- a/app/services/own_tracks/params.rb +++ b/app/services/own_tracks/params.rb @@ -7,10 +7,11 @@ class OwnTracks::Params @params = params.to_h.deep_symbolize_keys end + # rubocop:disable Metrics/MethodLength + # rubocop:disable Metrics/AbcSize def call { - latitude: params[:lat], - longitude: params[:lon], + lonlat: "POINT(#{params[:lon]} #{params[:lat]})", battery: params[:batt], ping: params[:p], altitude: params[:alt], @@ -30,6 +31,8 @@ class OwnTracks::Params raw_data: params.deep_stringify_keys } end + # rubocop:enable Metrics/AbcSize + # rubocop:enable Metrics/MethodLength private diff --git a/app/services/photos/import_parser.rb b/app/services/photos/import_parser.rb index 97b9c9d4..610681fb 100644 --- a/app/services/photos/import_parser.rb +++ b/app/services/photos/import_parser.rb @@ -20,8 +20,7 @@ class Photos::ImportParser return 0 if point_exists?(point, point['timestamp']) Point.create( - latitude: point['latitude'].to_d, - longitude: point['longitude'].to_d, + lonlat: "POINT(#{point['longitude']} #{point['latitude']})", timestamp: point['timestamp'], raw_data: point, import_id: import.id, @@ -33,8 +32,7 @@ class Photos::ImportParser def point_exists?(point, timestamp) Point.exists?( - latitude: point['latitude'].to_d, - longitude: point['longitude'].to_d, + lonlat: "POINT(#{point['longitude']} #{point['latitude']})", timestamp:, user_id: ) diff --git a/app/services/points/params.rb b/app/services/points/params.rb index 8c1b8a51..7a9c81f6 100644 --- a/app/services/points/params.rb +++ b/app/services/points/params.rb @@ -14,8 +14,7 @@ class Points::Params next unless params_valid?(point) { - latitude: point[:geometry][:coordinates][1], - longitude: point[:geometry][:coordinates][0], + lonlat: lonlat(point), battery_status: point[:properties][:battery_state], battery: battery_level(point[:properties][:battery_level]), timestamp: DateTime.parse(point[:properties][:timestamp]), @@ -46,4 +45,8 @@ class Points::Params point[:geometry][:coordinates].present? && point.dig(:properties, :timestamp).present? end + + def lonlat(point) + "POINT(#{point[:geometry][:coordinates][0]} #{point[:geometry][:coordinates][1]})" + end end diff --git a/app/services/reverse_geocoding/places/fetch_data.rb b/app/services/reverse_geocoding/places/fetch_data.rb index 9b691d36..3b6309a1 100644 --- a/app/services/reverse_geocoding/places/fetch_data.rb +++ b/app/services/reverse_geocoding/places/fetch_data.rb @@ -19,7 +19,6 @@ class ReverseGeocoding::Places::FetchData first_place = reverse_geocoded_places.shift update_place(first_place) - add_suggested_place_to_a_visit reverse_geocoded_places.each { |reverse_geocoded_place| fetch_and_create_place(reverse_geocoded_place) } end @@ -32,8 +31,7 @@ class ReverseGeocoding::Places::FetchData place.update!( name: place_name(data), - latitude: data['geometry']['coordinates'][1], - longitude: data['geometry']['coordinates'][0], + lonlat: "POINT(#{data['geometry']['coordinates'][0]} #{data['geometry']['coordinates'][1]})", city: data['properties']['city'], country: data['properties']['country'], geodata: data, @@ -53,24 +51,12 @@ class ReverseGeocoding::Places::FetchData new_place.source = :photon new_place.save! - - add_suggested_place_to_a_visit(suggested_place: new_place) end def reverse_geocoded? place.geodata.present? end - def add_suggested_place_to_a_visit(suggested_place: place) - visits = Place.near([suggested_place.latitude, suggested_place.longitude], 0.1).flat_map(&:visits) - - visits.each do |visit| - next if visit.suggested_places.include?(suggested_place) - - visit.suggested_places << suggested_place - end - end - def find_place(place_data) found_place = Place.where( "geodata->'properties'->>'osm_id' = ?", place_data['properties']['osm_id'].to_s @@ -79,6 +65,7 @@ class ReverseGeocoding::Places::FetchData return found_place if found_place.present? Place.find_or_initialize_by( + lonlat: "POINT(#{place_data['geometry']['coordinates'][0].to_f.round(5)} #{place_data['geometry']['coordinates'][1].to_f.round(5)})", latitude: place_data['geometry']['coordinates'][1].to_f.round(5), longitude: place_data['geometry']['coordinates'][0].to_f.round(5) ) @@ -97,11 +84,11 @@ class ReverseGeocoding::Places::FetchData def reverse_geocoded_places data = Geocoder.search( - [place.latitude, place.longitude], + [place.lat, place.lon], limit: 10, distance_sort: true, radius: 1, - units: DISTANCE_UNITS + units: ::DISTANCE_UNIT, ) data.reject do |place| diff --git a/app/services/reverse_geocoding/points/fetch_data.rb b/app/services/reverse_geocoding/points/fetch_data.rb index b6798c35..87e4faa4 100644 --- a/app/services/reverse_geocoding/points/fetch_data.rb +++ b/app/services/reverse_geocoding/points/fetch_data.rb @@ -18,7 +18,7 @@ class ReverseGeocoding::Points::FetchData private def update_point_with_geocoding_data - response = Geocoder.search([point.latitude, point.longitude]).first + response = Geocoder.search([point.lat, point.lon]).first return if response.blank? || response.data['error'].present? point.update!( diff --git a/app/services/stats/calculate_month.rb b/app/services/stats/calculate_month.rb index 324cc3a7..b303d39f 100644 --- a/app/services/stats/calculate_month.rb +++ b/app/services/stats/calculate_month.rb @@ -46,7 +46,7 @@ class Stats::CalculateMonth .tracked_points .without_raw_data .where(timestamp: start_timestamp..end_timestamp) - .select(:latitude, :longitude, :timestamp, :city, :country) + .select(:lonlat, :timestamp, :city, :country) .order(timestamp: :asc) end diff --git a/app/services/tracks/build_path.rb b/app/services/tracks/build_path.rb index 4feaf49c..a3e14b9c 100644 --- a/app/services/tracks/build_path.rb +++ b/app/services/tracks/build_path.rb @@ -7,7 +7,7 @@ class Tracks::BuildPath def call factory.line_string( - coordinates.map { |point| factory.point(point[1].to_f.round(5), point[0].to_f.round(5)) } + coordinates.map { |point| factory.point(point.lon.to_f.round(5), point.lat.to_f.round(5)) } ) end diff --git a/app/services/visits/bulk_update.rb b/app/services/visits/bulk_update.rb new file mode 100644 index 00000000..a96f566f --- /dev/null +++ b/app/services/visits/bulk_update.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +module Visits + class BulkUpdate + attr_reader :user, :visit_ids, :status, :errors + + def initialize(user, visit_ids, status) + @user = user + @visit_ids = visit_ids + @status = status + @errors = [] + end + + def call + validate + return false if errors.any? + + update_visits + end + + private + + def validate + if visit_ids.blank? + errors << 'No visits selected' + return + end + + return if Visit.statuses.keys.include?(status) + + errors << 'Invalid status' + end + + def update_visits + visits = user.visits.where(id: visit_ids) + + if visits.empty? + errors << 'No matching visits found' + return false + end + + # rubocop:disable Rails/SkipsModelValidations + updated_count = visits.update_all(status: status) + # rubocop:enable Rails/SkipsModelValidations + + { count: updated_count, visits: visits } + end + end +end diff --git a/app/services/visits/calculate.rb b/app/services/visits/calculate.rb deleted file mode 100644 index 0f0a4c06..00000000 --- a/app/services/visits/calculate.rb +++ /dev/null @@ -1,92 +0,0 @@ -# frozen_string_literal: true - -class Visits::Calculate - def initialize(points) - @points = points - end - - def call - # Only one visit per city per day - normalized_visits.flat_map do |country| - { - country: country[:country], - cities: country[:cities].uniq { [_1[:city], Time.zone.at(_1[:timestamp]).to_date] } - } - end - end - - def normalized_visits - normalize_result(city_visits) - end - - private - - attr_reader :points - - def group_points - points.sort_by(&:timestamp).reject { _1.city.nil? }.group_by(&:country) - end - - def city_visits - group_points.transform_values do |grouped_points| - grouped_points - .group_by(&:city) - .transform_values { |city_points| identify_consecutive_visits(city_points) } - end - end - - def identify_consecutive_visits(city_points) - visits = [] - current_visit = [] - - city_points.each_cons(2) do |point1, point2| - time_diff = (point2.timestamp - point1.timestamp) / 60 - - if time_diff <= MIN_MINUTES_SPENT_IN_CITY - current_visit << point1 unless current_visit.include?(point1) - current_visit << point2 - else - visits << create_visit(current_visit) if current_visit.size > 1 - current_visit = [] - end - end - - visits << create_visit(current_visit) if current_visit.size > 1 - visits - end - - def create_visit(points) - { - city: points.first.city, - points:, - stayed_for: calculate_stayed_time(points), - last_timestamp: points.last.timestamp - } - end - - def calculate_stayed_time(points) - return 0 if points.empty? - - min_time = points.first.timestamp - max_time = points.last.timestamp - ((max_time - min_time) / 60).round - end - - def normalize_result(hash) - hash.map do |country, cities| - { - country:, - cities: cities.values.flatten - .select { |visit| visit[:stayed_for] >= MIN_MINUTES_SPENT_IN_CITY } - .map do |visit| - { - city: visit[:city], - points: visit[:points].count, - timestamp: visit[:last_timestamp], - stayed_for: visit[:stayed_for] - } - end - } - end.reject { |entry| entry[:cities].empty? } - end -end diff --git a/app/services/visits/creator.rb b/app/services/visits/creator.rb new file mode 100644 index 00000000..c7e672d6 --- /dev/null +++ b/app/services/visits/creator.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +module Visits + # Creates visit records from detected visit data + class Creator + attr_reader :user + + def initialize(user) + @user = user + end + + def create_visits(visits) + visits.map do |visit_data| + # Variables to store data outside the transaction + visit_instance = nil + place_data = nil + + # First transaction to create the visit + ActiveRecord::Base.transaction do + # Try to find matching area or place + area = find_matching_area(visit_data) + + # Only find/create place if no area was found + place_data = PlaceFinder.new(user).find_or_create_place(visit_data) unless area + + main_place = place_data&.dig(:main_place) + + visit_instance = Visit.create!( + user: user, + area: area, + place: main_place, + started_at: Time.zone.at(visit_data[:start_time]), + ended_at: Time.zone.at(visit_data[:end_time]), + duration: visit_data[:duration] / 60, # Convert to minutes + name: generate_visit_name(area, main_place, visit_data[:suggested_name]), + status: :suggested + ) + + Point.where(id: visit_data[:points].map(&:id)).update_all(visit_id: visit_instance.id) + end + + # Associate suggested places outside the main transaction + # to avoid deadlocks when multiple processes run simultaneously + if place_data&.dig(:suggested_places).present? + associate_suggested_places(visit_instance, place_data[:suggested_places]) + end + + visit_instance + end + end + + private + + # Create place_visits records directly to avoid deadlocks + def associate_suggested_places(visit, suggested_places) + existing_place_ids = visit.place_visits.pluck(:place_id) + + # Only create associations that don't already exist + place_ids_to_add = suggested_places.map(&:id) - existing_place_ids + + # Skip if there's nothing to add + return if place_ids_to_add.empty? + + # Batch create place_visit records + place_visits_attrs = place_ids_to_add.map do |place_id| + { visit_id: visit.id, place_id: place_id, created_at: Time.current, updated_at: Time.current } + end + + # Use insert_all for efficient bulk insertion without callbacks + PlaceVisit.insert_all(place_visits_attrs) if place_visits_attrs.any? + end + + def find_matching_area(visit_data) + user.areas.find do |area| + near_area?([visit_data[:center_lat], visit_data[:center_lon]], area) + end + end + + def near_area?(center, area) + distance = Geocoder::Calculations.distance_between( + center, + [area.latitude, area.longitude], + units: :km + ) + distance * 1000 <= area.radius # Convert to meters + end + + def generate_visit_name(area, place, suggested_name) + return area.name if area + return place.name if place + return suggested_name if suggested_name.present? + + 'Unknown Location' + end + end +end diff --git a/app/services/visits/detector.rb b/app/services/visits/detector.rb new file mode 100644 index 00000000..13a2d64b --- /dev/null +++ b/app/services/visits/detector.rb @@ -0,0 +1,158 @@ +# frozen_string_literal: true + +module Visits + # Detects potential visits from a collection of tracked points + class Detector + MINIMUM_VISIT_DURATION = 3.minutes + MAXIMUM_VISIT_GAP = 30.minutes + MINIMUM_POINTS_FOR_VISIT = 2 + + attr_reader :points + + def initialize(points) + @points = points + end + + def detect_potential_visits + visits = [] + current_visit = nil + + points.each do |point| + if current_visit.nil? + current_visit = initialize_visit(point) + next + end + + if belongs_to_current_visit?(point, current_visit) + current_visit[:points] << point + current_visit[:end_time] = point.timestamp + else + visits << finalize_visit(current_visit) if valid_visit?(current_visit) + current_visit = initialize_visit(point) + end + end + + # Handle the last visit + visits << finalize_visit(current_visit) if current_visit && valid_visit?(current_visit) + + visits + end + + private + + def initialize_visit(point) + { + start_time: point.timestamp, + end_time: point.timestamp, + center_lat: point.lat, + center_lon: point.lon, + points: [point] + } + end + + def belongs_to_current_visit?(point, visit) + time_gap = point.timestamp - visit[:end_time] + return false if time_gap > MAXIMUM_VISIT_GAP + + # Calculate distance from visit center + distance = Geocoder::Calculations.distance_between( + [visit[:center_lat], visit[:center_lon]], + [point.lat, point.lon], + units: :km + ) + + # Dynamically adjust radius based on visit duration + max_radius = calculate_max_radius(visit[:end_time] - visit[:start_time]) + + distance <= max_radius + end + + def calculate_max_radius(duration_seconds) + # Start with a small radius for short visits, increase for longer stays + # but cap it at a reasonable maximum + base_radius = 0.05 # 50 meters + duration_hours = duration_seconds / 3600.0 + [base_radius * (1 + Math.log(1 + duration_hours)), 0.5].min # Cap at 500 meters + end + + def valid_visit?(visit) + duration = visit[:end_time] - visit[:start_time] + visit[:points].size >= MINIMUM_POINTS_FOR_VISIT && duration >= MINIMUM_VISIT_DURATION + end + + def finalize_visit(visit) + points = visit[:points] + center = calculate_center(points) + + visit.merge( + duration: visit[:end_time] - visit[:start_time], + center_lat: center[0], + center_lon: center[1], + radius: calculate_visit_radius(points, center), + suggested_name: suggest_place_name(points) + ) + end + + def calculate_center(points) + lat_sum = points.sum(&:lat) + lon_sum = points.sum(&:lon) + count = points.size.to_f + + [lat_sum / count, lon_sum / count] + end + + def calculate_visit_radius(points, center) + max_distance = points.map do |point| + Geocoder::Calculations.distance_between(center, [point.lat, point.lon], units: :km) + end.max + + # Convert to meters and ensure minimum radius + [(max_distance * 1000), 15].max + end + + def suggest_place_name(points) + # Get points with geodata + geocoded_points = points.select { |p| p.geodata.present? && !p.geodata.empty? } + return nil if geocoded_points.empty? + + # Extract all features from points' geodata + features = geocoded_points.flat_map do |point| + next [] unless point.geodata['features'].is_a?(Array) + + point.geodata['features'] + end.compact + + return nil if features.empty? + + # Group features by type and count occurrences + feature_counts = features.group_by { |f| f.dig('properties', 'type') } + .transform_values(&:size) + + # Find the most common feature type + most_common_type = feature_counts.max_by { |_, count| count }&.first + return nil unless most_common_type + + # Get all features of the most common type + common_features = features.select { |f| f.dig('properties', 'type') == most_common_type } + + # Group these features by name and get the most common one + name_counts = common_features.group_by { |f| f.dig('properties', 'name') } + .transform_values(&:size) + most_common_name = name_counts.max_by { |_, count| count }&.first + + return if most_common_name.blank? + + # If we have a name, try to get additional context + feature = common_features.find { |f| f.dig('properties', 'name') == most_common_name } + properties = feature['properties'] + + # Build a more descriptive name if possible + [ + most_common_name, + properties['street'], + properties['city'], + properties['state'] + ].compact.uniq.join(', ') + end + end +end diff --git a/app/services/visits/find_in_time.rb b/app/services/visits/find_in_time.rb new file mode 100644 index 00000000..e486382a --- /dev/null +++ b/app/services/visits/find_in_time.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Visits + class FindInTime + def initialize(user, params) + @user = user + @start_at = parse_time(params[:start_at]) + @end_at = parse_time(params[:end_at]) + end + + def call + Visit + .includes(:place) + .where(user:) + .where('started_at >= ? AND ended_at <= ?', start_at, end_at) + .order(started_at: :desc) + end + + private + + attr_reader :user, :start_at, :end_at + + def parse_time(time_string) + parsed_time = Time.zone.parse(time_string) + + raise ArgumentError, "Invalid time format: #{time_string}" if parsed_time.nil? + + parsed_time + end + end +end diff --git a/app/services/visits/find_within_bounding_box.rb b/app/services/visits/find_within_bounding_box.rb new file mode 100644 index 00000000..74b72ed7 --- /dev/null +++ b/app/services/visits/find_within_bounding_box.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Visits + # Finds visits in a selected area on the map + class FindWithinBoundingBox + def initialize(user, params) + @user = user + @sw_lat = params[:sw_lat].to_f + @sw_lng = params[:sw_lng].to_f + @ne_lat = params[:ne_lat].to_f + @ne_lng = params[:ne_lng].to_f + end + + def call + bounding_box = "ST_MakeEnvelope(#{sw_lng}, #{sw_lat}, #{ne_lng}, #{ne_lat}, 4326)" + + Visit + .includes(:place) + .where(user:) + .joins(:place) + .where("ST_Contains(#{bounding_box}, ST_SetSRID(places.lonlat::geometry, 4326))") + .order(started_at: :desc) + end + + private + + attr_reader :user, :sw_lat, :sw_lng, :ne_lat, :ne_lng + end +end diff --git a/app/services/visits/finder.rb b/app/services/visits/finder.rb new file mode 100644 index 00000000..8ed32593 --- /dev/null +++ b/app/services/visits/finder.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Visits + # Finds visits in a selected area on the map + class Finder + def initialize(user, params) + @user = user + @params = params + end + + def call + if area_selected? + Visits::FindWithinBoundingBox.new(user, params).call + else + Visits::FindInTime.new(user, params).call + end + end + + private + + attr_reader :user, :params + + def area_selected? + params[:selection] == 'true' && + params[:sw_lat].present? && + params[:sw_lng].present? && + params[:ne_lat].present? && + params[:ne_lng].present? + end + end +end diff --git a/app/services/visits/group_points.rb b/app/services/visits/group_points.rb deleted file mode 100644 index 00961c16..00000000 --- a/app/services/visits/group_points.rb +++ /dev/null @@ -1,58 +0,0 @@ -# frozen_string_literal: true - -class Visits::GroupPoints - INITIAL_RADIUS = 30 # meters - MAX_RADIUS = 100 # meters - RADIUS_STEP = 10 # meters - MIN_VISIT_DURATION = 3 * 60 # 3 minutes in seconds - - attr_reader :day_points, :initial_radius, :max_radius, :step - - def initialize(day_points, initial_radius = INITIAL_RADIUS, max_radius = MAX_RADIUS, step = RADIUS_STEP) - @day_points = day_points - @initial_radius = initial_radius - @max_radius = max_radius - @step = step - end - - def group_points_by_radius - grouped = [] - remaining_points = day_points.dup - - while remaining_points.any? - point = remaining_points.shift - radius = initial_radius - - while radius <= max_radius - new_group = [point] - - remaining_points.each do |next_point| - break unless within_radius?(new_group.first, next_point, radius) - - new_group << next_point - end - - if new_group.size > 1 - group_duration = new_group.last.timestamp - new_group.first.timestamp - - if group_duration >= MIN_VISIT_DURATION - remaining_points -= new_group - grouped << new_group - end - - break - else - radius += step - end - end - end - - grouped - end - - private - - def within_radius?(point1, point2, radius) - point1.distance_to(point2) * 1000 <= radius - end -end diff --git a/app/services/visits/merge_service.rb b/app/services/visits/merge_service.rb new file mode 100644 index 00000000..e2c971da --- /dev/null +++ b/app/services/visits/merge_service.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +module Visits + # Service to handle merging multiple visits into one from the visits drawer + class MergeService + attr_reader :visits, :errors, :base_visit + + def initialize(visits) + @visits = visits + @base_visit = visits.first + @errors = [] + end + + # Merges multiple visits into one + # @return [Visit, nil] The merged visit or nil if merge failed + def call + return add_error('At least 2 visits must be selected for merging') if visits.length < 2 + + merge_visits + end + + private + + def add_error(message) + @errors << message + nil + end + + def merge_visits + Visit.transaction do + update_base_visit(base_visit) + reassign_points(base_visit, visits) + + visits.drop(1).each(&:destroy!) + + base_visit + end + rescue ActiveRecord::RecordInvalid => e + Rails.logger.error("Failed to merge visits: #{e.message}") + add_error(e.record.errors.full_messages.join(', ')) + nil + end + + def prepare_base_visit + earliest_start = visits.min_by(&:started_at).started_at + latest_end = visits.max_by(&:ended_at).ended_at + total_duration = ((latest_end - earliest_start) / 60).round + combined_name = "Combined Visit (#{visits.map(&:name).join(', ')})" + + { + earliest_start:, + latest_end:, + total_duration:, + combined_name: + } + end + + def update_base_visit(base_visit) + base_visit_data = prepare_base_visit + + base_visit.update!( + started_at: base_visit_data[:earliest_start], + ended_at: base_visit_data[:latest_end], + duration: base_visit_data[:total_duration], + name: base_visit_data[:combined_name], + status: 'confirmed' + ) + end + + def reassign_points(base_visit, visits) + visits[1..].each do |visit| + visit.points.update_all(visit_id: base_visit.id) # rubocop:disable Rails/SkipsModelValidations + end + end + end +end diff --git a/app/services/visits/merger.rb b/app/services/visits/merger.rb new file mode 100644 index 00000000..e13bd218 --- /dev/null +++ b/app/services/visits/merger.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +module Visits + # Merges consecutive visits that are likely part of the same stay + class Merger + MAXIMUM_VISIT_GAP = 30.minutes + SIGNIFICANT_MOVEMENT_THRESHOLD = 50 # meters + + attr_reader :points + + def initialize(points) + @points = points + end + + def merge_visits(visits) + return visits if visits.empty? + + merged = [] + current_merged = visits.first + + visits[1..].each do |visit| + if can_merge_visits?(current_merged, visit) + # Merge the visits + current_merged[:end_time] = visit[:end_time] + current_merged[:points].concat(visit[:points]) + else + merged << current_merged + current_merged = visit + end + end + + merged << current_merged + merged + end + + private + + def can_merge_visits?(first_visit, second_visit) + return false unless same_location?(first_visit, second_visit) + return false if gap_too_large?(first_visit, second_visit) + return false if significant_movement_between?(first_visit, second_visit) + + true + end + + def same_location?(first_visit, second_visit) + distance = Geocoder::Calculations.distance_between( + [first_visit[:center_lat], first_visit[:center_lon]], + [second_visit[:center_lat], second_visit[:center_lon]], + units: :km + ) + + # Convert to meters and check if within threshold + (distance * 1000) <= SIGNIFICANT_MOVEMENT_THRESHOLD + end + + def gap_too_large?(first_visit, second_visit) + gap = second_visit[:start_time] - first_visit[:end_time] + gap > MAXIMUM_VISIT_GAP + end + + def significant_movement_between?(first_visit, second_visit) + # Get points between the two visits + between_points = points.where( + timestamp: (first_visit[:end_time] + 1)..(second_visit[:start_time] - 1) + ) + + return false if between_points.empty? + + visit_center = [first_visit[:center_lat], first_visit[:center_lon]] + max_distance = between_points.map do |point| + Geocoder::Calculations.distance_between( + visit_center, + [point.lat, point.lon], + units: :km + ) + end.max + + # Convert to meters and check if exceeds threshold + (max_distance * 1000) > SIGNIFICANT_MOVEMENT_THRESHOLD + end + end +end diff --git a/app/services/visits/place_finder.rb b/app/services/visits/place_finder.rb new file mode 100644 index 00000000..72a35e72 --- /dev/null +++ b/app/services/visits/place_finder.rb @@ -0,0 +1,246 @@ +# frozen_string_literal: true + +module Visits + # Finds or creates places for visits + class PlaceFinder + attr_reader :user + + SEARCH_RADIUS = 100 # meters + SIMILARITY_RADIUS = 50 # meters + + def initialize(user) + @user = user + end + + def find_or_create_place(visit_data) + lat = visit_data[:center_lat] + lon = visit_data[:center_lon] + + # First check if there's an existing place + existing_place = find_existing_place(lat, lon, visit_data[:suggested_name]) + + # If we found an exact match, return it + if existing_place + return { + main_place: existing_place, + suggested_places: find_suggested_places(lat, lon) + } + end + + # Get potential places from all sources + potential_places = collect_potential_places(visit_data) + + # Find or create the main place + main_place = select_or_create_main_place(potential_places, lat, lon, visit_data[:suggested_name]) + + # Get suggested places including our main place + all_suggested_places = potential_places.presence || [main_place] + + { + main_place: main_place, + suggested_places: all_suggested_places.uniq(&:name) + } + end + + private + + # Step 1: Find existing place + def find_existing_place(lat, lon, name) + # Try to find existing place by location first + existing_by_location = Place.near([lat, lon], SIMILARITY_RADIUS, :m).first + return existing_by_location if existing_by_location + + # Then try by name if available + return nil unless name.present? + + Place.where(name: name) + .near([lat, lon], SEARCH_RADIUS, :m) + .first + end + + # Step 2: Collect potential places from all sources + def collect_potential_places(visit_data) + lat = visit_data[:center_lat] + lon = visit_data[:center_lon] + + # Get places from points' geodata + places_from_points = extract_places_from_points(visit_data[:points], lat, lon) + + # Get places from external API + places_from_api = fetch_places_from_api(lat, lon) + + # Combine and deduplicate by name + combined_places = [] + + # Add API places first (usually better quality) + places_from_api.each do |api_place| + combined_places << api_place unless place_name_exists?(combined_places, api_place.name) + end + + # Add places from points if name doesn't already exist + places_from_points.each do |point_place| + combined_places << point_place unless place_name_exists?(combined_places, point_place.name) + end + + combined_places + end + + # Step 3: Extract places from points + def extract_places_from_points(points, center_lat, center_lon) + return [] if points.blank? + + # Filter points with geodata + points_with_geodata = points.select { |point| point.geodata.present? } + return [] if points_with_geodata.empty? + + # Process each point to create or find places + places = [] + + points_with_geodata.each do |point| + place = create_place_from_point(point) + places << place if place + end + + places.uniq { |place| place.name } + end + + # Step 4: Create place from point + def create_place_from_point(point) + return nil unless point.geodata.is_a?(Hash) + + properties = point.geodata['properties'] || {} + return nil if properties.blank? + + # Get or build a name + name = build_place_name(properties) + return nil if name == Place::DEFAULT_NAME + + # Look for existing place with this name + existing = Place.where(name: name) + .near([point.latitude, point.longitude], SIMILARITY_RADIUS, :m) + .first + + return existing if existing + + # Create new place + place = Place.new( + name: name, + lonlat: "POINT(#{point.longitude} #{point.latitude})", + latitude: point.latitude, + longitude: point.longitude, + city: properties['city'], + country: properties['country'], + geodata: point.geodata, + source: :photon + ) + + place.save! + place + rescue ActiveRecord::RecordInvalid + nil + end + + # Step 5: Fetch places from API + def fetch_places_from_api(lat, lon) + # Get broader search results from Geocoder + geocoder_results = Geocoder.search([lat, lon], units: :km, limit: 20, distance_sort: true) + return [] if geocoder_results.blank? + + places = [] + + geocoder_results.each do |result| + place = create_place_from_api_result(result) + places << place if place + end + + places + end + + # Step 6: Create place from API result + def create_place_from_api_result(result) + return nil unless result && result.data.is_a?(Hash) + + properties = result.data['properties'] || {} + return nil if properties.blank? + + # Get or build a name + name = build_place_name(properties) + return nil if name == Place::DEFAULT_NAME + + # Look for existing place with this name + existing = Place.where(name: name) + .near([result.latitude, result.longitude], SIMILARITY_RADIUS, :m) + .first + + return existing if existing + + # Create new place + place = Place.new( + name: name, + lonlat: "POINT(#{result.longitude} #{result.latitude})", + latitude: result.latitude, + longitude: result.longitude, + city: properties['city'], + country: properties['country'], + geodata: result.data, + source: :photon + ) + + place.save! + place + rescue ActiveRecord::RecordInvalid + nil + end + + # Step 7: Select or create main place + def select_or_create_main_place(potential_places, lat, lon, suggested_name) + return create_default_place(lat, lon, suggested_name) if potential_places.blank? + + # Choose the closest place as the main one + sorted_places = potential_places.sort_by do |place| + place.distance_to([lat, lon], :m) + end + + sorted_places.first + end + + # Step 8: Create default place when no other options + def create_default_place(lat, lon, suggested_name) + name = suggested_name.presence || Place::DEFAULT_NAME + + place = Place.new( + name: name, + lonlat: "POINT(#{lon} #{lat})", + latitude: lat, + longitude: lon, + source: :manual + ) + + place.save! + place + end + + # Step 9: Find suggested places + def find_suggested_places(lat, lon) + Place.near([lat, lon], SEARCH_RADIUS, :m).with_distance([lat, lon], :m) + end + + # Helper methods + + def build_place_name(properties) + name_components = [ + properties['name'], + properties['street'], + properties['housenumber'], + properties['postcode'], + properties['city'] + ].compact.reject(&:empty?).uniq + + name_components.any? ? name_components.join(', ') : Place::DEFAULT_NAME + end + + def place_name_exists?(places, name) + places.any? { |place| place.name == name } + end + end +end diff --git a/app/services/visits/prepare.rb b/app/services/visits/prepare.rb deleted file mode 100644 index 0bb9c2b7..00000000 --- a/app/services/visits/prepare.rb +++ /dev/null @@ -1,78 +0,0 @@ -# frozen_string_literal: true - -class Visits::Prepare - attr_reader :points - - def initialize(points) - @points = points - end - - def call - points_by_day = points.group_by { |point| point_date(point) } - - points_by_day.map do |day, day_points| - day_points.sort_by!(&:timestamp) - - grouped_points = Visits::GroupPoints.new(day_points).group_points_by_radius - day_result = prepare_day_result(grouped_points) - - # Iterate through the day_result, check if there are any points outside of visits that are between two consecutive visits. If there are none, merge the visits. - - day_result.each_cons(2) do |visit1, visit2| - next if visit1[:points].last == visit2[:points].first - - points_between_visits = day_points.select do |point| - point.timestamp > visit1[:points].last.timestamp && - point.timestamp < visit2[:points].first.timestamp - end - - if points_between_visits.any? - # If there are points between the visits, we need to check if they are close enough to the visits to be considered part of them. - - points_between_visits.each do |point| - next unless visit1[:points].last.distance_to(point) < visit1[:radius] || - visit2[:points].first.distance_to(point) < visit2[:radius] || - (point.timestamp - visit1[:points].last.timestamp).to_i < 600 - - visit1[:points] << point - end - end - - visit1[:points] += visit2[:points] - visit1[:duration] = (visit1[:points].last.timestamp - visit1[:points].first.timestamp).to_i / 60 - visit1[:ended_at] = Time.zone.at(visit1[:points].last.timestamp) - day_result.delete(visit2) - end - - next if day_result.blank? - - { date: day, visits: day_result } - end.compact - end - - private - - def point_date(point) = Time.zone.at(point.timestamp).to_date.to_s - - def calculate_radius(center_point, group) - max_distance = group.map { |point| center_point.distance_to(point) }.max - - (max_distance / 10.0).ceil * 10 - end - - def prepare_day_result(grouped_points) - grouped_points.map do |group| - center_point = group.first - - { - latitude: center_point.latitude, - longitude: center_point.longitude, - radius: calculate_radius(center_point, group), - points: group, - duration: (group.last.timestamp - group.first.timestamp).to_i / 60, - started_at: Time.zone.at(group.first.timestamp).to_s, - ended_at: Time.zone.at(group.last.timestamp).to_s - } - end - end -end diff --git a/app/services/visits/smart_detect.rb b/app/services/visits/smart_detect.rb new file mode 100644 index 00000000..64d66440 --- /dev/null +++ b/app/services/visits/smart_detect.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +module Visits + # Coordinates the process of detecting and creating visits from tracked points + class SmartDetect + MINIMUM_VISIT_DURATION = 3.minutes + MAXIMUM_VISIT_GAP = 30.minutes + MINIMUM_POINTS_FOR_VISIT = 3 + + attr_reader :user, :start_at, :end_at, :points + + def initialize(user, start_at:, end_at:) + @user = user + @start_at = start_at.to_i + @end_at = end_at.to_i + @points = user.tracked_points.not_visited + .order(timestamp: :asc) + .where(timestamp: start_at..end_at) + end + + def call + return [] if points.empty? + + potential_visits = Visits::Detector.new(points).detect_potential_visits + merged_visits = Visits::Merger.new(points).merge_visits(potential_visits) + grouped_visits = group_nearby_visits(merged_visits).flatten + + Visits::Creator.new(user).create_visits(grouped_visits) + end + + private + + def group_nearby_visits(visits) + visits.group_by do |visit| + [ + (visit[:center_lat] * 1000).round / 1000.0, + (visit[:center_lon] * 1000).round / 1000.0 + ] + end.values + end + end +end diff --git a/app/services/visits/suggest.rb b/app/services/visits/suggest.rb index 4d02a45c..5ee7881c 100644 --- a/app/services/visits/suggest.rb +++ b/app/services/visits/suggest.rb @@ -13,61 +13,24 @@ class Visits::Suggest end def call - prepared_visits = Visits::Prepare.new(points).call - - visited_places = create_places(prepared_visits) - visits = create_visits(visited_places) - - create_visits_notification(user) + visits = Visits::SmartDetect.new(user, start_at:, end_at:).call + create_visits_notification(user) if visits.any? return nil unless DawarichSettings.reverse_geocoding_enabled? - reverse_geocode(visits) + visits.each(&:async_reverse_geocode) + visits + rescue StandardError => e + # create a notification with stacktrace and what arguments were used + user.notifications.create!( + kind: :error, + title: 'Error suggesting visits', + content: "Error suggesting visits: #{e.message}\n#{e.backtrace.join("\n")}" + ) end private - def create_places(prepared_visits) - prepared_visits.flat_map do |date| - date[:visits] = handle_visits(date[:visits]) - - date - end - end - - def create_visits(visited_places) - visited_places.flat_map do |date| - date[:visits].map do |visit_data| - ActiveRecord::Base.transaction do - search_params = { - user_id: user.id, - duration: visit_data[:duration], - started_at: Time.zone.at(visit_data[:points].first.timestamp) - } - - if visit_data[:area].present? - search_params[:area_id] = visit_data[:area].id - elsif visit_data[:place].present? - search_params[:place_id] = visit_data[:place].id - end - - visit = Visit.find_or_initialize_by(search_params) - visit.name = visit_data[:place]&.name || visit_data[:area]&.name if visit.name.blank? - visit.ended_at = Time.zone.at(visit_data[:points].last.timestamp) - visit.save! - - visit_data[:points].each { |point| point.update!(visit_id: visit.id) } - - visit - end - end - end - end - - def reverse_geocode(visits) - visits.each(&:async_reverse_geocode) - end - def create_visits_notification(user) content = <<~CONTENT New visits have been suggested based on your location data from #{Time.zone.at(start_at)} to #{Time.zone.at(end_at)}. You can review them on the Visits page. @@ -79,32 +42,4 @@ class Visits::Suggest content: ) end - - def create_place(visit) - place = Place.find_or_initialize_by( - latitude: visit[:latitude].to_f.round(5), - longitude: visit[:longitude].to_f.round(5) - ) - - place.name = Place::DEFAULT_NAME - place.source = Place.sources[:manual] - - place.save! - - place - end - - def handle_visits(visits) - visits.map do |visit| - area = Area.near([visit[:latitude], visit[:longitude]], 0.100).first - - if area.present? - visit.merge(area:) - else - place = create_place(visit) - - visit.merge(place:) - end - end - end end diff --git a/app/services/visits/time_chunks.rb b/app/services/visits/time_chunks.rb new file mode 100644 index 00000000..5c7470da --- /dev/null +++ b/app/services/visits/time_chunks.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +module Visits + class TimeChunks + def initialize(start_at:, end_at:) + @start_at = start_at + @end_at = end_at + @time_chunks = [] + end + + def call + # If the start date is in the future or equal to the end date, + # handle as a special case extending to the end of the start's year + # or if the start and end are in the same year, return the year chunk + return [start_at..start_at.end_of_year] if start_in_future? || same_year? + + # First chunk: from start_at to end of that year + first_end = start_at.end_of_year + time_chunks << (start_at...first_end) + + # Full-year chunks + current = first_end.beginning_of_year + 1.year # Start from the next full year + while current.year < end_at.year + year_end = current.end_of_year + time_chunks << (current...year_end) + current += 1.year + end + + # Last chunk: from start of the last year to end_at + time_chunks << (current...end_at) if current.year == end_at.year + + time_chunks + end + + private + + attr_reader :start_at, :end_at, :time_chunks + + def start_in_future? + start_at >= end_at + end + + def same_year? + start_at.year == end_at.year + end + end +end diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 4063fad1..f41baeda 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -1,5 +1,5 @@ - + <%= full_title(yield(:title)) %> diff --git a/app/views/map/index.html.erb b/app/views/map/index.html.erb index 9fa4a0fe..5d32f61c 100644 --- a/app/views/map/index.html.erb +++ b/app/views/map/index.html.erb @@ -54,7 +54,7 @@ data-distance="<%= @distance %>" data-points_number="<%= @points_number %>" data-timezone="<%= Rails.configuration.time_zone %>"> -
+
diff --git a/app/views/places/index.html.erb b/app/views/places/index.html.erb index 939cca3b..5ed9365f 100644 --- a/app/views/places/index.html.erb +++ b/app/views/places/index.html.erb @@ -39,9 +39,9 @@ <%= place.name %> <%= place.created_at.strftime('%Y-%m-%d %H:%M:%S') %> - <%= place.to_coordinates.map(&:to_f) %> + <%= "#{place.lat}, #{place.lon}" %> - <%= link_to 'Delete', place, data: { confirm: "Are you sure?", turbo_confirm: "Are you sure?", turbo_method: :delete }, method: :delete, class: "px-4 py-2 bg-red-500 text-white rounded-md" %> + <%= link_to 'Delete', place, data: { confirm: "Are you sure? Deleting a place will result in deleting all visits for this place.", turbo_confirm: "Are you sure? Deleting a place will result in deleting all visits for this place.", turbo_method: :delete }, method: :delete, class: "px-4 py-2 bg-red-500 text-white rounded-md" %> <% end %> diff --git a/app/views/settings/_navigation.html.erb b/app/views/settings/_navigation.html.erb index 3f5aa627..8b5e51e0 100644 --- a/app/views/settings/_navigation.html.erb +++ b/app/views/settings/_navigation.html.erb @@ -1,6 +1,6 @@
<%= link_to 'Integrations', settings_path, role: 'tab', class: "tab #{active_tab?(settings_path)}" %> - <% if current_user.admin? %> + <% if DawarichSettings.self_hosted? && 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)}" %> <% end %> diff --git a/app/views/settings/maps/index.html.erb b/app/views/settings/maps/index.html.erb index e80d875a..0910f9b9 100644 --- a/app/views/settings/maps/index.html.erb +++ b/app/views/settings/maps/index.html.erb @@ -1,4 +1,4 @@ -<% content_for :title, "Background jobs" %> +<% content_for :title, "Map settings" %>
<%= render 'settings/navigation' %> diff --git a/app/views/shared/_flash.html.erb b/app/views/shared/_flash.html.erb index 54895274..08166411 100644 --- a/app/views/shared/_flash.html.erb +++ b/app/views/shared/_flash.html.erb @@ -1,4 +1,4 @@ -
+
<% flash.each do |key, value| %>
- <%= link_to 'DaWarIch', root_path, class: 'btn btn-ghost normal-case text-xl'%> + <%= link_to 'Dawarich', root_path, class: 'btn btn-ghost normal-case text-xl'%>
<% if new_version_available? %> @@ -42,12 +49,19 @@