1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-07-18 20:59:39 +02:00

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
This commit is contained in:
Zach Gollwitzer 2025-04-30 18:14:22 -04:00 committed by GitHub
parent 1aafed5f8b
commit 90a9546f32
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
291 changed files with 4143 additions and 3104 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,38 @@
<turbo-frame id="<%= frame %>">
<%= 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 %>
<div class="grow overflow-y-auto py-4 space-y-4">
<% if header? %>
<%= header %>
<% end %>
<% if body? %>
<div class="px-4">
<%= body %>
<% if sections.any? %>
<div class="space-y-4">
<% sections.each do |section| %>
<%= section %>
<% end %>
</div>
<% end %>
</div>
<% end %>
<%# Optional, for customizing dialogs %>
<%= content %>
</div>
<% if actions? %>
<div class="flex items-center gap-2 justify-end p-4">
<% actions.each do |action| %>
<%= action %>
<% end %>
</div>
<% end %>
<% end %>
<% end %>
<% end %>
</turbo-frame>

View file

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

View file

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

View file

@ -0,0 +1,25 @@
<details class="group" <%= "open" if open %>>
<%= tag.summary class: class_names(
"px-3 py-2 rounded-xl cursor-pointer flex items-center justify-between bg-surface"
) do %>
<div class="flex items-center gap-3">
<% 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 %>
</div>
<% 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 %>
<div class="mt-2">
<%= content %>
</div>
</details>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 %>
<button data-menu-target="button">
<div class="w-9 h-9 cursor-pointer">
<%= render "settings/user_avatar", avatar_url: avatar_url %>
</div>
</button>
<% end %>
<div data-menu-target="content" class="px-2 lg:px-0 max-w-full hidden z-50">
<div class="mx-auto min-w-[200px] shadow-border-xs bg-container rounded-lg">
<%= header %>
<%= tag.div class: class_names("py-1" => !no_padding) do %>
<% items.each do |item| %>
<%= item %>
<% end %>
<%= custom_content %>
<% end %>
</div>
</div>
<% end %>

View file

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

View file

@ -0,0 +1,12 @@
<% if variant == :divider %>
<hr class="border-tertiary my-1">
<% else %>
<div class="px-1">
<%= wrapper do %>
<% if icon %>
<%= lucide_icon(icon, class: destructive? ? "text-destructive" : "fg-gray") %>
<% end %>
<%= tag.span(text, class: text_classes) %>
<% end %>
</div>
<% end %>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,5 @@
<div class="relative inline-block select-none">
<%= 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, "&nbsp;".html_safe, class: label_classes, for: id %>
</div>

View file

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

View file

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

View file

@ -0,0 +1,3 @@
class LookbooksController < Lookbook::PreviewController
layout "lookbooks"
end

View file

@ -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 %>
# <div>Content here</div>
# <% 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 %>
# <div>Content here</div>
# <% 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? }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,8 @@
import { Controller } from "@hotwired/stimulus";
// Connects to data-controller="intercom"
export default class extends Controller {
show() {
Intercom("show");
}
}

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

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

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

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

View file

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

View file

@ -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 {
<div style="margin-bottom: 4px; color: var(--color-gray-500);">
${datum.date_formatted}
</div>
<div style="display: flex; align-items: center; gap: 16px; margin-bottom: 0px;">
<div style="display: flex; align-items: center; gap: 8px; color: var(--color-black);">
<div style="display: flex; align-items: center; justify-content: center; height: 16px; width: 16px;">
${datum.trend.previous.amount === datum.trend.current.amount ? `
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="${datum.trend.color}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-minus-icon lucide-minus"><path d="M5 12h14"/></svg>
` : Number(datum.trend.previous.amount) < Number(datum.trend.current.amount) ? `
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="${datum.trend.color}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-arrow-up-right-icon lucide-arrow-up-right"><path d="M7 7h10v10"/><path d="M7 17 17 7"/></svg>
` : `
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="${datum.trend.color}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-arrow-down-right-icon lucide-arrow-down-right"><path d="m7 7 10 10"/><path d="M17 7v10H7"/></svg>
`}
<div class="flex items-center gap-4">
<div class="flex items-center gap-2 text-primary">
<div class="flex items-center justify-center h-4 w-4">
${this._getTrendIcon(datum)}
</div>
${this._extractFormattedValue(datum.trend.current)}
</div>
${
datum.trend.value === 0
? `<span style="width: 80px;"></span>`
? `<span class="w-20"></span>`
: `
<span style="color: ${datum.trend.color};">
${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 `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="${datum.trend.color}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-arrow-up-right-icon lucide-arrow-up-right"><path d="M7 7h10v10"/><path d="M7 17 17 7"/></svg>`;
}
if (isDecrease) {
return `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="${datum.trend.color}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-arrow-down-right-icon lucide-arrow-down-right"><path d="m7 7 10 10"/><path d="M17 7v10H7"/></svg>`;
}
return `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="${datum.trend.color}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-minus-icon lucide-minus"><path d="M5 12h14"/></svg>`;
}
_getDatumValue = (datum) => {
return this._extractNumericValue(datum.trend.current);
};

View file

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

View file

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

View file

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

View file

@ -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 %>
</div>
@ -35,7 +35,9 @@
</p>
<% 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 %>
</div>
</div>

View file

@ -1,88 +1,91 @@
<%# locals: (family:) %>
<%# locals: (family:, active_account_group_tab:) %>
<% if family.requires_data_provider? && Provider::Registry.get_provider(:synth).nil? %>
<details class="group bg-yellow-tint-10 rounded-lg p-2 text-yellow-600 mb-3 text-xs">
<summary class="flex items-center justify-between gap-2">
<div class="flex items-center gap-2">
<%= icon "triangle-alert", size: "sm" %>
<p class="font-medium">Missing historical data</p>
<div>
<% if family.missing_data_provider? %>
<details class="group bg-yellow-tint-10 rounded-lg p-2 text-yellow-600 mb-3 text-xs">
<summary class="flex items-center justify-between gap-2">
<div class="flex items-center gap-2">
<%= icon "triangle-alert", size: "sm", color: "warning" %>
<p class="font-medium">Missing historical data</p>
</div>
<%= icon("chevron-down", color: "warning", class: "group-open:transform group-open:rotate-180") %>
</summary>
<div class="text-xs py-2 space-y-2">
<p>Maybe uses Synth API to fetch historical exchange rates, security prices, and more. This data is required to calculate accurate historical account balances.</p>
<p>
<%= link_to "Add your Synth API key here.", settings_hosting_path, class: "text-yellow-600 underline" %>
</p>
</div>
</details>
<% end %>
<%= lucide_icon "chevron-down", class: "text-yellow-600 group-open:transform group-open:rotate-180 w-5" %>
</summary>
<div class="text-xs py-2 space-y-2">
<p>Maybe uses Synth API to fetch historical exchange rates, security prices, and more. This data is required to calculate accurate historical account balances.</p>
<p>
<%= link_to "Add your Synth API key here.", settings_hosting_path, class: "text-yellow-600 underline" %>
</p>
</div>
</details>
<% end %>
<div
class="space-y-3"
data-controller="tabs"
data-tabs-local-storage-key-value="account-sidebar-tabs"
data-tabs-active-class="bg-surface shadow-sm text-primary"
data-tabs-inactive-class="text-secondary"
data-tabs-default-tab-value="assets-tab">
<div class="bg-surface-inset rounded-lg p-1 flex">
<button type="button" data-id="assets-tab" class="w-1/3 px-2 py-1 rounded-md text-sm text-secondary font-medium" data-tabs-target="btn" data-action="click->tabs#select">
Assets
</button>
<button type="button" data-id="debts-tab" class="w-1/3 px-2 py-1 rounded-md text-secondary text-sm font-medium" data-tabs-target="btn" data-action="click->tabs#select">
Debts
</button>
<button type="button" data-id="all-tab" class="w-1/3 px-2 py-1 rounded-md text-secondary text-sm font-medium" data-tabs-target="btn" data-action="click->tabs#select">
All
</button>
</div>
<div data-tabs-target="tab" id="assets-tab">
<%= 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") %>
<span>New asset</span>
<%= 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 %>
<div class="space-y-2">
<% family.balance_sheet.account_groups("asset").each do |group| %>
<%= render "accounts/accountable_group", account_group: group %>
<% end %>
</div>
</div>
<% tabs.with_panel(tab_id: "assets") do %>
<div class="space-y-2">
<%= 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"
) %>
<div data-tabs-target="tab" id="debts-tab" class="hidden">
<%= link_to new_account_path(step: "method_select", classification: "liability"),
class: "flex items-center gap-3 btn btn--ghost text-secondary mb-1",
data: { turbo_frame: "modal" } do %>
<%= icon("plus") %>
<span>New debt</span>
<div>
<% family.balance_sheet.account_groups("asset").each do |group| %>
<%= render "accounts/accountable_group", account_group: group %>
<% end %>
</div>
</div>
<% end %>
<div class="space-y-2">
<% family.balance_sheet.account_groups("liability").each do |group| %>
<%= render "accounts/accountable_group", account_group: group %>
<% end %>
</div>
</div>
<% tabs.with_panel(tab_id: "debts") do %>
<div class="space-y-2">
<%= 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"
) %>
<div data-tabs-target="tab" id="all-tab" class="hidden">
<%= link_to new_account_path(step: "method_select"),
class: "flex items-center gap-3 btn btn--ghost text-secondary mb-1",
data: { turbo_frame: "modal" } do %>
<%= icon("plus") %>
<span>New account</span>
<div>
<% family.balance_sheet.account_groups("liability").each do |group| %>
<%= render "accounts/accountable_group", account_group: group %>
<% end %>
</div>
</div>
<% end %>
<div class="space-y-2">
<% family.balance_sheet.account_groups.each do |group| %>
<%= render "accounts/accountable_group", account_group: group %>
<% end %>
</div>
</div>
<% tabs.with_panel(tab_id: "all") do %>
<div class="space-y-2">
<%= 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"
) %>
<div>
<% family.balance_sheet.account_groups.each do |group| %>
<%= render "accounts/accountable_group", account_group: group %>
<% end %>
</div>
</div>
<% end %>
<% end %>
</div>

View file

@ -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 %>
<span style="background-color: color-mix(in srgb, <%= accountable.color %> 10%, white);" class="flex w-8 h-8 shrink-0 grow-0 items-center justify-center rounded-lg border border-alpha-black-25">
<%= lucide_icon(accountable.icon, style: "color: #{accountable.color}", class: "w-5 h-5") %>
</span>
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 %>

View file

@ -1,11 +1,7 @@
<%# locals: (account_group:) %>
<details class="group" data-controller="account-collapse" data-account-collapse-type-value="<%= account_group.key %>">
<summary class="px-3 py-2 flex items-center gap-3 cursor-pointer h-10 mb-1">
<%= 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 %>
<div class="ml-auto text-right grow">
<%= tag.p format_money(account_group.total_money), class: "text-sm font-medium text-primary" %>
@ -15,11 +11,11 @@
</div>
<% end %>
</div>
</summary>
<% end %>
<div class="space-y-1">
<% 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 %>
<div class="min-w-0 grow">
@ -40,10 +36,13 @@
<% end %>
</div>
<%= 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") %>
<span>New <%= account_group.name.downcase.singularize %></span>
<% end %>
</details>
<%= 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 %>

View file

@ -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") %>
<span><%= t(".new_account") %></span>
<% end %>
<%= render LinkComponent.new(
text: t(".new_account"),
href: new_account_path,
frame: :modal
) %>
</div>
</div>

View file

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

View file

@ -2,23 +2,23 @@
<h1 class="text-xl"><%= t(".accounts") %></h1>
<div class="flex items-center gap-5">
<div class="flex items-center gap-2">
<%= 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" %>
<span class="hidden md:inline"><%= t(".sync") %></span>
<% 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 %>
<div class="flex items-center justify-center w-5 h-5">
<%= lucide_icon("plus") %>
</div>
<p class="hidden md:block text-sm font-medium"><%= t(".new_account") %></p>
<% end %>
<%= render LinkComponent.new(
text: "New account",
href: new_account_path(return_to: accounts_path),
variant: "primary",
icon: "plus",
frame: :modal
) %>
</div>
</div>
</header>

View file

@ -2,10 +2,10 @@
<details open class="group bg-container p-4 shadow-border-xs rounded-xl">
<summary class="flex items-center gap-2 focus-visible:outline-hidden">
<%= 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") %>
<div class="flex items-center justify-center h-8 w-8 rounded-full bg-black/5">
<%= lucide_icon("folder-pen", class: "w-5 h-5 text-secondary") %>
<%= icon("folder-pen") %>
</div>
<span class="mr-auto text-sm font-medium text-primary"><%= t(".other_accounts") %></span>

View file

@ -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 %>
<span style="background-color: color-mix(in srgb, #F79009 10%, white);" class="flex w-8 h-8 shrink-0 grow-0 items-center justify-center rounded-lg border border-alpha-black-25">
<%= lucide_icon("download", style: "color: #F79009", class: "w-5 h-5") %>
</span>
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 %>

View file

@ -1,18 +1,22 @@
<%# locals: (title:, back_path: nil) %>
<%= modal do %>
<div class="flex flex-col w-screen max-w-xl relative" data-controller="list-keyboard-navigation">
<div class="border-b border-tertiary md:border-alpha-black-25 p-4 text-gray-800 flex items-center space-x-3">
<% 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| %>
<div class="flex flex-col relative" data-controller="list-keyboard-navigation">
<div class="border-b border-tertiary md:border-alpha-black-25 px-4 pb-4 text-gray-800 flex items-center justify-between gap-2">
<div class="flex items-center gap-2">
<% if back_path %>
<%= render LinkComponent.new(
variant: "icon",
icon: "arrow-left",
href: back_path,
size: "lg"
) %>
<% end %>
<% end %>
<span class="text-primary"><%= title %></span>
<button class="absolute top-1/2 -translate-y-1/2 right-4 flex w-8 h-8 items-center justify-center rounded-lg md:hidden outline-0" data-action="click->modal#close">
<%= lucide_icon("x", class: "text-secondary w-6 h-6") %>
</button>
<span class="text-primary"><%= title %></span>
</div>
<%= icon("x", as_button: true, size: "lg", data: { action: "dialog#close" }) %>
</div>
<div class="p-2 text-subdued">
@ -22,20 +26,26 @@
<%= yield %>
</div>
<div class="border-t border-alpha-black-25 p-4 text-secondary text-sm justify-between hidden md:flex">
<div class="border-t border-alpha-black-25 px-4 pt-4 text-secondary text-sm justify-between hidden md:flex">
<div class="flex space-x-5">
<div class="flex items-center space-x-2">
<span>Select</span>
<kbd class="bg-alpha-black-50 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.1)] p-1 rounded-md flex w-5 h-5 shrink-0 grow-0 items-center justify-center"><%= lucide_icon("corner-down-left", class: "inline w-3 h-3") %></kbd>
<kbd class="bg-alpha-black-50 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.1)] p-1 rounded-md flex w-5 h-5 shrink-0 grow-0 items-center justify-center">
<%= icon("corner-down-left", size: "xs") %>
</kbd>
</div>
<div class="flex items-center space-x-2">
<span>Navigate</span>
<kbd class="bg-alpha-black-50 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.1)] p-1 rounded-md flex w-5 h-5 shrink-0 grow-0 items-center justify-center"><%= lucide_icon("arrow-up", class: "inline w-3 h-3") %></kbd>
<kbd class="bg-alpha-black-50 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.1)] p-1 rounded-md flex w-5 h-5 shrink-0 grow-0 items-center justify-center"><%= lucide_icon("arrow-down", class: "inline w-3 h-3") %></kbd>
<kbd class="bg-alpha-black-50 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.1)] p-1 rounded-md flex w-5 h-5 shrink-0 grow-0 items-center justify-center">
<%= icon("arrow-up", size: "xs") %>
</kbd>
<kbd class="bg-alpha-black-50 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.1)] p-1 rounded-md flex w-5 h-5 shrink-0 grow-0 items-center justify-center">
<%= icon("arrow-down", size: "xs") %>
</kbd>
</div>
</div>
<div class="flex items-center space-x-2">
<button data-action="modal#close">Close</button>
<button data-action="dialog#close">Close</button>
<kbd class="bg-alpha-black-50 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.1)] p-1 rounded-md flex w-8 h-5 shrink-0 grow-0 items-center justify-center text-xs">ESC</kbd>
</div>
</div>

View file

@ -2,18 +2,18 @@
<%= render layout: "accounts/new/container", locals: { title: t(".title"), back_path: new_account_path } do %>
<div class="text-sm">
<%= link_to path, class: "flex items-center gap-4 w-full text-center focus:outline-hidden focus:bg-surface border border-transparent focus:border focus:border-gray-200 px-2 hover:bg-surface rounded-lg p-2" do %>
<%= link_to path, class: "flex items-center gap-4 w-full text-center text-primary focus:outline-hidden focus:bg-surface border border-transparent focus:border focus:border-gray-200 px-2 hover:bg-surface rounded-lg p-2" do %>
<span class="flex w-8 h-8 shrink-0 grow-0 items-center justify-center rounded-lg bg-alpha-black-50 shadow-[inset_0_0_0_1px_rgba(0,0,0,0.02)]">
<%= lucide_icon("keyboard", class: "text-secondary w-5 h-5") %>
<%= icon("keyboard") %>
</span>
<%= t("accounts.new.method_selector.manual_entry") %>
<% end %>
<% if us_link_token %>
<%# Default US-only Link %>
<button data-controller="plaid" data-action="plaid#open modal#close" data-plaid-region-value="us" data-plaid-link-token-value="<%= us_link_token %>" class="flex items-center gap-4 w-full text-center focus:outline-hidden focus:bg-gray-50 border border-transparent focus:border focus:border-gray-200 px-2 hover:bg-gray-50 rounded-lg p-2">
<button data-controller="plaid" data-action="plaid#open dialog#close" data-plaid-region-value="us" data-plaid-link-token-value="<%= us_link_token %>" class="text-primary flex items-center gap-4 w-full text-center focus:outline-hidden focus:bg-gray-50 border border-transparent focus:border focus:border-gray-200 px-2 hover:bg-gray-50 rounded-lg p-2">
<span class="flex w-8 h-8 shrink-0 grow-0 items-center justify-center rounded-lg bg-alpha-black-50 shadow-[inset_0_0_0_1px_rgba(0,0,0,0.02)]">
<%= lucide_icon("link-2", class: "text-secondary w-5 h-5") %>
<%= icon("link-2") %>
</span>
<%= t("accounts.new.method_selector.connected_entry") %>
</button>
@ -21,9 +21,9 @@
<%# EU Link %>
<% if eu_link_token %>
<button data-controller="plaid" data-action="plaid#open modal#close" data-plaid-region-value="eu" data-plaid-link-token-value="<%= eu_link_token %>" class="flex items-center gap-4 w-full text-center focus:outline-hidden focus:bg-gray-50 border border-transparent focus:border focus:border-gray-200 px-2 hover:bg-gray-50 rounded-lg p-2">
<button data-controller="plaid" data-action="plaid#open dialog#close" data-plaid-region-value="eu" data-plaid-link-token-value="<%= eu_link_token %>" class="text-primary flex items-center gap-4 w-full text-center focus:outline-hidden focus:bg-gray-50 border border-transparent focus:border focus:border-gray-200 px-2 hover:bg-gray-50 rounded-lg p-2">
<span class="flex w-8 h-8 shrink-0 grow-0 items-center justify-center rounded-lg bg-alpha-black-50 shadow-[inset_0_0_0_1px_rgba(0,0,0,0.02)]">
<%= lucide_icon("link-2", class: "text-secondary w-5 h-5") %>
<%= icon("link-2") %>
</span>
<%= t("accounts.new.method_selector.connected_entry_eu") %>
</button>

View file

@ -2,30 +2,29 @@
<%= turbo_frame_tag dom_id(account, "entries") do %>
<div class="bg-container p-5 shadow-border-xs rounded-xl" data-controller="focus-record" data-focus-record-id-value="<%= @focused_record ? dom_id(@focused_record) : nil %>">
<div class="flex items-center justify-between mb-4">
<div class="flex items-center justify-between mb-4" data-testid="activity-menu">
<%= tag.h2 t(".title"), class: "font-medium text-lg" %>
<% unless @account.plaid_account_id.present? %>
<div data-controller="menu" data-testid="activity-menu">
<button class="btn btn--secondary flex items-center gap-2" data-menu-target="button">
<%= lucide_icon("plus", class: "w-4 h-4") %>
<%= tag.span t(".new") %>
</button>
<div data-menu-target="content" class="z-10 hidden bg-container rounded-lg border border-alpha-black-25 shadow-xs p-1">
<%= link_to new_valuation_path(account_id: @account.id), data: { turbo_frame: :modal }, class: "block p-2 rounded-lg hover:bg-gray-50 flex items-center gap-2" do %>
<%= lucide_icon("circle-dollar-sign", class: "text-secondary w-5 h-5") %>
<%= tag.span t(".new_balance"), class: "text-sm" %>
<% end %>
<%= render MenuComponent.new(variant: "button") do |menu| %>
<% menu.with_button(text: "New", variant: "secondary", icon: "plus") %>
<% unless @account.crypto? %>
<%= link_to @account.investment? ? new_trade_path(account_id: @account.id) : new_transaction_path(account_id: @account.id), data: { turbo_frame: :modal }, 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" do %>
<span class="flex items-center justify-center">
<%= lucide_icon("credit-card", class: "text-secondary w-5 h-5") %>
</span>
<%= tag.span t(".new_transaction"), class: "text-sm md:block" %>
<% end %>
<% end %>
</div>
</div>
<% menu.with_item(
variant: "link",
text: "New balance",
icon: "circle-dollar-sign",
href: new_valuation_path(account_id: @account.id),
data: { turbo_frame: :modal }) %>
<% unless @account.crypto? %>
<% href = @account.investment? ? new_trade_path(account_id: @account.id) : new_transaction_path(account_id: @account.id) %>
<% menu.with_item(
variant: "link",
text: "New transaction",
icon: "credit-card",
href: href,
data: { turbo_frame: :modal }) %>
<% end %>
<% end %>
<% end %>
</div>
@ -38,7 +37,7 @@
<div class="flex gap-2 mb-4">
<div class="grow">
<div class="flex items-center px-3 py-2 gap-2 border border-secondary rounded-lg focus-within:ring-gray-100 focus-within:border-gray-900">
<%= lucide_icon("search", class: "w-5 h-5 text-secondary") %>
<%= icon("search") %>
<%= hidden_field_tag :account_id, @account.id %>
<%= form.search_field :search,
placeholder: "Search entries by name",

View file

@ -24,7 +24,11 @@
data: { "auto-submit-form-target": "auto" } %>
<% end %>
<%= period_select form: form, selected: period %>
<%= form.select :period,
Period.as_options,
{ selected: period.key },
data: { "auto-submit-form-target": "auto" },
class: "bg-container border border-secondary font-medium rounded-lg px-3 py-2 text-sm pr-7 cursor-pointer text-primary focus:outline-hidden focus:ring-0" %>
</div>
<% end %>
</div>

View file

@ -19,17 +19,27 @@
</div>
<% end %>
<div class="flex items-center gap-3 ml-auto">
<div class="flex items-center gap-1 ml-auto">
<% if account.plaid_account_id.present? %>
<% if Rails.env.development? %>
<%= button_to sync_plaid_item_path(account.plaid_account.plaid_item), disabled: account.syncing?, data: { turbo: false }, class: "flex items-center gap-2", title: "Sync Account" do %>
<%= lucide_icon "refresh-cw", class: "w-4 h-4 text-secondary hover:text-subdued" %>
<% end %>
<%= icon(
"refresh-cw",
as_button: true,
size: "sm",
href: sync_plaid_item_path(account.plaid_account.plaid_item),
disabled: account.syncing?,
frame: :_top
) %>
<% end %>
<% else %>
<%= button_to sync_account_path(account), disabled: account.syncing?, data: { turbo: false }, class: "flex items-center gap-2", title: "Sync Account" do %>
<%= lucide_icon "refresh-cw", class: "w-4 h-4 text-secondary hover:text-subdued" %>
<% end %>
<%= icon(
"refresh-cw",
as_button: true,
size: "sm",
href: sync_account_path(account),
disabled: account.syncing?,
frame: :_top
) %>
<% end %>
<%= render "accounts/show/menu", account: account %>

View file

@ -1,47 +1,25 @@
<%# locals: (account:) %>
<%= contextual_menu do %>
<div class="w-48 p-1 text-sm leading-6 text-primary bg-container shadow-lg shrink rounded-xl ring-1 ring-gray-900/5">
<% if account.plaid_account_id.present? %>
<%= link_to accounts_path,
data: { turbo_frame: :_top },
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 "pencil-line", class: "w-5 h-5 text-secondary" %>
<%= render MenuComponent.new(testid: "account-menu") do |menu| %>
<% menu.with_item(variant: "link", text: "Edit", href: edit_account_path(account), icon: "pencil-line", data: { turbo_frame: :modal }) %>
<span><%= t(".manage") %></span>
<% end %>
<% else %>
<%= link_to edit_account_path(account),
data: { turbo_frame: :modal },
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 "pencil-line", class: "w-5 h-5 text-secondary" %>
<% unless account.crypto? %>
<% menu.with_item(
variant: "link",
text: "Import transactions",
href: imports_path({ import: { type: account.investment? ? "TradeImport" : "TransactionImport", account_id: account.id } }),
icon: "download",
data: { turbo_frame: :_top }
) %>
<% end %>
<span><%= t(".edit") %></span>
<% end %>
<% unless account.crypto? %>
<%= button_to imports_path({ import: { type: account.investment? ? "TradeImport" : "TransactionImport", account_id: account.id } }),
data: { turbo_frame: :_top },
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 "download", class: "w-5 h-5 text-secondary" %>
<span><%= t(".import") %></span>
<% end %>
<% end %>
<%= button_to account_path(account),
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_frame: :_top,
turbo_confirm: {
title: t(".confirm_title"),
body: t(".confirm_body_html"),
accept: t(".confirm_accept", name: account.name)
}
} do %>
<%= lucide_icon("trash-2", class: "w-5 h-5 mr-2") %> Delete account
<% end %>
<% end %>
</div>
<% menu.with_item(
variant: "button",
text: "Delete account",
href: account_path(account),
method: :delete,
icon: "trash-2",
confirm: CustomConfirm.for_resource_deletion("Account", high_severity: true),
data: { turbo_frame: :_top }
) %>
<% end %>

View file

@ -1,11 +1,17 @@
<%# locals: (account:, tabs:) %>
<% selected_tab = tabs.find { |tab| tab[:key] == params[:tab] } || tabs.first %>
<% active_tab = tabs.find { |tab| tab[:key] == params[:tab] } || tabs.first %>
<div class="flex gap-2 text-sm text-primary font-medium mb-4">
<% tabs.each do |tab| %>
<%= render "accounts/show/tab", account: account, key: tab[:key], is_selected: selected_tab[:key] == tab[:key] %>
<%= render TabsComponent.new(active_tab: active_tab[:key], url_param_key: "tab") do |tabs_container| %>
<% tabs_container.with_nav(classes: "max-w-fit") do |nav| %>
<% tabs.each do |tab| %>
<% nav.with_btn(id: tab[:key], label: tab[:key].humanize, classes: "px-6") %>
<% end %>
<% end %>
</div>
<%= selected_tab[:contents] %>
<% tabs.each do |tab| %>
<% tabs_container.with_panel(tab_id: tab[:key]) do %>
<%= tab[:contents] %>
<% end %>
<% end %>
<% end %>

View file

@ -16,7 +16,7 @@
<%= render "accounts/show/chart", account: account %>
<% end %>
<div class="min-h-[800px]">
<div class="min-h-[800px]" data-testid="account-details>
<% if tabs.present? %>
<%= tabs %>
<% else %>

View file

@ -5,7 +5,7 @@
<details class="group mb-1">
<summary class="flex items-center gap-2">
<p class="text-secondary text-sm">Assistant reasoning</p>
<%= lucide_icon "chevron-down", class: "group-open:transform group-open:rotate-180 text-secondary w-5" %>
<%= icon("chevron-down", class: "group-open:transform group-open:rotate-180") %>
</summary>
<div class="prose prose--ai-chat"><%= markdown(assistant_message.content) %></div>

View file

@ -2,7 +2,7 @@
<details class="my-2 group mb-4">
<summary class="text-secondary text-xs cursor-pointer flex items-center gap-2">
<%= 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") %>
<p>Tool Calls</p>
</summary>

View file

@ -8,11 +8,17 @@
<%= render "budget_categories/budget_category_donut", budget_category: budget_category %>
</div>
<% else %>
<div class="w-8 h-8 group-hover:scale-105 transition-all duration-300 rounded-full flex justify-center items-center" style="<%= mixed_hex_styles(budget_category.category.color) %>">
<div class="w-8 h-8 group-hover:scale-105 transition-all duration-300 rounded-full flex justify-center items-center" style="color: <%= budget_category.category.color %>">
<% if budget_category.category.lucide_icon %>
<%= icon(budget_category.category.lucide_icon) %>
<%= icon(budget_category.category.lucide_icon, color: "current") %>
<% else %>
<%= render "shared/circle_logo", name: budget_category.category.name, hex: budget_category.category.color %>
<%= render FilledIconComponent.new(
variant: :text,
hex_color: budget_category.category.color,
text: budget_category.category.name,
size: "sm",
rounded: true
) %>
<% end %>
</div>
<% end %>

View file

@ -11,7 +11,9 @@
<div data-donut-chart-target="contentContainer" class="flex justify-center items-center h-full p-1">
<div data-donut-chart-target="defaultContent" class="h-full w-full rounded-full flex flex-col items-center justify-center" style="background-color: <%= hex_with_alpha(budget_category.category.color, 0.05) %>">
<% if budget_category.category.lucide_icon %>
<%= lucide_icon budget_category.category.lucide_icon, class: "w-4 h-4 shrink-0", style: "color: #{budget_category.category.color}" %>
<span style="color: <%= budget_category.category.color %>">
<%= icon(budget_category.category.lucide_icon, size: "sm", color: "current") %>
</span>
<% else %>
<span class="text-sm uppercase" style="color: <%= budget_category.category.color %>">
<%= budget_category.category.name.first.upcase %>

View file

@ -1,11 +1,10 @@
<div id="<%= dom_id(budget, :confirm_button) %>">
<% if budget.allocations_valid? %>
<%= link_to "Confirm",
budget_path(budget),
class: "block btn btn--primary w-full text-center" %>
<% else %>
<span class="block btn btn--secondary w-full text-center text-subdued cursor-not-allowed">
Confirm
</span>
<% end %>
<%= render ButtonComponent.new(
text: "Confirm",
variant: "primary",
full_width: true,
href: budget_path(budget),
method: :get,
disabled: !budget.allocations_valid?
) %>
</div>

View file

@ -6,12 +6,18 @@
</p>
<div class="flex items-center gap-2">
<%= button_to "Use defaults (recommended)", bootstrap_categories_path, class: "btn btn--primary" %>
<%= render ButtonComponent.new(
text: "Use defaults (recommended)",
href: bootstrap_categories_path,
) %>
<%= link_to new_category_path, class: "btn btn--outline flex items-center gap-1", data: { turbo_frame: "modal" } do %>
<%= lucide_icon("plus", class: "w-5 h-5") %>
<span>New category</span>
<% end %>
<%= render LinkComponent.new(
text: "New category",
variant: "outline",
icon: "plus",
href: new_category_path,
frame: :modal,
) %>
</div>
</div>
</div>

View file

@ -32,7 +32,7 @@
<% group.budget_subcategories.each do |budget_subcategory| %>
<div class="w-full flex items-center gap-4">
<div class="ml-4 flex items-center justify-center text-subdued">
<%= lucide_icon "corner-down-right", class: "w-5 h-5 shrink-0" %>
<%= icon("corner-down-right") %>
</div>
<%= render "budget_categories/budget_category_form", budget_category: budget_subcategory %>

View file

@ -1,39 +1,32 @@
<%= drawer do %>
<div class="space-y-4">
<header class="flex justify-between">
<div>
<p class="text-sm text-secondary">Category</p>
<h3 class="text-2xl font-medium text-primary">
<%= @budget_category.name %>
</h3>
<% if @budget_category.budget.initialized? %>
<p class="text-sm text-secondary">
<span class="text-primary">
<%= format_money(@budget_category.actual_spending_money) %>
</span>
<span>/</span>
<span><%= format_money(@budget_category.budgeted_spending_money) %></span>
</p>
<% end %>
</div>
<%= render DialogComponent.new(variant: :drawer) do |dialog| %>
<% dialog.with_header do %>
<div>
<p class="text-sm text-secondary">Category</p>
<h3 class="text-2xl font-medium text-primary">
<%= @budget_category.name %>
</h3>
<% if @budget_category.budget.initialized? %>
<div class="ml-auto w-10 h-10">
<%= render "budget_categories/budget_category_donut",
budget_category: @budget_category %>
</div>
<p class="text-sm text-secondary">
<span class="text-primary">
<%= format_money(@budget_category.actual_spending_money) %>
</span>
<span>/</span>
<span><%= format_money(@budget_category.budgeted_spending_money) %></span>
</p>
<% end %>
</header>
</div>
<details class="group space-y-2" open>
<summary class="flex list-none items-center justify-between rounded-xl px-3 py-2
text-xs font-medium uppercase text-secondary bg-gray-25 focus-visible:outline-hidden">
<h4>Overview</h4>
<%= lucide_icon "chevron-down",
class: "group-open:transform group-open:rotate-180 text-secondary w-5" %>
</summary>
<% if @budget_category.budget.initialized? %>
<div class="ml-auto w-10 h-10">
<%= render "budget_categories/budget_category_donut",
budget_category: @budget_category %>
</div>
<% end %>
<% end %>
<% dialog.with_body do %>
<% dialog.with_section(title: "Overview", open: true) do %>
<div class="pb-4">
<dl class="space-y-3 px-3 py-2">
<div class="flex items-center justify-between text-sm">
@ -49,20 +42,20 @@
<div class="flex items-center justify-between text-sm">
<dt class="text-secondary">Status</dt>
<% if @budget_category.available_to_spend.negative? %>
<dd class="text-red-500 flex items-center gap-1 text-red-500 font-medium">
<%= lucide_icon "alert-circle", class: "shrink-0 w-4 h-4 text-red-500" %>
<dd class="flex items-center gap-1 text-red-500 font-medium">
<%= icon "alert-circle", size: "sm", color: "destructive" %>
<%= format_money @budget_category.available_to_spend_money.abs %>
<span>overspent</span>
</dd>
<% elsif @budget_category.available_to_spend.zero? %>
<dd class="text-orange-500 flex items-center gap-1 text-orange-500 font-medium">
<%= lucide_icon "x-circle", class: "shrink-0 w-4 h-4 text-orange-500" %>
<dd class="flex items-center gap-1 text-orange-500 font-medium">
<%= icon "x-circle", size: "sm", color: "warning" %>
<%= format_money @budget_category.available_to_spend_money %>
<span>left</span>
</dd>
<% else %>
<dd class="text-primary flex items-center gap-1 text-green-500 font-medium">
<%= lucide_icon "check-circle-2", class: "shrink-0 w-4 h-4 text-green-500" %>
<%= icon "check-circle", size: "sm", color: "success" %>
<%= format_money @budget_category.available_to_spend_money %>
<span>left</span>
</dd>
@ -92,16 +85,9 @@
</div>
</dl>
</div>
</details>
<details class="group space-y-2" open>
<summary class="flex list-none items-center justify-between rounded-xl px-3 py-2
text-xs font-medium uppercase text-secondary bg-gray-25 focus-visible:outline-hidden">
<h4>Recent Transactions</h4>
<%= lucide_icon "chevron-down",
class: "group-open:transform group-open:rotate-180 text-secondary w-5" %>
</summary>
<% end %>
<% dialog.with_section(title: "Recent Transactions", open: true) do %>
<div class="space-y-2">
<div class="px-3 py-4 space-y-2">
<% if @recent_transactions.any? %>
@ -133,14 +119,17 @@
<% end %>
</ul>
<%= link_to "View all category transactions",
transactions_path(q: {
categories: [@budget_category.name],
start_date: @budget.start_date,
end_date: @budget.end_date
}),
data: { turbo_frame: :_top },
class: "block text-center btn btn--outline w-full" %>
<%= render LinkComponent.new(
text: "View all category transactions",
variant: "outline",
full_width: true,
href: transactions_path(q: {
categories: [@budget_category.name],
start_date: @budget.start_date,
end_date: @budget.end_date
}),
frame: :_top
) %>
<% else %>
<p class="text-secondary text-sm mb-4">
No transactions found for this budget period.
@ -148,6 +137,6 @@
<% end %>
</div>
</div>
</details>
</div>
<% end %>
<% end %>
<% end %>

View file

@ -25,7 +25,7 @@
<% group.budget_subcategories.each do |budget_subcategory| %>
<div class="w-full flex items-center -mt-4">
<div class="ml-8 flex items-center justify-center text-subdued">
<%= lucide_icon "corner-down-right", class: "w-5 h-5 shrink-0" %>
<%= icon "corner-down-right" %>
</div>
<%= render "budget_categories/budget_category", budget_category: budget_subcategory %>

View file

@ -8,24 +8,29 @@
<span>Spent</span>
</div>
<div class="text-3xl font-medium <%= budget.available_to_spend.negative? ? "text-red-500" : "text-primary" %>">
<div class="mb-2 text-3xl font-medium <%= budget.available_to_spend.negative? ? "text-red-500" : "text-primary" %>">
<%= format_money(budget.actual_spending_money) %>
</div>
<%= link_to edit_budget_path(budget), class: "btn btn--secondary flex items-center gap-1 mt-2" do %>
<span class="text-primary font-medium">
of <%= format_money(budget.budgeted_spending_money) %>
</span>
<%= lucide_icon "pencil", class: "w-4 h-4 text-secondary hover:text-gray-600" %>
<% end %>
<%= render LinkComponent.new(
text: "of #{budget.budgeted_spending_money.format}",
variant: "secondary",
icon: "pencil",
icon_position: "right",
size: "sm",
href: edit_budget_path(budget)
) %>
<% else %>
<div class="text-subdued text-3xl mb-2">
<span><%= format_money Money.new(0, budget.currency || budget.family.currency) %></span>
</div>
<%= link_to edit_budget_path(budget), class: "flex items-center gap-2 btn btn--primary" do %>
<%= lucide_icon "plus", class: "w-4 h-4 text-white" %>
New budget
<% end %>
<%= render LinkComponent.new(
text: "New budget",
size: "sm",
icon: "plus",
href: edit_budget_path(budget)
) %>
<% end %>
</div>
@ -41,11 +46,14 @@
<%= format_money(bc.actual_spending_money) %>
</p>
<%= link_to budget_budget_categories_path(budget), class: "btn btn--secondary flex items-center gap-1" do %>
<span>of <%= format_money(bc.budgeted_spending_money, precision: 0) %></span>
<%= lucide_icon "pencil", class: "w-4 h-4 text-secondary shrink-0" %>
<% end %>
<%= render LinkComponent.new(
text: "of #{bc.budgeted_spending_money.format(precision: 0)}",
variant: "secondary",
icon: "pencil",
icon_position: "right",
size: "sm",
href: budget_budget_categories_path(budget)
) %>
</div>
</div>
<% end %>

View file

@ -3,38 +3,46 @@
<div class="flex items-center gap-1 mb-4">
<div class="flex items-center gap-2">
<% if budget.previous_budget_param %>
<%= link_to budget_path(budget.previous_budget_param) do %>
<%= lucide_icon "chevron-left" %>
<% end %>
<%= render LinkComponent.new(
variant: "icon",
icon: "chevron-left",
href: budget_path(budget.previous_budget_param),
) %>
<% else %>
<%= lucide_icon "chevron-left", class: "text-subdued" %>
<span class="text-subdued">
<%= icon "chevron-left", color: "current" %>
</span>
<% end %>
<% if budget.next_budget_param %>
<%= link_to budget_path(budget.next_budget_param) do %>
<%= lucide_icon "chevron-right" %>
<% end %>
<%= render LinkComponent.new(
variant: "icon",
icon: "chevron-right",
href: budget_path(budget.next_budget_param),
) %>
<% else %>
<%= lucide_icon "chevron-right", class: "text-subdued" %>
<span class="text-subdued">
<%= icon "chevron-right", color: "current" %>
</span>
<% end %>
</div>
<div data-controller="menu" data-menu-placement-value="bottom-start">
<%= tag.button data: { menu_target: "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>
<%= lucide_icon "chevron-down", class: "w-5 h-5 shrink-0 text-secondary" %>
<%= 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-lg lg:text-base"><%= @budget.name %></span>
<%= icon("chevron-down") %>
<% end %>
<div data-menu-target="content" class="hidden z-10">
<% menu.with_custom_content do %>
<%= render "budgets/picker", family: Current.family, year: budget.start_date.year %>
</div>
</div>
<% end %>
<% end %>
<div class="ml-auto">
<% if @budget.current? %>
<span class="border border-secondary text-primary text-sm font-medium px-3 py-2 rounded-lg">Today</span>
<% else %>
<%= link_to "Today", budget_path(Budget.date_to_param(Date.current)), class: "btn btn--outline" %>
<% end %>
<%= render LinkComponent.new(
text: "Today",
variant: "outline",
href: budget_path(Budget.date_to_param(Date.current)),
) %>
</div>
</div>

View file

@ -24,7 +24,7 @@
<%= link_to step[:path], class: "flex items-center gap-3" do %>
<div class="flex items-center gap-2 text-sm font-medium <%= text_class %>">
<span class="<%= step_class %> w-7 h-7 rounded-full shrink-0 inline-flex items-center justify-center border border-transparent">
<%= 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 %>
</span>
<span><%= step[:name] %></span>

View file

@ -1,13 +1,15 @@
<%# locals: (budget:) %>
<div class="flex flex-col gap-4 items-center justify-center h-full">
<%= lucide_icon "alert-triangle", class: "w-6 h-6 text-red-500" %>
<%= icon "alert-triangle", size: "lg", color: "destructive" %>
<p class="text-secondary text-sm text-center">You have over-allocated your budget. Please fix your allocations.</p>
<%= link_to budget_budget_categories_path(budget), class: "btn btn--secondary flex items-center gap-1" do %>
<span class="text-primary font-medium">
Fix allocations
</span>
<%= lucide_icon "pencil", class: "w-4 h-4 text-secondary hover:text-gray-600" %>
<% end %>
<%= render LinkComponent.new(
text: "Fix allocations",
variant: "secondary",
size: "sm",
icon: "pencil",
icon_position: "right",
href: budget_budget_categories_path(budget)
) %>
</div>

View file

@ -1,17 +1,17 @@
<%# locals: (family:, year:) %>
<%= turbo_frame_tag "budget_picker" do %>
<div class="bg-container shadow-border-xs p-3 rounded-xl space-y-4">
<div class="p-3 space-y-4">
<div class="flex items-center gap-2 justify-between">
<% last_month_of_previous_year = Date.new(year - 1, 12, 1) %>
<% if Budget.budget_date_valid?(last_month_of_previous_year, family: family) %>
<%= link_to picker_budgets_path(year: year - 1), data: { turbo_frame: "budget_picker" }, class: "p-2 flex items-center justify-center hover:bg-alpha-black-25 rounded-md" do %>
<%= lucide_icon "chevron-left", class: "w-5 h-5 shrink-0 text-secondary" %>
<%= icon "chevron-left" %>
<% end %>
<% else %>
<span class="p-2 flex items-center justify-center text-gray-300 rounded-md">
<%= lucide_icon "chevron-left", class: "w-5 h-5 shrink-0 text-subdued" %>
<span class="p-2 flex items-center justify-center text-subdued rounded-md">
<%= icon "chevron-left", color: "current" %>
</span>
<% end %>
@ -23,11 +23,11 @@
<% if Budget.budget_date_valid?(first_month_of_next_year, family: family) %>
<%= link_to picker_budgets_path(year: year + 1), data: { turbo_frame: "budget_picker" }, class: "p-2 flex items-center justify-center hover:bg-alpha-black-25 rounded-md" do %>
<%= lucide_icon "chevron-right", class: "w-5 h-5 shrink-0 text-secondary" %>
<%= icon "chevron-right" %>
<% end %>
<% else %>
<span class="p-2 flex items-center justify-center text-gray-300 rounded-md">
<%= lucide_icon "chevron-right", class: "w-5 h-5 shrink-0 text-subdued" %>
<span class="p-2 flex items-center justify-center text-subdued rounded-md">
<%= icon "chevron-right", color: "current" %>
</span>
<% end %>
</div>
@ -38,7 +38,13 @@
<% param_key = Budget.date_to_param(date) %>
<% if Budget.budget_date_valid?(date, family: family) %>
<%= link_to month_name, budget_path(param_key), data: { turbo_frame: "_top" }, class: "btn btn--ghost" %>
<%= render LinkComponent.new(
variant: "ghost",
text: month_name,
href: budget_path(param_key),
full_width: true,
frame: :_top
) %>
<% else %>
<span class="px-3 py-2 text-subdued rounded-md"><%= month_name %></span>
<% end %>

View file

@ -21,7 +21,7 @@
<% if @budget.estimated_income && @budget.estimated_spending %>
<div class="border border-tertiary rounded-lg p-3 flex">
<%= lucide_icon "sparkles", class: "w-5 h-5 text-secondary shrink-0" %>
<%= icon "sparkles" %>
<div class="ml-2 space-y-1 text-sm">
<h4 class="text-primary">Autosuggest income & spending budget</h4>
<p class="text-secondary">
@ -29,18 +29,18 @@
</p>
</div>
<div class="relative inline-block select-none ml-6">
<%= check_box_tag :auto_fill, "1", params[:auto_fill].present?, class: "sr-only peer", data: {
action: "change->budget-form#toggleAutoFill",
budget_form_income_param: { key: "budget_expected_income", value: sprintf("%.2f", @budget.estimated_income) },
budget_form_spending_param: { key: "budget_budgeted_spending", value: sprintf("%.2f", @budget.estimated_spending) }
} %>
<label for="auto_fill" class="switch"></label>
</div>
<%= render ToggleComponent.new(
id: "auto_fill",
data: {
action: "change->budget-form#toggleAutoFill",
budget_form_income_param: { key: "budget_expected_income", value: sprintf("%.2f", @budget.estimated_income) },
budget_form_spending_param: { key: "budget_budgeted_spending", value: sprintf("%.2f", @budget.estimated_spending) }
}
) %>
</div>
<% end %>
<%= f.submit "Continue", class: "btn btn--primary w-full" %>
<%= f.submit "Continue" %>
<% end %>
</div>
</div>

View file

@ -54,10 +54,12 @@
<h2 class="text-lg font-medium">Categories</h2>
<% if @budget.initialized? %>
<%= link_to budget_budget_categories_path(@budget), class: "btn btn--secondary flex items-center gap-2" do %>
<%= icon "settings-2", color: "gray" %>
<span>Edit</span>
<% end %>
<%= render LinkComponent.new(
text: "Edit",
variant: "secondary",
icon: "settings-2",
href: budget_budget_categories_path(@budget)
) %>
<% end %>
</div>

View file

@ -8,9 +8,8 @@
border-color: color-mix(in srgb, <%= category.color %> 30%, white);
color: <%= category.color %>;">
<% if category.lucide_icon.present? %>
<%= lucide_icon category.lucide_icon, class: "w-4 h-4 shrink-0" %>
<%= icon category.lucide_icon, size: "sm", color: "current" %>
<% end %>
<%= category.name %>
</span>
</div>

View file

@ -3,24 +3,22 @@
<div id="<%= dom_id(category) %>" class="flex justify-between items-center px-4 pb-4 <%= "pt-4" unless category.subcategory? %> <%= "pb-4" unless category.subcategories.any? %> bg-container">
<div class="flex w-full items-center gap-2.5">
<% if category.subcategory? %>
<%= lucide_icon "corner-down-right", class: "shrink-0 w-5 h-5 text-subdued ml-2" %>
<span style="color: <%= category.color %>">
<%= icon "corner-down-right", size: "sm", color: "current", class: "ml-2" %>
</span>
<% end %>
<%= render partial: "categories/badge", locals: { category: category } %>
</div>
<div class="justify-self-end">
<%= contextual_menu do %>
<%= contextual_menu_modal_action_item t(".edit"), edit_category_path(category) %>
<%= render MenuComponent.new do |menu| %>
<% menu.with_item(variant: "link", text: t(".edit"), icon: "pencil", href: edit_category_path(category), data: { turbo_frame: :modal }) %>
<% if category.transactions.any? %>
<%= link_to new_category_deletion_path(category),
class: "flex items-center w-full rounded-lg text-red-600 hover:bg-red-50 py-2 px-3 gap-2",
data: { turbo_frame: :modal } do %>
<%= lucide_icon "trash-2", class: "shrink-0 w-5 h-5" %>
<span class="text-sm"><%= t(".delete") %></span>
<% end %>
<% menu.with_item(variant: "link", text: t(".delete"), icon: "trash-2", href: new_category_deletion_path(category), destructive: true, data: { turbo_frame: :modal }) %>
<% else %>
<%= contextual_menu_destructive_item t(".delete"), category_path(category), turbo_confirm: nil %>
<% menu.with_item(variant: "button", text: t(".delete"), icon: "trash-2", href: category_path(category), method: :delete) %>
<% end %>
<% end %>
</div>

View file

@ -4,5 +4,5 @@
data-category-target="avatar"
class="w-14 h-14 flex items-center justify-center rounded-full"
style="background-color: color-mix(in oklab, <%= category.color %> 10%, transparent); color: <%= category.color %>">
<%= lucide_icon(category.lucide_icon, class: "w-8 h-8") %>
<%= icon(category.lucide_icon, size: "2xl", color: "current") %>
</span>

View file

@ -2,59 +2,60 @@
<div data-controller="category" data-category-preset-colors-value="<%= Category::COLORS %>">
<%= styled_form_with model: category, class: "space-y-4" do |f| %>
<section class="space-y-4">
<div class="w-fit m-auto">
<section class="space-y-4 ">
<div class="w-fit mx-auto relative">
<%= render partial: "color_avatar", locals: { category: category } %>
<details data-category-target="details">
<summary class="cursor-pointer absolute -bottom-2 -right-2 flex justify-center items-center bg-gray-50 border-2 w-7 h-7 border-white rounded-full text-gray-500">
<%= icon("pen", size: "sm") %>
</summary>
<div class="fixed ml-8 mt-2 z-50 bg-container p-4 border border-alpha-black-25 rounded-2xl shadow-xs h-fit">
<div class="flex gap-2 flex-col mb-4" data-category-target="selection" style="<%= "display:none;" if @category.subcategory? %>">
<div data-category-target="pickerSection"></div>
<h4 class="text-gray-500 text-sm">Color</h4>
<div class="flex gap-2 items-center" data-category-target="colorsSection">
<% Category::COLORS.each do |color| %>
<label class="relative">
<%= f.radio_button :color, color, class: "sr-only peer", data: { action: "change->category#handleColorChange" } %>
<div class="w-6 h-6 rounded-full cursor-pointer peer-checked:ring-2 peer-checked:ring-offset-2 peer-checked:ring-blue-500" style="background-color: <%= color %>"></div>
</label>
<% end %>
<label class="relative">
<%= f.radio_button :color, "custom-color", class: "sr-only peer", data: { category_target: "colorPickerRadioBtn"} %>
<div class="w-6 h-6 rounded-full cursor-pointer peer-checked:ring-2 peer-checked:ring-offset-2 peer-checked:ring-blue-500" data-category-target="pickerBtn" style="background: conic-gradient(red,orange,yellow,lime,green,teal,cyan,blue,indigo,purple,magenta,pink,red)"></div>
</label>
</div>
<div class="flex gap-2 items-center hidden flex-col" data-category-target="paletteSection">
<div class="flex gap-2 items-center w-full">
<div class="w-6 h-6 p-4 rounded-full cursor-pointer" style="background-color: <%= category.color %>" data-category-target="colorPreview"></div>
<%= f.text_field :color , data: { category_target: "colorInput"}, inline: true %>
<%= icon "palette", size: "2xl", data: { action: "click->category#toggleSections" } %>
</div>
<div data-category-target="validationMessage" class="hidden self-start flex gap-1 items-center text-xs text-destructive ">
<span>Poor contrast, choose darker color or</span>
<button type="button" class="underline cursor-pointer" data-action="category#autoAdjust">auto-adjust.</button>
</div>
</div>
</div>
<div class="flex flex-wrap gap-2 justify-center flex-col w-87">
<h4 class="text-gray-500 text-sm">Icon</h4>
<div class="flex flex-wrap gap-0.5">
<% Category.icon_codes.each do |icon| %>
<label class="relative">
<%= f.radio_button :lucide_icon, icon, class: "sr-only peer", data: { action: "change->category#handleIconChange change->category#handleIconColorChange", category_target:"icon" } %>
<div class="w-7 h-7 flex m-0.5 items-center justify-center rounded-full cursor-pointer hover:bg-gray-100 peer-checked:bg-gray-100 border-1 border-transparent">
<%= icon(icon, size: "sm", color: "current") %>
</div>
</label>
<% end %>
</div>
</div>
</div>
</details>
</div>
<details data-category-target="details">
<summary class="cursor-pointer absolute top-23 left-58.5 flex justify-center items-center bg-gray-50 border-2 w-7 h-7 border-white rounded-full text-gray-500">
<%= icon("pen", size: "sm") %>
</summary>
<div class=" absolute z-50 bg-container p-4 border border-alpha-black-25 rounded-2xl shadow-xs h-fit left-66 top-24">
<div class="flex gap-2 flex-col mb-4" data-category-target="selection" style="<%= "display:none;" if @category.subcategory? %>">
<div data-category-target="pickerSection"></div>
<h4 class="text-gray-500 text-sm">Color</h4>
<div class="flex gap-2 items-center" data-category-target="colorsSection">
<% Category::COLORS.each do |color| %>
<label class="relative">
<%= f.radio_button :color, color, class: "sr-only peer", data: { action: "change->category#handleColorChange" } %>
<div class="w-6 h-6 rounded-full cursor-pointer peer-checked:ring-2 peer-checked:ring-offset-2 peer-checked:ring-blue-500" style="background-color: <%= color %>"></div>
</label>
<% end %>
<label class="relative">
<%= f.radio_button :color, "custom-color", class: "sr-only peer", data: { category_target: "colorPickerRadioBtn"} %>
<div class="w-6 h-6 rounded-full cursor-pointer peer-checked:ring-2 peer-checked:ring-offset-2 peer-checked:ring-blue-500" data-category-target="pickerBtn" style="background: conic-gradient(red,orange,yellow,lime,green,teal,cyan,blue,indigo,purple,magenta,pink,red)"></div>
</label>
</div>
<div class="flex gap-2 items-center hidden flex-col" data-category-target="paletteSection">
<div class="flex gap-2 items-center w-full">
<div class="w-6 h-6 p-4 rounded-full cursor-pointer" style="background-color: <%= category.color %>" data-category-target="colorPreview"></div>
<%= f.text_field :color , data: { category_target: "colorInput"}, inline: true %>
<%= lucide_icon "palette", class: "w-8 h-8 cursor-pointer hover:bg-gray-100 p-1", data: { action: "click->category#toggleSections" } %>
</div>
<div data-category-target="validationMessage" class="hidden self-start flex gap-1 items-center text-xs text-destructive ">
<span>Poor contrast, choose darker color or</span>
<button type="button" class="underline cursor-pointer" data-action="category#autoAdjust">auto-adjust.</button>
</div>
</div>
</div>
<div class="flex flex-wrap gap-2 justify-center flex-col w-87">
<h4 class="text-gray-500 text-sm">Icon</h4>
<div class="flex flex-wrap gap-0.5">
<% Category.icon_codes.each do |icon| %>
<label class="relative">
<%= f.radio_button :lucide_icon, icon, class: "sr-only peer", data: { action: "change->category#handleIconChange change->category#handleIconColorChange", category_target:"icon" } %>
<div class="w-7 h-7 flex m-0.5 items-center justify-center rounded-full cursor-pointer hover:bg-gray-100 peer-checked:bg-gray-100 border-1 border-transparent">
<%= lucide_icon icon, class: "w-6 h-6 p-1" %>
</div>
</label>
<% end %>
</div>
</div>
</div>
</details>
<% if category.errors.any? %>
<%= render "shared/form_errors", model: category %>

View file

@ -1,16 +1,15 @@
<%# locals: (transaction:) %>
<div class="relative" data-controller="menu" id="<%= dom_id(transaction, :category_menu) %>">
<button data-menu-target="button" class="flex cursor-pointer">
<%= render partial: "categories/badge", locals: { category: transaction.category } %>
</button>
<div data-menu-target="content" class="absolute z-10 hidden w-screen mt-2 max-w-min cursor-default">
<div class="w-80 text-sm font-semibold leading-6 text-primary bg-container shadow-lg shrink rounded-xl ring-1 ring-gray-900/5">
<%= turbo_frame_tag "category_dropdown", src: category_dropdown_path(category_id: transaction.category_id, transaction_id: transaction.id), loading: :lazy do %>
<div class="p-6 flex items-center justify-center">
<p class="text-sm text-secondary animate-pulse"><%= t(".loading") %></p>
</div>
<% end %>
</div>
</div>
</div>
<%= render MenuComponent.new(variant: "button") do |menu| %>
<% menu.with_button do %>
<% render partial: "categories/badge", locals: { category: transaction.category } %>
<% end %>
<% menu.with_custom_content do %>
<%= turbo_frame_tag "category_dropdown", src: category_dropdown_path(category_id: transaction.category_id, transaction_id: transaction.id), loading: :lazy do %>
<div class="p-6 flex items-center justify-center">
<p class="text-sm text-secondary animate-pulse"><%= t(".loading") %></p>
</div>
<% end %>
<% end %>
<% end %>

View file

@ -1,3 +1,6 @@
<%= modal_form_wrapper title: t(".edit"), overflow_visible: true do %>
<%= render "form", category: @category, categories: @categories %>
<%= render DialogComponent.new do |dialog| %>
<% dialog.with_header(title: t(".edit")) %>
<% dialog.with_body do %>
<%= render "form", category: @category, categories: @categories %>
<% end %>
<% end %>

View file

@ -2,18 +2,23 @@
<h1 class="text-primary text-xl font-medium"><%= t(".categories") %></h1>
<div class="flex items-center gap-2">
<%= contextual_menu do %>
<%= contextual_menu_destructive_item "Delete all", destroy_all_categories_path, turbo_confirm: {
title: "Delete all categories?",
body: "All of your transactions will become uncategorized and this cannot be undone.",
accept: "Delete all categories",
} %>
<%= render MenuComponent.new do |menu| %>
<% menu.with_item(
variant: "button",
text: "Delete all",
href: destroy_all_categories_path,
method: :delete,
icon: "trash-2",
confirm: CustomConfirm.for_resource_deletion("All categories", high_severity: true)) %>
<% end %>
<%= link_to new_category_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" %>
<p><%= t(".new") %></p>
<% end %>
<%= render LinkComponent.new(
text: t(".new"),
variant: "primary",
icon: "plus",
href: new_category_path,
frame: :modal
) %>
</div>
</header>
@ -33,12 +38,18 @@
<div class="text-center flex flex-col items-center max-w-[500px]">
<p class="text-sm text-secondary mb-4"><%= t(".empty") %></p>
<div class="flex items-center gap-2">
<%= button_to t(".bootstrap"), bootstrap_categories_path, class: "btn btn--primary" %>
<%= render ButtonComponent.new(
text: t(".bootstrap"),
href: bootstrap_categories_path,
) %>
<%= link_to new_category_path, class: "btn btn--outline flex items-center gap-1", data: { turbo_frame: "modal" } do %>
<%= lucide_icon("plus", class: "w-5 h-5") %>
<span><%= t(".new") %></span>
<% end %>
<%= render LinkComponent.new(
text: t(".new"),
variant: "outline",
icon: "plus",
href: new_category_path,
frame: :modal
) %>
</div>
</div>
</div>

View file

@ -1,3 +1,6 @@
<%= modal_form_wrapper title: t(".new_category"), overflow_visible: true do %>
<%= render "form", category: @category, categories: @categories %>
<%= render DialogComponent.new do |dialog| %>
<% dialog.with_header(title: t(".new_category")) %>
<% dialog.with_body do %>
<%= render "form", category: @category, categories: @categories %>
<% end %>
<% end %>

Some files were not shown because too many files have changed in this diff Show more