diff --git a/app/components/app_layout_component.html.erb b/app/components/app_layout_component.html.erb new file mode 100644 index 00000000..922f7046 --- /dev/null +++ b/app/components/app_layout_component.html.erb @@ -0,0 +1,79 @@ +<%= tag.div class: "flex flex-col lg:flex-row h-dvh lg:h-full bg-surface pt-safe", + data: { + controller: "app-layout", + app_layout_user_id_value: user_id, + app_layout_left_sidebar_class: left_sidebar_classes, + app_layout_right_sidebar_class: right_sidebar_classes, + } do %> + <%# MOBILE - Popout nav menu %> + + + <%# MOBILE - Top nav %> + + + <%# DESKTOP - Left navbar %> + + + <%# DESKTOP - Left sidebar %> + <%= tag.div class: class_names("hidden py-4 overflow-y-auto shrink-0", left_sidebar_classes, "lg:block" => show_left?), data: { app_layout_target: "leftSidebar" } do %> + <%= left_sidebar %> + <% end %> + + <%# SHARED - Main content %> + <%= tag.main class: class_names("grow overflow-y-auto px-3 lg:px-10 py-4 h-full w-full mx-auto max-w-5xl"), data: { app_layout_target: "content" } do %> + + + <%= content %> + <% end %> + + <%# DESKTOP - Right sidebar %> + <%= tag.div class: class_names("hidden h-full overflow-y-auto shrink-0", right_sidebar_classes, "lg:block" => show_right?), data: { app_layout_target: "rightSidebar" } do %> + <%= right_sidebar %> + <% end %> + + <%# MOBILE - Bottom Nav %> + <%= tag.nav class: "lg:hidden bg-surface shrink-0 z-10 pb-2 border-t border-tertiary pb-safe flex justify-around" do %> + <% nav_items.each do |nav_item| %> + <%= nav_item %> + <% end %> + <% end %> +<% end %> diff --git a/app/components/app_layout_component.rb b/app/components/app_layout_component.rb new file mode 100644 index 00000000..06d0a87c --- /dev/null +++ b/app/components/app_layout_component.rb @@ -0,0 +1,40 @@ +class AppLayoutComponent < ViewComponent::Base + renders_many :nav_items, NavItem + renders_one :breadcrumbs + + # Desktop slots + renders_one :left_sidebar + renders_one :right_sidebar + renders_one :desktop_user_menu + + # Mobile slots + renders_one :mobile_sidebar + renders_one :mobile_user_menu + + def initialize(user:) + @user = user + end + + def show_left? + user.show_sidebar? + end + + def show_right? + user.show_ai_sidebar? + end + + def user_id + user.id + end + + def left_sidebar_classes + "w-[320px]" + end + + def right_sidebar_classes + "w-[400px]" + end + + private + attr_reader :user +end diff --git a/app/components/app_layout_component/nav_item.html.erb b/app/components/app_layout_component/nav_item.html.erb new file mode 100644 index 00000000..6f8854dc --- /dev/null +++ b/app/components/app_layout_component/nav_item.html.erb @@ -0,0 +1,18 @@ +<%= 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 %> + <%= helpers.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/components/app_layout_component/nav_item.rb b/app/components/app_layout_component/nav_item.rb new file mode 100644 index 00000000..0f8a2992 --- /dev/null +++ b/app/components/app_layout_component/nav_item.rb @@ -0,0 +1,12 @@ +class AppLayoutComponent::NavItem < ViewComponent::Base + attr_reader :name, :path, :icon, :icon_custom, :active, :mobile_only + + def initialize(name:, path:, icon:, icon_custom: false, active: false, mobile_only: false) + @name = name + @path = path + @icon = icon + @icon_custom = icon_custom + @active = active + @mobile_only = mobile_only + end +end diff --git a/app/components/app_layout_controller.js b/app/components/app_layout_controller.js new file mode 100644 index 00000000..f953a930 --- /dev/null +++ b/app/components/app_layout_controller.js @@ -0,0 +1,39 @@ +import { Controller } from "@hotwired/stimulus"; + +// Connects to data-controller="dialog" +export default class extends Controller { + static targets = ["leftSidebar", "rightSidebar", "mobileSidebar"] + static classes = ["leftSidebar", "rightSidebar"] + + openMobileSidebar() { + this.mobileSidebarTarget.classList.remove("hidden") + } + + closeMobileSidebar() { + this.mobileSidebarTarget.classList.add("hidden") + } + + toggleLeftSidebar() { + this.#updateUserPreference("show_sidebar", this.leftSidebarTarget.classList.contains("hidden")) + this.leftSidebarTarget.classList.toggle("hidden") + } + + toggleRightSidebar() { + this.#updateUserPreference("show_ai_sidebar", this.rightSidebarTarget.classList.contains("hidden")) + this.rightSidebarTarget.classList.toggle("hidden") + } + + #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/helpers/application_helper.rb b/app/helpers/application_helper.rb index e3b1786e..d612b71f 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -62,11 +62,6 @@ module ApplicationHelper 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 @@ -126,49 +121,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/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/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/views/budgets/_budget_header.html.erb b/app/views/budgets/_budget_header.html.erb index 4fe2f252..c6c8ce21 100644 --- a/app/views/budgets/_budget_header.html.erb +++ b/app/views/budgets/_budget_header.html.erb @@ -29,7 +29,7 @@ <%= render MenuComponent.new(variant: "button") do |menu| %> <% menu.with_button class: "flex items-center gap-1 hover:bg-alpha-black-25 cursor-pointer rounded-md p-2" do %> - <%= @budget.name %> + <%= @budget.name %> <%= icon("chevron-down") %> <% end %> diff --git a/app/views/chats/index.html.erb b/app/views/chats/index.html.erb index 277a9b84..e38da133 100644 --- a/app/views/chats/index.html.erb +++ b/app/views/chats/index.html.erb @@ -1,31 +1,33 @@ -<%= turbo_frame_tag chat_frame do %> -
- +
+ <%= turbo_frame_tag chat_frame do %> +
+ -
-

Chats

+
+

Chats

- <% if @chats.any? %> -
- <%= render @chats %> -
- <% else %> -
-
- <%= icon("message-square", size: "lg") %> + <% if @chats.any? %> +
+ <%= render @chats %>
-

No chats yet

-

Start a new conversation with the AI assistant

-
-
- <%= render "messages/chat_form", chat: nil %> -
- <% end %> + <% else %> +
+
+ <%= icon("message-square", size: "lg") %> +
+

No chats yet

+

Start a new conversation with the AI assistant

+
+
+ <%= render "messages/chat_form", chat: nil %> +
+ <% end %> +
-
-<% end %> + <% end %> +
diff --git a/app/views/chats/show.html.erb b/app/views/chats/show.html.erb index 341eca01..cb52b632 100644 --- a/app/views/chats/show.html.erb +++ b/app/views/chats/show.html.erb @@ -1,35 +1,38 @@ -<%= turbo_frame_tag chat_frame do %> - <%= turbo_stream_from @chat %> +
+ <%= turbo_frame_tag chat_frame do %> + <%= turbo_stream_from @chat %> -

<%= @chat.title %>

+

<%= @chat.title %>

-
-
- <%= render "chats/chat_nav", chat: @chat %> -
+
+
+ <%= render "chats/chat_nav", chat: @chat %> +
-
- <% if @chat.conversation_messages.any? %> - <% @chat.conversation_messages.ordered.each do |message| %> - <%= render message %> +
+ <% if @chat.conversation_messages.any? %> + <% @chat.conversation_messages.ordered.each do |message| %> + <%= render message %> + <% end %> + <% else %> +
+ <%= render "chats/ai_greeting", context: "chat" %> +
<% end %> - <% else %> -
- <%= render "chats/ai_greeting", context: "chat" %> -
- <% end %> - <% if params[:thinking].present? %> - <%= render "chats/thinking_indicator", chat: @chat %> - <% end %> + <% if params[:thinking].present? %> + <%= render "chats/thinking_indicator", chat: @chat %> + <% end %> - <% if @chat.error.present? && @chat.needs_assistant_response? %> - <%= render "chats/error", chat: @chat %> - <% end %> + <% if @chat.error.present? && @chat.needs_assistant_response? %> + <%= render "chats/error", chat: @chat %> + <% end %> +
+ + <%# DESKTOP - Chat form %> +
+ <%= render "messages/chat_form", chat: @chat %> +
- -
- <%= render "messages/chat_form", chat: @chat %> -
-
-<% end %> + <% end %> +
diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index b2634ebb..d74eb8c3 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -1,104 +1,42 @@ <%= render "layouts/shared/htmldoc" do %> - <% sidebar_config = app_sidebar_config(Current.user) %> + <%= render AppLayoutComponent.new(user: Current.user) do |app_layout| %> + <% app_layout.with_nav_items([ + { name: "Home", path: root_path, icon: "pie-chart", active: page_active?(root_path) }, + { name: "Transactions", path: transactions_path, icon: "credit-card", active: page_active?(transactions_path) }, + { name: "Budgets", path: budgets_path, icon: "map", active: page_active?(budgets_path) }, + { name: "Assistant", path: chats_path, icon: "icon-assistant", icon_custom: true, active: page_active?(chats_path), mobile_only: true } + ]) %> -
- - - - <% unless controller_name == 'chats' %> - + <% app_layout.with_mobile_sidebar do %> + <%= render( + "accounts/account_sidebar_tabs", + family: Current.family, + active_account_group_tab: params[:account_group_tab] || "assets" + ) %> <% end %> -
- <%= tag.div class: class_names("py-4 shrink-0 h-full overflow-y-auto transition-all duration-300 hidden lg:block"), - style: "width: #{sidebar_config.dig(:left_panel, :initial_width)}px", - data: { sidebar_target: "leftPanel" } do %> - <% if content_for?(:sidebar) %> - <%= yield :sidebar %> - <% else %> -
- <%= render "accounts/account_sidebar_tabs", family: Current.family, active_account_group_tab: params[:account_group_tab] || "assets" %> -
- <% end %> + <% app_layout.with_mobile_user_menu do %> + <%= render "users/user_menu", user: Current.user, placement: "bottom-end", offset: 12 %> + <% end %> + + <% app_layout.with_desktop_user_menu do %> + <%= render "users/user_menu", user: Current.user %> + <% end %> + + <% app_layout.with_left_sidebar do %> + <% if content_for?(:sidebar) %> + <%= yield :sidebar %> + <% else %> +
+ <%= render "accounts/account_sidebar_tabs", family: Current.family, active_account_group_tab: params[:account_group_tab] || "assets" %> +
<% end %> + <% end %> - <%= tag.main class: class_names("px-3 lg:px-10 py-4 grow h-full", require_upgrade? ? "relative overflow-hidden" : "overflow-y-auto") do %> - <% if require_upgrade? %> -
- <%= render "shared/subscribe_modal" %> -
- <% end %> - - <%= tag.div class: class_names("mx-auto max-w-5xl w-full h-full"), data: { sidebar_target: "content" } do %> - <% if content_for?(:breadcrumbs) %> - <%= yield :breadcrumbs %> - <% else %> - <%= render "layouts/shared/breadcrumbs", breadcrumbs: @breadcrumbs %> - <% end %> - - <% if content_for?(:page_header) %> - <%= yield :page_header %> - <% end %> - - <%= yield %> - <% end %> - <% end %> - - <%# AI chat sidebar %> + <% app_layout.with_right_sidebar do %> <%= tag.div id: "chat-container", - style: "width: #{sidebar_config.dig(:right_panel, :initial_width)}px; overflow: #{sidebar_config.dig(:right_panel, :overflow)}", - class: class_names("flex flex-col justify-between shrink-0 transition-all duration-300 hidden lg:block"), - data: { controller: "chat hotkey", sidebar_target: "rightPanel", turbo_permanent: true } do %> + class: class_names("flex flex-col h-full justify-between shrink-0 transition-all duration-300"), + data: { controller: "chat hotkey", turbo_permanent: true } do %> <% if Current.user.ai_enabled? %> <%= turbo_frame_tag chat_frame, src: chat_view_path(@chat), loading: "lazy", class: "h-full" do %> @@ -110,26 +48,23 @@ <%= render "chats/ai_consent" %> <% end %> <% end %> + <% end %> + + <% app_layout.with_breadcrumbs do %> + <% if content_for?(:breadcrumbs) %> + <%= yield :breadcrumbs %> + <% else %> + <%= render "layouts/shared/breadcrumbs", breadcrumbs: @breadcrumbs %> + <% end %> + <% end %> + + <%# Main page content %> +
+ <% if content_for?(:page_header) %> + <%= yield :page_header %> + <% end %> + + <%= yield %>
- - -
+ <% end %> <% end %> 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 d48750dc..665fa4be 100644 --- a/app/views/layouts/shared/_breadcrumbs.html.erb +++ b/app/views/layouts/shared/_breadcrumbs.html.erb @@ -1,29 +1,17 @@ -<%# locals: (breadcrumbs:, sidebar_toggle_enabled: true) %> +<%# locals: (breadcrumbs:) %> - +
diff --git a/app/views/layouts/shared/_htmldoc.html.erb b/app/views/layouts/shared/_htmldoc.html.erb index 11016d3f..0a5b1b98 100644 --- a/app/views/layouts/shared/_htmldoc.html.erb +++ b/app/views/layouts/shared/_htmldoc.html.erb @@ -27,6 +27,10 @@
<% end %> + <% if require_upgrade? %> + <%= render "shared/subscribe_modal" %> + <% end %> + <%= turbo_frame_tag "modal" %> <%= turbo_frame_tag "drawer" %> 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 9b50af74..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 %> - <%= icon(icon_key, color: icon_color, custom: is_custom) %> - <% 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/settings/_settings_nav.html.erb b/app/views/settings/_settings_nav.html.erb index 208017bc..0a035206 100644 --- a/app/views/settings/_settings_nav.html.erb +++ b/app/views/settings/_settings_nav.html.erb @@ -93,7 +93,7 @@ <% end %> -