diff --git a/Gemfile b/Gemfile index 7d219c27..70927ecd 100644 --- a/Gemfile +++ b/Gemfile @@ -79,6 +79,7 @@ group :development do gem "web-console" gem "faker" gem "benchmark-ips" + gem "foreman" end group :test do diff --git a/Gemfile.lock b/Gemfile.lock index dac7842a..16375f51 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -179,6 +179,7 @@ GEM ffi (1.17.1-x86_64-darwin) ffi (1.17.1-x86_64-linux-gnu) ffi (1.17.1-x86_64-linux-musl) + foreman (0.88.1) globalid (1.2.1) activesupport (>= 6.1) hashdiff (1.1.2) @@ -550,6 +551,7 @@ DEPENDENCIES faraday faraday-multipart faraday-retry + foreman hotwire-livereload hotwire_combobox i18n-tasks diff --git a/app/assets/images/icon-assistant.svg b/app/assets/images/icon-assistant.svg new file mode 100644 index 00000000..7ba1299f --- /dev/null +++ b/app/assets/images/icon-assistant.svg @@ -0,0 +1,37 @@ + + \ No newline at end of file diff --git a/app/assets/images/icon-csv.svg b/app/assets/images/icon-csv.svg new file mode 100644 index 00000000..ce835a31 --- /dev/null +++ b/app/assets/images/icon-csv.svg @@ -0,0 +1,5 @@ + \ No newline at end of file diff --git a/app/assets/tailwind/application.css b/app/assets/tailwind/application.css index 4b78a2db..1a0c15cf 100644 --- a/app/assets/tailwind/application.css +++ b/app/assets/tailwind/application.css @@ -167,3 +167,15 @@ } } /* The following Markdown CSS has been removed as requested */ + +.mt-safe { + margin-top: env(safe-area-inset-top); +} + +.pt-safe { + padding-top: env(safe-area-inset-top); +} + +.pb-safe { + padding-bottom: env(safe-area-inset-bottom); +} \ No newline at end of file diff --git a/app/assets/tailwind/maybe-design-system.css b/app/assets/tailwind/maybe-design-system.css index 61a3de14..d6126796 100644 --- a/app/assets/tailwind/maybe-design-system.css +++ b/app/assets/tailwind/maybe-design-system.css @@ -760,4 +760,12 @@ @variant theme-dark { @apply bg-alpha-black-700; } +} + +@utility bg-nav-indicator { + @apply bg-black; + + @variant theme-dark { + @apply bg-white; + } } \ No newline at end of file diff --git a/app/controllers/import/uploads_controller.rb b/app/controllers/import/uploads_controller.rb index d30e0082..e51b5278 100644 --- a/app/controllers/import/uploads_controller.rb +++ b/app/controllers/import/uploads_controller.rb @@ -6,6 +6,13 @@ class Import::UploadsController < ApplicationController def show end + def sample_csv + send_data @import.csv_template.to_csv, + filename: "#{@import.type.underscore.split('_').first}_sample.csv", + type: "text/csv", + disposition: "attachment" + end + def update if csv_valid?(csv_str) @import.account = Current.family.accounts.find_by(id: params.dig(:import, :account_id)) diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb index 0d8d6e92..b57b508d 100644 --- a/app/controllers/registrations_controller.rb +++ b/app/controllers/registrations_controller.rb @@ -6,6 +6,7 @@ class RegistrationsController < ApplicationController before_action :set_user, only: :create before_action :set_invitation before_action :claim_invite_code, only: :create, if: :invite_code_required? + before_action :validate_password_requirements, only: :create def new @user = User.new(email: @invitation&.email) @@ -53,4 +54,29 @@ class RegistrationsController < ApplicationController redirect_to new_registration_path, alert: t("registrations.create.invalid_invite_code") end end + + def validate_password_requirements + password = user_params[:password] + return if password.blank? # Let Rails built-in validations handle blank passwords + + if password.length < 8 + @user.errors.add(:password, "must be at least 8 characters") + end + + unless password.match?(/[A-Z]/) && password.match?(/[a-z]/) + @user.errors.add(:password, "must include both uppercase and lowercase letters") + end + + unless password.match?(/\d/) + @user.errors.add(:password, "must include at least one number") + end + + unless password.match?(/[!@#$%^&*(),.?":{}|<>]/) + @user.errors.add(:password, "must include at least one special character") + end + + if @user.errors.present? + render :new, status: :unprocessable_entity + end + end end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 2089e0f7..64321595 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -5,6 +5,10 @@ module ApplicationHelper render partial: "shared/icon", locals: { key:, size:, color: } end + def icon_custom(key, size: "md", color: "current") + render partial: "shared/icon_custom", locals: { key:, size:, color: } + end + # Convert alpha (0-1) to 8-digit hex (00-FF) def hex_with_alpha(hex, alpha) alpha_hex = (alpha * 255).round.to_s(16).rjust(2, "0") diff --git a/app/helpers/forms_helper.rb b/app/helpers/forms_helper.rb index d2f6cb04..5e311810 100644 --- a/app/helpers/forms_helper.rb +++ b/app/helpers/forms_helper.rb @@ -10,9 +10,9 @@ module FormsHelper render partial: "shared/modal_form", locals: { title:, subtitle:, content: } end - def radio_tab_tag(form:, name:, value:, label:, icon:, checked: false, disabled: false) + def radio_tab_tag(form:, name:, value:, label:, icon:, checked: false, disabled: false, class: nil) form.label name, for: form.field_id(name, value), class: "group has-disabled:cursor-not-allowed" do - concat radio_tab_contents(label:, icon:) + concat radio_tab_contents(label:, icon:, class:) concat form.radio_button(name, value, checked:, disabled:, class: "hidden") end end @@ -29,8 +29,8 @@ end end private - def radio_tab_contents(label:, icon:) - tag.div(class: "flex px-4 py-1 rounded-lg items-center space-x-2 justify-center text-subdued group-has-checked:bg-container group-has-checked:text-gray-800 group-has-checked:shadow-sm") do + def radio_tab_contents(label:, icon:, class: nil) + tag.div(class: "flex px-4 py-1 rounded-lg items-center space-x-2 justify-center text-sm md:text-normal text-subdued group-has-checked:bg-container group-has-checked:text-gray-800 group-has-checked:shadow-sm") do concat lucide_icon(icon, class: "w-5 h-5") concat tag.span(label, class: "group-has-checked:font-semibold") end diff --git a/app/helpers/imports_helper.rb b/app/helpers/imports_helper.rb index 67930cc1..4a0dce76 100644 --- a/app/helpers/imports_helper.rb +++ b/app/helpers/imports_helper.rb @@ -55,6 +55,11 @@ module ImportsHelper [ base, border ].join(" ") end + def cell_is_valid?(row, field) + row.valid? # populate errors + !row.errors.key?(field) + end + private def permitted_import_types %w[transaction_import trade_import account_import mint_import] diff --git a/app/helpers/languages_helper.rb b/app/helpers/languages_helper.rb index 7ebf267e..c26a692e 100644 --- a/app/helpers/languages_helper.rb +++ b/app/helpers/languages_helper.rb @@ -154,200 +154,200 @@ module LanguagesHelper ].freeze COUNTRY_MAPPING = { - AF: "Afghanistan", - AL: "Albania", - DZ: "Algeria", - AD: "Andorra", - AO: "Angola", - AG: "Antigua and Barbuda", - AR: "Argentina", - AM: "Armenia", - AU: "Australia", - AT: "Austria", - AZ: "Azerbaijan", - BS: "Bahamas", - BH: "Bahrain", - BD: "Bangladesh", - BB: "Barbados", - BY: "Belarus", - BE: "Belgium", - BZ: "Belize", - BJ: "Benin", - BT: "Bhutan", - BO: "Bolivia", - BA: "Bosnia and Herzegovina", - BW: "Botswana", - BR: "Brazil", - BN: "Brunei", - BG: "Bulgaria", - BF: "Burkina Faso", - BI: "Burundi", - KH: "Cambodia", - CM: "Cameroon", - CA: "Canada", - CV: "Cape Verde", - CF: "Central African Republic", - TD: "Chad", - CL: "Chile", - CN: "China", - CO: "Colombia", - KM: "Comoros", - CG: "Congo", - CD: "Congo, Democratic Republic of the", - CR: "Costa Rica", - CI: "Cรดte d'Ivoire", - HR: "Croatia", - CU: "Cuba", - CY: "Cyprus", - CZ: "Czech Republic", - DK: "Denmark", - DJ: "Djibouti", - DM: "Dominica", - DO: "Dominican Republic", - EC: "Ecuador", - EG: "Egypt", - SV: "El Salvador", - GQ: "Equatorial Guinea", - ER: "Eritrea", - EE: "Estonia", - ET: "Ethiopia", - FJ: "Fiji", - FI: "Finland", - FR: "France", - GA: "Gabon", - GM: "Gambia", - GE: "Georgia", - DE: "Germany", - GH: "Ghana", - GR: "Greece", - GD: "Grenada", - GT: "Guatemala", - GN: "Guinea", - GW: "Guinea-Bissau", - GY: "Guyana", - HT: "Haiti", - HN: "Honduras", - HU: "Hungary", - IS: "Iceland", - IN: "India", - ID: "Indonesia", - IR: "Iran", - IQ: "Iraq", - IE: "Ireland", - IL: "Israel", - IT: "Italy", - JM: "Jamaica", - JP: "Japan", - JO: "Jordan", - KZ: "Kazakhstan", - KE: "Kenya", - KI: "Kiribati", - KP: "North Korea", - KR: "South Korea", - KW: "Kuwait", - KG: "Kyrgyzstan", - LA: "Laos", - LV: "Latvia", - LB: "Lebanon", - LS: "Lesotho", - LR: "Liberia", - LY: "Libya", - LI: "Liechtenstein", - LT: "Lithuania", - LU: "Luxembourg", - MK: "North Macedonia", - MG: "Madagascar", - MW: "Malawi", - MY: "Malaysia", - MV: "Maldives", - ML: "Mali", - MT: "Malta", - MH: "Marshall Islands", - MR: "Mauritania", - MU: "Mauritius", - MX: "Mexico", - FM: "Micronesia", - MD: "Moldova", - MC: "Monaco", - MN: "Mongolia", - ME: "Montenegro", - MA: "Morocco", - MZ: "Mozambique", - MM: "Myanmar", - NA: "Namibia", - NR: "Nauru", - NP: "Nepal", - NL: "Netherlands", - NZ: "New Zealand", - NI: "Nicaragua", - NE: "Niger", - NG: "Nigeria", - NO: "Norway", - OM: "Oman", - PK: "Pakistan", - PW: "Palau", - PA: "Panama", - PG: "Papua New Guinea", - PY: "Paraguay", - PE: "Peru", - PH: "Philippines", - PL: "Poland", - PT: "Portugal", - QA: "Qatar", - RO: "Romania", - RU: "Russia", - RW: "Rwanda", - KN: "Saint Kitts and Nevis", - LC: "Saint Lucia", - VC: "Saint Vincent and the Grenadines", - WS: "Samoa", - SM: "San Marino", - ST: "Sao Tome and Principe", - SA: "Saudi Arabia", - SN: "Senegal", - RS: "Serbia", - SC: "Seychelles", - SL: "Sierra Leone", - SG: "Singapore", - SK: "Slovakia", - SI: "Slovenia", - SB: "Solomon Islands", - SO: "Somalia", - ZA: "South Africa", - SS: "South Sudan", - ES: "Spain", - LK: "Sri Lanka", - SD: "Sudan", - SR: "Suriname", - SE: "Sweden", - CH: "Switzerland", - SY: "Syria", - TW: "Taiwan", - TJ: "Tajikistan", - TZ: "Tanzania", - TH: "Thailand", - TL: "Timor-Leste", - TG: "Togo", - TO: "Tonga", - TT: "Trinidad and Tobago", - TN: "Tunisia", - TR: "Turkey", - TM: "Turkmenistan", - TV: "Tuvalu", - UG: "Uganda", - UA: "Ukraine", - AE: "United Arab Emirates", - GB: "United Kingdom", - US: "United States", - UY: "Uruguay", - UZ: "Uzbekistan", - VU: "Vanuatu", - VA: "Vatican City", - VE: "Venezuela", - VN: "Vietnam", - YE: "Yemen", - ZM: "Zambia", - ZW: "Zimbabwe" + AF: "๐ฆ๐ซ Afghanistan", + AL: "๐ฆ๐ฑ Albania", + DZ: "๐ฉ๐ฟ Algeria", + AD: "๐ฆ๐ฉ Andorra", + AO: "๐ฆ๐ด Angola", + AG: "๐ฆ๐ฌ Antigua and Barbuda", + AR: "๐ฆ๐ท Argentina", + AM: "๐ฆ๐ฒ Armenia", + AU: "๐ฆ๐บ Australia", + AT: "๐ฆ๐น Austria", + AZ: "๐ฆ๐ฟ Azerbaijan", + BS: "๐ง๐ธ Bahamas", + BH: "๐ง๐ญ Bahrain", + BD: "๐ง๐ฉ Bangladesh", + BB: "๐ง๐ง Barbados", + BY: "๐ง๐พ Belarus", + BE: "๐ง๐ช Belgium", + BZ: "๐ง๐ฟ Belize", + BJ: "๐ง๐ฏ Benin", + BT: "๐ง๐น Bhutan", + BO: "๐ง๐ด Bolivia", + BA: "๐ง๐ฆ Bosnia and Herzegovina", + BW: "๐ง๐ผ Botswana", + BR: "๐ง๐ท Brazil", + BN: "๐ง๐ณ Brunei", + BG: "๐ง๐ฌ Bulgaria", + BF: "๐ง๐ซ Burkina Faso", + BI: "๐ง๐ฎ Burundi", + KH: "๐ฐ๐ญ Cambodia", + CM: "๐จ๐ฒ Cameroon", + CA: "๐จ๐ฆ Canada", + CV: "๐จ๐ป Cape Verde", + CF: "๐จ๐ซ Central African Republic", + TD: "๐น๐ฉ Chad", + CL: "๐จ๐ฑ Chile", + CN: "๐จ๐ณ China", + CO: "๐จ๐ด Colombia", + KM: "๐ฐ๐ฒ Comoros", + CG: "๐จ๐ฌ Congo", + CD: "๐จ๐ฉ Congo, Democratic Republic of the", + CR: "๐จ๐ท Costa Rica", + CI: "๐จ๐ฎ Cรดte d'Ivoire", + HR: "๐ญ๐ท Croatia", + CU: "๐จ๐บ Cuba", + CY: "๐จ๐พ Cyprus", + CZ: "๐จ๐ฟ Czech Republic", + DK: "๐ฉ๐ฐ Denmark", + DJ: "๐ฉ๐ฏ Djibouti", + DM: "๐ฉ๐ฒ Dominica", + DO: "๐ฉ๐ด Dominican Republic", + EC: "๐ช๐จ Ecuador", + EG: "๐ช๐ฌ Egypt", + SV: "๐ธ๐ป El Salvador", + GQ: "๐ฌ๐ถ Equatorial Guinea", + ER: "๐ช๐ท Eritrea", + EE: "๐ช๐ช Estonia", + ET: "๐ช๐น Ethiopia", + FJ: "๐ซ๐ฏ Fiji", + FI: "๐ซ๐ฎ Finland", + FR: "๐ซ๐ท France", + GA: "๐ฌ๐ฆ Gabon", + GM: "๐ฌ๐ฒ Gambia", + GE: "๐ฌ๐ช Georgia", + DE: "๐ฉ๐ช Germany", + GH: "๐ฌ๐ญ Ghana", + GR: "๐ฌ๐ท Greece", + GD: "๐ฌ๐ฉ Grenada", + GT: "๐ฌ๐น Guatemala", + GN: "๐ฌ๐ณ Guinea", + GW: "๐ฌ๐ผ Guinea-Bissau", + GY: "๐ฌ๐พ Guyana", + HT: "๐ญ๐น Haiti", + HN: "๐ญ๐ณ Honduras", + HU: "๐ญ๐บ Hungary", + IS: "๐ฎ๐ธ Iceland", + IN: "๐ฎ๐ณ India", + ID: "๐ฎ๐ฉ Indonesia", + IR: "๐ฎ๐ท Iran", + IQ: "๐ฎ๐ถ Iraq", + IE: "๐ฎ๐ช Ireland", + IL: "๐ฎ๐ฑ Israel", + IT: "๐ฎ๐น Italy", + JM: "๐ฏ๐ฒ Jamaica", + JP: "๐ฏ๐ต Japan", + JO: "๐ฏ๐ด Jordan", + KZ: "๐ฐ๐ฟ Kazakhstan", + KE: "๐ฐ๐ช Kenya", + KI: "๐ฐ๐ฎ Kiribati", + KP: "๐ฐ๐ต North Korea", + KR: "๐ฐ๐ท South Korea", + KW: "๐ฐ๐ผ Kuwait", + KG: "๐ฐ๐ฌ Kyrgyzstan", + LA: "๐ฑ๐ฆ Laos", + LV: "๐ฑ๐ป Latvia", + LB: "๐ฑ๐ง Lebanon", + LS: "๐ฑ๐ธ Lesotho", + LR: "๐ฑ๐ท Liberia", + LY: "๐ฑ๐พ Libya", + LI: "๐ฑ๐ฎ Liechtenstein", + LT: "๐ฑ๐น Lithuania", + LU: "๐ฑ๐บ Luxembourg", + MK: "๐ฒ๐ฐ North Macedonia", + MG: "๐ฒ๐ฌ Madagascar", + MW: "๐ฒ๐ผ Malawi", + MY: "๐ฒ๐พ Malaysia", + MV: "๐ฒ๐ป Maldives", + ML: "๐ฒ๐ฑ Mali", + MT: "๐ฒ๐น Malta", + MH: "๐ฒ๐ญ Marshall Islands", + MR: "๐ฒ๐ท Mauritania", + MU: "๐ฒ๐บ Mauritius", + MX: "๐ฒ๐ฝ Mexico", + FM: "๐ซ๐ฒ Micronesia", + MD: "๐ฒ๐ฉ Moldova", + MC: "๐ฒ๐จ Monaco", + MN: "๐ฒ๐ณ Mongolia", + ME: "๐ฒ๐ช Montenegro", + MA: "๐ฒ๐ฆ Morocco", + MZ: "๐ฒ๐ฟ Mozambique", + MM: "๐ฒ๐ฒ Myanmar", + NA: "๐ณ๐ฆ Namibia", + NR: "๐ณ๐ท Nauru", + NP: "๐ณ๐ต Nepal", + NL: "๐ณ๐ฑ Netherlands", + NZ: "๐ณ๐ฟ New Zealand", + NI: "๐ณ๐ฎ Nicaragua", + NE: "๐ณ๐ช Niger", + NG: "๐ณ๐ฌ Nigeria", + NO: "๐ณ๐ด Norway", + OM: "๐ด๐ฒ Oman", + PK: "๐ต๐ฐ Pakistan", + PW: "๐ต๐ผ Palau", + PA: "๐ต๐ฆ Panama", + PG: "๐ต๐ฌ Papua New Guinea", + PY: "๐ต๐พ Paraguay", + PE: "๐ต๐ช Peru", + PH: "๐ต๐ญ Philippines", + PL: "๐ต๐ฑ Poland", + PT: "๐ต๐น Portugal", + QA: "๐ถ๐ฆ Qatar", + RO: "๐ท๐ด Romania", + RU: "๐ท๐บ Russia", + RW: "๐ท๐ผ Rwanda", + KN: "๐ฐ๐ณ Saint Kitts and Nevis", + LC: "๐ฑ๐จ Saint Lucia", + VC: "๐ป๐จ Saint Vincent and the Grenadines", + WS: "๐ผ๐ธ Samoa", + SM: "๐ธ๐ฒ San Marino", + ST: "๐ธ๐น Sao Tome and Principe", + SA: "๐ธ๐ฆ Saudi Arabia", + SN: "๐ธ๐ณ Senegal", + RS: "๐ท๐ธ Serbia", + SC: "๐ธ๐จ Seychelles", + SL: "๐ธ๐ฑ Sierra Leone", + SG: "๐ธ๐ฌ Singapore", + SK: "๐ธ๐ฐ Slovakia", + SI: "๐ธ๐ฎ Slovenia", + SB: "๐ธ๐ง Solomon Islands", + SO: "๐ธ๐ด Somalia", + ZA: "๐ฟ๐ฆ South Africa", + SS: "๐ธ๐ธ South Sudan", + ES: "๐ช๐ธ Spain", + LK: "๐ฑ๐ฐ Sri Lanka", + SD: "๐ธ๐ฉ Sudan", + SR: "๐ธ๐ท Suriname", + SE: "๐ธ๐ช Sweden", + CH: "๐จ๐ญ Switzerland", + SY: "๐ธ๐พ Syria", + TW: "๐น๐ผ Taiwan", + TJ: "๐น๐ฏ Tajikistan", + TZ: "๐น๐ฟ Tanzania", + TH: "๐น๐ญ Thailand", + TL: "๐น๐ฑ Timor-Leste", + TG: "๐น๐ฌ Togo", + TO: "๐น๐ด Tonga", + TT: "๐น๐น Trinidad and Tobago", + TN: "๐น๐ณ Tunisia", + TR: "๐น๐ท Turkey", + TM: "๐น๐ฒ Turkmenistan", + TV: "๐น๐ป Tuvalu", + UG: "๐บ๐ฌ Uganda", + UA: "๐บ๐ฆ Ukraine", + AE: "๐ฆ๐ช United Arab Emirates", + GB: "๐ฌ๐ง United Kingdom", + US: "๐บ๐ธ United States", + UY: "๐บ๐พ Uruguay", + UZ: "๐บ๐ฟ Uzbekistan", + VU: "๐ป๐บ Vanuatu", + VA: "๐ป๐ฆ Vatican City", + VE: "๐ป๐ช Venezuela", + VN: "๐ป๐ณ Vietnam", + YE: "๐พ๐ช Yemen", + ZM: "๐ฟ๐ฒ Zambia", + ZW: "๐ฟ๐ผ Zimbabwe" }.freeze def country_options diff --git a/app/helpers/menus_helper.rb b/app/helpers/menus_helper.rb index 34134888..0ddc9e5f 100644 --- a/app/helpers/menus_helper.rb +++ b/app/helpers/menus_helper.rb @@ -6,8 +6,8 @@ module MenusHelper end end - def contextual_menu_modal_action_item(label, url, icon: "pencil-line", turbo_frame: :modal) - link_to url, class: "flex items-center rounded-md text-primary hover:bg-container-hover p-2 gap-2", data: { action: "click->menu#close", turbo_frame: turbo_frame } do + def contextual_menu_modal_action_item(label, url, icon: "pencil-line", turbo_frame: :modal, class_name: nil) + link_to url, class: "flex items-center rounded-md text-primary hover:bg-container-hover p-2 gap-2 #{class_name}", data: { action: "click->menu#close", turbo_frame: turbo_frame } do concat(lucide_icon(icon, class: "shrink-0 w-5 h-5 text-secondary")) concat(tag.span(label, class: "text-sm")) end @@ -33,7 +33,8 @@ module MenusHelper private def contextual_menu_icon(icon) tag.button class: "w-9 h-9 flex justify-center items-center hover:bg-surface-hover rounded-lg cursor-pointer focus:outline-none focus-visible:outline-none", data: { menu_target: "button" } do - lucide_icon icon, class: "w-5 h-5 text-secondary" + concat lucide_icon("more-vertical", class: "w-5 h-5 text-secondary md:hidden") + concat lucide_icon(icon, class: "w-5 h-5 text-secondary hidden md:block") end end diff --git a/app/helpers/settings_helper.rb b/app/helpers/settings_helper.rb index e15414a5..674bea8b 100644 --- a/app/helpers/settings_helper.rb +++ b/app/helpers/settings_helper.rb @@ -40,7 +40,17 @@ module SettingsHelper previous_setting = adjacent_setting(request.path, -1) next_setting = adjacent_setting(request.path, 1) - content_tag :div, class: "flex justify-between gap-4" do + content_tag :div, class: "hidden md:flex flex-row justify-between gap-4" do + concat(previous_setting) + concat(next_setting) + end + end + + def settings_nav_footer_mobile + previous_setting = adjacent_setting(request.path, -1) + next_setting = adjacent_setting(request.path, 1) + + content_tag :div, class: "md:hidden flex flex-col gap-4" do concat(previous_setting) concat(next_setting) end diff --git a/app/javascript/controllers/file_upload_controller.js b/app/javascript/controllers/file_upload_controller.js new file mode 100644 index 00000000..6ad3e5d9 --- /dev/null +++ b/app/javascript/controllers/file_upload_controller.js @@ -0,0 +1,74 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["input", "fileName", "uploadArea", "uploadText"] + + connect() { + if (this.hasInputTarget) { + this.inputTarget.addEventListener("change", this.fileSelected.bind(this)) + } + + // Find the form element + this.form = this.element.closest("form") + if (this.form) { + this.form.addEventListener("turbo:submit-start", this.formSubmitting.bind(this)) + } + } + + disconnect() { + if (this.hasInputTarget) { + this.inputTarget.removeEventListener("change", this.fileSelected.bind(this)) + } + + if (this.form) { + this.form.removeEventListener("turbo:submit-start", this.formSubmitting.bind(this)) + } + } + + triggerFileInput() { + if (this.hasInputTarget) { + this.inputTarget.click() + } + } + + fileSelected() { + if (this.hasInputTarget && this.inputTarget.files.length > 0) { + const fileName = this.inputTarget.files[0].name + + if (this.hasFileNameTarget) { + // Find the paragraph element inside the fileName target + const fileNameText = this.fileNameTarget.querySelector('p') + if (fileNameText) { + fileNameText.textContent = fileName + } + + this.fileNameTarget.classList.remove("hidden") + } + + if (this.hasUploadTextTarget) { + this.uploadTextTarget.classList.add("hidden") + } + + + } + } + + formSubmitting() { + if (this.hasFileNameTarget && this.hasInputTarget && this.inputTarget.files.length > 0) { + const fileNameText = this.fileNameTarget.querySelector('p') + if (fileNameText) { + fileNameText.textContent = `Uploading ${this.inputTarget.files[0].name}...` + } + + // Change the icon to a loader + const iconContainer = this.fileNameTarget.querySelector('.lucide-file-text') + if (iconContainer) { + iconContainer.classList.add('animate-pulse') + } + } + + if (this.hasUploadAreaTarget) { + this.uploadAreaTarget.classList.add("opacity-70") + } + } +} \ No newline at end of file diff --git a/app/javascript/controllers/mobile_cell_interaction_controller.js b/app/javascript/controllers/mobile_cell_interaction_controller.js new file mode 100644 index 00000000..1eb0d82b --- /dev/null +++ b/app/javascript/controllers/mobile_cell_interaction_controller.js @@ -0,0 +1,149 @@ +import { Controller } from "@hotwired/stimulus"; + +// Connects to data-controller="mobile-cell-interaction" +export default class extends Controller { + static targets = ["field", "highlight", "errorTooltip", "errorIcon"]; + static values = { error: String }; + + touchTimeout = null; + activeTooltip = null; + documentClickHandler = null; + + connect() { + this.documentClickHandler = this.handleDocumentClick.bind(this); + document.addEventListener('click', this.documentClickHandler); + } + + disconnect() { + if (this.documentClickHandler) { + document.removeEventListener('click', this.documentClickHandler); + } + } + + handleDocumentClick(event) { + if (event.target.closest('[data-mobile-cell-interaction-target="errorTooltip"]') || + event.target.closest('[data-mobile-cell-interaction-target="errorIcon"]')) { + return; + } + + this.hideAllErrorTooltips(); + } + + highlightCell(event) { + const field = event.target; + const highlight = this.findHighlightForField(field); + if (highlight) { + highlight.style.opacity = '1'; + } + } + + unhighlightCell(event) { + const field = event.target; + const highlight = this.findHighlightForField(field); + if (highlight) { + highlight.style.opacity = '0'; + } + + this.hideAllErrorTooltips(); + } + + handleCellTouch(event) { + if (this.touchTimeout) { + clearTimeout(this.touchTimeout); + } + + const field = event.target; + + const highlight = this.findHighlightForField(field); + if (highlight) { + highlight.style.opacity = '1'; + + this.touchTimeout = window.setTimeout(() => { + if (document.activeElement !== field) { + highlight.style.opacity = '0'; + } + }, 1000); + } + + if (this.hasErrorValue && this.errorValue) { + this.showErrorTooltip(); + } + } + + toggleErrorMessage(event) { + const errorIcon = event.currentTarget; + const cellContainer = errorIcon.closest('div'); + const field = cellContainer.querySelector('input'); + + if (field) { + field.focus(); + } + + const tooltip = this.errorTooltipTarget; + + this.hideAllTooltipsExcept(tooltip); + + if (tooltip.classList.contains('hidden')) { + tooltip.classList.remove('hidden'); + this.activeTooltip = tooltip; + + setTimeout(() => { + if (tooltip === this.activeTooltip) { + tooltip.classList.add('hidden'); + this.activeTooltip = null; + } + }, 3000); + } else { + tooltip.classList.add('hidden'); + this.activeTooltip = null; + } + + event.stopPropagation(); + } + + showErrorTooltip() { + if (this.hasErrorTooltipTarget) { + const tooltip = this.errorTooltipTarget; + tooltip.classList.remove('hidden'); + this.activeTooltip = tooltip; + + setTimeout(() => { + if (tooltip === this.activeTooltip) { + tooltip.classList.add('hidden'); + this.activeTooltip = null; + } + }, 3000); + } + } + + hideAllErrorTooltips() { + document.querySelectorAll('[data-mobile-cell-interaction-target="errorTooltip"]').forEach(tooltip => { + tooltip.classList.add('hidden'); + }); + this.activeTooltip = null; + } + + hideAllTooltipsExcept(tooltipToKeep) { + document.querySelectorAll('[data-mobile-cell-interaction-target="errorTooltip"]').forEach(tooltip => { + if (tooltip !== tooltipToKeep) { + tooltip.classList.add('hidden'); + } + }); + } + + selectCell(event) { + const errorIcon = event.currentTarget; + const cellContainer = errorIcon.closest('div'); + const field = cellContainer.querySelector('input'); + + if (field) { + field.focus(); + event.stopPropagation(); + } + } + + findHighlightForField(field) { + const container = field.closest('div'); + return container ? container.querySelector('[data-mobile-cell-interaction-target="highlight"]') : null; + } +} \ No newline at end of file diff --git a/app/javascript/controllers/password_validator_controller.js b/app/javascript/controllers/password_validator_controller.js new file mode 100644 index 00000000..4de9c6fd --- /dev/null +++ b/app/javascript/controllers/password_validator_controller.js @@ -0,0 +1,63 @@ +import { Controller } from "@hotwired/stimulus"; + +// Connects to data-controller="password-validator" +export default class extends Controller { + static targets = ["input", "requirementType", "blockLine"]; + + connect() { + this.validate(); + } + + validate() { + const password = this.inputTarget.value; + let requirementsMet = 0; + + // Check each requirement and count how many are met + const lengthValid = password.length >= 8; + const caseValid = /[A-Z]/.test(password) && /[a-z]/.test(password); + const numberValid = /\d/.test(password); + const specialValid = /[!@#$%^&*(),.?":{}|<>]/.test(password); + + // Update individual requirement text + this.validateRequirementText("length", lengthValid); + this.validateRequirementText("case", caseValid); + this.validateRequirementText("number", numberValid); + this.validateRequirementText("special", specialValid); + + // Count total requirements met + if (lengthValid) requirementsMet++; + if (caseValid) requirementsMet++; + if (numberValid) requirementsMet++; + if (specialValid) requirementsMet++; + + // Update block lines sequentially + this.updateBlockLines(requirementsMet); + } + + validateRequirementText(type, isValid) { + this.requirementTypeTargets.forEach((target) => { + if (target.dataset.requirementType === type) { + if (isValid) { + target.classList.remove("text-secondary"); + target.classList.add("text-green-600"); + } else { + target.classList.remove("text-green-600"); + target.classList.add("text-secondary"); + } + } + }); + } + + updateBlockLines(requirementsMet) { + // Update block lines sequentially based on total requirements met + this.blockLineTargets.forEach((line, index) => { + if (index < requirementsMet) { + line.classList.remove("bg-gray-200"); + line.classList.add("bg-green-600"); + } else { + line.classList.remove("bg-green-600"); + line.classList.add("bg-gray-200"); + } + }); + } +} diff --git a/app/javascript/controllers/password_visibility_controller.js b/app/javascript/controllers/password_visibility_controller.js new file mode 100644 index 00000000..ebe68eae --- /dev/null +++ b/app/javascript/controllers/password_visibility_controller.js @@ -0,0 +1,19 @@ +import { Controller } from "@hotwired/stimulus"; + +// Connects to data-controller="password-visibility" +export default class extends Controller { + static targets = ["input", "showIcon", "hideIcon"]; + + connect() { + this.hideIconTarget.classList.add("hidden"); + } + + toggle() { + const input = this.inputTarget; + const type = input.type === "password" ? "text" : "password"; + input.type = type; + + this.showIconTarget.classList.toggle("hidden"); + this.hideIconTarget.classList.toggle("hidden"); + } +} diff --git a/app/javascript/controllers/preserve_scroll_controller.js b/app/javascript/controllers/preserve_scroll_controller.js new file mode 100644 index 00000000..c2110fd1 --- /dev/null +++ b/app/javascript/controllers/preserve_scroll_controller.js @@ -0,0 +1,39 @@ +/* + https://dev.to/konnorrogers/maintain-scroll-position-in-turbo-without-data-turbo-permanent-2b1i + modified to add support for horizontal scrolling + */ +if (!window.scrollPositions) { + window.scrollPositions = {}; +} + +function preserveScroll() { + document.querySelectorAll("[data-preserve-scroll]").forEach((element) => { + scrollPositions[element.id] = { + top: element.scrollTop, + left: element.scrollLeft + }; + }); +} + +function restoreScroll(event) { + document.querySelectorAll("[data-preserve-scroll]").forEach((element) => { + if (scrollPositions[element.id]) { + element.scrollTop = scrollPositions[element.id].top; + element.scrollLeft = scrollPositions[element.id].left; + } + }); + + if (!event.detail.newBody) return; + // event.detail.newBody is the body element to be swapped in. + // https://turbo.hotwired.dev/reference/events + event.detail.newBody.querySelectorAll("[data-preserve-scroll]").forEach((element) => { + if (scrollPositions[element.id]) { + element.scrollTop = scrollPositions[element.id].top; + element.scrollLeft = scrollPositions[element.id].left; + } + }); +} + +window.addEventListener("turbo:before-cache", preserveScroll); +window.addEventListener("turbo:before-render", restoreScroll); +window.addEventListener("turbo:render", restoreScroll); diff --git a/app/javascript/controllers/profile_image_preview_controller.js b/app/javascript/controllers/profile_image_preview_controller.js index 7e568bee..83134e38 100644 --- a/app/javascript/controllers/profile_image_preview_controller.js +++ b/app/javascript/controllers/profile_image_preview_controller.js @@ -8,6 +8,9 @@ export default class extends Controller { "deleteProfileImage", "input", "clearBtn", + "uploadText", + "changeText", + "cameraIcon" ]; clearFileInput() { @@ -17,6 +20,12 @@ export default class extends Controller { this.attachedImageTarget.classList.add("hidden"); this.previewImageTarget.classList.add("hidden"); this.deleteProfileImageTarget.value = "1"; + this.uploadTextTarget.classList.remove("hidden"); + this.changeTextTarget.classList.add("hidden"); + this.changeTextTarget.setAttribute("aria-hidden", "true"); + this.uploadTextTarget.setAttribute("aria-hidden", "false"); + this.cameraIconTarget.classList.remove("!hidden"); + } showFileInputPreview(event) { @@ -28,7 +37,11 @@ export default class extends Controller { this.previewImageTarget.classList.remove("hidden"); this.clearBtnTarget.classList.remove("hidden"); this.deleteProfileImageTarget.value = "0"; - + this.uploadTextTarget.classList.add("hidden"); + this.changeTextTarget.classList.remove("hidden"); + this.changeTextTarget.setAttribute("aria-hidden", "false"); + this.uploadTextTarget.setAttribute("aria-hidden", "true"); + this.cameraIconTarget.classList.add("!hidden"); this.previewImageTarget.querySelector("img").src = URL.createObjectURL(file); } diff --git a/app/javascript/controllers/theme_controller.js b/app/javascript/controllers/theme_controller.js index d01edf7f..47815efc 100644 --- a/app/javascript/controllers/theme_controller.js +++ b/app/javascript/controllers/theme_controller.js @@ -50,7 +50,8 @@ export default class extends Controller { } systemPrefersDark() { - return window.matchMedia("(prefers-color-scheme: dark)").matches + return false + // return window.matchMedia("(prefers-color-scheme: dark)").matches } handleSystemThemeChange = (event) => { diff --git a/app/views/accounts/index.html.erb b/app/views/accounts/index.html.erb index 7d2eb4f7..d8c9b821 100644 --- a/app/views/accounts/index.html.erb +++ b/app/views/accounts/index.html.erb @@ -4,17 +4,20 @@
<%= t(".new_account") %>
+ class: "btn btn--primary flex items-center justify-center gap-1 w-9 h-9 md:w-auto md:h-auto rounded-full md:rounded-lg" do %> +<%= t(".new_account") %>
<% end %>