1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-08-09 15:35:22 +02:00

Fix app layout issues, move to component with slots

This commit is contained in:
Zach Gollwitzer 2025-04-29 22:52:34 -04:00
parent a61a752341
commit fe7843b00c
17 changed files with 318 additions and 389 deletions

View file

@ -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 %>
<div
class="hidden fixed inset-0 bg-surface z-20 h-dvh w-full p-3 overflow-y-auto transition-all duration-300"
data-app-layout-target="mobileSidebar">
<div class="mb-2">
<%= helpers.icon("x", as_button: true, data: { action: "app-layout#closeMobileSidebar" }) %>
</div>
<%= mobile_sidebar %>
</div>
<%# MOBILE - Top nav %>
<nav class="lg:hidden flex justify-between items-center p-3">
<%= helpers.icon("panel-left", as_button: true, data: { action: "app-layout#openMobileSidebar"}) %>
<%= link_to root_path, class: "block" do %>
<%= image_tag "logomark-color.svg", class: "w-9 h-9 mx-auto" %>
<% end %>
<%= mobile_user_menu %>
</nav>
<%# DESKTOP - Left navbar %>
<div class="hidden lg:block">
<nav class="h-full flex flex-col shrink-0 w-[84px] py-4 mr-3">
<div class="pl-2 mb-3">
<%= link_to root_path, class: "block" do %>
<%= image_tag "logomark-color.svg", class: "w-9 h-9 mx-auto" %>
<% end %>
</div>
<ul class="space-y-0.5">
<% nav_items.reject { |item| item.mobile_only }.each do |nav_item| %>
<li><%= nav_item %></li>
<% end %>
</ul>
<div class="pl-2 mt-auto mx-auto">
<%= desktop_user_menu %>
</div>
</nav>
</div>
<%# 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 %>
<div class="hidden lg:flex gap-2 items-center justify-between mb-6">
<div class="flex items-center gap-2">
<%= helpers.icon("panel-left", as_button: true, data: { action: "app-layout#toggleLeftSidebar" }) %>
<%= breadcrumbs %>
</div>
<%= helpers.icon("panel-right", as_button: true, data: { action: "app-layout#toggleRightSidebar" }) %>
</div>
<%= 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 %>

View file

@ -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

View file

@ -0,0 +1,18 @@
<%= link_to path, class: "space-y-1 group block relative pb-1" do %>
<div class="grow flex flex-col lg:flex-row gap-1 items-center">
<%= 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 %>
</div>
<div class="grow flex justify-center lg:pl-2">
<%= tag.p class: class_names("font-medium text-[11px]", active ? "text-primary" : "text-secondary") do %>
<%= name %>
<% end %>
</div>
<% end %>

View file

@ -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

View file

@ -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(),
});
}
}

View file

@ -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? }

View file

@ -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);

View file

@ -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(),
});
}
}

View file

@ -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 %>
<span class="text-primary font-medium text-3xl md:text-base"><%= @budget.name %></span>
<span class="text-primary font-medium text-lg lg:text-base"><%= @budget.name %></span>
<%= icon("chevron-down") %>
<% end %>

View file

@ -1,3 +1,4 @@
<div data-controller="chat hotkey">
<%= turbo_frame_tag chat_frame do %>
<div class="flex flex-col h-full md:p-4">
<nav class="mb-6">
@ -29,3 +30,4 @@
</div>
</div>
<% end %>
</div>

View file

@ -1,3 +1,4 @@
<div data-controller="chat hotkey">
<%= turbo_frame_tag chat_frame do %>
<%= turbo_stream_from @chat %>
@ -8,7 +9,7 @@
<%= render "chats/chat_nav", chat: @chat %>
</div>
<div id="messages" class="grow overflow-y-auto p-4 space-y-6" data-chat-target="messages">
<div id="messages" class="grow overflow-y-auto p-4 space-y-6 pb-24 lg:pb-4" data-chat-target="messages">
<% if @chat.conversation_messages.any? %>
<% @chat.conversation_messages.ordered.each do |message| %>
<%= render message %>
@ -28,8 +29,10 @@
<% end %>
</div>
<div class="p-4 lg:mt-auto">
<%# DESKTOP - Chat form %>
<div class="p-4 lg:mt-auto fixed lg:static left-0 bottom-16 w-full bg-surface">
<%= render "messages/chat_form", chat: @chat %>
</div>
</div>
<% end %>
</div>

View file

@ -1,68 +1,29 @@
<%= 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 }
]) %>
<div class="flex flex-col lg:flex-row h-dvh lg:h-full bg-surface pt-safe"
data-controller="sidebar"
data-sidebar-user-id-value="<%= Current.user.id %>"
data-sidebar-config-value="<%= sidebar_config.to_json %>">
<button hidden data-controller="hotkey" data-hotkey="b" data-action="sidebar#toggleLeftPanel">Toggle accounts</button>
<button hidden data-controller="hotkey" data-hotkey="l" data-action="sidebar#toggleRightPanel">Toggle chat</button>
<% unless controller_name == 'chats' %>
<nav class="flex justify-between lg:justify-start lg:flex-col shrink-0 lg:w-[84px] p-3 lg:px-0 lg:py-4 lg:mr-3">
<button data-action="sidebar#toggleLeftPanelMobile" class="lg:hidden inline-flex p-2 rounded-lg items-center justify-center hover:bg-gray-100 cursor-pointer">
<%= icon("panel-left", color: "gray") %>
</button>
<%# Mobile only account sidebar groups %>
<%= tag.div class: class_names("hidden bg-surface z-20 absolute inset-0 h-dvh w-full p-4 overflow-y-auto transition-all duration-300 pt-safe"),
data: { sidebar_target: "leftPanelMobile" } do %>
<div id="account-sidebar-tabs" class="pt-6">
<div class="mb-4">
<button data-action="sidebar#toggleLeftPanelMobile">
<%= icon("x", color: "gray") %>
</button>
</div>
<%= render "accounts/account_sidebar_tabs", family: Current.family, active_account_group_tab: params[:account_group_tab] || "assets" %>
</div>
<% app_layout.with_mobile_sidebar do %>
<%= render(
"accounts/account_sidebar_tabs",
family: Current.family,
active_account_group_tab: params[:account_group_tab] || "assets"
) %>
<% end %>
<div class="lg:pl-2 lg:mb-3">
<%= link_to root_path, class: "block" do %>
<%= image_tag "logomark-color.svg", class: "w-9 h-9 mx-auto" %>
<% end %>
</div>
<ul class="space-y-0.5 hidden lg:block">
<li>
<%= render "layouts/sidebar/nav_item", name: "Home", path: root_path, icon_key: "pie-chart" %>
</li>
<li>
<%= render "layouts/sidebar/nav_item", name: "Transactions", path: transactions_path, icon_key: "credit-card" %>
</li>
<li>
<%= render "layouts/sidebar/nav_item", name: "Budgets", path: budgets_path, icon_key: "map" %>
</li>
</ul>
<div class="lg:pl-2 lg:mt-auto lg:mx-auto">
<div class="lg:hidden">
<% app_layout.with_mobile_user_menu do %>
<%= render "users/user_menu", user: Current.user, placement: "bottom-end", offset: 12 %>
</div>
<div class="hidden lg:block">
<%= render "users/user_menu", user: Current.user %>
</div>
</div>
</nav>
<% end %>
<div class="flex justify-between lg:justify-normal grow overflow-y-auto">
<%= 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 %>
<% 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 %>
@ -72,33 +33,10 @@
<% 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? %>
<div class="absolute inset-0 px-3 lg:px-10 h-full w-full z-50">
<%= render "shared/subscribe_modal" %>
</div>
<% 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 %>
</div>
<% end %>
<nav class="lg:hidden bg-surface md:bg-container shrink-0 z-10 pb-2 border-t border-tertiary pb-safe">
<ul class="flex items-center justify-around gap-1">
<li>
<%= render "layouts/sidebar/nav_item", name: "Home", path: root_path, icon_key: "pie-chart" %>
</li>
<% app_layout.with_breadcrumbs do %>
<% if content_for?(:breadcrumbs) %>
<%= yield :breadcrumbs %>
<% else %>
<%= render "layouts/shared/breadcrumbs", breadcrumbs: @breadcrumbs %>
<% end %>
<% end %>
<li>
<%= render "layouts/sidebar/nav_item", name: "Transactions", path: transactions_path, icon_key: "credit-card" %>
</li>
<%# Main page content %>
<div>
<% if content_for?(:page_header) %>
<%= yield :page_header %>
<% end %>
<li>
<%= render "layouts/sidebar/nav_item", name: "Budgets", path: budgets_path, icon_key: "map" %>
</li>
<li>
<%= render "layouts/sidebar/nav_item", name: "Assistant", path: chats_path, icon_key: "icon-assistant", is_custom: true %>
</li>
</ul>
</nav>
<%= yield %>
</div>
<% end %>
<% end %>

View file

@ -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) %>

View file

@ -1,9 +1,4 @@
<%# locals: (breadcrumbs:, sidebar_toggle_enabled: true) %>
<nav class="items-center gap-2 mb-6 hidden md:flex">
<% if sidebar_toggle_enabled %>
<%= icon("panel-left", as_button: true, data: { action: "sidebar#toggleLeftPanel" }) %>
<% end %>
<%# locals: (breadcrumbs:) %>
<div class="py-2 flex items-center gap-2">
<% breadcrumbs.each_with_index do |(name, path), index| %>
@ -20,10 +15,3 @@
<% end %>
<% end %>
</div>
<% if sidebar_toggle_enabled %>
<div class="ml-auto">
<%= icon("panel-right", as_button: true, data: { action: "sidebar#toggleRightPanel" }, title: "Toggle AI assistant") %>
</div>
<% end %>
</nav>

View file

@ -27,6 +27,10 @@
</div>
<% end %>
<% if require_upgrade? %>
<%= render "shared/subscribe_modal" %>
<% end %>
<%= turbo_frame_tag "modal" %>
<%= turbo_frame_tag "drawer" %>

View file

@ -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 %>
<div class="grow flex flex-col lg:flex-row gap-1 items-center">
<%= 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 %>
</div>
<div class="grow flex justify-center lg:pl-2">
<%= tag.p class: class_names("font-medium text-[11px]", page_active?(path) ? "text-primary" : "text-secondary") do %>
<%= name %>
<% end %>
</div>
<% end %>

View file

@ -93,7 +93,7 @@
<% end %>
</section>
</nav>
<nav class="space-y-4 overflow-y-auto md:hidden" id="mobile-settings-nav" data-preserve-scroll data-controller="preserve-scroll">
<nav class="space-y-4 overflow-y-auto md:hidden" id="mobile-settings-nav">
<ul class="flex space-y-1">