From 90a9546f320a36e0dc0ca216604a4324182b7b27 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Wed, 30 Apr 2025 18:14:22 -0400 Subject: [PATCH] Pre-launch design sync with Figma spec (#2154) * Add lookbook + viewcomponent, organize design system file * Build menu component * Button updates * More button fixes * Replace all menus with new ViewComponent * Checkpoint: fix tests, all buttons and menus converted * Split into Link and Button components for clarity * Button cleanup * Simplify custom confirmation configuration in views * Finalize button, link component API * Add toggle field to custom form builder + Component * Basic tabs component * Custom tabs, convert all menu / tab instances in app * Gem updates * Centralized icon helper * Update all icon usage to central helper * Lint fixes * Centralize all disclosure instances * Dialog replacements * Consolidation of all dialog styles * Test fixes * Fix app layout issues, move to component with slots * Layout simplification * Flakey test fix * Fix dashboard mobile issues * Finalize homepage * Lint fixes * Fix shadows and borders in dark mode * Fix tests * Remove stale class * Fix filled icon logic * Move transparent? to public interface --- .cursor/rules/project-conventions.mdc | 1 + Gemfile | 4 +- Gemfile.lock | 118 +++-- app/assets/tailwind/maybe-design-system.css | 489 ++---------------- .../maybe-design-system/background-utils.css | 87 ++++ .../maybe-design-system/border-utils.css | 88 ++++ .../maybe-design-system/component-utils.css | 109 ++++ .../maybe-design-system/foreground-utils.css | 63 +++ .../maybe-design-system/text-utils.css | 39 ++ app/components/button_component.html.erb | 13 + app/components/button_component.rb | 41 ++ app/components/buttonish_component.rb | 148 ++++++ app/components/dialog_component.html.erb | 38 ++ app/components/dialog_component.rb | 105 ++++ .../dialog_controller.js} | 15 +- app/components/disclosure_component.html.erb | 25 + app/components/disclosure_component.rb | 12 + app/components/filled_icon_component.html.erb | 8 + app/components/filled_icon_component.rb | 97 ++++ app/components/link_component.html.erb | 13 + app/components/link_component.rb | 31 ++ app/components/menu_component.html.erb | 27 + app/components/menu_component.rb | 37 ++ .../menu_controller.js | 0 app/components/menu_item_component.html.erb | 12 + app/components/menu_item_component.rb | 57 ++ app/components/tab_component.rb | 12 + app/components/tabs/nav_component.rb | 29 ++ app/components/tabs/panel_component.rb | 11 + app/components/tabs_component.html.erb | 17 + app/components/tabs_component.rb | 65 +++ app/components/tabs_controller.js | 42 ++ app/components/toggle_component.html.erb | 5 + app/components/toggle_component.rb | 26 + app/controllers/chats_controller.rb | 2 - app/controllers/lookbooks_controller.rb | 3 + app/helpers/application_helper.rb | 119 +---- app/helpers/custom_confirm.rb | 51 ++ app/helpers/forms_helper.rb | 22 - app/helpers/menus_helper.rb | 47 -- app/helpers/styled_form_builder.rb | 43 +- app/helpers/transactions_helper.rb | 14 +- .../account_collapse_controller.js | 51 -- .../controllers/app_layout_controller.js | 56 ++ app/javascript/controllers/application.js | 52 +- .../controllers/bulk_select_controller.js | 7 +- .../controllers/color_avatar_controller.js | 4 +- .../controllers/confirm_dialog_controller.js | 59 +++ .../controllers/deletion_controller.js | 32 +- .../controllers/intercom_controller.js | 8 + .../controllers/preserve_scroll_controller.js | 39 -- .../controllers/rule/actions_controller.js | 1 + .../controllers/sidebar_controller.js | 86 --- app/javascript/controllers/tabs_controller.js | 74 --- .../controllers/theme_controller.js | 56 +- .../time_series_chart_controller.js | 59 ++- app/models/balance_sheet.rb | 8 +- app/models/family.rb | 4 + app/models/period.rb | 4 + app/views/accounts/_account.html.erb | 6 +- .../accounts/_account_sidebar_tabs.html.erb | 155 +++--- app/views/accounts/_account_type.html.erb | 10 +- .../accounts/_accountable_group.html.erb | 29 +- app/views/accounts/_empty.html.erb | 9 +- app/views/accounts/_logo.html.erb | 2 +- app/views/accounts/index.html.erb | 32 +- .../accounts/index/_manual_accounts.html.erb | 4 +- app/views/accounts/new.html.erb | 10 +- app/views/accounts/new/_container.html.erb | 42 +- .../accounts/new/_method_selector.html.erb | 12 +- app/views/accounts/show/_activity.html.erb | 43 +- app/views/accounts/show/_chart.html.erb | 6 +- app/views/accounts/show/_header.html.erb | 24 +- app/views/accounts/show/_menu.html.erb | 62 +-- app/views/accounts/show/_tabs.html.erb | 18 +- app/views/accounts/show/_template.html.erb | 2 +- .../_assistant_message.html.erb | 2 +- .../assistant_messages/_tool_calls.html.erb | 2 +- .../_budget_category.html.erb | 12 +- .../_budget_category_donut.html.erb | 4 +- .../_confirm_button.html.erb | 17 +- .../budget_categories/_no_categories.html.erb | 16 +- app/views/budget_categories/index.html.erb | 2 +- app/views/budget_categories/show.html.erb | 99 ++-- app/views/budgets/_budget_categories.html.erb | 2 +- app/views/budgets/_budget_donut.html.erb | 40 +- app/views/budgets/_budget_header.html.erb | 48 +- app/views/budgets/_budget_nav.html.erb | 2 +- .../budgets/_over_allocation_warning.html.erb | 16 +- app/views/budgets/_picker.html.erb | 22 +- app/views/budgets/edit.html.erb | 20 +- app/views/budgets/show.html.erb | 10 +- app/views/categories/_badge.html.erb | 3 +- app/views/categories/_category.html.erb | 18 +- app/views/categories/_color_avatar.html.erb | 2 +- app/views/categories/_form.html.erb | 103 ++-- app/views/categories/_menu.html.erb | 27 +- app/views/categories/edit.html.erb | 7 +- app/views/categories/index.html.erb | 41 +- app/views/categories/new.html.erb | 7 +- app/views/category/deletions/new.html.erb | 33 +- app/views/category/dropdowns/_row.html.erb | 29 +- app/views/category/dropdowns/show.html.erb | 18 +- app/views/chats/_ai_consent.html.erb | 55 +- app/views/chats/_chat.html.erb | 12 +- app/views/chats/_chat_nav.html.erb | 19 +- app/views/chats/_error.html.erb | 7 +- app/views/chats/index.html.erb | 55 +- app/views/chats/show.html.erb | 59 ++- app/views/credit_cards/_overview.html.erb | 7 +- app/views/credit_cards/edit.html.erb | 8 +- app/views/credit_cards/new.html.erb | 7 +- app/views/cryptos/edit.html.erb | 7 +- app/views/cryptos/new.html.erb | 7 +- app/views/depositories/edit.html.erb | 7 +- app/views/depositories/new.html.erb | 7 +- app/views/entries/_selection_bar.html.erb | 4 +- .../_family_merchant.html.erb | 20 +- app/views/family_merchants/_form.html.erb | 10 +- app/views/family_merchants/edit.html.erb | 7 +- app/views/family_merchants/index.html.erb | 21 +- app/views/family_merchants/new.html.erb | 7 +- app/views/holdings/_cash.html.erb | 7 +- .../holdings/_missing_price_tooltip.html.erb | 2 +- app/views/holdings/index.html.erb | 4 +- app/views/holdings/show.html.erb | 39 +- .../_approval_bar.html.erb | 6 +- .../_super_admin_bar.html.erb | 4 +- app/views/import/cleans/show.html.erb | 18 +- .../configurations/_account_import.html.erb | 2 +- .../configurations/_mint_import.html.erb | 6 +- .../configurations/_trade_import.html.erb | 2 +- .../_transaction_import.html.erb | 4 +- app/views/import/configurations/show.html.erb | 4 +- app/views/import/confirms/_mappings.html.erb | 33 +- app/views/import/rows/_form.html.erb | 6 +- app/views/import/uploads/show.html.erb | 100 ++-- app/views/imports/_empty.html.erb | 12 +- app/views/imports/_failure.html.erb | 6 +- app/views/imports/_import.html.erb | 50 +- app/views/imports/_importing.html.erb | 6 +- app/views/imports/_nav.html.erb | 2 +- app/views/imports/_ready.html.erb | 8 +- app/views/imports/_revert_failure.html.erb | 10 +- app/views/imports/_success.html.erb | 11 +- app/views/imports/index.html.erb | 11 +- app/views/imports/new.html.erb | 43 +- app/views/investments/_value_tooltip.html.erb | 2 +- app/views/investments/edit.html.erb | 7 +- app/views/investments/new.html.erb | 7 +- app/views/invitations/new.html.erb | 36 +- app/views/invite_codes/_invite_code.html.erb | 4 +- app/views/invite_codes/index.html.erb | 2 +- app/views/layouts/application.html.erb | 203 ++++---- app/views/layouts/imports.html.erb | 16 +- app/views/layouts/lookbooks.html.erb | 14 + app/views/layouts/settings.html.erb | 2 +- .../layouts/shared/_breadcrumbs.html.erb | 42 +- .../layouts/shared/_confirm_dialog.html.erb | 30 ++ .../layouts/shared/_fixed_content.html.erb | 6 - app/views/layouts/shared/_htmldoc.html.erb | 17 +- app/views/layouts/shared/_nav_item.html.erb | 20 + app/views/layouts/sidebar/_nav_item.html.erb | 18 - app/views/layouts/wizard.html.erb | 16 +- app/views/loans/_overview.html.erb | 7 +- app/views/loans/edit.html.erb | 7 +- app/views/loans/new.html.erb | 7 +- app/views/merchants/_merchant.html.erb | 22 +- app/views/messages/_chat_form.html.erb | 8 +- app/views/mfa/backup_codes.html.erb | 9 +- app/views/mfa/new.html.erb | 6 +- app/views/onboardings/_header.html.erb | 2 +- app/views/onboardings/preferences.html.erb | 2 +- app/views/onboardings/show.html.erb | 7 +- app/views/other_assets/edit.html.erb | 7 +- app/views/other_assets/new.html.erb | 7 +- app/views/other_liabilities/edit.html.erb | 7 +- app/views/other_liabilities/new.html.erb | 7 +- app/views/pages/dashboard.html.erb | 36 +- .../pages/dashboard/_balance_sheet.html.erb | 79 ++- .../pages/dashboard/_group_weight.html.erb | 10 + .../pages/dashboard/_net_worth_chart.html.erb | 9 +- .../_no_account_empty_state.html.erb | 15 - .../_no_accounts_graph_placeholder.html.erb | 17 + app/views/pages/early_access.html.erb | 2 +- app/views/pages/feedback.html.erb | 5 +- app/views/plaid_items/_plaid_item.html.erb | 115 ++-- app/views/properties/_overview.html.erb | 7 +- app/views/properties/edit.html.erb | 7 +- app/views/properties/new.html.erb | 7 +- app/views/registrations/new.html.erb | 14 +- app/views/rule/actions/_action.html.erb | 10 +- app/views/rule/conditions/_condition.html.erb | 11 +- .../rule/conditions/_condition_group.html.erb | 19 +- app/views/rules/_category_rule_cta.html.erb | 5 +- app/views/rules/_form.html.erb | 21 +- app/views/rules/_rule.html.erb | 26 +- app/views/rules/confirm.html.erb | 36 +- app/views/rules/edit.html.erb | 7 +- app/views/rules/index.html.erb | 40 +- app/views/rules/new.html.erb | 7 +- app/views/settings/_settings_nav.html.erb | 24 +- .../settings/_settings_nav_item.html.erb | 4 +- .../_settings_nav_link_large.html.erb | 16 +- app/views/settings/_user_avatar.html.erb | 8 +- .../settings/_user_avatar_field.html.erb | 11 +- app/views/settings/billings/show.html.erb | 27 +- .../hostings/_invite_code_settings.html.erb | 20 +- app/views/settings/preferences/show.html.erb | 10 +- app/views/settings/profiles/show.html.erb | 86 ++- app/views/settings/securities/show.html.erb | 30 +- app/views/shared/_circle_logo.html.erb | 13 - app/views/shared/_confirm_modal.html.erb | 16 - app/views/shared/_disclosure.html.erb | 12 - app/views/shared/_drawer.html.erb | 19 - app/views/shared/_form_errors.html.erb | 4 +- app/views/shared/_icon.html.erb | 6 - app/views/shared/_icon_custom.html.erb | 6 - app/views/shared/_icon_image.html.erb | 6 - app/views/shared/_modal.html.erb | 19 - app/views/shared/_modal_form.html.erb | 18 - app/views/shared/_money_field.html.erb | 2 +- app/views/shared/_pagination.html.erb | 8 +- app/views/shared/_subscribe_modal.html.erb | 43 +- app/views/shared/_toggle_form.html.erb | 11 - .../shared/_transaction_type_tabs.html.erb | 12 +- app/views/shared/_trend_change.html.erb | 2 +- .../shared/notifications/_alert.html.erb | 4 +- app/views/shared/notifications/_cta.html.erb | 2 +- .../shared/notifications/_loading.html.erb | 2 +- .../shared/notifications/_notice.html.erb | 30 +- app/views/tag/deletions/new.html.erb | 33 +- app/views/tags/_tag.html.erb | 34 +- app/views/tags/edit.html.erb | 7 +- app/views/tags/index.html.erb | 22 +- app/views/tags/new.html.erb | 7 +- app/views/trades/_header.html.erb | 2 +- app/views/trades/_trade.html.erb | 9 +- app/views/trades/new.html.erb | 7 +- app/views/trades/show.html.erb | 27 +- app/views/transactions/_form.html.erb | 8 +- app/views/transactions/_header.html.erb | 2 +- .../transactions/_selection_bar.html.erb | 8 +- app/views/transactions/_transaction.html.erb | 13 +- .../transactions/_transfer_match.html.erb | 6 +- .../transactions/bulk_updates/new.html.erb | 76 +-- app/views/transactions/index.html.erb | 55 +- app/views/transactions/new.html.erb | 7 +- .../transactions/searches/_form.html.erb | 23 +- .../transactions/searches/_menu.html.erb | 71 ++- .../searches/filters/_account_filter.html.erb | 2 +- .../filters/_category_filter.html.erb | 2 +- .../filters/_merchant_filter.html.erb | 11 +- .../searches/filters/_tag_filter.html.erb | 11 +- app/views/transactions/show.html.erb | 53 +- app/views/transfer_matches/new.html.erb | 109 ++-- app/views/transfers/_account_links.html.erb | 23 +- app/views/transfers/_form.html.erb | 4 +- app/views/transfers/new.html.erb | 7 +- app/views/transfers/show.html.erb | 49 +- app/views/users/_user_menu.html.erb | 100 ++-- app/views/valuations/_valuation.html.erb | 4 +- app/views/valuations/index.html.erb | 4 +- app/views/valuations/new.html.erb | 7 +- app/views/valuations/show.html.erb | 19 +- app/views/vehicles/_overview.html.erb | 7 +- app/views/vehicles/edit.html.erb | 7 +- app/views/vehicles/new.html.erb | 7 +- config/application.rb | 5 + config/importmap.rb | 1 + config/initializers/assets.rb | 2 +- config/initializers/mini_profiler.rb | 3 + config/routes.rb | 2 + lib/money/currency.rb | 6 + test/application_system_test_case.rb | 6 + .../previews/button_component_preview.rb | 16 + .../previews/dialog_component_preview.rb | 46 ++ .../previews/disclosure_component_preview.rb | 13 + .../previews/filled_icon_component_preview.rb | 11 + .../previews/link_component_preview.rb | 25 + .../previews/menu_component_preview.rb | 44 ++ .../previews/tabs_component_preview.rb | 7 + .../tabs_component_preview/custom.html.erb | 29 ++ .../tabs_component_preview/default.html.erb | 16 + .../previews/toggle_component_preview.rb | 15 + test/controllers/chats_controller_test.rb | 6 - test/system/accounts_test.rb | 7 +- test/system/chats_test.rb | 2 +- test/system/imports_test.rb | 32 +- test/system/settings_test.rb | 6 +- test/system/trades_test.rb | 6 +- 291 files changed, 4143 insertions(+), 3104 deletions(-) create mode 100644 app/assets/tailwind/maybe-design-system/background-utils.css create mode 100644 app/assets/tailwind/maybe-design-system/border-utils.css create mode 100644 app/assets/tailwind/maybe-design-system/component-utils.css create mode 100644 app/assets/tailwind/maybe-design-system/foreground-utils.css create mode 100644 app/assets/tailwind/maybe-design-system/text-utils.css create mode 100644 app/components/button_component.html.erb create mode 100644 app/components/button_component.rb create mode 100644 app/components/buttonish_component.rb create mode 100644 app/components/dialog_component.html.erb create mode 100644 app/components/dialog_component.rb rename app/{javascript/controllers/modal_controller.js => components/dialog_controller.js} (55%) create mode 100644 app/components/disclosure_component.html.erb create mode 100644 app/components/disclosure_component.rb create mode 100644 app/components/filled_icon_component.html.erb create mode 100644 app/components/filled_icon_component.rb create mode 100644 app/components/link_component.html.erb create mode 100644 app/components/link_component.rb create mode 100644 app/components/menu_component.html.erb create mode 100644 app/components/menu_component.rb rename app/{javascript/controllers => components}/menu_controller.js (100%) create mode 100644 app/components/menu_item_component.html.erb create mode 100644 app/components/menu_item_component.rb create mode 100644 app/components/tab_component.rb create mode 100644 app/components/tabs/nav_component.rb create mode 100644 app/components/tabs/panel_component.rb create mode 100644 app/components/tabs_component.html.erb create mode 100644 app/components/tabs_component.rb create mode 100644 app/components/tabs_controller.js create mode 100644 app/components/toggle_component.html.erb create mode 100644 app/components/toggle_component.rb create mode 100644 app/controllers/lookbooks_controller.rb create mode 100644 app/helpers/custom_confirm.rb delete mode 100644 app/helpers/forms_helper.rb delete mode 100644 app/helpers/menus_helper.rb delete mode 100644 app/javascript/controllers/account_collapse_controller.js create mode 100644 app/javascript/controllers/app_layout_controller.js create mode 100644 app/javascript/controllers/confirm_dialog_controller.js create mode 100644 app/javascript/controllers/intercom_controller.js delete mode 100644 app/javascript/controllers/preserve_scroll_controller.js delete mode 100644 app/javascript/controllers/sidebar_controller.js delete mode 100644 app/javascript/controllers/tabs_controller.js create mode 100644 app/views/layouts/lookbooks.html.erb create mode 100644 app/views/layouts/shared/_confirm_dialog.html.erb delete mode 100644 app/views/layouts/shared/_fixed_content.html.erb create mode 100644 app/views/layouts/shared/_nav_item.html.erb delete mode 100644 app/views/layouts/sidebar/_nav_item.html.erb create mode 100644 app/views/pages/dashboard/_group_weight.html.erb delete mode 100644 app/views/pages/dashboard/_no_account_empty_state.html.erb create mode 100644 app/views/pages/dashboard/_no_accounts_graph_placeholder.html.erb delete mode 100644 app/views/shared/_circle_logo.html.erb delete mode 100644 app/views/shared/_confirm_modal.html.erb delete mode 100644 app/views/shared/_disclosure.html.erb delete mode 100644 app/views/shared/_drawer.html.erb delete mode 100644 app/views/shared/_icon.html.erb delete mode 100644 app/views/shared/_icon_custom.html.erb delete mode 100644 app/views/shared/_icon_image.html.erb delete mode 100644 app/views/shared/_modal.html.erb delete mode 100644 app/views/shared/_modal_form.html.erb delete mode 100644 app/views/shared/_toggle_form.html.erb create mode 100644 config/initializers/mini_profiler.rb create mode 100644 test/components/previews/button_component_preview.rb create mode 100644 test/components/previews/dialog_component_preview.rb create mode 100644 test/components/previews/disclosure_component_preview.rb create mode 100644 test/components/previews/filled_icon_component_preview.rb create mode 100644 test/components/previews/link_component_preview.rb create mode 100644 test/components/previews/menu_component_preview.rb create mode 100644 test/components/previews/tabs_component_preview.rb create mode 100644 test/components/previews/tabs_component_preview/custom.html.erb create mode 100644 test/components/previews/tabs_component_preview/default.html.erb create mode 100644 test/components/previews/toggle_component_preview.rb diff --git a/.cursor/rules/project-conventions.mdc b/.cursor/rules/project-conventions.mdc index 33906c22..5a312840 100644 --- a/.cursor/rules/project-conventions.mdc +++ b/.cursor/rules/project-conventions.mdc @@ -53,6 +53,7 @@ This codebase adopts a "skinny controller, fat models" convention. Furthermore, - Use Turbo streams to enhance functionality, but do not solely depend on it - Format currencies, numbers, dates, and other values server-side, then pass to Stimulus controllers for display only - Keep client-side code for where it truly shines. For example, @bulk_select_controller.js is a case where server-side solutions would degrade the user experience significantly. When bulk-selecting entries, client-side solutions are the way to go and Stimulus provides the right toolset to achieve this. +- Always use the `icon` helper in [application_helper.rb](mdc:app/helpers/application_helper.rb) for icons. NEVER use `lucide_icon` helper directly. The Hotwire suite (Turbo/Stimulus) works very well with these native elements and we optimize for this. diff --git a/Gemfile b/Gemfile index f5d73296..26b29fc9 100644 --- a/Gemfile +++ b/Gemfile @@ -19,9 +19,11 @@ gem "propshaft" gem "tailwindcss-rails" gem "lucide-rails", github: "maybe-finance/lucide-rails" -# Hotwire +# Hotwire + UI gem "stimulus-rails" gem "turbo-rails" +gem "view_component" +gem "lookbook", ">= 2.3.7" gem "hotwire_combobox" diff --git a/Gemfile.lock b/Gemfile.lock index 9fb79093..2e64fa11 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -85,8 +85,8 @@ GEM public_suffix (>= 2.0.2, < 7.0) ast (2.4.3) aws-eventstream (1.3.2) - aws-partitions (1.1073.0) - aws-sdk-core (3.221.0) + aws-partitions (1.1093.0) + aws-sdk-core (3.222.3) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.992.0) aws-sigv4 (~> 1.9) @@ -133,21 +133,23 @@ GEM logger (~> 1.5) chunky_png (1.4.0) climate_control (1.2.0) - concurrent-ruby (1.3.5) - connection_pool (2.5.2) + concurrent-ruby (1.3.4) + connection_pool (2.5.3) crack (1.0.0) bigdecimal rexml crass (1.0.6) + css_parser (1.21.1) + addressable csv (3.3.4) date (3.4.1) debug (1.10.0) irb (~> 1.10) reline (>= 0.3.8) docile (1.4.1) - dotenv (3.1.7) - dotenv-rails (3.1.7) - dotenv (= 3.1.7) + dotenv (3.1.8) + dotenv-rails (3.1.8) + dotenv (= 3.1.8) railties (>= 6.1) drb (2.2.1) erb_lint (0.9.0) @@ -171,14 +173,14 @@ GEM net-http (>= 0.5.0) faraday-retry (2.3.1) faraday (~> 2.0) - ffi (1.17.1-aarch64-linux-gnu) - ffi (1.17.1-aarch64-linux-musl) - ffi (1.17.1-arm-linux-gnu) - ffi (1.17.1-arm-linux-musl) - ffi (1.17.1-arm64-darwin) - ffi (1.17.1-x86_64-darwin) - ffi (1.17.1-x86_64-linux-gnu) - ffi (1.17.1-x86_64-linux-musl) + ffi (1.17.2-aarch64-linux-gnu) + ffi (1.17.2-aarch64-linux-musl) + ffi (1.17.2-arm-linux-gnu) + ffi (1.17.2-arm-linux-musl) + ffi (1.17.2-arm64-darwin) + ffi (1.17.2-x86_64-darwin) + ffi (1.17.2-x86_64-linux-gnu) + ffi (1.17.2-x86_64-linux-musl) foreman (0.88.1) globalid (1.2.1) activesupport (>= 6.1) @@ -194,6 +196,8 @@ GEM rails (>= 7.0.7.2) stimulus-rails (>= 1.2) turbo-rails (>= 1.2) + htmlbeautifier (1.4.3) + htmlentities (4.3.4) i18n (1.14.7) concurrent-ruby (~> 1.0) i18n-tasks (1.0.15) @@ -221,7 +225,7 @@ GEM activesupport (> 4.0) jwt (~> 2.0) io-console (0.8.0) - irb (1.15.1) + irb (1.15.2) pp (>= 0.6.0) rdoc (>= 4.0.0) reline (>= 0.4.2) @@ -255,6 +259,18 @@ GEM loofah (2.24.0) crass (~> 1.0.2) nokogiri (>= 1.12.0) + lookbook (2.3.9) + activemodel + css_parser + htmlbeautifier (~> 1.3) + htmlentities (~> 4.3.4) + marcel (~> 1.0) + railties (>= 5.0) + redcarpet (~> 3.5) + rouge (>= 3.26, < 5.0) + view_component (>= 2.0) + yard (~> 0.9) + zeitwerk (~> 2.5) mail (2.8.1) mini_mime (>= 0.1.1) net-imap @@ -262,6 +278,7 @@ GEM net-smtp marcel (1.0.4) matrix (0.4.2) + method_source (1.1.0) mini_magick (5.2.0) benchmark logger @@ -273,7 +290,7 @@ GEM multipart-post (2.4.1) net-http (0.6.0) uri - net-imap (0.5.6) + net-imap (0.5.7) date net-protocol net-pop (0.1.2) @@ -283,28 +300,28 @@ GEM net-smtp (0.5.1) net-protocol nio4r (2.7.4) - nokogiri (1.18.6-aarch64-linux-gnu) + nokogiri (1.18.8-aarch64-linux-gnu) racc (~> 1.4) - nokogiri (1.18.6-aarch64-linux-musl) + nokogiri (1.18.8-aarch64-linux-musl) racc (~> 1.4) - nokogiri (1.18.6-arm-linux-gnu) + nokogiri (1.18.8-arm-linux-gnu) racc (~> 1.4) - nokogiri (1.18.6-arm-linux-musl) + nokogiri (1.18.8-arm-linux-musl) racc (~> 1.4) - nokogiri (1.18.6-arm64-darwin) + nokogiri (1.18.8-arm64-darwin) racc (~> 1.4) - nokogiri (1.18.6-x86_64-darwin) + nokogiri (1.18.8-x86_64-darwin) racc (~> 1.4) - nokogiri (1.18.6-x86_64-linux-gnu) + nokogiri (1.18.8-x86_64-linux-gnu) racc (~> 1.4) - nokogiri (1.18.6-x86_64-linux-musl) + nokogiri (1.18.8-x86_64-linux-musl) racc (~> 1.4) octokit (10.0.0) faraday (>= 1, < 3) sawyer (~> 0.9) pagy (9.3.4) - parallel (1.26.3) - parser (3.3.7.2) + parallel (1.27.0) + parser (3.3.8.0) ast (~> 2.4.1) racc pg (1.5.9) @@ -382,7 +399,7 @@ GEM ffi (~> 1.0) rbs (3.9.2) logger - rdoc (6.13.0) + rdoc (6.13.1) psych (>= 4.0.0) redcarpet (3.6.1) redis (5.4.0) @@ -390,15 +407,16 @@ GEM redis-client (0.24.0) connection_pool regexp_parser (2.10.0) - reline (0.6.0) + reline (0.6.1) io-console (~> 0.5) rexml (3.4.1) rotp (6.3.0) - rqrcode (3.0.0) + rouge (4.5.2) + rqrcode (3.1.0) chunky_png (~> 1.0) rqrcode_core (~> 2.0) rqrcode_core (2.0.0) - rubocop (1.74.0) + rubocop (1.75.4) json (~> 2.3) language_server-protocol (~> 3.17.0.2) lint_roller (~> 1.1.0) @@ -406,20 +424,21 @@ GEM parser (>= 3.3.0.2) rainbow (>= 2.2.2, < 4.0) regexp_parser (>= 2.9.3, < 3.0) - rubocop-ast (>= 1.38.0, < 2.0) + rubocop-ast (>= 1.44.0, < 2.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 2.4.0, < 4.0) - rubocop-ast (1.41.0) + rubocop-ast (1.44.1) parser (>= 3.3.7.2) - rubocop-performance (1.24.0) + prism (~> 1.4) + rubocop-performance (1.25.0) lint_roller (~> 1.1) - rubocop (>= 1.72.1, < 2.0) + rubocop (>= 1.75.0, < 2.0) rubocop-ast (>= 1.38.0, < 2.0) - rubocop-rails (2.30.3) + rubocop-rails (2.31.0) activesupport (>= 4.2.0) lint_roller (~> 1.1) rack (>= 1.1) - rubocop (>= 1.72.1, < 2.0) + rubocop (>= 1.75.0, < 2.0) rubocop-ast (>= 1.38.0, < 2.0) rubocop-rails-omakase (1.1.0) rubocop (>= 1.72) @@ -479,18 +498,18 @@ GEM sorbet-runtime (0.5.12043) stimulus-rails (1.3.4) railties (>= 6.0.0) - stringio (3.1.5) + stringio (3.1.7) stripe (15.0.0) - tailwindcss-rails (4.2.1) + tailwindcss-rails (4.2.2) railties (>= 7.0.0) tailwindcss-ruby (~> 4.0) - tailwindcss-ruby (4.0.15) - tailwindcss-ruby (4.0.15-aarch64-linux-gnu) - tailwindcss-ruby (4.0.15-aarch64-linux-musl) - tailwindcss-ruby (4.0.15-arm64-darwin) - tailwindcss-ruby (4.0.15-x86_64-darwin) - tailwindcss-ruby (4.0.15-x86_64-linux-gnu) - tailwindcss-ruby (4.0.15-x86_64-linux-musl) + tailwindcss-ruby (4.1.4) + tailwindcss-ruby (4.1.4-aarch64-linux-gnu) + tailwindcss-ruby (4.1.4-aarch64-linux-musl) + tailwindcss-ruby (4.1.4-arm64-darwin) + tailwindcss-ruby (4.1.4-x86_64-darwin) + tailwindcss-ruby (4.1.4-x86_64-linux-gnu) + tailwindcss-ruby (4.1.4-x86_64-linux-musl) terminal-table (4.0.0) unicode-display_width (>= 1.1.1, < 4) thor (1.3.2) @@ -508,6 +527,10 @@ GEM vcr (6.3.1) base64 vernier (1.7.0) + view_component (3.22.0) + activesupport (>= 5.2.0, < 8.1) + concurrent-ruby (= 1.3.4) + method_source (~> 1.0) web-console (4.2.1) actionview (>= 6.0.0) activemodel (>= 6.0.0) @@ -524,6 +547,7 @@ GEM websocket-extensions (0.1.5) xpath (3.2.0) nokogiri (~> 1.8) + yard (0.9.37) zeitwerk (2.7.2) PLATFORMS @@ -564,6 +588,7 @@ DEPENDENCIES jwt letter_opener logtail-rails + lookbook (>= 2.3.7) lucide-rails! mocha octokit @@ -596,6 +621,7 @@ DEPENDENCIES tzinfo-data vcr vernier + view_component web-console webmock diff --git a/app/assets/tailwind/maybe-design-system.css b/app/assets/tailwind/maybe-design-system.css index fc7a67ac..d29b5c04 100644 --- a/app/assets/tailwind/maybe-design-system.css +++ b/app/assets/tailwind/maybe-design-system.css @@ -5,6 +5,12 @@ One-off styling (3rd party overrides, etc.) should be done in the application.css file. */ +@import './maybe-design-system/background-utils.css'; +@import './maybe-design-system/foreground-utils.css'; +@import './maybe-design-system/text-utils.css'; +@import './maybe-design-system/border-utils.css'; +@import './maybe-design-system/component-utils.css'; + @custom-variant theme-dark (&:where([data-theme=dark], [data-theme=dark] *)); @theme { @@ -18,6 +24,7 @@ --color-success: var(--color-green-600); --color-warning: var(--color-yellow-600); --color-destructive: var(--color-red-600); + --color-shadow: --alpha(var(--color-black) / 6%); /* Gray scale */ --color-gray-25: #FAFAFA; @@ -231,262 +238,25 @@ } } -/* Custom shadow borders used for surfaces / containers */ -@utility shadow-border-xs { - box-shadow: var(--shadow-xs), 0px 0px 0px 1px var(--color-alpha-black-50); -} - -@utility shadow-border-sm { - box-shadow: var(--shadow-sm), 0px 0px 0px 1px var(--color-alpha-black-50); -} - -@utility shadow-border-md { - box-shadow: var(--shadow-md), 0px 0px 0px 1px var(--color-alpha-black-50); -} - -@utility shadow-border-lg { - box-shadow: var(--shadow-lg), 0px 0px 0px 1px var(--color-alpha-black-50); -} - -@utility shadow-border-xl { - box-shadow: var(--shadow-xl), 0px 0px 0px 1px var(--color-alpha-black-50); -} - -/* Design system color utilities */ -@utility text-primary { - @apply text-gray-900; - - @variant theme-dark { - @apply text-white; - } -} - -@utility text-secondary { - @apply text-gray-500; - - @variant theme-dark { - @apply text-gray-400; - } -} - -@utility text-subdued { - @apply text-gray-400; - - @variant theme-dark { - @apply text-gray-600; - } -} - -@utility text-link { - @apply text-blue-600; - - @variant theme-dark { - @apply text-blue-500; - } -} - -@utility bg-surface { - @apply bg-gray-50; - - @variant theme-dark { - @apply bg-black; - } -} - -@utility bg-surface-hover { - @apply bg-gray-100; - - @variant theme-dark { - @apply bg-gray-800; - } -} - -@utility bg-surface-inset { - @apply bg-gray-100; - - @variant theme-dark { - @apply bg-gray-900; - } -} - -@utility bg-surface-inset-hover { - @apply bg-gray-200; - - @variant theme-dark { - @apply bg-gray-800; - } -} - -@utility bg-container { - @apply bg-white; - - @variant theme-dark { - @apply bg-gray-900; - } -} - -@utility bg-container-hover { - @apply bg-gray-50; - - @variant theme-dark { - @apply bg-gray-800; - } -} - -@utility bg-container-inset { - @apply bg-gray-50; - - @variant theme-dark { - @apply bg-gray-800; - } -} - -@utility bg-container-inset-hover { - @apply bg-gray-100; - - @variant theme-dark { - @apply bg-gray-700; - } -} - -@utility bg-inverse { - @apply bg-gray-800; - - @variant theme-dark { - @apply bg-white; - } -} - -@utility bg-inverse-hover { - @apply bg-gray-700; - - @variant theme-dark { - @apply bg-gray-100; - } -} - -@utility bg-overlay { - background-color: rgba(var(--color-gray-100), 0.5); - - @variant theme-dark { - background-color: var(--color-alpha-black-900); - } -} - -@utility border-primary { - @apply border-alpha-black-300; - - @variant theme-dark { - @apply border-alpha-white-400; - } -} - -@utility border-secondary { - @apply border-alpha-black-200; - - @variant theme-dark { - @apply border-alpha-white-300; - } -} - -@utility border-tertiary { - @apply border-alpha-black-100; - - @variant theme-dark { - @apply border-alpha-white-200; - } -} - -@utility border-subdued { - @apply border-alpha-black-50; - - @variant theme-dark { - @apply border-alpha-white-100; - } -} - -@utility border-solid { - @apply border-black; - - @variant theme-dark { - @apply border-white; - } -} - -@utility border-destructive { - @apply border-red-500; - - @variant theme-dark { - @apply border-red-400; - } -} - -/* Foreground Colors */ -@utility fg-gray { - @apply text-gray-500; - - @variant theme-dark { - @apply text-gray-400; - } -} - -@utility fg-contrast { - @apply text-gray-400; - - @variant theme-dark { - @apply text-gray-500; - } -} - -@utility fg-inverse { - @apply text-white; - - @variant theme-dark { - @apply text-gray-900; - } -} - -@utility fg-primary { - @apply text-gray-900; - - @variant theme-dark { - @apply text-white; - } -} - -@utility fg-primary-variant { - @apply text-gray-800; - - @variant theme-dark { - @apply text-gray-50; - } -} - -@utility fg-secondary { - @apply text-gray-50; - - @variant theme-dark { - @apply text-gray-700; - } -} - -@utility fg-secondary-variant { - @apply text-gray-100; - - @variant theme-dark { - @apply text-gray-600; - } -} - -@utility fg-subdued { - @apply text-gray-400; - - @variant theme-dark { - @apply text-gray-500; - } +/* Specific override for strong tags in prose under dark mode */ +.prose:where([data-theme=dark], [data-theme=dark] *) strong { + color: theme(colors.white) !important; } @layer base { + [data-theme="dark"] { + --color-success: var(--color-green-500); + --color-warning: var(--color-yellow-400); + --color-destructive: var(--color-red-400); + --color-shadow: --alpha(#000000 / 8%); + + --shadow-xs: 0px 1px 2px 0px --alpha(var(--color-white) / 8%); + --shadow-sm: 0px 1px 6px 0px --alpha(var(--color-white) / 8%); + --shadow-md: 0px 4px 8px -2px --alpha(var(--color-white) / 8%); + --shadow-lg: 0px 12px 16px -4px --alpha(var(--color-white) / 8%); + --shadow-xl: 0px 20px 24px -4px --alpha(var(--color-white) / 8%); + } + button { @apply cursor-pointer focus-visible:outline-gray-900; } @@ -495,6 +265,12 @@ @apply text-gray-200; } + /* We control the sizing through DialogComponent, so reset this value */ + dialog:modal { + max-width: 100dvw; + max-height: 100dvh; + } + details>summary::-webkit-details-marker { @apply hidden; } @@ -515,85 +291,6 @@ } @layer components { - /* Buttons */ - .btn { - @apply inline-flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium cursor-pointer disabled:cursor-not-allowed focus:outline-gray-500; - @apply transition-all duration-300; - } - - .btn--primary { - @apply button-bg-primary text-white disabled:text-gray-400; - @apply hover:button-bg-primary-hover; - @apply disabled:button-bg-disabled disabled:hover:button-bg-disabled; - - @variant theme-dark { - @apply button-bg-primary fg-primary; - @apply hover:button-bg-primary-hover; - @apply disabled:button-bg-disabled disabled:hover:button-bg-disabled; - } - } - - .btn--secondary { - @apply button-bg-secondary text-primary; - @apply hover:button-bg-secondary-hover; - - @variant theme-dark { - @apply text-white; - background-color: var(--color-gray-700); - &:hover { - background-color: var(--color-gray-800); - } - } - } - - .btn--outline { - @apply border border-alpha-black-200 text-primary disabled:button-bg-disabled disabled:hover:button-bg-disabled disabled:text-gray-400; - - &:hover { - background-color: var(--color-gray-100); - } - - @variant theme-dark { - @apply border-alpha-white-300 text-white disabled:button-bg-disabled disabled:hover:button-bg-disabled disabled:text-gray-600; - - &:hover { - background-color: var(--color-gray-800); - } - } - } - - .btn--ghost { - @apply border border-transparent text-primary; - - &:hover { - background-color: var(--color-gray-100) - } - - @variant theme-dark { - @apply fg-primary; - - &:hover { - background-color: var(--color-gray-900); - } - } - } - - .btn--outline-destructive { - @apply border border-red-500 text-red-500 hover:bg-gray-50; - - @variant theme-dark { - @apply border-red-400 text-red-400 hover:button-bg-destructive-hover; - } - } - - .btn--destructive { - @apply button-bg-destructive text-white hover:button-bg-destructive-hover disabled:button-bg-disabled disabled:hover:button-bg-disabled disabled:text-red-400; - - @variant theme-dark { - @apply button-bg-destructive text-white hover:button-bg-destructive-hover disabled:button-bg-disabled disabled:hover:button-bg-disabled; - } - } - /* Forms */ .form-field { @apply flex flex-col gap-1 relative px-3 py-2 rounded-md border bg-container border-secondary shadow-xs w-full; @@ -706,19 +403,6 @@ } } - /* Switches */ - .switch { - @apply block bg-gray-100 w-9 h-5 rounded-full cursor-pointer; - @apply after:content-[''] after:block after:absolute after:top-0.5 after:left-0.5 after:bg-white after:w-4 after:h-4 after:rounded-full; - @apply after:transition-transform after:duration-300 after:ease-in-out; - @apply peer-checked:bg-green-600 peer-checked:after:translate-x-4; - @apply transition-colors duration-300; - - @variant theme-dark { - background-color: var(--color-gray-700); - } - } - /* Tooltips */ .tooltip { @apply hidden absolute; @@ -733,120 +417,5 @@ } } -@layer utilities { - /* Specific override for strong tags in prose under dark mode */ - .prose:where([data-theme=dark], [data-theme=dark] *) strong { - color: theme(colors.white) !important; - } -} -/* Button Backgrounds */ -@utility button-bg-primary { - @apply bg-gray-900; - /* Maps to fg-primary light */ - - @variant theme-dark { - @apply bg-white; - /* Maps to fg-primary dark */ - } -} - -@utility button-bg-primary-hover { - @apply bg-gray-800; - /* Maps to fg-primary-variant light */ - - @variant theme-dark { - @apply bg-gray-50; - /* Maps to fg-primary-variant dark */ - } -} - -@utility button-bg-secondary { - @apply bg-gray-50; /* Maps to fg-secondary light */ - - @variant theme-dark { - @apply bg-gray-700; /* Maps to fg-secondary dark */ - } -} - -@utility button-bg-secondary-hover { - @apply bg-gray-100; /* Maps to fg-secondary-variant light */ - - @variant theme-dark { - @apply bg-gray-600; /* Maps to fg-secondary-variant dark */ - } -} - -@utility button-bg-disabled { - @apply bg-gray-50; - - @variant theme-dark { - @apply bg-gray-700; - } -} - -@utility button-bg-destructive { - @apply bg-red-500; - - @variant theme-dark { - @apply bg-red-400; - } -} - -@utility button-bg-destructive-hover { - @apply bg-red-600; - - @variant theme-dark { - @apply bg-red-500; - } -} - -@utility button-bg-ghost-hover { - @apply bg-gray-50; - - @variant theme-dark { - @apply bg-gray-800 fg-inverse; - } -} - -@utility button-bg-outline-hover { - @apply bg-gray-100; - - @variant theme-dark { - @apply bg-gray-700; - } -} - -/* Tab Styles */ -@utility tab-item-active { - @apply bg-white; - - @variant theme-dark { - @apply bg-gray-700; - } -} - -@utility tab-item-hover { - @apply bg-gray-200; - - @variant theme-dark { - @apply bg-gray-800; - } -} - -@utility tab-bg-group { - @apply bg-gray-50; - - @variant theme-dark { - @apply bg-alpha-black-700; - } -} - -@utility bg-nav-indicator { - @apply bg-black; - - @variant theme-dark { - @apply bg-white; - } -} diff --git a/app/assets/tailwind/maybe-design-system/background-utils.css b/app/assets/tailwind/maybe-design-system/background-utils.css new file mode 100644 index 00000000..1c7bc56a --- /dev/null +++ b/app/assets/tailwind/maybe-design-system/background-utils.css @@ -0,0 +1,87 @@ +@utility bg-surface { + @apply bg-gray-50; + + @variant theme-dark { + @apply bg-black; + } +} + +@utility bg-surface-hover { + @apply bg-gray-100; + + @variant theme-dark { + @apply bg-gray-900; + } +} + +@utility bg-surface-inset { + @apply bg-gray-100; + + @variant theme-dark { + @apply bg-gray-800; + } +} + +@utility bg-surface-inset-hover { + @apply bg-gray-200; + + @variant theme-dark { + @apply bg-gray-800; + } +} + +@utility bg-container { + @apply bg-white; + + @variant theme-dark { + @apply bg-gray-900; + } +} + +@utility bg-container-hover { + @apply bg-gray-50; + + @variant theme-dark { + @apply bg-gray-800; + } +} + +@utility bg-container-inset { + @apply bg-gray-50; + + @variant theme-dark { + @apply bg-gray-800; + } +} + +@utility bg-container-inset-hover { + @apply bg-gray-100; + + @variant theme-dark { + @apply bg-gray-700; + } +} + +@utility bg-inverse { + @apply bg-gray-800; + + @variant theme-dark { + @apply bg-white; + } +} + +@utility bg-inverse-hover { + @apply bg-gray-700; + + @variant theme-dark { + @apply bg-gray-100; + } +} + +@utility bg-overlay { + background-color: --alpha(var(--color-gray-100) / 50%); + + @variant theme-dark { + background-color: var(--color-alpha-black-900); + } +} \ No newline at end of file diff --git a/app/assets/tailwind/maybe-design-system/border-utils.css b/app/assets/tailwind/maybe-design-system/border-utils.css new file mode 100644 index 00000000..94c54a55 --- /dev/null +++ b/app/assets/tailwind/maybe-design-system/border-utils.css @@ -0,0 +1,88 @@ +/* Custom shadow borders used for surfaces / containers */ +@utility shadow-border-xs { + box-shadow: var(--shadow-xs), 0px 0px 0px 1px var(--color-alpha-black-50); + + @variant theme-dark { + box-shadow: var(--shadow-xs), 0px 0px 0px 1px var(--color-alpha-white-50); + } +} + +@utility shadow-border-sm { + box-shadow: var(--shadow-sm), 0px 0px 0px 1px var(--color-alpha-black-50); + + @variant theme-dark { + box-shadow: var(--shadow-sm), 0px 0px 0px 1px var(--color-alpha-white-50); + } +} + +@utility shadow-border-md { + box-shadow: var(--shadow-md), 0px 0px 0px 1px var(--color-alpha-black-50); + + @variant theme-dark { + box-shadow: var(--shadow-md), 0px 0px 0px 1px var(--color-alpha-white-50); + } +} + +@utility shadow-border-lg { + box-shadow: var(--shadow-lg), 0px 0px 0px 1px var(--color-alpha-black-50); + + @variant theme-dark { + box-shadow: var(--shadow-lg), 0px 0px 0px 1px var(--color-alpha-white-50); + } +} + +@utility shadow-border-xl { + box-shadow: var(--shadow-xl), 0px 0px 0px 1px var(--color-alpha-black-50); + + @variant theme-dark { + box-shadow: var(--shadow-xl), 0px 0px 0px 1px var(--color-alpha-white-50); + } +} + +@utility border-primary { + @apply border-alpha-black-300; + + @variant theme-dark { + @apply border-alpha-white-400; + } +} + +@utility border-secondary { + @apply border-alpha-black-200; + + @variant theme-dark { + @apply border-alpha-white-300; + } +} + +@utility border-tertiary { + @apply border-alpha-black-100; + + @variant theme-dark { + @apply border-alpha-white-200; + } +} + +@utility border-subdued { + @apply border-alpha-black-50; + + @variant theme-dark { + @apply border-alpha-white-100; + } +} + +@utility border-solid { + @apply border-black; + + @variant theme-dark { + @apply border-white; + } +} + +@utility border-destructive { + @apply border-red-500; + + @variant theme-dark { + @apply border-red-400; + } +} \ No newline at end of file diff --git a/app/assets/tailwind/maybe-design-system/component-utils.css b/app/assets/tailwind/maybe-design-system/component-utils.css new file mode 100644 index 00000000..597b5092 --- /dev/null +++ b/app/assets/tailwind/maybe-design-system/component-utils.css @@ -0,0 +1,109 @@ +/* Button Backgrounds */ +@utility button-bg-primary { + @apply bg-gray-900; + /* Maps to fg-primary light */ + + @variant theme-dark { + @apply bg-white; + /* Maps to fg-primary dark */ + } +} + +@utility button-bg-primary-hover { + @apply bg-gray-800; + /* Maps to fg-primary-variant light */ + + @variant theme-dark { + @apply bg-gray-50; + /* Maps to fg-primary-variant dark */ + } +} + +@utility button-bg-secondary { + @apply bg-gray-50; /* Maps to fg-secondary light */ + + @variant theme-dark { + @apply bg-gray-700; /* Maps to fg-secondary dark */ + } +} + +@utility button-bg-secondary-hover { + @apply bg-gray-100; /* Maps to fg-secondary-variant light */ + + @variant theme-dark { + @apply bg-gray-600; /* Maps to fg-secondary-variant dark */ + } +} + +@utility button-bg-disabled { + @apply bg-gray-50; + + @variant theme-dark { + @apply bg-gray-700; + } +} + +@utility button-bg-destructive { + @apply bg-red-500; + + @variant theme-dark { + @apply bg-red-400; + } +} + +@utility button-bg-destructive-hover { + @apply bg-red-600; + + @variant theme-dark { + @apply bg-red-500; + } +} + +@utility button-bg-ghost-hover { + @apply bg-gray-50; + + @variant theme-dark { + @apply bg-gray-800 fg-inverse; + } +} + +@utility button-bg-outline-hover { + @apply bg-gray-100; + + @variant theme-dark { + @apply bg-gray-700; + } +} + +/* Tab Styles */ +@utility tab-item-active { + @apply bg-white; + + @variant theme-dark { + @apply bg-gray-700; + } +} + +@utility tab-item-hover { + @apply bg-gray-200; + + @variant theme-dark { + @apply bg-gray-800; + } +} + +@utility tab-bg-group { + @apply bg-gray-50; + + @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/assets/tailwind/maybe-design-system/foreground-utils.css b/app/assets/tailwind/maybe-design-system/foreground-utils.css new file mode 100644 index 00000000..6bc76aa1 --- /dev/null +++ b/app/assets/tailwind/maybe-design-system/foreground-utils.css @@ -0,0 +1,63 @@ +@utility fg-gray { + @apply text-gray-500; + + @variant theme-dark { + @apply text-gray-400; + } +} + +@utility fg-contrast { + @apply text-gray-400; + + @variant theme-dark { + @apply text-gray-500; + } +} + +@utility fg-inverse { + @apply text-white; + + @variant theme-dark { + @apply text-gray-900; + } +} + +@utility fg-primary { + @apply text-gray-900; + + @variant theme-dark { + @apply text-white; + } +} + +@utility fg-primary-variant { + @apply text-gray-800; + + @variant theme-dark { + @apply text-gray-50; + } +} + +@utility fg-secondary { + @apply text-gray-50; + + @variant theme-dark { + @apply text-gray-700; + } +} + +@utility fg-secondary-variant { + @apply text-gray-100; + + @variant theme-dark { + @apply text-gray-600; + } +} + +@utility fg-subdued { + @apply text-gray-400; + + @variant theme-dark { + @apply text-gray-500; + } +} \ No newline at end of file diff --git a/app/assets/tailwind/maybe-design-system/text-utils.css b/app/assets/tailwind/maybe-design-system/text-utils.css new file mode 100644 index 00000000..1a35dfff --- /dev/null +++ b/app/assets/tailwind/maybe-design-system/text-utils.css @@ -0,0 +1,39 @@ +@utility text-primary { + @apply text-gray-900; + + @variant theme-dark { + @apply text-white; + } +} + +@utility text-inverse { + @apply text-white; + + @variant theme-dark { + @apply text-gray-900; + } +} + +@utility text-secondary { + @apply text-gray-500; + + @variant theme-dark { + @apply text-gray-400; + } +} + +@utility text-subdued { + @apply text-gray-400; + + @variant theme-dark { + @apply text-gray-600; + } +} + +@utility text-link { + @apply text-blue-600; + + @variant theme-dark { + @apply text-blue-500; + } +} \ No newline at end of file diff --git a/app/components/button_component.html.erb b/app/components/button_component.html.erb new file mode 100644 index 00000000..e0c5e017 --- /dev/null +++ b/app/components/button_component.html.erb @@ -0,0 +1,13 @@ +<%= container do %> + <% if icon && (icon_position != "right") %> + <%= lucide_icon(icon, class: icon_classes) %> + <% end %> + + <% unless icon_only? %> + <%= text %> + <% end %> + + <% if icon && icon_position == "right" %> + <%= lucide_icon(icon, class: icon_classes) %> + <% end %> +<% end %> diff --git a/app/components/button_component.rb b/app/components/button_component.rb new file mode 100644 index 00000000..36600a3c --- /dev/null +++ b/app/components/button_component.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +# An extension to `button_to` helper. All options are passed through to the `button_to` helper with some additional +# options available. +class ButtonComponent < ButtonishComponent + attr_reader :confirm + + def initialize(confirm: nil, **opts) + super(**opts) + @confirm = confirm + end + + def container(&block) + if href.present? + button_to(href, **merged_opts, &block) + else + content_tag(:button, **merged_opts, &block) + end + end + + private + def merged_opts + merged_opts = opts.dup || {} + extra_classes = merged_opts.delete(:class) + href = merged_opts.delete(:href) + data = merged_opts.delete(:data) || {} + + if confirm.present? + data = data.merge(turbo_confirm: confirm.to_data_attribute) + end + + if frame.present? + data = data.merge(turbo_frame: frame) + end + + merged_opts.merge( + class: class_names(container_classes, extra_classes), + data: data + ) + end +end diff --git a/app/components/buttonish_component.rb b/app/components/buttonish_component.rb new file mode 100644 index 00000000..4743616e --- /dev/null +++ b/app/components/buttonish_component.rb @@ -0,0 +1,148 @@ +class ButtonishComponent < ViewComponent::Base + VARIANTS = { + primary: { + container_classes: "text-inverse bg-inverse hover:bg-inverse-hover disabled:bg-gray-500 theme-dark:disabled:bg-gray-400", + icon_classes: "fg-inverse" + }, + secondary: { + container_classes: "text-secondary bg-gray-50 theme-dark:bg-gray-700 hover:bg-gray-100 theme-dark:hover:bg-gray-600 disabled:bg-gray-200 theme-dark:disabled:bg-gray-600", + icon_classes: "fg-primary" + }, + destructive: { + container_classes: "text-inverse bg-red-500 theme-dark:bg-red-400 hover:bg-red-600 theme-dark:hover:bg-red-500 disabled:bg-red-200 theme-dark:disabled:bg-red-600", + icon_classes: "fg-white" + }, + outline: { + container_classes: "text-primary border border-secondary bg-transparent hover:bg-surface-hover", + icon_classes: "fg-gray" + }, + outline_destructive: { + container_classes: "text-destructive border border-secondary bg-transparent hover:bg-gray-100 theme-dark:hover:bg-gray-700", + icon_classes: "fg-gray" + }, + ghost: { + container_classes: "text-primary bg-transparent hover:bg-gray-100 theme-dark:hover:bg-gray-700", + icon_classes: "fg-gray" + }, + icon: { + container_classes: "hover:bg-gray-100 theme-dark:hover:bg-gray-700", + icon_classes: "fg-gray" + }, + icon_inverse: { + container_classes: "bg-inverse hover:bg-inverse-hover", + icon_classes: "fg-inverse" + } + }.freeze + + SIZES = { + sm: { + container_classes: "px-2 py-1", + icon_container_classes: "inline-flex items-center justify-center w-8 h-8", + radius_classes: "rounded-md", + text_classes: "text-sm", + icon_classes: "w-4 h-4" + }, + md: { + container_classes: "px-3 py-2", + icon_container_classes: "inline-flex items-center justify-center w-9 h-9", + radius_classes: "rounded-lg", + text_classes: "text-sm", + icon_classes: "w-5 h-5" + }, + lg: { + container_classes: "px-4 py-3", + icon_container_classes: "inline-flex items-center justify-center w-10 h-10", + radius_classes: "rounded-xl", + text_classes: "text-base", + icon_classes: "w-6 h-6" + } + }.freeze + + attr_reader :variant, :size, :href, :icon, :icon_position, :text, :full_width, :extra_classes, :frame, :opts + + def initialize(variant: :primary, size: :md, href: nil, text: nil, icon: nil, icon_position: :left, full_width: false, frame: nil, **opts) + @variant = variant.to_s.underscore.to_sym + @size = size.to_sym + @href = href + @icon = icon + @icon_position = icon_position.to_sym + @text = text + @full_width = full_width + @extra_classes = opts.delete(:class) + @frame = frame + @opts = opts + end + + def call + raise NotImplementedError, "ButtonishComponent is an abstract class and cannot be instantiated directly." + end + + def container_classes(override_classes = nil) + class_names( + "font-medium whitespace-nowrap", + merged_base_classes, + full_width ? "w-full justify-center" : nil, + container_size_classes, + size_data.dig(:text_classes), + variant_data.dig(:container_classes) + ) + end + + def container_size_classes + icon_only? ? size_data.dig(:icon_container_classes) : size_data.dig(:container_classes) + end + + def icon_classes + class_names( + size_data.dig(:icon_classes), + variant_data.dig(:icon_classes) + ) + end + + def icon_only? + variant.in?([ :icon, :icon_inverse ]) + end + + private + def variant_data + self.class::VARIANTS.dig(variant) + end + + def size_data + self.class::SIZES.dig(size) + end + + # Make sure that user can override common classes like `hidden` + def merged_base_classes + base_display_classes = "inline-flex items-center gap-1" + base_radius_classes = size_data.dig(:radius_classes) + + extra_classes_list = (extra_classes || "").split + + has_display_override = extra_classes_list.any? { |c| permitted_display_override_classes.include?(c) } + has_radius_override = extra_classes_list.any? { |c| permitted_radius_override_classes.include?(c) } + + base_classes = [] + + unless has_display_override + base_classes << base_display_classes + end + + unless has_radius_override + base_classes << base_radius_classes + end + + class_names( + base_classes, + extra_classes + ) + end + + def permitted_radius_override_classes + [ "rounded-full" ] + end + + def permitted_display_override_classes + [ "hidden", "flex" ] + end +end diff --git a/app/components/dialog_component.html.erb b/app/components/dialog_component.html.erb new file mode 100644 index 00000000..8b2a67be --- /dev/null +++ b/app/components/dialog_component.html.erb @@ -0,0 +1,38 @@ + + <%= tag.dialog class: "w-full h-full bg-transparent theme-dark:backdrop:bg-alpha-black-900 backdrop:bg-overlay #{drawer? ? "lg:p-3" : "lg:p-1"}", **merged_opts do %> + <%= tag.div class: dialog_outer_classes do %> + <%= tag.div class: dialog_inner_classes, data: { dialog_target: "content" } do %> +
+ <% if header? %> + <%= header %> + <% end %> + + <% if body? %> +
+ <%= body %> + + <% if sections.any? %> +
+ <% sections.each do |section| %> + <%= section %> + <% end %> +
+ <% end %> +
+ <% end %> + + <%# Optional, for customizing dialogs %> + <%= content %> +
+ + <% if actions? %> +
+ <% actions.each do |action| %> + <%= action %> + <% end %> +
+ <% end %> + <% end %> + <% end %> + <% end %> +
diff --git a/app/components/dialog_component.rb b/app/components/dialog_component.rb new file mode 100644 index 00000000..16f04bdc --- /dev/null +++ b/app/components/dialog_component.rb @@ -0,0 +1,105 @@ +class DialogComponent < ViewComponent::Base + renders_one :header, ->(title: nil, subtitle: nil, hide_close_icon: false, **opts, &block) do + content_tag(:header, class: "px-4 flex flex-col gap-2", **opts) do + title_div = content_tag(:div, class: "flex items-center justify-between gap-2") do + title = content_tag(:h2, title, class: class_names("font-medium text-primary", drawer? ? "text-lg" : "")) if title + close_icon = render ButtonComponent.new(variant: "icon", class: "ml-auto", icon: "x", tabindex: "-1", data: { action: "dialog#close" }) unless hide_close_icon + safe_join([ title, close_icon ].compact) + end + + subtitle = content_tag(:p, subtitle, class: "text-sm text-secondary") if subtitle + + block_content = capture(&block) if block + + safe_join([ title_div, subtitle, block_content ].compact) + end + end + + renders_one :body + + renders_many :actions, ->(cancel_action: false, **button_opts) do + merged_opts = if cancel_action + button_opts.merge(type: "button", data: { action: "modal#close" }) + else + button_opts + end + + render ButtonComponent.new(**merged_opts) + end + + renders_many :sections, ->(title:, **disclosure_opts, &block) do + render DisclosureComponent.new(title: title, align: :right, **disclosure_opts) do + block.call + end + end + + attr_reader :variant, :auto_open, :reload_on_close, :frame, :width, :opts + + VARIANTS = %w[modal drawer].freeze + WIDTHS = { + sm: "lg:max-w-[300px]", + md: "lg:max-w-[550px]", + lg: "lg:max-w-[700px]", + full: "lg:max-w-full" + }.freeze + + def initialize(variant: "modal", auto_open: true, reload_on_close: false, frame: nil, width: "md", **opts) + @variant = variant.to_sym + @auto_open = auto_open + @reload_on_close = reload_on_close + @frame = frame + @width = width.to_sym + @opts = opts + end + + def frame + @frame || variant + end + + def dialog_outer_classes + variant_classes = if drawer? + "items-end justify-end" + else + "items-center justify-center" + end + + class_names( + "flex h-full w-full", + variant_classes + ) + end + + def dialog_inner_classes + variant_classes = if drawer? + "lg:w-[550px] h-full" + else + class_names( + "max-h-full", + WIDTHS[width] + ) + end + + class_names( + "flex flex-col bg-container lg:rounded-xl lg:shadow-border-xs w-full overflow-hidden", + variant_classes + ) + end + + def merged_opts + merged_opts = opts.dup + data = merged_opts.delete(:data) || {} + + data[:controller] = [ "dialog", "hotkey", data[:controller] ].compact.join(" ") + data[:dialog_auto_open_value] = auto_open + data[:dialog_reload_on_close_value] = reload_on_close + data[:action] = [ "mousedown->dialog#clickOutside", data[:action] ].compact.join(" ") + data[:hotkey] = "esc:dialog#close" + merged_opts[:data] = data + + merged_opts + end + + def drawer? + variant == :drawer + end +end diff --git a/app/javascript/controllers/modal_controller.js b/app/components/dialog_controller.js similarity index 55% rename from app/javascript/controllers/modal_controller.js rename to app/components/dialog_controller.js index 242c0247..8d746ad9 100644 --- a/app/javascript/controllers/modal_controller.js +++ b/app/components/dialog_controller.js @@ -1,19 +1,24 @@ import { Controller } from "@hotwired/stimulus"; -// Connects to data-controller="modal" +// Connects to data-controller="dialog" export default class extends Controller { + static targets = ["content"] + static values = { + autoOpen: { type: Boolean, default: false }, reloadOnClose: { type: Boolean, default: false }, }; connect() { if (this.element.open) return; - this.element.showModal(); + if (this.autoOpenValue) { + this.element.showModal(); + } } - - // Hide the dialog when the user clicks outside of it + + // If the user clicks anywhere outside of the visible content, close the dialog clickOutside(e) { - if (e.target === this.element) { + if (!this.contentTarget.contains(e.target)) { this.close(); } } diff --git a/app/components/disclosure_component.html.erb b/app/components/disclosure_component.html.erb new file mode 100644 index 00000000..554342d1 --- /dev/null +++ b/app/components/disclosure_component.html.erb @@ -0,0 +1,25 @@ +
> + <%= tag.summary class: class_names( + "px-3 py-2 rounded-xl cursor-pointer flex items-center justify-between bg-surface" + ) do %> +
+ <% if align == :left %> + <%= lucide_icon "chevron-right", class: "fg-gray w-5 h-5 group-open:transform group-open:rotate-90" %> + <% end %> + + <%= tag.span class: class_names("font-medium", align == :left ? "text-sm text-primary" : "text-xs uppercase text-secondary") do %> + <%= title %> + <% end %> +
+ + <% if align == :right %> + <%= lucide_icon "chevron-down", class: "fg-gray w-5 h-5 group-open:transform group-open:rotate-180" %> + <% elsif summary_content? %> + <%= summary_content %> + <% end %> + <% end %> + +
+ <%= content %> +
+
diff --git a/app/components/disclosure_component.rb b/app/components/disclosure_component.rb new file mode 100644 index 00000000..013e3e9d --- /dev/null +++ b/app/components/disclosure_component.rb @@ -0,0 +1,12 @@ +class DisclosureComponent < ViewComponent::Base + renders_one :summary_content + + attr_reader :title, :align, :open, :opts + + def initialize(title:, align: "right", open: false, **opts) + @title = title + @align = align.to_sym + @open = open + @opts = opts + end +end diff --git a/app/components/filled_icon_component.html.erb b/app/components/filled_icon_component.html.erb new file mode 100644 index 00000000..49adba9e --- /dev/null +++ b/app/components/filled_icon_component.html.erb @@ -0,0 +1,8 @@ +<%= tag.div style: transparent? ? container_styles : nil, + class: container_classes do %> + <% if icon %> + <%= helpers.icon(icon, size: icon_size, color: "current") %> + <% elsif text %> + <%= tag.span text.first, class: text_classes %> + <% end %> +<% end %> diff --git a/app/components/filled_icon_component.rb b/app/components/filled_icon_component.rb new file mode 100644 index 00000000..e9c3ce68 --- /dev/null +++ b/app/components/filled_icon_component.rb @@ -0,0 +1,97 @@ +class FilledIconComponent < ViewComponent::Base + attr_reader :icon, :text, :hex_color, :size, :rounded, :variant + + VARIANTS = %i[default text surface container].freeze + + SIZES = { + sm: { + container_size: "w-6 h-6", + container_radius: "rounded-md", + icon_size: "sm", + text_size: "text-xs" + }, + md: { + container_size: "w-8 h-8", + container_radius: "rounded-lg", + icon_size: "md", + text_size: "text-xs" + }, + lg: { + container_size: "w-9 h-9", + container_radius: "rounded-xl", + icon_size: "lg", + text_size: "text-sm" + } + }.freeze + + def initialize(variant: :default, icon: nil, text: nil, hex_color: nil, size: "md", rounded: false) + @variant = variant.to_sym + @icon = icon + @text = text + @hex_color = hex_color + @size = size.to_sym + @rounded = rounded + end + + def container_classes + class_names( + "flex justify-center items-center", + size_classes, + radius_classes, + transparent? ? "border" : solid_bg_class + ) + end + + def icon_size + SIZES[size][:icon_size] + end + + def text_classes + class_names( + "text-center font-medium uppercase", + SIZES[size][:text_size] + ) + end + + def container_styles + <<~STYLE.strip + background-color: #{transparent_bg_color}; + border-color: #{transparent_border_color}; + color: #{custom_fg_color}; + STYLE + end + + def transparent? + variant.in?(%i[default text]) + end + + private + def solid_bg_class + case variant + when :surface + "bg-surface-inset" + when :container + "bg-container-inset" + end + end + + def size_classes + SIZES[size][:container_size] + end + + def radius_classes + rounded ? "rounded-full" : SIZES[size][:container_radius] + end + + def custom_fg_color + hex_color || "var(--color-gray-500)" + end + + def transparent_bg_color + "color-mix(in oklab, #{custom_fg_color} 10%, transparent)" + end + + def transparent_border_color + "color-mix(in oklab, #{custom_fg_color} 10%, transparent)" + end +end diff --git a/app/components/link_component.html.erb b/app/components/link_component.html.erb new file mode 100644 index 00000000..707c3d9f --- /dev/null +++ b/app/components/link_component.html.erb @@ -0,0 +1,13 @@ +<%= link_to href, **merged_opts do %> + <% if icon && (icon_position != "right") %> + <%= lucide_icon(icon, class: icon_classes) %> + <% end %> + + <% unless icon_only? %> + <%= text %> + <% end %> + + <% if icon && icon_position == "right" %> + <%= lucide_icon(icon, class: icon_classes) %> + <% end %> +<% end %> diff --git a/app/components/link_component.rb b/app/components/link_component.rb new file mode 100644 index 00000000..4bbe10e5 --- /dev/null +++ b/app/components/link_component.rb @@ -0,0 +1,31 @@ +# An extension to `link_to` helper. All options are passed through to the `link_to` helper with some additional +# options available. +class LinkComponent < ButtonishComponent + attr_reader :frame + + VARIANTS = VARIANTS.reverse_merge( + default: { + container_classes: "", + icon_classes: "fg-gray" + } + ).freeze + + def merged_opts + merged_opts = opts.dup || {} + data = merged_opts.delete(:data) || {} + + if frame + data = data.merge(turbo_frame: frame) + end + + merged_opts.merge( + class: class_names(container_classes, extra_classes), + data: data + ) + end + + private + def container_size_classes + super unless variant == :default + end +end diff --git a/app/components/menu_component.html.erb b/app/components/menu_component.html.erb new file mode 100644 index 00000000..527e5e36 --- /dev/null +++ b/app/components/menu_component.html.erb @@ -0,0 +1,27 @@ +<%= tag.div data: { controller: "menu", menu_placement_value: placement, menu_offset_value: offset, testid: testid } do %> + <% if variant == :icon %> + <%= render ButtonComponent.new(variant: "icon", icon: icon_vertical ? "more-vertical" : "more-horizontal", data: { menu_target: "button" }) %> + <% elsif variant == :button %> + <%= button %> + <% elsif variant == :avatar %> + + <% end %> + + +<% end %> diff --git a/app/components/menu_component.rb b/app/components/menu_component.rb new file mode 100644 index 00000000..012b2f62 --- /dev/null +++ b/app/components/menu_component.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +class MenuComponent < ViewComponent::Base + attr_reader :variant, :avatar_url, :placement, :offset, :icon_vertical, :no_padding, :testid + + renders_one :button, ->(**button_options, &block) do + options_with_target = button_options.merge(data: { menu_target: "button" }) + + if block + content_tag(:button, **options_with_target, &block) + else + ButtonComponent.new(**options_with_target) + end + end + + renders_one :header, ->(&block) do + content_tag(:div, class: "border-b border-tertiary", &block) + end + + renders_one :custom_content + + renders_many :items, MenuItemComponent + + VARIANTS = %i[icon button avatar].freeze + + def initialize(variant: "icon", avatar_url: nil, placement: "bottom-end", offset: 12, icon_vertical: false, no_padding: false, testid: nil) + @variant = variant.to_sym + @avatar_url = avatar_url + @placement = placement + @offset = offset + @icon_vertical = icon_vertical + @no_padding = no_padding + @testid = testid + + raise ArgumentError, "Invalid variant: #{@variant}" unless VARIANTS.include?(@variant) + end +end diff --git a/app/javascript/controllers/menu_controller.js b/app/components/menu_controller.js similarity index 100% rename from app/javascript/controllers/menu_controller.js rename to app/components/menu_controller.js diff --git a/app/components/menu_item_component.html.erb b/app/components/menu_item_component.html.erb new file mode 100644 index 00000000..bd62bad7 --- /dev/null +++ b/app/components/menu_item_component.html.erb @@ -0,0 +1,12 @@ +<% if variant == :divider %> +
+<% else %> +
+ <%= wrapper do %> + <% if icon %> + <%= lucide_icon(icon, class: destructive? ? "text-destructive" : "fg-gray") %> + <% end %> + <%= tag.span(text, class: text_classes) %> + <% end %> +
+<% end %> diff --git a/app/components/menu_item_component.rb b/app/components/menu_item_component.rb new file mode 100644 index 00000000..c029afa7 --- /dev/null +++ b/app/components/menu_item_component.rb @@ -0,0 +1,57 @@ +class MenuItemComponent < ViewComponent::Base + VARIANTS = %i[link button divider].freeze + + attr_reader :variant, :text, :icon, :href, :method, :destructive, :confirm, :opts + + def initialize(variant:, text: nil, icon: nil, href: nil, method: :post, destructive: false, confirm: nil, **opts) + @variant = variant.to_sym + @text = text + @icon = icon + @href = href + @method = method.to_sym + @destructive = destructive + @opts = opts + @confirm = confirm + raise ArgumentError, "Invalid variant: #{@variant}" unless VARIANTS.include?(@variant) + end + + def wrapper(&block) + if variant == :button + button_to href, method: method, class: container_classes, **merged_button_opts, &block + elsif variant == :link + link_to href, class: container_classes, **opts, &block + else + nil + end + end + + def text_classes + [ + "text-sm", + destructive? ? "text-destructive" : "text-primary" + ].join(" ") + end + + def destructive? + method == :delete || destructive + end + + private + def container_classes + [ + "flex items-center gap-2 p-2 rounded-md w-full", + destructive? ? "hover:bg-red-tint-5 theme-dark:hover:bg-red-tint-10" : "hover:bg-container-hover" + ].join(" ") + end + + def merged_button_opts + merged_opts = opts.dup || {} + data = merged_opts.delete(:data) || {} + + if confirm.present? + data = data.merge(turbo_confirm: confirm.to_data_attribute) + end + + merged_opts.merge(data: data) + end +end diff --git a/app/components/tab_component.rb b/app/components/tab_component.rb new file mode 100644 index 00000000..fc084a1a --- /dev/null +++ b/app/components/tab_component.rb @@ -0,0 +1,12 @@ +class TabComponent < ViewComponent::Base + attr_reader :id, :label + + def initialize(id:, label:) + @id = id + @label = label + end + + def call + content + end +end diff --git a/app/components/tabs/nav_component.rb b/app/components/tabs/nav_component.rb new file mode 100644 index 00000000..2c4e81ca --- /dev/null +++ b/app/components/tabs/nav_component.rb @@ -0,0 +1,29 @@ +class Tabs::NavComponent < ViewComponent::Base + erb_template <<~ERB + <%= tag.nav class: classes do %> + <% btns.each do |btn| %> + <%= btn %> + <% end %> + <% end %> + ERB + + renders_many :btns, ->(id:, label:, classes: nil, &block) do + content_tag( + :button, label, id: id, + type: "button", + class: class_names(btn_classes, id == active_tab ? active_btn_classes : inactive_btn_classes, classes), + data: { id: id, action: "tabs#show", tabs_target: "navBtn" }, + &block + ) + end + + attr_reader :active_tab, :classes, :active_btn_classes, :inactive_btn_classes, :btn_classes + + def initialize(active_tab:, classes: nil, active_btn_classes: nil, inactive_btn_classes: nil, btn_classes: nil) + @active_tab = active_tab + @classes = classes + @active_btn_classes = active_btn_classes + @inactive_btn_classes = inactive_btn_classes + @btn_classes = btn_classes + end +end diff --git a/app/components/tabs/panel_component.rb b/app/components/tabs/panel_component.rb new file mode 100644 index 00000000..3c34932a --- /dev/null +++ b/app/components/tabs/panel_component.rb @@ -0,0 +1,11 @@ +class Tabs::PanelComponent < ViewComponent::Base + attr_reader :tab_id + + def initialize(tab_id:) + @tab_id = tab_id + end + + def call + content + end +end diff --git a/app/components/tabs_component.html.erb b/app/components/tabs_component.html.erb new file mode 100644 index 00000000..4ec901fa --- /dev/null +++ b/app/components/tabs_component.html.erb @@ -0,0 +1,17 @@ +<%= tag.div data: { + controller: "tabs", + testid: testid, + tabs_url_param_key_value: url_param_key, + tabs_nav_btn_active_class: active_btn_classes, + tabs_nav_btn_inactive_class: inactive_btn_classes +} do %> + <% if unstyled? %> + <%= content %> + <% else %> + <%= nav %> + + <% panels.each do |panel| %> + <%= panel %> + <% end %> + <% end %> +<% end %> diff --git a/app/components/tabs_component.rb b/app/components/tabs_component.rb new file mode 100644 index 00000000..4017b308 --- /dev/null +++ b/app/components/tabs_component.rb @@ -0,0 +1,65 @@ +class TabsComponent < ViewComponent::Base + renders_one :nav, ->(classes: nil) do + Tabs::NavComponent.new( + active_tab: active_tab, + active_btn_classes: active_btn_classes, + inactive_btn_classes: inactive_btn_classes, + btn_classes: base_btn_classes, + classes: unstyled? ? classes : class_names(nav_container_classes, classes) + ) + end + + renders_many :panels, ->(tab_id:, &block) do + content_tag( + :div, + class: ("hidden" unless tab_id == active_tab), + data: { id: tab_id, tabs_target: "panel" }, + &block + ) + end + + VARIANTS = { + default: { + active_btn_classes: "bg-white theme-dark:bg-gray-700 text-primary shadow-sm", + inactive_btn_classes: "text-secondary hover:bg-surface-inset-hover", + base_btn_classes: "w-full inline-flex justify-center items-center text-sm font-medium px-2 py-1 rounded-md transition-colors duration-200", + nav_container_classes: "flex bg-surface-inset p-1 rounded-lg mb-4" + } + } + + attr_reader :active_tab, :url_param_key, :variant, :testid + + def initialize(active_tab:, url_param_key: nil, variant: :default, active_btn_classes: "", inactive_btn_classes: "", testid: nil) + @active_tab = active_tab + @url_param_key = url_param_key + @variant = variant.to_sym + @active_btn_classes = active_btn_classes + @inactive_btn_classes = inactive_btn_classes + @testid = testid + end + + def active_btn_classes + unstyled? ? @active_btn_classes : VARIANTS.dig(variant, :active_btn_classes) + end + + def inactive_btn_classes + unstyled? ? @inactive_btn_classes : VARIANTS.dig(variant, :inactive_btn_classes) + end + + private + def unstyled? + variant == :unstyled + end + + def base_btn_classes + unless unstyled? + VARIANTS.dig(variant, :base_btn_classes) + end + end + + def nav_container_classes + unless unstyled? + VARIANTS.dig(variant, :nav_container_classes) + end + end +end diff --git a/app/components/tabs_controller.js b/app/components/tabs_controller.js new file mode 100644 index 00000000..32f18d08 --- /dev/null +++ b/app/components/tabs_controller.js @@ -0,0 +1,42 @@ +import { Controller } from "@hotwired/stimulus"; + +// Connects to data-controller="tabs--components" +export default class extends Controller { + static classes = ["navBtnActive", "navBtnInactive"]; + static targets = ["panel", "navBtn"]; + static values = { urlParamKey: String }; + + connect() { + console.log("tabs controller connected"); + } + + show(e) { + const btn = e.target.closest("button"); + const selectedTabId = btn.dataset.id; + + this.navBtnTargets.forEach((navBtn) => { + if (navBtn.dataset.id === selectedTabId) { + navBtn.classList.add(...this.navBtnActiveClasses); + navBtn.classList.remove(...this.navBtnInactiveClasses); + } else { + navBtn.classList.add(...this.navBtnInactiveClasses); + navBtn.classList.remove(...this.navBtnActiveClasses); + } + }); + + this.panelTargets.forEach((panel) => { + if (panel.dataset.id === selectedTabId) { + panel.classList.remove("hidden"); + } else { + panel.classList.add("hidden"); + } + }); + + // Update URL with the selected tab + if (this.urlParamKeyValue) { + const url = new URL(window.location.href); + url.searchParams.set(this.urlParamKeyValue, selectedTabId); + window.history.replaceState({}, "", url); + } + } +} diff --git a/app/components/toggle_component.html.erb b/app/components/toggle_component.html.erb new file mode 100644 index 00000000..6845686c --- /dev/null +++ b/app/components/toggle_component.html.erb @@ -0,0 +1,5 @@ +
+ <%= hidden_field_tag name, unchecked_value, id: nil %> + <%= check_box_tag name, checked_value, checked, class: "sr-only peer", disabled: disabled, id: id, **opts %> + <%= label_tag name, " ".html_safe, class: label_classes, for: id %> +
diff --git a/app/components/toggle_component.rb b/app/components/toggle_component.rb new file mode 100644 index 00000000..e3af85a8 --- /dev/null +++ b/app/components/toggle_component.rb @@ -0,0 +1,26 @@ +class ToggleComponent < ViewComponent::Base + attr_reader :id, :name, :checked, :disabled, :checked_value, :unchecked_value, :opts + + def initialize(id:, name: nil, checked: false, disabled: false, checked_value: "1", unchecked_value: "0", **opts) + @id = id + @name = name + @checked = checked + @disabled = disabled + @checked_value = checked_value + @unchecked_value = unchecked_value + @opts = opts + end + + def label_classes + class_names( + "block w-9 h-5 cursor-pointer", + "rounded-full bg-gray-100 theme-dark:bg-gray-700", + "transition-colors duration-300", + "after:content-[''] after:block after:bg-white after:absolute after:rounded-full", + "after:top-0.5 after:left-0.5 after:w-4 after:h-4", + "after:transition-transform after:duration-300 after:ease-in-out", + "peer-checked:bg-green-600 peer-checked:after:translate-x-4", + "peer-disabled:opacity-70 peer-disabled:cursor-not-allowed" + ) + end +end diff --git a/app/controllers/chats_controller.rb b/app/controllers/chats_controller.rb index 61909200..6b8b494a 100644 --- a/app/controllers/chats_controller.rb +++ b/app/controllers/chats_controller.rb @@ -1,8 +1,6 @@ class ChatsController < ApplicationController include ActionView::RecordIdentifier - guard_feature unless: -> { Current.user.ai_enabled? } - before_action :set_chat, only: [ :show, :edit, :update, :destroy ] def index diff --git a/app/controllers/lookbooks_controller.rb b/app/controllers/lookbooks_controller.rb new file mode 100644 index 00000000..6dc06d7e --- /dev/null +++ b/app/controllers/lookbooks_controller.rb @@ -0,0 +1,3 @@ +class LookbooksController < Lookbook::PreviewController + layout "lookbooks" +end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 316e7900..0db4c9e5 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -1,12 +1,30 @@ module ApplicationHelper include Pagy::Frontend - def icon(key, size: "md", color: "current") - render partial: "shared/icon", locals: { key:, size:, color: } + def styled_form_with(**options, &block) + options[:builder] = StyledFormBuilder + form_with(**options, &block) end - def icon_custom(key, size: "md", color: "current") - render partial: "shared/icon_custom", locals: { key:, size:, color: } + def icon(key, size: "md", color: "default", custom: false, as_button: false, **opts) + extra_classes = opts.delete(:class) + sizes = { xs: "w-3 h-3", sm: "w-4 h-4", md: "w-5 h-5", lg: "w-6 h-6", xl: "w-7 h-7", "2xl": "w-8 h-8" } + colors = { default: "fg-gray", success: "text-success", warning: "text-warning", destructive: "text-destructive", current: "text-current" } + + icon_classes = class_names( + "shrink-0", + sizes[size.to_sym], + colors[color.to_sym], + extra_classes + ) + + if custom + inline_svg_tag("#{key}.svg", class: icon_classes, **opts) + elsif as_button + render ButtonComponent.new(variant: "icon", class: extra_classes, icon: key, size: size, type: "button", **opts) + else + lucide_icon(key, class: icon_classes, **opts) + end end # Convert alpha (0-1) to 8-digit hex (00-FF) @@ -31,60 +49,10 @@ module ApplicationHelper turbo_stream_from Current.family if Current.family end - ## - # Helper to open a centered and overlayed modal with custom contents - # - # @example Basic usage - # <%= modal classes: "custom-class" do %> - #
Content here
- # <% end %> - # - def modal(reload_on_close: false, overflow_visible: false, &block) - content = capture &block - render partial: "shared/modal", locals: { content:, reload_on_close:, overflow_visible: } - end - - ## - # Helper to open a drawer on the right side of the screen with custom contents - # - # @example Basic usage - # <%= drawer do %> - #
Content here
- # <% end %> - # - def drawer(reload_on_close: false, &block) - content = capture &block - render partial: "shared/drawer", locals: { content:, reload_on_close: } - end - - def disclosure(title, default_open: true, &block) - content = capture &block - render partial: "shared/disclosure", locals: { title: title, content: content, open: default_open } - end - def page_active?(path) current_page?(path) || (request.path.start_with?(path) && path != "/") end - def mixed_hex_styles(hex) - color = hex || "#1570EF" # blue-600 - - <<-STYLE.strip - background-color: color-mix(in srgb, #{color} 10%, white); - border-color: color-mix(in srgb, #{color} 30%, white); - color: #{color}; - STYLE - end - - def circle_logo(name, hex: nil, size: "md") - render partial: "shared/circle_logo", locals: { name: name, hex: hex, size: size } - end - - def return_to_path(params, fallback = root_path) - uri = URI.parse(params[:return_to] || fallback) - uri.relative? ? uri.path : root_path - end - # Wrapper around I18n.l to support custom date formats def format_date(object, format = :default, options = {}) date = object.to_date @@ -144,49 +112,6 @@ module ApplicationHelper markdown.render(text).html_safe end - # Determines the starting widths of each panel depending on the user's sidebar preferences - def app_sidebar_config(user) - left_sidebar_showing = user.show_sidebar? - right_sidebar_showing = user.show_ai_sidebar? - - content_max_width = if !left_sidebar_showing && !right_sidebar_showing - 1024 # 5xl - elsif left_sidebar_showing && !right_sidebar_showing - 896 # 4xl - else - 768 # 3xl - end - - left_panel_min_width = 320 - left_panel_max_width = 320 - right_panel_min_width = 400 - right_panel_max_width = 550 - - left_panel_width = left_sidebar_showing ? left_panel_min_width : 0 - right_panel_width = if right_sidebar_showing - left_sidebar_showing ? right_panel_min_width : right_panel_max_width - else - 0 - end - - { - left_panel: { - is_open: left_sidebar_showing, - initial_width: left_panel_width, - min_width: left_panel_min_width, - max_width: left_panel_max_width - }, - right_panel: { - is_open: right_sidebar_showing, - initial_width: right_panel_width, - min_width: right_panel_min_width, - max_width: right_panel_max_width, - overflow: right_sidebar_showing ? "auto" : "hidden" - }, - content_max_width: content_max_width - } - end - private def calculate_total(item, money_method, negate) items = item.reject { |i| i.respond_to?(:entryable) && i.entryable.transfer? } diff --git a/app/helpers/custom_confirm.rb b/app/helpers/custom_confirm.rb new file mode 100644 index 00000000..bf40f449 --- /dev/null +++ b/app/helpers/custom_confirm.rb @@ -0,0 +1,51 @@ +# The shape of data expected by `confirm_dialog_controller.js` to override the +# default browser confirm API via Turbo. +class CustomConfirm + class << self + def for_resource_deletion(resource_name, high_severity: false) + new( + destructive: true, + high_severity: high_severity, + title: "Delete #{resource_name}?", + body: "Are you sure you want to delete #{resource_name}? This is not reversible.", + btn_text: "Delete #{resource_name}" + ) + end + end + + def initialize(title: default_title, body: default_body, btn_text: default_btn_text, destructive: false, high_severity: false) + @title = title + @body = body + @btn_text = btn_text + @btn_variant = derive_btn_variant(destructive, high_severity) + end + + def to_data_attribute + { + title: title, + body: body, + confirmText: btn_text, + variant: btn_variant + } + end + + private + attr_reader :title, :body, :btn_text, :btn_variant + + def derive_btn_variant(destructive, high_severity) + return "primary" unless destructive + high_severity ? "destructive" : "outline-destructive" + end + + def default_title + "Are you sure?" + end + + def default_body + "This is not reversible." + end + + def default_btn_text + "Confirm" + end +end diff --git a/app/helpers/forms_helper.rb b/app/helpers/forms_helper.rb deleted file mode 100644 index dfa5c3b5..00000000 --- a/app/helpers/forms_helper.rb +++ /dev/null @@ -1,22 +0,0 @@ -module FormsHelper - def styled_form_with(**options, &block) - options[:builder] = StyledFormBuilder - form_with(**options, &block) - end - - def modal_form_wrapper(title:, subtitle: nil, overflow_visible: false, &block) - content = capture &block - - render partial: "shared/modal_form", locals: { title:, subtitle:, content:, overflow_visible: } - end - - def period_select(form:, selected:, classes: "border border-secondary bg-container-inset rounded-lg text-sm pr-7 cursor-pointer text-primary focus:outline-hidden focus:ring-0") - periods_for_select = Period.all.map { |period| [ period.label_short, period.key ] } - - form.select(:period, periods_for_select, { selected: selected.key }, class: classes, data: { "auto-submit-form-target": "auto" }) - end - - def currencies_for_select - Money::Currency.all_instances.sort_by { |currency| [ currency.priority, currency.name ] } - end -end diff --git a/app/helpers/menus_helper.rb b/app/helpers/menus_helper.rb deleted file mode 100644 index 0ddc9e5f..00000000 --- a/app/helpers/menus_helper.rb +++ /dev/null @@ -1,47 +0,0 @@ -module MenusHelper - def contextual_menu(icon: "more-horizontal", id: nil, &block) - tag.div id: id, data: { controller: "menu" } do - concat contextual_menu_icon(icon) - concat contextual_menu_content(&block) - end - end - - 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 - end - - def contextual_menu_item(label, url:, icon:, turbo_frame: nil) - 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 - concat(lucide_icon(icon, class: "shrink-0 w-5 h-5 text-secondary")) - concat(tag.span(label, class: "text-sm")) - end - end - - def contextual_menu_destructive_item(label, url, turbo_confirm: true, turbo_frame: nil) - button_to url, - method: :delete, - class: "flex items-center w-full rounded-md text-red-500 hover:bg-red-500/5 p-2 gap-2", - data: { turbo_confirm: turbo_confirm, turbo_frame: } do - concat(lucide_icon("trash-2", class: "shrink-0 w-5 h-5")) - concat(tag.span(label, class: "text-sm")) - end - end - - 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 - 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 - - def contextual_menu_content(&block) - tag.div class: "min-w-[200px] p-1 z-50 shadow-border-xs bg-container rounded-lg hidden", - data: { menu_target: "content" } do - capture(&block) - end - end -end diff --git a/app/helpers/styled_form_builder.rb b/app/helpers/styled_form_builder.rb index 7a817578..1ddd445a 100644 --- a/app/helpers/styled_form_builder.rb +++ b/app/helpers/styled_form_builder.rb @@ -48,19 +48,46 @@ class StyledFormBuilder < ActionView::Helpers::FormBuilder } end - def submit(value = nil, options = {}) - default_options = { - data: { turbo_submits_with: "Submitting..." }, - class: "btn btn--primary w-full justify-center" - } + # A custom styled "toggle" switch input. Underlying input is a `check_box` (uses same API) + def toggle(method, options = {}, checked_value = "1", unchecked_value = "0") + if object + id = "#{object.id}_#{object_name}_#{method}" + name = "#{object_name}[#{method}]" + checked = object.send(method) + else + id = "#{method}_toggle_id" + name = method + checked = options[:checked] + end - merged_options = default_options.merge(options) + @template.render( + ToggleComponent.new( + id: id, + name: name, + checked: checked, + disabled: options[:disabled], + checked_value: checked_value, + unchecked_value: unchecked_value, + **options + ) + ) + end + + def submit(value = nil, options = {}) + # Rails superclass logic to extract the submit text value, options = nil, value if value.is_a?(Hash) - super(value, merged_options) + value ||= submit_default_value + + @template.render( + ButtonComponent.new( + text: value, + data: (options[:data] || {}).merge({ turbo_submits_with: "Submitting..." }), + full_width: true + ) + ) end private - def build_styled_field(label, field, options, remove_padding_right: false) if options[:inline] label + field diff --git a/app/helpers/transactions_helper.rb b/app/helpers/transactions_helper.rb index dd729c47..173306b9 100644 --- a/app/helpers/transactions_helper.rb +++ b/app/helpers/transactions_helper.rb @@ -1,13 +1,13 @@ module TransactionsHelper def transaction_search_filters [ - { key: "account_filter", icon: "layers" }, - { key: "date_filter", icon: "calendar" }, - { key: "type_filter", icon: "tag" }, - { key: "amount_filter", icon: "hash" }, - { key: "category_filter", icon: "shapes" }, - { key: "tag_filter", icon: "tags" }, - { key: "merchant_filter", icon: "store" } + { key: "account_filter", label: "Account", icon: "layers" }, + { key: "date_filter", label: "Date", icon: "calendar" }, + { key: "type_filter", label: "Type", icon: "tag" }, + { key: "amount_filter", label: "Amount", icon: "hash" }, + { key: "category_filter", label: "Category", icon: "shapes" }, + { key: "tag_filter", label: "Tag", icon: "tags" }, + { key: "merchant_filter", label: "Merchant", icon: "store" } ] end diff --git a/app/javascript/controllers/account_collapse_controller.js b/app/javascript/controllers/account_collapse_controller.js deleted file mode 100644 index 11c51cde..00000000 --- a/app/javascript/controllers/account_collapse_controller.js +++ /dev/null @@ -1,51 +0,0 @@ -import { Controller } from "@hotwired/stimulus"; - -// Connects to data-controller="account-collapse" -export default class extends Controller { - static values = { type: String }; - initialToggle = false; - STORAGE_NAME = "accountCollapseStates"; - - connect() { - this.element.addEventListener("toggle", this.onToggle); - this.updateFromLocalStorage(); - } - - disconnect() { - this.element.removeEventListener("toggle", this.onToggle); - } - - onToggle = () => { - if (this.initialToggle) { - this.initialToggle = false; - return; - } - - const items = this.getItemsFromLocalStorage(); - if (items.has(this.typeValue)) { - items.delete(this.typeValue); - } else { - items.add(this.typeValue); - } - localStorage.setItem(this.STORAGE_NAME, JSON.stringify([...items])); - }; - - updateFromLocalStorage() { - const items = this.getItemsFromLocalStorage(); - - if (items.has(this.typeValue)) { - this.initialToggle = true; - this.element.setAttribute("open", ""); - } - } - - getItemsFromLocalStorage() { - try { - const items = localStorage.getItem(this.STORAGE_NAME); - return new Set(items ? JSON.parse(items) : []); - } catch (error) { - console.error("Error parsing items from localStorage:", error); - return new Set(); - } - } -} diff --git a/app/javascript/controllers/app_layout_controller.js b/app/javascript/controllers/app_layout_controller.js new file mode 100644 index 00000000..cc15c78c --- /dev/null +++ b/app/javascript/controllers/app_layout_controller.js @@ -0,0 +1,56 @@ +import { Controller } from "@hotwired/stimulus"; + +// Connects to data-controller="dialog" +export default class extends Controller { + static targets = ["leftSidebar", "rightSidebar", "mobileSidebar"]; + static classes = [ + "expandedSidebar", + "collapsedSidebar", + "expandedTransition", + "collapsedTransition", + ]; + + openMobileSidebar() { + this.mobileSidebarTarget.classList.remove("hidden"); + } + + closeMobileSidebar() { + this.mobileSidebarTarget.classList.add("hidden"); + } + + toggleLeftSidebar() { + const isOpen = this.leftSidebarTarget.classList.contains("w-full"); + this.#updateUserPreference("show_sidebar", !isOpen); + this.#toggleSidebarWidth(this.leftSidebarTarget, isOpen); + } + + toggleRightSidebar() { + const isOpen = this.rightSidebarTarget.classList.contains("w-full"); + this.#updateUserPreference("show_ai_sidebar", !isOpen); + this.#toggleSidebarWidth(this.rightSidebarTarget, isOpen); + } + + #toggleSidebarWidth(el, isCurrentlyOpen) { + if (isCurrentlyOpen) { + el.classList.remove(...this.expandedSidebarClasses); + el.classList.add(...this.collapsedSidebarClasses); + } else { + el.classList.add(...this.expandedSidebarClasses); + el.classList.remove(...this.collapsedSidebarClasses); + } + } + + #updateUserPreference(field, value) { + fetch(`/users/${this.userIdValue}`, { + method: "PATCH", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + "X-CSRF-Token": document.querySelector('[name="csrf-token"]').content, + Accept: "application/json", + }, + body: new URLSearchParams({ + [`user[${field}]`]: value, + }).toString(), + }); + } +} diff --git a/app/javascript/controllers/application.js b/app/javascript/controllers/application.js index f898dcad..ae24872d 100644 --- a/app/javascript/controllers/application.js +++ b/app/javascript/controllers/application.js @@ -6,52 +6,14 @@ const application = Application.start(); application.debug = false; window.Stimulus = application; -Turbo.config.forms.confirm = (message) => { - const dialog = document.getElementById("turbo-confirm"); - - try { - const { title, body, accept, acceptClass } = JSON.parse(message); - - if (title) { - document.getElementById("turbo-confirm-title").innerHTML = title; - } - - if (body) { - document.getElementById("turbo-confirm-body").innerHTML = body; - } - - if (accept) { - document.getElementById("turbo-confirm-accept").innerHTML = accept; - } - - if (acceptClass) { - document.getElementById("turbo-confirm-accept").className = acceptClass; - } - } catch (e) { - document.getElementById("turbo-confirm-title").innerText = message; - } - - dialog.showModal(); - - return new Promise((resolve) => { - dialog.addEventListener( - "close", - () => { - const confirmed = dialog.returnValue === "confirm"; - - if (!confirmed) { - document.getElementById("turbo-confirm-title").innerHTML = - "Are you sure?"; - document.getElementById("turbo-confirm-body").innerHTML = - "You will not be able to undo this decision"; - document.getElementById("turbo-confirm-accept").innerHTML = "Confirm"; - } - - resolve(confirmed); - }, - { once: true }, +Turbo.config.forms.confirm = (data) => { + const confirmDialogController = + application.getControllerForElementAndIdentifier( + document.getElementById("confirm-dialog"), + "confirm-dialog", ); - }); + + return confirmDialogController.handleConfirm(data); }; export { application }; diff --git a/app/javascript/controllers/bulk_select_controller.js b/app/javascript/controllers/bulk_select_controller.js index a1e40157..0851da7a 100644 --- a/app/javascript/controllers/bulk_select_controller.js +++ b/app/javascript/controllers/bulk_select_controller.js @@ -7,7 +7,7 @@ export default class extends Controller { "group", "selectionBar", "selectionBarText", - "bulkEditDrawerTitle", + "bulkEditDrawerHeader", ]; static values = { singularLabel: String, @@ -25,8 +25,9 @@ export default class extends Controller { document.removeEventListener("turbo:load", this._updateView); } - bulkEditDrawerTitleTargetConnected(element) { - element.innerText = `Edit ${ + bulkEditDrawerHeaderTargetConnected(element) { + const headingTextEl = element.querySelector("h2"); + headingTextEl.innerText = `Edit ${ this.selectedIdsValue.length } ${this._pluralizedResourceName()}`; } diff --git a/app/javascript/controllers/color_avatar_controller.js b/app/javascript/controllers/color_avatar_controller.js index 49d9caeb..351bc437 100644 --- a/app/javascript/controllers/color_avatar_controller.js +++ b/app/javascript/controllers/color_avatar_controller.js @@ -21,8 +21,8 @@ export default class extends Controller { handleColorChange(e) { const color = e.currentTarget.value; - this.avatarTarget.style.backgroundColor = `color-mix(in srgb, ${color} 10%, white)`; - this.avatarTarget.style.borderColor = `color-mix(in srgb, ${color} 10%, white)`; + this.avatarTarget.style.backgroundColor = `color-mix(in srgb, ${color} 10%, transparent)`; + this.avatarTarget.style.borderColor = `color-mix(in srgb, ${color} 10%, transparent)`; this.avatarTarget.style.color = color; } } diff --git a/app/javascript/controllers/confirm_dialog_controller.js b/app/javascript/controllers/confirm_dialog_controller.js new file mode 100644 index 00000000..e66269a6 --- /dev/null +++ b/app/javascript/controllers/confirm_dialog_controller.js @@ -0,0 +1,59 @@ +import { Controller } from "@hotwired/stimulus"; + +// Connects to data-controller="confirm-dialog" +// See javascript/controllers/application.js for how this is wired up +export default class extends Controller { + static targets = ["title", "subtitle", "confirmButton"]; + + handleConfirm(rawData) { + const data = this.#normalizeRawData(rawData); + + this.#prepareDialog(data); + + this.element.showModal(); + + return new Promise((resolve) => { + this.element.addEventListener( + "close", + () => { + const isConfirmed = this.element.returnValue === "confirm"; + resolve(isConfirmed); + }, + { once: true }, + ); + }); + } + + #prepareDialog(data) { + const variant = data.variant || "primary"; + + this.confirmButtonTargets.forEach((button) => { + if (button.dataset.variant === variant) { + button.removeAttribute("hidden"); + } else { + button.setAttribute("hidden", true); + } + + button.textContent = data.confirmText || "Confirm"; + }); + + this.titleTarget.textContent = data.title || "Are you sure?"; + this.subtitleTarget.innerHTML = + data.body || "This action cannot be undone."; + } + + // If data is a string, it's the title. Otherwise, return the parsed object. + #normalizeRawData(rawData) { + try { + const parsed = JSON.parse(rawData); + + if (typeof parsed === "boolean") { + return { title: "Are you sure?" }; + } + + return parsed; + } catch (e) { + return { title: rawData }; + } + } +} diff --git a/app/javascript/controllers/deletion_controller.js b/app/javascript/controllers/deletion_controller.js index cd49065d..ec4bc9f2 100644 --- a/app/javascript/controllers/deletion_controller.js +++ b/app/javascript/controllers/deletion_controller.js @@ -1,30 +1,28 @@ import { Controller } from "@hotwired/stimulus"; export default class extends Controller { - static targets = ["replacementField", "submitButton"]; - static classes = ["dangerousAction", "safeAction"]; + static targets = [ + "replacementField", + "destructiveSubmitButton", + "safeSubmitButton", + ]; + static values = { submitTextWhenReplacing: String, submitTextWhenNotReplacing: String, }; - updateSubmitButton() { + chooseSubmitButton() { if (this.replacementFieldTarget.value) { - this.submitButtonTarget.value = this.submitTextWhenReplacingValue; - this.#markSafe(); + this.destructiveSubmitButtonTarget.hidden = true; + this.safeSubmitButtonTarget.textContent = + this.submitTextWhenReplacingValue; + this.safeSubmitButtonTarget.hidden = false; } else { - this.submitButtonTarget.value = this.submitTextWhenNotReplacingValue; - this.#markDangerous(); + this.destructiveSubmitButtonTarget.textContent = + this.submitTextWhenNotReplacingValue; + this.destructiveSubmitButtonTarget.hidden = false; + this.safeSubmitButtonTarget.hidden = true; } } - - #markSafe() { - this.submitButtonTarget.classList.remove(...this.dangerousActionClasses); - this.submitButtonTarget.classList.add(...this.safeActionClasses); - } - - #markDangerous() { - this.submitButtonTarget.classList.remove(...this.safeActionClasses); - this.submitButtonTarget.classList.add(...this.dangerousActionClasses); - } } diff --git a/app/javascript/controllers/intercom_controller.js b/app/javascript/controllers/intercom_controller.js new file mode 100644 index 00000000..f22d1db8 --- /dev/null +++ b/app/javascript/controllers/intercom_controller.js @@ -0,0 +1,8 @@ +import { Controller } from "@hotwired/stimulus"; + +// Connects to data-controller="intercom" +export default class extends Controller { + show() { + Intercom("show"); + } +} diff --git a/app/javascript/controllers/preserve_scroll_controller.js b/app/javascript/controllers/preserve_scroll_controller.js deleted file mode 100644 index c2110fd1..00000000 --- a/app/javascript/controllers/preserve_scroll_controller.js +++ /dev/null @@ -1,39 +0,0 @@ -/* - 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/rule/actions_controller.js b/app/javascript/controllers/rule/actions_controller.js index 815e027e..f4de40eb 100644 --- a/app/javascript/controllers/rule/actions_controller.js +++ b/app/javascript/controllers/rule/actions_controller.js @@ -8,6 +8,7 @@ export default class extends Controller { remove(e) { if (e.params.destroy) { this.destroyFieldTarget.value = true; + this.element.classList.add("hidden"); } else { this.element.remove(); } diff --git a/app/javascript/controllers/sidebar_controller.js b/app/javascript/controllers/sidebar_controller.js deleted file mode 100644 index a46794e3..00000000 --- a/app/javascript/controllers/sidebar_controller.js +++ /dev/null @@ -1,86 +0,0 @@ -import { Controller } from "@hotwired/stimulus"; - -// Connects to data-controller="sidebar" -export default class extends Controller { - static values = { - userId: String, - config: Object, - }; - - static targets = ["leftPanel", "leftPanelMobile", "rightPanel", "content"]; - - initialize() { - this.leftPanelOpen = this.configValue.left_panel.is_open; - this.rightPanelOpen = this.configValue.right_panel.is_open; - } - - toggleLeftPanel() { - this.leftPanelOpen = !this.leftPanelOpen; - this.#updatePanelWidths(); - this.#persistPreference("show_sidebar", this.leftPanelOpen); - } - - toggleLeftPanelMobile() { - if (this.leftPanelOpen) { - this.leftPanelMobileTarget.classList.remove("hidden"); - this.leftPanelOpen = false; - } else { - this.leftPanelMobileTarget.classList.add("hidden"); - this.leftPanelOpen = true; - } - } - - toggleRightPanel() { - this.rightPanelOpen = !this.rightPanelOpen; - this.#updatePanelWidths(); - this.#persistPreference("show_ai_sidebar", this.rightPanelOpen); - } - - #updatePanelWidths() { - this.leftPanelTarget.style.width = `${this.#leftPanelWidth()}px`; - this.rightPanelTarget.style.width = `${this.#rightPanelWidth()}px`; - this.rightPanelTarget.style.overflow = this.#rightPanelOverflow(); - } - - #leftPanelWidth() { - if (this.leftPanelOpen) { - return this.configValue.left_panel.min_width; - } - - return 0; - } - - #rightPanelWidth() { - if (this.rightPanelOpen) { - if (this.leftPanelOpen) { - return this.configValue.right_panel.min_width; - } - - return this.configValue.right_panel.max_width; - } - - return 0; - } - - #rightPanelOverflow() { - if (this.rightPanelOpen) { - return "auto"; - } - - return "hidden"; - } - - #persistPreference(field, value) { - fetch(`/users/${this.userIdValue}`, { - method: "PATCH", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - "X-CSRF-Token": document.querySelector('[name="csrf-token"]').content, - Accept: "application/json", - }, - body: new URLSearchParams({ - [`user[${field}]`]: value, - }).toString(), - }); - } -} diff --git a/app/javascript/controllers/tabs_controller.js b/app/javascript/controllers/tabs_controller.js deleted file mode 100644 index 1e1cd614..00000000 --- a/app/javascript/controllers/tabs_controller.js +++ /dev/null @@ -1,74 +0,0 @@ -import { Controller } from "@hotwired/stimulus"; - -// Connects to data-controller="tabs" -export default class extends Controller { - static classes = ["active", "inactive"]; - static targets = ["btn", "tab"]; - static values = { defaultTab: String, localStorageKey: String }; - - connect() { - const selectedTab = this.hasLocalStorageKeyValue - ? this.getStoredTab() || this.defaultTabValue - : this.defaultTabValue; - - this.updateClasses(selectedTab); - document.addEventListener("turbo:load", this.onTurboLoad); - } - - disconnect() { - document.removeEventListener("turbo:load", this.onTurboLoad); - } - - select(event) { - const element = event.target.closest("[data-id]"); - if (element) { - const selectedId = element.dataset.id; - this.updateClasses(selectedId); - if (this.hasLocalStorageKeyValue) { - this.storeTab(selectedId); - } - } - } - - onTurboLoad = () => { - const selectedTab = this.hasLocalStorageKeyValue - ? this.getStoredTab() || this.defaultTabValue - : this.defaultTabValue; - - this.updateClasses(selectedTab); - }; - - getStoredTab() { - const tabs = JSON.parse(localStorage.getItem("tabs") || "{}"); - return tabs[this.localStorageKeyValue]; - } - - storeTab(selectedId) { - const tabs = JSON.parse(localStorage.getItem("tabs") || "{}"); - tabs[this.localStorageKeyValue] = selectedId; - localStorage.setItem("tabs", JSON.stringify(tabs)); - } - - updateClasses = (selectedId) => { - this.btnTargets.forEach((btn) => { - btn.classList.remove(...this.activeClasses); - btn.classList.remove(...this.inactiveClasses); - }); - - this.tabTargets.forEach((tab) => tab.classList.add("hidden")); - - this.btnTargets.forEach((btn) => { - if (btn.dataset.id === selectedId) { - btn.classList.add(...this.activeClasses); - } else { - btn.classList.add(...this.inactiveClasses); - } - }); - - this.tabTargets.forEach((tab) => { - if (tab.id === selectedId) { - tab.classList.remove("hidden"); - } - }); - }; -} diff --git a/app/javascript/controllers/theme_controller.js b/app/javascript/controllers/theme_controller.js index d01edf7f..b9c0782d 100644 --- a/app/javascript/controllers/theme_controller.js +++ b/app/javascript/controllers/theme_controller.js @@ -1,73 +1,87 @@ -import { Controller } from "@hotwired/stimulus" +import { Controller } from "@hotwired/stimulus"; export default class extends Controller { - static values = { userPreference: String } + static values = { userPreference: String }; connect() { - this.applyTheme() - this.startSystemThemeListener() + this.applyTheme(); + this.startSystemThemeListener(); } disconnect() { - this.stopSystemThemeListener() + this.stopSystemThemeListener(); } // Called automatically by Stimulus when the userPreferenceValue changes (e.g., after form submit/page reload) userPreferenceValueChanged() { - this.applyTheme() + this.applyTheme(); } // Called when a theme radio button is clicked updateTheme(event) { - const selectedTheme = event.currentTarget.value + const selectedTheme = event.currentTarget.value; if (selectedTheme === "system") { - this.setTheme(this.systemPrefersDark()) + this.setTheme(this.systemPrefersDark()); } else if (selectedTheme === "dark") { - this.setTheme(true) + this.setTheme(true); } else { - this.setTheme(false) + this.setTheme(false); } } // Applies theme based on the userPreferenceValue (from server) applyTheme() { if (this.userPreferenceValue === "system") { - this.setTheme(this.systemPrefersDark()) + this.setTheme(this.systemPrefersDark()); } else if (this.userPreferenceValue === "dark") { - this.setTheme(true) + this.setTheme(true); } else { - this.setTheme(false) + this.setTheme(false); } } // Sets or removes the data-theme attribute setTheme(isDark) { if (isDark) { - document.documentElement.setAttribute("data-theme", "dark") + document.documentElement.setAttribute("data-theme", "dark"); } else { - document.documentElement.removeAttribute("data-theme") + document.documentElement.removeAttribute("data-theme"); } } systemPrefersDark() { - return window.matchMedia("(prefers-color-scheme: dark)").matches + return window.matchMedia("(prefers-color-scheme: dark)").matches; } handleSystemThemeChange = (event) => { // Only apply system theme changes if the user preference is currently 'system' if (this.userPreferenceValue === "system") { - this.setTheme(event.matches) + this.setTheme(event.matches); } + }; + + toDark() { + this.setTheme(true); + } + + toLight() { + this.setTheme(false); } startSystemThemeListener() { - this.darkMediaQuery = window.matchMedia("(prefers-color-scheme: dark)") - this.darkMediaQuery.addEventListener("change", this.handleSystemThemeChange) + this.darkMediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); + this.darkMediaQuery.addEventListener( + "change", + this.handleSystemThemeChange, + ); } stopSystemThemeListener() { if (this.darkMediaQuery) { - this.darkMediaQuery.removeEventListener("change", this.handleSystemThemeChange) + this.darkMediaQuery.removeEventListener( + "change", + this.handleSystemThemeChange, + ); } } -} \ No newline at end of file +} diff --git a/app/javascript/controllers/time_series_chart_controller.js b/app/javascript/controllers/time_series_chart_controller.js index 664e989d..57cac880 100644 --- a/app/javascript/controllers/time_series_chart_controller.js +++ b/app/javascript/controllers/time_series_chart_controller.js @@ -95,7 +95,8 @@ export default class extends Controller { .attr("cx", this._d3InitialContainerWidth / 2) .attr("cy", this._d3InitialContainerHeight / 2) .attr("r", 4) - .style("fill", "var(--color-gray-400)"); + .attr("class", "fg-subdued") + .style("fill", "currentColor"); } _drawChart() { @@ -151,7 +152,8 @@ export default class extends Controller { .append("stop") .attr("class", "end-color") .attr("offset", "100%") - .attr("stop-color", "var(--color-gray-300)"); + .attr("class", "fg-subdued") + .attr("stop-color", "currentColor"); } _setTrendlineSplitAt(percent) { @@ -191,7 +193,7 @@ export default class extends Controller { // Style ticks this._d3Group .selectAll(".tick text") - .style("fill", "var(--color-gray-500)") + .attr("class", "fg-gray") .style("font-size", "12px") .style("font-weight", "500") .attr("text-anchor", "middle") @@ -258,14 +260,10 @@ export default class extends Controller { this._d3Tooltip = d3 .select(`#${this.element.id}`) .append("div") - .style("position", "absolute") - .style("padding", "8px") - .style("font", "14px Inter, sans-serif") - .style("background", "var(--color-white)") - .style("border", "1px solid var(--color-alpha-black-100)") - .style("border-radius", "10px") - .style("pointer-events", "none") - .style("opacity", 0); // Starts as hidden + .attr( + "class", + "bg-container text-sm font-sans absolute p-2 border border-secondary rounded-lg pointer-events-none opacity-0", + ); } _trackMouseForShowingTooltip() { @@ -273,6 +271,7 @@ export default class extends Controller { this._d3Group .append("rect") + .attr("class", "bg-container") .attr("width", this._d3ContainerWidth) .attr("height", this._d3ContainerHeight) .attr("fill", "none") @@ -308,12 +307,12 @@ export default class extends Controller { // Guideline this._d3Group .append("line") - .attr("class", "guideline") + .attr("class", "guideline fg-subdued") .attr("x1", this._d3XScale(d.date)) .attr("y1", 0) .attr("x2", this._d3XScale(d.date)) .attr("y2", this._d3ContainerHeight) - .attr("stroke", "var(--color-gray-300)") + .attr("stroke", "currentColor") .attr("stroke-dasharray", "4, 4"); // Big circle @@ -353,7 +352,6 @@ export default class extends Controller { this._d3Group.selectAll(".guideline").remove(); this._d3Group.selectAll(".data-point-circle").remove(); this._d3Tooltip.style("opacity", 0); - this._setTrendlineSplitAt(1); } }); @@ -364,23 +362,17 @@ export default class extends Controller {
${datum.date_formatted}
-
-
-
- ${datum.trend.previous.amount === datum.trend.current.amount ? ` - - ` : Number(datum.trend.previous.amount) < Number(datum.trend.current.amount) ? ` - - ` : ` - - `} +
+
+
+ ${this._getTrendIcon(datum)}
${this._extractFormattedValue(datum.trend.current)}
${ datum.trend.value === 0 - ? `` + ? `` : ` ${this._extractFormattedValue(datum.trend.value)} (${datum.trend.percent_formatted}) @@ -391,6 +383,23 @@ export default class extends Controller { `; } + _getTrendIcon(datum) { + const isIncrease = + Number(datum.trend.previous.amount) < Number(datum.trend.current.amount); + const isDecrease = + Number(datum.trend.previous.amount) > Number(datum.trend.current.amount); + + if (isIncrease) { + return ``; + } + + if (isDecrease) { + return ``; + } + + return ``; + } + _getDatumValue = (datum) => { return this._extractNumericValue(datum.trend.current); }; diff --git a/app/models/balance_sheet.rb b/app/models/balance_sheet.rb index 21b4aeca..c289f86f 100644 --- a/app/models/balance_sheet.rb +++ b/app/models/balance_sheet.rb @@ -26,13 +26,15 @@ class BalanceSheet ClassificationGroup.new( key: "asset", display_name: "Assets", - icon: "blocks", + icon: "plus", + total_money: total_assets_money, account_groups: account_groups("asset") ), ClassificationGroup.new( key: "liability", display_name: "Debts", - icon: "scale", + icon: "minus", + total_money: total_liabilities_money, account_groups: account_groups("liability") ) ] @@ -75,7 +77,7 @@ class BalanceSheet end private - ClassificationGroup = Struct.new(:key, :display_name, :icon, :account_groups, keyword_init: true) + ClassificationGroup = Struct.new(:key, :display_name, :icon, :total_money, :account_groups, keyword_init: true) AccountGroup = Struct.new(:key, :name, :accountable_type, :classification, :total, :total_money, :weight, :accounts, :color, :missing_rates?, keyword_init: true) def active_accounts diff --git a/app/models/family.rb b/app/models/family.rb index caaa4134..1ab64523 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -146,6 +146,10 @@ class Family < ApplicationRecord false end + def missing_data_provider? + requires_data_provider? && Provider::Registry.get_provider(:synth).nil? + end + def primary_user users.order(:created_at).first end diff --git a/app/models/period.rb b/app/models/period.rb index 2cceb743..2fbcd30b 100644 --- a/app/models/period.rb +++ b/app/models/period.rb @@ -84,6 +84,10 @@ class Period def all PERIODS.map { |key, period| from_key(key) } end + + def as_options + all.map { |period| [ period.label_short, period.key ] } + end end PERIODS.each do |key, period| diff --git a/app/views/accounts/_account.html.erb b/app/views/accounts/_account.html.erb index 07236d6c..07ffd3d5 100644 --- a/app/views/accounts/_account.html.erb +++ b/app/views/accounts/_account.html.erb @@ -25,7 +25,7 @@ <% unless account.scheduled_for_deletion? %> <%= link_to edit_account_path(account, return_to: return_to), data: { turbo_frame: :modal }, class: "group-hover/account:flex hidden hover:opacity-80 items-center justify-center" do %> - <%= lucide_icon "pencil-line", class: "w-4 h-4 text-secondary" %> + <%= icon("pencil-line", size: "sm") %> <% end %> <% end %>
@@ -35,7 +35,9 @@

<% unless account.scheduled_for_deletion? %> - <%= render "shared/toggle_form", model: account, attribute: :is_active, turbo_frame: "_top" %> + <%= styled_form_with model: account, data: { turbo_frame: "_top", controller: "auto-submit-form" } do |f| %> + <%= f.toggle :is_active, { data: { auto_submit_form_target: "auto" } } %> + <% end %> <% end %>
diff --git a/app/views/accounts/_account_sidebar_tabs.html.erb b/app/views/accounts/_account_sidebar_tabs.html.erb index 49d6ebaf..1ed98300 100644 --- a/app/views/accounts/_account_sidebar_tabs.html.erb +++ b/app/views/accounts/_account_sidebar_tabs.html.erb @@ -1,88 +1,91 @@ -<%# locals: (family:) %> +<%# locals: (family:, active_account_group_tab:) %> -<% if family.requires_data_provider? && Provider::Registry.get_provider(:synth).nil? %> -
- -
- <%= icon "triangle-alert", size: "sm" %> -

Missing historical data

+
+ <% if family.missing_data_provider? %> +
+ +
+ <%= icon "triangle-alert", size: "sm", color: "warning" %> +

Missing historical data

+
+ + <%= icon("chevron-down", color: "warning", class: "group-open:transform group-open:rotate-180") %> +
+
+

Maybe uses Synth API to fetch historical exchange rates, security prices, and more. This data is required to calculate accurate historical account balances.

+ +

+ <%= link_to "Add your Synth API key here.", settings_hosting_path, class: "text-yellow-600 underline" %> +

+
+ <% end %> - <%= lucide_icon "chevron-down", class: "text-yellow-600 group-open:transform group-open:rotate-180 w-5" %> -
-
-

Maybe uses Synth API to fetch historical exchange rates, security prices, and more. This data is required to calculate accurate historical account balances.

- -

- <%= link_to "Add your Synth API key here.", settings_hosting_path, class: "text-yellow-600 underline" %> -

-
-
-<% end %> - -
-
- - - - - -
- -
- <%= link_to new_account_path(step: "method_select", classification: "asset"), - class: "flex items-center gap-3 btn btn--ghost text-secondary mb-1", - data: { turbo_frame: "modal" } do %> - <%= icon("plus") %> - New asset + <%= render TabsComponent.new(active_tab: active_account_group_tab, url_param_key: "account_group_tab", testid: "account-sidebar-tabs") do |tabs| %> + <% tabs.with_nav do |nav| %> + <% nav.with_btn(id: "assets", label: "Assets") %> + <% nav.with_btn(id: "debts", label: "Debts") %> + <% nav.with_btn(id: "all", label: "All") %> <% end %> -
- <% family.balance_sheet.account_groups("asset").each do |group| %> - <%= render "accounts/accountable_group", account_group: group %> - <% end %> -
-
+ <% tabs.with_panel(tab_id: "assets") do %> +
+ <%= render LinkComponent.new( + text: "New asset", + variant: "ghost", + href: new_account_path(step: "method_select", classification: "asset"), + icon: "plus", + frame: :modal, + full_width: true, + class: "justify-start" + ) %> - <% end %> -
- <% family.balance_sheet.account_groups("liability").each do |group| %> - <%= render "accounts/accountable_group", account_group: group %> - <% end %> -
-
+ <% tabs.with_panel(tab_id: "debts") do %> +
+ <%= render LinkComponent.new( + text: "New debt", + variant: "ghost", + href: new_account_path(step: "method_select", classification: "liability"), + icon: "plus", + frame: :modal, + full_width: true, + class: "justify-start" + ) %> - <% end %> -
- <% family.balance_sheet.account_groups.each do |group| %> - <%= render "accounts/accountable_group", account_group: group %> - <% end %> -
-
+ <% tabs.with_panel(tab_id: "all") do %> +
+ <%= render LinkComponent.new( + text: "New account", + variant: "ghost", + full_width: true, + href: new_account_path(step: "method_select"), + icon: "plus", + frame: :modal, + class: "justify-start" + ) %> + +
+ <% family.balance_sheet.account_groups.each do |group| %> + <%= render "accounts/accountable_group", account_group: group %> + <% end %> +
+
+ <% end %> + <% end %>
diff --git a/app/views/accounts/_account_type.html.erb b/app/views/accounts/_account_type.html.erb index 63622859..9eb98d6e 100644 --- a/app/views/accounts/_account_type.html.erb +++ b/app/views/accounts/_account_type.html.erb @@ -1,9 +1,11 @@ <%# locals: (accountable:) %> <%= link_to new_polymorphic_path(accountable, step: "method_select", return_to: params[:return_to]), - class: "flex items-center gap-4 w-full text-center focus:outline-hidden hover:bg-surface-hover focus:bg-surface-hover fg-contrast hover:fg-primary focus:fg-primary border border-transparent block px-2 rounded-lg p-2" do %> - - <%= lucide_icon(accountable.icon, style: "color: #{accountable.color}", class: "w-5 h-5") %> - + class: "flex items-center gap-4 w-full text-center focus:outline-hidden hover:bg-surface-hover focus:bg-surface-hover fg-primary border border-transparent block px-2 rounded-lg p-2" do %> + <%= render FilledIconComponent.new( + icon: accountable.icon, + hex_color: accountable.color, + ) %> + <%= accountable.display_name.singularize %> <% end %> diff --git a/app/views/accounts/_accountable_group.html.erb b/app/views/accounts/_accountable_group.html.erb index 3e2a26ee..a3939727 100644 --- a/app/views/accounts/_accountable_group.html.erb +++ b/app/views/accounts/_accountable_group.html.erb @@ -1,11 +1,7 @@ <%# locals: (account_group:) %> -
- - <%= lucide_icon("chevron-right", class: "group-open:rotate-90 text-secondary w-5 h-5") %> - - <%= tag.p account_group.name, class: "text-sm font-medium" %> - +<%= render DisclosureComponent.new(title: account_group.name, align: :left) do |disclosure| %> + <% disclosure.with_summary_content do %>
<%= tag.p format_money(account_group.total_money), class: "text-sm font-medium text-primary" %> @@ -15,11 +11,11 @@
<% end %>
- + <% end %>
<% account_group.accounts.each do |account| %> - <%= link_to account_path(account), class: "block flex items-center gap-2 btn btn--ghost", title: account.name do %> + <%= link_to account_path(account), class: "block flex items-center gap-2 px-3 py-2 hover:bg-surface-hover", title: account.name do %> <%= render "accounts/logo", account: account, size: "sm", color: account_group.color %>
@@ -40,10 +36,13 @@ <% end %>
- <%= link_to new_polymorphic_path(account_group.key, step: "method_select"), - class: "flex items-center gap-3 btn btn--ghost text-secondary", - data: { turbo_frame: "modal" } do %> - <%= icon("plus") %> - New <%= account_group.name.downcase.singularize %> - <% end %> - + <%= render LinkComponent.new( + href: new_polymorphic_path(account_group.key, step: "method_select"), + text: "New #{account_group.name.downcase.singularize}", + icon: "plus", + full_width: true, + variant: "ghost", + frame: :modal, + class: "justify-start" + ) %> +<% end %> diff --git a/app/views/accounts/_empty.html.erb b/app/views/accounts/_empty.html.erb index 6a1b6c89..a1eef715 100644 --- a/app/views/accounts/_empty.html.erb +++ b/app/views/accounts/_empty.html.erb @@ -3,9 +3,10 @@ <%= tag.p t(".no_accounts"), class: "text-primary mb-1 font-medium" %> <%= tag.p t(".empty_message"), class: "text-secondary mb-4" %> - <%= link_to new_account_path, class: "w-fit flex text-white text-sm font-medium items-center gap-1 bg-gray-900 rounded-lg p-2 pr-3", data: { turbo_frame: "modal" } do %> - <%= lucide_icon("plus", class: "w-5 h-5") %> - <%= t(".new_account") %> - <% end %> + <%= render LinkComponent.new( + text: t(".new_account"), + href: new_account_path, + frame: :modal + ) %>
diff --git a/app/views/accounts/_logo.html.erb b/app/views/accounts/_logo.html.erb index 9286a0f3..8eec2153 100644 --- a/app/views/accounts/_logo.html.erb +++ b/app/views/accounts/_logo.html.erb @@ -12,5 +12,5 @@ <% elsif account.logo.attached? %> <%= image_tag account.logo, class: "shrink-0 rounded-full #{size_classes[size]}" %> <% else %> - <%= circle_logo(account.name, hex: color || account.accountable.color, size: size) %> + <%= render FilledIconComponent.new(variant: :text, hex_color: color || account.accountable.color, text: account.name, size: size, rounded: true) %> <% end %> diff --git a/app/views/accounts/index.html.erb b/app/views/accounts/index.html.erb index fcdca138..4893e44c 100644 --- a/app/views/accounts/index.html.erb +++ b/app/views/accounts/index.html.erb @@ -2,23 +2,23 @@

<%= t(".accounts") %>

- <%= button_to sync_all_accounts_path, - disabled: Current.family.syncing?, - class: "md:btn md:btn--outline flex items-center justify-center gap-2 w-9 h-9 md:w-auto md:h-auto rounded-full md:rounded-lg", - title: t(".sync") do %> - <%= lucide_icon "refresh-cw", class: "w-5 h-5" %> - - <% end %> + <%= render ButtonComponent.new( + text: "Sync all", + href: sync_all_accounts_path, + method: :post, + variant: "outline", + disabled: Current.family.syncing?, + icon: "refresh-cw", + class: "" + ) %> - <%= link_to new_account_path(return_to: accounts_path), - data: { turbo_frame: "modal" }, - 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 %> -
- - <%= lucide_icon("plus") %> -
- - <% end %> + <%= render LinkComponent.new( + text: "New account", + href: new_account_path(return_to: accounts_path), + variant: "primary", + icon: "plus", + frame: :modal + ) %>
diff --git a/app/views/accounts/index/_manual_accounts.html.erb b/app/views/accounts/index/_manual_accounts.html.erb index b20c400e..0dd8f66d 100644 --- a/app/views/accounts/index/_manual_accounts.html.erb +++ b/app/views/accounts/index/_manual_accounts.html.erb @@ -2,10 +2,10 @@
- <%= lucide_icon "chevron-right", class: "group-open:transform group-open:rotate-90 text-secondary w-5" %> + <%= icon("chevron-right", class: "group-open:transform group-open:rotate-90") %>
- <%= lucide_icon("folder-pen", class: "w-5 h-5 text-secondary") %> + <%= icon("folder-pen") %>
<%= t(".other_accounts") %> diff --git a/app/views/accounts/new.html.erb b/app/views/accounts/new.html.erb index 0bde1bd8..1ca5229e 100644 --- a/app/views/accounts/new.html.erb +++ b/app/views/accounts/new.html.erb @@ -24,10 +24,12 @@ <% unless params[:return_to].present? %> <%= button_to imports_path(import: { type: "AccountImport" }), data: { turbo_frame: :_top }, - class: "flex items-center gap-4 w-full text-center focus:outline-hidden hover:bg-surface-hover focus:bg-surface-hover fg-contrast hover:fg-primary focus:fg-primary border border-transparent block px-2 rounded-lg p-2" do %> - - <%= lucide_icon("download", style: "color: #F79009", class: "w-5 h-5") %> - + class: "flex items-center gap-4 w-full text-center focus:outline-hidden hover:bg-surface-hover focus:bg-surface-hover fg-primary border border-transparent block px-2 rounded-lg p-2" do %> + <%= render FilledIconComponent.new( + icon: "download", + hex_color: "#F79009", + ) %> + <%= t("accounts.new.import_accounts") %> <% end %> <% end %> diff --git a/app/views/accounts/new/_container.html.erb b/app/views/accounts/new/_container.html.erb index d83d2e8c..2d3582f7 100644 --- a/app/views/accounts/new/_container.html.erb +++ b/app/views/accounts/new/_container.html.erb @@ -1,18 +1,22 @@ <%# locals: (title:, back_path: nil) %> -<%= modal do %> -
-
- <% if back_path %> - <%= link_to back_path, class: "flex w-8 h-8 shrink-0 grow-0 items-center justify-center rounded-lg bg-alpha-black-50 focus:outline-gray-300 focus:outline" do %> - <%= lucide_icon("arrow-left", class: "text-secondary w-5 h-5") %> +<%= render DialogComponent.new do |dialog| %> +
+
+
+ <% if back_path %> + <%= render LinkComponent.new( + variant: "icon", + icon: "arrow-left", + href: back_path, + size: "lg" + ) %> <% end %> - <% end %> - <%= title %> - + <%= title %> +
+ + <%= icon("x", as_button: true, size: "lg", data: { action: "dialog#close" }) %>
@@ -22,20 +26,26 @@ <%= yield %>
-
- -
- -

<%= t(".history") %>

- <%= lucide_icon "chevron-down", class: "group-open:transform group-open:rotate-180 text-secondary w-5" %> -
+ <% end %> + <% dialog.with_section(title: t(".history"), open: true) do %>
<% if @holding.trades.any? %> @@ -85,15 +77,10 @@ <% end %>
-
+ <% end %> <% unless @holding.account.plaid_account_id.present? %> -
- -

<%= t(".settings") %>

- <%= lucide_icon "chevron-down", class: "group-open:transform group-open:rotate-180 text-secondary w-5" %> -
- + <% dialog.with_section(title: t(".settings"), open: true) do %>
@@ -108,7 +95,7 @@ data: { turbo_confirm: true } %>
-
+ <% end %> <% end %> - + <% end %> <% end %> diff --git a/app/views/impersonation_sessions/_approval_bar.html.erb b/app/views/impersonation_sessions/_approval_bar.html.erb index 7ab60104..b8690c74 100644 --- a/app/views/impersonation_sessions/_approval_bar.html.erb +++ b/app/views/impersonation_sessions/_approval_bar.html.erb @@ -2,9 +2,9 @@ <% in_progress_session = Current.true_user.impersonated_support_sessions.in_progress.first %>
-
- <%= lucide_icon "alert-triangle", class: "w-6 h-6 text-white mr-2" %> - Access <%= in_progress_session.present? ? "Session" : "Request" %> +
+ <%= icon "alert-triangle", size: "lg", color: "current", class: "mr-2" %> + Access <%= in_progress_session.present? ? "Session" : "Request" %>
<% if pending_session.present? %> diff --git a/app/views/impersonation_sessions/_super_admin_bar.html.erb b/app/views/impersonation_sessions/_super_admin_bar.html.erb index 57bfa825..a09a8250 100644 --- a/app/views/impersonation_sessions/_super_admin_bar.html.erb +++ b/app/views/impersonation_sessions/_super_admin_bar.html.erb @@ -1,7 +1,7 @@
- <%= lucide_icon "alert-triangle", class: "w-6 h-6 text-white mr-2" %> - Super Admin + <%= icon "alert-triangle", size: "lg", color: "current", class: "mr-2" %> + Super Admin
<%= link_to "Jobs", sidekiq_web_url, class: "text-white underline hover:text-gray-100" %> diff --git a/app/views/import/cleans/show.html.erb b/app/views/import/cleans/show.html.erb index efce906c..1412cb5e 100644 --- a/app/views/import/cleans/show.html.erb +++ b/app/views/import/cleans/show.html.erb @@ -13,18 +13,24 @@ <% if @import.cleaned? %>
- <%= lucide_icon "check-circle", class: "w-4 h-4 text-green-500" %> -

Your data has been cleaned

+ <%= icon "check-circle", size: "sm", color: "success" %> +

Your data has been cleaned

- <%= link_to "Next step", import_confirm_path(@import), class: "btn btn--primary w-full md:w-auto" %> + <%= render LinkComponent.new( + text: "Next step", + variant: "primary", + href: import_confirm_path(@import), + frame: :_top, + class: "w-full md:w-auto" + ) %>
<% else %>
- <%= lucide_icon "alert-triangle", class: "w-4 h-4 text-red-500 flex-shrink-0" %> - -

<%= t(".errors_notice_mobile") %>

+ <%= icon "alert-triangle", size: "sm", color: "destructive" %> + +

<%= t(".errors_notice_mobile") %>

diff --git a/app/views/import/configurations/_account_import.html.erb b/app/views/import/configurations/_account_import.html.erb index 7f6ff6f2..28096ff9 100644 --- a/app/views/import/configurations/_account_import.html.erb +++ b/app/views/import/configurations/_account_import.html.erb @@ -6,5 +6,5 @@ <%= form.select :amount_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Balance" }, required: true %> <%= form.select :currency_col_label, import.csv_headers, { include_blank: "Default", label: "Currency" } %> - <%= form.submit "Apply configuration", class: "w-full btn btn--primary", disabled: import.complete? %> + <%= form.submit "Apply configuration", disabled: import.complete? %> <% end %> diff --git a/app/views/import/configurations/_mint_import.html.erb b/app/views/import/configurations/_mint_import.html.erb index e1271c4a..4fdbe698 100644 --- a/app/views/import/configurations/_mint_import.html.erb +++ b/app/views/import/configurations/_mint_import.html.erb @@ -1,7 +1,9 @@ <%# locals: (import:) %>
- <%= lucide_icon("check-circle", class: "w-5 h-5 shrink-0 text-green-500") %> + + <%= icon("check-circle", color: "current") %> +

We have pre-configured your Mint import for you. Please proceed to the next step.

@@ -29,5 +31,5 @@ <%= form.select :category_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Category (optional)" }, disabled: import.complete? %> <%= form.select :tags_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Tags (optional)" }, disabled: import.complete? %> - <%= form.submit "Apply configuration", class: "w-full btn btn--primary", disabled: import.complete? %> + <%= form.submit "Apply configuration", disabled: import.complete? %> <% end %> diff --git a/app/views/import/configurations/_trade_import.html.erb b/app/views/import/configurations/_trade_import.html.erb index 8231e9cf..007bf34b 100644 --- a/app/views/import/configurations/_trade_import.html.erb +++ b/app/views/import/configurations/_trade_import.html.erb @@ -36,5 +36,5 @@ <% end %>
- <%= form.submit "Apply configuration", class: "w-full btn btn--primary", disabled: import.complete? %> + <%= form.submit "Apply configuration", disabled: import.complete? %> <% end %> diff --git a/app/views/import/configurations/_transaction_import.html.erb b/app/views/import/configurations/_transaction_import.html.erb index 9181f97f..84301d69 100644 --- a/app/views/import/configurations/_transaction_import.html.erb +++ b/app/views/import/configurations/_transaction_import.html.erb @@ -107,7 +107,5 @@ import.csv_headers, { include_blank: "Leave empty", label: "Notes" } %> - <%= form.submit "Apply configuration", - class: "w-full btn btn--primary", - disabled: import.complete? %> + <%= form.submit "Apply configuration", disabled: import.complete? %> <% end %> diff --git a/app/views/import/configurations/show.html.erb b/app/views/import/configurations/show.html.erb index d061b692..89e53e07 100644 --- a/app/views/import/configurations/show.html.erb +++ b/app/views/import/configurations/show.html.erb @@ -18,8 +18,8 @@

We found a configuration from a previous import for this account. Would you like to apply it to this import?

- <%= link_to "Manually configure", import_configuration_path(@import), class: "btn btn--outline" %> - <%= button_to "Apply template", apply_template_import_path(@import), class: "btn btn--primary", method: :put, data: { turbo_frame: :_top } %> + <%= render LinkComponent.new(text: "Manually configure", href: import_configuration_path(@import), variant: "outline") %> + <%= render ButtonComponent.new(text: "Apply template", href: apply_template_import_path(@import), method: :put, data: { turbo_frame: :_top }) %>
diff --git a/app/views/import/confirms/_mappings.html.erb b/app/views/import/confirms/_mappings.html.erb index 76e353be..2fcb14ac 100644 --- a/app/views/import/confirms/_mappings.html.erb +++ b/app/views/import/confirms/_mappings.html.erb @@ -8,18 +8,29 @@ <% if import.requires_account? %>
-
+
<%= tag.p t(".no_accounts"), class: "text-sm" %> - <%= link_to t(".create_account"), new_account_path(return_to: import_confirm_path(import)), class: "btn btn--primary whitespace-nowrap", data: { turbo_frame: :modal } %> + + <%= render LinkComponent.new( + text: "Create account", + variant: "primary", + href: new_account_path(return_to: import_confirm_path(import)), + frame: :modal + ) %>
<% elsif import.has_unassigned_account? %>
-
+
<%= tag.p t(".unassigned_account"), class: "text-sm" %> - <%= link_to t(".create_account"), new_account_path(return_to: import_confirm_path(import)), class: "btn btn--primary whitespace-nowrap", data: { turbo_frame: :modal } %> + <%= render LinkComponent.new( + text: t(".create_account"), + variant: "primary", + href: new_account_path(return_to: import_confirm_path(import)), + frame: :modal + ) %>
@@ -29,7 +40,7 @@
-
+

<%= t(".csv_mapping_label", mapping: mapping_label(mapping_class)) %>

<%= t(".maybe_mapping_label", mapping: mapping_label(mapping_class)) %>

@@ -48,10 +59,14 @@
- <%= link_to is_last_step ? import_path(import) : url_for(step: step_idx + 2), class: "btn btn--primary w-full md:w-36 flex items-center justify-between gap-2" do %> - Next - <%= lucide_icon "arrow-right", class: "w-5 h-5" %> - <% end %> + <%= render LinkComponent.new( + text: "Next", + variant: "primary", + href: is_last_step ? import_path(import) : url_for(step: step_idx + 2), + icon: "arrow-right", + icon_position: "right", + class: "w-full md:w-auto" + ) %>
diff --git a/app/views/import/rows/_form.html.erb b/app/views/import/rows/_form.html.erb index 43521eb6..b5057d63 100644 --- a/app/views/import/rows/_form.html.erb +++ b/app/views/import/rows/_form.html.erb @@ -28,10 +28,10 @@ disabled: row.import.complete? %> <% if !cell_is_valid?(row, key) %> - - <%= lucide_icon "alert-circle", class: "w-4 h-4" %> + data-mobile-cell-interaction-target="errorIcon"> + <%= icon "alert-circle", size: "sm", color: "destructive" %> -
-
-
- - -
-
+ <%= render TabsComponent.new(active_tab: params[:tab] || "csv-upload", url_param_key: "tab", testid: "import-tabs") do |tabs| %> + <% tabs.with_nav do |nav| %> + <% nav.with_btn(id: "csv-upload", label: "Upload CSV") %> + <% nav.with_btn(id: "csv-paste", label: "Copy & Paste") %> + <% end %> - <% ["csv-paste-tab", "csv-upload-tab"].each do |tab| %> - <%= tag.div id: tab, data: { tabs_target: "tab" }, class: tab == "csv-upload-tab" ? "hidden" : "" do %> - <%= styled_form_with model: @import, scope: :import, url: import_upload_path(@import), multipart: true, class: "space-y-2" do |form| %> - <%= form.select :col_sep, Import::SEPARATORS, label: true %> + <% tabs.with_panel(tab_id: "csv-upload") do %> + <%= styled_form_with model: @import, scope: :import, url: import_upload_path(@import), multipart: true, class: "space-y-2" do |form| %> + <%= form.select :col_sep, Import::SEPARATORS, label: true %> - <% if @import.type == "TransactionImport" || @import.type == "TradeImport" %> - <%= form.select :account_id, @import.family.accounts.pluck(:name, :id), { label: "Account (optional)", include_blank: "Multi-account import", selected: @import.account_id } %> - <% end %> + <% if @import.type == "TransactionImport" || @import.type == "TradeImport" %> + <%= form.select :account_id, @import.family.accounts.pluck(:name, :id), { label: "Account (optional)", include_blank: "Multi-account import", selected: @import.account_id } %> + <% end %> - <% if tab == "csv-paste-tab" %> - <%= form.text_area :raw_file_str, +
+
+
+ <%= icon("plus", size: "lg", class: "mb-4 mx-auto") %> +

+ Browse to add your CSV file here +

+
+ + + + <%= form.file_field :csv_file, class: "hidden", "data-auto-submit-form-target": "auto", "data-file-upload-target": "input" %> +
+
+ + <%= form.submit "Upload CSV", disabled: @import.complete? %> + <% end %> + <% end %> + + <% tabs.with_panel(tab_id: "csv-paste") do %> + <%= styled_form_with model: @import, scope: :import, url: import_upload_path(@import), multipart: true, class: "space-y-2" do |form| %> + <%= form.select :col_sep, Import::SEPARATORS, label: true %> + + <% if @import.type == "TransactionImport" || @import.type == "TradeImport" %> + <%= form.select :account_id, @import.family.accounts.pluck(:name, :id), { label: "Account (optional)", include_blank: "Multi-account import", selected: @import.account_id } %> + <% end %> + + <%= form.text_area :raw_file_str, rows: 10, required: true, placeholder: "Paste your CSV file contents here", "data-auto-submit-form-target": "auto" %> - <% else %> -
-
-
- <%= lucide_icon("plus", class: "w-6 h-6 mb-4 text-secondary mx-auto") %> -

- Browse to add your CSV file here -

-
- - - <%= form.file_field :csv_file, class: "hidden", "data-auto-submit-form-target": "auto", "data-file-upload-target": "input" %> -
-
- <% end %> - - <%= form.submit "Upload CSV", disabled: @import.complete? %> - <% end %> + <%= form.submit "Upload CSV", disabled: @import.complete? %> <% end %> <% end %> -
+ <% end %>
-
- - - <%= link_to "Download a sample CSV", "/imports/#{@import.id}/upload/sample_csv", class: "text-primary underline", data: { turbo: false } %> to see the required CSV format - -
- +
+ + <%= link_to "Download a sample CSV", "/imports/#{@import.id}/upload/sample_csv", class: "text-primary underline", data: { turbo: false } %> to see the required CSV format + +
diff --git a/app/views/imports/_empty.html.erb b/app/views/imports/_empty.html.erb index 334ab4b6..8f147797 100644 --- a/app/views/imports/_empty.html.erb +++ b/app/views/imports/_empty.html.erb @@ -1,9 +1,13 @@

<%= t(".message") %>

- <%= link_to new_import_path, class: "btn btn--primary flex items-center gap-2", data: { turbo_frame: "modal" } do %> - <%= lucide_icon("plus", class: "w-5 h-5") %> - <%= t(".new") %> - <% end %> + + <%= render LinkComponent.new( + text: t(".new"), + variant: "primary", + href: new_import_path, + icon: "plus", + frame: :modal + ) %>
diff --git a/app/views/imports/_failure.html.erb b/app/views/imports/_failure.html.erb index 0ae9f2a5..533d4e52 100644 --- a/app/views/imports/_failure.html.erb +++ b/app/views/imports/_failure.html.erb @@ -3,7 +3,7 @@
- <%= lucide_icon "alert-octagon", class: "w-5 h-5 text-red-500" %> + <%= icon "alert-octagon", color: "destructive" %>
@@ -11,8 +11,6 @@

Please check that your file format, for any errors and that all required fields are filled, then come back and try again.

-
- <%= button_to "Try again", publish_import_path(import), class: "btn btn--primary text-center w-full" %> -
+ <%= render ButtonComponent.new(text: "Try again", href: publish_import_path(import), full_width: true) %>
diff --git a/app/views/imports/_import.html.erb b/app/views/imports/_import.html.erb index 2b84bd53..fca4764b 100644 --- a/app/views/imports/_import.html.erb +++ b/app/views/imports/_import.html.erb @@ -36,34 +36,30 @@ <% end %>
- <%= contextual_menu do %> -
- <%= link_to import_path(import), - class: "block w-full py-2 px-3 space-x-2 text-primary hover:bg-gray-50 flex items-center rounded-lg" do %> - <%= lucide_icon "eye", class: "w-5 h-5 text-secondary" %> + <%= render MenuComponent.new do |menu| %> + <% menu.with_item(variant: "link", text: t(".view"), href: import_path(import), icon: "eye") %> - <%= t(".view") %> - <% end %> + <% if import.complete? || import.revert_failed? %> + <% menu.with_item( + variant: "button", + text: t(".revert"), + href: revert_import_path(import), + icon: "rotate-ccw", + method: :put, + confirm: CustomConfirm.new( + title: "Revert import?", + body: "This will delete transactions that were imported, but you will still be able to review and re-import your data at any time.", + btn_text: "Revert" + )) %> - <% if import.complete? || import.revert_failed? %> - <%= button_to revert_import_path(import), - method: :put, - class: "block w-full py-2 px-3 space-x-2 text-orange-600 hover:bg-orange-50 flex items-center rounded-lg", - data: { turbo_confirm: true } do %> - <%= lucide_icon "rotate-ccw", class: "w-5 h-5" %> - - Revert - <% end %> - <% else %> - <%= button_to import_path(import), - method: :delete, - class: "block w-full py-2 px-3 space-x-2 text-red-600 hover:bg-red-50 flex items-center rounded-lg", - data: { turbo_confirm: true } do %> - <%= lucide_icon "trash-2", class: "w-5 h-5" %> - - <%= t(".delete") %> - <% end %> - <% end %> -
+ <% else %> + <% menu.with_item( + variant: "button", + text: t(".delete"), + href: import_path(import), + icon: "trash-2", + method: :delete, + confirm: CustomConfirm.for_resource_deletion("Import")) %> + <% end %> <% end %>
diff --git a/app/views/imports/_importing.html.erb b/app/views/imports/_importing.html.erb index df1c70a0..3815cc07 100644 --- a/app/views/imports/_importing.html.erb +++ b/app/views/imports/_importing.html.erb @@ -3,7 +3,7 @@
- <%= lucide_icon "loader", class: "animate-pulse w-5 h-5 text-secondary" %> + <%= icon "loader", class: "animate-pulse" %>
@@ -12,8 +12,8 @@
- <%= link_to "Check status", import_path(import), class: "block btn btn--primary text-center w-full" %> - <%= link_to "Back to dashboard", root_path, class: "block btn btn--secondary text-center w-full" %> + <%= render LinkComponent.new(text: "Check status", href: import_path(import), variant: "primary") %> + <%= render LinkComponent.new(text: "Back to dashboard", href: root_path, variant: "secondary") %>
diff --git a/app/views/imports/_nav.html.erb b/app/views/imports/_nav.html.erb index 3353e15f..eb8f5c08 100644 --- a/app/views/imports/_nav.html.erb +++ b/app/views/imports/_nav.html.erb @@ -36,7 +36,7 @@ <%= link_to step[:path], class: "flex items-center gap-3" do %>
- <%= step[:is_complete] && !is_current ? lucide_icon("check", class: "w-4 h-4") : idx + 1 %> + <%= step[:is_complete] && !is_current ? icon("check", size: "sm") : idx + 1 %> <%= step[:name] %> diff --git a/app/views/imports/_ready.html.erb b/app/views/imports/_ready.html.erb index fbbf4a1d..225c0dd1 100644 --- a/app/views/imports/_ready.html.erb +++ b/app/views/imports/_ready.html.erb @@ -18,9 +18,9 @@
-
- <%= lucide_icon resource.icon, class: "#{resource.text_class} w-5 h-5 shrink-0" %> -
+ <%= tag.div class: class_names(resource.bg_class, resource.text_class, "w-8 h-8 rounded-full flex justify-center items-center") do %> + <%= icon resource.icon, color: "current" %> + <% end %>

<%= resource.label %>

@@ -35,5 +35,5 @@
- <%= button_to "Publish import", publish_import_path(import), class: "btn btn--primary w-full" %> + <%= render ButtonComponent.new(text: "Publish import", href: publish_import_path(import), full_width: true) %>
diff --git a/app/views/imports/_revert_failure.html.erb b/app/views/imports/_revert_failure.html.erb index 6566648d..6eacd786 100644 --- a/app/views/imports/_revert_failure.html.erb +++ b/app/views/imports/_revert_failure.html.erb @@ -3,7 +3,7 @@
- <%= lucide_icon "alert-octagon", class: "w-5 h-5 text-red-500" %> + <%= icon "alert-octagon", color: "destructive" %>
@@ -11,8 +11,10 @@

Please try again or contact support.

-
- <%= button_to "Try again", revert_import_path(import), class: "btn btn--primary text-center w-full" %> -
+ <%= render ButtonComponent.new( + text: "Try again", + full_width: true, + href: revert_import_path(import) + ) %>
diff --git a/app/views/imports/_success.html.erb b/app/views/imports/_success.html.erb index 4c58e07b..a1130c0d 100644 --- a/app/views/imports/_success.html.erb +++ b/app/views/imports/_success.html.erb @@ -3,7 +3,7 @@
- <%= lucide_icon "check", class: "w-5 h-5 text-green-500" %> + <%= icon "check", color: "success" %>
@@ -11,8 +11,11 @@

Your imported data has been successfully added to the app and is now ready for use.

-
- <%= link_to "Back to dashboard", root_path, class: "block btn btn--primary text-center w-full" %> -
+ <%= render LinkComponent.new( + text: "Back to dashboard", + variant: "primary", + full_width: true, + href: root_path + ) %>
diff --git a/app/views/imports/index.html.erb b/app/views/imports/index.html.erb index 1a87e72c..108f3dc5 100644 --- a/app/views/imports/index.html.erb +++ b/app/views/imports/index.html.erb @@ -1,10 +1,13 @@

<%= t(".title") %>

- <%= link_to new_import_path, class: "btn btn--primary flex items-center gap-2", data: { turbo_frame: :modal } do %> - <%= lucide_icon("plus", class: "w-5 h-5") %> - <%= t(".new") %> - <% end %> + <%= render LinkComponent.new( + text: "New import", + href: new_import_path, + icon: "plus", + variant: "primary", + frame: :modal + ) %>
diff --git a/app/views/imports/new.html.erb b/app/views/imports/new.html.erb index 92794acb..ac739056 100644 --- a/app/views/imports/new.html.erb +++ b/app/views/imports/new.html.erb @@ -1,16 +1,7 @@ -<%= modal do %> -
-
-
-

<%= t(".title") %>

- -
- -

<%= t(".description") %>

-
+<%= render DialogComponent.new do |dialog| %> + <% dialog.with_header(title: t(".title"), subtitle: t(".description")) %> + <% dialog.with_body do %>

<%= t(".sources") %>

    @@ -19,13 +10,15 @@ <%= link_to import_path(@pending_import), class: "flex items-center justify-between p-4 group cursor-pointer", data: { turbo: false } do %>
    - <%= lucide_icon("loader", class: "w-5 h-5 text-orange-500") %> + + <%= icon("loader", color: "current") %> +
    <%= t(".resume", type: @pending_import.type.titleize) %>
    - <%= lucide_icon("chevron-right", class: "w-5 h-5 text-secondary") %> + <%= icon("chevron-right") %> <% end %>
    @@ -39,13 +32,15 @@ <%= button_to imports_path(import: { type: "TransactionImport" }), class: "flex items-center justify-between p-4 group cursor-pointer w-full", data: { turbo: false } do %>
    - <%= lucide_icon("file-spreadsheet", class: "w-5 h-5 text-indigo-500") %> + + <%= icon("file-spreadsheet", color: "current") %> +
    <%= t(".import_transactions") %>
    - <%= lucide_icon("chevron-right", class: "w-5 h-5 text-secondary") %> + <%= icon("chevron-right") %> <% end %>
    @@ -59,13 +54,15 @@ <%= button_to imports_path(import: { type: "TradeImport" }), class: "flex items-center justify-between p-4 group cursor-pointer w-full", data: { turbo: false } do %>
    - <%= lucide_icon("square-percent", class: "w-5 h-5 text-yellow-500") %> + + <%= icon("square-percent", color: "current") %> +
    <%= t(".import_portfolio") %>
    - <%= lucide_icon("chevron-right", class: "w-5 h-5 text-secondary") %> + <%= icon("chevron-right") %> <% end %>
    @@ -79,13 +76,15 @@ <%= button_to imports_path(import: { type: "AccountImport" }), class: "flex items-center justify-between p-4 group cursor-pointer w-full", data: { turbo: false } do %>
    - <%= lucide_icon("building", class: "w-5 h-5 text-violet-500") %> + + <%= icon("building", color: "current") %> +
    <%= t(".import_accounts") %>
    - <%= lucide_icon("chevron-right", class: "w-5 h-5 text-secondary") %> + <%= icon("chevron-right") %> <% end %>
    @@ -103,7 +102,7 @@ <%= t(".import_mint") %>
    - <%= lucide_icon("chevron-right", class: "w-5 h-5 text-secondary") %> + <%= icon("chevron-right") %> <% end %>
    @@ -113,5 +112,5 @@ <% end %>
-
+ <% end %> <% end %> diff --git a/app/views/investments/_value_tooltip.html.erb b/app/views/investments/_value_tooltip.html.erb index c311474a..62615d76 100644 --- a/app/views/investments/_value_tooltip.html.erb +++ b/app/views/investments/_value_tooltip.html.erb @@ -1,7 +1,7 @@ <%# locals: (balance:, holdings:, cash:) %>
- <%= lucide_icon("info", class: "w-4 h-4 shrink-0 text-secondary") %> + <%= icon("info", size: "sm") %> diff --git a/app/views/invite_codes/index.html.erb b/app/views/invite_codes/index.html.erb index ad27bc18..467037c4 100644 --- a/app/views/invite_codes/index.html.erb +++ b/app/views/invite_codes/index.html.erb @@ -4,7 +4,7 @@ <%= render @invite_codes %> <% else %>
- <%= lucide_icon "binary", class: "w-6 h-6 text-sm text-secondary" %> + <%= icon "binary", size: "lg" %>

<%= t(".no_invite_codes") %>

<%= t(".invite_code_description") %>

diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 846a656c..42275c90 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -1,135 +1,140 @@ +<% mobile_nav_items = [ + { name: "Home", path: root_path, icon: "pie-chart", icon_custom: false, active: page_active?(root_path) }, + { name: "Transactions", path: transactions_path, icon: "credit-card", icon_custom: false, active: page_active?(transactions_path) }, + { name: "Budgets", path: budgets_path, icon: "map", icon_custom: false, active: page_active?(budgets_path) }, + { name: "Assistant", path: chats_path, icon: "icon-assistant", icon_custom: true, active: page_active?(chats_path), mobile_only: true } +] %> + +<% desktop_nav_items = mobile_nav_items.reject { |item| item[:mobile_only] } %> +<% expanded_sidebar_class = "w-full" %> +<% collapsed_sidebar_class = "w-0" %> + <%= render "layouts/shared/htmldoc" do %> - <% sidebar_config = app_sidebar_config(Current.user) %> +
+ <% end %> diff --git a/app/views/layouts/imports.html.erb b/app/views/layouts/imports.html.erb index 8acf9c6c..6b73ad67 100644 --- a/app/views/layouts/imports.html.erb +++ b/app/views/layouts/imports.html.erb @@ -1,17 +1,21 @@ <%= render "layouts/shared/htmldoc" do %>
- <%= link_to content_for(:previous_path) || imports_path do %> - <%= lucide_icon "arrow-left", class: "w-5 h-5 text-secondary" %> - <% end %> + <%= render LinkComponent.new( + variant: "icon", + icon: "arrow-left", + href: content_for(:previous_path) || imports_path + ) %> - <%= link_to imports_path do %> - <%= lucide_icon "x", class: "text-secondary w-5 h-5" %> - <% end %> + <%= render LinkComponent.new( + variant: "icon", + icon: "x", + href: imports_path + ) %>
diff --git a/app/views/layouts/lookbooks.html.erb b/app/views/layouts/lookbooks.html.erb new file mode 100644 index 00000000..de1b9dc9 --- /dev/null +++ b/app/views/layouts/lookbooks.html.erb @@ -0,0 +1,14 @@ + + + + Component Preview + <%= csrf_meta_tags %> + <%= csp_meta_tag %> + <%= stylesheet_link_tag "tailwind", "data-turbo-track": "reload" %> + <%= javascript_include_tag "application", "data-turbo-track": "reload", defer: true %> + <%= javascript_importmap_tags %> + + + <%= yield %> + + diff --git a/app/views/layouts/settings.html.erb b/app/views/layouts/settings.html.erb index 30e248c1..e4e68784 100644 --- a/app/views/layouts/settings.html.erb +++ b/app/views/layouts/settings.html.erb @@ -10,7 +10,7 @@ <% if content_for?(:breadcrumbs) %> <%= yield :breadcrumbs %> <% else %> - <%= render "layouts/shared/breadcrumbs", breadcrumbs: @breadcrumbs, sidebar_toggle_enabled: false %> + <%= render "layouts/shared/breadcrumbs", breadcrumbs: @breadcrumbs %> <% end %> <% if content_for?(:page_title) %> diff --git a/app/views/layouts/shared/_breadcrumbs.html.erb b/app/views/layouts/shared/_breadcrumbs.html.erb index a9d41928..665fa4be 100644 --- a/app/views/layouts/shared/_breadcrumbs.html.erb +++ b/app/views/layouts/shared/_breadcrumbs.html.erb @@ -1,33 +1,17 @@ -<%# locals: (breadcrumbs:, sidebar_toggle_enabled: true) %> +<%# locals: (breadcrumbs:) %> - +
diff --git a/app/views/layouts/shared/_confirm_dialog.html.erb b/app/views/layouts/shared/_confirm_dialog.html.erb new file mode 100644 index 00000000..4709ffb4 --- /dev/null +++ b/app/views/layouts/shared/_confirm_dialog.html.erb @@ -0,0 +1,30 @@ +<%# This dialog is used as an override to the browser's confirm API when submitting forms with data-turbo-confirm %> +<%# See confirm_dialog_controller.js and _htmldoc.html.erb %> +<%= render DialogComponent.new(id: "confirm-dialog", auto_open: false, data: { controller: "confirm-dialog" }, width: "sm") do |dialog| %> + <% dialog.with_body do %> +
+
+
+

Are you sure?

+ <%= icon("x", as_button: true, type: "submit", value: "cancel") %> +
+ +

This action cannot be undone.

+
+ +
+ <% ["primary", "outline-destructive", "destructive"].each do |variant| %> + <%= render ButtonComponent.new( + text: "Confirm", + variant: variant, + autofocus: true, + full_width: true, + value: "confirm", + data: { variant: variant, confirm_dialog_target: "confirmButton" }, + hidden: true, + ) %> + <% end %> +
+
+ <% end %> +<% end %> diff --git a/app/views/layouts/shared/_fixed_content.html.erb b/app/views/layouts/shared/_fixed_content.html.erb deleted file mode 100644 index 88ace0d3..00000000 --- a/app/views/layouts/shared/_fixed_content.html.erb +++ /dev/null @@ -1,6 +0,0 @@ -<%= turbo_frame_tag "modal" %> -<%= turbo_frame_tag "drawer" %> -<%= render "shared/confirm_modal" %> - -<%= render "impersonation_sessions/super_admin_bar" if Current.true_user&.super_admin? && show_super_admin_bar? %> -<%= render "impersonation_sessions/approval_bar" if Current.true_user&.impersonated_support_sessions&.initiated&.any? %> diff --git a/app/views/layouts/shared/_htmldoc.html.erb b/app/views/layouts/shared/_htmldoc.html.erb index 3643637d..efb89615 100644 --- a/app/views/layouts/shared/_htmldoc.html.erb +++ b/app/views/layouts/shared/_htmldoc.html.erb @@ -1,5 +1,5 @@ -"> +"> <%= render "layouts/shared/head" %> <%= yield :head %> @@ -20,9 +20,22 @@ <%= family_stream %> + <% if Rails.env.development? %> +
+ <%= icon("eclipse", as_button: true, data: { action: "theme#toDark" }) %> + <%= icon("sun", as_button: true, data: { action: "theme#toLight" }) %> +
+ <% end %> + + <% if require_upgrade? %> + <%= render "shared/subscribe_modal" %> + <% end %> + <%= turbo_frame_tag "modal" %> <%= turbo_frame_tag "drawer" %> - <%= render "shared/confirm_modal" %> + + <%# Custom overrides for browser's confirm API %> + <%= render "layouts/shared/confirm_dialog" %> <%= render "impersonation_sessions/super_admin_bar" if Current.true_user&.super_admin? && show_super_admin_bar? %> <%= render "impersonation_sessions/approval_bar" if Current.true_user&.impersonated_support_sessions&.initiated&.any? %> diff --git a/app/views/layouts/shared/_nav_item.html.erb b/app/views/layouts/shared/_nav_item.html.erb new file mode 100644 index 00000000..8c5fd683 --- /dev/null +++ b/app/views/layouts/shared/_nav_item.html.erb @@ -0,0 +1,20 @@ +<%# locals:(name:, path:, icon:, icon_custom:, active:, mobile_only: false) %> + +<%= link_to path, class: "space-y-1 group block relative pb-1" do %> +
+ <%= tag.div class: class_names("w-4 h-1 lg:w-1 lg:h-4 rounded-bl-sm rounded-br-sm lg:rounded-tr-sm lg:rounded-br-sm lg:rounded-bl-none", "bg-nav-indicator" => active) %> + + <%= tag.div class: class_names( + "w-8 h-8 flex items-center justify-center mx-auto rounded-lg", + active ? "bg-container shadow-xs text-primary" : "group-hover:bg-container-hover text-secondary" + ) do %> + <%= icon(icon, color: active ? "current" : "default", custom: icon_custom) %> + <% end %> +
+ +
+ <%= tag.p class: class_names("font-medium text-[11px]", active ? "text-primary" : "text-secondary") do %> + <%= name %> + <% end %> +
+<% end %> diff --git a/app/views/layouts/sidebar/_nav_item.html.erb b/app/views/layouts/sidebar/_nav_item.html.erb deleted file mode 100644 index dd8661af..00000000 --- a/app/views/layouts/sidebar/_nav_item.html.erb +++ /dev/null @@ -1,18 +0,0 @@ -<%# locals: (name:, path:, icon_key:, is_custom: false) %> -<%= link_to path, class: "space-y-1 group block relative pb-1" do %> -
- <%= tag.div class: class_names("w-4 h-1 lg:w-1 lg:h-4 rounded-bl-sm rounded-br-sm lg:rounded-tr-sm lg:rounded-br-sm lg:rounded-bl-none", "bg-nav-indicator" => page_active?(path)) %> - - <% icon_color = page_active?(path) ? "current" : "gray" %> - - <%= tag.div class: class_names("w-8 h-8 flex items-center justify-center mx-auto rounded-lg", page_active?(path) ? "bg-container shadow-xs text-primary" : "group-hover:bg-container-hover text-secondary") do %> - <%= is_custom ? icon_custom(icon_key, color: icon_color) : icon(icon_key, color: icon_color) %> - <% end %> -
- -
- <%= tag.p class: class_names("font-medium text-[11px]", page_active?(path) ? "text-primary" : "text-secondary") do %> - <%= name %> - <% end %> -
-<% end %> diff --git a/app/views/layouts/wizard.html.erb b/app/views/layouts/wizard.html.erb index a188fe60..1be2229d 100644 --- a/app/views/layouts/wizard.html.erb +++ b/app/views/layouts/wizard.html.erb @@ -1,17 +1,21 @@ <%= render "layouts/shared/htmldoc" do %>
- <%= link_to content_for(:previous_path) || root_path do %> - <%= lucide_icon "arrow-left", class: "w-5 h-5 text-secondary" %> - <% end %> + <%= render LinkComponent.new( + variant: "icon", + icon: "arrow-left", + href: content_for(:previous_path) || root_path + ) %> - <%= link_to content_for(:cancel_path) || root_path do %> - <%= lucide_icon "x", class: "text-secondary w-5 h-5" %> - <% end %> + <%= render LinkComponent.new( + variant: "icon", + icon: "x", + href: content_for(:cancel_path) || root_path + ) %>
diff --git a/app/views/loans/_overview.html.erb b/app/views/loans/_overview.html.erb index bbeccb6e..f04ccc49 100644 --- a/app/views/loans/_overview.html.erb +++ b/app/views/loans/_overview.html.erb @@ -45,5 +45,10 @@
- <%= link_to "Edit loan details", edit_loan_path(account), class: "btn btn--ghost", data: { turbo_frame: :modal } %> + <%= render LinkComponent.new( + text: "Edit loan details", + variant: "ghost", + href: edit_loan_path(account), + frame: :modal + ) %>
diff --git a/app/views/loans/edit.html.erb b/app/views/loans/edit.html.erb index 5fb3b13e..f9a56f12 100644 --- a/app/views/loans/edit.html.erb +++ b/app/views/loans/edit.html.erb @@ -1,3 +1,6 @@ -<%= modal_form_wrapper title: t(".edit", account: @account.name) do %> - <%= render "form", account: @account, url: loan_path(@account) %> +<%= render DialogComponent.new do |dialog| %> + <% dialog.with_header(title: t(".edit", account: @account.name)) %> + <% dialog.with_body do %> + <%= render "form", account: @account, url: loan_path(@account) %> + <% end %> <% end %> diff --git a/app/views/loans/new.html.erb b/app/views/loans/new.html.erb index b09a6b32..de4c9575 100644 --- a/app/views/loans/new.html.erb +++ b/app/views/loans/new.html.erb @@ -1,7 +1,10 @@ <% if params[:step] == "method_select" %> <%= render "accounts/new/method_selector", path: new_loan_path(return_to: params[:return_to]), us_link_token: @us_link_token, eu_link_token: @eu_link_token %> <% else %> - <%= modal_form_wrapper title: t(".title") do %> - <%= render "loans/form", account: @account, url: loans_path %> + <%= render DialogComponent.new do |dialog| %> + <% dialog.with_header(title: t(".title")) %> + <% dialog.with_body do %> + <%= render "loans/form", account: @account, url: loans_path %> + <% end %> <% end %> <% end %> diff --git a/app/views/merchants/_merchant.html.erb b/app/views/merchants/_merchant.html.erb index 6c930ead..964a7fff 100644 --- a/app/views/merchants/_merchant.html.erb +++ b/app/views/merchants/_merchant.html.erb @@ -15,19 +15,15 @@

- <%= contextual_menu do %> -
- <%= contextual_menu_modal_action_item t(".edit"), edit_merchant_path(merchant) %> - - <%= contextual_menu_destructive_item t(".delete"), - merchant_path(merchant), - turbo_frame: "_top", - turbo_confirm: merchant.transactions.any? ? { - title: t(".confirm_title"), - body: t(".confirm_body"), - accept: t(".confirm_accept") - } : nil %> -
+ <%= render MenuComponent.new do |menu| %> + <% menu.with_item(variant: "link", text: t(".edit"), href: edit_merchant_path(merchant), icon: "pencil", data: { turbo_frame: "modal" }) %> + <% menu.with_item( + variant: "button", + text: t(".delete"), + href: merchant_path(merchant), + icon: "trash-2", + method: :delete, + confirm: CustomConfirm.for_resource_deletion(merchant.name)) %> <% end %>
diff --git a/app/views/messages/_chat_form.html.erb b/app/views/messages/_chat_form.html.erb index 10c35e5a..0c536c94 100644 --- a/app/views/messages/_chat_form.html.erb +++ b/app/views/messages/_chat_form.html.erb @@ -19,15 +19,11 @@ - + <%= icon("arrow-up", as_button: true, type: "submit") %>
<% end %> diff --git a/app/views/mfa/backup_codes.html.erb b/app/views/mfa/backup_codes.html.erb index b27e78d4..e4684030 100644 --- a/app/views/mfa/backup_codes.html.erb +++ b/app/views/mfa/backup_codes.html.erb @@ -19,9 +19,12 @@ <% end %>
-
- <%= link_to t(".continue"), settings_security_path, class: "w-full btn btn--primary" %> -
+ <%= render LinkComponent.new( + text: t(".continue"), + href: settings_security_path, + variant: "primary", + full_width: true + ) %>
<% end %> diff --git a/app/views/mfa/new.html.erb b/app/views/mfa/new.html.erb index 0726bf1b..c8ad0892 100644 --- a/app/views/mfa/new.html.erb +++ b/app/views/mfa/new.html.erb @@ -29,10 +29,10 @@ class="text-sm bg-container px-2 py-1 rounded border border-secondary w-96 font-mono">
@@ -57,7 +57,7 @@ placeholder: t(".code_placeholder") %>
- <%= f.submit t(".verify_button"), class: "btn btn--primary" %> + <%= f.submit t(".verify_button") %>
<% end %> diff --git a/app/views/onboardings/_header.html.erb b/app/views/onboardings/_header.html.erb index 6f671662..14dabc5e 100644 --- a/app/views/onboardings/_header.html.erb +++ b/app/views/onboardings/_header.html.erb @@ -1,7 +1,7 @@
<%= image_tag "logo.svg", class: "h-[22px]" %>
- <%= lucide_icon "log-in", class: "w-5 h-5 shrink-0 text-secondary gap-2" %> + <%= icon("log-in", color: "secondary") %> <%= button_to t(".sign_out"), session_path(Current.session), method: :delete, class: "text-sm text-primary font-medium" %>
diff --git a/app/views/onboardings/preferences.html.erb b/app/views/onboardings/preferences.html.erb index 0af8e27f..95e701dd 100644 --- a/app/views/onboardings/preferences.html.erb +++ b/app/views/onboardings/preferences.html.erb @@ -69,7 +69,7 @@ { data: { action: "onboarding#setLocale" } } %> <%= family_form.select :currency, - currencies_for_select.map { |currency| [ "#{currency.name} (#{currency.iso_code})", currency.iso_code ] }, + Money::Currency.as_options.map { |currency| [ "#{currency.name} (#{currency.iso_code})", currency.iso_code ] }, { label: t(".currency"), required: true, selected: params[:currency] || @user.family.currency }, { data: { action: "onboarding#setCurrency" } } %> diff --git a/app/views/onboardings/show.html.erb b/app/views/onboardings/show.html.erb index c4af9793..542691a3 100644 --- a/app/views/onboardings/show.html.erb +++ b/app/views/onboardings/show.html.erb @@ -5,7 +5,12 @@ <%= tag.h1 t(".title"), class: "text-3xl font-medium mb-2" %> <%= tag.p t(".message"), class: "text-sm text-secondary mb-6" %> - <%= link_to t(".setup"), profile_onboarding_path, class: "block flex justify-center items-center btn btn--primary w-full" %> + <%= render LinkComponent.new( + text: t(".setup"), + href: profile_onboarding_path, + variant: "primary", + full_width: true + ) %> diff --git a/app/views/other_assets/edit.html.erb b/app/views/other_assets/edit.html.erb index 4c38d223..271982fc 100644 --- a/app/views/other_assets/edit.html.erb +++ b/app/views/other_assets/edit.html.erb @@ -1,3 +1,6 @@ -<%= modal_form_wrapper title: t(".edit", account: @account.name) do %> - <%= render "other_assets/form", account: @account, url: other_asset_path(@account) %> +<%= render DialogComponent.new do |dialog| %> + <% dialog.with_header(title: t(".edit", account: @account.name)) %> + <% dialog.with_body do %> + <%= render "form", account: @account, url: other_asset_path(@account) %> + <% end %> <% end %> diff --git a/app/views/other_assets/new.html.erb b/app/views/other_assets/new.html.erb index bff7face..106b2994 100644 --- a/app/views/other_assets/new.html.erb +++ b/app/views/other_assets/new.html.erb @@ -1,3 +1,6 @@ -<%= modal_form_wrapper title: t(".title") do %> - <%= render "other_assets/form", account: @account, url: other_assets_path(return_to: params[:return_to]) %> +<%= render DialogComponent.new do |dialog| %> + <% dialog.with_header(title: t(".title")) %> + <% dialog.with_body do %> + <%= render "form", account: @account, url: other_assets_path %> + <% end %> <% end %> diff --git a/app/views/other_liabilities/edit.html.erb b/app/views/other_liabilities/edit.html.erb index 4473faff..8cc6c1be 100644 --- a/app/views/other_liabilities/edit.html.erb +++ b/app/views/other_liabilities/edit.html.erb @@ -1,3 +1,6 @@ -<%= modal_form_wrapper title: t(".edit", account: @account.name) do %> - <%= render "form", account: @account, url: other_liability_path(@account) %> +<%= render DialogComponent.new do |dialog| %> + <% dialog.with_header(title: t(".edit", account: @account.name)) %> + <% dialog.with_body do %> + <%= render "form", account: @account, url: other_liability_path(@account) %> + <% end %> <% end %> diff --git a/app/views/other_liabilities/new.html.erb b/app/views/other_liabilities/new.html.erb index 364a5ecf..f8a1ab46 100644 --- a/app/views/other_liabilities/new.html.erb +++ b/app/views/other_liabilities/new.html.erb @@ -1,3 +1,6 @@ -<%= modal_form_wrapper title: t(".title") do %> - <%= render "other_liabilities/form", account: @account, url: other_liabilities_path(return_to: params[:return_to]) %> +<%= render DialogComponent.new do |dialog| %> + <% dialog.with_header(title: t(".title")) %> + <% dialog.with_body do %> + <%= render "form", account: @account, url: other_liabilities_path %> + <% end %> <% end %> diff --git a/app/views/pages/dashboard.html.erb b/app/views/pages/dashboard.html.erb index 9d0e21f7..298e48e3 100644 --- a/app/views/pages/dashboard.html.erb +++ b/app/views/pages/dashboard.html.erb @@ -1,24 +1,38 @@ <% content_for :page_header do %> -
+

Welcome back, <%= Current.user.first_name %>

Here's what's happening with your finances

- <%= link_to new_account_path(step: "method_select", classification: "asset"), - class: "btn btn--primary flex items-center justify-center gap-2 rounded-full w-9 h-9 md:hidden", - data: { turbo_frame: "modal" } do %> - - <%= lucide_icon("plus", class: "size-5") %> - - <% end %> + <%= render LinkComponent.new( + icon: "plus", + text: "New", + href: new_account_path, + frame: :modal, + class: "hidden lg:inline-flex" + ) %> + + <%= render LinkComponent.new( + variant: "icon-inverse", + icon: "plus", + href: new_account_path, + frame: :modal, + class: "rounded-full lg:hidden" + ) %>
<% end %>
-
- <%= render partial: "pages/dashboard/net_worth_chart", locals: { series: @balance_sheet.net_worth_series(period: @period), period: @period } %> -
+ <% if Current.family.accounts.any? %> +
+ <%= render partial: "pages/dashboard/net_worth_chart", locals: { series: @balance_sheet.net_worth_series(period: @period), period: @period } %> +
+ <% else %> +
+ <%= render "pages/dashboard/no_accounts_graph_placeholder" %> +
+ <% end %>
<%= render "pages/dashboard/balance_sheet", balance_sheet: @balance_sheet %> diff --git a/app/views/pages/dashboard/_balance_sheet.html.erb b/app/views/pages/dashboard/_balance_sheet.html.erb index 0c76d046..e5383278 100644 --- a/app/views/pages/dashboard/_balance_sheet.html.erb +++ b/app/views/pages/dashboard/_balance_sheet.html.erb @@ -1,9 +1,19 @@ <%# locals: (balance_sheet:) %> -
+
<% balance_sheet.classification_groups.each do |classification_group| %>
-

<%= classification_group.display_name %>

+

+ + <%= classification_group.display_name %> + + + <% if classification_group.account_groups.any? %> + · + + <%= classification_group.total_money.format(precision: 0) %> + <% end %> +

<% if classification_group.account_groups.any? %>
@@ -23,9 +33,9 @@
-
+
- +
Name

Weight

@@ -36,33 +46,22 @@
-
+
<% classification_group.account_groups.each do |account_group| %>
-
- + <%= icon( + "trash-2", + as_button: true, + size: "sm", + data: { action: "rule--conditions#remove", rule__conditions_destroy_param: condition.persisted? } + ) %> diff --git a/app/views/rule/conditions/_condition_group.html.erb b/app/views/rule/conditions/_condition_group.html.erb index e00df533..67b3eb0f 100644 --- a/app/views/rule/conditions/_condition_group.html.erb +++ b/app/views/rule/conditions/_condition_group.html.erb @@ -19,9 +19,12 @@

of the following conditions

- + <%= icon( + "trash-2", + size: "sm", + as_button: true, + data: { action: "element-removal#remove" } + ) %>
<%# Sub-condition template, used by Stimulus controller to add new sub-conditions dynamically %> @@ -37,8 +40,10 @@ <% end %> - + <%= render ButtonComponent.new( + text: "Add condition", + leading_icon: "plus", + variant: "ghost", + data: { action: "rule--conditions#addSubCondition" } + ) %> diff --git a/app/views/rules/_category_rule_cta.html.erb b/app/views/rules/_category_rule_cta.html.erb index 54f2b752..66996044 100644 --- a/app/views/rules/_category_rule_cta.html.erb +++ b/app/views/rules/_category_rule_cta.html.erb @@ -13,8 +13,9 @@ <%= f.hidden_field :rule_prompt_dismissed_at, value: Time.current %> <%= tag.div class:"flex gap-2 justify-end" do %> - <%= f.submit "Dismiss", class: "btn btn--secondary" %> - <%= tag.a "Create rule", href: new_rule_path(resource_type: "transaction", action_type: "set_transaction_category", action_value: cta[:category_id]), class: "btn btn--primary", data: { turbo_frame: "modal" } %> + <%= render ButtonComponent.new(text: "Dismiss", variant: "secondary") %> + <% rule_href = new_rule_path(resource_type: "transaction", action_type: "set_transaction_category", action_value: cta[:category_id]) %> + <%= render LinkComponent.new(text: "Create rule", variant: "primary", href: rule_href, frame: :modal) %> <% end %> <% end %> <% end %> diff --git a/app/views/rules/_form.html.erb b/app/views/rules/_form.html.erb index 5a03eff6..79c0b45e 100644 --- a/app/views/rules/_form.html.erb +++ b/app/views/rules/_form.html.erb @@ -1,6 +1,6 @@ <%# locals: (rule:) %> -<%= styled_form_with model: rule, class: "space-y-4 w-[550px]", +<%= styled_form_with model: rule, class: "space-y-4", data: { controller: "rules", rule_registry_value: rule.registry.to_json } do |f| %> <%= f.hidden_field :resource_type, value: rule.resource_type %> @@ -37,15 +37,8 @@
- - - + <%= render ButtonComponent.new(text: "Add condition", icon: "plus", variant: "ghost", type: "button", data: { action: "rules#addCondition" }) %> + <%= render ButtonComponent.new(text: "Add condition group", icon: "boxes", variant: "ghost", type: "button", data: { action: "rules#addConditionGroup" }) %>
@@ -65,13 +58,7 @@ <% end %> - + <%= render ButtonComponent.new(text: "Add action", icon: "plus", variant: "ghost", type: "button", data: { action: "rules#addAction" }) %>
diff --git a/app/views/rules/_rule.html.erb b/app/views/rules/_rule.html.erb index 2e433c72..0a19d9b3 100644 --- a/app/views/rules/_rule.html.erb +++ b/app/views/rules/_rule.html.erb @@ -42,20 +42,20 @@
- <%= render "shared/toggle_form", model: rule, attribute: :active %> + <%= styled_form_with model: rule, data: { controller: "auto-submit-form" } do |f| %> + <%= f.toggle :active, { data: { auto_submit_form_target: "auto" } } %> + <% end %> - <%= contextual_menu icon: "more-vertical", id: "chat-menu" do %> - <%= contextual_menu_item "Edit", url: edit_rule_path(rule), icon: "pencil", turbo_frame: "modal" %> - - <%= contextual_menu_item "Re-apply rule", url: confirm_rule_path(rule), turbo_frame: "modal", icon: "refresh-cw" %> - - <% turbo_confirm = { - title: "Delete rule", - body: "Are you sure you want to delete this rule? Data affected by this rule will no longer be automatically updated. This action cannot be undone.", - accept: "Delete rule", - } %> - - <%= contextual_menu_destructive_item "Delete", rule_path(rule), turbo_confirm: turbo_confirm %> + <%= render MenuComponent.new do |menu| %> + <% menu.with_item(variant: "link", text: "Edit", href: edit_rule_path(rule), icon: "pencil", data: { turbo_frame: "modal" }) %> + <% menu.with_item(variant: "link", text: "Re-apply rule", href: confirm_rule_path(rule), icon: "refresh-cw", data: { turbo_frame: "modal" }) %> + <% menu.with_item( + variant: "button", + text: "Delete", + href: rule_path(rule), + icon: "trash-2", + method: :delete, + confirm: CustomConfirm.for_resource_deletion("Rule")) %> <% end %>
diff --git a/app/views/rules/confirm.html.erb b/app/views/rules/confirm.html.erb index 4749dea8..987a7fa9 100644 --- a/app/views/rules/confirm.html.erb +++ b/app/views/rules/confirm.html.erb @@ -1,20 +1,18 @@ -<%= modal(reload_on_close: true) do %> -
-
-
-

Confirm changes

- -
-
-

- You are about to apply this rule to - <%= @rule.affected_resource_count %> <%= @rule.resource_type.pluralize %> - that meet the specified rule criteria. Please confirm if you wish to proceed with this change. -

-
-
- <%= button_to "Confirm changes", apply_rule_path(@rule), class: "btn btn--primary w-full justify-center", data: { turbo_frame: "_top"} %> -
+<%= render DialogComponent.new(reload_on_close: true) do |dialog| %> + <% dialog.with_header(title: "Confirm changes") %> + + <% dialog.with_body do %> +

+ You are about to apply this rule to + <%= @rule.affected_resource_count %> <%= @rule.resource_type.pluralize %> + that meet the specified rule criteria. Please confirm if you wish to proceed with this change. +

+ + <%= render ButtonComponent.new( + text: "Confirm changes", + href: apply_rule_path(@rule), + method: :post, + full_width: true, + data: { turbo_frame: "_top" }) %> + <% end %> <% end %> diff --git a/app/views/rules/edit.html.erb b/app/views/rules/edit.html.erb index e5693fa2..6693ac5e 100644 --- a/app/views/rules/edit.html.erb +++ b/app/views/rules/edit.html.erb @@ -1,5 +1,8 @@ <%= link_to "Back to rules", rules_path %> -<%= modal_form_wrapper title: "Edit #{@rule.resource_type} rule" do %> - <%= render "rules/form", rule: @rule %> +<%= render DialogComponent.new do |dialog| %> + <% dialog.with_header(title: "Edit #{@rule.resource_type} rule") %> + <% dialog.with_body do %> + <%= render "rules/form", rule: @rule %> + <% end %> <% end %> diff --git a/app/views/rules/index.html.erb b/app/views/rules/index.html.erb index 39412b4e..13e538b4 100644 --- a/app/views/rules/index.html.erb +++ b/app/views/rules/index.html.erb @@ -1,29 +1,32 @@

Rules

- <% turbo_confirm = { - title: "Delete all rules", - body: "Are you sure you want to delete all rules? This action cannot be undone.", - accept: "Delete all rules", - } %> -
<% if @rules.any? %> - <%= contextual_menu do %> - <%= contextual_menu_destructive_item "Delete all rules", destroy_all_rules_path, turbo_confirm: turbo_confirm %> + <%= render MenuComponent.new do |menu| %> + <% menu.with_item( + variant: "button", + text: "Delete all rules", + href: destroy_all_rules_path, + icon: "trash-2", + method: :delete, + confirm: CustomConfirm.for_resource_deletion("All rules", high_severity: true)) %> <% end %> <% end %> - <%= link_to new_rule_path(resource_type: "transaction"), class: "btn btn--primary flex items-center gap-1 justify-center", data: { turbo_frame: :modal } do %> - <%= lucide_icon "plus", class: "w-5 h-5" %> -

New rule

- <% end %> + <%= render LinkComponent.new( + text: "New rule", + variant: "primary", + href: new_rule_path(resource_type: "transaction"), + icon: "plus", + frame: :modal + ) %>
<% if self_hosted? %>
- <%= lucide_icon("circle-alert", class: "w-4 h-4 text-secondary") %> + <%= icon("circle-alert", size: "sm") %>

AI-enabled rule actions will cost money. Be sure to filter as narrowly as possible to avoid unnecessary costs.

@@ -50,10 +53,13 @@

No rules yet

Set up rules to perform actions to your transactions and other data on every account sync.

- <%= link_to new_rule_path(resource_type: "transaction"), class: "btn btn--primary flex items-center gap-1", data: { turbo_frame: "modal" } do %> - <%= lucide_icon("plus", class: "w-5 h-5") %> - New rule - <% end %> + <%= render LinkComponent.new( + text: "New rule", + variant: "primary", + href: new_rule_path(resource_type: "transaction"), + icon: "plus", + frame: :modal + ) %>
diff --git a/app/views/rules/new.html.erb b/app/views/rules/new.html.erb index 37205dd9..a39a299b 100644 --- a/app/views/rules/new.html.erb +++ b/app/views/rules/new.html.erb @@ -1,5 +1,8 @@ <%= link_to "Back to rules", rules_path %> -<%= modal_form_wrapper title: "New #{@rule.resource_type} rule" do %> - <%= render "rules/form", rule: @rule %> +<%= render DialogComponent.new do |dialog| %> + <% dialog.with_header(title: "New #{@rule.resource_type} rule") %> + <% dialog.with_body do %> + <%= render "rules/form", rule: @rule %> + <% end %> <% end %> diff --git a/app/views/settings/_settings_nav.html.erb b/app/views/settings/_settings_nav.html.erb index be1ad416..0a035206 100644 --- a/app/views/settings/_settings_nav.html.erb +++ b/app/views/settings/_settings_nav.html.erb @@ -1,11 +1,15 @@
- <%= link_to previous_path, class: "flex items-center gap-1 text-primary font-medium text-sm" do %> - <%= lucide_icon "chevron-left", class: "w-5 h-5 text-secondary" %> - - <% end %> + <%= render LinkComponent.new( + text: "Back", + icon: "chevron-left", + href: previous_path, + variant: "ghost", + class: "hidden md:inline-flex" + ) %> + <%= link_to previous_path, class: "hidden md:block uppercase bg-surface-inset-hover rounded-sm px-1 py-0.5 text-xs text-secondary shadow-sm ml-1 pointer-events-none", data: { controller: "hotkey", hotkey: "Escape" } do %> - esc + esc <% end %>
-
@@ -30,16 +30,15 @@ data-profile-image-preview-target="clearBtn" data-action="click->profile-image-preview#clearFileInput" class="<%= user.profile_image.attached? ? "" : "hidden" %> cursor-pointer absolute bottom-0 right-0 w-8 h-8 bg-gray-50 rounded-full flex justify-center items-center border border-white border-2"> - <%= lucide_icon "x", class: "w-4 h-4 text-secondary" %> + <%= icon "x", size: "sm" %>
<%= form.hidden_field :delete_profile_image, value: "0", data: { profile_image_preview_target: "deleteProfileImage" } %> - <%= form.label :profile_image, class: "btn btn--outline inline-block", data: { profile_image_preview_target: "uploadButton" } do %> - - <%= lucide_icon "camera", class: "w-5 h-5 mr-2 inline-block", data: { profile_image_preview_target: "cameraIcon" } %> + <%= form.label :profile_image, class: "px-3 py-2 rounded-lg text-sm hover:bg-surface-hover border border-secondary inline-flex items-center gap-2 cursor-pointer", data: { profile_image_preview_target: "uploadButton" } do %> + <%= icon "camera", data: { profile_image_preview_target: "cameraIcon" } %> <%= t(".choose") %> <%= t(".choose_label") %> diff --git a/app/views/settings/billings/show.html.erb b/app/views/settings/billings/show.html.erb index e683efa1..1b4cc5e2 100644 --- a/app/views/settings/billings/show.html.erb +++ b/app/views/settings/billings/show.html.erb @@ -5,7 +5,7 @@
- <%= lucide_icon "gem", class: "w-5 h-5 text-secondary" %> + <%= icon "gem" %>
@@ -19,15 +19,24 @@
<% if @user.family.subscribed? || subscription_pending? %> - <%= link_to subscription_path, class: "btn btn--secondary flex items-center gap-1", target: "_blank", rel: "noopener" do %> - Manage - <%= lucide_icon "external-link", class: "w-5 h-5 shrink-0 text-secondary" %> - <% end %> + <%= render LinkComponent.new( + text: "Manage", + icon: "external-link", + variant: "primary", + icon_position: "right", + href: subscription_path, + target: "_blank", + rel: "noopener" + ) %> <% else %> - <%= link_to new_subscription_path, class: "btn btn--secondary flex items-center gap-1", target: "_blank", rel: "noopener" do %> - Subscribe - <%= lucide_icon "external-link", class: "w-5 h-5 shrink-0 text-secondary" %> - <% end %> + <%= render LinkComponent.new( + text: "Subscribe", + variant: "primary", + icon: "external-link", + icon_position: "right", + href: new_subscription_path, + target: "_blank", + rel: "noopener") %> <% end %>
diff --git a/app/views/settings/hostings/_invite_code_settings.html.erb b/app/views/settings/hostings/_invite_code_settings.html.erb index 138f5c95..63a5a335 100644 --- a/app/views/settings/hostings/_invite_code_settings.html.erb +++ b/app/views/settings/hostings/_invite_code_settings.html.erb @@ -5,11 +5,11 @@

<%= t(".description") %>

- <%= styled_form_with model: Setting.new, url: settings_hosting_path, method: :patch, data: { controller: "auto-submit-form", "auto-submit-form-trigger-event-value" => "blur" } do |form| %> -
- <%= form.check_box :require_invite_for_signup, class: "sr-only peer", "data-auto-submit-form-target": "auto", "data-autosubmit-trigger-event": "input", disabled: !Current.user.admin? %> - <%= form.label :require_invite_for_signup, " ".html_safe, class: "switch" %> -
+ <%= styled_form_with model: Setting.new, + url: settings_hosting_path, + method: :patch, + data: { controller: "auto-submit-form", auto_submit_form_trigger_event_value: "change" } do |form| %> + <%= form.toggle :require_invite_for_signup, { data: { auto_submit_form_target: "auto" } } %> <% end %>
@@ -19,11 +19,11 @@

<%= t(".email_confirmation_description") %>

- <%= styled_form_with model: Setting.new, url: settings_hosting_path, method: :patch, data: { controller: "auto-submit-form", "auto-submit-form-trigger-event-value" => "blur" } do |form| %> -
- <%= form.check_box :require_email_confirmation, class: "sr-only peer", "data-auto-submit-form-target": "auto", "data-autosubmit-trigger-event": "input", disabled: !Current.user.admin? %> - <%= form.label :require_email_confirmation, " ".html_safe, class: "switch" %> -
+ <%= styled_form_with model: Setting.new, + url: settings_hosting_path, + method: :patch, + data: { controller: "auto-submit-form", auto_submit_form_trigger_event_value: "change" } do |form| %> + <%= form.toggle :require_email_confirmation, { data: { auto_submit_form_target: "auto" } } %> <% end %> diff --git a/app/views/settings/preferences/show.html.erb b/app/views/settings/preferences/show.html.erb index 169df40a..fae15f30 100644 --- a/app/views/settings/preferences/show.html.erb +++ b/app/views/settings/preferences/show.html.erb @@ -7,7 +7,7 @@ <%= form.fields_for :family do |family_form| %> <%= family_form.select :currency, - currencies_for_select.map { |currency| [ "#{currency.name} (#{currency.iso_code})", currency.iso_code ] }, + Money::Currency.as_options.map { |currency| [ "#{currency.name} (#{currency.iso_code})", currency.iso_code ] }, { label: t(".currency") }, disabled: true %> <%= family_form.select :locale, @@ -46,9 +46,9 @@ <%= form_with model: @user, class: "flex flex-col md:flex-row justify-between items-center gap-4", id: "theme_form", data: { controller: "auto-submit-form", auto_submit_form_trigger_event_value: "change" } do |form| %> <%= form.hidden_field :redirect_to, value: "preferences" %> - + <% theme_option_class = "text-center transition-all duration-200 p-3 rounded-lg hover:bg-surface-hover cursor-pointer [&:has(input:checked)]:bg-surface-hover [&:has(input:checked)]:border [&:has(input:checked)]:border-primary [&:has(input:checked)]:shadow-xs" %> - + <% [ { value: "light", image: "light-mode-preview.png" }, { value: "dark", image: "dark-mode-preview.png" }, @@ -57,8 +57,8 @@ <%= form.label :"theme_#{theme[:value]}", class: "group" do %>
<%= image_tag(theme[:image], alt: "#{theme[:value].titleize} Theme Preview", class: "h-44 mb-2") %> -
- <%= form.radio_button :theme, theme[:value], checked: @user.theme == theme[:value], class: "sr-only", +
"> + <%= form.radio_button :theme, theme[:value], checked: @user.theme == theme[:value], class: "sr-only", data: { auto_submit_form_target: "auto", autosubmit_trigger_event: "change", action: "theme#updateTheme" } %> <%= t(".theme_#{theme[:value]}") %>
diff --git a/app/views/settings/profiles/show.html.erb b/app/views/settings/profiles/show.html.erb index dae79cca..e375f2ad 100644 --- a/app/views/settings/profiles/show.html.erb +++ b/app/views/settings/profiles/show.html.erb @@ -6,17 +6,20 @@
<%= form.email_field :email, placeholder: t(".email"), label: t(".email") %> + <% if @user.unconfirmed_email.present? %>

You have requested to change your email to <%= @user.unconfirmed_email %>. Please go to your email and confirm for the change to take effect.

<% end %> +
<%= form.text_field :first_name, placeholder: t(".first_name"), label: t(".first_name") %> <%= form.text_field :last_name, placeholder: t(".last_name"), label: t(".last_name") %>
+
- <%= form.submit t(".save"), class: "btn btn--primary md:w-auto w-full" %> + <%= render ButtonComponent.new(text: t(".save"), class: "md:w-auto w-full justify-center") %>
<% end %> @@ -40,7 +43,7 @@ <% @users.each do |user| %>
- <%= render "settings/user_avatar", user: user %> + <%= render "settings/user_avatar", avatar_url: user.profile_image.url %>

<%= user.display_name %>

@@ -48,17 +51,13 @@
<% if Current.user.admin? && user != Current.user %>
- <%= button_to settings_profile_path(user_id: user), - method: :delete, - class: "text-red-500 hover:text-red-700", - data: { turbo_confirm: { - title: t(".confirm_remove_member.title"), - body: t(".confirm_remove_member.body", name: user.display_name), - accept: t(".remove_member"), - acceptClass: "w-full btn btn--destructive text-white rounded-xl text-center p-[10px] mb-2" - }} do %> - <%= lucide_icon "x", class: "w-5 h-5" %> - <% end %> + <%= render ButtonComponent.new( + variant: "icon", + icon: "x", + href: settings_profile_path(user_id: user), + method: :delete, + confirm: CustomConfirm.for_resource_deletion(user.display_name, high_severity: true) + ) %>
<% end %>
@@ -89,26 +88,23 @@ class="text-sm bg-gray-50 px-2 py-1 rounded border border-secondary w-72">
<% end %> + <% if Current.user.admin? %> - <%= button_to invitation_path(invitation), - method: :delete, - class: "text-red-500 hover:text-red-700", - data: { turbo_confirm: { - title: t(".confirm_remove_invitation.title"), - body: t(".confirm_remove_invitation.body", email: invitation.email), - accept: t(".remove_invitation"), - acceptClass: "w-full btn btn--destructive text-white rounded-xl text-center p-[10px] mb-2" - }} do %> - <%= lucide_icon "x", class: "w-5 h-5" %> - <% end %> + <%= render ButtonComponent.new( + variant: "icon", + icon: "x", + href: invitation_path(invitation), + method: :delete, + confirm: CustomConfirm.for_resource_deletion(invitation.email, high_severity: true) + ) %> <% end %>
@@ -118,7 +114,7 @@ <%= link_to new_invitation_path, class: "bg-container-inset flex items-center justify-center gap-2 text-secondary mt-1 hover:bg-container-inset-hover rounded-lg px-4 py-2 w-full text-center", data: { turbo_frame: :modal } do %> - <%= lucide_icon("plus", class: "w-5 h-5 text-secondary") %> + <%= icon("plus") %> <%= t(".invite_member") %> <% end %> <% end %> @@ -134,16 +130,14 @@

<%= t(".reset_account") %>

<%= t(".reset_account_warning") %>

- <%= - button_to t(".reset_account"), reset_user_path(@user), method: :delete, - class: "w-full md:w-auto btn btn--destructive", - data: { turbo_confirm: { - title: t(".confirm_reset.title"), - body: t(".confirm_reset.body"), - accept: t(".reset_account"), - acceptClass: "w-full btn btn--destructive text-primary rounded-xl text-center p-[10px] mb-2" - }} - %> + + <%= render ButtonComponent.new( + text: t(".reset_account"), + variant: "destructive", + href: reset_user_path(@user), + method: :delete, + confirm: CustomConfirm.for_resource_deletion("Account", high_severity: true) + ) %> <% end %>
@@ -151,16 +145,14 @@

<%= t(".delete_account") %>

<%= t(".delete_account_warning") %>

- <%= - button_to t(".delete_account"), user_path(@user), method: :delete, - class: "w-full md:w-auto btn btn--destructive", - data: { turbo_confirm: { - title: t(".confirm_delete.title"), - body: t(".confirm_delete.body"), - accept: t(".delete_account"), - acceptClass: "w-full btn btn--destructive text-white rounded-xl text-center p-[10px] mb-2" - }} - %> + + <%= render ButtonComponent.new( + text: t(".delete_account"), + variant: "destructive", + href: user_path(@user), + method: :delete, + confirm: CustomConfirm.for_resource_deletion("your account", high_severity: true) + ) %> <% end %> diff --git a/app/views/settings/securities/show.html.erb b/app/views/settings/securities/show.html.erb index e1bd3af3..1ac38d73 100644 --- a/app/views/settings/securities/show.html.erb +++ b/app/views/settings/securities/show.html.erb @@ -5,7 +5,7 @@
- <%= lucide_icon "shield-check", class: "w-5 h-5 text-secondary" %> + <%= icon "shield-check" %>
@@ -21,18 +21,24 @@
<% if Current.user.otp_required? %> - <%= button_to t(".disable_mfa"), disable_mfa_path, - method: :delete, - class: "w-full md:w-auto btn btn--secondary flex items-center gap-1 justify-center", - data: { turbo_confirm: { - title: t(".disable_mfa_confirm"), - body: t(".disable_mfa_confirm"), - accept: t(".disable_mfa"), - acceptClass: "w-full bg-red-500 text-white rounded-xl text-center p-[10px] border mb-2" - } } %> + <%= render ButtonComponent.new( + text: t(".disable_mfa"), + variant: "secondary", + href: disable_mfa_path, + method: :delete, + confirm: CustomConfirm.new( + title: t(".disable_mfa_confirm"), + body: t(".disable_mfa_confirm"), + btn_text: t(".disable_mfa"), + destructive: true + ) + ) %> <% else %> - <%= link_to t(".enable_mfa"), new_mfa_path, - class: "w-full md:w-auto btn btn--primary flex items-center gap-1 justify-center" %> + <%= render LinkComponent.new( + text: t(".enable_mfa"), + variant: "primary", + href: new_mfa_path + ) %> <% end %>
diff --git a/app/views/shared/_circle_logo.html.erb b/app/views/shared/_circle_logo.html.erb deleted file mode 100644 index 0aba2373..00000000 --- a/app/views/shared/_circle_logo.html.erb +++ /dev/null @@ -1,13 +0,0 @@ -<%# locals: (name:, hex: nil, size: "md") %> - -<% size_classes = { - "sm" => "w-6 h-6", - "md" => "w-9 h-9", - "lg" => "w-10 h-10", - "full" => "w-full h-full" -} %> - -<%= tag.div style: mixed_hex_styles(hex || "#1570EF"), - class: [size_classes[size], "flex shrink-0 items-center justify-center rounded-full"] do %> - <%= tag.span (name.presence&.first || "T").upcase, class: ["font-medium", size == "sm" ? "text-xs" : "text-sm"] %> -<% end %> diff --git a/app/views/shared/_confirm_modal.html.erb b/app/views/shared/_confirm_modal.html.erb deleted file mode 100644 index ec24a1e0..00000000 --- a/app/views/shared/_confirm_modal.html.erb +++ /dev/null @@ -1,16 +0,0 @@ - -
-
-
-

<%= t(".title") %>

- -
-
- <%= t(".body_html") %> -
-
- -
-
diff --git a/app/views/shared/_disclosure.html.erb b/app/views/shared/_disclosure.html.erb deleted file mode 100644 index c4e59d9c..00000000 --- a/app/views/shared/_disclosure.html.erb +++ /dev/null @@ -1,12 +0,0 @@ -<%# locals: (title:, content:, open: true) %> - -
> - -

<%= title %>

- <%= lucide_icon "chevron-down", - class: "group-open:transform group-open:rotate-180 text-secondary w-5 h-5" %> -
- - <%= content %> -
diff --git a/app/views/shared/_drawer.html.erb b/app/views/shared/_drawer.html.erb deleted file mode 100644 index e133fa0a..00000000 --- a/app/views/shared/_drawer.html.erb +++ /dev/null @@ -1,19 +0,0 @@ -<%# locals: (content:, reload_on_close: false) %> - -<%= turbo_frame_tag "drawer" do %> - -
-
-
- <%= lucide_icon("x", class: "w-5 h-5 shrink-0") %> -
-
-
- <%= content %> -
-
-
-<% end %> diff --git a/app/views/shared/_form_errors.html.erb b/app/views/shared/_form_errors.html.erb index 30e37a97..f2d703e2 100644 --- a/app/views/shared/_form_errors.html.erb +++ b/app/views/shared/_form_errors.html.erb @@ -1,6 +1,6 @@ <%# locals: (model:) %>
- <%= lucide_icon("alert-circle", class: "text-red-500 w-4 h-4 shrink-0") %> -

<%= model.errors.full_messages.to_sentence %>

+ <%= icon("alert-circle", size: "sm", color: "destructive") %> +

<%= model.errors.full_messages.to_sentence %>

diff --git a/app/views/shared/_icon.html.erb b/app/views/shared/_icon.html.erb deleted file mode 100644 index 82f999de..00000000 --- a/app/views/shared/_icon.html.erb +++ /dev/null @@ -1,6 +0,0 @@ -<%# locals: (key:, size: "md", color: "current") %> - -<% size_class = case size when "sm" then "w-4 h-4" when "md" then "w-5 h-5" when "lg" then "w-6 h-6" end %> -<% color_class = case color when "current" then "text-current" when "gray" then "text-secondary" end %> - -<%= lucide_icon key, class: class_names(size_class, color_class, "shrink-0") %> diff --git a/app/views/shared/_icon_custom.html.erb b/app/views/shared/_icon_custom.html.erb deleted file mode 100644 index ae3b6935..00000000 --- a/app/views/shared/_icon_custom.html.erb +++ /dev/null @@ -1,6 +0,0 @@ -<%# locals: (key:, size: "md", color: "current") %> - -<% size_class = case size when "sm" then "w-4 h-4" when "md" then "w-5 h-5" when "lg" then "w-6 h-6" end %> -<% color_class = case color when "current" then "text-current" when "gray" then "text-secondary" end %> - -<%= inline_svg_tag "#{key}.svg", class: class_names(size_class, color_class, "shrink-0") %> diff --git a/app/views/shared/_icon_image.html.erb b/app/views/shared/_icon_image.html.erb deleted file mode 100644 index b782dc3f..00000000 --- a/app/views/shared/_icon_image.html.erb +++ /dev/null @@ -1,6 +0,0 @@ -<%# locals: (key:, size: "md", color: "current") %> - -<% size_class = case size when "sm" then "w-4 h-4" when "md" then "w-5 h-5" when "lg" then "w-6 h-6" end %> -<% color_class = case color when "current" then "text-current" when "gray" then "text-secondary" end %> - -<%= image_tag("icon-#{key}.svg", class: class_names(size_class, color_class, "shrink-0"), alt: key ) %> diff --git a/app/views/shared/_modal.html.erb b/app/views/shared/_modal.html.erb deleted file mode 100644 index 3fdcb70a..00000000 --- a/app/views/shared/_modal.html.erb +++ /dev/null @@ -1,19 +0,0 @@ -<%# locals: (content:, reload_on_close:, overflow_visible: false) -%> - -<%= turbo_frame_tag "modal" do %> - <%= tag.dialog( - class: class_names( - "focus:outline-none md:m-auto bg-container rounded-none md:rounded-2xl max-w-screen max-h-screen md:max-w-max w-full h-full md:h-fit md:w-auto shadow-border-xs", - overflow_visible ? "overflow-visible" : "overflow-auto" - ), - data: { - controller: "modal", - action: "mousedown->modal#clickOutside", - modal_reload_on_close_value: reload_on_close - } - ) do %> -
- <%= content %> -
- <% end %> -<% end %> diff --git a/app/views/shared/_modal_form.html.erb b/app/views/shared/_modal_form.html.erb deleted file mode 100644 index fb6d21c8..00000000 --- a/app/views/shared/_modal_form.html.erb +++ /dev/null @@ -1,18 +0,0 @@ -<%# locals: (title:, content:, subtitle: nil, overflow_visible: false) %> - -<%= modal overflow_visible: overflow_visible do %> -
-
-
-

<%= title %>

- <%= lucide_icon("x", class: "cursor-pointer w-6 h-6 md:w-5 md:w-5 text-secondary", data: { action: "mousedown->modal#close" }) %> -
- - <% if subtitle.present? %> - <%= tag.p subtitle, class: "text-secondary font-light" %> - <% end %> -
- - <%= content %> -
-<% end %> diff --git a/app/views/shared/_money_field.html.erb b/app/views/shared/_money_field.html.erb index 9b83820a..ac8e6e55 100644 --- a/app/views/shared/_money_field.html.erb +++ b/app/views/shared/_money_field.html.erb @@ -44,7 +44,7 @@ <% unless options[:hide_currency] %>
<%= form.select currency_method, - currencies_for_select.map(&:iso_code), + Money::Currency.as_options.map(&:iso_code), { inline: true, selected: currency.iso_code }, { class: "w-fit pr-5 disabled:text-subdued form-field__input", diff --git a/app/views/shared/_pagination.html.erb b/app/views/shared/_pagination.html.erb index 73a6ddea..afc047de 100644 --- a/app/views/shared/_pagination.html.erb +++ b/app/views/shared/_pagination.html.erb @@ -7,11 +7,11 @@ <%= link_to pagy_url_for(pagy, pagy.prev), data: { turbo_frame: :_top }, class: "inline-flex items-center p-2 text-sm font-medium text-secondary bg-container-inset hover:border-secondary hover:text-secondary" do %> - <%= lucide_icon("chevron-left", class: "w-5 h-5 text-secondary") %> + <%= icon("chevron-left") %> <% end %> <% else %>
- <%= lucide_icon("chevron-left", class: "w-5 h-5 text-secondary") %> + <%= icon("chevron-left") %>
<% end %>
@@ -39,11 +39,11 @@ <%= link_to pagy_url_for(pagy, pagy.next), data: { turbo_frame: :_top }, class: "inline-flex items-center p-2 text-sm font-medium text-secondary hover:border-secondary hover:text-secondary" do %> - <%= lucide_icon("chevron-right", class: "w-5 h-5 text-secondary") %> + <%= icon("chevron-right") %> <% end %> <% else %>
- <%= lucide_icon("chevron-right", class: "w-5 h-5 text-secondary") %> + <%= icon("chevron-right") %>
<% end %>
diff --git a/app/views/shared/_subscribe_modal.html.erb b/app/views/shared/_subscribe_modal.html.erb index 3afedaab..62d7d72a 100644 --- a/app/views/shared/_subscribe_modal.html.erb +++ b/app/views/shared/_subscribe_modal.html.erb @@ -1,25 +1,26 @@ - diff --git a/app/views/tags/edit.html.erb b/app/views/tags/edit.html.erb index 75ad4a0f..3453189d 100644 --- a/app/views/tags/edit.html.erb +++ b/app/views/tags/edit.html.erb @@ -1,3 +1,6 @@ -<%= modal_form_wrapper title: t(".edit") do %> - <%= render "form", tag: @tag %> +<%= render DialogComponent.new do |dialog| %> + <% dialog.with_header(title: t(".edit")) %> + <% dialog.with_body do %> + <%= render "form", tag: @tag %> + <% end %> <% end %> diff --git a/app/views/tags/index.html.erb b/app/views/tags/index.html.erb index 1b05c051..d12bd356 100644 --- a/app/views/tags/index.html.erb +++ b/app/views/tags/index.html.erb @@ -1,10 +1,13 @@

<%= t(".tags") %>

- <%= link_to new_tag_path, class: "btn btn--primary flex items-center gap-1 justify-center", data: { turbo_frame: :modal } do %> - <%= lucide_icon "plus", class: "w-5 h-5" %> -

<%= t(".new") %>

- <% end %> + <%= render LinkComponent.new( + text: t(".new"), + variant: "primary", + href: new_tag_path, + icon: "plus", + frame: :modal + ) %>
@@ -26,10 +29,13 @@

<%= t(".empty") %>

- <%= link_to new_tag_path, class: "w-fit flex text-white text-sm font-medium items-center gap-1 bg-gray-900 rounded-lg p-2 pr-3", data: { turbo_frame: "modal" } do %> - <%= lucide_icon("plus", class: "w-5 h-5") %> - <%= t(".new") %> - <% end %> + + <%= render LinkComponent.new( + text: t(".new"), + icon: "plus", + href: new_tag_path, + frame: :modal + ) %>
<% end %> diff --git a/app/views/tags/new.html.erb b/app/views/tags/new.html.erb index ad97c79d..19508471 100644 --- a/app/views/tags/new.html.erb +++ b/app/views/tags/new.html.erb @@ -1,3 +1,6 @@ -<%= modal_form_wrapper title: t(".new") do %> - <%= render "form", tag: @tag %> +<%= render DialogComponent.new do |dialog| %> + <% dialog.with_header(title: t(".new")) %> + <% dialog.with_body do %> + <%= render "form", tag: @tag %> + <% end %> <% end %> diff --git a/app/views/trades/_header.html.erb b/app/views/trades/_header.html.erb index a649ebf5..7fcf1a47 100644 --- a/app/views/trades/_header.html.erb +++ b/app/views/trades/_header.html.erb @@ -26,7 +26,7 @@ <% trade = entry.trade %>
- <%= disclosure t(".overview") do %> + <%= render DisclosureComponent.new(title: t(".overview"), open: true) do %>
diff --git a/app/views/trades/_trade.html.erb b/app/views/trades/_trade.html.erb index eb558dd3..2a4c32f0 100644 --- a/app/views/trades/_trade.html.erb +++ b/app/views/trades/_trade.html.erb @@ -12,9 +12,12 @@
<%= tag.div class: ["flex items-center gap-2"] do %> -
- <%= entry.name.first.upcase %> -
+ <%= render FilledIconComponent.new( + variant: :text, + text: entry.name, + size: "sm", + rounded: true + ) %>
<%= link_to entry.name, diff --git a/app/views/trades/new.html.erb b/app/views/trades/new.html.erb index d3df030f..381a809e 100644 --- a/app/views/trades/new.html.erb +++ b/app/views/trades/new.html.erb @@ -1,3 +1,6 @@ -<%= modal_form_wrapper title: t(".title") do %> - <%= render "trades/form", entry: @entry %> +<%= render DialogComponent.new do |dialog| %> + <% dialog.with_header(title: t(".title")) %> + <% dialog.with_body do %> + <%= render "trades/form", entry: @entry %> + <% end %> <% end %> diff --git a/app/views/trades/show.html.erb b/app/views/trades/show.html.erb index 565f8c8b..97c48e2d 100644 --- a/app/views/trades/show.html.erb +++ b/app/views/trades/show.html.erb @@ -1,11 +1,12 @@ -<%= drawer do %> - <%= render "trades/header", entry: @entry %> +<%= render DialogComponent.new(variant: "drawer") do |dialog| %> + <% dialog.with_header do %> + <%= render "trades/header", entry: @entry %> + <% end %> <% trade = @entry.trade %> -
- - <%= disclosure t(".details") do %> + <% dialog.with_body do %> + <% dialog.with_section(title: t(".details"), open: true) do %>
<%= styled_form_with model: @entry, url: trade_path(@entry), @@ -42,8 +43,7 @@
<% end %> - - <%= disclosure t(".additional") do %> + <% dialog.with_section(title: t(".additional")) do %>
<%= styled_form_with model: @entry, url: trade_path(@entry), @@ -58,8 +58,7 @@
<% end %> - - <%= disclosure t(".settings") do %> + <% dialog.with_section(title: t(".settings")) do %>
<%= styled_form_with model: @entry, @@ -72,13 +71,7 @@

<%= t(".exclude_subtitle") %>

-
- <%= f.check_box :excluded, - class: "sr-only peer", - "data-auto-submit-form-target": "auto" %> - -
+ <%= f.toggle :excluded, { data: { auto_submit_form_target: "auto" } } %>
<% end %> @@ -98,5 +91,5 @@
<% end %> -
+ <% end %> <% end %> diff --git a/app/views/transactions/_form.html.erb b/app/views/transactions/_form.html.erb index 19c9acaa..733af700 100644 --- a/app/views/transactions/_form.html.erb +++ b/app/views/transactions/_form.html.erb @@ -1,4 +1,6 @@ -<%= styled_form_with model: @entry, url: transactions_path, class: "space-y-4 text-subdued", data: { controller: "transaction-form" } do |f| %> +<%# locals: (entry:, income_categories:, expense_categories:) %> + +<%= styled_form_with model: entry, url: transactions_path, class: "space-y-4 text-subdued", data: { controller: "transaction-form" } do |f| %> <% if entry.errors.any? %> <%= render "shared/form_errors", model: entry %> <% end %> @@ -21,13 +23,13 @@ <%= f.money_field :amount, label: t(".amount"), required: true %> <%= f.fields_for :entryable do |ef| %> - <% categories = params[:nature] == "inflow" ? @income_categories : @expense_categories %> + <% categories = params[:nature] == "inflow" ? income_categories : expense_categories %> <%= ef.collection_select :category_id, categories, :id, :name, { prompt: t(".category_prompt"), label: t(".category") } %> <% end %> <%= f.date_field :date, label: t(".date"), required: true, min: Entry.min_supported_date, max: Date.current, value: Date.current %> - <%= disclosure t(".details"), default_open: false do %> + <%= render DisclosureComponent.new(title: t(".details")) do %> <%= f.fields_for :entryable do |ef| %> <%= ef.select :tag_ids, Current.family.tags.alphabetically.pluck(:name, :id), diff --git a/app/views/transactions/_header.html.erb b/app/views/transactions/_header.html.erb index 5b3e75b8..565892d5 100644 --- a/app/views/transactions/_header.html.erb +++ b/app/views/transactions/_header.html.erb @@ -13,7 +13,7 @@ <% if entry.transaction.transfer? %> - <%= lucide_icon "arrow-left-right", class: "text-secondary mt-1 w-5 h-5" %> + <%= icon "arrow-left-right", class: "mt-1" %> <% end %>
diff --git a/app/views/transactions/_selection_bar.html.erb b/app/views/transactions/_selection_bar.html.erb index fada06aa..65e2ce12 100644 --- a/app/views/transactions/_selection_bar.html.erb +++ b/app/views/transactions/_selection_bar.html.erb @@ -8,15 +8,15 @@
<%= turbo_frame_tag "bulk_transaction_edit_drawer" %> <%= link_to new_transactions_bulk_update_path, - class: "p-1.5 group hover:bg-gray-700 flex items-center justify-center rounded-md", + class: "p-1.5 group hover:bg-inverse flex items-center justify-center rounded-md", title: "Edit", data: { turbo_frame: "bulk_transaction_edit_drawer" } do %> - <%= lucide_icon "pencil-line", class: "w-5 group-hover:text-white" %> + <%= icon "pencil-line", class: "group-hover:text-inverse" %> <% end %> <%= form_with url: transactions_bulk_deletion_path, data: { turbo_confirm: true, turbo_frame: "_top" } do %> - <% end %>
diff --git a/app/views/transactions/_transaction.html.erb b/app/views/transactions/_transaction.html.erb index 4aa8ddd1..1ad74980 100644 --- a/app/views/transactions/_transaction.html.erb +++ b/app/views/transactions/_transaction.html.erb @@ -26,9 +26,12 @@ class: "w-6 h-6 rounded-full", loading: "lazy" %> <% else %> - <%= render "shared/circle_logo", - name: entry.name, - size: "sm" %> + <%= render FilledIconComponent.new( + variant: :text, + text: entry.name, + size: "sm", + rounded: true + ) %> <% end %>
@@ -45,8 +48,8 @@ ) %> <% if entry.excluded %> - (excluded from averages)"> - <%= lucide_icon "asterisk", class: "w-4 h-4 shrink-0 text-orange-500" %> + (excluded from averages)"> + <%= icon "asterisk", size: "sm", color: "current" %> <% end %> diff --git a/app/views/transactions/_transfer_match.html.erb b/app/views/transactions/_transfer_match.html.erb index a406e7f4..a1ff6a76 100644 --- a/app/views/transactions/_transfer_match.html.erb +++ b/app/views/transactions/_transfer_match.html.erb @@ -3,7 +3,7 @@
" class="flex items-center gap-1"> <% if transaction.transfer.confirmed? %> is confirmed"> - <%= lucide_icon "link-2", class: "w-4 h-4 text-indigo-600" %> + <%= icon "link-2", size: "sm", class: "text-indigo-600" %> <% elsif transaction.transfer.pending? %> @@ -14,7 +14,7 @@ method: :patch, class: "text-secondary hover:text-gray-800 flex items-center justify-center cursor-pointer", title: "Confirm match" do %> - <%= lucide_icon "check", class: "w-4 h-4 text-indigo-400 hover:text-indigo-600" %> + <%= icon "check", size: "sm", class: "text-indigo-400 hover:text-indigo-600" %> <% end %> <%= button_to transfer_path(transaction.transfer, transfer: { status: "rejected" }), @@ -22,7 +22,7 @@ data: { turbo: false }, class: "text-secondary hover:text-gray-800 flex items-center justify-center cursor-pointer", title: "Reject match" do %> - <%= lucide_icon "x", class: "w-4 h-4 text-subdued hover:text-gray-600" %> + <%= icon "x", size: "sm", class: "text-subdued hover:text-gray-600" %> <% end %> <% end %>
diff --git a/app/views/transactions/bulk_updates/new.html.erb b/app/views/transactions/bulk_updates/new.html.erb index 80c04526..072605b6 100644 --- a/app/views/transactions/bulk_updates/new.html.erb +++ b/app/views/transactions/bulk_updates/new.html.erb @@ -1,63 +1,25 @@ -<%= turbo_frame_tag "bulk_transaction_edit_drawer" do %> - +<%= render DialogComponent.new(variant: "drawer", frame: "bulk_transaction_edit_drawer") do |dialog| %> + <% dialog.with_header(title: "Edit transactions", data: { bulk_select_target: "bulkEditDrawerHeader" }) %> + + <% dialog.with_body do %> <%= styled_form_with url: transactions_bulk_update_path, scope: "bulk_update", class: "h-full", data: { turbo_frame: "_top" } do |form| %> -
-
-
-
- <%= lucide_icon("x", class: "w-5 h-5 shrink-0") %> -
-
- -
-
-
-

- Edit transactions -

-
- -
-
- -

Overview

- <%= lucide_icon "chevron-down", class: "group-open:transform group-open:rotate-180 text-secondary w-5" %> -
- -
- <%= form.date_field :date, label: "Date", max: Date.current %> -
-
- -
- -

Details

- <%= lucide_icon "chevron-down", class: "group-open:transform group-open:rotate-180 text-secondary w-5" %> -
- -
- <%= form.collection_select :category_id, Current.family.categories.alphabetically, :id, :name, { prompt: "Select a category", label: "Category", class: "text-subdued" } %> - <%= form.collection_select :merchant_id, Current.family.merchants.alphabetically, :id, :name, { prompt: "Select a merchant", label: "Merchant", class: "text-subdued" } %> - <%= form.select :tag_ids, Current.family.tags.alphabetically.pluck(:name, :id), { include_blank: "None", multiple: true, label: "Tags", container_class: "h-40" } %> - <%= form.text_area :notes, label: "Notes", placeholder: "Enter a note that will be applied to selected transactions", rows: 5 %> -
-
-
-
-
+ <% dialog.with_section(title: "Overview", open: true) do %> +
+ <%= form.date_field :date, label: "Date", max: Date.current %>
+ <% end %> -
- <%= link_to "Cancel", transactions_path, class: "btn btn--ghost" %> - - <%= tag.button "Save", - type: "button", - data: { "bulk-select-scope-param": "bulk_update", action: "bulk-select#submitBulkRequest" }, - class: "btn btn--primary" %> + <% dialog.with_section(title: "Transactions", open: true) do %> +
+ <%= form.collection_select :category_id, Current.family.categories.alphabetically, :id, :name, { prompt: "Select a category", label: "Category", class: "text-subdued" } %> + <%= form.collection_select :merchant_id, Current.family.merchants.alphabetically, :id, :name, { prompt: "Select a merchant", label: "Merchant", class: "text-subdued" } %> + <%= form.select :tag_ids, Current.family.tags.alphabetically.pluck(:name, :id), { include_blank: "None", multiple: true, label: "Tags", container_class: "h-40" } %> + <%= form.text_area :notes, label: "Notes", placeholder: "Enter a note that will be applied to selected transactions", rows: 5 %>
-
+ <% end %> <% end %> -
+ <% end %> + + <% dialog.with_action(cancel_action: true, text: "Cancel", variant: "ghost") %> + <% dialog.with_action(text: "Save", data: { bulk_select_scope_param: "bulk_update", action: "bulk-select#submitBulkRequest" }) %> <% end %> diff --git a/app/views/transactions/index.html.erb b/app/views/transactions/index.html.erb index 7ed329aa..6ab1cc44 100644 --- a/app/views/transactions/index.html.erb +++ b/app/views/transactions/index.html.erb @@ -3,30 +3,43 @@

Transactions

- <%= contextual_menu do %> - <% if Rails.env.development? %> - <%= button_to "Dev only: Sync all", sync_all_accounts_path, class: "btn btn--ghost w-full" %> - <% end %> - <%= contextual_menu_item "New rule", url: new_rule_path(resource_type: "transaction"), icon: "plus", turbo_frame: :modal %> - <%= contextual_menu_item "Edit rules", url: rules_path, icon: "git-branch", turbo_frame: :_top %> - <%= contextual_menu_modal_action_item t(".edit_categories"), categories_path, icon: "shapes", turbo_frame: :_top %> - <%= contextual_menu_modal_action_item t(".edit_tags"), tags_path, icon: "tags", turbo_frame: :_top %> - <%= contextual_menu_modal_action_item t(".edit_merchants"), family_merchants_path, icon: "store", turbo_frame: :_top %> - <%= contextual_menu_modal_action_item t(".edit_imports"), imports_path, icon: "hard-drive-upload", turbo_frame: :_top %> - <%= contextual_menu_modal_action_item t(".import"), new_import_path, icon: "download", turbo_frame: "modal", class_name: "md:!hidden" %> + <%= render MenuComponent.new do |menu| %> + <% menu.with_item(variant: "button", text: "Dev only: Sync all", href: sync_all_accounts_path, method: :post, icon: "refresh-cw") %> + <% menu.with_item(variant: "link", text: "New rule", href: new_rule_path(resource_type: "transaction"), icon: "plus", data: { turbo_frame: :modal }) %> + <% menu.with_item(variant: "link", text: "Edit rules", href: rules_path, icon: "git-branch", data: { turbo_frame: :_top }) %> + <% menu.with_item(variant: "link", text: "Edit categories", href: categories_path, icon: "shapes", data: { turbo_frame: :_top }) %> + <% menu.with_item(variant: "link", text: "Edit tags", href: tags_path, icon: "tags", data: { turbo_frame: :_top }) %> + <% menu.with_item(variant: "link", text: "Edit merchants", href: family_merchants_path, icon: "store", data: { turbo_frame: :_top }) %> + <% menu.with_item(variant: "link", text: "Edit imports", href: imports_path, icon: "hard-drive-upload", data: { turbo_frame: :_top }) %> + <% menu.with_item(variant: "link", text: "Import", href: new_import_path, icon: "download", data: { turbo_frame: "modal", class_name: "md:!hidden" }) %> <% end %> - <%= link_to new_import_path, class: "btn btn--outline flex items-center gap-2 hidden md:flex", data: { turbo_frame: "modal" } do %> - <%= lucide_icon("download", class: "text-secondary w-4 h-4") %> -

<%= t(".import") %>

- <% end %> + - <%= link_to new_transaction_path, class: "btn btn--primary flex items-center justify-center gap-2 rounded-full md:rounded-lg w-9 h-9 md:w-auto md:h-auto", data: { turbo_frame: :modal } do %> - - <%= lucide_icon("plus", class: "w-5 h-5") %> - - - <% end %> + <%= render LinkComponent.new( + text: "New transaction", + icon: "plus", + variant: "primary", + href: new_transaction_path, + frame: :modal, + class: "hidden md:inline-flex" + ) %> + + <%= render LinkComponent.new( + icon: "plus", + variant: "icon-inverse", + href: new_transaction_path, + frame: :modal, + class: "rounded-full md:hidden" + ) %>
diff --git a/app/views/transactions/new.html.erb b/app/views/transactions/new.html.erb index 3ad75f36..c9183822 100644 --- a/app/views/transactions/new.html.erb +++ b/app/views/transactions/new.html.erb @@ -1,3 +1,6 @@ -<%= modal_form_wrapper title: "New transaction" do %> - <%= render "form", entry: @entry %> +<%= render DialogComponent.new do |dialog| %> + <% dialog.with_header(title: "New transaction") %> + <% dialog.with_body do %> + <%= render "form", entry: @entry, income_categories: @income_categories, expense_categories: @expense_categories %> + <% end %> <% end %> diff --git a/app/views/transactions/searches/_form.html.erb b/app/views/transactions/searches/_form.html.erb index dfffd7fd..eba44cb5 100644 --- a/app/views/transactions/searches/_form.html.erb +++ b/app/views/transactions/searches/_form.html.erb @@ -8,7 +8,7 @@
- <%= lucide_icon("search", class: "w-5 h-5 text-secondary") %> + <%= icon("search") %> <%= form.text_field :search, placeholder: "Search transactions ...", value: @q[:search], @@ -16,13 +16,20 @@ "data-auto-submit-form-target": "auto" %>
-
- - <%= render "transactions/searches/menu", form: form %> -
+ <%= render MenuComponent.new(variant: "button", no_padding: true) do |menu| %> + <% menu.with_button( + id: "transaction-filters-button", + type: "button", + text: "Filter", + variant: "outline", + icon: "list-filter", + data: { menu_target: "button" } + ) %> + + <% menu.with_custom_content do %> + <%= render "transactions/searches/menu", form: form %> + <% end %> + <% end %>
<% end %> diff --git a/app/views/transactions/searches/_menu.html.erb b/app/views/transactions/searches/_menu.html.erb index cdcd2536..3d11a37a 100644 --- a/app/views/transactions/searches/_menu.html.erb +++ b/app/views/transactions/searches/_menu.html.erb @@ -1,47 +1,46 @@ <%# locals: (form:) %> -