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:
parent
1aafed5f8b
commit
90a9546f32
291 changed files with 4143 additions and 3104 deletions
|
@ -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.
|
||||
|
||||
|
|
4
Gemfile
4
Gemfile
|
@ -19,9 +19,11 @@ gem "propshaft"
|
|||
gem "tailwindcss-rails"
|
||||
gem "lucide-rails", github: "maybe-finance/lucide-rails"
|
||||
|
||||
# Hotwire
|
||||
# Hotwire + UI
|
||||
gem "stimulus-rails"
|
||||
gem "turbo-rails"
|
||||
gem "view_component"
|
||||
gem "lookbook", ">= 2.3.7"
|
||||
|
||||
gem "hotwire_combobox"
|
||||
|
||||
|
|
118
Gemfile.lock
118
Gemfile.lock
|
@ -85,8 +85,8 @@ GEM
|
|||
public_suffix (>= 2.0.2, < 7.0)
|
||||
ast (2.4.3)
|
||||
aws-eventstream (1.3.2)
|
||||
aws-partitions (1.1073.0)
|
||||
aws-sdk-core (3.221.0)
|
||||
aws-partitions (1.1093.0)
|
||||
aws-sdk-core (3.222.3)
|
||||
aws-eventstream (~> 1, >= 1.3.0)
|
||||
aws-partitions (~> 1, >= 1.992.0)
|
||||
aws-sigv4 (~> 1.9)
|
||||
|
@ -133,21 +133,23 @@ GEM
|
|||
logger (~> 1.5)
|
||||
chunky_png (1.4.0)
|
||||
climate_control (1.2.0)
|
||||
concurrent-ruby (1.3.5)
|
||||
connection_pool (2.5.2)
|
||||
concurrent-ruby (1.3.4)
|
||||
connection_pool (2.5.3)
|
||||
crack (1.0.0)
|
||||
bigdecimal
|
||||
rexml
|
||||
crass (1.0.6)
|
||||
css_parser (1.21.1)
|
||||
addressable
|
||||
csv (3.3.4)
|
||||
date (3.4.1)
|
||||
debug (1.10.0)
|
||||
irb (~> 1.10)
|
||||
reline (>= 0.3.8)
|
||||
docile (1.4.1)
|
||||
dotenv (3.1.7)
|
||||
dotenv-rails (3.1.7)
|
||||
dotenv (= 3.1.7)
|
||||
dotenv (3.1.8)
|
||||
dotenv-rails (3.1.8)
|
||||
dotenv (= 3.1.8)
|
||||
railties (>= 6.1)
|
||||
drb (2.2.1)
|
||||
erb_lint (0.9.0)
|
||||
|
@ -171,14 +173,14 @@ GEM
|
|||
net-http (>= 0.5.0)
|
||||
faraday-retry (2.3.1)
|
||||
faraday (~> 2.0)
|
||||
ffi (1.17.1-aarch64-linux-gnu)
|
||||
ffi (1.17.1-aarch64-linux-musl)
|
||||
ffi (1.17.1-arm-linux-gnu)
|
||||
ffi (1.17.1-arm-linux-musl)
|
||||
ffi (1.17.1-arm64-darwin)
|
||||
ffi (1.17.1-x86_64-darwin)
|
||||
ffi (1.17.1-x86_64-linux-gnu)
|
||||
ffi (1.17.1-x86_64-linux-musl)
|
||||
ffi (1.17.2-aarch64-linux-gnu)
|
||||
ffi (1.17.2-aarch64-linux-musl)
|
||||
ffi (1.17.2-arm-linux-gnu)
|
||||
ffi (1.17.2-arm-linux-musl)
|
||||
ffi (1.17.2-arm64-darwin)
|
||||
ffi (1.17.2-x86_64-darwin)
|
||||
ffi (1.17.2-x86_64-linux-gnu)
|
||||
ffi (1.17.2-x86_64-linux-musl)
|
||||
foreman (0.88.1)
|
||||
globalid (1.2.1)
|
||||
activesupport (>= 6.1)
|
||||
|
@ -194,6 +196,8 @@ GEM
|
|||
rails (>= 7.0.7.2)
|
||||
stimulus-rails (>= 1.2)
|
||||
turbo-rails (>= 1.2)
|
||||
htmlbeautifier (1.4.3)
|
||||
htmlentities (4.3.4)
|
||||
i18n (1.14.7)
|
||||
concurrent-ruby (~> 1.0)
|
||||
i18n-tasks (1.0.15)
|
||||
|
@ -221,7 +225,7 @@ GEM
|
|||
activesupport (> 4.0)
|
||||
jwt (~> 2.0)
|
||||
io-console (0.8.0)
|
||||
irb (1.15.1)
|
||||
irb (1.15.2)
|
||||
pp (>= 0.6.0)
|
||||
rdoc (>= 4.0.0)
|
||||
reline (>= 0.4.2)
|
||||
|
@ -255,6 +259,18 @@ GEM
|
|||
loofah (2.24.0)
|
||||
crass (~> 1.0.2)
|
||||
nokogiri (>= 1.12.0)
|
||||
lookbook (2.3.9)
|
||||
activemodel
|
||||
css_parser
|
||||
htmlbeautifier (~> 1.3)
|
||||
htmlentities (~> 4.3.4)
|
||||
marcel (~> 1.0)
|
||||
railties (>= 5.0)
|
||||
redcarpet (~> 3.5)
|
||||
rouge (>= 3.26, < 5.0)
|
||||
view_component (>= 2.0)
|
||||
yard (~> 0.9)
|
||||
zeitwerk (~> 2.5)
|
||||
mail (2.8.1)
|
||||
mini_mime (>= 0.1.1)
|
||||
net-imap
|
||||
|
@ -262,6 +278,7 @@ GEM
|
|||
net-smtp
|
||||
marcel (1.0.4)
|
||||
matrix (0.4.2)
|
||||
method_source (1.1.0)
|
||||
mini_magick (5.2.0)
|
||||
benchmark
|
||||
logger
|
||||
|
@ -273,7 +290,7 @@ GEM
|
|||
multipart-post (2.4.1)
|
||||
net-http (0.6.0)
|
||||
uri
|
||||
net-imap (0.5.6)
|
||||
net-imap (0.5.7)
|
||||
date
|
||||
net-protocol
|
||||
net-pop (0.1.2)
|
||||
|
@ -283,28 +300,28 @@ GEM
|
|||
net-smtp (0.5.1)
|
||||
net-protocol
|
||||
nio4r (2.7.4)
|
||||
nokogiri (1.18.6-aarch64-linux-gnu)
|
||||
nokogiri (1.18.8-aarch64-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.6-aarch64-linux-musl)
|
||||
nokogiri (1.18.8-aarch64-linux-musl)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.6-arm-linux-gnu)
|
||||
nokogiri (1.18.8-arm-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.6-arm-linux-musl)
|
||||
nokogiri (1.18.8-arm-linux-musl)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.6-arm64-darwin)
|
||||
nokogiri (1.18.8-arm64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.6-x86_64-darwin)
|
||||
nokogiri (1.18.8-x86_64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.6-x86_64-linux-gnu)
|
||||
nokogiri (1.18.8-x86_64-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.6-x86_64-linux-musl)
|
||||
nokogiri (1.18.8-x86_64-linux-musl)
|
||||
racc (~> 1.4)
|
||||
octokit (10.0.0)
|
||||
faraday (>= 1, < 3)
|
||||
sawyer (~> 0.9)
|
||||
pagy (9.3.4)
|
||||
parallel (1.26.3)
|
||||
parser (3.3.7.2)
|
||||
parallel (1.27.0)
|
||||
parser (3.3.8.0)
|
||||
ast (~> 2.4.1)
|
||||
racc
|
||||
pg (1.5.9)
|
||||
|
@ -382,7 +399,7 @@ GEM
|
|||
ffi (~> 1.0)
|
||||
rbs (3.9.2)
|
||||
logger
|
||||
rdoc (6.13.0)
|
||||
rdoc (6.13.1)
|
||||
psych (>= 4.0.0)
|
||||
redcarpet (3.6.1)
|
||||
redis (5.4.0)
|
||||
|
@ -390,15 +407,16 @@ GEM
|
|||
redis-client (0.24.0)
|
||||
connection_pool
|
||||
regexp_parser (2.10.0)
|
||||
reline (0.6.0)
|
||||
reline (0.6.1)
|
||||
io-console (~> 0.5)
|
||||
rexml (3.4.1)
|
||||
rotp (6.3.0)
|
||||
rqrcode (3.0.0)
|
||||
rouge (4.5.2)
|
||||
rqrcode (3.1.0)
|
||||
chunky_png (~> 1.0)
|
||||
rqrcode_core (~> 2.0)
|
||||
rqrcode_core (2.0.0)
|
||||
rubocop (1.74.0)
|
||||
rubocop (1.75.4)
|
||||
json (~> 2.3)
|
||||
language_server-protocol (~> 3.17.0.2)
|
||||
lint_roller (~> 1.1.0)
|
||||
|
@ -406,20 +424,21 @@ GEM
|
|||
parser (>= 3.3.0.2)
|
||||
rainbow (>= 2.2.2, < 4.0)
|
||||
regexp_parser (>= 2.9.3, < 3.0)
|
||||
rubocop-ast (>= 1.38.0, < 2.0)
|
||||
rubocop-ast (>= 1.44.0, < 2.0)
|
||||
ruby-progressbar (~> 1.7)
|
||||
unicode-display_width (>= 2.4.0, < 4.0)
|
||||
rubocop-ast (1.41.0)
|
||||
rubocop-ast (1.44.1)
|
||||
parser (>= 3.3.7.2)
|
||||
rubocop-performance (1.24.0)
|
||||
prism (~> 1.4)
|
||||
rubocop-performance (1.25.0)
|
||||
lint_roller (~> 1.1)
|
||||
rubocop (>= 1.72.1, < 2.0)
|
||||
rubocop (>= 1.75.0, < 2.0)
|
||||
rubocop-ast (>= 1.38.0, < 2.0)
|
||||
rubocop-rails (2.30.3)
|
||||
rubocop-rails (2.31.0)
|
||||
activesupport (>= 4.2.0)
|
||||
lint_roller (~> 1.1)
|
||||
rack (>= 1.1)
|
||||
rubocop (>= 1.72.1, < 2.0)
|
||||
rubocop (>= 1.75.0, < 2.0)
|
||||
rubocop-ast (>= 1.38.0, < 2.0)
|
||||
rubocop-rails-omakase (1.1.0)
|
||||
rubocop (>= 1.72)
|
||||
|
@ -479,18 +498,18 @@ GEM
|
|||
sorbet-runtime (0.5.12043)
|
||||
stimulus-rails (1.3.4)
|
||||
railties (>= 6.0.0)
|
||||
stringio (3.1.5)
|
||||
stringio (3.1.7)
|
||||
stripe (15.0.0)
|
||||
tailwindcss-rails (4.2.1)
|
||||
tailwindcss-rails (4.2.2)
|
||||
railties (>= 7.0.0)
|
||||
tailwindcss-ruby (~> 4.0)
|
||||
tailwindcss-ruby (4.0.15)
|
||||
tailwindcss-ruby (4.0.15-aarch64-linux-gnu)
|
||||
tailwindcss-ruby (4.0.15-aarch64-linux-musl)
|
||||
tailwindcss-ruby (4.0.15-arm64-darwin)
|
||||
tailwindcss-ruby (4.0.15-x86_64-darwin)
|
||||
tailwindcss-ruby (4.0.15-x86_64-linux-gnu)
|
||||
tailwindcss-ruby (4.0.15-x86_64-linux-musl)
|
||||
tailwindcss-ruby (4.1.4)
|
||||
tailwindcss-ruby (4.1.4-aarch64-linux-gnu)
|
||||
tailwindcss-ruby (4.1.4-aarch64-linux-musl)
|
||||
tailwindcss-ruby (4.1.4-arm64-darwin)
|
||||
tailwindcss-ruby (4.1.4-x86_64-darwin)
|
||||
tailwindcss-ruby (4.1.4-x86_64-linux-gnu)
|
||||
tailwindcss-ruby (4.1.4-x86_64-linux-musl)
|
||||
terminal-table (4.0.0)
|
||||
unicode-display_width (>= 1.1.1, < 4)
|
||||
thor (1.3.2)
|
||||
|
@ -508,6 +527,10 @@ GEM
|
|||
vcr (6.3.1)
|
||||
base64
|
||||
vernier (1.7.0)
|
||||
view_component (3.22.0)
|
||||
activesupport (>= 5.2.0, < 8.1)
|
||||
concurrent-ruby (= 1.3.4)
|
||||
method_source (~> 1.0)
|
||||
web-console (4.2.1)
|
||||
actionview (>= 6.0.0)
|
||||
activemodel (>= 6.0.0)
|
||||
|
@ -524,6 +547,7 @@ GEM
|
|||
websocket-extensions (0.1.5)
|
||||
xpath (3.2.0)
|
||||
nokogiri (~> 1.8)
|
||||
yard (0.9.37)
|
||||
zeitwerk (2.7.2)
|
||||
|
||||
PLATFORMS
|
||||
|
@ -564,6 +588,7 @@ DEPENDENCIES
|
|||
jwt
|
||||
letter_opener
|
||||
logtail-rails
|
||||
lookbook (>= 2.3.7)
|
||||
lucide-rails!
|
||||
mocha
|
||||
octokit
|
||||
|
@ -596,6 +621,7 @@ DEPENDENCIES
|
|||
tzinfo-data
|
||||
vcr
|
||||
vernier
|
||||
view_component
|
||||
web-console
|
||||
webmock
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
87
app/assets/tailwind/maybe-design-system/background-utils.css
Normal file
87
app/assets/tailwind/maybe-design-system/background-utils.css
Normal 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);
|
||||
}
|
||||
}
|
88
app/assets/tailwind/maybe-design-system/border-utils.css
Normal file
88
app/assets/tailwind/maybe-design-system/border-utils.css
Normal 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;
|
||||
}
|
||||
}
|
109
app/assets/tailwind/maybe-design-system/component-utils.css
Normal file
109
app/assets/tailwind/maybe-design-system/component-utils.css
Normal 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;
|
||||
}
|
||||
}
|
63
app/assets/tailwind/maybe-design-system/foreground-utils.css
Normal file
63
app/assets/tailwind/maybe-design-system/foreground-utils.css
Normal 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;
|
||||
}
|
||||
}
|
39
app/assets/tailwind/maybe-design-system/text-utils.css
Normal file
39
app/assets/tailwind/maybe-design-system/text-utils.css
Normal 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;
|
||||
}
|
||||
}
|
13
app/components/button_component.html.erb
Normal file
13
app/components/button_component.html.erb
Normal 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 %>
|
41
app/components/button_component.rb
Normal file
41
app/components/button_component.rb
Normal 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
|
148
app/components/buttonish_component.rb
Normal file
148
app/components/buttonish_component.rb
Normal 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
|
38
app/components/dialog_component.html.erb
Normal file
38
app/components/dialog_component.html.erb
Normal 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>
|
105
app/components/dialog_component.rb
Normal file
105
app/components/dialog_component.rb
Normal 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
|
|
@ -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();
|
||||
}
|
||||
}
|
25
app/components/disclosure_component.html.erb
Normal file
25
app/components/disclosure_component.html.erb
Normal 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>
|
12
app/components/disclosure_component.rb
Normal file
12
app/components/disclosure_component.rb
Normal 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
|
8
app/components/filled_icon_component.html.erb
Normal file
8
app/components/filled_icon_component.html.erb
Normal 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 %>
|
97
app/components/filled_icon_component.rb
Normal file
97
app/components/filled_icon_component.rb
Normal 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
|
13
app/components/link_component.html.erb
Normal file
13
app/components/link_component.html.erb
Normal 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 %>
|
31
app/components/link_component.rb
Normal file
31
app/components/link_component.rb
Normal 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
|
27
app/components/menu_component.html.erb
Normal file
27
app/components/menu_component.html.erb
Normal 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 %>
|
37
app/components/menu_component.rb
Normal file
37
app/components/menu_component.rb
Normal 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
|
12
app/components/menu_item_component.html.erb
Normal file
12
app/components/menu_item_component.html.erb
Normal 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 %>
|
57
app/components/menu_item_component.rb
Normal file
57
app/components/menu_item_component.rb
Normal 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
|
12
app/components/tab_component.rb
Normal file
12
app/components/tab_component.rb
Normal 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
|
29
app/components/tabs/nav_component.rb
Normal file
29
app/components/tabs/nav_component.rb
Normal 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
|
11
app/components/tabs/panel_component.rb
Normal file
11
app/components/tabs/panel_component.rb
Normal 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
|
17
app/components/tabs_component.html.erb
Normal file
17
app/components/tabs_component.html.erb
Normal 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 %>
|
65
app/components/tabs_component.rb
Normal file
65
app/components/tabs_component.rb
Normal 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
|
42
app/components/tabs_controller.js
Normal file
42
app/components/tabs_controller.js
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
5
app/components/toggle_component.html.erb
Normal file
5
app/components/toggle_component.html.erb
Normal 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, " ".html_safe, class: label_classes, for: id %>
|
||||
</div>
|
26
app/components/toggle_component.rb
Normal file
26
app/components/toggle_component.rb
Normal 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
|
|
@ -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
|
||||
|
|
3
app/controllers/lookbooks_controller.rb
Normal file
3
app/controllers/lookbooks_controller.rb
Normal file
|
@ -0,0 +1,3 @@
|
|||
class LookbooksController < Lookbook::PreviewController
|
||||
layout "lookbooks"
|
||||
end
|
|
@ -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? }
|
||||
|
|
51
app/helpers/custom_confirm.rb
Normal file
51
app/helpers/custom_confirm.rb
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
56
app/javascript/controllers/app_layout_controller.js
Normal file
56
app/javascript/controllers/app_layout_controller.js
Normal 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(),
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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 };
|
||||
|
|
|
@ -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()}`;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
59
app/javascript/controllers/confirm_dialog_controller.js
Normal file
59
app/javascript/controllers/confirm_dialog_controller.js
Normal 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 };
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
8
app/javascript/controllers/intercom_controller.js
Normal file
8
app/javascript/controllers/intercom_controller.js
Normal file
|
@ -0,0 +1,8 @@
|
|||
import { Controller } from "@hotwired/stimulus";
|
||||
|
||||
// Connects to data-controller="intercom"
|
||||
export default class extends Controller {
|
||||
show() {
|
||||
Intercom("show");
|
||||
}
|
||||
}
|
|
@ -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);
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -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(),
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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");
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
};
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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|
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 %>
|
||||
|
|
|
@ -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 %>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 %>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 %>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 %>
|
||||
|
|
|
@ -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 %>
|
||||
|
|
|
@ -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 %>
|
||||
|
|
|
@ -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 %>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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 %>
|
||||
|
|
|
@ -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 %>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 %>
|
||||
|
|
|
@ -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 %>
|
||||
|
|
|
@ -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 %>
|
||||
|
|
|
@ -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 %>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 %>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 %>
|
||||
|
|
|
@ -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 %>
|
||||
|
|
|
@ -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 %>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue