Compare commits
288 Commits
v0.4.2
...
zachgoll/p
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a8ac82823 | ||
|
|
6e202bd7ec | ||
|
|
e1b81ef879 | ||
|
|
151bf25d27 | ||
|
|
854a21993a | ||
|
|
d21e385962 | ||
|
|
c701755b02 | ||
|
|
43a403e431 | ||
|
|
03e0230e99 | ||
|
|
ffc5f844b2 | ||
|
|
5125411822 | ||
|
|
aecb5aafd8 | ||
|
|
6935ffa3d1 | ||
|
|
03a146222d | ||
|
|
5c82af0e8c | ||
|
|
5cfb4addbd | ||
|
|
fd65b5a747 | ||
|
|
6d4509fbe6 | ||
|
|
c7d9c94489 | ||
|
|
fcdc42760d | ||
|
|
19804d2b05 | ||
|
|
fe24117c50 | ||
|
|
e4ee06c9f6 | ||
|
|
857436d894 | ||
|
|
092350f1f8 | ||
|
|
b719a8b80d | ||
|
|
a71b62575c | ||
|
|
2fbd6cbc5d | ||
|
|
a7438e5c78 | ||
|
|
fd3b583737 | ||
|
|
34b3e4ae20 | ||
|
|
8070986763 | ||
|
|
3d2213b760 | ||
|
|
cc9a75ee28 | ||
|
|
443b834b46 | ||
|
|
868d4ede6e | ||
|
|
caf35701ef | ||
|
|
94a807c3c9 | ||
|
|
dd605a577e | ||
|
|
137219c121 | ||
|
|
ab5bce3462 | ||
|
|
a262a749fe | ||
|
|
7e7ae31216 | ||
|
|
efdd03cfe7 | ||
|
|
1b4577e21e | ||
|
|
e569ad0a8c | ||
|
|
6f68d66eda | ||
|
|
e26e5c5aec | ||
|
|
f82f77466a | ||
|
|
74c7b0941d | ||
|
|
29a8ac9d8a | ||
|
|
9f13b5bb83 | ||
|
|
10f255a9a9 | ||
|
|
b8903d0980 | ||
|
|
35d1447494 | ||
|
|
6dc1d22672 | ||
|
|
6917cecf33 | ||
|
|
5efa8268f6 | ||
|
|
9155e737b2 | ||
|
|
a565343102 | ||
|
|
10dd9e061a | ||
|
|
9793cc74f9 | ||
|
|
3f48992aea | ||
|
|
bcb47a9d29 | ||
|
|
bebe7b40d6 | ||
|
|
050d5ebaad | ||
|
|
30d3eef67f | ||
|
|
df8e22afe9 | ||
|
|
0fb689290a | ||
|
|
9e6e4b1ce6 | ||
|
|
908b3e2489 | ||
|
|
a268c5a563 | ||
|
|
0006b6f6ca | ||
|
|
48a07d6158 | ||
|
|
5d798fe0a0 | ||
|
|
f07c41821e | ||
|
|
7605b0221d | ||
|
|
ab2cec55e7 | ||
|
|
03e3899541 | ||
|
|
3371243a00 | ||
|
|
d8e058d7c6 | ||
|
|
867318cbc1 | ||
|
|
1e5edd9f2f | ||
|
|
42207e487e | ||
|
|
ea1b6f2bd8 | ||
|
|
2707a40a2a | ||
|
|
8b857e9c8a | ||
|
|
a07e9d40a3 | ||
|
|
71be2a04ad | ||
|
|
a67f36bf64 | ||
|
|
628d266980 | ||
|
|
64d5a73eb7 | ||
|
|
2b2dfd03e0 | ||
|
|
fb7107d614 | ||
|
|
c26a7dd2dd | ||
|
|
5da4bb6dc3 | ||
|
|
8c10e87387 | ||
|
|
60c3a04a48 | ||
|
|
c0267d5665 | ||
|
|
0fdeebceb1 | ||
|
|
2e0794b8e1 | ||
|
|
2000f05453 | ||
|
|
470b753833 | ||
|
|
fea1baeb1e | ||
|
|
c022e862aa | ||
|
|
dcc43cb253 | ||
|
|
6e4d35d6ae | ||
|
|
98644f1b87 | ||
|
|
fc9961d420 | ||
|
|
441f436187 | ||
|
|
bc7e32deab | ||
|
|
a7a29b4780 | ||
|
|
1e1ed5ca45 | ||
|
|
793a5d2502 | ||
|
|
84eb2c90d4 | ||
|
|
1210a8f3a3 | ||
|
|
752835f492 | ||
|
|
a1d64d6c2e | ||
|
|
c24ae1762f | ||
|
|
adc5bf58d7 | ||
|
|
0c79b335f1 | ||
|
|
be0d51057d | ||
|
|
cf72f1a387 | ||
|
|
0946a1497a | ||
|
|
aebbb9a3c1 | ||
|
|
194dad702d | ||
|
|
17fa5413f6 | ||
|
|
38b6e30bea | ||
|
|
a51c4d2cba | ||
|
|
79b4a3769b | ||
|
|
9a291edbc8 | ||
|
|
d266b6a35e | ||
|
|
d8cf35eca7 | ||
|
|
23adfb2ef0 | ||
|
|
ed8011f792 | ||
|
|
90a9546f32 | ||
|
|
1aafed5f8b | ||
|
|
47017a6432 | ||
|
|
ae41b3de46 | ||
|
|
d8e34cf791 | ||
|
|
e70295394a | ||
|
|
8019a0b33c | ||
|
|
fe578c8f08 | ||
|
|
9be0553b18 | ||
|
|
218040584d | ||
|
|
90d491906e | ||
|
|
3370ae260d | ||
|
|
341a800b65 | ||
|
|
e6b69c1f5c | ||
|
|
71bc51ca15 | ||
|
|
ce83418f0b | ||
|
|
210b89cd17 | ||
|
|
47aeaf8cea | ||
|
|
db34f6d7a2 | ||
|
|
b6cf6198f4 | ||
|
|
a7dfafc907 | ||
|
|
9b33e50b89 | ||
|
|
1b1add38f2 | ||
|
|
08091d24f9 | ||
|
|
21623eeb2d | ||
|
|
bcfbc4b324 | ||
|
|
04ee1e73be | ||
|
|
79243822bd | ||
|
|
297a695d0f | ||
|
|
8edd7ecef0 | ||
|
|
c88fe2e3b2 | ||
|
|
8cf077f28d | ||
|
|
8985592967 | ||
|
|
d22a16d8de | ||
|
|
77a2d6a048 | ||
|
|
65e1bc6edd | ||
|
|
6a21f26d2d | ||
|
|
298e150f43 | ||
|
|
e657c40d19 | ||
|
|
f181ba941f | ||
|
|
5cb2183bdf | ||
|
|
6f70a54d6f | ||
|
|
f235697178 | ||
|
|
e517127062 | ||
|
|
b06fd1edf0 | ||
|
|
1e01840fee | ||
|
|
48c8499b70 | ||
|
|
8648f11413 | ||
|
|
9fa3698823 | ||
|
|
88a6373e84 | ||
|
|
52d170e36c | ||
|
|
2bc3887262 | ||
|
|
0da057b792 | ||
|
|
0e1c902b63 | ||
|
|
bb0f0239fb | ||
|
|
e6c1c5f368 | ||
|
|
02bbeeaec5 | ||
|
|
1649e991b4 | ||
|
|
7096eefa2b | ||
|
|
4c72231312 | ||
|
|
d86ccd36b6 | ||
|
|
02bfa9f251 | ||
|
|
f2020a816a | ||
|
|
5f2a031d4c | ||
|
|
939244bd3e | ||
|
|
0a17b84566 | ||
|
|
5cf758bd03 | ||
|
|
6331788b33 | ||
|
|
83bee295ca | ||
|
|
dc17a0a298 | ||
|
|
29f445d75e | ||
|
|
9fadfe074b | ||
|
|
2a505b000c | ||
|
|
36a66baf00 | ||
|
|
67716f3006 | ||
|
|
1061aacb0f | ||
|
|
2f6b11c18f | ||
|
|
8e6b81af77 | ||
|
|
9f062de6b4 | ||
|
|
3dfdd0aea5 | ||
|
|
86431e79a3 | ||
|
|
54f5a44a60 | ||
|
|
b41897b5e5 | ||
|
|
f8d64561cf | ||
|
|
5a8074c7ee | ||
|
|
9122eafd31 | ||
|
|
19cc63c8f4 | ||
|
|
a7db914005 | ||
|
|
06468a05b1 | ||
|
|
087dd720c1 | ||
|
|
78baf2b327 | ||
|
|
56203b04d3 | ||
|
|
f65b93a352 | ||
|
|
dd75cadebc | ||
|
|
ed55ef624b | ||
|
|
f363fd4a4e | ||
|
|
b8a3ca7732 | ||
|
|
7b751ac7ca | ||
|
|
15d59959cf | ||
|
|
c66401dc0f | ||
|
|
9dcb9e8ed2 | ||
|
|
045fa1931c | ||
|
|
3f8351abfe | ||
|
|
dc44da6c00 | ||
|
|
2e4180fbf0 | ||
|
|
4b19ca50eb | ||
|
|
a3cd5f4f1d | ||
|
|
86bf47a32e | ||
|
|
5f8a3c9f50 | ||
|
|
eac5d5e663 | ||
|
|
26762477a3 | ||
|
|
372b64ffea | ||
|
|
9627a6bf6f | ||
|
|
cffafd23f0 | ||
|
|
f7fa8fa085 | ||
|
|
28bfcda50a | ||
|
|
e49bda4a2e | ||
|
|
071ad52c7f | ||
|
|
381e39bea8 | ||
|
|
eaa1b6abe0 | ||
|
|
e384369cfb | ||
|
|
8d0509fda0 | ||
|
|
d66c37939a | ||
|
|
cf59fe45e7 | ||
|
|
0544089710 | ||
|
|
5b2fa3d707 | ||
|
|
cf0e573533 | ||
|
|
4e96ca8376 | ||
|
|
c5da8ea550 | ||
|
|
e907b073ed | ||
|
|
4c4a4026c4 | ||
|
|
c95bb082a9 | ||
|
|
4d0df9b950 | ||
|
|
7c66f16750 | ||
|
|
fa0248056d | ||
|
|
624faa10d0 | ||
|
|
9138bd2b76 | ||
|
|
882857fcf0 | ||
|
|
d6793dec05 | ||
|
|
e771c8c1df | ||
|
|
58cc09f5ae | ||
|
|
98c842d3b8 | ||
|
|
fae781e1be | ||
|
|
8208722247 | ||
|
|
f7064fd4dd | ||
|
|
c610b0ba4b | ||
|
|
a4874815a6 | ||
|
|
763e222cdd | ||
|
|
e8390a68d8 | ||
|
|
0e76d753bd | ||
|
|
f5ff5332d5 | ||
|
|
0dea36ec7d | ||
|
|
95989a6c9b |
21
.cursor/rules/general-rules.mdc
Normal file
21
.cursor/rules/general-rules.mdc
Normal file
@@ -0,0 +1,21 @@
|
||||
---
|
||||
description: Miscellaneous rules to get the AI to behave
|
||||
globs: *
|
||||
alwaysApply: true
|
||||
---
|
||||
# General rules for AI
|
||||
|
||||
- Use `Current.user` for the current user. Do NOT use `current_user`.
|
||||
- Use `Current.family` for the current family. Do NOT use `current_family`.
|
||||
- Prior to generating any code, carefully read the project conventions and guidelines
|
||||
- Read [project-design.mdc](mdc:.cursor/rules/project-design.mdc) to understand the codebase
|
||||
- Read [project-conventions.mdc](mdc:.cursor/rules/project-conventions.mdc) to understand _how_ to write code for the codebase
|
||||
- Read [ui-ux-design-guidelines.mdc](mdc:.cursor/rules/ui-ux-design-guidelines.mdc) to understand how to implement frontend code specifically
|
||||
- Ignore i18n methods and files. Hardcode strings in English for now to optimize speed of development.
|
||||
|
||||
## Prohibited actions
|
||||
|
||||
- Do not run `rails server` in your responses.
|
||||
- Do not run `touch tmp/restart.txt`
|
||||
- Do not run `rails credentials`
|
||||
- Do not automatically run migrations
|
||||
@@ -1,14 +1,9 @@
|
||||
---
|
||||
description: This rule explains the project's tech stack and code conventions
|
||||
globs: *
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: true
|
||||
---
|
||||
This rule serves as high-level documentation for how the Maybe codebase is structured.
|
||||
|
||||
## Rules for AI
|
||||
|
||||
- Use this file to understand how the codebase works
|
||||
- Treat this rule/file as your "source of truth" when making code recommendations
|
||||
- When creating migrations, always use `rails g migration` instead of creating the file yourself
|
||||
This rule serves as high-level documentation for how you should write code for the Maybe codebase.
|
||||
|
||||
## Project Tech Stack
|
||||
|
||||
@@ -18,8 +13,9 @@ This rule serves as high-level documentation for how the Maybe codebase is struc
|
||||
- Hotwire Turbo/Stimulus for SPA-like UI/UX
|
||||
- TailwindCSS for styles
|
||||
- Lucide Icons for icons
|
||||
- OpenAI for AI chat
|
||||
- Database: PostgreSQL
|
||||
- Jobs: GoodJob
|
||||
- Jobs: Sidekiq + Redis
|
||||
- External
|
||||
- Payments: Stripe
|
||||
- User bank data syncing: Plaid
|
||||
@@ -46,39 +42,79 @@ This codebase adopts a "skinny controller, fat models" convention. Furthermore,
|
||||
- When concerns are used for code organization, they should be organized around the "traits" of a model; not for simply moving code to another spot in the codebase.
|
||||
- When possible, models should answer questions about themselves—for example, we might have a method, `account.balance_series` that returns a time-series of the account's most recent balances. We prefer this over something more service-like such as `AccountSeries.new(account).call`.
|
||||
|
||||
### Convention 3: Prefer server-side solutions over client-side solutions
|
||||
### Convention 3: Leverage Hotwire, write semantic HTML, CSS, and JS, prefer server-side solutions
|
||||
|
||||
- When possible, leverage Turbo frames over complex, JS-driven client-side solutions
|
||||
- When writing a client-side solution, use Stimulus controllers and keep it simple!
|
||||
- Especially when dealing with money and currencies, calculate + format server-side and then pass that to the client to display
|
||||
- Keep client-side code for where it truly shines. For example, [bulk_select_controller.js](mdc:app/javascript/controllers/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.
|
||||
|
||||
### Convention 4: Sacrifice performance, optimize for simplicitly and clarity
|
||||
|
||||
This codebase is still young. We are still rapidly iterating on domain designs and features. Because of this, code should be optimized for simplicitly and clarity over performance.
|
||||
|
||||
- Focus on good OOP design first, performance second
|
||||
- Be mindful of large performance bottlenecks, but don't sweat the small stuff
|
||||
|
||||
### Convention 5: Prefer semantic, native HTML features
|
||||
|
||||
The HTML spec has improved tremendously over the years and offers a ton of functionality out of the box. We prefer semantic, native HTML solutions over JS-based ones. A few examples of this include:
|
||||
|
||||
- Using the `dialog` element for modals
|
||||
- Using `summary` / `details` elements for disclosures (or `popover` attribute)
|
||||
- Native HTML is always preferred over JS-based components
|
||||
- Example 1: Use `<dialog>` element for modals instead of creating a custom component
|
||||
- Example 2: Use `<details><summary>...</summary></details>` for disclosures rather than custom components
|
||||
- Leverage Turbo frames to break up the page over JS-driven client-side solutions
|
||||
- Example 1: A good example of turbo frame usage is in [application.html.erb](mdc:app/views/layouts/application.html.erb) where we load [chats_controller.rb](mdc:app/controllers/chats_controller.rb) actions in a turbo frame in the global layout
|
||||
- Leverage query params in the URL for state over local storage and sessions. If absolutely necessary, utilize the DB for persistent state.
|
||||
- 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.
|
||||
|
||||
### Convention 6: Use Minitest + Fixtures for testing, minimize fixtures
|
||||
### Convention 4: Optimize for simplicitly and clarity
|
||||
|
||||
All code should maximize readability and simplicity.
|
||||
|
||||
- Prioritize good OOP domain design over performance
|
||||
- Only focus on performance for critical and global areas of the codebase; otherwise, don't sweat the small stuff.
|
||||
- Example 1: be mindful of loading large data payloads in global layouts
|
||||
- Example 2: Avoid N+1 queries
|
||||
|
||||
### Convention 5: Use Minitest + Fixtures for testing, minimize fixtures
|
||||
|
||||
Due to the open-source nature of this project, we have chosen Minitest + Fixtures for testing to maximize familiarity and predictability.
|
||||
|
||||
- Always use Minitest and fixtures for testing.
|
||||
- Keep fixtures to a minimum. Most models should have 2-3 fixtures maximum that represent the "base cases" for that model. "Edge cases" should be created on the fly, within the context of the test which it is needed.
|
||||
- For tests that require a large number of fixture records to be created, use Rails helpers such as [entries_test_helper.rb](mdc:test/support/account/entries_test_helper.rb) to act as a "factory" for creating these. For a great example of this, check out [balance_calculator_test.rb](mdc:test/models/account/balance_calculator_test.rb)
|
||||
- For tests that require a large number of fixture records to be created, use Rails helpers such as [entries_test_helper.rb](mdc:test/support/entries_test_helper.rb) to act as a "factory" for creating these. For a great example of this, check out [forward_calculator_test.rb](mdc:test/models/account/balance/forward_calculator_test.rb)
|
||||
- Take a minimal approach to testing—only test the absolutely critical code paths that will significantly increase developer confidence
|
||||
|
||||
### Convention 7: Use ActiveRecord for complex validations, DB for simple ones, keep business logic out of DB
|
||||
#### Convention 5a: Write minimal, effective tests
|
||||
|
||||
- Use system tests sparingly as they increase the time to complete the test suite
|
||||
- Only write tests for critical and important code paths
|
||||
- Write tests as you go, when required
|
||||
- Take a practical approach to testing. Tests are effective when their presence _significantly increases confidence in the codebase_.
|
||||
|
||||
Below are examples of necessary vs. unnecessary tests:
|
||||
|
||||
```rb
|
||||
# GOOD!!
|
||||
# Necessary test - in this case, we're testing critical domain business logic
|
||||
test "syncs balances" do
|
||||
Holding::Syncer.any_instance.expects(:sync_holdings).returns([]).once
|
||||
|
||||
@account.expects(:start_date).returns(2.days.ago.to_date)
|
||||
|
||||
Balance::ForwardCalculator.any_instance.expects(:calculate).returns(
|
||||
[
|
||||
Balance.new(date: 1.day.ago.to_date, balance: 1000, cash_balance: 1000, currency: "USD"),
|
||||
Balance.new(date: Date.current, balance: 1000, cash_balance: 1000, currency: "USD")
|
||||
]
|
||||
)
|
||||
|
||||
assert_difference "@account.balances.count", 2 do
|
||||
Balance::Syncer.new(@account, strategy: :forward).sync_balances
|
||||
end
|
||||
end
|
||||
|
||||
# BAD!!
|
||||
# Unnecessary test - in this case, this is simply testing ActiveRecord's functionality
|
||||
test "saves balance" do
|
||||
balance_record = Balance.new(balance: 100, currency: "USD")
|
||||
|
||||
assert balance_record.save
|
||||
end
|
||||
```
|
||||
|
||||
### Convention 6: Use ActiveRecord for complex validations, DB for simple ones, keep business logic out of DB
|
||||
|
||||
- Enforce `null` checks, unique indexes, and other simple validations in the DB
|
||||
- ActiveRecord validations _may_ mirror the DB level ones, but not 100% necessary. These are for convenience when error handling in forms. Always prefer client-side form validation when possible.
|
||||
- Complex validations and business logic should remain in ActiveRecord
|
||||
- Complex validations and business logic should remain in ActiveRecord
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
---
|
||||
description: This rule explains the system architecture and data flow of the Rails app
|
||||
globs: *
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
This file outlines how the codebase is structured and how data flows through the app.
|
||||
@@ -54,29 +55,29 @@ All balances are calculated daily by [balance_calculator.rb](mdc:app/models/acco
|
||||
|
||||
### Account Holdings
|
||||
|
||||
An account [holding.rb](mdc:app/models/account/holding.rb) applies to [investment.rb](mdc:app/models/investment.rb) type accounts and represents a `qty` of a certain [security.rb](mdc:app/models/security.rb) at a specific `price` on a specific `date`.
|
||||
An account [holding.rb](mdc:app/models/holding.rb) applies to [investment.rb](mdc:app/models/investment.rb) type accounts and represents a `qty` of a certain [security.rb](mdc:app/models/security.rb) at a specific `price` on a specific `date`.
|
||||
|
||||
For investment accounts with holdings, [holding_calculator.rb](mdc:app/models/account/holding_calculator.rb) is used to calculate the daily historical holding quantities and prices, which are then rolled up into a final "Balance" for the account in [balance_calculator.rb](mdc:app/models/account/balance_calculator.rb).
|
||||
For investment accounts with holdings, [base_calculator.rb](mdc:app/models/holding/base_calculator.rb) is used to calculate the daily historical holding quantities and prices, which are then rolled up into a final "Balance" for the account in [base_calculator.rb](mdc:app/models/account/balance/base_calculator.rb).
|
||||
|
||||
### Account Entries
|
||||
|
||||
An account [entry.rb](mdc:app/models/account/entry.rb) is also a Rails "delegated type". `Account::Entry` represents any record that _modifies_ an `Account` [balance.rb](mdc:app/models/account/balance.rb) and/or [holding.rb](mdc:app/models/account/holding.rb). Therefore, every entry must have a `date`, `amount`, and `currency`.
|
||||
An account [entry.rb](mdc:app/models/entry.rb) is also a Rails "delegated type". `Entry` represents any record that _modifies_ an `Account` [balance.rb](mdc:app/models/account/balance.rb) and/or [holding.rb](mdc:app/models/holding.rb). Therefore, every entry must have a `date`, `amount`, and `currency`.
|
||||
|
||||
The `amount` of an [entry.rb](mdc:app/models/account/entry.rb) is a signed value. A _negative_ amount is an "inflow" of money to that account. A _positive_ value is an "outflow" of money from that account. For example:
|
||||
The `amount` of an [entry.rb](mdc:app/models/entry.rb) is a signed value. A _negative_ amount is an "inflow" of money to that account. A _positive_ value is an "outflow" of money from that account. For example:
|
||||
|
||||
- A negative amount for a credit card account represents a "payment" to that account, which _reduces_ its balance (since it is a `liability`)
|
||||
- A negative amount for a checking account represents an "income" to that account, which _increases_ its balance (since it is an `asset`)
|
||||
- A negative amount for an investment/brokerage trade represents a "sell" transaction, which _increases_ the cash balance of the account
|
||||
|
||||
There are 3 entry types, defined as [entryable.rb](mdc:app/models/account/entryable.rb) records:
|
||||
There are 3 entry types, defined as [entryable.rb](mdc:app/models/entryable.rb) records:
|
||||
|
||||
- `Account::Valuation` - an account [valuation.rb](mdc:app/models/account/valuation.rb) is an entry that says, "here is the value of this account on this date". It is an absolute measure of an account value / debt. If there is an `Account::Valuation` of 5,000 for today's date, that means that the account balance will be 5,000 today.
|
||||
- `Account::Transaction` - an account [transaction.rb](mdc:app/models/account/transaction.rb) is an entry that alters the account balance by the `amount`. This is the most common type of entry and can be thought of as an "income" or "expense".
|
||||
- `Account::Trade` - an account [trade.rb](mdc:app/models/account/trade.rb) is an entry that only applies to an investment account. This represents a "buy" or "sell" of a holding and has a `qty` and `price`.
|
||||
- `Valuation` - an account [valuation.rb](mdc:app/models/valuation.rb) is an entry that says, "here is the value of this account on this date". It is an absolute measure of an account value / debt. If there is an `Valuation` of 5,000 for today's date, that means that the account balance will be 5,000 today.
|
||||
- `Transaction` - an account [transaction.rb](mdc:app/models/transaction.rb) is an entry that alters the account balance by the `amount`. This is the most common type of entry and can be thought of as an "income" or "expense".
|
||||
- `Trade` - an account [trade.rb](mdc:app/models/trade.rb) is an entry that only applies to an investment account. This represents a "buy" or "sell" of a holding and has a `qty` and `price`.
|
||||
|
||||
### Account Transfers
|
||||
|
||||
A [transfer.rb](mdc:app/models/transfer.rb) represents a movement of money between two accounts. A transfer has an inflow [transaction.rb](mdc:app/models/account/transaction.rb) and an outflow [transaction.rb](mdc:app/models/account/transaction.rb). The Maybe system auto-matches transfers based on the following criteria:
|
||||
A [transfer.rb](mdc:app/models/transfer.rb) represents a movement of money between two accounts. A transfer has an inflow [transaction.rb](mdc:app/models/transaction.rb) and an outflow [transaction.rb](mdc:app/models/transaction.rb). The Maybe system auto-matches transfers based on the following criteria:
|
||||
|
||||
- Must be from different accounts
|
||||
- Must be within 4 days of each other
|
||||
@@ -110,14 +111,14 @@ Below are brief descriptions of each type of sync in more detail.
|
||||
|
||||
### Account Syncs
|
||||
|
||||
The most important type of sync is the account sync. It is orchestrated by the account [syncer.rb](mdc:app/models/account/syncer.rb), and performs a few important tasks:
|
||||
The most important type of sync is the account sync. It is orchestrated by the account's `sync_data` method, which performs a few important tasks:
|
||||
|
||||
- Auto-matches transfer records for the account
|
||||
- Calculates holdings and balances for the account
|
||||
- Enriches transaction data
|
||||
- Converts account balances that are not in the family's preferred currency to the preferred currency
|
||||
- Calculates daily [balance.rb](mdc:app/models/account/balance.rb) records for the account from `account.start_date` to `Date.current` using [base_calculator.rb](mdc:app/models/account/balance/base_calculator.rb)
|
||||
- Balances are dependent on the calculation of [holding.rb](mdc:app/models/holding.rb), which uses [base_calculator.rb](mdc:app/models/account/holding/base_calculator.rb)
|
||||
- Enriches transaction data if enabled by user
|
||||
|
||||
An account sync happens every time an [entry.rb](mdc:app/models/account/entry.rb) is updated.
|
||||
An account sync happens every time an [entry.rb](mdc:app/models/entry.rb) is updated.
|
||||
|
||||
### Plaid Item Syncs
|
||||
|
||||
@@ -125,10 +126,124 @@ A Plaid Item sync is an ETL (extract, transform, load) operation:
|
||||
|
||||
1. [plaid_item.rb](mdc:app/models/plaid_item.rb) fetches data from the external Plaid API
|
||||
2. [plaid_item.rb](mdc:app/models/plaid_item.rb) creates and loads this data to [plaid_account.rb](mdc:app/models/plaid_account.rb) records
|
||||
3. [plaid_item.rb](mdc:app/models/plaid_item.rb) and [plaid_account.rb](mdc:app/models/plaid_account.rb) transform and load data to [account.rb](mdc:app/models/account.rb) and [entry.rb](mdc:app/models/account/entry.rb), the internal Maybe representations of the data.
|
||||
3. [plaid_item.rb](mdc:app/models/plaid_item.rb) and [plaid_account.rb](mdc:app/models/plaid_account.rb) transform and load data to [account.rb](mdc:app/models/account.rb) and [entry.rb](mdc:app/models/entry.rb), the internal Maybe representations of the data.
|
||||
|
||||
### Family Syncs
|
||||
|
||||
A family sync happens once daily via [auto_sync.rb](mdc:app/controllers/concerns/auto_sync.rb). A family sync is an "orchestrator" of Account and Plaid Item syncs.
|
||||
|
||||
## Data Providers
|
||||
|
||||
The Maybe app utilizes several 3rd party data services to calculate historical account balances, enrich data, and more. Since the app can be run in both "hosted" and "self hosted" mode, this means that data providers are _optional_ for self hosted users and must be configured.
|
||||
|
||||
Because of this optionality, data providers must be configured at _runtime_ through [registry.rb](mdc:app/models/provider/registry.rb) utilizing [setting.rb](mdc:app/models/setting.rb) for runtime parameters like API keys:
|
||||
|
||||
There are two types of 3rd party data in the Maybe app:
|
||||
|
||||
1. "Concept" data
|
||||
2. One-off data
|
||||
|
||||
### "Concept" data
|
||||
|
||||
Since the app is self hostable, users may prefer using different providers for generic data like exchange rates and security prices. When data is generic enough where we can easily swap out different providers, we call it a data "concept".
|
||||
|
||||
Each "concept" has an interface defined in the `app/models/provider/concepts` directory.
|
||||
|
||||
```
|
||||
app/models/
|
||||
exchange_rate/
|
||||
provided.rb # <- Responsible for selecting the concept provider from the registry
|
||||
provider.rb # <- Base provider class
|
||||
provider/
|
||||
registry.rb <- Defines available providers by concept
|
||||
concepts/
|
||||
exchange_rate.rb <- defines the interface required for the exchange rate concept
|
||||
synth.rb # <- Concrete provider implementation
|
||||
```
|
||||
|
||||
### One-off data
|
||||
|
||||
For data that does not fit neatly into a "concept", an interface is not required and the concrete provider may implement ad-hoc methods called directly in code. For example, the [synth.rb](mdc:app/models/provider/synth.rb) provider has a `usage` method that is only applicable to this specific provider. This should be called directly without any abstractions:
|
||||
|
||||
```rb
|
||||
class SomeModel < Application
|
||||
def synth_usage
|
||||
Provider::Registry.get_provider(:synth)&.usage
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
## "Provided" Concerns
|
||||
|
||||
In general, domain models should not be calling [registry.rb](mdc:app/models/provider/registry.rb) directly. When 3rd party data is required for a domain model, we use the `Provided` concern within that model's namespace. This concern is primarily responsible for:
|
||||
|
||||
- Choosing the provider to use for this "concept"
|
||||
- Providing convenience methods on the model for accessing data
|
||||
|
||||
For example, [exchange_rate.rb](mdc:app/models/exchange_rate.rb) has a [provided.rb](mdc:app/models/exchange_rate/provided.rb) concern with the following convenience methods:
|
||||
|
||||
```rb
|
||||
module ExchangeRate::Provided
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
class_methods do
|
||||
def provider
|
||||
registry = Provider::Registry.for_concept(:exchange_rates)
|
||||
registry.get_provider(:synth)
|
||||
end
|
||||
|
||||
def find_or_fetch_rate(from:, to:, date: Date.current, cache: true)
|
||||
# Implementation
|
||||
end
|
||||
|
||||
def sync_provider_rates(from:, to:, start_date:, end_date: Date.current)
|
||||
# Implementation
|
||||
end
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
This exposes a generic access pattern where the caller does not care _which_ provider has been chosen for the concept of exchange rates and can get a predictable response:
|
||||
|
||||
```rb
|
||||
def access_patterns_example
|
||||
# Call exchange rate provider directly
|
||||
ExchangeRate.provider.fetch_exchange_rate(from: "USD", to: "CAD", date: Date.current)
|
||||
|
||||
# Call convenience method
|
||||
ExchangeRate.sync_provider_rates(from: "USD", to: "CAD", start_date: 2.days.ago.to_date)
|
||||
end
|
||||
```
|
||||
|
||||
## Concrete provider implementations
|
||||
|
||||
Each 3rd party data provider should have a class under the `Provider::` namespace that inherits from `Provider` and returns `with_provider_response`, which will return a `Provider::ProviderResponse` object:
|
||||
|
||||
```rb
|
||||
class ConcreteProvider < Provider
|
||||
def fetch_some_data
|
||||
with_provider_response do
|
||||
ExampleData.new(
|
||||
example: "data"
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
The `with_provider_response` automatically catches provider errors, so concrete provider classes should raise when valid data is not possible:
|
||||
|
||||
```rb
|
||||
class ConcreteProvider < Provider
|
||||
def fetch_some_data
|
||||
with_provider_response do
|
||||
data = nil
|
||||
|
||||
# Raise an error if data cannot be returned
|
||||
raise ProviderError.new("Could not find the data you need") if data.nil?
|
||||
|
||||
data
|
||||
end
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
@@ -1,13 +1,22 @@
|
||||
---
|
||||
description: This file describes Maybe's design system and how views should be styled
|
||||
globs: app/views/**,app/helpers/**,app/javascript/controllers/**
|
||||
alwaysApply: true
|
||||
---
|
||||
Use this rule whenever you are writing html, css, or even styles in Stimulus controllers that use D3.js.
|
||||
Use the rules below when:
|
||||
|
||||
- You are writing HTML
|
||||
- You are writing CSS
|
||||
- You are writing styles in a JavaScript Stimulus controller
|
||||
|
||||
## Rules for AI (mandatory)
|
||||
|
||||
The codebase uses TailwindCSS v4.x (the newest version) with a custom design system defined in [maybe-design-system.css](mdc:app/assets/tailwind/maybe-design-system.css)
|
||||
|
||||
- Always start by referencing [maybe-design-system.css](mdc:app/assets/tailwind/maybe-design-system.css) to see the base primitives and tokens we use in the codebase
|
||||
- Always generate semantic HTML
|
||||
- Always start by referencing [maybe-design-system.css](mdc:app/assets/tailwind/maybe-design-system.css) to see the base primitives, functional tokens, and component tokens we use in the codebase
|
||||
- Always prefer using the functional "tokens" defined in @maybe-design-system.css when possible.
|
||||
- Example 1: use `text-primary` rather than `text-white`
|
||||
- Example 2: use `bg-container` rather than `bg-white`
|
||||
- Example 3: use `border border-primary` rather than `border border-gray-200`
|
||||
- Never create new styles in [maybe-design-system.css](mdc:app/assets/tailwind/maybe-design-system.css) or [application.css](mdc:app/assets/tailwind/application.css) without explicitly receiving permission to do so
|
||||
- Always favor the "utility first" Tailwind approach. Reusable style classes should not be created often. Code should be reused primarily through ERB partials.
|
||||
- Always prefer using the utility "tokens" defined in [maybe-design-system.css](mdc:app/assets/tailwind/maybe-design-system.css) when possible. For example, use `text-primary` rather than `text-gray-900`.
|
||||
- Always generate semantic HTML
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
ARG RUBY_VERSION=3.4.1
|
||||
ARG RUBY_VERSION=3.4.4
|
||||
FROM ruby:${RUBY_VERSION}-slim-bullseye
|
||||
|
||||
RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
|
||||
@@ -10,6 +10,8 @@ RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
|
||||
imagemagick \
|
||||
iproute2 \
|
||||
libpq-dev \
|
||||
libyaml-dev \
|
||||
libyaml-0-2 \
|
||||
openssh-client \
|
||||
postgresql-client \
|
||||
vim
|
||||
|
||||
@@ -1,4 +1,15 @@
|
||||
version: "3"
|
||||
x-db-env: &db_env
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_DB: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
|
||||
x-rails-env: &rails_env
|
||||
DB_HOST: db
|
||||
HOST: "0.0.0.0"
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
BUNDLE_PATH: /bundle
|
||||
REDIS_URL: redis://redis:6379/1
|
||||
|
||||
services:
|
||||
app:
|
||||
@@ -16,32 +27,41 @@ services:
|
||||
command: sleep infinity
|
||||
|
||||
environment:
|
||||
DB_HOST: db
|
||||
HOST: "0.0.0.0"
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
BUNDLE_PATH: /bundle
|
||||
<<: *rails_env
|
||||
|
||||
depends_on:
|
||||
- db
|
||||
- redis
|
||||
|
||||
worker:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: .devcontainer/Dockerfile
|
||||
command: bundle exec sidekiq
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
<<: *rails_env
|
||||
depends_on:
|
||||
- redis
|
||||
|
||||
redis:
|
||||
image: redis:latest
|
||||
ports:
|
||||
- "6379:6379"
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- redis-data:/data
|
||||
db:
|
||||
image: postgres:latest
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_DB: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
<<: *db_env
|
||||
ports:
|
||||
- "5432:5432"
|
||||
|
||||
volumes:
|
||||
postgres-data:
|
||||
redis-data:
|
||||
bundle_cache:
|
||||
|
||||
96
.env.example
96
.env.example
@@ -1,20 +1,31 @@
|
||||
# ================================ PLEASE READ ==========================================
|
||||
# This file outlines all the possible environment variables supported by the Maybe app.
|
||||
#
|
||||
# This includes several features that are for our "hosted" version of Maybe, which most
|
||||
# open-source contributors won't need.
|
||||
# ================================ PLEASE READ ===========================================================
|
||||
# This file outlines all the possible environment variables supported by the Maybe app for self hosting.
|
||||
#
|
||||
# If you are developing locally, you should be referencing `.env.local.example` instead.
|
||||
# =======================================================================================
|
||||
# If you're a developer setting up your local environment, please use `.env.local.example` instead.
|
||||
# ========================================================================================================
|
||||
|
||||
# Required self-hosting vars
|
||||
# --------------------------------------------------------------------------------------------------------
|
||||
|
||||
# Enables self hosting features (should be set to true unless you know what you're doing)
|
||||
SELF_HOSTED=true
|
||||
|
||||
# Secret key used to encrypt credentials (https://api.rubyonrails.org/v7.1.3.2/classes/Rails/Application.html#method-i-secret_key_base)
|
||||
# Has to be a random string, generated eg. by running `openssl rand -hex 64`
|
||||
SECRET_KEY_BASE=secret-value
|
||||
|
||||
# Optional self-hosting vars
|
||||
# --------------------------------------------------------------------------------------------------------
|
||||
|
||||
# Optional: Synth API Key for exchange rates + stock prices
|
||||
# (you can also set this in your self-hosted settings page)
|
||||
# Get it here: https://synthfinance.com/
|
||||
SYNTH_API_KEY=
|
||||
|
||||
# Custom port config
|
||||
# For users who have other applications listening at 3000, this allows them to set a value puma will listen to.
|
||||
PORT=3000
|
||||
|
||||
# Exchange Rate & Stock Pricing API
|
||||
# This is used to convert between different currencies in the app. In addition, it fetches global stock prices. We use Synth, which is a Maybe product. You can sign up for a free account at synthfinance.com.
|
||||
SYNTH_API_KEY=
|
||||
|
||||
# SMTP Configuration
|
||||
# This is only needed if you intend on sending emails from your Maybe instance (such as for password resets or email financial reports).
|
||||
# Resend.com is a good option that offers a free tier for sending emails.
|
||||
@@ -37,60 +48,20 @@ POSTGRES_USER=postgres
|
||||
# This is the domain that your Maybe instance will be hosted at. It is used to generate links in emails and other places.
|
||||
APP_DOMAIN=
|
||||
|
||||
## Error and Performance Monitoring
|
||||
# The app uses Sentry to monitor errors and performance. In reality, you likely don't need this unless you're deploying Maybe to many users.
|
||||
SENTRY_DSN=
|
||||
|
||||
# If enabled, an invite code generated by `rake invites:create` is required to sign up as a new user.
|
||||
# This is useful for controlling who can sign up for your Maybe instance.
|
||||
REQUIRE_INVITE_CODE=false
|
||||
|
||||
# Enables self hosting features (should be set to true for most folks)
|
||||
SELF_HOSTED=true
|
||||
|
||||
# The hosting platform used to deploy the app (e.g. "render")
|
||||
# `localhost` (or unset) is used for local development and testing
|
||||
HOSTING_PLATFORM=localhost
|
||||
|
||||
# Secret key used to encrypt credentials (https://api.rubyonrails.org/v7.1.3.2/classes/Rails/Application.html#method-i-secret_key_base)
|
||||
# Has to be a random string, generated eg. by running `openssl rand -hex 64`
|
||||
SECRET_KEY_BASE=secret-value
|
||||
|
||||
# Disable enforcing SSL connections
|
||||
# DISABLE_SSL=true
|
||||
|
||||
# ======================================================================================================
|
||||
# Upgrades Module - responsible for triggering upgrade alerts, prompts, and auto-upgrade functionality
|
||||
# ======================================================================================================
|
||||
#
|
||||
# UPGRADES_ENABLED: Enables Upgrader class functionality.
|
||||
# UPGRADES_MODE: Controls how the app will upgrade. `manual` means the user must manually upgrade the app. `auto` means the app will upgrade automatically (great for self-hosting)
|
||||
# UPGRADES_TARGET: Controls what the app will upgrade to. `release` means the app will upgrade to the latest release. `commit` means the app will upgrade to the latest commit.
|
||||
#
|
||||
UPGRADES_ENABLED=false # unless editing the flow, you should keep this `false` locally in development
|
||||
UPGRADES_MODE=manual # `manual` or `auto`
|
||||
UPGRADES_TARGET=release # `release` or `commit`
|
||||
|
||||
|
||||
# ======================================================================================================
|
||||
# Git Repository Module - responsible for fetching latest commit data for upgrades
|
||||
# ======================================================================================================
|
||||
#
|
||||
GITHUB_REPO_OWNER=maybe-finance
|
||||
GITHUB_REPO_NAME=maybe
|
||||
GITHUB_REPO_BRANCH=main
|
||||
|
||||
# ======================================================================================================
|
||||
# Active Storage Configuration - responsible for storing file uploads
|
||||
# ======================================================================================================
|
||||
#
|
||||
# * Defaults to disk storage but you can also use Amazon S3, Google Cloud Storage, or Microsoft Azure Storage.
|
||||
# * Defaults to disk storage but you can also use Amazon S3 or Cloudflare R2
|
||||
# * Set the appropriate environment variables to use these services.
|
||||
# * Ensure libvips is installed on your system for image processing - https://github.com/libvips/libvips
|
||||
#
|
||||
# Amazon S3
|
||||
# ==========
|
||||
# ACTIVE_STORAGE_SERVICE=amazon
|
||||
# ACTIVE_STORAGE_SERVICE=amazon <- Enables Amazon S3 storage
|
||||
# S3_ACCESS_KEY_ID=
|
||||
# S3_SECRET_ACCESS_KEY=
|
||||
# S3_REGION= # defaults to `us-east-1` if not set
|
||||
@@ -98,26 +69,9 @@ GITHUB_REPO_BRANCH=main
|
||||
#
|
||||
# Cloudflare R2
|
||||
# =============
|
||||
# ACTIVE_STORAGE_SERVICE=cloudflare
|
||||
# ACTIVE_STORAGE_SERVICE=cloudflare <- Enables Cloudflare R2 storage
|
||||
# CLOUDFLARE_ACCOUNT_ID=
|
||||
# CLOUDFLARE_ACCESS_KEY_ID=
|
||||
# CLOUDFLARE_SECRET_ACCESS_KEY=
|
||||
# CLOUDFLARE_BUCKET=
|
||||
|
||||
# ======================================================================================================
|
||||
# Billing Module - responsible for handling billing
|
||||
# ======================================================================================================
|
||||
#
|
||||
STRIPE_PUBLISHABLE_KEY=
|
||||
STRIPE_SECRET_KEY=
|
||||
STRIPE_WEBHOOK_SECRET=
|
||||
|
||||
# ======================================================================================================
|
||||
# Plaid Configuration
|
||||
# ======================================================================================================
|
||||
#
|
||||
PLAID_CLIENT_ID=
|
||||
PLAID_SECRET=
|
||||
PLAID_ENV=
|
||||
PLAID_EU_CLIENT_ID=
|
||||
PLAID_EU_SECRET=
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
SELF_HOSTED=false
|
||||
SYNTH_API_KEY=fookey
|
||||
|
||||
# Set to true if you want SimpleCov reports generated
|
||||
COVERAGE=false
|
||||
|
||||
# Set to true to run test suite serially
|
||||
DISABLE_PARALLELIZATION=false
|
||||
@@ -1,3 +1,5 @@
|
||||
SELF_HOSTED=false
|
||||
|
||||
# ================
|
||||
# Data Providers
|
||||
# ---------------------------------------------------------------------------------
|
||||
|
||||
52
.github/ISSUE_TEMPLATE/bug_report.md
vendored
52
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -1,31 +1,61 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
about: Open a bug report when you experience broken functionality within the latest
|
||||
version of the Maybe app
|
||||
title: 'Bug: [Add descriptive title here]'
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Where did this bug occur? (required)**
|
||||
## Before you start (required)
|
||||
|
||||
- [ ] I am a self-hosted user reporting a bug from my self hosted app
|
||||
- [ ] I am a user of Maybe's paid app
|
||||
### General checklist
|
||||
|
||||
_Please note, if you are reporting a bug with sensitive data, please open an Intercom chat from within the app for help_
|
||||
- [ ] I have removed personal / sensitive data from screenshots and logs
|
||||
- [ ] I have searched [existing issues](https://github.com/maybe-finance/maybe/issues?q=is:issue) and [discussions](https://github.com/maybe-finance/maybe/discussions) to ensure this is not a duplicate issue
|
||||
|
||||
### How are you using Maybe?
|
||||
|
||||
- [ ] I am a paying Maybe customer (hosted version)
|
||||
- Paying Maybe users can also open requests in Intercom (if there is sensitive info involved)
|
||||
- [ ] I am a self-hosted user
|
||||
|
||||
### Self hoster checklist
|
||||
|
||||
_Paying, hosted users should delete this entire section._
|
||||
|
||||
If you are a self-hosted user, please complete all of the information below. Issues with incomplete information will be marked as `Needs Info` to help our small team prioritize bug fixes.
|
||||
|
||||
- Self hosted app commit SHA (find in user menu): [enter commit sha here]
|
||||
- [ ] I have confirmed that my app's commit is the latest version of Maybe
|
||||
- Where are you hosting?
|
||||
- [ ] Render
|
||||
- [ ] Docker Compose
|
||||
- [ ] Umbrel
|
||||
- [ ] Other (please specify)
|
||||
|
||||
---
|
||||
|
||||
## Bug description
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
### To Reproduce
|
||||
|
||||
Be as specific as possible so Maybe maintainers can quickly reproduce the bug you're experiencing.
|
||||
|
||||
Steps to reproduce the behavior:
|
||||
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
### Expected behavior
|
||||
|
||||
**Screenshots / Recordings**
|
||||
If applicable, add screenshots or short video recordings to help show the bug in more detail.
|
||||
What is the intended behavior that you would expect?
|
||||
|
||||
### Screenshots and/or recordings
|
||||
|
||||
We highly recommend providing additional context with screenshots and/or screen recordings. This will _significantly_ improve the chances of the bug being addressed and fixed quickly.
|
||||
|
||||
32
.github/ISSUE_TEMPLATE/other.md
vendored
32
.github/ISSUE_TEMPLATE/other.md
vendored
@@ -7,15 +7,33 @@ assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**PLEASE READ before opening an issue:**
|
||||
## Before you start (required)
|
||||
|
||||
- Is this a feature request? Please [open a feature request discussion](https://github.com/maybe-finance/maybe/discussions/new?category=feature-requests).
|
||||
- Do you need help or have a question? Please [open a discussion](https://github.com/maybe-finance/maybe/discussions/new/choose) or [join our Discord](https://link.maybe.co/discord) and post to the "help" channel.
|
||||
### Is this a bug?
|
||||
|
||||
----------------------
|
||||
A bug is _broken functionality_ of the app (i.e. it prevents you from using the app). For bugs, please use the ["Bug Report" template](https://github.com/maybe-finance/maybe/issues) instead.
|
||||
|
||||
**Is this issue related to a problem? Please describe.**
|
||||
### Is this a bug with _sensitive info_?
|
||||
|
||||
**Describe the work that needs to be done to address this issue**
|
||||
If you are a _paying_ Maybe user, you can open a support request in Intercom.
|
||||
|
||||
**Additional context**
|
||||
### Is this a feature request?
|
||||
|
||||
A feature request is functionality that you would like that is not already on our [Roadmap](https://github.com/maybe-finance/maybe/wiki/Roadmap).
|
||||
|
||||
All feature requests should be opened in a [Feature request Discussion](https://github.com/maybe-finance/maybe/discussions/categories/feature-requests).
|
||||
|
||||
Be sure to search existing discussions prior to opening a new feature request.
|
||||
|
||||
### Is this related to Docker and/or hosting for self hosting?
|
||||
|
||||
If you are having a Docker configuration issue, please do not open a Github issue unless you've identified a bug in our Dockerfile. To get help with self hosting, there are several options:
|
||||
|
||||
- **First**: Read our [Docker hosting guide](https://github.com/maybe-finance/maybe/tree/main/docs/hosting/docker.md) and follow it step-by-step
|
||||
- Open a [Docker Discussion](https://github.com/maybe-finance/maybe/discussions/categories/docker-compose-hosting)
|
||||
|
||||
---
|
||||
|
||||
## Issue description
|
||||
|
||||
If your issue does not fall into the categories above, please provide a **descriptive and complete** overview of your issue.
|
||||
|
||||
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@@ -77,6 +77,8 @@ jobs:
|
||||
timeout-minutes: 10
|
||||
|
||||
env:
|
||||
PLAID_CLIENT_ID: foo
|
||||
PLAID_SECRET: bar
|
||||
DATABASE_URL: postgres://postgres:postgres@localhost:5432
|
||||
RAILS_ENV: test
|
||||
|
||||
|
||||
10
.github/workflows/publish.yml
vendored
10
.github/workflows/publish.yml
vendored
@@ -1,6 +1,13 @@
|
||||
name: Publish Docker image
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
ref:
|
||||
description: 'Git ref (tag or commit SHA) to build'
|
||||
required: true
|
||||
type: string
|
||||
default: 'main'
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
@@ -33,6 +40,8 @@ jobs:
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.inputs.ref || github.ref }}
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
@@ -73,3 +82,4 @@ jobs:
|
||||
provenance: false
|
||||
# https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-container-registry#adding-a-description-to-multi-arch-images
|
||||
outputs: type=image,name=target,annotation-index.org.opencontainers.image.description=A multi-arch Docker image for the Maybe Rails app
|
||||
build-args: BUILD_COMMIT_SHA=${{ github.sha }}
|
||||
|
||||
9
.gitignore
vendored
9
.gitignore
vendored
@@ -11,7 +11,6 @@
|
||||
# Ignore all environment files (except templates).
|
||||
/.env*
|
||||
!/.env*.erb
|
||||
!.env.test
|
||||
!.env*.example
|
||||
|
||||
# Ignore all logfiles and tempfiles.
|
||||
@@ -63,6 +62,12 @@ gcp-storage-keyfile.json
|
||||
|
||||
coverage
|
||||
.cursorrules
|
||||
.cursor/rules/structure.mdc
|
||||
.cursor/rules/agent.mdc
|
||||
|
||||
# Ignore node related files
|
||||
node_modules
|
||||
node_modules
|
||||
|
||||
compose.yml
|
||||
|
||||
plaid_test_accounts/
|
||||
@@ -1 +1 @@
|
||||
3.4.1
|
||||
3.4.4
|
||||
|
||||
@@ -4,7 +4,7 @@ It means so much that you're interested in contributing to Maybe! Seriously. Tha
|
||||
|
||||
## House Rules
|
||||
|
||||
- Before contributing, familiarize yourself with our project conventions. You should read through our [Project Conventions Rule](https://github.com/maybe-finance/maybe/.cursor/rules/project-conventions.mdc), which is intended for LLMs, but is also an excellent primer on how we write code for Maybe.
|
||||
- Before contributing, familiarize yourself with our project conventions. You should read through our [Project Conventions Rule](https://github.com/maybe-finance/maybe/.cursor/rules/project-conventions.mdc), which is intended for LLMs, but is also an excellent primer on how we write code for Maybe.
|
||||
- While totally optional, consider using Cursor + VSCode as it will automatically apply our project conventions to your code via the `.cursor/rules` directory.
|
||||
- Before contributing, please check if it already exists in [issues](https://github.com/maybe-finance/maybe/issues) or [PRs](https://github.com/maybe-finance/maybe/pulls)
|
||||
- Given the speed at which we're moving on the codebase, we don't assign issues or "give" issues to anyone.
|
||||
|
||||
12
Dockerfile
12
Dockerfile
@@ -1,7 +1,7 @@
|
||||
# syntax = docker/dockerfile:1
|
||||
|
||||
# Make sure RUBY_VERSION matches the Ruby version in .ruby-version and Gemfile
|
||||
ARG RUBY_VERSION=3.4.1
|
||||
ARG RUBY_VERSION=3.4.4
|
||||
FROM registry.docker.com/library/ruby:$RUBY_VERSION-slim AS base
|
||||
|
||||
# Rails app lives here
|
||||
@@ -9,19 +9,21 @@ WORKDIR /rails
|
||||
|
||||
# Install base packages
|
||||
RUN apt-get update -qq && \
|
||||
apt-get install --no-install-recommends -y curl libvips postgresql-client git
|
||||
apt-get install --no-install-recommends -y curl libvips postgresql-client libyaml-0-2
|
||||
|
||||
# Set production environment
|
||||
ARG BUILD_COMMIT_SHA
|
||||
ENV RAILS_ENV="production" \
|
||||
BUNDLE_DEPLOYMENT="1" \
|
||||
BUNDLE_PATH="/usr/local/bundle" \
|
||||
BUNDLE_WITHOUT="development"
|
||||
|
||||
BUNDLE_WITHOUT="development" \
|
||||
BUILD_COMMIT_SHA=${BUILD_COMMIT_SHA}
|
||||
|
||||
# Throw-away build stage to reduce size of final image
|
||||
FROM base AS build
|
||||
|
||||
# Install packages needed to build gems
|
||||
RUN apt-get install --no-install-recommends -y build-essential libpq-dev pkg-config
|
||||
RUN apt-get install --no-install-recommends -y build-essential libpq-dev git pkg-config libyaml-dev
|
||||
|
||||
# Install application gems
|
||||
COPY .ruby-version Gemfile Gemfile.lock ./
|
||||
|
||||
30
Gemfile
30
Gemfile
@@ -7,6 +7,7 @@ gem "rails", "~> 7.2.2"
|
||||
|
||||
# Drivers
|
||||
gem "pg", "~> 1.5"
|
||||
gem "redis", "~> 5.4"
|
||||
|
||||
# Deployment
|
||||
gem "puma", ">= 5.0"
|
||||
@@ -18,28 +19,32 @@ gem "propshaft"
|
||||
gem "tailwindcss-rails"
|
||||
gem "lucide-rails", github: "maybe-finance/lucide-rails"
|
||||
|
||||
# Hotwire
|
||||
# Hotwire + UI
|
||||
gem "stimulus-rails"
|
||||
gem "turbo-rails"
|
||||
|
||||
# Temporary pin to commit to fix crypto.randomUUID() errors. Revert this when the change has been released.
|
||||
gem "hotwire_combobox", github: "josefarias/hotwire_combobox", ref: "b827048a8305e1115d5f96931ba1c9750d1e59fc"
|
||||
gem "view_component"
|
||||
gem "lookbook", ">= 2.3.7"
|
||||
gem "hotwire_combobox"
|
||||
|
||||
# Background Jobs
|
||||
gem "good_job"
|
||||
gem "sidekiq"
|
||||
gem "sidekiq-cron"
|
||||
|
||||
# Error logging
|
||||
gem "stackprof"
|
||||
# Monitoring
|
||||
gem "vernier"
|
||||
gem "rack-mini-profiler"
|
||||
gem "sentry-ruby"
|
||||
gem "sentry-rails"
|
||||
gem "sentry-sidekiq"
|
||||
gem "logtail-rails"
|
||||
gem "skylight"
|
||||
|
||||
# Active Storage
|
||||
gem "aws-sdk-s3", "~> 1.177.0", require: false
|
||||
gem "image_processing", ">= 1.2"
|
||||
|
||||
# Other
|
||||
gem "ostruct"
|
||||
gem "bcrypt", "~> 3.1"
|
||||
gem "jwt"
|
||||
gem "faraday"
|
||||
@@ -56,7 +61,15 @@ gem "stripe"
|
||||
gem "intercom-rails"
|
||||
gem "plaid"
|
||||
gem "rotp", "~> 6.3"
|
||||
gem "rqrcode", "~> 2.2"
|
||||
gem "rqrcode", "~> 3.0"
|
||||
gem "activerecord-import"
|
||||
|
||||
# State machines
|
||||
gem "aasm"
|
||||
gem "after_commit_everywhere", "~> 1.0"
|
||||
|
||||
# AI
|
||||
gem "ruby-openai"
|
||||
|
||||
group :development, :test do
|
||||
gem "debug", platforms: %i[mri windows]
|
||||
@@ -74,6 +87,7 @@ group :development do
|
||||
gem "web-console"
|
||||
gem "faker"
|
||||
gem "benchmark-ips"
|
||||
gem "foreman"
|
||||
end
|
||||
|
||||
group :test do
|
||||
|
||||
325
Gemfile.lock
325
Gemfile.lock
@@ -1,14 +1,3 @@
|
||||
GIT
|
||||
remote: https://github.com/josefarias/hotwire_combobox.git
|
||||
revision: b827048a8305e1115d5f96931ba1c9750d1e59fc
|
||||
ref: b827048a8305e1115d5f96931ba1c9750d1e59fc
|
||||
specs:
|
||||
hotwire_combobox (0.3.2)
|
||||
platform_agent (>= 1.0.1)
|
||||
rails (>= 7.0.7.2)
|
||||
stimulus-rails (>= 1.2)
|
||||
turbo-rails (>= 1.2)
|
||||
|
||||
GIT
|
||||
remote: https://github.com/maybe-finance/lucide-rails.git
|
||||
revision: 272e5fb8418ea458da3995d6abe0ba0ceee9c9f0
|
||||
@@ -19,6 +8,8 @@ GIT
|
||||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
aasm (5.5.0)
|
||||
concurrent-ruby (~> 1.0)
|
||||
actioncable (7.2.2.1)
|
||||
actionpack (= 7.2.2.1)
|
||||
activesupport (= 7.2.2.1)
|
||||
@@ -72,6 +63,8 @@ GEM
|
||||
activemodel (= 7.2.2.1)
|
||||
activesupport (= 7.2.2.1)
|
||||
timeout (>= 0.4.0)
|
||||
activerecord-import (2.1.0)
|
||||
activerecord (>= 4.2)
|
||||
activestorage (7.2.2.1)
|
||||
actionpack (= 7.2.2.1)
|
||||
activejob (= 7.2.2.1)
|
||||
@@ -92,15 +85,20 @@ GEM
|
||||
tzinfo (~> 2.0, >= 2.0.5)
|
||||
addressable (2.8.7)
|
||||
public_suffix (>= 2.0.2, < 7.0)
|
||||
ast (2.4.2)
|
||||
aws-eventstream (1.3.0)
|
||||
aws-partitions (1.1043.0)
|
||||
aws-sdk-core (3.217.0)
|
||||
after_commit_everywhere (1.6.0)
|
||||
activerecord (>= 4.2)
|
||||
activesupport
|
||||
ast (2.4.3)
|
||||
aws-eventstream (1.3.2)
|
||||
aws-partitions (1.1105.0)
|
||||
aws-sdk-core (3.224.0)
|
||||
aws-eventstream (~> 1, >= 1.3.0)
|
||||
aws-partitions (~> 1, >= 1.992.0)
|
||||
aws-sigv4 (~> 1.9)
|
||||
base64
|
||||
jmespath (~> 1, >= 1.6.1)
|
||||
aws-sdk-kms (1.97.0)
|
||||
logger
|
||||
aws-sdk-kms (1.101.0)
|
||||
aws-sdk-core (~> 3, >= 3.216.0)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sdk-s3 (1.177.0)
|
||||
@@ -122,9 +120,9 @@ GEM
|
||||
smart_properties
|
||||
bigdecimal (3.1.9)
|
||||
bindex (0.8.1)
|
||||
bootsnap (1.18.4)
|
||||
bootsnap (1.18.6)
|
||||
msgpack (~> 1.2)
|
||||
brakeman (7.0.0)
|
||||
brakeman (7.0.2)
|
||||
racc
|
||||
builder (3.3.0)
|
||||
capybara (3.40.0)
|
||||
@@ -140,23 +138,29 @@ GEM
|
||||
logger (~> 1.5)
|
||||
chunky_png (1.4.0)
|
||||
climate_control (1.2.0)
|
||||
concurrent-ruby (1.3.5)
|
||||
connection_pool (2.5.0)
|
||||
concurrent-ruby (1.3.4)
|
||||
connection_pool (2.5.3)
|
||||
crack (1.0.0)
|
||||
bigdecimal
|
||||
rexml
|
||||
crass (1.0.6)
|
||||
csv (3.3.2)
|
||||
cronex (0.15.0)
|
||||
tzinfo
|
||||
unicode (>= 0.4.4.5)
|
||||
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 (5.0.1)
|
||||
erb_lint (0.9.0)
|
||||
activesupport
|
||||
better_html (>= 2.0.1)
|
||||
@@ -167,9 +171,10 @@ GEM
|
||||
erubi (1.13.1)
|
||||
et-orbi (1.2.11)
|
||||
tzinfo
|
||||
event_stream_parser (1.0.0)
|
||||
faker (3.5.1)
|
||||
i18n (>= 1.8.11, < 2)
|
||||
faraday (2.12.2)
|
||||
faraday (2.13.1)
|
||||
faraday-net_http (>= 2.0, < 3.5)
|
||||
json
|
||||
logger
|
||||
@@ -177,28 +182,22 @@ GEM
|
||||
multipart-post (~> 2.0)
|
||||
faraday-net_http (3.4.0)
|
||||
net-http (>= 0.5.0)
|
||||
faraday-retry (2.2.1)
|
||||
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)
|
||||
fugit (1.11.1)
|
||||
et-orbi (~> 1, >= 1.2.11)
|
||||
raabro (~> 1.4)
|
||||
globalid (1.2.1)
|
||||
activesupport (>= 6.1)
|
||||
good_job (4.9.0)
|
||||
activejob (>= 6.1.0)
|
||||
activerecord (>= 6.1.0)
|
||||
concurrent-ruby (>= 1.3.1)
|
||||
fugit (>= 1.11.0)
|
||||
railties (>= 6.1.0)
|
||||
thor (>= 1.0.0)
|
||||
hashdiff (1.1.2)
|
||||
highline (3.1.2)
|
||||
reline
|
||||
@@ -206,9 +205,16 @@ GEM
|
||||
actioncable (>= 7.0.0)
|
||||
listen (>= 3.0.0)
|
||||
railties (>= 7.0.0)
|
||||
hotwire_combobox (0.4.0)
|
||||
platform_agent (>= 1.0.1)
|
||||
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.14)
|
||||
i18n-tasks (1.0.15)
|
||||
activesupport (>= 4.0.2)
|
||||
ast (>= 2.1.0)
|
||||
erubi
|
||||
@@ -217,6 +223,7 @@ GEM
|
||||
parser (>= 3.2.2.1)
|
||||
rails-i18n
|
||||
rainbow (>= 2.2.2, < 4.0)
|
||||
ruby-progressbar (~> 1.8, >= 1.8.1)
|
||||
terminal-table (>= 1.5.1)
|
||||
image_processing (1.14.0)
|
||||
mini_magick (>= 4.9.5, < 6)
|
||||
@@ -232,26 +239,27 @@ 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)
|
||||
jmespath (1.6.2)
|
||||
json (2.10.1)
|
||||
json (2.12.0)
|
||||
jwt (2.10.1)
|
||||
base64
|
||||
language_server-protocol (3.17.0.4)
|
||||
launchy (3.1.0)
|
||||
language_server-protocol (3.17.0.5)
|
||||
launchy (3.1.1)
|
||||
addressable (~> 2.8)
|
||||
childprocess (~> 5.0)
|
||||
logger (~> 1.6)
|
||||
letter_opener (1.10.0)
|
||||
launchy (>= 2.2, < 4)
|
||||
lint_roller (1.1.0)
|
||||
listen (3.9.0)
|
||||
rb-fsevent (~> 0.10, >= 0.10.3)
|
||||
rb-inotify (~> 0.9, >= 0.9.10)
|
||||
logger (1.6.6)
|
||||
logtail (0.1.15)
|
||||
logger (1.7.0)
|
||||
logtail (0.1.17)
|
||||
msgpack (~> 1.0)
|
||||
logtail-rack (0.2.6)
|
||||
logtail (~> 0.1)
|
||||
@@ -262,9 +270,21 @@ GEM
|
||||
logtail (~> 0.1, >= 0.1.14)
|
||||
logtail-rack (~> 0.1)
|
||||
railties (>= 5.0.0)
|
||||
loofah (2.24.0)
|
||||
loofah (2.24.1)
|
||||
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
|
||||
@@ -272,53 +292,55 @@ GEM
|
||||
net-smtp
|
||||
marcel (1.0.4)
|
||||
matrix (0.4.2)
|
||||
mini_magick (5.1.2)
|
||||
method_source (1.1.0)
|
||||
mini_magick (5.2.0)
|
||||
benchmark
|
||||
logger
|
||||
mini_mime (1.1.5)
|
||||
minitest (5.25.4)
|
||||
minitest (5.25.5)
|
||||
mocha (2.7.1)
|
||||
ruby2_keywords (>= 0.0.5)
|
||||
msgpack (1.8.0)
|
||||
multipart-post (2.4.1)
|
||||
net-http (0.6.0)
|
||||
uri
|
||||
net-imap (0.5.5)
|
||||
net-imap (0.5.8)
|
||||
date
|
||||
net-protocol
|
||||
net-pop (0.1.2)
|
||||
net-protocol
|
||||
net-protocol (0.2.2)
|
||||
timeout
|
||||
net-smtp (0.5.0)
|
||||
net-smtp (0.5.1)
|
||||
net-protocol
|
||||
nio4r (2.7.4)
|
||||
nokogiri (1.18.2-aarch64-linux-gnu)
|
||||
nokogiri (1.18.8-aarch64-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.2-aarch64-linux-musl)
|
||||
nokogiri (1.18.8-aarch64-linux-musl)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.2-arm-linux-gnu)
|
||||
nokogiri (1.18.8-arm-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.2-arm-linux-musl)
|
||||
nokogiri (1.18.8-arm-linux-musl)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.2-arm64-darwin)
|
||||
nokogiri (1.18.8-arm64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.2-x86_64-darwin)
|
||||
nokogiri (1.18.8-x86_64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.2-x86_64-linux-gnu)
|
||||
nokogiri (1.18.8-x86_64-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.2-x86_64-linux-musl)
|
||||
nokogiri (1.18.8-x86_64-linux-musl)
|
||||
racc (~> 1.4)
|
||||
octokit (9.2.0)
|
||||
octokit (10.0.0)
|
||||
faraday (>= 1, < 3)
|
||||
sawyer (~> 0.9)
|
||||
pagy (9.3.3)
|
||||
parallel (1.26.3)
|
||||
parser (3.3.7.0)
|
||||
ostruct (0.6.1)
|
||||
pagy (9.3.4)
|
||||
parallel (1.27.0)
|
||||
parser (3.3.8.0)
|
||||
ast (~> 2.4.1)
|
||||
racc
|
||||
pg (1.5.9)
|
||||
plaid (36.1.0)
|
||||
plaid (39.0.0)
|
||||
faraday (>= 1.0.1, < 3.0)
|
||||
faraday-multipart (>= 1.0.1, < 2.0)
|
||||
platform_agent (1.0.1)
|
||||
@@ -327,24 +349,24 @@ GEM
|
||||
pp (0.6.2)
|
||||
prettyprint
|
||||
prettyprint (0.2.0)
|
||||
prism (1.3.0)
|
||||
prism (1.4.0)
|
||||
propshaft (1.1.0)
|
||||
actionpack (>= 7.0.0)
|
||||
activesupport (>= 7.0.0)
|
||||
rack
|
||||
railties (>= 7.0.0)
|
||||
psych (5.2.3)
|
||||
psych (5.2.6)
|
||||
date
|
||||
stringio
|
||||
public_suffix (6.0.1)
|
||||
public_suffix (6.0.2)
|
||||
puma (6.6.0)
|
||||
nio4r (~> 2.0)
|
||||
raabro (1.4.0)
|
||||
racc (1.8.1)
|
||||
rack (3.1.10)
|
||||
rack (3.1.15)
|
||||
rack-mini-profiler (3.3.1)
|
||||
rack (>= 1.2.0)
|
||||
rack-session (2.1.0)
|
||||
rack-session (2.1.1)
|
||||
base64 (>= 0.1.0)
|
||||
rack (>= 3.0.0)
|
||||
rack-test (2.2.0)
|
||||
@@ -391,55 +413,65 @@ GEM
|
||||
rb-fsevent (0.11.2)
|
||||
rb-inotify (0.11.1)
|
||||
ffi (~> 1.0)
|
||||
rbs (3.8.1)
|
||||
rbs (3.9.4)
|
||||
logger
|
||||
rdoc (6.12.0)
|
||||
rdoc (6.14.0)
|
||||
erb
|
||||
psych (>= 4.0.0)
|
||||
redcarpet (3.6.0)
|
||||
redcarpet (3.6.1)
|
||||
redis (5.4.0)
|
||||
redis-client (>= 0.22.0)
|
||||
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 (2.2.0)
|
||||
rouge (4.5.2)
|
||||
rqrcode (3.1.0)
|
||||
chunky_png (~> 1.0)
|
||||
rqrcode_core (~> 1.0)
|
||||
rqrcode_core (1.2.0)
|
||||
rubocop (1.71.0)
|
||||
rqrcode_core (~> 2.0)
|
||||
rqrcode_core (2.0.0)
|
||||
rubocop (1.75.6)
|
||||
json (~> 2.3)
|
||||
language_server-protocol (>= 3.17.0)
|
||||
language_server-protocol (~> 3.17.0.2)
|
||||
lint_roller (~> 1.1.0)
|
||||
parallel (~> 1.10)
|
||||
parser (>= 3.3.0.2)
|
||||
rainbow (>= 2.2.2, < 4.0)
|
||||
regexp_parser (>= 2.9.3, < 3.0)
|
||||
rubocop-ast (>= 1.36.2, < 2.0)
|
||||
rubocop-ast (>= 1.44.0, < 2.0)
|
||||
ruby-progressbar (~> 1.7)
|
||||
unicode-display_width (>= 2.4.0, < 4.0)
|
||||
rubocop-ast (1.38.0)
|
||||
parser (>= 3.3.1.0)
|
||||
rubocop-minitest (0.36.0)
|
||||
rubocop (>= 1.61, < 2.0)
|
||||
rubocop-ast (>= 1.31.1, < 2.0)
|
||||
rubocop-performance (1.23.1)
|
||||
rubocop (>= 1.48.1, < 2.0)
|
||||
rubocop-ast (>= 1.31.1, < 2.0)
|
||||
rubocop-rails (2.29.1)
|
||||
rubocop-ast (1.44.1)
|
||||
parser (>= 3.3.7.2)
|
||||
prism (~> 1.4)
|
||||
rubocop-performance (1.25.0)
|
||||
lint_roller (~> 1.1)
|
||||
rubocop (>= 1.75.0, < 2.0)
|
||||
rubocop-ast (>= 1.38.0, < 2.0)
|
||||
rubocop-rails (2.32.0)
|
||||
activesupport (>= 4.2.0)
|
||||
lint_roller (~> 1.1)
|
||||
rack (>= 1.1)
|
||||
rubocop (>= 1.52.0, < 2.0)
|
||||
rubocop-ast (>= 1.31.1, < 2.0)
|
||||
rubocop-rails-omakase (1.0.0)
|
||||
rubocop
|
||||
rubocop-minitest
|
||||
rubocop-performance
|
||||
rubocop-rails
|
||||
ruby-lsp (0.23.9)
|
||||
rubocop (>= 1.75.0, < 2.0)
|
||||
rubocop-ast (>= 1.44.0, < 2.0)
|
||||
rubocop-rails-omakase (1.1.0)
|
||||
rubocop (>= 1.72)
|
||||
rubocop-performance (>= 1.24)
|
||||
rubocop-rails (>= 2.30)
|
||||
ruby-lsp (0.23.20)
|
||||
language_server-protocol (~> 3.17.0)
|
||||
prism (>= 1.2, < 2.0)
|
||||
rbs (>= 3, < 4)
|
||||
sorbet-runtime (>= 0.5.10782)
|
||||
ruby-lsp-rails (0.4.0)
|
||||
ruby-lsp (>= 0.23.0, < 0.24.0)
|
||||
ruby-lsp-rails (0.4.3)
|
||||
ruby-lsp (>= 0.23.18, < 0.24.0)
|
||||
ruby-openai (8.1.0)
|
||||
event_stream_parser (>= 0.3.0, < 2.0.0)
|
||||
faraday (>= 1)
|
||||
faraday-multipart (>= 1)
|
||||
ruby-progressbar (1.13.0)
|
||||
ruby-vips (2.2.3)
|
||||
ffi (~> 1.12)
|
||||
@@ -450,63 +482,84 @@ GEM
|
||||
addressable (>= 2.3.5)
|
||||
faraday (>= 0.17.3, < 3)
|
||||
securerandom (0.4.1)
|
||||
selenium-webdriver (4.29.1)
|
||||
selenium-webdriver (4.32.0)
|
||||
base64 (~> 0.2)
|
||||
logger (~> 1.4)
|
||||
rexml (~> 3.2, >= 3.2.5)
|
||||
rubyzip (>= 1.2.2, < 3.0)
|
||||
websocket (~> 1.0)
|
||||
sentry-rails (5.22.4)
|
||||
sentry-rails (5.24.0)
|
||||
railties (>= 5.0)
|
||||
sentry-ruby (~> 5.22.4)
|
||||
sentry-ruby (5.22.4)
|
||||
sentry-ruby (~> 5.24.0)
|
||||
sentry-ruby (5.24.0)
|
||||
bigdecimal
|
||||
concurrent-ruby (~> 1.0, >= 1.0.2)
|
||||
sentry-sidekiq (5.24.0)
|
||||
sentry-ruby (~> 5.24.0)
|
||||
sidekiq (>= 3.0)
|
||||
sidekiq (8.0.3)
|
||||
connection_pool (>= 2.5.0)
|
||||
json (>= 2.9.0)
|
||||
logger (>= 1.6.2)
|
||||
rack (>= 3.1.0)
|
||||
redis-client (>= 0.23.2)
|
||||
sidekiq-cron (2.3.0)
|
||||
cronex (>= 0.13.0)
|
||||
fugit (~> 1.8, >= 1.11.1)
|
||||
globalid (>= 1.0.1)
|
||||
sidekiq (>= 6.5.0)
|
||||
simplecov (0.22.0)
|
||||
docile (~> 1.1)
|
||||
simplecov-html (~> 0.11)
|
||||
simplecov_json_formatter (~> 0.1)
|
||||
simplecov-html (0.13.1)
|
||||
simplecov_json_formatter (0.1.4)
|
||||
skylight (6.0.4)
|
||||
activesupport (>= 5.2.0)
|
||||
smart_properties (1.17.0)
|
||||
sorbet-runtime (0.5.11813)
|
||||
stackprof (0.2.27)
|
||||
sorbet-runtime (0.5.12117)
|
||||
stimulus-rails (1.3.4)
|
||||
railties (>= 6.0.0)
|
||||
stringio (3.1.3)
|
||||
stripe (13.4.1)
|
||||
tailwindcss-rails (4.0.0)
|
||||
stringio (3.1.7)
|
||||
stripe (15.1.0)
|
||||
tailwindcss-rails (4.2.3)
|
||||
railties (>= 7.0.0)
|
||||
tailwindcss-ruby (~> 4.0)
|
||||
tailwindcss-ruby (4.0.6)
|
||||
tailwindcss-ruby (4.0.6-aarch64-linux-gnu)
|
||||
tailwindcss-ruby (4.0.6-aarch64-linux-musl)
|
||||
tailwindcss-ruby (4.0.6-arm64-darwin)
|
||||
tailwindcss-ruby (4.0.6-x86_64-darwin)
|
||||
tailwindcss-ruby (4.0.6-x86_64-linux-gnu)
|
||||
tailwindcss-ruby (4.0.6-x86_64-linux-musl)
|
||||
tailwindcss-ruby (4.1.7)
|
||||
tailwindcss-ruby (4.1.7-aarch64-linux-gnu)
|
||||
tailwindcss-ruby (4.1.7-aarch64-linux-musl)
|
||||
tailwindcss-ruby (4.1.7-arm64-darwin)
|
||||
tailwindcss-ruby (4.1.7-x86_64-darwin)
|
||||
tailwindcss-ruby (4.1.7-x86_64-linux-gnu)
|
||||
tailwindcss-ruby (4.1.7-x86_64-linux-musl)
|
||||
terminal-table (4.0.0)
|
||||
unicode-display_width (>= 1.1.1, < 4)
|
||||
thor (1.3.2)
|
||||
timeout (0.4.3)
|
||||
turbo-rails (2.0.11)
|
||||
actionpack (>= 6.0.0)
|
||||
railties (>= 6.0.0)
|
||||
turbo-rails (2.0.13)
|
||||
actionpack (>= 7.1.0)
|
||||
railties (>= 7.1.0)
|
||||
tzinfo (2.0.6)
|
||||
concurrent-ruby (~> 1.0)
|
||||
unicode (0.4.4.5)
|
||||
unicode-display_width (3.1.4)
|
||||
unicode-emoji (~> 4.0, >= 4.0.4)
|
||||
unicode-emoji (4.0.4)
|
||||
uri (1.0.2)
|
||||
uri (1.0.3)
|
||||
useragent (0.16.11)
|
||||
vcr (6.3.1)
|
||||
base64
|
||||
vernier (1.7.1)
|
||||
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)
|
||||
bindex (>= 0.4.0)
|
||||
railties (>= 6.0.0)
|
||||
webmock (3.25.0)
|
||||
webmock (3.25.1)
|
||||
addressable (>= 2.8.0)
|
||||
crack (>= 0.3.2)
|
||||
hashdiff (>= 0.4.0, < 2.0.0)
|
||||
@@ -517,22 +570,23 @@ GEM
|
||||
websocket-extensions (0.1.5)
|
||||
xpath (3.2.0)
|
||||
nokogiri (~> 1.8)
|
||||
zeitwerk (2.7.1)
|
||||
yard (0.9.37)
|
||||
zeitwerk (2.7.3)
|
||||
|
||||
PLATFORMS
|
||||
aarch64-linux
|
||||
aarch64-linux-gnu
|
||||
aarch64-linux-musl
|
||||
arm-linux
|
||||
arm-linux-gnu
|
||||
arm-linux-musl
|
||||
arm64-darwin
|
||||
x86_64-darwin
|
||||
x86_64-linux
|
||||
x86_64-linux-gnu
|
||||
x86_64-linux-musl
|
||||
|
||||
DEPENDENCIES
|
||||
aasm
|
||||
activerecord-import
|
||||
after_commit_everywhere (~> 1.0)
|
||||
aws-sdk-s3 (~> 1.177.0)
|
||||
bcrypt (~> 3.1)
|
||||
benchmark-ips
|
||||
@@ -548,9 +602,9 @@ DEPENDENCIES
|
||||
faraday
|
||||
faraday-multipart
|
||||
faraday-retry
|
||||
good_job
|
||||
foreman
|
||||
hotwire-livereload
|
||||
hotwire_combobox!
|
||||
hotwire_combobox
|
||||
i18n-tasks
|
||||
image_processing (>= 1.2)
|
||||
importmap-rails
|
||||
@@ -559,9 +613,11 @@ DEPENDENCIES
|
||||
jwt
|
||||
letter_opener
|
||||
logtail-rails
|
||||
lookbook (>= 2.3.7)
|
||||
lucide-rails!
|
||||
mocha
|
||||
octokit
|
||||
ostruct
|
||||
pagy
|
||||
pg (~> 1.5)
|
||||
plaid
|
||||
@@ -571,26 +627,33 @@ DEPENDENCIES
|
||||
rails (~> 7.2.2)
|
||||
rails-settings-cached
|
||||
redcarpet
|
||||
redis (~> 5.4)
|
||||
rotp (~> 6.3)
|
||||
rqrcode (~> 2.2)
|
||||
rqrcode (~> 3.0)
|
||||
rubocop-rails-omakase
|
||||
ruby-lsp-rails
|
||||
ruby-openai
|
||||
selenium-webdriver
|
||||
sentry-rails
|
||||
sentry-ruby
|
||||
sentry-sidekiq
|
||||
sidekiq
|
||||
sidekiq-cron
|
||||
simplecov
|
||||
stackprof
|
||||
skylight
|
||||
stimulus-rails
|
||||
stripe
|
||||
tailwindcss-rails
|
||||
turbo-rails
|
||||
tzinfo-data
|
||||
vcr
|
||||
vernier
|
||||
view_component
|
||||
web-console
|
||||
webmock
|
||||
|
||||
RUBY VERSION
|
||||
ruby 3.4.1p0
|
||||
ruby 3.4.4p34
|
||||
|
||||
BUNDLED WITH
|
||||
2.6.3
|
||||
2.6.9
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
web: bundle exec ${DEBUG:+rdbg -O -n -c --} bin/rails server -b 0.0.0.0
|
||||
css: bundle exec bin/rails tailwindcss:watch
|
||||
worker: bundle exec good_job start
|
||||
css: bundle exec bin/rails tailwindcss:watch 2>/dev/null
|
||||
worker: bundle exec sidekiq
|
||||
|
||||
51
README.md
51
README.md
@@ -1,14 +1,11 @@
|
||||
<img width="1440" alt="dashboard_mockup" src="https://github.com/maybe-finance/maybe/assets/35243/a7763d0e-a942-42db-bde7-eb8d28106917">
|
||||
<sup><i>(Note: The image above is a mockup of what we're working towards. We're rapidly approaching the functionality shown, but not all of the parts are ready just yet.)</i></sup>
|
||||
|
||||
# Maybe: The OS for your personal finances
|
||||
<img width="1190" alt="maybe_hero" src="https://github.com/user-attachments/assets/13fc5ef4-ce0f-4073-a163-9dbc3eb4c8e5" />
|
||||
|
||||
# Maybe: The personal finance app for everyone
|
||||
|
||||
<b>Get
|
||||
involved: [Discord](https://link.maybe.co/discord) • [Website](https://maybefinance.com) • [Issues](https://github.com/maybe-finance/maybe/issues)</b>
|
||||
|
||||
_If you're looking for the previous React codebase, you can find it
|
||||
at [maybe-finance/maybe-archive](https://github.com/maybe-finance/maybe-archive)._
|
||||
|
||||
## Backstory
|
||||
|
||||
We spent the better part of 2021/2022 building a personal finance + wealth
|
||||
@@ -27,11 +24,10 @@ and eventually offer a hosted version of the app for a small monthly fee.
|
||||
|
||||
## Maybe Hosting
|
||||
|
||||
There are 3 primary ways to use the Maybe app:
|
||||
There are 2 primary ways to use the Maybe app:
|
||||
|
||||
1. Managed (easiest) - _coming soon..._
|
||||
2. [One-click deploy](docs/hosting/one-click-deploy.md)
|
||||
3. [Self-host with Docker](docs/hosting/docker.md)
|
||||
1. Managed (easiest) - we're in alpha and release invites in our Discord
|
||||
2. [Self-host with Docker](docs/hosting/docker.md)
|
||||
|
||||
## Contributing
|
||||
|
||||
@@ -84,37 +80,10 @@ If you'd like multi-currency support, there are a few extra steps to follow.
|
||||
|
||||
### Setup Guides
|
||||
|
||||
#### Dev Container (optional)
|
||||
|
||||
This is 100% optional and meant for devs who don't want to worry about
|
||||
installing requirements manually for their platform. You can
|
||||
follow [this guide](https://code.visualstudio.com/docs/devcontainers/containers)
|
||||
to learn more about Dev Containers.
|
||||
|
||||
If you run into `could not connect to server` errors, you may need to change
|
||||
your `.env`'s `DB_HOST` environment variable value to `db` to point to the
|
||||
Postgres container.
|
||||
|
||||
#### Mac
|
||||
|
||||
Please visit
|
||||
our [Mac dev setup guide](https://github.com/maybe-finance/maybe/wiki/Mac-Dev-Setup-Guide).
|
||||
|
||||
#### Linux
|
||||
|
||||
Please visit
|
||||
our [Linux dev setup guide](https://github.com/maybe-finance/maybe/wiki/Linux-Dev-Setup-Guide).
|
||||
|
||||
#### Windows
|
||||
|
||||
Please visit
|
||||
our [Windows dev setup guide](https://github.com/maybe-finance/maybe/wiki/Windows-Dev-Setup-Guide).
|
||||
|
||||
### Testing Emails
|
||||
|
||||
In development, we use `letter_opener` to automatically open emails in your
|
||||
browser. When an email sends locally, a new browser tab will open with a
|
||||
preview.
|
||||
- [Mac dev setup guide](https://github.com/maybe-finance/maybe/wiki/Mac-Dev-Setup-Guide)
|
||||
- [Linux dev setup guide](https://github.com/maybe-finance/maybe/wiki/Linux-Dev-Setup-Guide)
|
||||
- [Windows dev setup guide](https://github.com/maybe-finance/maybe/wiki/Windows-Dev-Setup-Guide)
|
||||
- Dev containers - visit [this guide](https://code.visualstudio.com/docs/devcontainers/containers) to learn more
|
||||
|
||||
## Repo Activity
|
||||
|
||||
|
||||
71
app/assets/images/ai-dark.svg
Normal file
71
app/assets/images/ai-dark.svg
Normal file
@@ -0,0 +1,71 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32" fill="none">
|
||||
<g filter="url(#filter0_i_2942_1229)">
|
||||
<rect width="32" height="32" rx="10" fill="url(#paint0_linear_2942_1229)"/>
|
||||
<rect width="32" height="32" rx="10" fill="white" fill-opacity="0.07" style="mix-blend-mode:plus-lighter"/>
|
||||
</g>
|
||||
<g filter="url(#filter1_ii_2942_1229)">
|
||||
<rect x="1.75" y="1.75" width="28.5" height="28.5" rx="8" fill="url(#paint1_linear_2942_1229)"/>
|
||||
</g>
|
||||
<path d="M21.524 8.8489C21.9357 8.84115 22.2757 9.16862 22.2835 9.58033C22.3104 11.0111 22.3592 12.4355 22.4277 13.8639C22.4474 14.2752 22.1299 14.6246 21.7186 14.6443C21.3073 14.664 20.9579 14.3466 20.9382 13.9353C20.8691 12.4933 20.8198 11.0544 20.7926 9.60841C20.7848 9.1967 21.1123 8.85665 21.524 8.8489Z" fill="#141414"/>
|
||||
<path d="M21.524 8.8489C21.9357 8.84115 22.2757 9.16862 22.2835 9.58033C22.3104 11.0111 22.3592 12.4355 22.4277 13.8639C22.4474 14.2752 22.1299 14.6246 21.7186 14.6443C21.3073 14.664 20.9579 14.3466 20.9382 13.9353C20.8691 12.4933 20.8198 11.0544 20.7926 9.60841C20.7848 9.1967 21.1123 8.85665 21.524 8.8489Z" fill="url(#paint2_linear_2942_1229)"/>
|
||||
<path d="M15.4203 9.51724C15.6565 9.17999 15.5747 8.71506 15.2375 8.47877C14.9002 8.24249 14.4353 8.32434 14.199 8.66158C13.5874 9.53452 12.935 10.3942 12.2667 11.2747C12.0113 11.6113 11.7536 11.951 11.4949 12.2955C11.4948 12.2256 11.4945 12.1549 11.494 12.0833C11.4899 11.4759 11.4682 10.827 11.3511 10.1757C11.2781 9.77047 10.8905 9.50105 10.4852 9.57398C10.0799 9.64691 9.8105 10.0346 9.88343 10.4398C9.97721 10.9609 9.99884 11.5056 10.0029 12.0935C10.0039 12.2387 10.0037 12.3886 10.0036 12.5417C10.0032 12.9877 10.0028 13.4604 10.0303 13.919C10.0365 14.0218 10.0632 14.1185 10.1062 14.2053C9.39275 15.2317 8.72212 16.2913 8.16155 17.3909C8.15394 17.4058 8.14271 17.427 8.12867 17.4534C8.04301 17.6148 7.85275 17.9733 7.74077 18.3186C7.67701 18.5151 7.60594 18.7988 7.63944 19.0917C7.65733 19.2481 7.70803 19.4336 7.8297 19.6082C7.95607 19.7894 8.13073 19.9179 8.32815 19.9925C9.5403 20.4508 10.8812 20.4975 12.1498 20.4009C13.1799 20.3225 14.2197 20.1434 15.1461 19.9837C15.358 19.9472 15.5639 19.9117 15.7625 19.8787C16.1687 19.8111 16.4432 19.4271 16.3756 19.0209C16.3081 18.6147 15.924 18.3402 15.5178 18.4077C15.3021 18.4436 15.0842 18.4811 14.8647 18.5189C13.9419 18.6776 12.9891 18.8415 12.0367 18.914C10.998 18.9931 10.0276 18.956 9.18383 18.7076C9.25117 18.5246 9.34702 18.3419 9.42851 18.1866C9.45032 18.145 9.47115 18.1053 9.49008 18.0682C10.2409 16.5954 11.2184 15.1729 12.268 13.7535C12.6447 13.2441 13.035 12.7296 13.4266 12.2134C14.1092 11.3136 14.7956 10.4088 15.4203 9.51724Z" fill="#141414"/>
|
||||
<path d="M15.4203 9.51724C15.6565 9.17999 15.5747 8.71506 15.2375 8.47877C14.9002 8.24249 14.4353 8.32434 14.199 8.66158C13.5874 9.53452 12.935 10.3942 12.2667 11.2747C12.0113 11.6113 11.7536 11.951 11.4949 12.2955C11.4948 12.2256 11.4945 12.1549 11.494 12.0833C11.4899 11.4759 11.4682 10.827 11.3511 10.1757C11.2781 9.77047 10.8905 9.50105 10.4852 9.57398C10.0799 9.64691 9.8105 10.0346 9.88343 10.4398C9.97721 10.9609 9.99884 11.5056 10.0029 12.0935C10.0039 12.2387 10.0037 12.3886 10.0036 12.5417C10.0032 12.9877 10.0028 13.4604 10.0303 13.919C10.0365 14.0218 10.0632 14.1185 10.1062 14.2053C9.39275 15.2317 8.72212 16.2913 8.16155 17.3909C8.15394 17.4058 8.14271 17.427 8.12867 17.4534C8.04301 17.6148 7.85275 17.9733 7.74077 18.3186C7.67701 18.5151 7.60594 18.7988 7.63944 19.0917C7.65733 19.2481 7.70803 19.4336 7.8297 19.6082C7.95607 19.7894 8.13073 19.9179 8.32815 19.9925C9.5403 20.4508 10.8812 20.4975 12.1498 20.4009C13.1799 20.3225 14.2197 20.1434 15.1461 19.9837C15.358 19.9472 15.5639 19.9117 15.7625 19.8787C16.1687 19.8111 16.4432 19.4271 16.3756 19.0209C16.3081 18.6147 15.924 18.3402 15.5178 18.4077C15.3021 18.4436 15.0842 18.4811 14.8647 18.5189C13.9419 18.6776 12.9891 18.8415 12.0367 18.914C10.998 18.9931 10.0276 18.956 9.18383 18.7076C9.25117 18.5246 9.34702 18.3419 9.42851 18.1866C9.45032 18.145 9.47115 18.1053 9.49008 18.0682C10.2409 16.5954 11.2184 15.1729 12.268 13.7535C12.6447 13.2441 13.035 12.7296 13.4266 12.2134C14.1092 11.3136 14.7956 10.4088 15.4203 9.51724Z" fill="url(#paint3_linear_2942_1229)"/>
|
||||
<path d="M21.5709 21.534C21.6983 21.1424 21.484 20.7217 21.0924 20.5944C20.7008 20.467 20.2801 20.6813 20.1528 21.0729C19.9756 21.6179 19.7424 22.0319 19.4518 22.3169C19.1738 22.5896 18.8105 22.7774 18.2938 22.8258C17.5241 22.898 16.7434 22.5737 16.4029 22.0103C16.1898 21.6579 15.7315 21.545 15.3791 21.758C15.0267 21.971 14.9137 22.4294 15.1267 22.7818C15.8398 23.9614 17.2577 24.4207 18.4329 24.3105C19.2796 24.2311 19.9656 23.9017 20.496 23.3815C21.0138 22.8737 21.3481 22.2193 21.5709 21.534Z" fill="#141414"/>
|
||||
<path d="M21.5709 21.534C21.6983 21.1424 21.484 20.7217 21.0924 20.5944C20.7008 20.467 20.2801 20.6813 20.1528 21.0729C19.9756 21.6179 19.7424 22.0319 19.4518 22.3169C19.1738 22.5896 18.8105 22.7774 18.2938 22.8258C17.5241 22.898 16.7434 22.5737 16.4029 22.0103C16.1898 21.6579 15.7315 21.545 15.3791 21.758C15.0267 21.971 14.9137 22.4294 15.1267 22.7818C15.8398 23.9614 17.2577 24.4207 18.4329 24.3105C19.2796 24.2311 19.9656 23.9017 20.496 23.3815C21.0138 22.8737 21.3481 22.2193 21.5709 21.534Z" fill="url(#paint4_linear_2942_1229)"/>
|
||||
<defs>
|
||||
<filter id="filter0_i_2942_1229" x="0" y="0" width="32" height="32" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset/>
|
||||
<feGaussianBlur stdDeviation="0.49869"/>
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.1 0"/>
|
||||
<feBlend mode="normal" in2="shape" result="effect1_innerShadow_2942_1229"/>
|
||||
</filter>
|
||||
<filter id="filter1_ii_2942_1229" x="1.75" y="0.75" width="28.5" height="30.5" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dy="-1"/>
|
||||
<feGaussianBlur stdDeviation="1"/>
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0.980392 0 0 0 0 0.309804 0 0 0 0 0.67451 0 0 0 0.2 0"/>
|
||||
<feBlend mode="normal" in2="shape" result="effect1_innerShadow_2942_1229"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dy="1"/>
|
||||
<feGaussianBlur stdDeviation="1"/>
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0.2 0 0 0 0 0.835294 0 0 0 0 1 0 0 0 0.15 0"/>
|
||||
<feBlend mode="normal" in2="effect1_innerShadow_2942_1229" result="effect2_innerShadow_2942_1229"/>
|
||||
</filter>
|
||||
<linearGradient id="paint0_linear_2942_1229" x1="16" y1="0" x2="16" y2="32" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#22CCEE"/>
|
||||
<stop offset="0.274483" stop-color="#1570EF"/>
|
||||
<stop offset="0.629793" stop-color="#6927DA"/>
|
||||
<stop offset="1" stop-color="#F23E94"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint1_linear_2942_1229" x1="16" y1="10.6562" x2="16" y2="30.25" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#171717"/>
|
||||
<stop offset="0.3" stop-color="#0B0B0B"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint2_linear_2942_1229" x1="17.8739" y1="7.84186" x2="13.7328" y2="23.2967" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#22CCEE"/>
|
||||
<stop offset="0.274483" stop-color="#1570EF"/>
|
||||
<stop offset="0.629793" stop-color="#6927DA"/>
|
||||
<stop offset="1" stop-color="#F23E94"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint3_linear_2942_1229" x1="17.8739" y1="7.84186" x2="13.7328" y2="23.2967" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#22CCEE"/>
|
||||
<stop offset="0.274483" stop-color="#1570EF"/>
|
||||
<stop offset="0.629793" stop-color="#6927DA"/>
|
||||
<stop offset="1" stop-color="#F23E94"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint4_linear_2942_1229" x1="17.8739" y1="7.84186" x2="13.7328" y2="23.2967" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#22CCEE"/>
|
||||
<stop offset="0.274483" stop-color="#1570EF"/>
|
||||
<stop offset="0.629793" stop-color="#6927DA"/>
|
||||
<stop offset="1" stop-color="#F23E94"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 8.6 KiB |
71
app/assets/images/ai.svg
Normal file
71
app/assets/images/ai.svg
Normal file
@@ -0,0 +1,71 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32" fill="none">
|
||||
<g filter="url(#filter0_i_2942_1218)">
|
||||
<rect width="32" height="32" rx="10" fill="url(#paint0_linear_2942_1218)"/>
|
||||
<rect width="32" height="32" rx="10" fill="white" fill-opacity="0.07" style="mix-blend-mode:plus-lighter"/>
|
||||
</g>
|
||||
<g filter="url(#filter1_ii_2942_1218)">
|
||||
<rect x="1.75" y="1.75" width="28.5" height="28.5" rx="8" fill="url(#paint1_linear_2942_1218)"/>
|
||||
</g>
|
||||
<path d="M21.524 8.8489C21.9357 8.84115 22.2757 9.16862 22.2835 9.58033C22.3104 11.0111 22.3592 12.4355 22.4277 13.8639C22.4474 14.2752 22.1299 14.6246 21.7186 14.6443C21.3073 14.664 20.9579 14.3466 20.9382 13.9353C20.8691 12.4933 20.8198 11.0544 20.7926 9.60841C20.7848 9.1967 21.1123 8.85665 21.524 8.8489Z" fill="#141414"/>
|
||||
<path d="M21.524 8.8489C21.9357 8.84115 22.2757 9.16862 22.2835 9.58033C22.3104 11.0111 22.3592 12.4355 22.4277 13.8639C22.4474 14.2752 22.1299 14.6246 21.7186 14.6443C21.3073 14.664 20.9579 14.3466 20.9382 13.9353C20.8691 12.4933 20.8198 11.0544 20.7926 9.60841C20.7848 9.1967 21.1123 8.85665 21.524 8.8489Z" fill="url(#paint2_linear_2942_1218)"/>
|
||||
<path d="M15.4203 9.51724C15.6565 9.17999 15.5747 8.71506 15.2375 8.47877C14.9002 8.24249 14.4353 8.32434 14.199 8.66158C13.5874 9.53452 12.935 10.3942 12.2667 11.2747C12.0113 11.6113 11.7536 11.951 11.4949 12.2955C11.4948 12.2256 11.4945 12.1549 11.494 12.0833C11.4899 11.4759 11.4682 10.827 11.3511 10.1757C11.2781 9.77047 10.8905 9.50105 10.4852 9.57398C10.0799 9.64691 9.8105 10.0346 9.88343 10.4398C9.97721 10.9609 9.99884 11.5056 10.0029 12.0935C10.0039 12.2387 10.0037 12.3886 10.0036 12.5417C10.0032 12.9877 10.0028 13.4604 10.0303 13.919C10.0365 14.0218 10.0632 14.1185 10.1062 14.2053C9.39275 15.2317 8.72212 16.2913 8.16155 17.3909C8.15394 17.4058 8.14271 17.427 8.12867 17.4534C8.04301 17.6148 7.85275 17.9733 7.74077 18.3186C7.67701 18.5151 7.60594 18.7988 7.63944 19.0917C7.65733 19.2481 7.70803 19.4336 7.8297 19.6082C7.95607 19.7894 8.13073 19.9179 8.32815 19.9925C9.5403 20.4508 10.8812 20.4975 12.1498 20.4009C13.1799 20.3225 14.2197 20.1434 15.1461 19.9837C15.358 19.9472 15.5639 19.9117 15.7625 19.8787C16.1687 19.8111 16.4432 19.4271 16.3756 19.0209C16.3081 18.6147 15.924 18.3402 15.5178 18.4077C15.3021 18.4436 15.0842 18.4811 14.8647 18.5189C13.9419 18.6776 12.9891 18.8415 12.0367 18.914C10.998 18.9931 10.0276 18.956 9.18383 18.7076C9.25117 18.5246 9.34702 18.3419 9.42851 18.1866C9.45032 18.145 9.47115 18.1053 9.49008 18.0682C10.2409 16.5954 11.2184 15.1729 12.268 13.7535C12.6447 13.2441 13.035 12.7296 13.4266 12.2134C14.1092 11.3136 14.7956 10.4088 15.4203 9.51724Z" fill="#141414"/>
|
||||
<path d="M15.4203 9.51724C15.6565 9.17999 15.5747 8.71506 15.2375 8.47877C14.9002 8.24249 14.4353 8.32434 14.199 8.66158C13.5874 9.53452 12.935 10.3942 12.2667 11.2747C12.0113 11.6113 11.7536 11.951 11.4949 12.2955C11.4948 12.2256 11.4945 12.1549 11.494 12.0833C11.4899 11.4759 11.4682 10.827 11.3511 10.1757C11.2781 9.77047 10.8905 9.50105 10.4852 9.57398C10.0799 9.64691 9.8105 10.0346 9.88343 10.4398C9.97721 10.9609 9.99884 11.5056 10.0029 12.0935C10.0039 12.2387 10.0037 12.3886 10.0036 12.5417C10.0032 12.9877 10.0028 13.4604 10.0303 13.919C10.0365 14.0218 10.0632 14.1185 10.1062 14.2053C9.39275 15.2317 8.72212 16.2913 8.16155 17.3909C8.15394 17.4058 8.14271 17.427 8.12867 17.4534C8.04301 17.6148 7.85275 17.9733 7.74077 18.3186C7.67701 18.5151 7.60594 18.7988 7.63944 19.0917C7.65733 19.2481 7.70803 19.4336 7.8297 19.6082C7.95607 19.7894 8.13073 19.9179 8.32815 19.9925C9.5403 20.4508 10.8812 20.4975 12.1498 20.4009C13.1799 20.3225 14.2197 20.1434 15.1461 19.9837C15.358 19.9472 15.5639 19.9117 15.7625 19.8787C16.1687 19.8111 16.4432 19.4271 16.3756 19.0209C16.3081 18.6147 15.924 18.3402 15.5178 18.4077C15.3021 18.4436 15.0842 18.4811 14.8647 18.5189C13.9419 18.6776 12.9891 18.8415 12.0367 18.914C10.998 18.9931 10.0276 18.956 9.18383 18.7076C9.25117 18.5246 9.34702 18.3419 9.42851 18.1866C9.45032 18.145 9.47115 18.1053 9.49008 18.0682C10.2409 16.5954 11.2184 15.1729 12.268 13.7535C12.6447 13.2441 13.035 12.7296 13.4266 12.2134C14.1092 11.3136 14.7956 10.4088 15.4203 9.51724Z" fill="url(#paint3_linear_2942_1218)"/>
|
||||
<path d="M21.5709 21.534C21.6983 21.1424 21.484 20.7217 21.0924 20.5944C20.7008 20.467 20.2801 20.6813 20.1528 21.0729C19.9756 21.6179 19.7424 22.0319 19.4518 22.3169C19.1738 22.5896 18.8105 22.7774 18.2938 22.8258C17.5241 22.898 16.7434 22.5737 16.4029 22.0103C16.1898 21.6579 15.7315 21.545 15.3791 21.758C15.0267 21.971 14.9137 22.4294 15.1267 22.7818C15.8398 23.9614 17.2577 24.4207 18.4329 24.3105C19.2796 24.2311 19.9656 23.9017 20.496 23.3815C21.0138 22.8737 21.3481 22.2193 21.5709 21.534Z" fill="#141414"/>
|
||||
<path d="M21.5709 21.534C21.6983 21.1424 21.484 20.7217 21.0924 20.5944C20.7008 20.467 20.2801 20.6813 20.1528 21.0729C19.9756 21.6179 19.7424 22.0319 19.4518 22.3169C19.1738 22.5896 18.8105 22.7774 18.2938 22.8258C17.5241 22.898 16.7434 22.5737 16.4029 22.0103C16.1898 21.6579 15.7315 21.545 15.3791 21.758C15.0267 21.971 14.9137 22.4294 15.1267 22.7818C15.8398 23.9614 17.2577 24.4207 18.4329 24.3105C19.2796 24.2311 19.9656 23.9017 20.496 23.3815C21.0138 22.8737 21.3481 22.2193 21.5709 21.534Z" fill="url(#paint4_linear_2942_1218)"/>
|
||||
<defs>
|
||||
<filter id="filter0_i_2942_1218" x="0" y="0" width="32" height="32" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset/>
|
||||
<feGaussianBlur stdDeviation="0.49869"/>
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.1 0"/>
|
||||
<feBlend mode="normal" in2="shape" result="effect1_innerShadow_2942_1218"/>
|
||||
</filter>
|
||||
<filter id="filter1_ii_2942_1218" x="1.75" y="0.861111" width="28.5" height="30.2778" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dy="-0.888889"/>
|
||||
<feGaussianBlur stdDeviation="0.888889"/>
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0.980392 0 0 0 0 0.309804 0 0 0 0 0.67451 0 0 0 0.1 0"/>
|
||||
<feBlend mode="normal" in2="shape" result="effect1_innerShadow_2942_1218"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dy="0.888889"/>
|
||||
<feGaussianBlur stdDeviation="0.888889"/>
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0.2 0 0 0 0 0.835294 0 0 0 0 1 0 0 0 0.1 0"/>
|
||||
<feBlend mode="normal" in2="effect1_innerShadow_2942_1218" result="effect2_innerShadow_2942_1218"/>
|
||||
</filter>
|
||||
<linearGradient id="paint0_linear_2942_1218" x1="16" y1="0" x2="16" y2="32" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#22CCEE"/>
|
||||
<stop offset="0.274483" stop-color="#1570EF"/>
|
||||
<stop offset="0.629793" stop-color="#6927DA"/>
|
||||
<stop offset="1" stop-color="#F23E94"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint1_linear_2942_1218" x1="16" y1="10.6562" x2="16" y2="30.25" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="white"/>
|
||||
<stop offset="0.3" stop-color="#F7F7F7"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint2_linear_2942_1218" x1="17.8739" y1="7.84186" x2="13.7328" y2="23.2967" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#22CCEE"/>
|
||||
<stop offset="0.274483" stop-color="#1570EF"/>
|
||||
<stop offset="0.629793" stop-color="#6927DA"/>
|
||||
<stop offset="1" stop-color="#F23E94"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint3_linear_2942_1218" x1="17.8739" y1="7.84186" x2="13.7328" y2="23.2967" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#22CCEE"/>
|
||||
<stop offset="0.274483" stop-color="#1570EF"/>
|
||||
<stop offset="0.629793" stop-color="#6927DA"/>
|
||||
<stop offset="1" stop-color="#F23E94"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint4_linear_2942_1218" x1="17.8739" y1="7.84186" x2="13.7328" y2="23.2967" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#22CCEE"/>
|
||||
<stop offset="0.274483" stop-color="#1570EF"/>
|
||||
<stop offset="0.629793" stop-color="#6927DA"/>
|
||||
<stop offset="1" stop-color="#F23E94"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 8.7 KiB |
37
app/assets/images/icon-assistant.svg
Normal file
37
app/assets/images/icon-assistant.svg
Normal file
@@ -0,0 +1,37 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="0.75" y="0.75" width="18.5" height="18.5" rx="5.25"
|
||||
class="gradient-fill"
|
||||
fill="url(#paint0_linear_2046_1939)" />
|
||||
<rect x="0.75" y="0.75" width="18.5" height="18.5" rx="5.25" stroke="currentColor" stroke-width="1.5" />
|
||||
<path
|
||||
d="M13.166 5.78146C13.4233 5.77662 13.6358 5.98129 13.6407 6.2386C13.6575 7.13281 13.688 8.02308 13.7308 8.91583C13.7431 9.1729 13.5447 9.39128 13.2876 9.40361C13.0306 9.41593 12.8122 9.21753 12.7999 8.96046C12.7567 8.05922 12.7259 7.1599 12.7089 6.25615C12.704 5.99883 12.9087 5.78631 13.166 5.78146Z"
|
||||
fill="currentColor" />
|
||||
<path
|
||||
d="M9.35116 6.19917C9.49883 5.98839 9.44768 5.69781 9.2369 5.55013C9.02612 5.40246 8.73554 5.45361 8.58786 5.66439C8.20561 6.20997 7.79785 6.74728 7.3802 7.29762C7.22057 7.50796 7.05946 7.72025 6.89782 7.93558C6.89774 7.8919 6.89758 7.84771 6.89727 7.80294C6.89466 7.42332 6.88115 7.01776 6.8079 6.61074C6.76232 6.35744 6.52004 6.18906 6.26674 6.23464C6.01345 6.28022 5.84506 6.5225 5.89064 6.7758C5.94925 7.10149 5.96277 7.44189 5.9653 7.80935C5.96592 7.90008 5.96583 7.9938 5.96575 8.08947C5.96549 8.36824 5.96523 8.66365 5.98243 8.95029C5.98629 9.01454 6.00299 9.07497 6.02989 9.12923C5.58396 9.77068 5.16482 10.433 4.81446 11.1202C4.8097 11.1296 4.80269 11.1428 4.79392 11.1593C4.74038 11.2602 4.62146 11.4842 4.55147 11.7C4.51162 11.8229 4.46721 12.0002 4.48814 12.1832C4.49932 12.281 4.53101 12.3969 4.60706 12.506C4.68604 12.6193 4.7952 12.6996 4.91859 12.7462C5.67618 13.0326 6.51425 13.0618 7.30714 13.0015C7.95092 12.9525 8.60078 12.8405 9.17979 12.7407C9.31222 12.7179 9.44094 12.6957 9.56504 12.6751C9.81891 12.6329 9.99049 12.3928 9.94826 12.1389C9.90603 11.8851 9.66599 11.7135 9.41211 11.7557C9.27728 11.7782 9.14113 11.8016 9.00391 11.8252C8.42721 11.9244 7.83168 12.0269 7.2364 12.0722C6.58727 12.1216 5.98075 12.0984 5.45339 11.9432C5.49547 11.8288 5.55538 11.7146 5.60631 11.6175C5.61995 11.5915 5.63296 11.5667 5.64479 11.5435C6.11404 10.623 6.72498 9.73396 7.38096 8.84686C7.61644 8.52843 7.86038 8.20687 8.10509 7.8843C8.53175 7.3219 8.96074 6.75642 9.35116 6.19917Z"
|
||||
fill="currentColor" />
|
||||
<path
|
||||
d="M13.1953 13.7096C13.2749 13.4649 13.141 13.2019 12.8962 13.1224C12.6515 13.0428 12.3886 13.1767 12.309 13.4215C12.1983 13.7621 12.0525 14.0208 11.8709 14.199C11.6971 14.3694 11.4701 14.4868 11.1471 14.517C10.6661 14.5621 10.1781 14.3594 9.96528 14.0074C9.83214 13.7871 9.54566 13.7165 9.32541 13.8496C9.10516 13.9828 9.03455 14.2693 9.16769 14.4895C9.61336 15.2268 10.4996 15.5138 11.2341 15.445C11.7632 15.3954 12.192 15.1895 12.5235 14.8644C12.8471 14.547 13.0561 14.1379 13.1953 13.7096Z"
|
||||
fill="currentColor" />
|
||||
<path
|
||||
d="M13.166 5.78146C13.4233 5.77662 13.6358 5.98129 13.6407 6.2386C13.6575 7.13281 13.688 8.02308 13.7308 8.91583C13.7431 9.1729 13.5447 9.39128 13.2876 9.40361C13.0306 9.41593 12.8122 9.21753 12.7999 8.96046C12.7567 8.05922 12.7259 7.1599 12.7089 6.25615C12.704 5.99883 12.9087 5.78631 13.166 5.78146Z"
|
||||
stroke="currentColor" stroke-width="0.3" stroke-linecap="round" />
|
||||
<path
|
||||
d="M9.35116 6.19917C9.49883 5.98839 9.44768 5.69781 9.2369 5.55013C9.02612 5.40246 8.73554 5.45361 8.58786 5.66439C8.20561 6.20997 7.79785 6.74728 7.3802 7.29762C7.22057 7.50796 7.05946 7.72025 6.89782 7.93558C6.89774 7.8919 6.89758 7.84771 6.89727 7.80294C6.89466 7.42332 6.88115 7.01776 6.8079 6.61074C6.76232 6.35744 6.52004 6.18906 6.26674 6.23464C6.01345 6.28022 5.84506 6.5225 5.89064 6.7758C5.94925 7.10149 5.96277 7.44189 5.9653 7.80935C5.96592 7.90008 5.96583 7.9938 5.96575 8.08947C5.96549 8.36824 5.96523 8.66365 5.98243 8.95029C5.98629 9.01454 6.00299 9.07497 6.02989 9.12923C5.58396 9.77068 5.16482 10.433 4.81446 11.1202C4.8097 11.1296 4.80269 11.1428 4.79392 11.1593C4.74038 11.2602 4.62146 11.4842 4.55147 11.7C4.51162 11.8229 4.46721 12.0002 4.48814 12.1832C4.49932 12.281 4.53101 12.3969 4.60706 12.506C4.68604 12.6193 4.7952 12.6996 4.91859 12.7462C5.67618 13.0326 6.51425 13.0618 7.30714 13.0015C7.95092 12.9525 8.60078 12.8405 9.17979 12.7407C9.31222 12.7179 9.44094 12.6957 9.56504 12.6751C9.81891 12.6329 9.99049 12.3928 9.94826 12.1389C9.90603 11.8851 9.66599 11.7135 9.41211 11.7557C9.27728 11.7782 9.14113 11.8016 9.00391 11.8252C8.42721 11.9244 7.83168 12.0269 7.2364 12.0722C6.58727 12.1216 5.98075 12.0984 5.45339 11.9432C5.49547 11.8288 5.55538 11.7146 5.60631 11.6175C5.61995 11.5915 5.63296 11.5667 5.64479 11.5435C6.11404 10.623 6.72498 9.73396 7.38096 8.84686C7.61644 8.52843 7.86038 8.20687 8.10509 7.8843C8.53175 7.3219 8.96074 6.75642 9.35116 6.19917Z"
|
||||
stroke="currentColor" stroke-width="0.3" stroke-linecap="round" />
|
||||
<path
|
||||
d="M13.1953 13.7096C13.2749 13.4649 13.141 13.2019 12.8962 13.1224C12.6515 13.0428 12.3886 13.1767 12.309 13.4215C12.1983 13.7621 12.0525 14.0208 11.8709 14.199C11.6971 14.3694 11.4701 14.4868 11.1471 14.517C10.6661 14.5621 10.1781 14.3594 9.96528 14.0074C9.83214 13.7871 9.54566 13.7165 9.32541 13.8496C9.10516 13.9828 9.03455 14.2693 9.16769 14.4895C9.61336 15.2268 10.4996 15.5138 11.2341 15.445C11.7632 15.3954 12.192 15.1895 12.5235 14.8644C12.8471 14.547 13.0561 14.1379 13.1953 13.7096Z"
|
||||
stroke="currentColor" stroke-width="0.3" stroke-linecap="round" />
|
||||
<style>
|
||||
[data-theme=dark] .gradient-fill {
|
||||
fill: transparent;
|
||||
}
|
||||
</style>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_2046_1939" x1="10" y1="6.25" x2="10" y2="20"
|
||||
gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="white" />
|
||||
<stop offset="0.3" stop-color="#F7F7F7" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 5.6 KiB |
5
app/assets/images/icon-csv.svg
Normal file
5
app/assets/images/icon-csv.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M2.5 7.5H17.5M2.5 12.5H17.5M7.5 7.5V17.5M12.5 7.5V17.5M4.16667 2.5H15.8333C16.7538 2.5 17.5 3.24619 17.5 4.16667V15.8333C17.5 16.7538 16.7538 17.5 15.8333 17.5H4.16667C3.24619 17.5 2.5 16.7538 2.5 15.8333V4.16667C2.5 3.24619 3.24619 2.5 4.16667 2.5Z"
|
||||
stroke="#737373" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 468 B |
@@ -1 +0,0 @@
|
||||
/* Application styles */
|
||||
@@ -8,6 +8,34 @@
|
||||
@plugin "@tailwindcss/typography";
|
||||
@plugin "@tailwindcss/forms";
|
||||
|
||||
@import "./simonweb_pickr.css";
|
||||
|
||||
@layer components {
|
||||
.pcr-app{
|
||||
position: static !important;
|
||||
background: none !important;
|
||||
box-shadow: none !important;
|
||||
padding: 0 !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
.pcr-color-palette{
|
||||
height: 12em !important;
|
||||
}
|
||||
.pcr-palette{
|
||||
border-radius: 10px !important;
|
||||
}
|
||||
.pcr-palette:before{
|
||||
border-radius: 10px !important;
|
||||
}
|
||||
.pcr-color-chooser{
|
||||
height: 1.5em !important;
|
||||
}
|
||||
.pcr-picker{
|
||||
height: 20px !important;
|
||||
width: 20px !important;
|
||||
}
|
||||
}
|
||||
|
||||
.combobox {
|
||||
.hw-combobox__main__wrapper,
|
||||
.hw-combobox__input {
|
||||
@@ -42,18 +70,22 @@
|
||||
|
||||
/* Typography */
|
||||
.prose {
|
||||
@apply max-w-none;
|
||||
@apply max-w-none text-primary;
|
||||
|
||||
a {
|
||||
@apply text-link;
|
||||
}
|
||||
|
||||
h2 {
|
||||
@apply text-xl font-medium;
|
||||
@apply text-xl font-medium text-primary;
|
||||
}
|
||||
|
||||
h3 {
|
||||
@apply text-lg font-medium;
|
||||
@apply text-lg font-medium text-primary;
|
||||
}
|
||||
|
||||
li {
|
||||
@apply m-0;
|
||||
@apply m-0 text-primary;
|
||||
}
|
||||
|
||||
details {
|
||||
@@ -83,6 +115,30 @@
|
||||
}
|
||||
}
|
||||
|
||||
.prose--ai-chat {
|
||||
@apply break-words;
|
||||
|
||||
p, li {
|
||||
@apply text-sm text-primary;
|
||||
}
|
||||
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(156, 163, 175, 0.5) transparent;
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(156, 163, 175, 0.5);
|
||||
border-radius: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom scrollbar implementation for Windows browsers */
|
||||
.windows {
|
||||
::-webkit-scrollbar {
|
||||
|
||||
@@ -5,6 +5,14 @@
|
||||
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 {
|
||||
/* Font families */
|
||||
--font-sans: 'Geist', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
@@ -16,6 +24,12 @@
|
||||
--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%);
|
||||
|
||||
/* Colors used in Stimulus controllers with SVGs (easier to define light/dark mode here than toggle within the controllers) */
|
||||
/* See @layer base block below for dark mode overrides */
|
||||
--budget-unused-fill: var(--color-gray-200);
|
||||
--budget-unallocated-fill: var(--color-gray-50);
|
||||
|
||||
/* Gray scale */
|
||||
--color-gray-25: #FAFAFA;
|
||||
@@ -215,201 +229,149 @@
|
||||
--shadow-md: 0px 4px 8px -2px --alpha(var(--color-black) / 6%);
|
||||
--shadow-lg: 0px 12px 16px -4px --alpha(var(--color-black) / 6%);
|
||||
--shadow-xl: 0px 20px 24px -4px --alpha(var(--color-black) / 6%);
|
||||
|
||||
--animate-stroke-fill: stroke-fill 3s 300ms forwards;
|
||||
|
||||
@keyframes stroke-fill {
|
||||
0% {
|
||||
stroke-dashoffset: 43.9822971503;
|
||||
}
|
||||
|
||||
100% {
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
|
||||
@utility text-secondary {
|
||||
@apply text-gray-500;
|
||||
}
|
||||
|
||||
@utility text-subdued {
|
||||
@apply text-gray-400;
|
||||
}
|
||||
|
||||
@utility text-link {
|
||||
@apply text-blue-600;
|
||||
}
|
||||
|
||||
@utility bg-surface {
|
||||
@apply bg-gray-50;
|
||||
}
|
||||
|
||||
@utility bg-surface-hover {
|
||||
@apply bg-gray-100;
|
||||
}
|
||||
|
||||
@utility bg-surface-inset {
|
||||
@apply bg-gray-100;
|
||||
}
|
||||
|
||||
@utility bg-surface-inset-hover {
|
||||
@apply bg-gray-200;
|
||||
}
|
||||
|
||||
@utility bg-container {
|
||||
@apply bg-white;
|
||||
}
|
||||
|
||||
@utility bg-container-hover {
|
||||
@apply bg-gray-50;
|
||||
}
|
||||
|
||||
@utility bg-container-inset {
|
||||
@apply bg-gray-50;
|
||||
}
|
||||
|
||||
@utility bg-container-inset-hover {
|
||||
@apply bg-gray-100;
|
||||
}
|
||||
|
||||
@utility bg-inverse {
|
||||
@apply bg-gray-800;
|
||||
}
|
||||
|
||||
@utility bg-inverse-hover {
|
||||
@apply bg-gray-700;
|
||||
}
|
||||
|
||||
@utility bg-overlay {
|
||||
@apply bg-alpha-black-200;
|
||||
}
|
||||
|
||||
@utility border-primary {
|
||||
@apply border-alpha-black-300;
|
||||
}
|
||||
|
||||
@utility border-secondary {
|
||||
@apply border-alpha-black-200;
|
||||
}
|
||||
|
||||
@utility border-tertiary {
|
||||
@apply border-alpha-black-100;
|
||||
}
|
||||
|
||||
@utility border-subdued {
|
||||
@apply border-alpha-black-50;
|
||||
/* 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 {
|
||||
form>button {
|
||||
@apply cursor-pointer;
|
||||
[data-theme="dark"] {
|
||||
--color-success: var(--color-green-500);
|
||||
--color-warning: var(--color-yellow-400);
|
||||
--color-destructive: var(--color-red-400);
|
||||
--color-shadow: --alpha(var(--color-white) / 8%);
|
||||
|
||||
/* Dark mode overrides for colors used in Stimulus controllers with SVGs */
|
||||
--budget-unused-fill: var(--color-gray-500);
|
||||
--budget-unallocated-fill: var(--color-gray-700);
|
||||
|
||||
--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%);
|
||||
}
|
||||
|
||||
html {
|
||||
padding-top: env(safe-area-inset-top);
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
|
||||
button {
|
||||
@apply cursor-pointer focus-visible:outline-gray-900;
|
||||
}
|
||||
|
||||
hr {
|
||||
@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;
|
||||
}
|
||||
|
||||
|
||||
details>summary {
|
||||
@apply list-none;
|
||||
}
|
||||
|
||||
select[multiple="multiple"] {
|
||||
@apply py-2 pr-2 space-y-0.5 overflow-y-auto;
|
||||
}
|
||||
|
||||
select[multiple="multiple"] option {
|
||||
@apply py-2 rounded-md;
|
||||
}
|
||||
|
||||
select[multiple="multiple"] option:checked {
|
||||
@apply after:content-['\2713'] bg-white after:text-gray-500 after:ml-2;
|
||||
}
|
||||
|
||||
select[multiple="multiple"] option:active,
|
||||
select[multiple="multiple"] option:focus {
|
||||
@apply bg-white;
|
||||
|
||||
input[type='radio'] {
|
||||
@apply border-gray-300 text-indigo-600 focus:ring-indigo-600;
|
||||
/* Default light mode */
|
||||
|
||||
@variant theme-dark {
|
||||
/* Dark mode radio button base and checked styles */
|
||||
@apply border-gray-600 bg-gray-700 checked:bg-blue-500 focus:ring-blue-500 focus:ring-offset-gray-800;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
/* Buttons */
|
||||
.btn {
|
||||
@apply px-3 py-2 rounded-lg text-sm font-medium cursor-pointer disabled:cursor-not-allowed focus:outline-gray-500;
|
||||
}
|
||||
|
||||
.btn--primary {
|
||||
@apply bg-gray-900 text-white hover:bg-gray-700 disabled:bg-gray-50 disabled:hover:bg-gray-50 disabled:text-gray-400;
|
||||
}
|
||||
|
||||
.btn--secondary {
|
||||
@apply bg-gray-50 hover:bg-gray-100 text-gray-900;
|
||||
}
|
||||
|
||||
.btn--outline {
|
||||
@apply border border-alpha-black-200 text-gray-900 hover:bg-gray-50 disabled:bg-gray-50 disabled:hover:bg-gray-50 disabled:text-gray-400;
|
||||
}
|
||||
|
||||
.btn--ghost {
|
||||
@apply border border-transparent text-gray-900 hover:bg-gray-100;
|
||||
}
|
||||
|
||||
.btn--destructive {
|
||||
@apply bg-red-500 text-white hover:bg-red-600 disabled:bg-red-50 disabled:hover:bg-red-50 disabled:text-red-400;
|
||||
}
|
||||
|
||||
/* Forms */
|
||||
.form-field {
|
||||
@apply flex flex-col gap-1 relative px-3 py-2 rounded-md border bg-white border-alpha-black-100 shadow-xs w-full;
|
||||
@apply focus-within:border-gray-900 focus-within:shadow-none focus-within:ring-4 focus-within:ring-gray-100;
|
||||
@apply flex flex-col gap-1 relative px-3 py-2 rounded-md border bg-container border-secondary shadow-xs w-full;
|
||||
@apply focus-within:border-secondary focus-within:shadow-none focus-within:ring-4 focus-within:ring-alpha-black-200;
|
||||
@apply transition-all duration-300;
|
||||
|
||||
@variant theme-dark {
|
||||
@apply focus-within:ring-alpha-white-300;
|
||||
}
|
||||
|
||||
/* Add styles for multiple select within form fields */
|
||||
select[multiple] {
|
||||
@apply py-2 pr-2 space-y-0.5 overflow-y-auto;
|
||||
|
||||
option {
|
||||
@apply py-2 rounded-md;
|
||||
}
|
||||
|
||||
option:checked {
|
||||
@apply after:content-['\2713'] bg-container-inset after:text-gray-500 after:ml-2;
|
||||
}
|
||||
|
||||
option:active,
|
||||
option:focus {
|
||||
@apply bg-container-inset;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.form-field__label {
|
||||
@apply block text-xs text-gray-500 peer-disabled:text-gray-400;
|
||||
@apply block text-xs text-secondary peer-disabled:text-subdued;
|
||||
}
|
||||
|
||||
.form-field__input {
|
||||
@apply border-none bg-transparent text-sm opacity-100 w-full p-0;
|
||||
@apply text-primary border-none bg-container text-sm opacity-100 w-full p-0;
|
||||
@apply focus:opacity-100 focus:outline-hidden focus:ring-0;
|
||||
@apply placeholder-shown:opacity-50;
|
||||
@apply disabled:text-gray-400;
|
||||
@apply disabled:text-subdued;
|
||||
@apply text-ellipsis overflow-hidden whitespace-nowrap;
|
||||
|
||||
@apply transition-opacity duration-300;
|
||||
@apply placeholder:text-subdued;
|
||||
|
||||
&select {
|
||||
@apply pr-8;
|
||||
}
|
||||
|
||||
@variant theme-dark {
|
||||
&::-webkit-calendar-picker-indicator {
|
||||
filter: invert(1);
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.form-field__radio {
|
||||
@apply text-gray-900;
|
||||
@apply text-primary;
|
||||
}
|
||||
|
||||
|
||||
.form-field__submit {
|
||||
@apply cursor-pointer rounded-lg bg-black p-3 text-center text-white hover:bg-gray-700;
|
||||
@apply cursor-pointer rounded-lg bg-surface p-3 text-center text-white hover:bg-surface-hover;
|
||||
}
|
||||
|
||||
/* Checkboxes */
|
||||
.checkbox {
|
||||
&[type='checkbox'] {
|
||||
@apply rounded-sm;
|
||||
@apply transition-colors duration-300;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -417,35 +379,53 @@
|
||||
&[type='checkbox'] {
|
||||
@apply border-alpha-black-200 checked:bg-gray-900 checked:ring-gray-900 focus:ring-gray-900 focus-visible:ring-gray-900 checked:hover:bg-gray-500;
|
||||
}
|
||||
|
||||
|
||||
&[type='checkbox']:disabled {
|
||||
@apply cursor-not-allowed opacity-80 bg-gray-50 border-gray-200 checked:bg-gray-400 checked:ring-gray-400;
|
||||
}
|
||||
}
|
||||
|
||||
@variant theme-dark {
|
||||
&[type='checkbox'] {
|
||||
@apply ring-gray-900 checked:text-white;
|
||||
background-color: var(--color-gray-600);
|
||||
}
|
||||
|
||||
&[type='checkbox']:disabled {
|
||||
@apply cursor-not-allowed opacity-80 ring-gray-600;
|
||||
}
|
||||
|
||||
&[type='checkbox']:checked {
|
||||
background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z'/%3e%3c/svg%3e");
|
||||
background-color: var(--color-gray-600);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.checkbox--dark {
|
||||
&[type='checkbox'] {
|
||||
@apply ring-gray-900 checked:text-white;
|
||||
}
|
||||
|
||||
|
||||
&[type='checkbox']:disabled {
|
||||
@apply cursor-not-allowed opacity-80 ring-gray-600;
|
||||
}
|
||||
|
||||
|
||||
&[type='checkbox']:checked {
|
||||
background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='111827' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z'/%3e%3c/svg%3e");
|
||||
}
|
||||
}
|
||||
|
||||
/* 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 after:transition-transform after:duration-300 after:ease-in-out;
|
||||
@apply peer-checked:bg-green-600 peer-checked:after:translate-x-4;
|
||||
}
|
||||
|
||||
/* Tooltips */
|
||||
.tooltip {
|
||||
@apply hidden absolute;
|
||||
}
|
||||
}
|
||||
|
||||
.qrcode svg path {
|
||||
fill: var(--color-black);
|
||||
@variant theme-dark {
|
||||
fill: var(--color-white);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
91
app/assets/tailwind/maybe-design-system/background-utils.css
Normal file
91
app/assets/tailwind/maybe-design-system/background-utils.css
Normal file
@@ -0,0 +1,91 @@
|
||||
@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);
|
||||
}
|
||||
}
|
||||
|
||||
@utility bg-loader {
|
||||
@apply bg-surface-inset animate-pulse;
|
||||
}
|
||||
92
app/assets/tailwind/maybe-design-system/border-utils.css
Normal file
92
app/assets/tailwind/maybe-design-system/border-utils.css
Normal file
@@ -0,0 +1,92 @@
|
||||
/* 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-divider {
|
||||
@apply border-tertiary;
|
||||
}
|
||||
|
||||
@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;
|
||||
}
|
||||
}
|
||||
2
app/assets/tailwind/simonweb_pickr.css
Normal file
2
app/assets/tailwind/simonweb_pickr.css
Normal file
File diff suppressed because one or more lines are too long
@@ -1,4 +1,10 @@
|
||||
module ApplicationCable
|
||||
class Connection < ActionCable::Connection::Base
|
||||
rescue_from StandardError, with: :report_error
|
||||
|
||||
private
|
||||
def report_error(e)
|
||||
Sentry.capture_exception(e)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
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) %>
|
||||
<%= helpers.icon(icon, size: size, color: icon_color) %>
|
||||
<% end %>
|
||||
|
||||
<% unless icon_only? %>
|
||||
<%= text %>
|
||||
<% end %>
|
||||
|
||||
<% if icon && icon_position == :right %>
|
||||
<%= helpers.icon(icon, size: size, color: icon_color) %>
|
||||
<% 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
|
||||
156
app/components/buttonish_component.rb
Normal file
156
app/components/buttonish_component.rb
Normal file
@@ -0,0 +1,156 @@
|
||||
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"
|
||||
},
|
||||
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"
|
||||
},
|
||||
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"
|
||||
}
|
||||
}.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_color
|
||||
# Map variant to icon color for the icon helper
|
||||
case variant
|
||||
when :primary, :icon_inverse
|
||||
:white
|
||||
when :destructive, :outline_destructive
|
||||
:destructive
|
||||
else
|
||||
:default
|
||||
end
|
||||
end
|
||||
|
||||
def icon_classes
|
||||
class_names(
|
||||
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 @@
|
||||
<%= wrapper_element do %>
|
||||
<%= 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 flex flex-col">
|
||||
<% if header? %>
|
||||
<%= header %>
|
||||
<% end %>
|
||||
|
||||
<% if body? %>
|
||||
<div class="px-4 grow">
|
||||
<%= 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 %>
|
||||
<% end %>
|
||||
115
app/components/dialog_component.rb
Normal file
115
app/components/dialog_component.rb
Normal file
@@ -0,0 +1,115 @@
|
||||
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, :width, :disable_frame, :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, width: "md", frame: nil, disable_frame: false, **opts)
|
||||
@variant = variant.to_sym
|
||||
@auto_open = auto_open
|
||||
@reload_on_close = reload_on_close
|
||||
@width = width.to_sym
|
||||
@frame = frame
|
||||
@disable_frame = disable_frame
|
||||
@opts = opts
|
||||
end
|
||||
|
||||
def frame
|
||||
@frame || variant
|
||||
end
|
||||
|
||||
# Caller must "opt-out" of using the default turbo-frame based on the variant
|
||||
def wrapper_element(&block)
|
||||
if disable_frame
|
||||
content_tag(:div, &block)
|
||||
else
|
||||
content_tag("turbo-frame", id: frame, &block)
|
||||
end
|
||||
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 rounded-xl shadow-border-xs mx-3 lg:mx-0 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
|
||||
33
app/components/dialog_controller.js
Normal file
33
app/components/dialog_controller.js
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Controller } from "@hotwired/stimulus";
|
||||
|
||||
// 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;
|
||||
if (this.autoOpenValue) {
|
||||
this.element.showModal();
|
||||
}
|
||||
}
|
||||
|
||||
// If the user clicks anywhere outside of the visible content, close the dialog
|
||||
clickOutside(e) {
|
||||
if (!this.contentTarget.contains(e.target)) {
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
|
||||
close() {
|
||||
this.element.close();
|
||||
|
||||
if (this.reloadOnCloseValue) {
|
||||
Turbo.visit(window.location.href);
|
||||
}
|
||||
}
|
||||
}
|
||||
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 %>
|
||||
<%= helpers.icon "chevron-right", class: "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 %>
|
||||
<%= helpers.icon "chevron-down", class: "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 %>
|
||||
99
app/components/filled_icon_component.rb
Normal file
99
app/components/filled_icon_component.rb
Normal file
@@ -0,0 +1,99 @@
|
||||
class FilledIconComponent < ViewComponent::Base
|
||||
attr_reader :icon, :text, :hex_color, :size, :rounded, :variant
|
||||
|
||||
VARIANTS = %i[default text surface container inverse].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 shrink-0",
|
||||
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"
|
||||
when :inverse
|
||||
"bg-container"
|
||||
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
|
||||
15
app/components/link_component.html.erb
Normal file
15
app/components/link_component.html.erb
Normal file
@@ -0,0 +1,15 @@
|
||||
<%= link_to href, **merged_opts do %>
|
||||
<% if icon && (icon_position != "right") %>
|
||||
<%= helpers.icon(icon, size: size, color: icon_color) %>
|
||||
|
||||
<% end %>
|
||||
|
||||
<% unless icon_only? %>
|
||||
<%= text %>
|
||||
<% end %>
|
||||
|
||||
<% if icon && icon_position == "right" %>
|
||||
<%= helpers.icon(icon, size: size, color: icon_color) %>
|
||||
|
||||
<% 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, initials: initials %>
|
||||
</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 %>
|
||||
38
app/components/menu_component.rb
Normal file
38
app/components/menu_component.rb
Normal file
@@ -0,0 +1,38 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class MenuComponent < ViewComponent::Base
|
||||
attr_reader :variant, :avatar_url, :initials, :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, initials: nil, placement: "bottom-end", offset: 12, icon_vertical: false, no_padding: false, testid: nil)
|
||||
@variant = variant.to_sym
|
||||
@avatar_url = avatar_url
|
||||
@initials = initials
|
||||
@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 %>
|
||||
<%= render "shared/ruler", classes: "my-1" %>
|
||||
<% else %>
|
||||
<div class="px-1">
|
||||
<%= wrapper do %>
|
||||
<% if icon %>
|
||||
<%= helpers.icon(icon, color: destructive? ? :destructive : :default) %>
|
||||
<% end %>
|
||||
<%= tag.span(text, class: text_classes) %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
62
app/components/menu_item_component.rb
Normal file
62
app/components/menu_item_component.rb
Normal file
@@ -0,0 +1,62 @@
|
||||
class MenuItemComponent < ViewComponent::Base
|
||||
VARIANTS = %i[link button divider].freeze
|
||||
|
||||
attr_reader :variant, :text, :icon, :href, :method, :destructive, :confirm, :frame, :opts
|
||||
|
||||
def initialize(variant:, text: nil, icon: nil, href: nil, method: :post, destructive: false, confirm: nil, frame: nil, **opts)
|
||||
@variant = variant.to_sym
|
||||
@text = text
|
||||
@icon = icon
|
||||
@href = href
|
||||
@method = method.to_sym
|
||||
@destructive = destructive
|
||||
@confirm = confirm
|
||||
@frame = frame
|
||||
@opts = opts
|
||||
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_opts, &block
|
||||
elsif variant == :link
|
||||
link_to href, class: container_classes, **merged_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_opts
|
||||
merged_opts = opts.dup || {}
|
||||
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(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
|
||||
18
app/components/tabs_component.html.erb
Normal file
18
app/components/tabs_component.html.erb
Normal file
@@ -0,0 +1,18 @@
|
||||
<%= tag.div data: {
|
||||
controller: "tabs",
|
||||
testid: testid,
|
||||
tabs_session_key_value: session_key,
|
||||
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 %>
|
||||
66
app/components/tabs_component.rb
Normal file
66
app/components/tabs_component.rb
Normal file
@@ -0,0 +1,66 @@
|
||||
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, :session_key, :variant, :testid
|
||||
|
||||
def initialize(active_tab:, url_param_key: nil, session_key: nil, variant: :default, active_btn_classes: "", inactive_btn_classes: "", testid: nil)
|
||||
@active_tab = active_tab
|
||||
@url_param_key = url_param_key
|
||||
@session_key = session_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
|
||||
57
app/components/tabs_controller.js
Normal file
57
app/components/tabs_controller.js
Normal file
@@ -0,0 +1,57 @@
|
||||
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 = { sessionKey: String, urlParamKey: String };
|
||||
|
||||
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");
|
||||
}
|
||||
});
|
||||
|
||||
if (this.urlParamKeyValue) {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set(this.urlParamKeyValue, selectedTabId);
|
||||
window.history.replaceState({}, "", url);
|
||||
}
|
||||
|
||||
// Update URL with the selected tab
|
||||
if (this.sessionKeyValue) {
|
||||
this.#updateSessionPreference(selectedTabId);
|
||||
}
|
||||
}
|
||||
|
||||
#updateSessionPreference(selectedTabId) {
|
||||
fetch("/current_session", {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"X-CSRF-Token": document.querySelector('[name="csrf-token"]').content,
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
"current_session[tab_key]": this.sessionKeyValue,
|
||||
"current_session[tab_value]": selectedTabId,
|
||||
}).toString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
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,37 +0,0 @@
|
||||
class Account::TradesController < ApplicationController
|
||||
include EntryableResource
|
||||
|
||||
permitted_entryable_attributes :id, :qty, :price
|
||||
|
||||
private
|
||||
def build_entry
|
||||
Account::TradeBuilder.new(create_entry_params)
|
||||
end
|
||||
|
||||
def create_entry_params
|
||||
params.require(:account_entry).permit(
|
||||
:account_id, :date, :amount, :currency, :qty, :price, :ticker, :type, :transfer_account_id
|
||||
).tap do |params|
|
||||
account_id = params.delete(:account_id)
|
||||
params[:account] = Current.family.accounts.find(account_id)
|
||||
end
|
||||
end
|
||||
|
||||
def update_entry_params
|
||||
return entry_params unless entry_params[:entryable_attributes].present?
|
||||
|
||||
update_params = entry_params
|
||||
update_params = update_params.merge(entryable_type: "Account::Trade")
|
||||
|
||||
qty = update_params[:entryable_attributes][:qty]
|
||||
price = update_params[:entryable_attributes][:price]
|
||||
|
||||
if qty.present? && price.present?
|
||||
qty = update_params[:nature] == "inflow" ? -qty.to_d : qty.to_d
|
||||
update_params[:entryable_attributes][:qty] = qty
|
||||
update_params[:amount] = qty * price.to_d
|
||||
end
|
||||
|
||||
update_params.except(:nature)
|
||||
end
|
||||
end
|
||||
@@ -1,22 +0,0 @@
|
||||
class Account::TransactionCategoriesController < ApplicationController
|
||||
def update
|
||||
@entry = Current.family.entries.account_transactions.find(params[:transaction_id])
|
||||
@entry.update!(entry_params)
|
||||
|
||||
respond_to do |format|
|
||||
format.html { redirect_back_or_to account_transaction_path(@entry) }
|
||||
format.turbo_stream do
|
||||
render turbo_stream: turbo_stream.replace(
|
||||
"category_menu_account_transaction_#{@entry.account_transaction_id}",
|
||||
partial: "categories/menu",
|
||||
locals: { transaction: @entry.account_transaction }
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def entry_params
|
||||
params.require(:account_entry).permit(:entryable_type, entryable_attributes: [ :id, :category_id ])
|
||||
end
|
||||
end
|
||||
@@ -1,37 +0,0 @@
|
||||
class Account::TransactionsController < ApplicationController
|
||||
include EntryableResource
|
||||
|
||||
permitted_entryable_attributes :id, :category_id, :merchant_id, { tag_ids: [] }
|
||||
|
||||
def bulk_delete
|
||||
destroyed = Current.family.entries.destroy_by(id: bulk_delete_params[:entry_ids])
|
||||
destroyed.map(&:account).uniq.each(&:sync_later)
|
||||
redirect_back_or_to transactions_url, notice: t(".success", count: destroyed.count)
|
||||
end
|
||||
|
||||
def bulk_edit
|
||||
end
|
||||
|
||||
def bulk_update
|
||||
updated = Current.family
|
||||
.entries
|
||||
.where(id: bulk_update_params[:entry_ids])
|
||||
.bulk_update!(bulk_update_params)
|
||||
|
||||
redirect_back_or_to transactions_url, notice: t(".success", count: updated)
|
||||
end
|
||||
|
||||
private
|
||||
def bulk_delete_params
|
||||
params.require(:bulk_delete).permit(entry_ids: [])
|
||||
end
|
||||
|
||||
def bulk_update_params
|
||||
params.require(:bulk_update).permit(:date, :notes, :category_id, :merchant_id, entry_ids: [])
|
||||
end
|
||||
|
||||
def search_params
|
||||
params.fetch(:q, {})
|
||||
.permit(:start_date, :end_date, :search, :amount, :amount_operator, accounts: [], account_ids: [], categories: [], merchants: [], types: [], tags: [])
|
||||
end
|
||||
end
|
||||
@@ -1,3 +0,0 @@
|
||||
class Account::ValuationsController < ApplicationController
|
||||
include EntryableResource
|
||||
end
|
||||
@@ -3,12 +3,17 @@ class AccountableSparklinesController < ApplicationController
|
||||
@accountable = Accountable.from_type(params[:accountable_type]&.classify)
|
||||
|
||||
@series = Rails.cache.fetch(cache_key) do
|
||||
family.accounts.active
|
||||
.where(accountable_type: @accountable.name)
|
||||
.balance_series(
|
||||
currency: family.currency,
|
||||
favorable_direction: @accountable.favorable_direction
|
||||
)
|
||||
account_ids = family.accounts.active.where(accountable_type: @accountable.name).pluck(:id)
|
||||
|
||||
builder = Balance::ChartSeriesBuilder.new(
|
||||
account_ids: account_ids,
|
||||
currency: family.currency,
|
||||
period: Period.last_30_days,
|
||||
favorable_direction: @accountable.favorable_direction,
|
||||
interval: "1 day"
|
||||
)
|
||||
|
||||
builder.balance_series
|
||||
end
|
||||
|
||||
render layout: false
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
class AccountsController < ApplicationController
|
||||
before_action :set_account, only: %i[sync chart sparkline]
|
||||
include Periodable
|
||||
|
||||
def index
|
||||
@manual_accounts = family.accounts.manual.alphabetically
|
||||
@@ -17,6 +18,7 @@ class AccountsController < ApplicationController
|
||||
end
|
||||
|
||||
def chart
|
||||
@chart_view = params[:chart_view] || "balance"
|
||||
render layout: "application"
|
||||
end
|
||||
|
||||
@@ -24,14 +26,6 @@ class AccountsController < ApplicationController
|
||||
render layout: false
|
||||
end
|
||||
|
||||
def sync_all
|
||||
unless family.syncing?
|
||||
family.sync_later
|
||||
end
|
||||
|
||||
redirect_to accounts_path
|
||||
end
|
||||
|
||||
private
|
||||
def family
|
||||
Current.family
|
||||
|
||||
@@ -1,27 +1,15 @@
|
||||
class ApplicationController < ActionController::Base
|
||||
include Onboardable, Localize, AutoSync, Authentication, Invitable, SelfHostable, StoreLocation, Impersonatable
|
||||
include RestoreLayoutPreferences, Onboardable, Localize, AutoSync, Authentication, Invitable,
|
||||
SelfHostable, StoreLocation, Impersonatable, Breadcrumbable,
|
||||
FeatureGuardable, Notifiable
|
||||
|
||||
include Pagy::Backend
|
||||
|
||||
helper_method :require_upgrade?, :subscription_pending?
|
||||
|
||||
before_action :detect_os
|
||||
before_action :set_default_chat
|
||||
before_action :set_active_storage_url_options
|
||||
|
||||
private
|
||||
def require_upgrade?
|
||||
return false if self_hosted?
|
||||
return false unless Current.session
|
||||
return false if Current.family.subscribed?
|
||||
return false if subscription_pending? || request.path == settings_billing_path
|
||||
return false if Current.family.active_accounts_count <= 3
|
||||
|
||||
true
|
||||
end
|
||||
|
||||
def subscription_pending?
|
||||
subscribed_at = Current.session.subscribed_at
|
||||
subscribed_at.present? && subscribed_at <= Time.current && subscribed_at > 1.hour.ago
|
||||
end
|
||||
|
||||
def detect_os
|
||||
user_agent = request.user_agent
|
||||
@os = case user_agent
|
||||
@@ -33,4 +21,18 @@ class ApplicationController < ActionController::Base
|
||||
else ""
|
||||
end
|
||||
end
|
||||
|
||||
# By default, we show the user the last chat they interacted with
|
||||
def set_default_chat
|
||||
@last_viewed_chat = Current.user&.last_viewed_chat
|
||||
@chat = @last_viewed_chat
|
||||
end
|
||||
|
||||
def set_active_storage_url_options
|
||||
ActiveStorage::Current.url_options = {
|
||||
protocol: request.protocol,
|
||||
host: request.host,
|
||||
port: request.optional_port
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
@@ -11,14 +11,14 @@ class BudgetCategoriesController < ApplicationController
|
||||
|
||||
if params[:id] == BudgetCategory.uncategorized.id
|
||||
@budget_category = @budget.uncategorized_budget_category
|
||||
@recent_transactions = @recent_transactions.where(account_transactions: { category_id: nil })
|
||||
@recent_transactions = @recent_transactions.where(transactions: { category_id: nil })
|
||||
else
|
||||
@budget_category = Current.family.budget_categories.find(params[:id])
|
||||
@recent_transactions = @recent_transactions.joins("LEFT JOIN categories ON categories.id = account_transactions.category_id")
|
||||
@recent_transactions = @recent_transactions.joins("LEFT JOIN categories ON categories.id = transactions.category_id")
|
||||
.where("categories.id = ? OR categories.parent_id = ?", @budget_category.category.id, @budget_category.category.id)
|
||||
end
|
||||
|
||||
@recent_transactions = @recent_transactions.order("account_entries.date DESC, ABS(account_entries.amount) DESC").take(3)
|
||||
@recent_transactions = @recent_transactions.order("entries.date DESC, ABS(entries.amount) DESC").take(3)
|
||||
end
|
||||
|
||||
def update
|
||||
|
||||
@@ -25,6 +25,7 @@ class BudgetsController < ApplicationController
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def budget_create_params
|
||||
params.require(:budget).permit(:start_date)
|
||||
end
|
||||
|
||||
@@ -56,8 +56,13 @@ class CategoriesController < ApplicationController
|
||||
redirect_back_or_to categories_path, notice: t(".success")
|
||||
end
|
||||
|
||||
def destroy_all
|
||||
Current.family.categories.destroy_all
|
||||
redirect_back_or_to categories_path, notice: "All categories deleted"
|
||||
end
|
||||
|
||||
def bootstrap
|
||||
Current.family.categories.bootstrap_defaults
|
||||
Current.family.categories.bootstrap!
|
||||
|
||||
redirect_back_or_to categories_path, notice: t(".success")
|
||||
end
|
||||
|
||||
65
app/controllers/chats_controller.rb
Normal file
65
app/controllers/chats_controller.rb
Normal file
@@ -0,0 +1,65 @@
|
||||
class ChatsController < ApplicationController
|
||||
include ActionView::RecordIdentifier
|
||||
|
||||
before_action :set_chat, only: [ :show, :edit, :update, :destroy ]
|
||||
|
||||
def index
|
||||
@chat = nil # override application_controller default behavior of setting @chat to last viewed chat
|
||||
@chats = Current.user.chats.order(created_at: :desc)
|
||||
end
|
||||
|
||||
def show
|
||||
set_last_viewed_chat(@chat)
|
||||
end
|
||||
|
||||
def new
|
||||
@chat = Current.user.chats.new(title: "New chat #{Time.current.strftime("%Y-%m-%d %H:%M")}")
|
||||
end
|
||||
|
||||
def create
|
||||
@chat = Current.user.chats.start!(chat_params[:content], model: chat_params[:ai_model])
|
||||
set_last_viewed_chat(@chat)
|
||||
redirect_to chat_path(@chat, thinking: true)
|
||||
end
|
||||
|
||||
def edit
|
||||
end
|
||||
|
||||
def update
|
||||
@chat.update!(chat_params)
|
||||
|
||||
respond_to do |format|
|
||||
format.html { redirect_back_or_to chat_path(@chat), notice: "Chat updated" }
|
||||
format.turbo_stream { render turbo_stream: turbo_stream.replace(dom_id(@chat, :title), partial: "chats/chat_title", locals: { chat: @chat }) }
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
@chat.destroy
|
||||
clear_last_viewed_chat
|
||||
|
||||
redirect_to chats_path, notice: "Chat was successfully deleted"
|
||||
end
|
||||
|
||||
def retry
|
||||
@chat.retry_last_message!
|
||||
redirect_to chat_path(@chat, thinking: true)
|
||||
end
|
||||
|
||||
private
|
||||
def set_chat
|
||||
@chat = Current.user.chats.find(params[:id])
|
||||
end
|
||||
|
||||
def set_last_viewed_chat(chat)
|
||||
Current.user.update!(last_viewed_chat: chat)
|
||||
end
|
||||
|
||||
def clear_last_viewed_chat
|
||||
Current.user.update!(last_viewed_chat: nil)
|
||||
end
|
||||
|
||||
def chat_params
|
||||
params.require(:chat).permit(:title, :content, :ai_model)
|
||||
end
|
||||
end
|
||||
@@ -2,10 +2,10 @@ module AccountableResource
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
include ScrollFocusable
|
||||
include ScrollFocusable, Periodable
|
||||
|
||||
before_action :set_account, only: [ :show, :edit, :update, :destroy ]
|
||||
before_action :set_link_token, only: :new
|
||||
before_action :set_link_options, only: :new
|
||||
end
|
||||
|
||||
class_methods do
|
||||
@@ -23,6 +23,7 @@ module AccountableResource
|
||||
end
|
||||
|
||||
def show
|
||||
@chart_view = params[:chart_view] || "balance"
|
||||
@q = params.fetch(:q, {}).permit(:search)
|
||||
entries = @account.entries.search(@q).reverse_chronological
|
||||
|
||||
@@ -36,48 +37,31 @@ module AccountableResource
|
||||
|
||||
def create
|
||||
@account = Current.family.accounts.create_and_sync(account_params.except(:return_to))
|
||||
@account.lock_saved_attributes!
|
||||
|
||||
redirect_to account_params[:return_to].presence || @account, notice: t("accounts.create.success", type: accountable_type.name.underscore.humanize)
|
||||
end
|
||||
|
||||
def update
|
||||
@account.update_with_sync!(account_params.except(:return_to))
|
||||
@account.lock_saved_attributes!
|
||||
|
||||
redirect_back_or_to @account, notice: t("accounts.update.success", type: accountable_type.name.underscore.humanize)
|
||||
end
|
||||
|
||||
def destroy
|
||||
@account.destroy_later
|
||||
redirect_to accounts_path, notice: t("accounts.destroy.success", type: accountable_type.name.underscore.humanize)
|
||||
if @account.linked?
|
||||
redirect_to account_path(@account), alert: "Cannot delete a linked account"
|
||||
else
|
||||
@account.destroy_later
|
||||
redirect_to accounts_path, notice: t("accounts.destroy.success", type: accountable_type.name.underscore.humanize)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def set_link_token
|
||||
@us_link_token = Current.family.get_link_token(
|
||||
webhooks_url: plaid_us_webhooks_url,
|
||||
redirect_url: accounts_url,
|
||||
accountable_type: accountable_type.name,
|
||||
region: :us
|
||||
)
|
||||
|
||||
if Current.family.eu?
|
||||
@eu_link_token = Current.family.get_link_token(
|
||||
webhooks_url: plaid_eu_webhooks_url,
|
||||
redirect_url: accounts_url,
|
||||
accountable_type: accountable_type.name,
|
||||
region: :eu
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def plaid_us_webhooks_url
|
||||
return webhooks_plaid_url if Rails.env.production?
|
||||
|
||||
ENV.fetch("DEV_WEBHOOKS_URL", root_url.chomp("/")) + "/webhooks/plaid"
|
||||
end
|
||||
|
||||
def plaid_eu_webhooks_url
|
||||
return webhooks_plaid_eu_url if Rails.env.production?
|
||||
|
||||
ENV.fetch("DEV_WEBHOOKS_URL", root_url.chomp("/")) + "/webhooks/plaid_eu"
|
||||
def set_link_options
|
||||
@show_us_link = Current.family.can_connect_plaid_us?
|
||||
@show_eu_link = Current.family.can_connect_plaid_eu?
|
||||
end
|
||||
|
||||
def accountable_type
|
||||
|
||||
@@ -4,11 +4,13 @@ module Authentication
|
||||
included do
|
||||
before_action :set_request_details
|
||||
before_action :authenticate_user!
|
||||
before_action :set_sentry_user
|
||||
end
|
||||
|
||||
class_methods do
|
||||
def skip_authentication(**options)
|
||||
skip_before_action :authenticate_user!, **options
|
||||
skip_before_action :set_sentry_user, **options
|
||||
end
|
||||
end
|
||||
|
||||
@@ -26,7 +28,13 @@ module Authentication
|
||||
end
|
||||
|
||||
def find_session_by_cookie
|
||||
Session.find_by(id: cookies.signed[:session_token])
|
||||
cookie_value = cookies.signed[:session_token]
|
||||
|
||||
if cookie_value.present?
|
||||
Session.find_by(id: cookie_value)
|
||||
else
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
def create_session_for(user)
|
||||
@@ -43,4 +51,17 @@ module Authentication
|
||||
Current.user_agent = request.user_agent
|
||||
Current.ip_address = request.ip
|
||||
end
|
||||
|
||||
def set_sentry_user
|
||||
return unless defined?(Sentry) && ENV["SENTRY_DSN"].present?
|
||||
|
||||
if Current.user
|
||||
Sentry.set_user(
|
||||
id: Current.user.id,
|
||||
email: Current.user.email,
|
||||
username: Current.user.display_name,
|
||||
ip_address: Current.ip_address
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -7,13 +7,16 @@ module AutoSync
|
||||
|
||||
private
|
||||
def sync_family
|
||||
Current.family.update!(last_synced_at: Time.current)
|
||||
Current.family.sync_later
|
||||
end
|
||||
|
||||
def family_needs_auto_sync?
|
||||
return false unless Current.family.present?
|
||||
return false unless Current.family&.accounts&.active&.any?
|
||||
return false if (Current.family.last_sync_created_at&.to_date || 1.day.ago) >= Date.current
|
||||
return false unless Current.family.auto_sync_on_login
|
||||
|
||||
(Current.family.last_synced_at&.to_date || 1.day.ago) < Date.current
|
||||
Rails.logger.info "Auto-syncing family #{Current.family.id}, last sync was #{Current.family.last_sync_created_at}"
|
||||
|
||||
true
|
||||
end
|
||||
end
|
||||
|
||||
13
app/controllers/concerns/breadcrumbable.rb
Normal file
13
app/controllers/concerns/breadcrumbable.rb
Normal file
@@ -0,0 +1,13 @@
|
||||
module Breadcrumbable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
before_action :set_breadcrumbs
|
||||
end
|
||||
|
||||
private
|
||||
# The default, unless specific controller or action explicitly overrides
|
||||
def set_breadcrumbs
|
||||
@breadcrumbs = [ [ "Home", root_path ], [ controller_name.titleize, nil ] ]
|
||||
end
|
||||
end
|
||||
@@ -2,14 +2,9 @@ module EntryableResource
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
before_action :set_entry, only: %i[show update destroy]
|
||||
end
|
||||
include StreamExtensions, ActionView::RecordIdentifier
|
||||
|
||||
class_methods do
|
||||
def permitted_entryable_attributes(*attrs)
|
||||
@permitted_entryable_attributes = attrs if attrs.any?
|
||||
@permitted_entryable_attributes ||= [ :id ]
|
||||
end
|
||||
before_action :set_entry, only: %i[show update destroy]
|
||||
end
|
||||
|
||||
def show
|
||||
@@ -21,49 +16,16 @@ module EntryableResource
|
||||
@entry = Current.family.entries.new(
|
||||
account: account,
|
||||
currency: account ? account.currency : Current.family.currency,
|
||||
entryable: entryable_type.new
|
||||
entryable: entryable
|
||||
)
|
||||
end
|
||||
|
||||
def create
|
||||
@entry = build_entry
|
||||
|
||||
if @entry.save
|
||||
@entry.sync_account_later
|
||||
|
||||
flash[:notice] = t("account.entries.create.success")
|
||||
|
||||
respond_to do |format|
|
||||
format.html { redirect_back_or_to account_path(@entry.account) }
|
||||
|
||||
redirect_target_url = request.referer || account_path(@entry.account)
|
||||
format.turbo_stream { render turbo_stream: turbo_stream.action(:redirect, redirect_target_url) }
|
||||
end
|
||||
else
|
||||
render :new, status: :unprocessable_entity
|
||||
end
|
||||
raise NotImplementedError, "Entryable resources must implement #create"
|
||||
end
|
||||
|
||||
def update
|
||||
if @entry.update(update_entry_params)
|
||||
@entry.sync_account_later
|
||||
|
||||
respond_to do |format|
|
||||
format.html { redirect_back_or_to account_path(@entry.account), notice: t("account.entries.update.success") }
|
||||
format.turbo_stream do
|
||||
render turbo_stream: [
|
||||
turbo_stream.replace(
|
||||
"header_account_entry_#{@entry.id}",
|
||||
partial: "#{entryable_type.name.underscore.pluralize}/header",
|
||||
locals: { entry: @entry }
|
||||
),
|
||||
turbo_stream.replace("account_entry_#{@entry.id}", partial: "account/entries/entry", locals: { entry: @entry })
|
||||
]
|
||||
end
|
||||
end
|
||||
else
|
||||
render :show, status: :unprocessable_entity
|
||||
end
|
||||
raise NotImplementedError, "Entryable resources must implement #update"
|
||||
end
|
||||
|
||||
def destroy
|
||||
@@ -71,58 +33,15 @@ module EntryableResource
|
||||
@entry.destroy!
|
||||
@entry.sync_account_later
|
||||
|
||||
flash[:notice] = t("account.entries.destroy.success")
|
||||
|
||||
respond_to do |format|
|
||||
format.html { redirect_back_or_to account_path(account) }
|
||||
|
||||
redirect_target_url = request.referer || account_path(@entry.account)
|
||||
format.turbo_stream { render turbo_stream: turbo_stream.action(:redirect, redirect_target_url) }
|
||||
end
|
||||
redirect_back_or_to account_path(account), notice: t("account.entries.destroy.success")
|
||||
end
|
||||
|
||||
private
|
||||
def entryable_type
|
||||
permitted_entryable_types = %w[Account::Transaction Account::Valuation Account::Trade]
|
||||
klass = params[:entryable_type] || "Account::#{controller_name.classify}"
|
||||
klass.constantize if permitted_entryable_types.include?(klass)
|
||||
def entryable
|
||||
controller_name.classify.constantize.new
|
||||
end
|
||||
|
||||
def set_entry
|
||||
@entry = Current.family.entries.find(params[:id])
|
||||
end
|
||||
|
||||
def build_entry
|
||||
Current.family.entries.new(create_entry_params)
|
||||
end
|
||||
|
||||
def update_entry_params
|
||||
prepared_entry_params
|
||||
end
|
||||
|
||||
def create_entry_params
|
||||
prepared_entry_params.merge({
|
||||
entryable_type: entryable_type.name,
|
||||
entryable_attributes: entry_params[:entryable_attributes] || {}
|
||||
})
|
||||
end
|
||||
|
||||
def prepared_entry_params
|
||||
default_params = entry_params.except(:nature)
|
||||
default_params = default_params.merge(entryable_type: entryable_type.name) if entry_params[:entryable_attributes].present?
|
||||
|
||||
if entry_params[:nature].present? && entry_params[:amount].present?
|
||||
signed_amount = entry_params[:nature] == "inflow" ? -entry_params[:amount].to_d : entry_params[:amount].to_d
|
||||
default_params = default_params.merge(amount: signed_amount)
|
||||
end
|
||||
|
||||
default_params
|
||||
end
|
||||
|
||||
def entry_params
|
||||
params.require(:account_entry).permit(
|
||||
:account_id, :name, :enriched_name, :date, :amount, :currency, :excluded, :notes, :nature,
|
||||
entryable_attributes: self.class.permitted_entryable_attributes
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
23
app/controllers/concerns/feature_guardable.rb
Normal file
23
app/controllers/concerns/feature_guardable.rb
Normal file
@@ -0,0 +1,23 @@
|
||||
# Simple feature guard that renders a 403 Forbidden status with a message
|
||||
# when the feature is disabled.
|
||||
#
|
||||
# Example:
|
||||
#
|
||||
# class MessagesController < ApplicationController
|
||||
# guard_feature unless: -> { Current.user.ai_enabled? }
|
||||
# end
|
||||
#
|
||||
module FeatureGuardable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
class_methods do
|
||||
def guard_feature(**options)
|
||||
before_action :guard_feature, **options
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def guard_feature
|
||||
render plain: "Feature disabled: #{controller_name}##{action_name}", status: :forbidden
|
||||
end
|
||||
end
|
||||
56
app/controllers/concerns/notifiable.rb
Normal file
56
app/controllers/concerns/notifiable.rb
Normal file
@@ -0,0 +1,56 @@
|
||||
module Notifiable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
helper_method :render_flash_notifications
|
||||
helper_method :flash_notification_stream_items
|
||||
end
|
||||
|
||||
private
|
||||
def render_flash_notifications
|
||||
notifications = flash.flat_map { |type, data| resolve_notifications(type, data) }.compact
|
||||
|
||||
view_context.safe_join(
|
||||
notifications.map { |notification| view_context.render(**notification) }
|
||||
)
|
||||
end
|
||||
|
||||
def flash_notification_stream_items
|
||||
items = flash.flat_map do |type, data|
|
||||
notifications = resolve_notifications(type, data)
|
||||
|
||||
if type == "cta"
|
||||
notifications.map { |notification| turbo_stream.replace("cta", **notification) }
|
||||
else
|
||||
notifications.map { |notification| turbo_stream.append("notification-tray", **notification) }
|
||||
end
|
||||
end.compact
|
||||
|
||||
# If rendering flash notifications via stream, we mark them as used to avoid
|
||||
# them being rendered again on the next page load
|
||||
flash.clear
|
||||
|
||||
items
|
||||
end
|
||||
|
||||
def resolve_cta(cta)
|
||||
case cta[:type]
|
||||
when "category_rule"
|
||||
{ partial: "rules/category_rule_cta", locals: { cta: } }
|
||||
end
|
||||
end
|
||||
|
||||
def resolve_notifications(type, data)
|
||||
case type
|
||||
when "alert"
|
||||
[ { partial: "shared/notifications/alert", locals: { message: data } } ]
|
||||
when "cta"
|
||||
[ resolve_cta(data) ]
|
||||
when "notice"
|
||||
messages = Array(data)
|
||||
messages.map { |message| { partial: "shared/notifications/notice", locals: { message: message } } }
|
||||
else
|
||||
[]
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -2,16 +2,35 @@ module Onboardable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
before_action :redirect_to_onboarding, if: :needs_onboarding?
|
||||
before_action :require_onboarding_and_upgrade
|
||||
end
|
||||
|
||||
private
|
||||
def redirect_to_onboarding
|
||||
redirect_to onboarding_path
|
||||
# First, we require onboarding, then once that's complete, we require an upgrade for non-subscribed users.
|
||||
def require_onboarding_and_upgrade
|
||||
return unless Current.user
|
||||
return unless redirectable_path?(request.path)
|
||||
|
||||
if Current.user.needs_onboarding?
|
||||
redirect_to onboarding_path
|
||||
elsif Current.family.needs_subscription?
|
||||
redirect_to trial_onboarding_path
|
||||
elsif Current.family.upgrade_required?
|
||||
redirect_to upgrade_subscription_path
|
||||
end
|
||||
end
|
||||
|
||||
def needs_onboarding?
|
||||
Current.user && Current.user.onboarded_at.blank? &&
|
||||
!%w[/users /onboarding /sessions].any? { |path| request.path.start_with?(path) }
|
||||
def redirectable_path?(path)
|
||||
return false if path.starts_with?("/settings")
|
||||
return false if path.starts_with?("/subscription")
|
||||
return false if path.starts_with?("/onboarding")
|
||||
return false if path.starts_with?("/users")
|
||||
|
||||
[
|
||||
new_registration_path,
|
||||
new_session_path,
|
||||
new_password_reset_path,
|
||||
new_email_confirmation_path
|
||||
].exclude?(path)
|
||||
end
|
||||
end
|
||||
|
||||
14
app/controllers/concerns/periodable.rb
Normal file
14
app/controllers/concerns/periodable.rb
Normal file
@@ -0,0 +1,14 @@
|
||||
module Periodable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
before_action :set_period
|
||||
end
|
||||
|
||||
private
|
||||
def set_period
|
||||
@period = Period.from_key(params[:period] || Current.user&.default_period)
|
||||
rescue Period::InvalidKeyError
|
||||
@period = Period.last_30_days
|
||||
end
|
||||
end
|
||||
24
app/controllers/concerns/restore_layout_preferences.rb
Normal file
24
app/controllers/concerns/restore_layout_preferences.rb
Normal file
@@ -0,0 +1,24 @@
|
||||
module RestoreLayoutPreferences
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
before_action :restore_active_tabs
|
||||
end
|
||||
|
||||
private
|
||||
def restore_active_tabs
|
||||
last_selected_tab = Current.session&.get_preferred_tab("account_sidebar_tab") || "asset"
|
||||
|
||||
@account_group_tab = account_group_tab_param || last_selected_tab
|
||||
end
|
||||
|
||||
def valid_account_group_tabs
|
||||
%w[asset liability all]
|
||||
end
|
||||
|
||||
def account_group_tab_param
|
||||
param_value = params[:account_sidebar_tab]
|
||||
return nil unless param_value.in?(valid_account_group_tabs)
|
||||
param_value
|
||||
end
|
||||
end
|
||||
20
app/controllers/concerns/stream_extensions.rb
Normal file
20
app/controllers/concerns/stream_extensions.rb
Normal file
@@ -0,0 +1,20 @@
|
||||
module StreamExtensions
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def stream_redirect_to(path, notice: nil, alert: nil)
|
||||
custom_stream_redirect(path, notice: notice, alert: alert)
|
||||
end
|
||||
|
||||
def stream_redirect_back_or_to(path, notice: nil, alert: nil)
|
||||
custom_stream_redirect(path, redirect_back: true, notice: notice, alert: alert)
|
||||
end
|
||||
|
||||
private
|
||||
def custom_stream_redirect(path, redirect_back: false, notice: nil, alert: nil)
|
||||
flash[:notice] = notice if notice.present?
|
||||
flash[:alert] = alert if alert.present?
|
||||
|
||||
redirect_target_url = redirect_back ? request.referer : path
|
||||
render turbo_stream: turbo_stream.action(:redirect, redirect_target_url)
|
||||
end
|
||||
end
|
||||
22
app/controllers/cookie_sessions_controller.rb
Normal file
22
app/controllers/cookie_sessions_controller.rb
Normal file
@@ -0,0 +1,22 @@
|
||||
class CookieSessionsController < ApplicationController
|
||||
def update
|
||||
save_kv_to_session(
|
||||
cookie_session_params[:tab_key],
|
||||
cookie_session_params[:tab_value]
|
||||
)
|
||||
|
||||
redirect_back_or_to root_path
|
||||
end
|
||||
|
||||
private
|
||||
def cookie_session_params
|
||||
params.require(:cookie_session).permit(:tab_key, :tab_value)
|
||||
end
|
||||
|
||||
def save_kv_to_session(key, value)
|
||||
raise "Key must be a string" unless key.is_a?(String)
|
||||
raise "Value must be a string" unless value.is_a?(String)
|
||||
|
||||
session["custom_#{key}"] = value
|
||||
end
|
||||
end
|
||||
14
app/controllers/current_sessions_controller.rb
Normal file
14
app/controllers/current_sessions_controller.rb
Normal file
@@ -0,0 +1,14 @@
|
||||
class CurrentSessionsController < ApplicationController
|
||||
def update
|
||||
if session_params[:tab_key].present? && session_params[:tab_value].present?
|
||||
Current.session.set_preferred_tab(session_params[:tab_key], session_params[:tab_value])
|
||||
end
|
||||
|
||||
head :ok
|
||||
end
|
||||
|
||||
private
|
||||
def session_params
|
||||
params.require(:current_session).permit(:tab_key, :tab_value)
|
||||
end
|
||||
end
|
||||
54
app/controllers/family_merchants_controller.rb
Normal file
54
app/controllers/family_merchants_controller.rb
Normal file
@@ -0,0 +1,54 @@
|
||||
class FamilyMerchantsController < ApplicationController
|
||||
before_action :set_merchant, only: %i[edit update destroy]
|
||||
|
||||
def index
|
||||
@breadcrumbs = [ [ "Home", root_path ], [ "Merchants", nil ] ]
|
||||
|
||||
@merchants = Current.family.merchants.alphabetically
|
||||
|
||||
render layout: "settings"
|
||||
end
|
||||
|
||||
def new
|
||||
@merchant = FamilyMerchant.new(family: Current.family)
|
||||
end
|
||||
|
||||
def create
|
||||
@merchant = FamilyMerchant.new(merchant_params.merge(family: Current.family))
|
||||
|
||||
if @merchant.save
|
||||
respond_to do |format|
|
||||
format.html { redirect_to family_merchants_path, notice: t(".success") }
|
||||
format.turbo_stream { render turbo_stream: turbo_stream.action(:redirect, family_merchants_path) }
|
||||
end
|
||||
else
|
||||
render :new, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def edit
|
||||
end
|
||||
|
||||
def update
|
||||
@merchant.update!(merchant_params)
|
||||
respond_to do |format|
|
||||
format.html { redirect_to family_merchants_path, notice: t(".success") }
|
||||
format.turbo_stream { render turbo_stream: turbo_stream.action(:redirect, family_merchants_path) }
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
@merchant.destroy!
|
||||
redirect_to family_merchants_path, notice: t(".success")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_merchant
|
||||
@merchant = Current.family.merchants.find(params[:id])
|
||||
end
|
||||
|
||||
def merchant_params
|
||||
params.require(:family_merchant).permit(:name, :color)
|
||||
end
|
||||
end
|
||||
@@ -1,4 +1,4 @@
|
||||
class Account::HoldingsController < ApplicationController
|
||||
class HoldingsController < ApplicationController
|
||||
before_action :set_holding, only: %i[show destroy]
|
||||
|
||||
def index
|
||||
@@ -9,9 +9,12 @@ class Account::HoldingsController < ApplicationController
|
||||
end
|
||||
|
||||
def destroy
|
||||
@holding.destroy_holding_and_entries!
|
||||
|
||||
flash[:notice] = t(".success")
|
||||
if @holding.account.plaid_account_id.present?
|
||||
flash[:alert] = "You cannot delete this holding"
|
||||
else
|
||||
@holding.destroy_holding_and_entries!
|
||||
flash[:notice] = t(".success")
|
||||
end
|
||||
|
||||
respond_to do |format|
|
||||
format.html { redirect_back_or_to account_path(@holding.account) }
|
||||
@@ -35,7 +35,10 @@ class Import::ConfigurationsController < ApplicationController
|
||||
:notes_col_label,
|
||||
:currency_col_label,
|
||||
:date_format,
|
||||
:signage_convention
|
||||
:number_format,
|
||||
:signage_convention,
|
||||
:amount_type_strategy,
|
||||
:amount_type_inflow_value,
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -4,6 +4,10 @@ class Import::ConfirmsController < ApplicationController
|
||||
before_action :set_import
|
||||
|
||||
def show
|
||||
if @import.mapping_steps.empty?
|
||||
return redirect_to import_path(@import)
|
||||
end
|
||||
|
||||
redirect_to import_clean_path(@import), alert: "You have invalid data, please edit until all errors are resolved" unless @import.cleaned?
|
||||
end
|
||||
|
||||
|
||||
@@ -2,9 +2,7 @@ class Import::RowsController < ApplicationController
|
||||
before_action :set_import_row
|
||||
|
||||
def update
|
||||
@row.assign_attributes(row_params)
|
||||
@row.save!(validate: false)
|
||||
@row.sync_mappings
|
||||
@row.update_and_sync(row_params)
|
||||
|
||||
redirect_to import_row_path(@row.import, @row)
|
||||
end
|
||||
|
||||
@@ -6,12 +6,20 @@ class Import::UploadsController < ApplicationController
|
||||
def show
|
||||
end
|
||||
|
||||
def sample_csv
|
||||
send_data @import.csv_template.to_csv,
|
||||
filename: "#{@import.type.underscore.split('_').first}_sample.csv",
|
||||
type: "text/csv",
|
||||
disposition: "attachment"
|
||||
end
|
||||
|
||||
def update
|
||||
if csv_valid?(csv_str)
|
||||
@import.account = Current.family.accounts.find_by(id: params.dig(:import, :account_id))
|
||||
@import.assign_attributes(raw_file_str: csv_str, col_sep: upload_params[:col_sep])
|
||||
@import.save!(validate: false)
|
||||
|
||||
redirect_to import_configuration_path(@import), notice: "CSV uploaded successfully."
|
||||
redirect_to import_configuration_path(@import, template_hint: true), notice: "CSV uploaded successfully."
|
||||
else
|
||||
flash.now[:alert] = "Must be valid CSV with headers and at least one row of data"
|
||||
|
||||
@@ -29,10 +37,8 @@ class Import::UploadsController < ApplicationController
|
||||
end
|
||||
|
||||
def csv_valid?(str)
|
||||
require "csv"
|
||||
|
||||
begin
|
||||
csv = CSV.parse(str || "", headers: true, col_sep: upload_params[:col_sep])
|
||||
csv = Import.parse_csv_str(str, col_sep: upload_params[:col_sep])
|
||||
return false if csv.headers.empty?
|
||||
return false if csv.count == 0
|
||||
true
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
class ImportsController < ApplicationController
|
||||
before_action :set_import, only: %i[show publish destroy revert]
|
||||
before_action :set_import, only: %i[show publish destroy revert apply_template]
|
||||
|
||||
def publish
|
||||
@import.publish_later
|
||||
|
||||
redirect_to import_path(@import), notice: "Your import has started in the background."
|
||||
rescue Import::MaxRowCountExceededError
|
||||
redirect_back_or_to import_path(@import), alert: "Your import exceeds the maximum row count of #{@import.max_row_count}."
|
||||
end
|
||||
|
||||
def index
|
||||
@@ -18,7 +20,12 @@ class ImportsController < ApplicationController
|
||||
end
|
||||
|
||||
def create
|
||||
import = Current.family.imports.create! import_params
|
||||
account = Current.family.accounts.find_by(id: params.dig(:import, :account_id))
|
||||
import = Current.family.imports.create!(
|
||||
type: import_params[:type],
|
||||
account: account,
|
||||
date_format: Current.family.date_format,
|
||||
)
|
||||
|
||||
redirect_to import_upload_path(import)
|
||||
end
|
||||
@@ -36,6 +43,15 @@ class ImportsController < ApplicationController
|
||||
redirect_to imports_path, notice: "Import is reverting in the background."
|
||||
end
|
||||
|
||||
def apply_template
|
||||
if @import.suggested_template
|
||||
@import.apply_template!(@import.suggested_template)
|
||||
redirect_to import_configuration_path(@import), notice: "Template applied."
|
||||
else
|
||||
redirect_to import_configuration_path(@import), alert: "No template found, please manually configure your import."
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
@import.destroy
|
||||
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
class Issue::ExchangeRateProviderMissingsController < ApplicationController
|
||||
before_action :set_issue, only: :update
|
||||
|
||||
def update
|
||||
Setting.synth_api_key = exchange_rate_params[:synth_api_key]
|
||||
account = @issue.issuable
|
||||
account.sync_later
|
||||
redirect_back_or_to account
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_issue
|
||||
@issue = Current.family.issues.find(params[:id])
|
||||
end
|
||||
|
||||
def exchange_rate_params
|
||||
params.require(:issue_exchange_rate_provider_missing).permit(:synth_api_key)
|
||||
end
|
||||
end
|
||||
@@ -1,13 +0,0 @@
|
||||
class IssuesController < ApplicationController
|
||||
before_action :set_issue, only: :show
|
||||
|
||||
def show
|
||||
render template: "#{@issue.class.name.underscore.pluralize}/show", layout: "issues"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_issue
|
||||
@issue = Current.family.issues.find(params[:id])
|
||||
end
|
||||
end
|
||||
@@ -2,6 +2,6 @@ class LoansController < ApplicationController
|
||||
include AccountableResource
|
||||
|
||||
permitted_accountable_attributes(
|
||||
:id, :rate_type, :interest_rate, :term_months
|
||||
:id, :rate_type, :interest_rate, :term_months, :initial_balance
|
||||
)
|
||||
end
|
||||
|
||||
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,46 +0,0 @@
|
||||
class MerchantsController < ApplicationController
|
||||
before_action :set_merchant, only: %i[edit update destroy]
|
||||
|
||||
def index
|
||||
@merchants = Current.family.merchants.alphabetically
|
||||
|
||||
render layout: "settings"
|
||||
end
|
||||
|
||||
def new
|
||||
@merchant = Merchant.new
|
||||
end
|
||||
|
||||
def create
|
||||
@merchant = Current.family.merchants.new(merchant_params)
|
||||
|
||||
if @merchant.save
|
||||
redirect_to merchants_path, notice: t(".success")
|
||||
else
|
||||
redirect_to merchants_path, alert: t(".error", error: @merchant.errors.full_messages.to_sentence)
|
||||
end
|
||||
end
|
||||
|
||||
def edit
|
||||
end
|
||||
|
||||
def update
|
||||
@merchant.update!(merchant_params)
|
||||
redirect_to merchants_path, notice: t(".success")
|
||||
end
|
||||
|
||||
def destroy
|
||||
@merchant.destroy!
|
||||
redirect_to merchants_path, notice: t(".success")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_merchant
|
||||
@merchant = Current.family.merchants.find(params[:id])
|
||||
end
|
||||
|
||||
def merchant_params
|
||||
params.require(:merchant).permit(:name, :color)
|
||||
end
|
||||
end
|
||||
24
app/controllers/messages_controller.rb
Normal file
24
app/controllers/messages_controller.rb
Normal file
@@ -0,0 +1,24 @@
|
||||
class MessagesController < ApplicationController
|
||||
guard_feature unless: -> { Current.user.ai_enabled? }
|
||||
|
||||
before_action :set_chat
|
||||
|
||||
def create
|
||||
@message = UserMessage.create!(
|
||||
chat: @chat,
|
||||
content: message_params[:content],
|
||||
ai_model: message_params[:ai_model]
|
||||
)
|
||||
|
||||
redirect_to chat_path(@chat, thinking: true)
|
||||
end
|
||||
|
||||
private
|
||||
def set_chat
|
||||
@chat = Current.user.chats.find(params[:chat_id])
|
||||
end
|
||||
|
||||
def message_params
|
||||
params.require(:message).permit(:content, :ai_model)
|
||||
end
|
||||
end
|
||||
@@ -20,7 +20,10 @@ class MfaController < ApplicationController
|
||||
|
||||
def verify
|
||||
@user = User.find_by(id: session[:mfa_user_id])
|
||||
redirect_to new_session_path unless @user
|
||||
|
||||
if @user.nil?
|
||||
redirect_to new_session_path
|
||||
end
|
||||
end
|
||||
|
||||
def verify_code
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
class OnboardingsController < ApplicationController
|
||||
layout "wizard"
|
||||
|
||||
before_action :set_user
|
||||
before_action :load_invitation
|
||||
|
||||
def show
|
||||
end
|
||||
|
||||
def profile
|
||||
end
|
||||
|
||||
def preferences
|
||||
end
|
||||
|
||||
private
|
||||
def trial
|
||||
end
|
||||
|
||||
private
|
||||
def set_user
|
||||
@user = Current.user
|
||||
end
|
||||
|
||||
@@ -1,14 +1,33 @@
|
||||
class PagesController < ApplicationController
|
||||
skip_before_action :authenticate_user!, only: %i[early_access]
|
||||
include Periodable
|
||||
|
||||
def dashboard
|
||||
@period = Period.from_key(params[:period], fallback: true)
|
||||
@balance_sheet = Current.family.balance_sheet
|
||||
@accounts = Current.family.accounts.active.with_attached_logo
|
||||
|
||||
period_param = params[:cashflow_period]
|
||||
@cashflow_period = if period_param.present?
|
||||
begin
|
||||
Period.from_key(period_param)
|
||||
rescue Period::InvalidKeyError
|
||||
Period.last_30_days
|
||||
end
|
||||
else
|
||||
Period.last_30_days
|
||||
end
|
||||
|
||||
family_currency = Current.family.currency
|
||||
income_totals = Current.family.income_statement.income_totals(period: @cashflow_period)
|
||||
expense_totals = Current.family.income_statement.expense_totals(period: @cashflow_period)
|
||||
|
||||
@cashflow_sankey_data = build_cashflow_sankey_data(income_totals, expense_totals, family_currency)
|
||||
|
||||
@breadcrumbs = [ [ "Home", root_path ], [ "Dashboard", nil ] ]
|
||||
end
|
||||
|
||||
def changelog
|
||||
@release_notes = Provider::Github.new.fetch_latest_release_notes
|
||||
@release_notes = github_provider.fetch_latest_release_notes
|
||||
|
||||
render layout: "settings"
|
||||
end
|
||||
@@ -24,4 +43,105 @@ class PagesController < ApplicationController
|
||||
@invite_code = InviteCode.order("RANDOM()").limit(1).first
|
||||
render layout: false
|
||||
end
|
||||
|
||||
private
|
||||
def github_provider
|
||||
Provider::Registry.get_provider(:github)
|
||||
end
|
||||
|
||||
def build_cashflow_sankey_data(income_totals, expense_totals, currency_symbol)
|
||||
nodes = []
|
||||
links = []
|
||||
node_indices = {} # Memoize node indices by a unique key: "type_categoryid"
|
||||
|
||||
# Helper to add/find node and return its index
|
||||
add_node = ->(unique_key, display_name, value, percentage, color) {
|
||||
node_indices[unique_key] ||= begin
|
||||
nodes << { name: display_name, value: value.to_f.round(2), percentage: percentage.to_f.round(1), color: color }
|
||||
nodes.size - 1
|
||||
end
|
||||
}
|
||||
|
||||
total_income_val = income_totals.total.to_f.round(2)
|
||||
total_expense_val = expense_totals.total.to_f.round(2)
|
||||
|
||||
# --- Create Central Cash Flow Node ---
|
||||
cash_flow_idx = add_node.call("cash_flow_node", "Cash Flow", total_income_val, 0, "var(--color-success)")
|
||||
|
||||
# --- Process Income Side (Top-level categories only) ---
|
||||
income_totals.category_totals.each do |ct|
|
||||
# Skip subcategories – only include root income categories
|
||||
next if ct.category.parent_id.present?
|
||||
|
||||
val = ct.total.to_f.round(2)
|
||||
next if val.zero?
|
||||
|
||||
percentage_of_total_income = total_income_val.zero? ? 0 : (val / total_income_val * 100).round(1)
|
||||
|
||||
node_display_name = ct.category.name
|
||||
node_color = ct.category.color.presence || Category::COLORS.sample
|
||||
|
||||
current_cat_idx = add_node.call(
|
||||
"income_#{ct.category.id}",
|
||||
node_display_name,
|
||||
val,
|
||||
percentage_of_total_income,
|
||||
node_color
|
||||
)
|
||||
|
||||
links << {
|
||||
source: current_cat_idx,
|
||||
target: cash_flow_idx,
|
||||
value: val,
|
||||
color: node_color,
|
||||
percentage: percentage_of_total_income
|
||||
}
|
||||
end
|
||||
|
||||
# --- Process Expense Side (Top-level categories only) ---
|
||||
expense_totals.category_totals.each do |ct|
|
||||
# Skip subcategories – only include root expense categories to keep Sankey shallow
|
||||
next if ct.category.parent_id.present?
|
||||
|
||||
val = ct.total.to_f.round(2)
|
||||
next if val.zero?
|
||||
|
||||
percentage_of_total_expense = total_expense_val.zero? ? 0 : (val / total_expense_val * 100).round(1)
|
||||
|
||||
node_display_name = ct.category.name
|
||||
node_color = ct.category.color.presence || Category::UNCATEGORIZED_COLOR
|
||||
|
||||
current_cat_idx = add_node.call(
|
||||
"expense_#{ct.category.id}",
|
||||
node_display_name,
|
||||
val,
|
||||
percentage_of_total_expense,
|
||||
node_color
|
||||
)
|
||||
|
||||
links << {
|
||||
source: cash_flow_idx,
|
||||
target: current_cat_idx,
|
||||
value: val,
|
||||
color: node_color,
|
||||
percentage: percentage_of_total_expense
|
||||
}
|
||||
end
|
||||
|
||||
# --- Process Surplus ---
|
||||
leftover = (total_income_val - total_expense_val).round(2)
|
||||
if leftover.positive?
|
||||
percentage_of_total_income_for_surplus = total_income_val.zero? ? 0 : (leftover / total_income_val * 100).round(1)
|
||||
surplus_idx = add_node.call("surplus_node", "Surplus", leftover, percentage_of_total_income_for_surplus, "var(--color-success)")
|
||||
links << { source: cash_flow_idx, target: surplus_idx, value: leftover, color: "var(--color-success)", percentage: percentage_of_total_income_for_surplus }
|
||||
end
|
||||
|
||||
# Update Cash Flow and Income node percentages (relative to total income)
|
||||
if node_indices["cash_flow_node"]
|
||||
nodes[node_indices["cash_flow_node"]][:percentage] = 100.0
|
||||
end
|
||||
# No primary income node anymore, percentages are on individual income cats relative to total_income_val
|
||||
|
||||
{ nodes: nodes, links: links, currency_symbol: Money::Currency.new(currency_symbol).symbol }
|
||||
end
|
||||
end
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user