Compare commits

..

25 Commits

Author SHA1 Message Date
hackerESQ e6f38d9481 Chore: Upgrade to Laravel 12 + remove Mary and Jetstream dependencies (#141)
* docs: remove requirement for setting APP_KEY manually

* optimize date picker

* clean up modals

* spot light working

* reorganization

* add lazy load

* wip

* remove filament

* styling
2025-09-26 17:41:28 -05:00
hackerESQ 910d426ad4 add test 2025-09-13 22:24:02 -05:00
hackerESQ 72ad02de4b fix: standardize currency 2025-09-13 22:24:02 -05:00
hackerESQ 50285a3d51 Update SECURITY.md 2025-09-05 21:00:48 -05:00
hackerESQ ff31e3d48b Delete .github/dependabot.yml 2025-09-05 18:34:01 -05:00
hackerESQ 3d944afeb4 auto-bump deps using dependabot 2025-09-05 18:24:15 -05:00
hackerESQ 8e625107c1 keep APP_KEY reference in configuration section 2025-09-05 11:53:26 -05:00
enterprised1 df034863c7 Remove references to add APP_KEY in README.md
The current instructions specify an APP_KEY needs to be manually generated and added to the environment properties.  However, doing so results in a 500 error and the following post mentions APP_KEY is now generated automatically.  

https://github.com/orgs/investbrainapp/discussions/74#discussioncomment-14269950
2025-09-05 11:53:26 -05:00
hackerESQ 70cdfc9fd8 Delete .shift 2025-09-01 21:32:25 -05:00
hackerESQ a0bd776abb fix: quantity validation should not count current transaction 2025-08-29 15:47:38 -05:00
hackerESQ afcafa6031 chore: upgrade deps 2025-08-28 21:56:28 -05:00
hackerESQ 07c85697f3 chore: upgrade deps 2025-08-28 21:56:05 -05:00
hackerESQ a882b5aadb chore: clean up 2025-08-28 21:26:11 -05:00
hackerESQ bad82fb41b chore: cleanup old files 2025-08-28 21:26:11 -05:00
Shift 5aca9008cb Add .shift to open Pull Request 2025-08-28 21:26:11 -05:00
hackerESQ 712a4c6c57 docs: further clarification 2025-08-28 20:58:25 -05:00
hackerESQ 78f0d21b73 docs: clarify commands and update intro 2025-08-28 18:08:28 -05:00
hackerESQ 19cac58692 Fix: do not gracefully fail when symbol not found 2025-08-28 16:03:02 -05:00
hackerESQ 7d77b6fbc8 feat: add fix command for cost basis for sale transaction 2025-08-27 20:22:13 -05:00
hackerESQ e4e08091af fix: need to chunk alpaca history requests 2025-08-27 20:03:25 -05:00
hackerESQ 292d43b154 feat: adds cost basis fixer 2025-08-26 23:03:23 -05:00
hackerESQ eae4422ad8 fix: alphavantage multiply by string 2025-08-26 19:29:10 -05:00
hackerESQ 53d463b8b5 chore: upgrade deps 2025-08-26 19:27:26 -05:00
hackerESQ 827644bb32 fix: yahoo driver 2025-08-26 19:13:00 -05:00
hackerESQ 21e8672a12 feat: add twelve data market data provider 2025-08-26 18:26:12 -05:00
164 changed files with 6700 additions and 4389 deletions
+1
View File
@@ -26,6 +26,7 @@ ALPHAVANTAGE_API_KEY=
FINNHUB_API_KEY=
ALPACA_API_KEY=
ALPACA_API_SECRET=
TWELVEDATA_API_SECRET=
# Cadence to refresh market data (in minutes)
MARKET_DATA_REFRESH=30
+18 -11
View File
@@ -28,7 +28,7 @@ Investbrain is a smart open-source investment tracker that helps you manage, tra
## Under the hood
Investbrain is a Laravel PHP web application that leverages Livewire and Tailwind for its frontend. Most databases should work, including MySQL and SQLite. Out of the box, we feature many market data providers. But we also offer an extensible market data provider interface for intrepid developers to create their own! We also offer integrations with OpenAI and Ollama for our ["chat with your holdings"](#chat-with-your-holdings) capability. Finally, of course we have robust support for i18n, a11y, and dark mode.
Investbrain is a Laravel PHP web application that has an extensible market data provider interface. Out of the box, we feature many market data providers. But intrepid developers can [create their own providers](#custom-providers)! We also offer integrations with OpenAI and Ollama for our ["chat with your holdings"](#chat-with-your-holdings) capability. Finally, of course we have robust support for i18n, a11y, and dark mode.
## Self hosting
@@ -50,8 +50,6 @@ curl -O https://raw.githubusercontent.com/investbrainapp/investbrain/main/docker
Adjust the `environment` properties in the compose file to your preferences.
**Importantly**, you need to set the `APP_KEY` value. If you're unsure, Investbrain will generate an `APP_KEY` for you on first run, but it will not persist. You must _manually_ update your environment configuration with this generated value!
**3. Run `docker compose up`**
It might take a few minutes to pull the Docker images. But assuming everything worked as expected, you should now be able to access Investbrain in the browser by visiting:
@@ -74,7 +72,7 @@ Always keep in mind the limitations of LLMs. When in doubt, consult a licensed i
## Market data providers
Investbrain includes an extensible market data provider interface that allows you to retrieve stock market data from multiple providers, such as [Yahoo Finance](https://finance.yahoo.com/), [Finnhub](https://finnhub.io/pricing-stock-api-market-data), [Alpaca](https://alpaca.markets/), and [Alpha Vantage](https://www.alphavantage.co/support/). The interface includes a built-in fallback mechanism to ensure reliable data access, even if a provider fails.
Investbrain includes an extensible market data provider interface that allows you to retrieve stock market data from multiple providers, such as [Yahoo Finance](https://finance.yahoo.com/), [Twelve Data](https://twelvedata.com), [Finnhub](https://finnhub.io/pricing-stock-api-market-data), [Alpaca](https://alpaca.markets/), and [Alpha Vantage](https://www.alphavantage.co/support/). The interface includes a built-in fallback mechanism to ensure reliable data access, even if a provider fails.
### Configuration
@@ -92,7 +90,7 @@ Your selected providers should be listed in your environment variables. Each sho
MARKET_DATA_PROVIDER=yahoo,alphavantage
```
In the above example, Yahoo Finance will be attempted first and the Alpha Vantage provider will be used as the fallback. If Yahoo Finance fails to retrieve market data, the application will automatically try Alpha Vantage.
In the above example, Yahoo Finance will be attempted first. If Yahoo Finance fails to retrieve market data, the application will automatically try Alpha Vantage.
### Custom providers
@@ -137,10 +135,13 @@ There are several optional configurations available when installing using the re
| ------------- | ------------- | ------------- |
| APP_URL | The URL where your Investbrain installation will be accessible | http://localhost |
| APP_PORT | The HTTP port exposed by the NGINX container | 8000 |
| APP_KEY | Must be set during install - encryption key for various security-related functions | `null` |
| MARKET_DATA_PROVIDER | The market data provider to use (either `yahoo`, `alphavantage`, `alpaca`, or `finnhub`) | yahoo |
| APP_KEY | Encryption key for various security-related functions | Set automatically during install |
| MARKET_DATA_PROVIDER | The market data provider to use (either `yahoo`, `twelvedata`, `alphavantage`, `alpaca`, or `finnhub`) | yahoo |
| ALPHAVANTAGE_API_KEY | If using the Alpha Vantage provider | `null` |
| FINNHUB_API_KEY | If using the Finnhub provider | `null` |
| ALPACA_API_KEY | If using the Alpaca provider | `null` |
| ALPACA_API_SECRET | If using the Alpaca provider | `null` |
| TWELVEDATA_API_SECRET | If using the Twelve Data provider | `null` |
| MARKET_DATA_REFRESH | Cadence to refresh market data in minutes | 30 |
| APP_TIMEZONE | Timezone for the application, including daily change captures | UTC |
| AI_CHAT_ENABLED | Whether to enable AI chat features | `false` |
@@ -178,7 +179,7 @@ Easy as that!
## Command line utilities
Investbrain comes bundled with several helpful command line utilities to make managing your portfolios and holdings more efficient. Keep in mind these commands are extremely powerful and can make irreversable changes to your holdings.
Investbrain comes bundled with several helpful command line utilities to make managing your portfolios and holdings more efficient. Keep in mind these commands are extremely powerful and can make irreversable changes to your holdings. Just to be safe, we recommend backing up your portfolios before using these commands.
To run these commands, you can use `docker exec` like this:
@@ -186,7 +187,12 @@ To run these commands, you can use `docker exec` like this:
docker exec -it investbrain-app php artisan <replace with command you want to run>
```
Just to be safe, we recommend backing up your portfolios before using these commands:
If you need more details on what the command does, you can take a look at the options available using the `help` option:
```bash
<command you want to run> --help
```
| Command | Description |
| ------------- | ------------- |
@@ -195,8 +201,9 @@ Just to be safe, we recommend backing up your portfolios before using these comm
| refresh:split-data | Refreshes splits data with your configured market data provider. Will also create new transactions to account for any splits. |
| refresh:currency-data | Grabs the latest daily currency exchange rate data and persists to the database. |
| capture:daily-change | Captures a snapshot of each portfolio's daily performance. |
| sync:daily-change | Re-calculates daily snapshots of your portfolio's daily performance. Useful to fill in gaps in your portfolio charts. (Note: this is an extremely resource intensive query.) |
| sync:holdings | Re-calculates performance of holdings with related transactions (i.e. dividends, realized gains, etc). |
| sync:daily-change | Syncs daily snapshots of your portfolio's daily performance. Useful to fill in gaps in your portfolio charts. (Note: this is an extremely resource intensive query.) |
| sync:holdings | Syncs performance of holdings with related transactions (i.e. dividends, realized gains, etc). |
| fix:cost-basis-for-sales | Utility to automatically re-calculates cost basis for sale transactions. |
## Troubleshooting
+2 -1
View File
@@ -4,7 +4,8 @@
| Version | Supported |
| ------- | ------------------ |
| 1.1.x | :white_check_mark: |
| 1.2.x | :white_check_mark: |
| 1.1.x | :x: |
| 1.0.x | :x: |
| < 1.0.0 | :x: |
-21
View File
@@ -1,21 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Jetstream;
use App\Models\User;
use Laravel\Jetstream\Contracts\DeletesUsers;
class DeleteUser implements DeletesUsers
{
/**
* Delete the given user.
*/
public function delete(User $user): void
{
$user->deleteProfilePhoto();
$user->tokens->each->delete();
$user->delete();
}
}
@@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\Portfolio;
use App\Models\Transaction;
use Illuminate\Console\Command;
class FixCostBasisForSales extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'fix:cost-basis-for-sales
{--portfolio= : The ID of the portfolio to fix.}
{--user= : The user ID of transactions to fix.}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Fixes broken costs basis for sale transactions';
/**
* Create a new command instance.
*
* @return void
*/
public function __construct()
{
parent::__construct();
}
/**
* Execute the console command.
*
* @return int
*/
public function handle()
{
if (empty($this->option('user')) && empty($this->option('portfolio'))) {
$this->error('Must provide at least a user or portfolio.');
return;
}
$transactions = Transaction::where(['transaction_type' => 'SELL']);
if ($this->option('user')) {
$portfolios = Portfolio::fullAccess($this->option('user'))->get('id')
->pluck('id')
->toArray();
$transactions->whereIn('portfolio_id', $portfolios);
} else {
$transactions->where(['portfolio_id' => $this->option('portfolio')]);
}
$transactions = $transactions->get();
$this->line("Fixing cost basis for {$transactions->count()} sale transactions...");
$transactions->chunk(10)->each(function ($chunk) {
dispatch(function () use ($chunk) {
$chunk->each(function ($transaction) {
$cost_basis = Transaction::where([
'portfolio_id' => $transaction->portfolio_id,
'symbol' => $transaction->symbol,
'transaction_type' => 'BUY',
])->whereDate('date', '<=', $transaction->date)
->selectRaw('SUM(transactions.cost_basis * transactions.quantity) as total_cost_basis')
->selectRaw('SUM(transactions.quantity) as total_quantity')
->first();
$average_cost_basis = empty($cost_basis->total_quantity)
? 0
: $cost_basis->total_cost_basis / $cost_basis->total_quantity;
$transaction->cost_basis = $average_cost_basis ?? 0;
$transaction->save();
});
});
});
$this->line('Done!');
}
}
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
class ApiTokenController extends Controller
{
/**
* Show the user API token screen.
*
* @return \Illuminate\View\View
*/
public function index(Request $request)
{
return view('api.index', [
'request' => $request,
'user' => $request->user(),
]);
}
}
@@ -124,7 +124,7 @@ class ConnectedAccountController extends Controller
'title' => __('Your :provider account has been connected.', ['provider' => config("services.{$connected_account->provider}.name")]),
'description' => null,
'css' => 'alert-success',
'icon' => Blade::render("<x-mary-icon class='w-7 h-7' name='o-check-circle' />"),
'icon' => Blade::render("<x-ui.icon class='w-7 h-7' name='o-check-circle' />"),
'position' => 'toast-top toast-end',
'timeout' => '5000',
],
@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Traits\HasLocalizedMarkdown;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Support\Str;
class PrivacyPolicyController extends Controller
{
use HasLocalizedMarkdown;
/**
* Show the privacy policy for the application.
*
* @return \Illuminate\View\View
*/
public function show(Request $request)
{
$policyFile = $this->localizedMarkdownPath('policy.md');
return view('policy', [
'policy' => Str::markdown(file_get_contents($policyFile)),
]);
}
}
@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Traits\HasLocalizedMarkdown;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Support\Str;
class TermsOfServiceController extends Controller
{
use HasLocalizedMarkdown;
/**
* Show the terms of service for the application.
*
* @return \Illuminate\View\View
*/
public function show(Request $request)
{
$termsFile = $this->localizedMarkdownPath('terms.md');
return view('terms', [
'terms' => Str::markdown(file_get_contents($termsFile)),
]);
}
}
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
class UserProfileController extends Controller
{
/**
* Show the user profile screen.
*
* @return \Illuminate\View\View
*/
public function show(Request $request)
{
return view('profile.show', [
'request' => $request,
'user' => $request->user(),
]);
}
}
+2 -1
View File
@@ -39,7 +39,8 @@ class TransactionRequest extends FormRequest
$this->input('portfolio'),
$this->requestOrModelValue('symbol', 'transaction'),
$this->requestOrModelValue('transaction_type', 'transaction'),
$this->requestOrModelValue('date', 'transaction')
$this->requestOrModelValue('date', 'transaction'),
$this->transaction
),
],
'currency' => ['required', 'exists:currencies,currency'],
+50 -18
View File
@@ -8,11 +8,13 @@ use App\Interfaces\MarketData\Types\Dividend;
use App\Interfaces\MarketData\Types\Ohlc;
use App\Interfaces\MarketData\Types\Quote;
use App\Interfaces\MarketData\Types\Split;
use Carbon\CarbonInterval;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Arr;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Http;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class AlpacaMarketData implements MarketDataInterface
{
@@ -23,6 +25,11 @@ class AlpacaMarketData implements MarketDataInterface
public string $apiBaseUrl = 'https://api.alpaca.markets/';
public function __construct()
{
$this->createNewClient();
}
private function createNewClient()
{
$this->client = Http::withOptions([
'headers' => [
@@ -45,15 +52,15 @@ class AlpacaMarketData implements MarketDataInterface
$quote = $response->json('trade');
if (is_null(Arr::get($quote, 'p'))) {
throw new \Exception('Could not find ticker on Alpaca');
}
throw_if(empty(Arr::get($quote, 'p')), NotFoundHttpException::class, "Symbol `{$symbol}` was not found");
$fundamental = cache()->remember(
'ap-symbol-'.$symbol,
1440,
function () use ($symbol) {
$this->createNewClient();
$basic = $this->client->baseUrl($this->apiBaseUrl)->get("v2/assets/{$symbol}")->json();
$fifty_two_week = $this->client->baseUrl($this->dataBaseUrl)->withQueryParameters([
'timeframe' => '12M',
@@ -125,24 +132,49 @@ class AlpacaMarketData implements MarketDataInterface
public function history(string $symbol, $startDate, $endDate): Collection
{
$response = $this->client->baseUrl($this->dataBaseUrl)->withQueryParameters([
'timeframe' => '1D',
'start' => Carbon::parse($startDate)->format('Y-m-d'),
'end' => Carbon::parse($endDate)->subHours(36)->format('Y-m-d'), // todo: can't query recent SIP data
])->get("v2/stocks/{$symbol}/bars");
$startDate = Carbon::parse($startDate);
$endDate = Carbon::parse($endDate)->subHours(36); // alpaca has sip data limits
$history = $response->json('bars');
$allHistory = collect();
return collect($history)
->map(function ($history) use ($symbol) {
$chunks = 1000;
$date = Carbon::parse($history['t'])->format('Y-m-d');
$period = CarbonInterval::days($chunks)->toPeriod($startDate, $endDate);
foreach ($period as $startDate) {
return [$date => new Ohlc([
'symbol' => $symbol,
'date' => $date,
'close' => Arr::get($history, 'c'),
])];
});
$chunkEnd = $startDate->copy()->addDays($chunks - 1);
if ($chunkEnd->gt($endDate)) {
$chunkEnd = $endDate;
}
$this->createNewClient();
$response = $this->client->baseUrl($this->dataBaseUrl)->withQueryParameters([
'timeframe' => '1D',
'start' => $startDate->format('Y-m-d'),
'end' => $chunkEnd->format('Y-m-d'),
])->get("v2/stocks/{$symbol}/bars");
$history = $response->json('bars');
throw_if(empty($history), NotFoundHttpException::class, "Symbol `{$symbol}` was not found");
$chunkedHistory = collect($history)
->mapWithKeys(function ($history) use ($symbol) {
$date = Carbon::parse($history['t'])->format('Y-m-d');
return [$date => new Ohlc([
'symbol' => $symbol,
'date' => $date,
'close' => Arr::get($history, 'c'),
])];
});
$allHistory = $allHistory->merge($chunkedHistory);
}
return $allHistory;
}
}
@@ -69,7 +69,7 @@ class AlphaVantageMarketData implements MarketDataInterface
? Arr::get($fundamental, 'DividendDate')
: null,
'dividend_yield' => Arr::get($fundamental, 'DividendYield') != 'None'
? Arr::get($fundamental, 'DividendYield') * 100
? ((float) Arr::get($fundamental, 'DividendYield')) * 100
: null,
'meta_data' => [
'industry' => Arr::get($fundamental, 'Industry'),
@@ -145,7 +145,7 @@ class AlphaVantageMarketData implements MarketDataInterface
return [$date => new Ohlc([
'symbol' => $symbol,
'date' => $date,
'close' => Arr::get($history, '4. close'),
'close' => (float) Arr::get($history, '4. close'),
])];
});
}
@@ -0,0 +1,170 @@
<?php
declare(strict_types=1);
namespace App\Interfaces\MarketData;
use App\Interfaces\MarketData\Types\Dividend;
use App\Interfaces\MarketData\Types\Ohlc;
use App\Interfaces\MarketData\Types\Quote;
use App\Interfaces\MarketData\Types\Split;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Arr;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Http;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class TwelveDataMarketData implements MarketDataInterface
{
public PendingRequest $client;
public string $apiBaseUrl = 'https://api.twelvedata.com/';
public function __construct()
{
$this->createNewClient();
}
private function createNewClient()
{
$this->client = Http::withOptions([
'headers' => [
'content-type' => 'application/json',
'accept' => 'application/json',
],
])->withQueryParameters([
'apikey' => config('twelvedata.secret'),
]);
}
public function exists(string $symbol): bool
{
return (bool) $this->quote($symbol);
}
public function quote(string $symbol): Quote
{
$response = $this->client
->baseUrl($this->apiBaseUrl)
->withQueryParameters(['symbol' => $symbol])
->get('price');
$quote = $response->json();
throw_if(empty(Arr::get($quote, 'price')), NotFoundHttpException::class, "Symbol `{$symbol}` was not found");
$current_market_value = Arr::get($quote, 'price');
$fundamental = cache()->remember(
'twelve-data-symbol-'.$symbol,
1440,
function () use ($symbol) {
$this->createNewClient();
$response = $this->client
->baseUrl($this->apiBaseUrl)
->withQueryParameters(['symbol' => $symbol])
->get('quote');
return $response->json();
}
);
return new Quote([
'name' => Arr::get($fundamental, 'name'),
'symbol' => $symbol,
'currency' => Arr::get($fundamental, 'currency'),
'market_value' => (float) $current_market_value,
'fifty_two_week_high' => (float) Arr::get($fundamental, 'fifty_two_week.high'),
'fifty_two_week_low' => (float) Arr::get($fundamental, 'fifty_two_week.low'),
'meta_data' => [
'exchange' => Arr::get($fundamental, 'exchange'),
'source' => 'twelvedata',
],
]);
}
public function dividends(string $symbol, $startDate, $endDate): Collection
{
$response = $this->client
->baseUrl($this->apiBaseUrl)
->withQueryParameters([
'symbol' => $symbol,
'start_date' => Carbon::parse($startDate)->toDateString(),
'end_date' => Carbon::parse($endDate)->toDateString(),
])
->get('dividends');
$dividends = $response->json('dividends');
return collect($dividends)
->map(function ($dividend) use ($symbol) {
return new Dividend([
'symbol' => $symbol,
'date' => Arr::get($dividend, 'ex_date'),
'dividend_amount' => Arr::get($dividend, 'amount'),
]);
});
}
public function splits(string $symbol, $startDate, $endDate): Collection
{
$response = $this->client
->baseUrl($this->apiBaseUrl)
->withQueryParameters([
'symbol' => $symbol,
'start_date' => Carbon::parse($startDate)->toDateString(),
'end_date' => Carbon::parse($endDate)->toDateString(),
])
->get('splits');
$splits = $response->json('splits');
return collect($splits)
->map(function ($split) use ($symbol) {
return new Split([
'symbol' => $symbol,
'date' => Arr::get($split, 'date'),
'split_amount' => Arr::get($split, 'from_factor') / Arr::get($split, 'to_factor'),
]);
});
}
public function history(string $symbol, $startDate, $endDate): Collection
{
$response = $this->client
->baseUrl($this->apiBaseUrl)
->withQueryParameters([
'symbol' => $symbol,
'interval' => '1day',
'start_date' => Carbon::parse($startDate)->toDateString(),
'end_date' => Carbon::parse($endDate)->toDateString(),
])
->get('time_series');
$history = $response->json('values');
throw_if(empty($history), NotFoundHttpException::class, "Symbol `{$symbol}` was not found");
return collect($history)
->mapWithKeys(function ($history) use ($symbol) {
$date = Carbon::parse(Arr::get($history, 'datetime'))->toDateString();
return [$date => new Ohlc([
'symbol' => $symbol,
'date' => $date,
'close' => (float) Arr::get($history, 'close'),
])];
});
}
}
@@ -38,6 +38,14 @@ class Quote extends MarketDataType
public function setCurrency(string $currency): self
{
// need to standardize to ISO 4217
$currency = match ($currency) {
'US' => 'USD',
'CA' => 'CAD',
'GBp' => 'GBX',
default => $currency
};
$this->items['currency'] = strtoupper((string) $currency);
return $this;
@@ -21,7 +21,10 @@ class YahooMarketData implements MarketDataInterface
{
// create yahoo finance client factory
$this->client = YahooFinance::createApiClient();
$this->client = YahooFinance::createApiClient(
clientOptions: ['headers' => ['User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36']],
cache: app('cache.psr6')
);
}
public function exists(string $symbol): bool
+169
View File
@@ -0,0 +1,169 @@
<?php
namespace App\Livewire\Datatables;
use App\Models\Holding;
use Illuminate\Support\Number;
use Illuminate\Database\Eloquent\Builder;
use Rappasoft\LaravelLivewireTables\Views\Column;
use Rappasoft\LaravelLivewireTables\DataTableComponent;
class HoldingsTable extends DataTableComponent
{
public $portfolio;
public array $hiddenColumns = [];
public function mount ($portfolio): void
{
//
}
public function builder(): Builder
{
return Holding::query()
->portfolio($this->portfolio->id)
->with(['market_data'])
->withCount(['transactions as num_transactions' => function ($query) {
return $query->whereRaw('transactions.symbol = holdings.symbol');
}])
->withPerformance();
}
public function configure(): void
{
$this->hiddenColumns = ['name', 'average_cost_basis', 'market_value', 'fifty_two_week_low', 'fifty_two_week_high'];
$this->setTableWrapperAttributes([
'default' => false,
'default-styling' => false,
'default-colors' => false,
'class' => 'overflow-scroll'
]);
$this->setTableAttributes([
'default' => false,
'default-styling' => false,
'default-colors' => false,
'class' => 'table',
]);
$this->setTheadAttributes([
'default' => false,
'default-styling' => true,
'default-colors' => false,
]);
$this->setThAttributes(function(Column $column) {
$attributes = [
'default' => false,
'default-styling' => false,
'default-colors' => false,
'class' => 'text-xs font-medium whitespace-nowrap uppercase tracking-wider text-nowrap'
];
if (in_array($column->getField(), $this->hiddenColumns)) {
$attributes['class'] = $attributes['class'] . ' hidden md:table-cell';
}
return $attributes;
});
$this->setThSortButtonAttributes(fn() => [
'default' => false,
'default-styling' => true,
'default-colors' => false,
'class' => 'cursor-pointer'
]);
$this->setTbodyAttributes([
'default' => false,
'default-styling' => true,
'default-colors' => false,
]);
$this->setTrAttributes(fn() => [
'default' => false,
'default-styling' => true,
'default-colors' => false,
'class' => 'cursor-pointer hover:bg-neutral/25'
]);
$this->setTdAttributes(function(Column $column) {
$attributes = [
'default' => false,
'default-styling' => false,
'default-colors' => false,
'class' => 'text-nowrap'
];
if (in_array($column->getField(), $this->hiddenColumns)) {
$attributes['class'] = $attributes['class'] . ' hidden md:table-cell';
}
return $attributes;
});
$this->setDefaultSort('symbol', 'asc');
$this->setToolsDisabled();
$this->setFooterDisabled();
$this->setPaginationDisabled();
$this->setDisplayPaginationDetailsDisabled();
$this->setPrimaryKey('id');
$this->setTableRowUrl(function($row) {
return route('holding.show', ['portfolio' => $row->portfolio_id, 'symbol' => $row->symbol]);
})->setTableRowUrlTarget(function($row) {
return 'navigate';
});
}
public function columns(): array
{
return [
Column::make(__('Symbol'), 'symbol')
->sortable(),
Column::make(__('Name'), 'market_data.name')
->sortable(),
Column::make(__('Quantity'), 'quantity')
->sortable(),
Column::make(__('Average Cost Basis'), 'average_cost_basis')
->sortable()
->format(fn($value, $row) => Number::currency($value ?? 0, $row->market_data->currency) ),
Column::make(__('Total Cost Basis'), 'total_cost_basis')
->sortable()
->format(fn($value, $row) => Number::currency($value ?? 0, $row->market_data->currency) ),
Column::make(__('Market Value'), 'market_data.market_value')
->sortable()
->format(fn($value, $row) => Number::currency($value ?? 0, $row->market_data->currency) ),
Column::make(__('Total Market Value'))
->sortable(fn (Builder $query, string $direction) => $query->orderBy('total_market_value', $direction))
->label(fn ($row) => Number::currency($row->total_market_value ?? 0, $row->market_data->currency)),
Column::make(__('Market Gain/Loss'))
->html()
->label(fn($row) => Number::currency($row->market_gain_dollars ?? 0, $row->market_data->currency) . view('components.ui.gain-loss-arrow-badge', [
'costBasis' => $row->average_cost_basis,
'marketValue' => $row->market_data->market_value,
'small' => true,
]))
->sortable(fn (Builder $query, string $direction) => $query->orderBy('market_gain_dollars', $direction)),
Column::make(__('Realized Gain/Loss'), 'realized_gain_dollars')
->sortable()
->format(fn($value, $row) => Number::currency($value ?? 0, $row->market_data->currency) )
->format(fn($value, $row) => Number::currency($value ?? 0, $row->market_data->currency) ),
Column::make(__('Dividends Earned'), 'dividends_earned')
->sortable()
->format(fn($value, $row) => Number::currency($value ?? 0, $row->market_data->currency) ),
Column::make(__('52 week low'), 'market_data.fifty_two_week_low')
->sortable()
->format(fn($value, $row) => Number::currency($value ?? 0, $row->market_data->currency) ),
Column::make(__('52 week high'), 'market_data.fifty_two_week_high')
->sortable()
->format(fn($value, $row) => Number::currency($value ?? 0, $row->market_data->currency) ),
Column::make(__('Number of Transactions'))
->sortable(fn (Builder $query, string $direction) => $query->orderBy('num_transactions', $direction))
->label(fn ($row) => $row->num_transactions),
Column::make(__('Last Refreshed'), 'market_data.updated_at')
->sortable()
->format(fn($value) => \Carbon\Carbon::parse($value)->diffForHumans() )
];
}
}
@@ -0,0 +1,157 @@
<?php
namespace App\Livewire\Datatables;
use App\Models\Transaction;
use Illuminate\Support\Number;
use Illuminate\Database\Eloquent\Builder;
use Rappasoft\LaravelLivewireTables\Views\Column;
use Rappasoft\LaravelLivewireTables\DataTableComponent;
class TransactionsTable extends DataTableComponent
{
public array $hiddenColumns = [];
public function mount (): void
{
//
}
public function builder(): Builder
{
return Transaction::query()
->with(['portfolio', 'market_data'])
->myTransactions()
->addSelect(['portfolio_id', 'transaction_type', 'split'])
->selectRaw('
CASE
WHEN transaction_type = \'SELL\'
THEN COALESCE(transactions.sale_price - transactions.cost_basis, 0)
ELSE COALESCE(market_data.market_value - transactions.cost_basis, 0)
END AS gain_dollars');
}
public function configure(): void
{
$this->hiddenColumns = ['name', 'cost_basis', 'gain_dollars'];
$this->setTableWrapperAttributes([
'default' => false,
'default-styling' => false,
'default-colors' => false,
'class' => 'overflow-scroll'
]);
$this->setTableAttributes([
'default' => false,
'default-styling' => false,
'default-colors' => false,
'class' => 'table',
]);
$this->setTheadAttributes([
'default' => false,
'default-styling' => true,
'default-colors' => false,
]);
$this->setThAttributes(function(Column $column) {
$attributes = [
'default' => false,
'default-styling' => false,
'default-colors' => false,
'class' => 'text-xs font-medium whitespace-nowrap uppercase tracking-wider text-nowrap'
];
if (in_array($column->getField(), $this->hiddenColumns)) {
$attributes['class'] = $attributes['class'] . ' hidden md:table-cell';
}
return $attributes;
});
$this->setThSortButtonAttributes(fn() => [
'default' => false,
'default-styling' => true,
'default-colors' => false,
'class' => 'cursor-pointer'
]);
$this->setTbodyAttributes([
'default' => false,
'default-styling' => true,
'default-colors' => false,
]);
$this->setTrAttributes(fn() => [
'default' => false,
'default-styling' => true,
'default-colors' => false,
'class' => 'cursor-pointer hover:bg-neutral/25'
]);
$this->setTdAttributes(function(Column $column) {
$attributes = [
'default' => false,
'default-styling' => false,
'default-colors' => false,
'class' => 'text-nowrap'
];
if (in_array($column->getField(), $this->hiddenColumns)) {
$attributes['class'] = $attributes['class'] . ' hidden md:table-cell';
}
return $attributes;
});
$this->setDefaultSort('date', 'desc');
$this->setPerPageAccepted([10, 15, 20]);
$this->setPerPage(15);
$this->setSearchDisabled();
$this->setColumnSelectDisabled();
$this->setPerPageVisibilityDisabled();
$this->setFooterDisabled();
$this->setPrimaryKey('id');
$this->setTableRowUrl(function($row) {
return route('holding.show', ['portfolio' => $row->portfolio_id, 'symbol' => $row->symbol]);
})->setTableRowUrlTarget(function($row) {
return 'navigate';
});
}
public function columns(): array
{
return [
Column::make(__('Date'), 'date')
->sortable()
->format(fn($value) => \Carbon\Carbon::parse($value)->format('M d, Y') ),
Column::make(__('Portfolio'), 'portfolio.title')
->sortable(),
Column::make(__('Symbol'), 'symbol')
->sortable(),
Column::make(__('Name'), 'market_data.name')
->sortable(),
Column::make(__('Type'), 'transaction_type')
->label(fn($row) => view('components.ui.badge', [
'value' => $row->split ? 'SPLIT'
: ($row->reinvested_dividend
? 'REINVEST'
: $row->transaction_type),
'class' => ($row->transaction_type == 'BUY'
? 'badge-success'
: 'badge-error') . ' badge-sm mr-3',
]))
->sortable(fn (Builder $query, string $direction) => $query->orderBy('transaction_type', $direction)),
Column::make(__('Quantity'), 'quantity')
->sortable(),
Column::make(__('Cost Basis'), 'cost_basis')
->sortable(fn (Builder $query, string $direction) => $query->orderBy('cost_basis', $direction))
->label(fn ($row) => Number::currency($row->cost_basis ?? 0, $row->market_data->currency)),
Column::make(__('Gain/Loss'), 'gain_dollars')
->sortable(fn (Builder $query, string $direction) => $query->orderBy('gain_dollars', $direction))
->label(fn ($row) => Number::currency($row->gain_dollars ?? 0, $row->market_data->currency)),
];
}
}
+1 -1
View File
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Models;
use App\Traits\HasConnectedAccounts;
use App\Traits\HasProfilePhoto;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -12,7 +13,6 @@ use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Arr;
use Laravel\Fortify\TwoFactorAuthenticatable;
use Laravel\Jetstream\HasProfilePhoto;
use Laravel\Sanctum\HasApiTokens;
use Staudenmeir\EloquentHasManyDeep\HasManyDeep;
use Staudenmeir\EloquentHasManyDeep\HasRelationships;
+1
View File
@@ -30,6 +30,7 @@ class FortifyServiceProvider extends ServiceProvider
*/
public function boot(): void
{
Fortify::viewPrefix('auth.');
Fortify::createUsersUsing(CreateNewUser::class);
Fortify::updateUserProfileInformationUsing(UpdateUserProfileInformation::class);
@@ -1,55 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Providers;
use App\Actions\Jetstream\DeleteUser;
use Illuminate\Support\ServiceProvider;
use Laravel\Jetstream\Jetstream;
class JetstreamServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*/
public function register(): void
{
//
}
/**
* Bootstrap any application services.
*/
public function boot(): void
{
$this->configurePermissions();
Jetstream::deleteUsersUsing(DeleteUser::class);
}
/**
* Configure the permissions that are available within the application.
*/
protected function configurePermissions(): void
{
Jetstream::defaultApiTokenPermissions([
// 'portfolio:read',
// 'portfolio:write',
// 'holding:read',
// 'holding:write',
// 'transaction:read',
// 'transaction:write',
]);
Jetstream::permissions([
// 'Read Portfolios' => 'portfolio:read',
// 'Create Portfolios' => 'portfolio:write',
// 'Read Holdings' => 'holding:read',
// 'Update Holdings' => 'holding:write',
// 'Read Transactions' => 'transaction:read',
// 'Create Transactions' => 'transaction:write',
]);
}
}
+4 -2
View File
@@ -4,8 +4,8 @@ declare(strict_types=1);
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
use Livewire\Volt\Volt;
use Illuminate\Support\ServiceProvider;
class VoltServiceProvider extends ServiceProvider
{
@@ -21,14 +21,16 @@ class VoltServiceProvider extends ServiceProvider
* Bootstrap services.
*/
public function boot(): void
{
{
Volt::mount([
// config('livewire.view_path', resource_path('views/livewire')),
resource_path('views/components'),
resource_path('views/profile'),
resource_path('views/api'),
resource_path('views/holding'),
resource_path('views/transaction'),
resource_path('views/portfolio'),
resource_path('views/import-export'),
resource_path('views/auth'),
]);
}
+5 -3
View File
@@ -6,8 +6,8 @@ namespace App\Rules;
use App\Models\Portfolio;
use App\Models\Transaction;
use Illuminate\Support\Carbon;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Support\Carbon;
class QuantityValidationRule implements ValidationRule
{
@@ -20,8 +20,9 @@ class QuantityValidationRule implements ValidationRule
protected ?Portfolio $portfolio,
protected ?string $symbol,
protected ?string $transactionType,
protected string|Carbon|null $date
) { }
protected string|Carbon|null $date,
protected ?Transaction $transaction
) {}
/**
* Validate the attribute.
@@ -42,6 +43,7 @@ class QuantityValidationRule implements ValidationRule
->sum('quantity');
$sales_qty = (float) $this->portfolio->transactions()
->where('id', '!=', $this->transaction?->id)
->symbol($this->symbol)
->sell()
->whereDate('date', '<', $this->date)
-16
View File
@@ -1,16 +0,0 @@
<?php
declare(strict_types=1);
use App\Models\Currency;
if (! function_exists('currency')) {
// /**
// * Returns an instance of the currency model
// * */
// function currency(): Currency
// {
// return new Currency;
// }
}
+115
View File
@@ -0,0 +1,115 @@
<?php
namespace App\Traits;
use Illuminate\Contracts\Auth\StatefulGuard;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\ValidationException;
use Laravel\Fortify\Actions\ConfirmPassword;
trait ConfirmsPasswords
{
/**
* Indicates if the user's password is being confirmed.
*
* @var bool
*/
public $confirmingPassword = false;
/**
* The ID of the operation being confirmed.
*
* @var string|null
*/
public $confirmableId = null;
/**
* The user's password.
*
* @var string
*/
public $confirmablePassword = '';
/**
* Start confirming the user's password.
*
* @param string $confirmableId
* @return void
*/
public function startConfirmingPassword(string $confirmableId)
{
$this->resetErrorBag();
if ($this->passwordIsConfirmed()) {
return $this->dispatch('password-confirmed',
id: $confirmableId,
);
}
$this->confirmingPassword = true;
$this->confirmableId = $confirmableId;
$this->confirmablePassword = '';
$this->dispatch('confirming-password');
}
/**
* Stop confirming the user's password.
*
* @return void
*/
public function stopConfirmingPassword()
{
$this->confirmingPassword = false;
$this->confirmableId = null;
$this->confirmablePassword = '';
}
/**
* Confirm the user's password.
*
* @return void
*/
public function confirmPassword()
{
if (! app(ConfirmPassword::class)(app(StatefulGuard::class), Auth::user(), $this->confirmablePassword)) {
throw ValidationException::withMessages([
'confirmable_password' => [__('This password does not match our records.')],
]);
}
session(['auth.password_confirmed_at' => time()]);
$this->dispatch('password-confirmed',
id: $this->confirmableId,
);
$this->stopConfirmingPassword();
}
/**
* Ensure that the user's password has been recently confirmed.
*
* @param int|null $maximumSecondsSinceConfirmation
* @return void
*/
protected function ensurePasswordIsConfirmed($maximumSecondsSinceConfirmation = null)
{
$maximumSecondsSinceConfirmation = $maximumSecondsSinceConfirmation ?: config('auth.password_timeout', 900);
$this->passwordIsConfirmed($maximumSecondsSinceConfirmation) ? null : abort(403);
}
/**
* Determine if the user's password has been recently confirmed.
*
* @param int|null $maximumSecondsSinceConfirmation
* @return bool
*/
protected function passwordIsConfirmed($maximumSecondsSinceConfirmation = null)
{
$maximumSecondsSinceConfirmation = $maximumSecondsSinceConfirmation ?: config('auth.password_timeout', 900);
return (time() - session('auth.password_confirmed_at', 0)) < $maximumSecondsSinceConfirmation;
}
}
+22
View File
@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Traits;
use Illuminate\Support\Arr;
trait HasLocalizedMarkdown
{
public function localizedMarkdownPath($name)
{
$localName = preg_replace('#(\.md)$#i', '.'.app()->getLocale().'$1', $name);
return Arr::first([
resource_path('markdown/'.$localName),
resource_path('markdown/'.$name),
], function ($path) {
return file_exists($path);
});
}
}
+77
View File
@@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
namespace App\Traits;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
trait HasProfilePhoto
{
/**
* Update the user's profile photo.
*
* @param string $storagePath
* @return void
*/
public function updateProfilePhoto(UploadedFile $photo, $storagePath = 'profile-photos')
{
tap($this->profile_photo_path, function ($previous) use ($photo, $storagePath) {
$this->forceFill([
'profile_photo_path' => $photo->storePublicly(
$storagePath, ['disk' => 'public']
),
])->save();
if ($previous) {
Storage::disk('public')->delete($previous);
}
});
}
/**
* Delete the user's profile photo.
*
* @return void
*/
public function deleteProfilePhoto()
{
if (is_null($this->profile_photo_path)) {
return;
}
Storage::disk('public')->delete($this->profile_photo_path);
$this->forceFill([
'profile_photo_path' => null,
])->save();
}
/**
* Get the URL to the user's profile photo.
*/
protected function profilePhotoUrl(): Attribute
{
return Attribute::get(function (): string {
return $this->profile_photo_path
? Storage::disk('public')->url($this->profile_photo_path)
: $this->defaultProfilePhotoUrl();
});
}
/**
* Get the default profile photo URL if no profile photo has been uploaded.
*
* @return string
*/
protected function defaultProfilePhotoUrl()
{
$name = trim(collect(explode(' ', $this->name))->map(function ($segment) {
return mb_substr($segment, 0, 1);
})->join(' '));
return 'https://ui-avatars.com/api/?name='.urlencode($name).'&color=7F9CF5&background=EBF4FF';
}
}
+88
View File
@@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
namespace App\Traits;
use Illuminate\Support\Facades\Blade;
trait Toast
{
public function toast(
string $type,
string $title,
?string $description = null,
?string $position = null,
string $icon = 'o-information-circle',
string $css = 'alert-info',
int $timeout = 3000,
?string $redirectTo = null
) {
$toast = [
'type' => $type,
'title' => $title,
'description' => $description,
'position' => $position,
'icon' => Blade::render("<x-ui.icon class='w-7 h-7' name='".$icon."' />"),
'css' => $css,
'timeout' => $timeout,
];
$this->js('toast('.json_encode(['toast' => $toast]).')');
// session()->flash('ib.toast.title', $title);
// session()->flash('ib.toast.description', $description);
if ($redirectTo) {
return $this->redirect($redirectTo, navigate: true);
}
}
public function success(
string $title,
?string $description = null,
?string $position = null,
string $icon = 'o-check-circle',
string $css = 'alert-success',
int $timeout = 3000,
?string $redirectTo = null
) {
return $this->toast('success', $title, $description, $position, $icon, $css, $timeout, $redirectTo);
}
public function warning(
string $title,
?string $description = null,
?string $position = null,
string $icon = 'o-exclamation-triangle',
string $css = 'alert-warning',
int $timeout = 3000,
?string $redirectTo = null
) {
return $this->toast('warning', $title, $description, $position, $icon, $css, $timeout, $redirectTo);
}
public function error(
string $title,
?string $description = null,
?string $position = null,
string $icon = 'o-x-circle',
string $css = 'alert-error',
int $timeout = 3000,
?string $redirectTo = null
) {
return $this->toast('error', $title, $description, $position, $icon, $css, $timeout, $redirectTo);
}
public function info(
string $title,
?string $description = null,
?string $position = null,
string $icon = 'o-information-circle',
string $css = 'alert-info',
int $timeout = 3000,
?string $redirectTo = null
) {
return $this->toast('info', $title, $description, $position, $icon, $css, $timeout, $redirectTo);
}
}
-53
View File
@@ -1,53 +0,0 @@
<?php
declare(strict_types=1);
namespace App\View\Components;
use Illuminate\View\Component;
class AppLayout extends Component
{
/**
* Get the view / contents that represents the component.
*/
public function render()
{
return <<<'HTML'
<x-main-layout>
<x-slot:body class="min-h-screen font-sans antialiased bg-base-200/50 dark:bg-base-200" x-data>
<div>
<x-partials.nav-bar />
<x-partials.main with-nav full-width>
<x-slot:sidebar drawer="main-drawer" class="bg-base-100 lg:bg-inherit">
@livewire('partials.side-bar')
</x-slot:sidebar>
<x-slot:content>
{{ $slot }}
</x-slot:content>
</x-partials.main>
@if(session('toast'))
<script lang="text/javascript">
window.addEventListener('DOMContentLoaded', function () {
window.toast(JSON.parse(@json(session('toast'))))
});
</script>
@endif
<x-toast />
</div>
</x-slot:body>
</x-main-layout>
HTML;
}
}
-28
View File
@@ -1,28 +0,0 @@
<?php
declare(strict_types=1);
namespace App\View\Components;
use Illuminate\View\Component;
class GuestLayout extends Component
{
/**
* Get the view / contents that represents the component.
*/
public function render()
{
return <<<'HTML'
<x-main-layout>
<x-slot:body class="font-sans text-gray-900 dark:text-gray-100 antialiased">
{{ $slot }}
<x-theme-toggle class="hidden" darkTheme="business" lightTheme="corporate"/>
</x-slot:body>
</x-main-layout>
HTML;
}
}
-25
View File
@@ -1,25 +0,0 @@
<?php
declare(strict_types=1);
namespace App\View\Components;
use Illuminate\View\Component;
use Illuminate\View\View;
class MainLayout extends Component
{
public function __construct(
// Slots
public mixed $body = null,
) {}
/**
* Get the view / contents that represents the component.
*/
public function render(): View
{
return view('layouts.main-layout');
}
}
-1
View File
@@ -5,6 +5,5 @@ declare(strict_types=1);
return [
App\Providers\AppServiceProvider::class,
App\Providers\FortifyServiceProvider::class,
App\Providers\JetstreamServiceProvider::class,
App\Providers\VoltServiceProvider::class,
];
+13 -8
View File
@@ -2,18 +2,25 @@
"name": "investbrainapp/investbrain",
"type": "project",
"description": "A smart open-source tool that consolidates and tracks portfolios from your different brokerages",
"keywords": ["stocks", "dividends", "investments", "tracking"],
"keywords": [
"stocks",
"dividends",
"investments",
"tracking"
],
"license": "CC-BY-NC 4.0",
"require": {
"php": "^8.3",
"ext-gd": "*",
"ext-json": "*",
"ext-mbstring": "*",
"ext-zip": "*",
"blade-ui-kit/blade-heroicons": "^2.6",
"finnhub/client": "master@dev",
"hackeresq/filter-models": "dev-main",
"investbrainapp/frankfurter-client": "dev-main",
"laravel/framework": "^11.35",
"laravel/jetstream": "^5.1",
"laravel/fortify": "^1.30.0",
"laravel/framework": "^12.0",
"laravel/sanctum": "^4.0",
"laravel/socialite": "^5.16",
"laravel/tinker": "^2.9",
@@ -23,9 +30,10 @@
"maatwebsite/excel": "^3.1",
"openai-php/client": "^0.10.3",
"predis/predis": "^2.2",
"robsontenorio/mary": "^1.35",
"rappasoft/laravel-livewire-tables": "^3.7",
"scheb/yahoo-finance-api": "^5.0",
"staudenmeir/eloquent-has-many-deep": "^1.20",
"symfony/cache": "^7.3",
"tschucki/alphavantage-laravel": "^0.0"
},
"require-dev": {
@@ -34,7 +42,7 @@
"laravel/sail": "^1.26",
"mockery/mockery": "^1.6",
"nunomaduro/collision": "^8.0",
"phpunit/phpunit": "^11.0.1"
"phpunit/phpunit": "^11.0"
},
"repositories": [
{
@@ -54,9 +62,6 @@
}
],
"autoload": {
"files": [
"app/Support/Helpers.php"
],
"psr-4": {
"App\\": "app/",
"Database\\Factories\\": "database/factories/",
Generated
+1289 -1022
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -13,6 +13,7 @@ return [
'alphavantage' => App\Interfaces\MarketData\AlphaVantageMarketData::class,
'alpaca' => App\Interfaces\MarketData\AlpacaMarketData::class,
'finnhub' => App\Interfaces\MarketData\FinnhubMarketData::class,
'twelvedata' => App\Interfaces\MarketData\TwelveDataMarketData::class,
'fake' => App\Interfaces\MarketData\FakeMarketData::class,
],
-81
View File
@@ -1,81 +0,0 @@
<?php
declare(strict_types=1);
use Laravel\Jetstream\Features;
use Laravel\Jetstream\Http\Middleware\AuthenticateSession;
return [
/*
|--------------------------------------------------------------------------
| Jetstream Stack
|--------------------------------------------------------------------------
|
| This configuration value informs Jetstream which "stack" you will be
| using for your application. In general, this value is set for you
| during installation and will not need to be changed after that.
|
*/
'stack' => 'livewire',
/*
|--------------------------------------------------------------------------
| Jetstream Route Middleware
|--------------------------------------------------------------------------
|
| Here you may specify which middleware Jetstream will assign to the routes
| that it registers with the application. When necessary, you may modify
| these middleware; however, this default value is usually sufficient.
|
*/
'middleware' => ['web'],
'auth_session' => AuthenticateSession::class,
/*
|--------------------------------------------------------------------------
| Jetstream Guard
|--------------------------------------------------------------------------
|
| Here you may specify the authentication guard Jetstream will use while
| authenticating users. This value should correspond with one of your
| guards that is already present in your "auth" configuration file.
|
*/
'guard' => 'sanctum',
/*
|--------------------------------------------------------------------------
| Features
|--------------------------------------------------------------------------
|
| Some of Jetstream's features are optional. You may disable the features
| by removing them from this array. You're free to only remove some of
| these features or you can even remove all of these if you need to.
|
*/
'features' => [
Features::profilePhotos(),
Features::api(),
Features::accountDeletion(),
],
/*
|--------------------------------------------------------------------------
| Profile Photo Disk
|--------------------------------------------------------------------------
|
| This configuration value determines the default disk that will be used
| when storing profile photos for your application's users. Typically
| this will be the "public" disk but you may adjust this if needed.
|
*/
'profile_photo_disk' => env('JETSTREAM_PROFILE_PHOTO_DISK', 'public'),
];
+24
View File
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
return [
'theme' => 'tailwind',
/**
* Enable or Disable automatic injection of core assets
*/
'inject_core_assets_enabled' => false,
/**
* Enable or Disable automatic injection of third-party assets
*/
'inject_third_party_assets_enabled' => false,
/**
* Enable Blade Directives (Not required if automatically injecting or using bundler approaches)
*/
'enable_blade_directives ' => false,
];
-46
View File
@@ -1,46 +0,0 @@
<?php
declare(strict_types=1);
return [
/**
* Default component prefix.
*
* Make sure to clear view cache after renaming with `php artisan view:clear`
*
* prefix => ''
* <x-button />
* <x-card />
*
* prefix => 'mary-'
* <x-mary-button />
* <x-mary-card />
*/
'prefix' => '',
/**
* Default route prefix.
*
* Some maryUI components make network request to its internal routes.
*
* route_prefix => ''
* - Spotlight: '/mary/spotlight'
* - Editor: '/mary/upload'
* - ...
*
* route_prefix => 'my-components'
* - Spotlight: '/my-components/mary/spotlight'
* - Editor: '/my-components/mary/upload'
* - ...
*/
'route_prefix' => '',
/**
* Components settings
*/
'components' => [
'spotlight' => [
'class' => 'App\Support\Spotlight',
],
],
];
+7
View File
@@ -0,0 +1,7 @@
<?php
declare(strict_types=1);
return [
'secret' => env('TWELVEDATA_API_SECRET'),
];
+4 -2
View File
@@ -122,19 +122,21 @@ class TransactionFactory extends Factory
]);
}
public function buy(): static
public function buy($quantity = 1): static
{
return $this->state(fn (array $attributes) => [
'transaction_type' => 'BUY',
'quantity' => $quantity,
'cost_basis' => $this->faker->randomFloat(2, 10, 500),
'sale_price' => null,
]);
}
public function sell(): static
public function sell($quantity = 1): static
{
return $this->state(fn (array $attributes) => [
'transaction_type' => 'SELL',
'quantity' => $quantity,
'sale_price' => $this->faker->randomFloat(2, 10, 500),
'cost_basis' => null,
]);
+12 -15
View File
@@ -15,6 +15,7 @@
"Cancel": "Cancel",
"Save": "Save",
"Close": "Close",
"Dismiss": "Dismiss",
"or": "or",
"and": "and",
"Yes": "Yes",
@@ -28,21 +29,14 @@
"Permanently delete your account.": "Permanently delete your account.",
"Once your account is deleted, all of its resources and data will be permanently deleted. Before deleting your account, please download any data or information that you wish to retain.": "Once your account is deleted, all of its resources and data will be permanently deleted. Before deleting your account, please download any data or information that you wish to retain.",
"Are you sure you want to delete your account? Once your account is deleted, all of its resources and data will be permanently deleted. Please enter your password to confirm you would like to permanently delete your account.": "Are you sure you want to delete your account? Once your account is deleted, all of its resources and data will be permanently deleted. Please enter your password to confirm you would like to permanently delete your account.",
"Browser Sessions": "Browser Sessions",
"Manage and log out your active sessions on other browsers and devices.": "Manage and log out your active sessions on other browsers and devices.",
"If necessary, you may log out of all of your other browser sessions across all of your devices. Some of your recent sessions are listed below; however, this list may not be exhaustive. If you feel your account has been compromised, you should also update your password.": "If necessary, you may log out of all of your other browser sessions across all of your devices. Some of your recent sessions are listed below; however, this list may not be exhaustive. If you feel your account has been compromised, you should also update your password.",
"This device": "This device",
"Last active": "Last active",
"Log Out Other Browser Sessions": "Log Out Other Browser Sessions",
"Please enter your password to confirm you would like to log out of your other browser sessions across all of your devices.": "Please enter your password to confirm you would like to log out of your other browser sessions across all of your devices.",
"Two Factor Authentication": "Two Factor Authentication",
"Add additional security to your account using two factor authentication.": "Add additional security to your account using two factor authentication.",
"Finish enabling two factor authentication.": "Finish enabling two factor authentication.",
"You have enabled two factor authentication.": "You have enabled two factor authentication.",
"You have not enabled two factor authentication.": "You have not enabled two factor authentication.",
"When two factor authentication is enabled, you will be prompted for a secure, random token during authentication. You may retrieve this token from your phone\\'s Google Authenticator application.": "When two factor authentication is enabled, you will be prompted for a secure, random token during authentication. You may retrieve this token from your phone\\'s Google Authenticator application.",
"To finish enabling two factor authentication, scan the following QR code using your phone\\'s authenticator application or enter the setup key and provide the generated OTP code.": "To finish enabling two factor authentication, scan the following QR code using your phone\\'s authenticator application or enter the setup key and provide the generated OTP code.",
"Two factor authentication is now enabled. Scan the following QR code using your phone\\'s authenticator application or enter the setup key.": "Two factor authentication is now enabled. Scan the following QR code using your phone\\'s authenticator application or enter the setup key.",
"When two factor authentication is enabled, you will be prompted for a secure, random token during authentication. You may retrieve this token from your phone's Google Authenticator application.": "When two factor authentication is enabled, you will be prompted for a secure, random token during authentication. You may retrieve this token from your phone's Google Authenticator application.",
"To finish enabling two factor authentication, scan the following QR code using your phone's authenticator application or enter the setup key and provide the generated OTP code.": "To finish enabling two factor authentication, scan the following QR code using your phone's authenticator application or enter the setup key and provide the generated OTP code.",
"Two factor authentication is now enabled. Scan the following QR code using your phone's authenticator application or enter the setup key.": "Two factor authentication is now enabled. Scan the following QR code using your phone's authenticator application or enter the setup key.",
"Setup Key": "Setup Key",
"Store these recovery codes in a secure password manager. They can be used to recover access to your account if your two factor authentication device is lost.": "Store these recovery codes in a secure password manager. They can be used to recover access to your account if your two factor authentication device is lost.",
"Regenerate Recovery Codes": "Regenerate Recovery Codes",
@@ -57,7 +51,7 @@
"Your :provider account has been connected.": "Your :provider account has been connected.",
"Account already exists. Check your email to connect your :provider account.": "Account already exists. Check your email to connect your :provider account.",
"Could not login using :provider. Try again later.": "Could not login using :provider. Try again later.",
"Update your account\\'s profile information and email address.": "Update your account\\'s profile information and email address.",
"Update your account's profile information and email address.": "Update your account's profile information and email address.",
"Photo": "Photo",
"Select A New Photo": "Select A New Photo",
"Remove Photo": "Remove Photo",
@@ -68,7 +62,9 @@
"Last used": "Last used",
"Delete": "Delete",
"API Token": "API Token",
"Please copy your new API token. For your security, it won\\'t be shown again.": "Please copy your new API token. For your security, it won\\'t be shown again.",
"Please copy your new API token. For your security, it won't be shown again.": "Please copy your new API token. For your security, it won't be shown again.",
"Copy to clipboard": "Copy to clipboard",
"Successfully copied!": "Successfully copied!",
"API Token Permissions": "API Token Permissions",
"API tokens allow third-party services to authenticate with Investbrain on your behalf.": "API tokens allow third-party services to authenticate with Investbrain on your behalf.",
"Delete API Token": "Delete API Token",
@@ -96,7 +92,7 @@
"Recovery Code": "Recovery Code",
"Use a recovery code": "Use a recovery code",
"Use an authentication code": "Use an authentication code",
"Before continuing, could you verify your email address by clicking on the link we just emailed to you? If you didn\\'t receive the email, we will gladly send you another.": "Before continuing, could you verify your email address by clicking on the link we just emailed to you? If you didn\\'t receive the email, we will gladly send you another.",
"Before continuing, could you verify your email address by clicking on the link we just emailed to you? If you didn't receive the email, we will gladly send you another.": "Before continuing, could you verify your email address by clicking on the link we just emailed to you? If you didn't receive the email, we will gladly send you another.",
"A new verification link has been sent to the email address you provided in your profile settings.": "A new verification link has been sent to the email address you provided in your profile settings.",
"Resend Verification Email": "Resend Verification Email",
"Edit Profile": "Edit Profile",
@@ -114,8 +110,9 @@
"The provided password does not match your current password.": "The provided password does not match your current password.",
"Documentation": "Documentation",
"We\\'re open source!": "We\\'re open source!",
"We're open source!": "We're open source!",
"Toggle Theme": "Toggle Theme",
"Toggle Sidebar": "Toggle Sidebar",
"Dashboard": "Dashboard",
"Gain/Loss": "Gain/Loss",
@@ -333,7 +330,7 @@
"passwords.sent": "We have emailed your password reset link.",
"passwords.throttled": "Please wait before retrying.",
"passwords.token": "This password reset token is invalid.",
"passwords.user": "We can\\'t find a user with that email address.",
"passwords.user": "We can't find a user with that email address.",
"pagination.previous": "&laquo; Previous",
"pagination.next": "Next &raquo;",
+4 -7
View File
@@ -15,6 +15,7 @@
"Cancel": "Cancelar",
"Save": "Guardar",
"Close": "Cerrar",
"Dismiss": "Despedir",
"or": "o",
"and": "y",
"Yes": "Sí",
@@ -28,13 +29,6 @@
"Permanently delete your account.": "Elimina tu cuenta de forma permanente.",
"Once your account is deleted, all of its resources and data will be permanently deleted. Before deleting your account, please download any data or information that you wish to retain.": "Una vez que tu cuenta sea eliminada, todos sus recursos y datos serán eliminados de forma permanente. Antes de eliminar tu cuenta, descarga cualquier dato o información que desees conservar.",
"Are you sure you want to delete your account? Once your account is deleted, all of its resources and data will be permanently deleted. Please enter your password to confirm you would like to permanently delete your account.": "¿Estás seguro de que deseas eliminar tu cuenta? Una vez que tu cuenta sea eliminada, todos sus recursos y datos serán eliminados de forma permanente. Introduce tu contraseña para confirmar que deseas eliminar tu cuenta de forma permanente.",
"Browser Sessions": "Sesiones del Navegador",
"Manage and log out your active sessions on other browsers and devices.": "Gestiona y cierra sesión en tus sesiones activas en otros navegadores y dispositivos.",
"If necessary, you may log out of all of your other browser sessions across all of your devices. Some of your recent sessions are listed below; however, this list may not be exhaustive. If you feel your account has been compromised, you should also update your password.": "Si es necesario, puedes cerrar sesión en todas tus otras sesiones del navegador en todos tus dispositivos. Algunas de tus sesiones recientes se enumeran a continuación; sin embargo, esta lista puede no ser exhaustiva. Si sientes que tu cuenta ha sido comprometida, también deberías actualizar tu contraseña.",
"This device": "Este dispositivo",
"Last active": "Última actividad",
"Log Out Other Browser Sessions": "Cerrar Sesión en Otras Sesiones del Navegador",
"Please enter your password to confirm you would like to log out of your other browser sessions across all of your devices.": "Introduce tu contraseña para confirmar que deseas cerrar sesión en tus otras sesiones del navegador en todos tus dispositivos.",
"Two Factor Authentication": "Autenticación de Dos Factores",
"Add additional security to your account using two factor authentication.": "Añade seguridad adicional a tu cuenta utilizando autenticación de dos factores.",
"Finish enabling two factor authentication.": "Finaliza la activación de la autenticación de dos factores.",
@@ -69,6 +63,8 @@
"Delete": "Eliminar",
"API Token": "Token API",
"Please copy your new API token. For your security, it won't be shown again.": "Por favor, copia tu nuevo token API. Por seguridad, no se mostrará nuevamente.",
"Copy to clipboard": "Copiar al portapapeles",
"Successfully copied!": "Copiado con éxito!",
"API Token Permissions": "Permisos del Token API",
"API tokens allow third-party services to authenticate with Investbrain on your behalf.": "Los tokens API permiten que servicios de terceros se autentiquen con Investbrain en tu nombre.",
"Delete API Token": "Eliminar Token API",
@@ -116,6 +112,7 @@
"Documentation": "Documentación",
"We're open source!": "¡Somos de código abierto!",
"Toggle Theme": "Cambiar Tema",
"Toggle Sidebar": "Alternar Navegación Lateral",
"Dashboard": "Tablero",
"Gain/Loss": "Ganancia/Pérdida",
+678 -1300
View File
File diff suppressed because it is too large Load Diff
+7 -2
View File
@@ -8,15 +8,20 @@
"devDependencies": {
"@tailwindcss/forms": "^0.5.7",
"@tailwindcss/typography": "^0.5.10",
"@tailwindcss/vite": "^4.1.13",
"autoprefixer": "^10.4.19",
"axios": "^1.7.4",
"daisyui": "^4.12.10",
"daisyui": "^5.1.14",
"laravel-vite-plugin": "^1.0",
"postcss": "^8.4.40",
"tailwindcss": "^3.4.7",
"tailwindcss": "^4.1.13",
"vite": "^5.4"
},
"dependencies": {
"@alpinejs/focus": "^3.15.0",
"@alpinejs/persist": "^3.14.9",
"@alpinejs/resize": "^3.14.9",
"alpinejs": "^3.14.9",
"apexcharts": "^3.51.0"
}
}
-6
View File
@@ -1,6 +0,0 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+18
View File
@@ -0,0 +1,18 @@
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns:svgjs="http://svgjs.dev/svgjs" viewBox="0 0 700 700" width="700" height="700" opacity="0.25">
<defs>
<filter id="nnnoise-filter" x="-20%" y="-20%" width="140%" height="140%" filterUnits="objectBoundingBox"
primitiveUnits="userSpaceOnUse" color-interpolation-filters="linearRGB">
<feTurbulence type="fractalNoise" baseFrequency="0.2" numOctaves="4" seed="15" stitchTiles="stitch" x="0%"
y="0%" width="100%" height="100%" result="turbulence"></feTurbulence>
<feSpecularLighting surfaceScale="5" specularConstant="1" specularExponent="20" lighting-color="#3939A8"
x="0%" y="0%" width="100%" height="100%" in="turbulence" result="specularLighting">
<feDistantLight azimuth="3" elevation="18"></feDistantLight>
</feSpecularLighting>
<feColorMatrix type="saturate" values="0" x="0%" y="0%" width="100%" height="100%" in="specularLighting"
result="colormatrix"></feColorMatrix>
</filter>
</defs>
<rect width="700" height="700" fill="transparent"></rect>
<rect width="700" height="700" class="fill-current" filter="url(#nnnoise-filter)"></rect>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

+123 -5
View File
@@ -1,11 +1,129 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@import url("https://fonts.bunny.net/css?family=Inter:400,500,600&display=swap");
[x-cloak] {
display: none;
@import "tailwindcss";
@plugin "@tailwindcss/typography";
@theme {
--font-sans: "Inter", sans-serif;
}
@plugin "daisyui" {
themes: light, dark --default --prefersdark;
}
@custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *));
@plugin "daisyui/theme" {
name: "dark";
default: true;
prefersdark: true;
color-scheme: dark;
--animation-input: 0.15s;
--radius-selector: 0.15rem;
--radius-field: 0.25rem;
--radius-box: 0.5rem;
--size-selector: 0.25rem;
--size-field: 0.25rem;
--color-primary: "#78716c";
--color-primary-content: "#e3e1e0";
--color-secondary: "#7a7a7a";
--color-secondary-content: "#d1d1d5";
--color-accent: "#8c9ae3";
--color-accent-content: "#d3d4dd";
--color-neutral: "#302f3c";
--color-neutral-content: "#d1d1d5";
--color-base-100: "#20202A";
--color-base-200: "#1a1a23";
--color-base-300: "#15151c";
--color-base-content: "#cecdd0";
--color-info: "#1e40af";
--color-info-content: "#ced9f2";
--color-success: "#166534";
--color-success-content: "#d1dfd3";
--color-warning: "#a16207";
--color-warning-content: "#eddfd1";
--color-error: "#991b1b";
--color-error-content: "#efd3cf";
--depth: 0;
--noise: 0;
}
@plugin "daisyui/theme" {
name: "light";
prefersdark: false;
color-scheme: light;
--animation-input: 0.15s;
--radius-selector: 0.15rem;
--radius-field: 0.25rem;
--radius-box: 0.5rem;
--size-selector: 0.25rem;
--size-field: 0.25rem;
--color-primary: "#d6d3d1";
--color-primary-content: "#101010";
--color-secondary: "#9ca3af";
--color-secondary-content: "#090a0b";
--color-accent: "#525783";
--color-accent-content: "#110c16";
--color-neutral: "#6b7280";
--color-neutral-content: "#e0e1e4";
--color-base-100: "oklch(100% 0 0)";
--color-base-200: "oklch(97.466% 0.011 259.822)";
--color-base-300: "oklch(0.95 0.016 244.89)";
--color-base-content: "#161616";
--color-info: "#60a5fa";
--color-info-content: "#030a15";
--color-success: "#10b981";
--color-success-content: "#000d06";
--color-warning: "#fb923c";
--color-warning-content: "#150801";
--color-error: "#ef4444";
--color-error-content: "#140202";
--depth: 0;
--noise: 0;
}
@source "../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php";
@source "../../storage/framework/views/*.php";
@source "../**/*.blade.php";
/* Tool tip for apex charts */
[data-theme=dark] .apexcharts-tooltip-title {
background: #292933 !important;
border-bottom: 1px solid #393939 !important;
}
[data-theme=dark] .apexcharts-tooltip {
background: #20202A !important;
border-color: #393939 !important;
}
/* Wiggle animation */
@keyframes wiggle {
10%, 90% {
transform: translate3d(-1px, 0, 0);
}
20%, 80% {
transform: translate3d(2px, 0, 0);
}
30%, 50%, 70% {
transform: translate3d(-4px, 0, 0);
}
40%, 60% {
transform: translate3d(4px, 0, 0);
}
}
.wiggle {
animation: wiggle 0.5s cubic-bezier(.36,.07,.19,.97) both;
}
/* AI chat styles */
.ai-chat ul, .ai-chat ol, .ai-chat ol li > ul {
margin-left: 1.1rem;
}
+2
View File
@@ -1,6 +1,8 @@
import ApexCharts from 'apexcharts'
window.ApexCharts = ApexCharts;
import '../../vendor/rappasoft/laravel-livewire-tables/resources/imports/laravel-livewire-tables.js';
import axios from 'axios';
window.axios = axios;
+269 -56
View File
@@ -1,5 +1,195 @@
<div>
<!-- Generate API Token -->
<?php
use App\Traits\Toast;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Validator;
use Livewire\Volt\Component;
new class extends Component
{
use Toast;
/**
* The create API token form state.
*
* @var array
*/
public $createApiTokenForm = [
'name' => '',
'permissions' => [],
];
/**
* Indicates if the plain text token is being displayed to the user.
*
* @var bool
*/
public $displayingToken = false;
/**
* The plain text token value.
*
* @var string|null
*/
public $plainTextToken;
/**
* Indicates if the user is currently managing an API token's permissions.
*
* @var bool
*/
public $managingApiTokenPermissions = false;
/**
* The token that is currently having its permissions managed.
*
* @var \Laravel\Sanctum\PersonalAccessToken|null
*/
public $managingPermissionsFor;
/**
* The update API token form state.
*
* @var array
*/
public $updateApiTokenForm = [
'permissions' => [],
];
/**
* Indicates if the application is confirming if an API token should be deleted.
*
* @var bool
*/
public $confirmingApiTokenDeletion = false;
/**
* The ID of the API token being deleted.
*
* @var int
*/
public $apiTokenIdBeingDeleted;
/**
* Mount the component.
*
* @return void
*/
public function mount()
{
//
}
/**
* Create a new API token.
*
* @return void
*/
public function createApiToken()
{
$this->resetErrorBag();
Validator::make([
'name' => $this->createApiTokenForm['name'],
], [
'name' => ['required', 'string', 'max:255'],
])->validateWithBag('createApiToken');
$this->displayTokenValue($this->user->createToken(
$this->createApiTokenForm['name']
));
$this->createApiTokenForm['name'] = '';
$this->dispatch('created');
}
/**
* Display the token value to the user.
*
* @param \Laravel\Sanctum\NewAccessToken $token
* @return void
*/
protected function displayTokenValue($token)
{
$this->displayingToken = true;
$this->plainTextToken = explode('|', $token->plainTextToken, 2)[1];
}
/**
* Allow the given token's permissions to be managed.
*
* @param int $tokenId
* @return void
*/
public function manageApiTokenPermissions($tokenId)
{
$this->managingApiTokenPermissions = true;
$this->managingPermissionsFor = $this->user->tokens()->where(
'id', $tokenId
)->firstOrFail();
$this->updateApiTokenForm['permissions'] = $this->managingPermissionsFor->abilities;
}
/**
* Update the API token's permissions.
*
* @return void
*/
public function updateApiToken()
{
$this->managingPermissionsFor->forceFill([
'abilities' => [],
])->save();
$this->managingApiTokenPermissions = false;
}
/**
* Confirm that the given API token should be deleted.
*
* @param int $tokenId
* @return void
*/
public function confirmApiTokenDeletion($tokenId)
{
$this->confirmingApiTokenDeletion = true;
$this->apiTokenIdBeingDeleted = $tokenId;
}
/**
* Delete the API token.
*
* @return void
*/
public function deleteApiToken()
{
$this->user->tokens()->where('id', $this->apiTokenIdBeingDeleted)->first()->delete();
$this->user->load('tokens');
$this->confirmingApiTokenDeletion = false;
$this->managingPermissionsFor = null;
}
/**
* Get the current user of the application.
*
* @return mixed
*/
public function getUserProperty()
{
return Auth::user();
}
}; ?>
<div x-data>
{{-- Generate API Token --}}
<x-forms.form-section submit="createApiToken">
<x-slot name="title">
{{ __('Create API Token') }}
@@ -10,13 +200,13 @@
</x-slot>
<x-slot name="form">
<!-- Token Name -->
{{-- Token Name --}}
<div class="col-span-6 sm:col-span-4">
<x-input id="name" label="{{ __('Token Name') }}" type="text" class="mt-1 block w-full" wire:model="createApiTokenForm.name" autofocus />
<x-ui.input id="name" label="{{ __('Token Name') }}" type="text" class="mt-1 block w-full" wire:model="createApiTokenForm.name" autofocus />
</div>
<!-- Token Permissions -->
@if (Laravel\Jetstream\Jetstream::hasPermissions())
{{-- Token Permissions --}}
@if (false)
<div class="col-span-6">
<label class="pt-0 label label-text font-semibold">
<span>
@@ -25,15 +215,63 @@
</span>
<div class="mt-2 grid grid-cols-1 md:grid-cols-2 gap-4">
@foreach (Laravel\Jetstream\Jetstream::$permissions as $label => $permission)
@foreach ([] as $label => $permission)
<label class="flex items-center">
<x-checkbox wire:model="createApiTokenForm.permissions" :value="$permission"/>
<x-ui.checkbox wire:model="createApiTokenForm.permissions" :value="$permission"/>
<span class="ms-2 text-sm text-gray-600 dark:text-gray-400">{{ $label }}</span>
</label>
@endforeach
</div>
</div>
@endif
{{-- Token Value Modal --}}
<x-ui.modal
persistent
key="token-display-modal"
wire:model.live="displayingToken"
title="{{ __('API Token') }}"
>
<div class="mt-2 text-sm text-secondary-content">
<div class="mb-4">
{{ __('Please copy your new API token. For your security, it won\'t be shown again.') }}
</div>
<x-ui.input
x-ref="plaintextToken"
type="text"
readonly
:value="$plainTextToken"
class="font-mono break-all focus:outline-none focus:ring-0"
autocomplete="off"
autocorrect="off"
autocapitalize="off"
spellcheck="false"
>
<x-slot:suffix>
<x-ui.button
title="{{ __('Copy to clipboard') }}"
class="btn-circle btn-sm btn-ghost me-2"
icon="o-clipboard"
@click="
navigator.clipboard.writeText($wire.plainTextToken);
$wire.$set('displayingToken', false);
$wire.success('{{ __('Successfully copied!') }}')
"
/>
</x-slot:suffix>
</x-ui.input>
</div>
<div class="flex flex-row items-center justify-end mt-8 text-end">
<x-ui.button class="btn-outline" wire:click="$set('displayingToken', false)" wire:loading.attr="disabled">
{{ __('Close') }}
</x-ui.button>
</div>
</x-ui.modal>
</x-slot>
<x-slot name="actions">
@@ -41,16 +279,16 @@
{{ __('Created.') }}
</x-forms.action-message>
<x-button type="submit">
<x-ui.button type="submit">
{{ __('Create') }}
</x-button>
</x-ui.button>
</x-slot>
</x-forms.form-section>
@if ($this->user->tokens->isNotEmpty())
<x-section-border hide-on-mobile />
<x-ui.section-border hide-on-mobile />
<!-- Manage API Tokens -->
{{-- Manage API Tokens --}}
<div class="mt-10 sm:mt-0">
<x-forms.action-section>
<x-slot name="title">
@@ -61,12 +299,12 @@
{{ __('You may delete any of your existing tokens if they are no longer needed.') }}
</x-slot>
<!-- API Token List -->
{{-- API Token List --}}
<x-slot name="content">
<div class="space-y-6">
@foreach ($this->user->tokens->sortBy('name') as $token)
<div class="flex items-center justify-between">
<div class="break-all dark:text-white">
<div class="break-all">
{{ $token->name }}
</div>
@@ -77,7 +315,7 @@
</div>
@endif
@if (Laravel\Jetstream\Jetstream::hasPermissions())
@if (false)
<button class="cursor-pointer ms-6 text-sm text-gray-400 underline" wire:click="manageApiTokenPermissions({{ $token->id }})">
{{ __('Permissions') }}
</button>
@@ -95,42 +333,17 @@
</div>
@endif
<!-- Token Value Modal -->
<x-dialog-modal wire:model.live="displayingToken">
<x-slot name="title">
{{ __('API Token') }}
</x-slot>
<x-slot name="content">
<div>
{{ __('Please copy your new API token. For your security, it won\'t be shown again.') }}
</div>
<x-input x-ref="plaintextToken" type="text" readonly :value="$plainTextToken"
class="mt-4 px-4 py-2 rounded font-mono text-sm w-full break-all"
autofocus autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"
@showing-token-modal.window="setTimeout(() => $refs.plaintextToken.select(), 250)"
/>
</x-slot>
<x-slot name="footer">
<x-button class="btn-outline" wire:click="$set('displayingToken', false)" wire:loading.attr="disabled">
{{ __('Close') }}
</x-button>
</x-slot>
</x-dialog-modal>
<!-- API Token Permissions Modal -->
<x-dialog-modal wire:model.live="managingApiTokenPermissions">
{{-- API Token Permissions Modal --}}
<x-ui.dialog-modal key="manage-permission-modal" wire:model.live="managingApiTokenPermissions">
<x-slot name="title">
{{ __('API Token Permissions') }}
</x-slot>
<x-slot name="content">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
@foreach (Laravel\Jetstream\Jetstream::$permissions as $label => $permission)
@foreach ([] as $label => $permission)
<label class="flex items-center">
<x-checkbox wire:model="updateApiTokenForm.permissions" :value="$permission"/>
<x-ui.checkbox wire:model="updateApiTokenForm.permissions" :value="$permission"/>
<span class="ms-2 text-sm text-gray-600 dark:text-gray-400">{{ $label }}</span>
</label>
@endforeach
@@ -138,18 +351,18 @@
</x-slot>
<x-slot name="footer">
<x-button class="btn-outline" wire:click="$set('managingApiTokenPermissions', false)" wire:loading.attr="disabled">
<x-ui.button class="btn-outline" wire:click="$set('managingApiTokenPermissions', false)" wire:loading.attr="disabled">
{{ __('Cancel') }}
</x-button>
</x-ui.button>
<x-button type="submit" class="ms-3" wire:click="updateApiToken" wire:loading.attr="disabled">
<x-ui.button type="submit" class="ms-3" wire:click="updateApiToken" wire:loading.attr="disabled">
{{ __('Save') }}
</x-button>
</x-ui.button>
</x-slot>
</x-dialog-modal>
</x-ui.dialog-modal>
<!-- Delete Token Confirmation Modal -->
<x-confirmation-modal wire:model.live="confirmingApiTokenDeletion">
{{-- Delete Token Confirmation Modal --}}
<x-ui.confirmation-modal key="confirm-deletion-modal" wire:model.live="confirmingApiTokenDeletion">
<x-slot name="title">
{{ __('Delete API Token') }}
</x-slot>
@@ -159,13 +372,13 @@
</x-slot>
<x-slot name="footer">
<x-button class="btn-outline" wire:click="$toggle('confirmingApiTokenDeletion')" wire:loading.attr="disabled">
<x-ui.button class="btn-outline" wire:click="$toggle('confirmingApiTokenDeletion')" wire:loading.attr="disabled">
{{ __('Cancel') }}
</x-button>
</x-ui.button>
<x-button class="ms-3 btn-error text-white" wire:click="deleteApiToken" wire:loading.attr="disabled">
<x-ui.button class="ms-3 btn-error text-white" wire:click="deleteApiToken" wire:loading.attr="disabled">
{{ __('Delete') }}
</x-button>
</x-ui.button>
</x-slot>
</x-confirmation-modal>
</x-ui.confirmation-modal>
</div>
+5 -11
View File
@@ -1,13 +1,7 @@
<x-app-layout>
<x-slot name="header">
<h2 class="font-semibold text-xl text-gray-800 dark:text-gray-200 leading-tight">
{{ __('API Tokens') }}
</h2>
</x-slot>
<x-layouts.app>
<div>
<div class="max-w-7xl mx-auto py-10 sm:px-6 lg:px-8">
@livewire('api.api-token-manager')
</div>
<div class="max-w-7xl mx-auto py-10 sm:px-6 lg:px-8">
@livewire('api-token-manager')
</div>
</x-app-layout>
</x-layouts.app>
@@ -1,8 +1,8 @@
<x-guest-layout>
<x-authentication-card>
<x-layouts.guest>
<x-ui.authentication-card>
<x-slot name="logo">
<div class="w-24 mb-10">
<x-glyph-only-logo />
<x-ui.logo />
</div>
</x-slot>
@@ -10,20 +10,20 @@
{{ __('This is a secure area of the application. Please confirm your password before continuing.') }}
</div>
<x-errors class="mb-4" />
<x-ui.errors class="mb-4" />
<form method="POST" action="{{ route('password.confirm') }}">
@csrf
<div>
<x-input id="password" label="{{ __('Password') }}" class="block mt-1 w-full" type="password" name="password" required autocomplete="current-password" autofocus />
<x-ui.input id="password" label="{{ __('Password') }}" class="block mt-1 w-full" type="password" name="password" required autocomplete="current-password" autofocus />
</div>
<div class="flex justify-end mt-4">
<x-button type="submit" class="btn-primary ms-4">
<x-ui.button type="submit" class="btn-primary ms-4">
{{ __('Confirm') }}
</x-button>
</x-ui.button>
</div>
</form>
</x-authentication-card>
</x-guest-layout>
</x-ui.authentication-card>
</x-layouts.guest>
+11 -11
View File
@@ -1,8 +1,8 @@
<x-guest-layout>
<x-authentication-card>
<x-layouts.guest>
<x-ui.authentication-card>
<x-slot name="logo">
<div class="w-24 mb-10">
<x-glyph-only-logo />
<x-ui.logo />
</div>
</x-slot>
@@ -11,26 +11,26 @@
</div>
@session('status')
<x-alert icon="o-envelope" class="alert-success mb-4">
<x-ui.alert icon="o-envelope" class="alert-success mb-4">
{{ $value }}
</x-alert>
</x-ui.alert>
@endsession
<x-errors class="mb-4" />
<x-ui.errors class="mb-4" />
<form method="POST" action="{{ route('password.email') }}">
@csrf
<div class="block">
<x-input id="email" label="{{ __('Email') }}" class="block mt-1 w-full" type="email" name="email" :value="old('email')" required autofocus autocomplete="username" />
<x-ui.input id="email" label="{{ __('Email') }}" class="block mt-1 w-full" type="email" name="email" :value="old('email')" required autofocus autocomplete="username" />
</div>
<div class="flex items-center justify-end mt-4">
<x-button class="btn-primary" type="submit">
<x-ui.button class="btn-primary" type="submit">
{{ __('Email Password Reset Link') }}
</x-button>
</x-ui.button>
</div>
</form>
</x-authentication-card>
</x-guest-layout>
</x-ui.authentication-card>
</x-layouts.guest>
@@ -6,10 +6,11 @@ use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\Rule;
use Livewire\Volt\Component;
new class extends Component {
new class extends Component
{
// props
public Portfolio $portfolio;
public User $user;
#[Rule('required|string')]
@@ -41,30 +42,29 @@ new class extends Component {
return redirect(route('portfolio.show', ['portfolio' => $this->portfolio->id]));
}
}; ?>
<x-form wire:submit="updateUserInformation" class="">
<div class="mt-2">
<x-input wire:model="name" label="{{ __('Name') }}" class="block mt-1 w-full" required autofocus />
<x-ui.input wire:model="name" label="{{ __('Name') }}" class="block mt-1 w-full" required autofocus />
</div>
<div class="mt-2">
<x-input wire:model="password" label="{{ __('Password') }}" class="block mt-1 w-full" type="password" required autocomplete="new-password" />
<x-ui.input wire:model="password" label="{{ __('Password') }}" class="block mt-1 w-full" type="password" required autocomplete="new-password" />
</div>
<div class="mt-2">
<x-input wire:model="password_confirmation" label="{{ __('Confirm Password') }}" class="block mt-1 w-full" type="password" required autocomplete="new-password" />
<x-ui.input wire:model="password_confirmation" label="{{ __('Confirm Password') }}" class="block mt-1 w-full" type="password" required autocomplete="new-password" />
</div>
<div class="flex items-center justify-end mt-2">
<x-button class="btn-primary" type="submit">
<x-ui.button class="btn-primary" type="submit">
{{ __('Get Started') }}
</x-button>
</x-ui.button>
</div>
</x-form>
@@ -1,8 +1,8 @@
<x-guest-layout>
<x-authentication-card>
<x-layouts.guest>
<x-ui.authentication-card>
<x-slot:logo>
<div class="w-24 mb-10">
<x-glyph-only-logo />
<x-ui.logo />
</div>
</x-slot:logo>
@@ -14,5 +14,5 @@
'user' => $user,
])
</x-authentication-card>
</x-guest-layout>
</x-ui.authentication-card>
</x-layouts.guest>
+17 -17
View File
@@ -1,17 +1,17 @@
<x-guest-layout>
<x-authentication-card>
<x-layouts.guest>
<x-ui.authentication-card>
<x-slot name="logo">
<div class="w-24 mb-10">
<x-glyph-only-logo />
<x-ui.logo />
</div>
</x-slot>
<x-errors class="mb-4" />
<x-ui.errors class="mb-4" />
@session('status')
<x-alert icon="o-envelope" class="alert-success mb-4">
<x-ui.alert icon="o-envelope" class="alert-success mb-4">
{{ $value }}
</x-alert>
</x-ui.alert>
@endsession
<form method="POST" action="{{ route('login') }}">
@@ -19,16 +19,16 @@
<div>
<x-input id="email" label="{{ __('Email') }}" class="block mt-1 w-full" type="email" name="email" :value="old('email')" required autofocus autocomplete="username" />
<x-ui.input id="email" label="{{ __('Email') }}" class="block mt-1 w-full" type="email" name="email" :value="old('email')" required autofocus autocomplete="username" />
</div>
<div class="mt-4">
<x-input id="password" label="{{ __('Password') }}" class="block mt-1 w-full" type="password" name="password" required autocomplete="current-password" />
<x-ui.input id="password" label="{{ __('Password') }}" class="block mt-1 w-full" type="password" name="password" required autocomplete="current-password" />
</div>
<div class="block mt-4">
<x-checkbox id="remember_me" name="remember" class="text-sm" label="{{ __('Remember me') }}" />
<x-ui.checkbox id="remember_me" name="remember" class="text-sm" label="{{ __('Remember me') }}" />
</div>
<div class="flex items-center justify-end mt-4">
@@ -38,26 +38,26 @@
</a>
@endif
<x-button type="submit" class="btn-primary ms-4" >
<x-ui.button type="submit" class="btn-primary ms-4" >
{{ __('Log in') }}
</x-button>
</x-ui.button>
</div>
@if (\Laravel\Fortify\Features::enabled('registration'))
<x-section-border />
<x-ui.section-border />
<x-connected-accounts-login />
<x-social.connected-accounts-login />
<x-button
<x-ui.button
link="{{ route('register') }}"
class="btn-sm btn-block btn-outline btn-secondary my-1"
>
{{ __('Sign up with email') }}
</x-button>
</x-ui.button>
@endif
</form>
</x-authentication-card>
</x-guest-layout>
</x-ui.authentication-card>
</x-layouts.guest>
+13 -13
View File
@@ -1,41 +1,41 @@
<x-guest-layout>
<x-authentication-card>
<x-layouts.guest>
<x-ui.authentication-card>
<x-slot name="logo">
<div class="w-24 mb-10">
<x-glyph-only-logo />
<x-ui.logo />
</div>
</x-slot>
<x-errors class="mb-4" />
<x-ui.errors class="mb-4" />
<form method="POST" action="{{ route('register') }}">
@csrf
<div>
<x-input id="name" label="{{ __('Name') }}" class="block mt-1 w-full" type="text" name="name" :value="old('name')" required autofocus autocomplete="name" />
<x-ui.input id="name" label="{{ __('Name') }}" class="block mt-1 w-full" type="text" name="name" :value="old('name')" required autofocus autocomplete="name" />
</div>
<div class="mt-4">
<x-input id="email" label="{{ __('Email') }}" class="block mt-1 w-full" type="email" name="email" :value="old('email')" required autocomplete="username" />
<x-ui.input id="email" label="{{ __('Email') }}" class="block mt-1 w-full" type="email" name="email" :value="old('email')" required autocomplete="username" />
</div>
<div class="mt-4">
<x-input id="password" label="{{ __('Password') }}" class="block mt-1 w-full" type="password" name="password" required autocomplete="new-password" />
<x-ui.input id="password" label="{{ __('Password') }}" class="block mt-1 w-full" type="password" name="password" required autocomplete="new-password" />
</div>
<div class="mt-4">
<x-input id="password_confirmation" label="{{ __('Confirm Password') }}" class="block mt-1 w-full" type="password" name="password_confirmation" required autocomplete="new-password" />
<x-ui.input id="password_confirmation" label="{{ __('Confirm Password') }}" class="block mt-1 w-full" type="password" name="password_confirmation" required autocomplete="new-password" />
</div>
@if (! config('investbrain.self_hosted'))
<div class="mt-4">
<label>
<div class="flex items-center">
<x-checkbox name="terms" id="terms" required />
<x-ui.checkbox name="terms" id="terms" required />
<div class="ms-2 text-sm">
{!! __('I agree to the :terms_of_service and :privacy_policy', [
@@ -53,10 +53,10 @@
{{ __('Already registered?') }}
</a>
<x-button type="submit" class="btn-primary ms-4">
<x-ui.button type="submit" class="btn-primary ms-4">
{{ __('Register') }}
</x-button>
</x-ui.button>
</div>
</form>
</x-authentication-card>
</x-guest-layout>
</x-ui.authentication-card>
</x-layouts.guest>
+11 -11
View File
@@ -1,12 +1,12 @@
<x-guest-layout>
<x-authentication-card>
<x-layouts.guest>
<x-ui.authentication-card>
<x-slot name="logo">
<div class="w-24 mb-10">
<x-glyph-only-logo />
<x-ui.logo />
</div>
</x-slot>
<x-errors class="mb-4" />
<x-ui.errors class="mb-4" />
<form method="POST" action="{{ route('password.update') }}">
@csrf
@@ -15,24 +15,24 @@
<div class="block">
<x-input id="email" label="{{ __('Email') }}" class="block mt-1 w-full" type="email" name="email" :value="old('email', $request->email)" required autofocus autocomplete="username" />
<x-ui.input id="email" label="{{ __('Email') }}" class="block mt-1 w-full" type="email" name="email" :value="old('email', $request->email)" required autofocus autocomplete="username" />
</div>
<div class="mt-4">
<x-input id="password" label="{{ __('Password') }}" class="block mt-1 w-full" type="password" name="password" required autocomplete="new-password" />
<x-ui.input id="password" label="{{ __('Password') }}" class="block mt-1 w-full" type="password" name="password" required autocomplete="new-password" />
</div>
<div class="mt-4">
<x-input id="password_confirmation" label="{{ __('Confirm Password') }}" class="block mt-1 w-full" type="password" name="password_confirmation" required autocomplete="new-password" />
<x-ui.input id="password_confirmation" label="{{ __('Confirm Password') }}" class="block mt-1 w-full" type="password" name="password_confirmation" required autocomplete="new-password" />
</div>
<div class="flex items-center justify-end mt-4">
<x-button class="btn-primary" type="submit">
<x-ui.button class="btn-primary" type="submit">
{{ __('Reset Password') }}
</x-button>
</x-ui.button>
</div>
</form>
</x-authentication-card>
</x-guest-layout>
</x-ui.authentication-card>
</x-layouts.guest>
@@ -1,8 +1,8 @@
<x-guest-layout>
<x-authentication-card>
<x-layouts.guest>
<x-ui.authentication-card>
<x-slot name="logo">
<div class="w-24 mb-10">
<x-glyph-only-logo />
<x-ui.logo />
</div>
</x-slot>
@@ -15,19 +15,19 @@
{{ __('Please confirm access to your account by entering one of your emergency recovery codes.') }}
</div>
<x-errors class="mb-4" />
<x-ui.errors class="mb-4" />
<form method="POST" action="{{ route('two-factor.login') }}">
@csrf
<div class="mt-4" x-show="! recovery">
<x-input id="code" label="{{ __('Code') }}" class="block mt-1 w-full" type="text" inputmode="numeric" name="code" autofocus x-ref="code" autocomplete="one-time-code" />
<x-ui.input id="code" label="{{ __('Code') }}" class="block mt-1 w-full" type="text" inputmode="numeric" name="code" autofocus x-ref="code" autocomplete="one-time-code" />
</div>
<div class="mt-4" x-cloak x-show="recovery">
<x-input id="recovery_code" label="{{ __('Recovery Code') }}" class="block mt-1 w-full" type="text" name="recovery_code" x-ref="recovery_code" autocomplete="one-time-code" />
<x-ui.input id="recovery_code" label="{{ __('Recovery Code') }}" class="block mt-1 w-full" type="text" name="recovery_code" x-ref="recovery_code" autocomplete="one-time-code" />
</div>
<div class="flex items-center justify-end mt-4">
@@ -50,11 +50,11 @@
{{ __('Use an authentication code') }}
</button>
<x-button type="submit" class="btn-primary ms-4">
<x-ui.button type="submit" class="btn-primary ms-4">
{{ __('Log in') }}
</x-button>
</x-ui.button>
</div>
</form>
</div>
</x-authentication-card>
</x-guest-layout>
</x-ui.authentication-card>
</x-layouts.guest>
+10 -10
View File
@@ -1,8 +1,8 @@
<x-guest-layout>
<x-authentication-card>
<x-layouts.guest>
<x-ui.authentication-card>
<x-slot name="logo">
<div class="w-24 mb-10">
<x-glyph-only-logo />
<x-ui.logo />
</div>
</x-slot>
@@ -11,9 +11,9 @@
</div>
@if (session('status') == 'verification-link-sent')
<x-alert icon="o-envelope" class="alert-success mb-4">
<x-ui.alert icon="o-envelope" class="alert-success mb-4">
{{ __('A new verification link has been sent to the email address you provided in your profile settings.') }}
</x-alert>
</x-ui.alert>
@endif
<div class="mt-4 flex items-center justify-between">
@@ -21,9 +21,9 @@
@csrf
<div>
<x-button type="submit" type="submit" class="btn-primary">
{{ __('Resend Verification Email') }}
</x-button>
<x-ui.button label="{{ __('Resend Verification Email') }}" type="submit" class="bg-primary hover:bg-secondary focus:ring-2 focus:ring-offset-2 focus:ring-neutral-900 focus:shadow-outline focus:outline-none" />
</div>
</form>
@@ -43,5 +43,5 @@
</form>
</div>
</div>
</x-authentication-card>
</x-guest-layout>
</x-ui.authentication-card>
</x-layouts.guest>
File diff suppressed because one or more lines are too long
@@ -1,9 +0,0 @@
<div class="min-h-screen flex flex-col sm:justify-center items-center pt-6 sm:pt-0 bg-gray-100 dark:bg-gray-900">
<div>
{{ $logo }}
</div>
<div class="w-full sm:max-w-md mt-6 px-6 py-4 bg-white dark:bg-gray-800 shadow-md overflow-hidden sm:rounded-lg">
{{ $slot }}
</div>
</div>
@@ -1,17 +0,0 @@
@props(['id' => null, 'maxWidth' => null])
<x-ib-livewire-modal :id="$id" :maxWidth="$maxWidth" {{ $attributes }} :showClose="false">
<div class="p-2">
<div class="text-lg font-medium text-gray-900 dark:text-gray-100">
{{ $title }}
</div>
<div class="mt-4 text-sm text-gray-600 dark:text-gray-400">
{{ $content }}
</div>
</div>
<div class="flex flex-row items-center justify-end mt-3 p-2 text-end">
{{ $footer }}
</div>
</x-ib-livewire-modal>
@@ -5,7 +5,7 @@
</x-forms.section-title>
<div class="mt-5 md:mt-0 md:col-span-2">
<div class="px-4 py-5 sm:p-6 bg-white dark:bg-gray-800 shadow sm:rounded-lg">
<div class="px-4 py-5 sm:p-6 bg-base-100 shadow sm:rounded-lg">
{{ $content }}
</div>
</div>
@@ -15,7 +15,7 @@
</span>
@once
<x-dialog-modal wire:model.live="confirmingPassword">
<x-ui.dialog-modal wire:model.live="confirmingPassword">
<x-slot name="title">
{{ $title }}
</x-slot>
@@ -25,7 +25,7 @@
<div class="mt-4" x-data="{}" x-on:confirming-password.window="setTimeout(() => $refs.confirmable_password.focus(), 250)">
<x-input type="password" class="mt-1 block w-3/4" placeholder="{{ __('Password') }}" autocomplete="current-password"
<x-ui.input type="password" class="mt-1 block w-3/4" placeholder="{{ __('Password') }}" autocomplete="current-password"
x-ref="confirmable_password"
wire:model="confirmablePassword"
wire:keydown.enter="confirmPassword"
@@ -36,13 +36,13 @@
</x-slot>
<x-slot name="footer">
<x-button class="btn-outline" wire:click="stopConfirmingPassword" wire:loading.attr="disabled">
<x-ui.button class="btn-outline" wire:click="stopConfirmingPassword" wire:loading.attr="disabled">
{{ __('Cancel') }}
</x-button>
</x-ui.button>
<x-button type="submit" class="ms-3" dusk="confirm-password-button" wire:click="confirmPassword" wire:loading.attr="disabled">
<x-ui.button type="submit" class="ms-3" dusk="confirm-password-button" wire:click="confirmPassword" wire:loading.attr="disabled">
{{ $button }}
</x-button>
</x-ui.button>
</x-slot>
</x-dialog-modal>
</x-ui.dialog-modal>
@endonce
@@ -8,14 +8,14 @@
<div class="mt-5 md:mt-0 md:col-span-2">
<form wire:submit="{{ $submit }}">
<div class="px-4 py-5 bg-white dark:bg-gray-800 sm:p-6 shadow {{ isset($actions) ? 'sm:rounded-tl-md sm:rounded-tr-md' : 'sm:rounded-md' }}">
<div class="px-4 py-5 bg-base-100 sm:p-6 shadow {{ isset($actions) ? 'sm:rounded-tl-md sm:rounded-tr-md' : 'sm:rounded-md' }}">
<div class="grid grid-cols-6 gap-6">
{{ $form }}
</div>
</div>
@if (isset($actions))
<div class="flex items-center justify-end px-4 py-3 bg-gray-50 dark:bg-gray-800 text-end sm:px-6 shadow sm:rounded-bl-md sm:rounded-br-md">
<div class="flex items-center justify-end px-4 py-3 bg-base-100 text-end sm:px-6 shadow sm:rounded-bl-md sm:rounded-br-md">
{{ $actions }}
</div>
@endif
@@ -1,27 +0,0 @@
@php
if (isset($percent)) {
$isUp = $percent > 0;
} else {
$isUp = $costBasis <= $marketValue;
$percent = $costBasis ? (($marketValue - $costBasis) / $costBasis) * 100 : 0;
}
@endphp
@if(!empty($percent))
<x-badge class="badge-sm {{ $isUp ? 'badge-success' : 'badge-error' }} badge-outline ml-2">
<x-slot:value>
{!! $isUp ? '&#9650;' :'&#9660;' !!}
{{ Number::percentage(
$percent,
$percent < 1 ? 2 : 0
) }}
</x-slot:value>
</x-badge>
@endif
@@ -1,52 +0,0 @@
<a href="/" title="Investbrain">
<svg width="100%" height="100%" id="Layer_1" class="fill-current" data-name="Layer 1" viewBox="0 0 1001 783" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" >
<path {{ $attributes->merge(['class' => 'text-primary']) }} d="M553.875,632.571L567.884,627.131C567.884,627.131 588.94,642.044 611.341,650.542C660.03,669.007 666.181,693.68 670.67,711.697C671.541,715.201 672.368,718.512 673.431,721.293C679.103,736.17 685.326,746.904 694.882,758.003L737.893,737.748C730.866,729.455 721.087,714.1 721.273,693.007C721.419,676.837 731.456,663.936 740.313,652.55C749.261,641.048 756.99,631.115 754.689,619.792C754.428,618.501 750.205,606.681 683.457,589.378C664.971,584.588 632.955,577.931 632.955,577.931C632.955,577.931 635.967,564.803 636.504,564.91C650.287,567.669 668.765,571.64 687.293,576.443C757.295,594.586 767.837,608.754 769.682,617.83C773.076,634.571 762.843,647.722 752.948,660.444C744.551,671.243 736.616,681.44 736.507,693.555C736.275,719.609 754.703,734.781 754.889,734.933L762.945,741.43L691.292,775.182L687.22,770.803C674.012,756.601 666.101,743.826 659.014,725.231C657.68,721.736 656.764,718.071 655.801,714.191C651.633,697.476 641.744,677.506 600.566,661.887C573.409,651.585 553.875,632.571 553.875,632.571Z"/>
<path {{ $attributes->merge(['class' => 'text-primary']) }} d="M469.894,617.03C491.9,625.608 537.785,632.498 578.066,616.912C606.757,605.811 625.69,585.647 634.343,556.967L635.932,544.895L650.678,549.582L650.547,550.556L649.213,560.348C639.567,592.821 617.208,616.664 584.546,629.301C557.593,639.728 525.875,641.415 498.98,637.874C484.116,635.917 475.069,632.909 464.77,628.35L469.894,617.03Z"/>
<path {{ $attributes->merge(['class' => 'text-primary']) }} d="M756.363,647.659C766.597,652.226 735.904,647.812 749.88,647.831C752.018,647.834 754.181,647.777 756.363,647.659ZM756.363,647.659L759.121,630.608C776.312,645.1 814.041,614.388 822.007,607.977C847.271,587.646 859.429,573.432 865.582,531.56L857.892,525.97L871.854,519.291L871.902,520.701L871.646,533.167C868.374,581.138 854.724,595.681 826.924,619.726C805.922,637.888 779.998,646.378 756.363,647.659Z"/>
<path {{ $attributes->merge(['class' => 'text-primary']) }} d="M278.425,160.523C277.934,160.447 277.438,160.37 276.948,160.286C277.44,160.359 277.93,160.439 278.425,160.523Z"/>
<path {{ $attributes->merge(['class' => 'text-primary']) }} d="M56.427,281.384L53.276,278.254C53.915,253.987 64.477,230.809 81.468,211.975C77.818,216.38 74.378,221.228 71.199,226.564C59.438,246.327 55.735,264.917 56.427,281.384ZM125.55,179.848C146.913,170.054 171.349,165.376 196.488,168.027C196.773,169.34 196.992,170.047 196.992,170.047L196.819,170.025C194.125,169.67 160.165,165.575 125.55,179.848ZM876.465,148.788C875.253,140.191 872.604,131.249 868.55,122.317C872.79,131.336 875.507,140.302 876.465,148.788ZM636.968,67.078C632.455,59.767 626.37,52.471 618.591,45.74C627.058,52.458 633.412,59.75 636.968,67.078ZM830.233,73.639C814.235,60.456 794.248,49.212 770.413,41.637C761.569,38.826 752.892,36.944 744.475,35.836C730.805,34.037 717.803,34.272 705.832,35.868C719.737,33.548 733.39,33.475 746.563,35.21C778.577,39.424 807.696,54.337 830.233,73.639Z"/>
<path {{ $attributes->merge(['class' => 'text-primary']) }} d="M546.485,261.415C546.455,261.429 546.424,261.444 546.394,261.458C546.389,261.461 546.389,261.461 546.385,261.46L546.485,261.415ZM546.485,261.415C547.832,260.79 549.176,260.186 550.513,259.608L546.485,261.415Z"/>
<path {{ $attributes->merge(['class' => 'text-primary']) }} d="M951.448,338.201C967.066,346.748 978.94,360.259 984.357,371.461C991.028,385.267 1009.88,464.746 938.38,509.182C874.197,549.074 824.465,524.364 805.506,511.387C784.609,543.131 735.375,571.199 677.333,565.042C620.575,558.985 591.016,530.312 576.286,507.76C561.52,528.554 541.685,537.599 526.615,541.516C511.159,545.534 494.383,545.742 479.342,542.424C490.897,565.908 498.729,604.571 467.806,641.145C445.634,667.37 414.398,675.189 392.099,677.128C370.311,679.025 349.702,675.811 337.482,671.143C324.069,682.794 288.704,704.331 243.88,698.43C233.085,697.009 221.742,694 210.023,688.874C158.439,666.305 147.126,623.344 154.77,594.547C111.783,593.541 77.23,581.082 51.934,557.44C20.86,528.397 4.976,481.26 9.445,431.35C14.204,378.198 55.606,354.896 74.754,346.889C60.508,328.89 30.467,280.339 64.437,223.27C98.829,165.496 163.458,161.805 188.094,162.668C186.255,144.263 189.011,101.243 245.926,69.359C313.249,31.644 373.035,54.386 397.781,70.781C414.353,45.661 452.387,10.329 510.461,7.925C585.849,4.8 622.364,35.827 637.949,55.854C660.603,36.887 713.288,16.387 772.778,35.297C839.209,56.412 875.616,104.13 883.278,143.714C947.161,162.729 985.435,206.49 988.544,264.393C990.859,307.537 971.373,327.501 951.448,338.201ZM929.36,498.056C991.957,459.153 975.932,387.913 970.382,376.422C965.636,366.6 951.481,350.075 931.943,344.8L911.518,339.288L930.807,332.122C953.179,323.814 975.727,309.291 973.33,264.596C971.652,233.374 956.575,177.653 874.439,155.23L869.539,153.891L868.907,149.406C863.81,113.255 830.649,67.872 768.052,47.976C704.505,27.778 653.597,58.332 643.204,71.132L636.222,79.733L630.312,70.164C620.597,54.426 589.582,18.173 511.669,21.398C440.094,24.362 408.415,81.914 407.103,84.363L402.557,92.834L394.883,85.976C379.908,72.608 321.954,42.965 254.369,80.828C189.472,117.183 204.179,167.969 204.337,168.478L207.359,178.33L196.014,176.705C192.739,176.258 115.819,166.267 77.965,229.863C39.999,293.638 91.82,344.913 92.351,345.421L100.424,353.227L89.31,356.328C86.907,357.008 29.915,373.837 24.63,432.853C20.503,478.949 34.782,522.122 62.831,548.336C87.022,570.947 121.342,581.965 164.831,581.071L176.905,580.826L172.295,590.588C161.421,613.62 167.914,655.605 216.409,676.818C274.503,702.221 322.493,666.638 329.535,658.577L333.699,653.813L339.597,657.183C352.785,664.72 419.879,674.98 455.506,632.841C490.702,591.211 468.011,546.744 456.297,533.124L432.423,505.365L466.09,523.416C481.09,531.455 502.99,533.478 521.89,528.564C536.577,524.747 556.747,514.997 569.395,490.245L576.355,476.635L583.311,490.646C593.267,510.709 618.898,545.364 678.656,551.649C737.765,557.918 782.891,523.943 796.055,497.816L800.455,489.081L808.318,496.037C816.887,503.614 862.973,539.314 929.36,498.056Z"/>
<path {{ $attributes->merge(['class' => 'text-primary']) }} d="M164.45,581.093C168.821,580.604 195.946,573.542 211.278,553.997C221.78,540.606 224.635,523.998 219.76,504.639C219.76,504.639 247.605,532.462 221.626,564.658C202.884,586.185 171.934,594.399 165.717,594.554L164.791,581.073C164.703,581.077 164.591,581.078 164.45,581.093ZM164.45,581.093C164.448,581.093 164.447,581.094 164.445,581.094C164.447,581.094 164.448,581.093 164.45,581.093Z"/>
<path {{ $attributes->merge(['class' => 'text-primary']) }} d="M213.575,553.555C222.582,551.317 231.34,551.047 239.325,552.226C255.233,554.572 268.058,562.655 273.619,572.298C273.619,572.298 245.37,554.286 218.136,566.552L213.575,553.555Z"/>
<path {{ $attributes->merge(['class' => 'text-primary']) }} d="M310.41,312.188C362.408,280.533 430.339,286.541 475.605,326.802L464.987,336.16C425.111,300.696 365.213,295.443 319.316,323.38C275.107,350.295 256.595,417.442 280.48,464.26C307.052,516.356 357.867,539.991 394.332,536.42C394.332,536.42 358.776,552.43 324.184,531.648C299.807,517 279.907,495.504 266.63,469.474C239.543,416.376 259.999,342.877 310.41,312.188Z"/>
<path {{ $attributes->merge(['class' => 'text-primary']) }} d="M330.494,520.536C338.379,526.429 342.731,534.458 344.7,543.023C348.021,570.08 326.463,580.957 326.463,580.957C333.323,569.603 338.064,541.076 322.267,529.275L330.494,520.536Z"/>
<path {{ $attributes->merge(['class' => 'text-primary']) }} d="M170.498,451.168C205.064,437.661 243.653,440.477 276.359,458.882L268.492,470.196C240.229,454.292 206.893,451.865 177.027,463.541C153.301,472.81 139.19,488.122 137.509,498.456C137.509,498.456 134.627,468.393 170.498,451.168Z"/>
<path {{ $attributes->merge(['class' => 'text-primary']) }} d="M122.051,418.399C124.266,425.291 153.678,451.572 184.056,447.87L186.251,459.326C180.771,459.993 175.279,459.883 169.892,459.174C162.413,458.189 155.13,456.047 148.357,453.205C119.151,440.422 122.051,418.399 122.051,418.399Z"/>
<path {{ $attributes->merge(['class' => 'text-primary']) }} d="M83.89,343.578C93.152,340.443 119.954,335.902 149.393,342.543C189.622,353.407 200.664,388.305 200.664,388.305C157.717,337.275 90.386,355.973 89.732,356.197L83.89,343.578Z"/>
<path {{ $attributes->merge(['class' => 'text-primary']) }} d="M403.057,462.599C412.109,479.736 430.231,504.115 465.183,522.924L466.14,523.442L458.465,534.863L457.586,534.391C419.225,513.745 399.276,486.879 389.295,467.98C380.508,451.336 378.406,434.828 379.826,419.97C384.383,385.186 414.727,373.954 414.727,373.954C402.573,387.065 383.727,425.985 403.057,462.599Z"/>
<path {{ $attributes->merge(['class' => 'text-primary']) }} d="M322.397,408.686C322.397,408.686 360.323,391.713 392.009,422.14L382.306,429.591C369.382,416.512 339.485,407.613 322.397,408.686Z"/>
<path {{ $attributes->merge(['class' => 'text-primary']) }} d="M690.853,274.028C735.995,311.348 753.013,373.417 740.587,407.967C734.08,426.056 723.599,439.419 712.073,449.263C685.57,471.905 649.16,462.799 649.16,462.799C651.515,462.516 707.46,455.197 726.054,403.494C736.835,373.524 720.838,316.972 680.654,283.744C656.633,263.884 614.846,244.873 552.154,267.086C489.758,289.191 475.391,326.522 474.322,353.951C472.725,395.045 499.768,434.915 514.438,442.054C514.438,442.054 481.579,436.735 467.588,400.693C461.928,385.953 458.458,369.521 459.1,353.048C460.315,321.737 476.396,279.231 546.1,254.536C616.21,229.702 663.52,251.425 690.853,274.028Z"/>
<path {{ $attributes->merge(['class' => 'text-primary']) }} d="M574.041,405.452C577.929,399.868 582.591,394.925 588.017,390.626C622.566,366.135 666.154,382.688 666.154,382.688C627.495,382.845 600.885,392.975 587.077,412.802C566.225,442.734 580.371,485.117 583.223,490.479L569.482,495.902C565.551,488.513 549.669,440.434 574.041,405.452Z"/>
<path {{ $attributes->merge(['class' => 'text-primary']) }} d="M586.976,325.57C586.976,325.57 612.868,328.978 618.672,355.583C621.052,372.019 615.594,386.416 603.3,396.116L594.44,387.377C606.107,378.168 607.013,365.603 605.724,356.686C603.595,342.023 594.518,329.351 586.976,325.57Z"/>
<path {{ $attributes->merge(['class' => 'text-primary']) }} d="M708.183,440.658C738.696,458.976 721.687,492.93 721.687,492.93C725.772,471.083 707.178,453.368 701.816,450.565L708.183,440.658Z"/>
<path {{ $attributes->merge(['class' => 'text-primary']) }} d="M793.227,434.541L805.895,427.418C827.426,456.566 813.55,497.025 809.929,503.784L796.126,497.678C798.539,493.179 810.551,457.997 793.227,434.541Z"/>
<path {{ $attributes->merge(['class' => 'text-primary']) }} d="M843.106,432.778C872.723,432.517 895.844,420.562 906.531,399.983C906.531,399.983 907.929,428.423 880.172,440.043C869.358,444.009 857.092,446.148 843.754,446.269C837.448,446.323 831.171,445.926 825.002,445.114C790.398,440.559 759.118,422.884 743.733,398.136C712.965,348.644 746.651,293.716 782.802,273.093C802.223,262.012 823.22,255.668 842.44,254.262C876.032,253.772 883.4,276.414 883.4,276.414C864.944,262.029 824.51,265.578 791.345,284.501C760.84,301.905 731.064,350.173 757.023,391.933C772.169,416.297 807.583,433.1 843.106,432.778Z"/>
<path {{ $attributes->merge(['class' => 'text-primary']) }} d="M901.023,427.592C919.082,437.647 914.375,459.502 914.375,459.502C914.382,455.074 911.352,449.929 905.845,445.02C898.256,438.251 889.946,435.226 888.088,435.21C888.281,435.213 882.328,424.936 882.328,424.936C886.457,422.572 892.743,423.469 901.023,427.592Z"/>
<path {{ $attributes->merge(['class' => 'text-primary']) }} d="M841.501,320.927C869.016,346.791 923.886,334.774 930.727,332.154L937.161,344.56C930.833,346.984 903.788,353.308 876.093,349.661C844.437,342.756 841.501,320.927 841.501,320.927Z"/>
<path {{ $attributes->merge(['class' => 'text-primary']) }} d="M856.8,381.78C856.8,381.78 854.677,356.846 873.976,346.331C881.356,342.487 889.96,339.571 899.7,338.12L902.276,349.522C873.542,353.801 859.967,372.495 856.8,381.78Z"/>
<path {{ $attributes->merge(['class' => 'text-primary']) }} d="M609.849,376.237C641.704,348.782 649.339,320.22 649.339,320.22C651.29,328.519 647.387,350.181 634.449,367.575C629.624,374.059 623.544,379.948 616.045,384.331L609.849,376.237Z"/>
<path {{ $attributes->merge(['class' => 'text-primary']) }} d="M834.92,234.947C840.865,227.575 848.327,221.775 857.035,217.633C881.755,204.76 908.313,222.518 908.313,222.518C888.901,219.963 862.264,221.245 845.624,241.87L834.92,234.947Z"/>
<path {{ $attributes->merge(['class' => 'text-primary']) }} d="M831.928,192.93C831.928,192.93 855.516,211.969 848.036,239.116C840.815,259.861 823.515,271.361 810.178,275.615L804.608,262.904C806.453,262.317 849.598,247.868 831.928,192.93Z"/>
<path {{ $attributes->merge(['class' => 'text-primary']) }} d="M730.919,99.579C730.919,99.579 763.085,113.201 772.811,151.075C776.7,170.409 776.278,191.455 771.605,214.455C761.626,263.558 702.305,281.445 679.251,280.632L679.359,267.156C693.228,267.644 747.798,255.006 756.611,211.647C765.902,165.932 757.739,130.323 730.919,99.579Z"/>
<path {{ $attributes->merge(['class' => 'text-primary']) }} d="M762.546,160.453C800.164,134.33 864.452,138.777 878.343,142.312L874.583,155.268C864.309,152.659 804.878,148.468 772.188,171.172L762.546,160.453Z"/>
<path {{ $attributes->merge(['class' => 'text-primary']) }} d="M630.258,70.076L643.679,64.083C644.1,64.79 673.076,114.431 662.623,161.021C654.783,197.124 613.774,203.603 613.774,203.603C683.262,160.803 630.799,70.977 630.258,70.076Z"/>
<path {{ $attributes->merge(['class' => 'text-primary']) }} d="M659.485,165.881C663.907,186.681 682.077,207.853 695.622,213.847C695.622,213.847 651.903,202.29 646.659,167.659L659.485,165.881Z"/>
<path {{ $attributes->merge(['class' => 'text-primary']) }} d="M539.576,186.699C541.122,210.599 554.444,240.07 576.078,247.622L570.893,260.15C542.825,250.352 526.242,215.953 524.367,187.035C523.494,173.554 526.287,151.23 536.041,131.967C553.129,100.656 581.582,110.001 581.582,110.001C548.902,118.029 538.098,163.918 539.576,186.699Z"/>
<path {{ $attributes->merge(['class' => 'text-primary']) }} d="M493.292,103.391C493.292,103.391 525.2,108.011 535.526,135.513C538.424,143.235 540.253,152.053 540.204,161.981L527.145,161.553C527.336,122.786 493.632,103.579 493.292,103.391Z"/>
<path {{ $attributes->merge(['class' => 'text-primary']) }} d="M276.948,160.286C277.399,160.303 311.64,161.895 336.502,187.86C344.788,196.516 352.036,207.878 356.639,222.792C374.898,281.945 349.413,306.144 319.144,323.482C309.497,329.013 295.604,330.674 280.619,328.701C269.215,327.2 257.176,323.595 245.876,317.989C231.133,310.674 218.952,300.706 209.701,288.689C189.323,255.458 204.276,225.968 204.276,225.968C204.379,274.859 234.842,297.268 252.991,306.273C276.57,317.97 300.259,318.002 310.581,312.087C335.503,297.81 358.45,279.395 341.951,225.95C326.56,176.089 276.948,160.286 276.948,160.286Z"/>
<path {{ $attributes->merge(['class' => 'text-primary']) }} d="M351.881,137.831C351.881,137.831 334.475,159.829 340.948,195.549L328.057,197.016C320.399,154.765 351.881,137.831 351.881,137.831Z"/>
<path {{ $attributes->merge(['class' => 'text-primary']) }} d="M153.949,295.541C153.949,295.541 171.351,271.232 201.075,276.773C207.935,278.053 215.116,280.367 222.406,284.126L216.118,294.07C184.328,277.698 154.249,295.363 153.949,295.541Z"/>
<path {{ $attributes->merge(['class' => 'text-primary']) }} d="M197.109,163.312C203.351,163.568 225.129,169.866 245.94,182.735C292.963,211.559 273.952,244.742 273.952,244.742C272.306,198.284 202.893,177.207 196.827,176.78L197.109,163.312Z"/>
<path {{ $attributes->merge(['class' => 'text-primary']) }} d="M405.259,76.415C409.176,79.606 443.528,109.023 443.781,158.317C444.017,204.496 406.987,221.615 406.987,221.615C407.205,221.492 428.805,207.829 428.547,157.937C428.324,114.494 398.54,88.97 395.144,86.2L405.259,76.415Z"/>
<path {{ $attributes->merge(['class' => 'text-primary']) }} d="M442.447,165.707C445.65,183.47 472.051,205.798 482.678,207.829C482.678,207.829 438.374,205.199 429.554,167.162L442.447,165.707Z"/>
<path {{ $attributes->merge(['class' => 'text-primary']) }} d="M528.269,208.508L543.276,206.633C543.59,208.225 550.613,245.979 521.528,275.478C495.28,302.101 458.033,314.248 418.869,309.091C417.654,308.932 416.445,308.757 415.226,308.561C378.701,302.768 354.675,288.29 347.06,276.512L360.223,270.105C364.706,277.043 384.206,290.018 417.431,295.288C452.627,300.856 486.324,290.348 509.913,266.423C534.272,241.715 528.331,208.838 528.269,208.508Z"/>
<path {{ $attributes->merge(['class' => 'text-primary']) }} d="M346.454,612.287C346.454,612.287 364.459,630.137 350.49,655.895C346.095,662.684 342.024,666.615 341.627,666.989L330.334,657.619C330.525,657.439 349.385,639.196 346.454,612.287Z"/>
<path {{ $attributes->merge(['class' => 'text-primary']) }} d="M746.169,21.499C756.017,22.795 765.878,25.001 775.478,28.052C846.582,50.652 881.597,100.292 890.828,137.976C955.354,158.86 994.038,204.652 997.236,264.281C999.012,297.296 988.628,322.18 966.359,338.423C979.244,347.991 988.032,359.715 992.343,368.627C996.839,377.939 1002.72,402.821 999.332,429.929C996.187,455.119 983.953,490.419 943.534,515.538C921.02,529.53 898.079,537.216 875.214,538.444C874.524,557.481 866.669,577.821 852.635,596.528C833.067,622.623 803.096,642.513 769.991,651.49C766.899,656.306 763.357,660.861 760.17,664.955C752.191,675.212 745.299,684.072 745.21,693.867C745.015,715.994 760.064,728.832 760.639,729.311L768.69,735.812C770.809,737.518 771.894,740.002 771.599,742.476C771.308,744.951 769.679,747.105 767.227,748.26L695.574,782.012C693.919,782.79 692.07,783.039 690.288,782.805C688.153,782.524 686.117,781.546 684.646,779.964L680.573,775.585C666.603,760.562 658.245,747.077 650.775,727.481C649.287,723.582 648.322,719.717 647.301,715.615C643.246,699.342 639.415,683.975 602.531,669.984C582.529,662.395 566.583,652.959 555.043,641.893C536.815,644.647 517.346,644.794 498.397,642.299C492.15,641.477 486.064,640.365 480.225,638.984C478.528,641.351 476.724,643.657 474.834,645.894C450.844,674.272 417.216,682.715 393.226,684.799C381.934,685.782 370.071,685.527 358.921,684.059C351.917,683.137 345.364,681.766 339.649,680.041C319.088,696.008 283.075,711.345 242.877,706.053C230.564,704.432 218.283,700.97 206.372,695.761C181.07,684.694 162.338,667.791 152.203,646.89C145.306,632.663 142.634,616.673 144.507,601.785C139.806,601.472 135.2,601.017 130.725,600.428C96.286,595.894 67.686,583.183 45.707,562.646C12.903,531.987 -3.897,482.581 0.767,430.49C5.257,380.339 40.511,354.902 62.285,343.855C46.313,320.624 24.457,273.681 56.708,219.503C89.53,164.361 147.812,155.406 179.02,154.885C179.027,132.85 187.435,92.873 241.101,62.81C273.272,44.788 306.984,37.939 341.311,42.462C365.525,45.65 384.1,53.896 395.041,59.951C402.448,50.197 414.871,36.551 433.253,24.573C456.287,9.566 482.031,1.374 509.768,0.225C523.796,-0.356 537.288,0.189 549.873,1.846C595.648,7.874 623.192,27.2 639.177,44.655C661.749,29.219 700.506,15.487 746.169,21.499ZM745.165,29.122C697.38,22.831 657.024,39.885 637.949,55.854C625.287,39.581 598.795,16.044 548.865,9.469C537.348,7.953 524.59,7.339 510.461,7.925C452.387,10.329 414.353,45.661 397.781,70.781C385.798,62.844 365.594,53.414 340.307,50.085C313.364,46.538 280.651,49.905 245.926,69.359C189.011,101.247 186.254,144.267 188.094,162.668C163.458,161.805 98.829,165.496 64.437,223.27C30.466,280.339 60.508,328.89 74.754,346.889C55.606,354.896 14.204,378.198 9.445,431.35C4.976,481.26 20.86,528.397 51.934,557.44C72.538,576.699 99.287,588.534 131.729,592.805C139.117,593.778 146.795,594.358 154.77,594.547C147.126,623.344 158.439,666.305 210.023,688.874C221.738,693.999 233.085,697.009 243.88,698.43C288.704,704.331 324.069,682.794 337.482,671.143C343.311,673.373 351.058,675.269 359.925,676.436C369.639,677.715 380.709,678.122 392.095,677.127C414.398,675.189 445.634,667.37 467.806,641.145C470.947,637.429 473.669,633.693 476.048,629.954C483.187,631.956 491.044,633.576 499.401,634.676C517.749,637.092 538.341,637.065 558.132,633.497C566.971,643.025 581.569,653.745 605.736,662.914C646.914,678.533 651.633,697.476 655.797,714.19C656.764,718.071 657.68,721.736 659.01,725.23C666.101,743.826 674.012,756.601 687.22,770.803L691.292,775.182L762.945,741.43L754.889,734.933C754.703,734.781 736.275,719.609 736.507,693.555C736.616,681.44 744.547,671.243 752.948,660.444C756.865,655.411 760.818,650.309 763.97,644.946C800.832,635.856 828.982,613.927 845.338,592.12C860.499,571.901 867.745,550.109 866.404,530.89C873.884,531.027 881.855,530.414 890.261,528.779C905.055,525.902 921.196,519.861 938.38,509.182C1009.87,464.746 991.028,385.267 984.357,371.461C978.94,360.259 967.066,346.748 951.448,338.201C971.373,327.501 990.859,307.537 988.544,264.393C985.435,206.49 947.161,162.729 883.274,143.713C875.616,104.13 839.209,56.412 772.778,35.297C763.372,32.308 754.136,30.303 745.165,29.122Z"/>
</svg>
<span class="sr-only">Investbrain</span>
</a>
@@ -1,55 +0,0 @@
@props([
'key' => 'modal',
'showClose' => true,
'closeOnEscape' => true,
'title' => null,
'subtitle' => null,
'persistent' => false
])
<div>
@teleport('body')
<dialog
x-data="{ open: false }"
x-on:toggle-{{ $key }}.window="open = !open"
class="relative z-50 w-auto h-auto"
@if($closeOnEscape)
@keydown.window.escape="open = false"
@endif
>
<template x-teleport="body">
<div x-transition.opacity x-show="open" class="fixed top-0 left-0 z-[99] flex items-center justify-center w-full h-full">
<div
@if(!$persistent)
@click="open=false"
@endif
class="absolute inset-0 w-full h-full bg-black bg-opacity-40"
x-show="open"
x-cloak
></div>
<x-card
x-trap.inert.noscroll="open"
:title="$title"
:subtitle="$subtitle"
{{ $attributes->merge(['class' => 'relative transform overflow-hidden rounded-md ext-left shadow-xl w-full sm:w-2/3 lg:w-1/3 m-2 sm:m-0']) }}
x-show="open"
x-cloak
>
@if ($showClose)
<x-button
icon="o-x-mark"
title="{{ __('Close') }}"
class="absolute top-4 right-4 btn-ghost btn-circle btn-sm"
@click="open = false"
/>
@endif
{{ $slot }}
</x-card>
</div>
</template>
</dialog>
@endteleport
</div>
@@ -1,10 +0,0 @@
@props(['title' => ''])
<x-card
{{ $attributes->merge(['class' => 'bg-slate-100 dark:bg-base-200 rounded-lg']) }}
>
<h2 class="text-xl mb-2 flex items-center truncate"> {{ $title }} </h2>
{{ $slot }}
</x-card>
@@ -1,47 +0,0 @@
@props([
'key' => 'drawer',
'showClose' => true,
'closeOnEscape' => true,
'title' => null,
'subtitle' => null
])
<div
x-data="{ open: false }"
x-on:toggle-{{ $key }}.window="open = !open"
x-show="open"
@if($closeOnEscape)
@keydown.window.escape="open = false"
@endif
x-trap="open"
x-bind:inert="!open"
class="fixed inset-0 flex justify-end z-50"
x-transition.opacity
x-cloak
>
<div @click="open = false" class="fixed inset-0 bg-black opacity-50"></div>
<x-card
{{ $attributes->merge(['class' => 'min-h-screen w-full md:w-3/4 xl:w-3/5 rounded-none px-8 transition overflow-y-scroll']) }}
>
@if($title)
<x-slot:title>
{!! strip_tags($title) !!}
</x-slot:title>
@endif
@if($subtitle)
<x-slot:subtitle>
{!! strip_tags($subtitle) !!}
</x-slot:subtitle>
@endif
@if ($showClose)
<x-button icon="o-x-mark" title="{{ __('Close') }}" class="btn-ghost btn-circle btn-sm absolute top-4 right-4 " @click="open = false" />
@endif
{{ $slot }}
</x-card>
</div>
@@ -1,51 +0,0 @@
@props([
'showClose' => true,
'closeOnEscape' => true,
'title' => null,
'subtitle' => null,
'persistent' => false
])
<div>
@teleport('body')
<dialog
{{ $attributes->except('wire:model')->class(["modal"]) }}
x-data="{open: @entangle($attributes->wire('model')).live }"
:class="{'modal-open !animate-none': open}"
:open="open"
@if($closeOnEscape)
@keydown.escape.window = "$wire.{{ $attributes->wire('model')->value() }} = false"
@endif
>
<x-card
:title="$title"
:subtitle="$subtitle"
{{ $attributes->merge(['class' => 'modal-box relative transform overflow-hidden rounded-md ext-left shadow-xl w-full sm:w-2/3 lg:w-1/3 m-2 sm:m-0']) }}
>
@if ($showClose)
<x-button
icon="o-x-mark"
title="{{ __('Close') }}"
class="absolute top-4 right-4 btn-ghost btn-circle btn-sm"
@click="$wire.{{ $attributes->wire('model')->value() }} = false"
/>
@endif
{{ $slot }}
</x-card>
<div class="modal-backdrop" method="dialog">
<a
@if(!$persistent)
@click="$wire.{{ $attributes->wire('model')->value() }} = false"
@endif
type="button"
title="{{ __('Close') }}"
>
{{ __('Close') }}
</a>
</div>
</dialog>
@endteleport
</div>
@@ -1,9 +0,0 @@
<div role="status" class="flex w-full animate-pulse" wire:loading.delay>
<div class="h-2.5 bg-gray-200 rounded-full dark:bg-gray-700 w-48 mb-4"></div>
<div class="h-2 bg-gray-200 rounded-full dark:bg-gray-700 max-w-[360px] mb-2.5"></div>
<div class="h-2 bg-gray-200 rounded-full dark:bg-gray-700 mb-2.5"></div>
<div class="h-2 bg-gray-200 rounded-full dark:bg-gray-700 max-w-[330px] mb-2.5"></div>
<div class="h-2 bg-gray-200 rounded-full dark:bg-gray-700 max-w-[300px] mb-2.5"></div>
<div class="h-2 bg-gray-200 rounded-full dark:bg-gray-700 max-w-[360px]"></div>
<span class="sr-only">Loading...</span>
</div>
@@ -0,0 +1,28 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="bg-base-200">
<head>
@include('components.partials.head')
</head>
<body class="font-sans antialiased scroll-smooth" x-data="{ sideBarOpen: false }">
@livewire('partials.nav-bar')
@livewire('partials.side-bar')
<main class="py-5 px-6 md:px-8 md:py-0 md:ml-68 mb-14">
{{ $slot }}
</main>
@if(session('toast'))
<script lang="text/javascript">
window.addEventListener('DOMContentLoaded', function () {
window.toast(JSON.parse(@json(session('toast'))))
});
</script>
@endif
<x-ui.toast />
@livewireScripts
</body>
</html>
@@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
@include('components.partials.head')
</head>
<body class="font-sans antialiased scroll-smooth min-h-screen" x-data="{}">
<main class="">
<x-ui.theme-selector hidden="true" />
{{ $slot }}
</main>
@livewireScripts
</body>
</html>
@@ -0,0 +1,10 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}">
<link rel="icon" href="{{ asset('favicon.svg') }}">
<title>{{ config('app.name', 'Investbrain') }}</title>
@vite(['resources/css/app.css', 'resources/js/app.js'])
@livewireStyles
@@ -1,64 +0,0 @@
@props([
'sidebar' => null,
'content' => null,
'footer' => null,
'fullWidth' => false,
'withNav' => false,
'collapseText' => 'Collapse',
'collapseIcon' => 'o-bars-3-bottom-right',
'collapsible' => false,
'url' => route('mary.toogle-sidebar', absolute: false),
])
<main class="{{ !$fullWidth ? 'max-w-screen-2xl' : '' }} w-full mx-auto">
<div class="drawer {{ $sidebar?->attributes['right'] ? 'drawer-end' : '' }} lg:drawer-open">
<input id="{{ $sidebar?->attributes['drawer'] }}" type="checkbox" class="drawer-toggle" />
<div {{ $content->attributes->class(["drawer-content w-full mx-auto p-5 lg:px-10 lg:py-5"]) }}>
{{-- MAIN CONTENT --}}
{{ $content }}
</div>
{{-- SIDEBAR --}}
@if($sidebar)
<div
x-data="{
collapsed: {{ session('mary-sidebar-collapsed', 'false') }},
collapseText: '{{ $collapseText }}',
toggle() {
this.collapsed = !this.collapsed;
fetch('{{ $url }}?collapsed=' + this.collapsed);
this.$dispatch('sidebar-toggled', this.collapsed);
}
}"
@menu-sub-clicked="if(collapsed) { toggle() }"
@class(["drawer-side z-20 lg:z-auto", "top-0 lg:top-[73px] lg:h-[calc(100vh-73px)]" => $withNav])
>
<label for="{{ $sidebar?->attributes['drawer'] }}" aria-label="close sidebar" class="drawer-overlay"></label>
{{-- SIDEBAR CONTENT --}}
<div>
{{ $sidebar }}
{{-- SIDEBAR COLLAPSE --}}
@if($sidebar->attributes['collapsible'])
<x-mary-menu class="hidden !bg-inherit lg:block">
<x-mary-menu-item
@click="toggle"
icon="{{ $sidebar->attributes['collapse-icon'] ?? $collapseIcon }}"
title="{{ $sidebar->attributes['collapse-text'] ?? $collapseText }}" />
</x-mary-menu>
@endif
</div>
</div>
@endif
{{-- END SIDEBAR--}}
</div>
</main>
{{-- FOOTER --}}
@if($footer)
<footer {{ $footer?->attributes->class(["mx-auto w-full", "max-w-screen-2xl" => !$fullWidth ]) }}>
{{ $footer }}
</footer>
@endif
@@ -18,34 +18,31 @@ new class extends Component
// methods
}; ?>
<div class="bg-base-100 border-base-300 border-b sticky top-0 z-10">
<div class="flex justify-between items-center px-7 py-3 gap-4 mx-auto">
<div class="flex flex-0 items-center">
<label for="main-drawer" class="lg:hidden mr-3">
<x-icon name="o-bars-3" class="cursor-pointer" />
</label>
<div class="hidden md:block" style="height:2.5em">
<x-application-logo />
</div>
<nav class="z-10 p-5 ml-0 md:ml-68 md:border-0 border-b border-zinc-200 dark:border-zinc-800">
<div class="flex flex-wrap justify-between items-center">
</div>
<div class="flex flex-1 justify-center" x-data>
<x-spotlight
shortcut="slash"
search-text="{{ __('Search holdings, portfolios, or anything else...') }}"
no-results-text="{{ __('Darn! Nothing found for that search.') }}"
<div class="flex">
<x-ui.button
aria-controls="drawer-navigation"
title="{{ __('Toggle Sidebar') }}"
class="btn-circle btn-ghost btn-sm block md:hidden"
icon="o-bars-3"
@click="sideBarOpen = true"
/>
<x-button
@click.stop="$dispatch('mary-search-open')"
class="btn-sm flex-1 justify-start md:flex-none"
<div class="ml-3 w-8 hidden sm:block md:hidden"> <x-ui.logo /> </div>
</div>
<div>
<x-ui.button
@click.stop="$dispatch('toggle-spotlight')"
class="btn-sm btn-ghost bg-base-300 flex-1 justify-start md:flex-none border-none"
>
<x-slot:label>
<span class="flex items-center text-gray-400">
<x-icon name="o-magnifying-glass" class="mr-2" />
<x-ui.icon name="o-magnifying-glass" class="mr-2" />
<span class=" truncate hidden sm:block">
@lang('Click or press :key to search', ['key' => '<kbd class="kbd kbd-sm">/</kbd>'])
</span>
@@ -54,35 +51,41 @@ new class extends Component
</span>
</span>
</x-slot:label>
</x-button>
</x-ui.button>
<x-ui.spotlight
search-text="{{ __('Search holdings, portfolios, or anything else...') }}"
no-results-text="{{ __('Darn! Nothing found for that search.') }}"
/>
</div>
<div class="flex flex-0 items-center gap-4">
<x-button
<x-ui.button
title="{{ __('Documentation') }}"
icon="o-book-open"
class="btn-circle btn-ghost btn-sm"
link="https://github.com/investbrainapp/investbrain"
external
>
</x-button>
</x-ui.button>
<x-button
<x-ui.button
title="{{ __('We\'re open source!') }}"
class="btn-circle btn-ghost btn-sm"
link="https://github.com/investbrainapp/investbrain"
external
>
<x-github-icon />
</x-button>
<x-social.github-icon />
</x-ui.button>
<x-theme-toggle
<x-ui.theme-selector
id="theme-selector"
title="{{ __('Toggle Theme') }}"
class="btn-circle btn-ghost btn-sm"
darkTheme="business"
lightTheme="corporate"
/>
</div>
</div>
</div>
</nav>
@@ -19,76 +19,97 @@ new class extends Component
}; ?>
<div class="
flex
flex-col
!transition-all
!duration-100
ease-out
overflow-x-hidden
overflow-y-auto
h-screen
lg:h-[calc(100vh-73px)]
bg-base-100
lg:bg-inherit
{{ session('mary-sidebar-collapsed') == 'true' ? 'w-[70px] [&>*_summary::after]:hidden [&_.mary-hideable]:hidden [&_.display-when-collapsed]:block [&_.hidden-when-collapsed]:hidden' : null }}
{{ session('mary-sidebar-collapsed') != 'true' ? 'w-[270px] [&>*_summary::after]:block [&_.mary-hideable]:block [&_.hidden-when-collapsed]:block [&_.display-when-collapsed]:hidden' : null }}
">
<div class="flex-1">
<x-menu activate-by-route>
<div
aria-label="Sidebar"
style="background-image: url('{{ asset('images/noise.svg') }}')"
class="
h-full
bg-base-300
border-r
border-base-100
fixed
top-0
left-0
z-50
md:w-68
w-3/4
transition-transform
-translate-x-full
md:translate-x-0
"
:class="{'translate-x-0': sideBarOpen, '-translate-x-full': !sideBarOpen}"
x-data="{
responsiveSidebar() {
if (window.innerWidth >= 768) {
this.sideBarOpen = true
return;
}
this.sideBarOpen = false
}
}"
@resize.window="responsiveSidebar"
@keyup.escape.window="sideBarOpen = false"
>
<template x-teleport="body">
<div
aria-label="Overlay"
class="block md:hidden z-10 fixed w-screen h-screen inset-0 bg-black/20 backdrop-blur-sm"
x-on:click="sideBarOpen=false"
x-show="sideBarOpen"
x-cloak
></div>
</template>
<div class="h-full px-1 overflow-y-auto flex flex-col ">
<x-menu-item title="{{ __('Dashboard') }}" icon="o-home" link="{{ route('dashboard') }}" />
<x-menu-sub title="{{ __('Portfolios') }}" icon="o-document-duplicate">
@foreach (auth()->user()->portfolios as $portfolio)
<x-menu-item icon="o-document" link="{{ route('portfolio.show', ['portfolio' => $portfolio->id ]) }}" >
<x-slot:title>
{{ $portfolio->title }}
@if($portfolio->wishlist)
<x-badge value="{{ __('Wishlist') }}" class="badge-secondary badge-sm ml-2" />
@endif
</x-slot:title>
</x-menu-item>
@endforeach
<div class="w-10 m-5"> <x-ui.logo /> </div>
<x-menu-item title="{{ __('Create Portfolio') }}" icon="o-document-plus" link="{{ route('portfolio.create') }}" />
</x-menu-sub>
<x-menu-item title="{{ __('Transactions') }}" icon="o-banknotes" link="{{ route('transaction.index') }}" />
{{-- <x-menu-item title="{{ __('Reporting') }}" icon="o-chart-bar-square" link="####" /> --}}
<x-ui.menu class="space-y-2 text-wrap w-full overflow-x-hidden" activate-by-route="true">
<x-ui.menu-item icon="o-home" title="{{ __('Dashboard') }}" link="{{ route('dashboard') }}" class="font-medium text-md" />
</x-menu>
@foreach (auth()->user()->portfolios as $portfolio)
<x-ui.menu-item
:title="$portfolio->title"
icon="o-document"
:badge="$portfolio->wishlist ? __('Wishlist') : null"
badge-classes="badge-secondary badge-outline"
link="{{ route('portfolio.show', ['portfolio' => $portfolio->id ]) }}"
class="font-medium text-md"
/>
@endforeach
<x-ui.menu-item icon="o-document-plus" title="{{ __('Create Portfolio') }}" link="{{ route('portfolio.create') }}" class="font-medium text-md" />
</div>
<div class="px-3">
<x-section-border />
<x-ui.menu-item icon="o-banknotes" title="{{ __('Transactions') }}" link="{{ route('transaction.index') }}" class="font-medium text-md" />
</x-ui.menu>
<div class="flex-1"></div>
@php
$user = auth()->user();
@endphp
<x-list-item :item="$user" avatar="profile_photo_url" value="name" sub-value="email" no-separator no-hover class="mb-3 !-mt-3 rounded">
<x-ui.list-item :item="$user" avatar="profile_photo_url" value="name" sub-value="email" no-separator no-hover class="rounded mb-2">
<x-slot:actions>
<x-dropdown>
<x-ui.dropdown>
<x-slot:trigger>
<x-button icon="o-cog-6-tooth" class="btn-circle btn-ghost btn-xs" />
<x-ui.button icon="o-cog-6-tooth" class="btn-circle btn-ghost btn-sm relative transition-transform focus:rotate-90" />
</x-slot:trigger>
<x-menu-item title="{{ __('Manage Profile') }}" icon="o-user" link="{{ @route('profile.show') }}" />
<x-menu-item title="{{ __('API Tokens') }}" icon="o-command-line" link="{{ @route('api-tokens.index') }}" />
<x-menu-item title="{{ __('Import / Export Data') }}" icon="o-cloud-arrow-down" link="{{ @route('import-export') }}" />
<x-ui.menu-item title="{{ __('Manage Profile') }}" icon="o-user" link="{{ @route('profile.show') }}" />
<x-ui.menu-item title="{{ __('API Tokens') }}" icon="o-command-line" link="{{ @route('api-tokens.index') }}" />
<x-ui.menu-item title="{{ __('Import / Export Data') }}" icon="o-cloud-arrow-down" link="{{ @route('import-export') }}" />
<x-section-border class="py-1" />
<x-ui.section-border class="py-1" />
<x-menu-item title="{{ __('Log Out') }}" icon="o-power" onclick="event.preventDefault(); document.getElementById('logout').submit();" />
<x-ui.menu-item title="{{ __('Log Out') }}" icon="o-power" onclick="event.preventDefault(); document.getElementById('logout').submit();" />
<form id="logout" action="{{ route('logout') }}" method="POST" style="display: none;">
{{ csrf_field() }}
@csrf
</form>
</x-dropdown>
</x-ui.dropdown>
</x-slot:actions>
</x-list-item>
</div>
</x-ui.list-item>
</div>
</div>
@@ -1,16 +1,16 @@
<div>
@if(!empty(config('services.enabled_login_providers')))
@foreach(explode(',', config('services.enabled_login_providers')) as $provider)
<x-button
<x-ui.button
link="{{ route('oauth.redirect', ['provider' => $provider]) }}"
class="btn-sm btn-block my-1"
class="btn-sm btn-block my-1 text-white"
style='background-color: {{ config("services.$provider.color") }}'
no-wire-navigate
>
@include("components.$provider-icon")
@include("components.social.$provider-icon")
{{ __('Login with') }} {{ config("services.$provider.name") }}
</x-button>
</x-ui.button>
@endforeach
@endif
</div>

Before

Width:  |  Height:  |  Size: 472 B

After

Width:  |  Height:  |  Size: 472 B

Before

Width:  |  Height:  |  Size: 871 B

After

Width:  |  Height:  |  Size: 871 B

Before

Width:  |  Height:  |  Size: 582 B

After

Width:  |  Height:  |  Size: 582 B

Before

Width:  |  Height:  |  Size: 676 B

After

Width:  |  Height:  |  Size: 676 B

@@ -1,27 +1,26 @@
<?php
use Mary\Traits\Toast;
use App\Models\AiChat;
use App\Models\Holding;
use Illuminate\Database\Eloquent\Model;
use Livewire\Volt\Component;
use OpenAI\Factory;
use OpenAI\Responses\StreamResponse;
new class extends Component {
use Toast;
new class extends Component
{
// props
public Model $chatable;
public string $system_prompt = 'You are an investment portfolio assistant providing advice to an investor. Use the following information to provide relevant recommendations. Use the words \'likely\' or \'may\' instead of concrete statements (except for obvious statements of fact or common sense). Use github style markdown for any formatting.';
public array $suggested_prompts = [];
public array $messages = [];
public ?string $prompt = null;
public ?string $answer = null;
public bool $streaming = false;
// methods
public function mount()
{
@@ -33,10 +32,11 @@ new class extends Component {
// prevent spam
if ($this->isRateLimited() || $this->streaming) {
array_push($this->messages, [
'role' => 'assistant',
'content' => __('Hang on! You\'re doing that too much.')
'role' => 'assistant',
'content' => __('Hang on! You\'re doing that too much.'),
]);
$this->js('scrollChatWindow(250)');
return;
}
@@ -51,7 +51,7 @@ new class extends Component {
$this->js('scrollChatWindow(250)');
return;
}
}
$this->chatable->chats()->save(new AiChat(['role' => 'user', 'content' => $this->prompt]));
array_push($this->messages, ['role' => 'user', 'content' => $this->prompt]);
@@ -65,17 +65,17 @@ new class extends Component {
public function generateCompletion(): void
{
try {
$client = $this->createOpenAiClient();
$stream = $client->chat()->createStreamed([
'model' => config('openai.model'),
'messages' => [
['role' => 'system', 'content' => "Today's date is "
.now()->toDateString()
.".\n\n".$this->system_prompt],
...array_slice($this->messages, -10)
...array_slice($this->messages, -10),
],
]);
} catch (\Exception $e) {
@@ -83,14 +83,15 @@ new class extends Component {
$this->chatable->chats()->save(new AiChat(['role' => 'assistant', 'content' => $e->getMessage()]));
array_push($this->messages, ['role' => 'assistant', 'content' => $e->getMessage()]);
$this->resetPrompt();
return;
}
$this->stream(to: "answer", content: '', replace: true);
foreach($stream as $response){
if(!empty($response->choices[0]->delta->content)) {
$this->stream(to: 'answer', content: '', replace: true);
foreach ($stream as $response) {
if (! empty($response->choices[0]->delta->content)) {
$this->stream(to: 'answer', content: $response->choices[0]->delta->content, replace: false);
$this->answer .= $response->choices[0]->delta->content;
}
@@ -116,34 +117,34 @@ new class extends Component {
'name' => 'suggested_prompts_schema',
'strict' => true,
'schema' => [
"type" => "object",
"properties" => [
"suggested_prompts" => [
"type" => "array",
"items" => [
"type" => "object",
"properties" => [
"text" => [
"type" => "string",
"description" => "The suggested prompt question (no more than 5 words)"
'type' => 'object',
'properties' => [
'suggested_prompts' => [
'type' => 'array',
'items' => [
'type' => 'object',
'properties' => [
'text' => [
'type' => 'string',
'description' => 'The suggested prompt question (no more than 5 words)',
],
'value' => [
'type' => 'string',
'description' => 'The detailed version of the question',
],
"value" => [
"type" => "string",
"description" => "The detailed version of the question"
]
],
"required" => ["text", "value"],
"additionalProperties" => false
]
]
'required' => ['text', 'value'],
'additionalProperties' => false,
],
],
],
"required" => ["suggested_prompts"],
"additionalProperties" => false
]
]
'required' => ['suggested_prompts'],
'additionalProperties' => false,
],
],
],
'messages' => [
['role' => 'system', 'content' => "
['role' => 'system', 'content' => '
Your role is to assist investors in asking thoughtful questions of their investment advisors.
When you help investors ask good questions, you should ensure the you questions you recommend
@@ -155,12 +156,12 @@ new class extends Component {
explanation.
Your response should only include valid JSON.
"],
'],
['role' => 'user', 'content' => "
Generate between 1 and 5 (no more than 5) follow up questions a savvy investor might ask their
advisor based on the following conversation:
\n\n
".json_encode(array_slice($this->messages, -4))
".json_encode(array_slice($this->messages, -4)),
],
],
]);
@@ -171,6 +172,7 @@ new class extends Component {
$this->suggested_prompts = [];
$this->error($e->getMessage());
return;
}
}
@@ -184,10 +186,10 @@ new class extends Component {
public function isRateLimited(): bool
{
$rateLimitKey = auth()->id() . '/' . $this->chatable->id;
$rateLimitKey = auth()->id().'/'.$this->chatable->id;
if (RateLimiter::tooManyAttempts($rateLimitKey, 20)) {
return true;
}
@@ -210,7 +212,6 @@ new class extends Component {
->withBaseUri($baseUri)
->make();
}
}; ?>
<div
@@ -227,15 +228,16 @@ new class extends Component {
class="fixed z-50 bottom-8 right-8"
>
{{-- toggle button --}}
<x-button
<x-ui.button
x-show="!open"
@click="$dispatch('toggle-ai-chat')"
@keyup.escape.window="open = false"
class="flex btn btn-circle md:btn-lg btn-primary"
>
<x-slot:label>
<x-icon name="o-sparkles" class="w-6 h-6 md:w-8 md:h-8"></x-icon>
<x-ui.icon name="o-sparkles" class="w-6 h-6 md:w-8 md:h-8"></x-ui.icon>
</x-slot:label>
</x-button>
</x-ui.button>
{{-- popup --}}
<div
@@ -251,17 +253,17 @@ new class extends Component {
x-transition:leave-end="opacity-0 transform translate-y-full"
x-cloak
key="ai-chat"
class="fixed bg-base-100 shadow-2xl rounded-none md:rounded-lg
inset-0 h-screen w-full
md:inset-auto md:right-6 md:bottom-6 md:w-[32rem] md:h-[46rem]"
class="fixed bg-base-300 shadow-2xl rounded-none md:rounded-lg
inset-0 h-screen w-full md:inset-auto md:right-6
md:bottom-6 md:w-[32rem] md:h-[46rem]"
>
<div
class="absolute inset-0 flex flex-col overflow-hidden p-4"
x-intersect="scrollChatWindow()"
>
<div class="flex grow-0 justify-between items-center pb-4 ">
<h2 class="text-lg text-bold">{{ __('AI Chat') }}</h2>
<x-button
<h2 class="text-lg text-bold select-none">{{ __('AI Chat') }}</h2>
<x-ui.button
icon="o-x-mark"
class="absolute top-5 right-4 btn-ghost btn-circle btn-sm"
title="{{ __('Close') }}"
@@ -284,7 +286,7 @@ new class extends Component {
bg-slate-200
dark:bg-slate-800
">
<x-icon name="o-sparkles" class="h-auto p-1 w-10" />
<x-ui.icon name="o-sparkles" class="h-auto p-1 w-10" />
</span>
<p class="leading-relaxed w-full">
<span class="block font-bold">AI</span> {{ __('Hi, how can I help?') }}
@@ -298,7 +300,7 @@ new class extends Component {
<div class="flex gap-3 mb-5 flex-1">
<span class="relative flex shrink-0 overflow-hidden rounded-full w-10 h-10">
<x-avatar :image="auth()->user()->profile_photo_url" class="!w-10" />
<x-ui.avatar :image="auth()->user()->profile_photo_url" class="!w-10" />
</span>
<p class="leading-relaxed">
@@ -319,7 +321,7 @@ new class extends Component {
bg-slate-200
dark:bg-slate-800
">
<x-icon name="o-sparkles" class="h-auto p-1 w-10" />
<x-ui.icon name="o-sparkles" class="h-auto p-1 w-10" />
</span>
<div class="leading-relaxed" >
<span class="block font-bold ">AI </span> {!! Str::markdown($message['content']) !!}
@@ -342,7 +344,7 @@ new class extends Component {
bg-slate-200
dark:bg-slate-800
">
<x-icon name="o-sparkles" class="h-auto p-1 w-10" />
<x-ui.icon name="o-sparkles" class="h-auto p-1 w-10" />
</span>
<p class="leading-relaxed" >
<span class="block font-bold ">AI </span> <span wire:stream="answer">{{ $answer }}</span>
@@ -353,40 +355,42 @@ new class extends Component {
{{-- prompt input --}}
<div class="mt-3 grow-0">
<form submit="startCompletion" >
<form submit="startCompletion">
<div class="">
@foreach($suggested_prompts as $prompt)
<x-button
<x-ui.button
class="btn-xs btn-primary btn-outline mr-1 mb-2"
wire:click="startCompletion('{{ addslashes($prompt['value']) }}')"
>{{ $prompt['text'] }}</x-button>
>{{ $prompt['text'] }}</x-ui.button>
@endforeach
</div>
<div class="flex justify-between align-bottom space-x-2 mt-1">
<div class="w-full">
<div class="w-full" >
<x-textarea
<x-ui.textarea
wire:model="prompt"
class="h-24 resize-none "
class="h-18 resize-none bg-base-200"
placeholder="{{ __('Have a question? AI might be able to help...') }}"
wire:keydown.enter.prevent="startCompletion"
autofocus
></x-textarea>
@toggle-ai-chat.window="setTimeout(() => $el.focus(), 250)"
></x-ui.textarea>
{{-- --}}
</div>
<x-button
<x-ui.button
spinner="generateCompletion"
wire:click="startCompletion"
class="btn btn-ghost h-24"
class="btn btn-ghost h-32"
icon="o-paper-airplane"
></x-button>
></x-ui.button>
</div>
<div class="w-full mt-2">
<p class="text-xs text-secondary leading-tight">{{ __('Advice generated by AI may contain errors. Use at your own risk. Always consult a licensed investment advisor.') }} </p>
<p class="text-xs text-secondary leading-tight select-none">{{ __('Advice generated by AI may contain errors. Use at your own risk. Always consult a licensed investment advisor.') }} </p>
</div>
</form>
</div>
@@ -0,0 +1,36 @@
@props([
'id' => null,
'title' => null
'icon' => null,
'description' => null,
'shadow' => false,
'dismissable' => false
])
<div
wire:key="{{ $id }}"
{{ $attributes->whereDoesntStartWith('class') }}
{{ $attributes->class(['alert rounded-md', 'shadow-md' => $shadow])}}
x-data="{ show: true }" x-show="show"
>
@if($icon)
<x-icon :name="$icon" class="self-center" />
@endif
@if($title)
<div>
<div @class(["font-bold" => $description])>{{ $title }}</div>
<div class="text-xs">{{ $description }}</div>
</div>
@else
<span>{{ $slot }}</span>
@endif
<div class="flex items-center gap-3">
{{ $actions }}
</div>
@if($dismissible)
<x-button icon="o-x-mark" @click="show = false" class="btn-xs btn-circle btn-ghost static self-start end-0" />
@endif
</div>
@@ -0,0 +1,9 @@
<div class="min-h-screen flex flex-col sm:justify-center items-center pt-6 sm:pt-0">
<div>
{{ $logo }}
</div>
<div class="w-full sm:max-w-md mt-6 px-6 py-4 bg-base-200 shadow-md overflow-hidden sm:rounded-lg">
{{ $slot }}
</div>
</div>
@@ -0,0 +1,36 @@
@props([
'id' => null,
'image' => '',
'alt' => '',
'placeholder' => '',
'fallbackImage' => null,
'title' => null,
'subtitle' => null,
])
<div class="flex items-center gap-3">
<div class="avatar @if(empty($image)) avatar-placeholder @endif">
<div {{ $attributes->class(["w-7 rounded-full", "bg-neutral text-neutral-content" => empty($image)]) }}>
@if(empty($image))
<span class="text-xs" alt="{{ $alt }}">{{ $placeholder }}</span>
@else
<img src="{{ $image }}" alt="{{ $alt }}" @if($fallbackImage) onerror="this.src='{{ $fallbackImage }}'" @endif />
@endif
</div>
</div>
@if($title || $subtitle)
<div>
@if($title)
<div @class(["font-semibold font-lg", is_string($title) ? '' : $title?->attributes->get('class') ]) >
{{ $title }}
</div>
@endif
@if($subtitle)
<div @class(["text-sm text-base-content/50", is_string($subtitle) ? '' : $subtitle?->attributes->get('class') ]) >
{{ $subtitle }}
</div>
@endif
</div>
@endif
</div>
@@ -0,0 +1,13 @@
@props([
'value' => null,
])
@php
if (isset($class)) {
$attributes->setAttributes(['class' => $class]);
}
@endphp
<div {{ $attributes->class(["badge select-none"]) }}>
{{ $value ?? $slot ?? '' }}
</div>
@@ -0,0 +1,75 @@
@props([
'type' => 'button',
'external' => false,
'link' => null,
'label' => null,
'icon' => null,
'spinner' => null,
'tooltip' => null,
'tooltipLeft' => null,
'tooltipRight' => null,
'tooltipBottom' => null,
'badge' => null,
'badgeClasses' => null,
])
@php
$tooltip = $tooltip ?? $tooltipLeft ?? $tooltipRight ?? $tooltipBottom;
$tooltipPosition = $tooltipLeft ? 'lg:tooltip-left' : ($tooltipRight ? 'lg:tooltip-right' : ($tooltipBottom ? 'lg:tooltip-bottom' : 'lg:tooltip-top'));
$spinnerTarget = $spinner ?? $attributes->whereStartsWith('wire:click')->first();
@endphp
@if($link)
<a href="{!! $link !!}"
@else
<button
@endif
{{ $attributes->whereDoesntStartWith('class')->merge(['type' => $type]) }}
type="button"
{{ $attributes->class(['btn', "!inline-flex lg:tooltip $tooltipPosition" => $tooltip]) }}
@if($link && $external)
target="_blank"
@endif
@if($link && !$external)
wire:navigate
@endif
data-tip="{{ $tooltip }}"
@if($spinner)
wire:target="{{ $spinnerTarget }}"
wire:loading.attr="disabled"
@endif
>
{{-- spinner --}}
@if($spinner)
<span wire:loading wire:target="{{ $spinnerTarget }}" class="loading loading-spinner w-5 h-5">Loading</span>
@endif
{{-- icon --}}
@if($icon)
<span class="block" @if($spinner) wire:loading.class="hidden" wire:target="{{ $spinnerTarget }}" @endif>
<x-ui.icon :name="$icon" />
</span>
@endif
{{-- label / slot --}}
@if($label)
<span>
{{ $label }}
</span>
@if(strlen($badge ?? '') > 0)
<span class="badge badge-sm {{ $badgeClasses }}">{{ $badge }}</span>
@endif
@else
{{ $slot }}
@endif
@if($link)
</a>
@else
</button>
@endif
@@ -0,0 +1,22 @@
@props([
'title' => '',
'subTitle' => '',
'dense' => false,
'expanded' => false
])
<div
{{ $attributes->merge()->class(['p-5', 'shadow-sm', 'rounded-lg', 'bg-base-100']) }}
>
@if($title)
<h3 @class(['pb-2' => !$subTitle && !$dense, 'text-xl font-bold leading-none tracking-tight flex items-center truncate'])> {{ $title }} </h3>
@endif
@if($subTitle)
<h5 @class(['pb-2' => !$dense, 'text-sm text-gray-400 flex items-center truncate'])> {{ $subTitle }} </h5>
@endif
<div @class(['mt-2' => !$dense && !$expanded, 'mt-0' => $dense, 'mt-5' => $expanded])>
{{ $slot }}
</div>
</div>
@@ -0,0 +1,62 @@
@props([
'id' => null,
'label' => null,
'right' => false,
'tight' => false,
'hint' => null,
'hintClass' => 'label-text-alt text-gray-400 py-1 pb-0',
'errorField' => null,
'errorClass' => 'text-red-500 label-text-alt p-1',
'omitError' => false,
'firstErrorOnly' => false,
])
@php
$modelName = $attributes->whereStartsWith('wire:model')->first();
$errorFieldName = $errorField ?? $modelName;
$id = $id == $modelName ? $modelName : "{$id}{$modelName}";
@endphp
<div>
<label for="{{ $id }}" class="flex gap-3 items-center cursor-pointer">
@if($right)
<span @class(["flex-1" => !$tight])>
{{ $label }}
@if($attributes->get('required'))
<span class="text-error">*</span>
@endif
</span>
@endif
<input
id="{{ $id }}"
type="checkbox"
{{ $attributes->whereDoesntStartWith('id')->merge(['class' => 'checkbox checkbox-primary']) }} />
@if(!$right)
{{ $label }}
@if($attributes->get('required'))
<span class="text-error">*</span>
@endif
@endif
</label>
{{-- ERROR --}}
@if(!$omitError && $errors->has($errorFieldName))
@foreach($errors->get($errorFieldName) as $message)
@foreach(Arr::wrap($message) as $line)
<div class="{{ $errorClass }}" x-classes="text-red-500 label-text-alt p-1">{{ $line }}</div>
@break($firstErrorOnly)
@endforeach
@break($firstErrorOnly)
@endforeach
@endif
{{-- HINT --}}
@if($hint)
<div class="{{ $hintClass }}" x-classes="label-text-alt text-gray-400 py-1 pb-0">{{ $hint }}</div>
@endif
</div>
@@ -1,7 +1,14 @@
@props(['id' => null, 'maxWidth' => null])
@props(['key' => 'confirmation'])
<x-ib-livewire-modal :id="$id" :maxWidth="$maxWidth" {{ $attributes }} :showClose="false">
<div class="p-2">
<x-ui.modal
:key="$key"
box-class="max-w-xl"
persistent="true"
no-card="true"
{{ $attributes }}
>
<div class="p-5">
<div class="sm:flex sm:items-start">
<div class="mx-auto shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-red-100 sm:mx-0 sm:h-10 sm:w-10">
<svg class="h-6 w-6 text-red-600 dark:text-red-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
@@ -10,18 +17,18 @@
</div>
<div class="mt-3 text-center sm:mt-0 sm:ms-4 sm:text-start">
<h3 class="text-lg font-medium text-gray-900 dark:text-gray-100">
<h3 class="text-xl font-bold text-primary-content">
{{ $title }}
</h3>
<div class="mt-4 text-sm text-gray-600 dark:text-gray-400">
<div class="mt-2 text-sm text-secondary-content">
{{ $content }}
</div>
</div>
</div>
</div>
<div class="flex flex-row items-center justify-end mt-3 p-2 text-end">
{{ $footer }}
<div class="flex flex-row items-center justify-center sm:justify-end mt-8 text-end">
{{ $footer }}
</div>
</div>
</x-ib-livewire-modal>
</x-ui.modal>
@@ -0,0 +1,261 @@
@props([
'id' => null,
'label' => null,
'icon' => null,
'hint' => null,
'hintClass' => 'label-text-alt text-gray-400 py-1 pb-0',
'errorField' => null,
'errorClass' => 'text-red-500 label-text-alt p-1',
'omitError' => false,
'firstErrorOnly' => false,
])
@php
$modelName = $attributes->whereStartsWith('wire:model')->first();
$errorFieldName = $errorField ?? $modelName;
$id = $id == $modelName ? $modelName : "{$id}{$modelName}";
@endphp
<style>
input[type="date"]::-webkit-calendar-picker-indicator {
color: transparent;
background: transparent;
}
</style>
<div
x-cloak
x-data="{
datePickerOpen: false,
datePickerValue: $wire.entangle(@js($modelName)),
datePickerMonth: '',
datePickerYear: '',
datePickerDay: '',
datePickerDaysInMonth: [],
datePickerBlankDaysInMonth: [],
datePickerMonthNames: ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'],
datePickerDays: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'],
datePickerDayClicked(day) {
let selectedDate = new Date(this.datePickerYear, this.datePickerMonth, day);
this.datePickerDay = day;
this.datePickerValue = this.dateToValue(selectedDate);
this.datePickerIsSelectedDate(day);
this.datePickerOpen = false;
},
datePickerPreviousMonth(){
if (this.datePickerMonth == 0) {
this.datePickerYear--;
this.datePickerMonth = 12;
}
this.datePickerMonth--;
this.datePickerCalculateDays();
},
datePickerNextMonth(){
if (this.datePickerMonth == 11) {
this.datePickerMonth = 0;
this.datePickerYear++;
} else {
this.datePickerMonth++;
}
this.datePickerCalculateDays();
},
datePickerIsSelectedDate(day) {
const d = new Date(this.datePickerYear, this.datePickerMonth, day);
return this.datePickerValue === this.dateToValue(d) ? true : false;
},
datePickerIsToday(day) {
const today = new Date();
const d = new Date(this.datePickerYear, this.datePickerMonth, day);
return today.toDateString() === d.toDateString() ? true : false;
},
datePickerCalculateDays() {
let daysInMonth = new Date(this.datePickerYear, this.datePickerMonth + 1, 0).getDate();
// find where to start calendar day of week
let dayOfWeek = new Date(this.datePickerYear, this.datePickerMonth).getDay();
let blankdaysArray = [];
for (var i = 1; i <= dayOfWeek; i++) {
blankdaysArray.push(i);
}
let daysArray = [];
for (var i = 1; i <= daysInMonth; i++) {
daysArray.push(i);
}
this.datePickerBlankDaysInMonth = blankdaysArray;
this.datePickerDaysInMonth = daysArray;
},
dateToValue(d) {
d = this.parseDate(d)
let formattedDate = ('0' + d.getDate()).slice(-2);
let formattedMonthInNumber = ('0' + (parseInt(d.getMonth()) + 1)).slice(-2);
let formattedYear = d.getFullYear();
return `${formattedYear}-${formattedMonthInNumber}-${formattedDate}`;
},
parseDate(d) {
date = new Date();
let userTimezoneOffset = date.getTimezoneOffset() * 60000;
return new Date(Date.parse(d) + userTimezoneOffset);
}
}"
x-init="
currentDate = new Date();
if (datePickerValue) {
currentDate = parseDate(datePickerValue)
}
datePickerMonth = currentDate.getMonth();
datePickerYear = currentDate.getFullYear();
datePickerDay = currentDate.getDay();
datePickerValue = currentDate.toISOString().slice(0, 10);
datePickerCalculateDays();
"
>
{{-- STANDARD LABEL --}}
@if($label)
<label for="{{ $id }}" class="pt-0 label label-text font-semibold">
<span>
{{ $label }}
@if($attributes->get('required'))
<span class="text-error">*</span>
@endif
</span>
</label>
@endif
<div class="flex-1 relative">
{{-- DESKTOP --}}
<div
x-ref="desktopDatePickerInput"
x-html="parseDate(datePickerValue).toLocaleDateString()"
x-on:keydown.escape="datePickerOpen=false"
@click="datePickerOpen=true"
{{ $attributes->class([
"hidden md:block py-2 input px-4 input-primary w-full peer appearance-none",
'ps-10' => ($icon),
'border border-dashed' => $attributes->has('readonly') && $attributes->get('readonly') == true,
'input-error' => $errors->has($errorFieldName)
]) }}
></div>
<div
x-show="datePickerOpen"
x-transition:enter="ease-out duration-200"
x-transition:enter-start="-translate-x-2"
x-transition:enter-end="translate-x-0"
@click.away="datePickerOpen = false"
class="
p-4
mt-12
top-0
left-0
max-w-lg
w-[17rem]
absolute
z-100
bg-base-100
dark:bg-base-300
rounded-box
shadow-md
select-none
"
>
<div class="flex justify-between items-center mb-2">
<div>
<span x-text="datePickerMonthNames[datePickerMonth]" class="text-lg font-bold"></span>
<span x-text="datePickerYear" class="ml-1 text-lg font-normal text-gray-600"></span>
</div>
<div>
<button @click="datePickerPreviousMonth()" type="button" class="inline-flex p-1 rounded-full transition duration-100 ease-in-out cursor-pointer focus:outline-none focus:shadow-outline hover:bg-accent/50">
<x-ui.icon name="o-chevron-left" />
</button>
<button @click="datePickerNextMonth()" type="button" class="inline-flex p-1 rounded-full transition duration-100 ease-in-out cursor-pointer focus:outline-none focus:shadow-outline hover:bg-accent/50">
<x-ui.icon name="o-chevron-right" />
</button>
</div>
</div>
<div class="grid grid-cols-7 mb-3">
<template x-for="(day, index) in datePickerDays" :key="index">
<div class="px-0.5">
<div x-text="day" class="text-xs font-medium text-center"></div>
</div>
</template>
</div>
<div class="grid grid-cols-7">
<template x-for="blankDay in datePickerBlankDaysInMonth">
<div class="p-1 text-sm text-center border border-transparent"></div>
</template>
<template x-for="(day, dayIndex) in datePickerDaysInMonth" :key="dayIndex">
<div class="px-0.5 mb-1 aspect-square">
<div
x-text="day"
@click="datePickerDayClicked(day)"
:class="{
'border border-accent/50': datePickerIsToday(day) == true,
'hover:bg-neutral-800/70': datePickerIsToday(day) == false && datePickerIsSelectedDate(day) == false,
'text-primary-content bg-primary hover:bg-primary/50': datePickerIsSelectedDate(day) == true
}"
class="flex justify-center items-center w-7 h-7 text-sm leading-none text-center rounded-full cursor-pointer"
></div>
</div>
</template>
</div>
</div>
{{-- MOBILE/NATIVE --}}
<input
type="date"
x-model="datePickerValue"
placeholder="Select date"
id="{{ $id }}"
onfocus="this.showPicker?.()"
x-ref="mobileDatePickerInput"
{{ $attributes->class([
"block md:hidden input input-primary w-full peer appearance-none",
'ps-10' => ($icon),
'border border-dashed' => $attributes->has('readonly') && $attributes->get('readonly') == true,
'input-error' => $errors->has($errorFieldName)
]) }}
/>
{{-- ICON --}}
<div @click="
if ($refs.mobileDatePickerInput?.checkVisibility()) {
$refs.mobileDatePickerInput?.showPicker()
return;
}
if(datePickerOpen) {
$refs.desktopDatePickerInput.focus();
return;
}
datePickerOpen=!datePickerOpen;
"
class="z-60 absolute top-1/2 -translate-y-1/2 end-0 p-3 cursor-pointer text-neutral-400 hover:text-neutral-500"
>
<x-ui.icon name="o-calendar" />
</div>
</div>
{{-- ERROR --}}
@if(!$omitError && $errors->has($errorFieldName))
@foreach($errors->get($errorFieldName) as $message)
@foreach(Arr::wrap($message) as $line)
<div class="{{ $errorClass }}" x-classes="text-red-500 label-text-alt p-1">{{ $line }}</div>
@break($firstErrorOnly)
@endforeach
@break($firstErrorOnly)
@endforeach
@endif
{{-- HINT --}}
@if($hint)
<div class="{{ $hintClass }}" x-classes="label-text-alt text-gray-400 py-1 pb-0">{{ $hint }}</div>
@endif
</div>
@@ -0,0 +1,25 @@
@props(['key' => 'dialog'])
<x-ui.modal
:key="$key"
box-class="max-w-xl"
persistent="true"
no-card="true"
{{ $attributes }}
>
<div class="p-5">
<div class="text-xl font-bold text-primary-content">
{{ $title }}
</div>
<div class="mt-2 text-sm text-secondary-content">
{{ $content }}
</div>
<div class="flex flex-row items-center justify-end mt-8 text-end">
{{ $footer }}
</div>
</div>
</x-ui.modal>
@@ -0,0 +1,52 @@
@props([
'key' => 'drawer',
'showClose' => true,
'closeOnEscape' => true,
'title' => null,
'subtitle' => null
])
<div
x-data="{ open: false }"
x-on:toggle-{{ $key }}.window="open = !open"
@if($closeOnEscape)
@keydown.window.escape="open = false"
@endif
x-trap="open"
x-bind:inert="!open"
class="fixed inset-0 flex justify-end z-50"
x-cloak
>
{{-- overlay --}}
<div @click="open = false" x-show="open" class="z-40 fixed inset-0 bg-black opacity-50"></div>
{{-- content --}}
<div
class="transition duration-200 ease-out transition-transform translate-x-full transform z-50 md:w-3/4 xl:w-3/5"
:class="{'translate-x-0': open, 'translate-x-full': !open}"
>
<x-ui.card
{{ $attributes->merge(['class' => 'w-full min-h-screen rounded-none px-8 overflow-y-scroll']) }}
>
@if($title)
<x-slot:title>
{!! strip_tags($title) !!}
</x-slot:title>
@endif
@if($subtitle)
<x-slot:subtitle>
{!! strip_tags($subtitle) !!}
</x-slot:subtitle>
@endif
@if ($showClose)
<x-ui.button icon="o-x-mark" title="{{ __('Close') }}" class="btn-ghost btn-circle btn-sm absolute top-4 right-4 " @click="open = false" />
@endif
{{ $slot }}
</x-ui.card>
</div>
</div>
@@ -0,0 +1,62 @@
@props([
'id' => null,
'label' => null,
'icon' => 'o-chevron-down',
'trigger' => null,
])
<details
x-data="{
dropdownOpen: false
}"
:open="dropdownOpen"
@click.outside="dropdownOpen = false"
@class(['dropdown'])
>
{{-- CUSTOM TRIGGER --}}
@if($trigger)
<summary x-ref="button" @click.prevent="dropdownOpen = !dropdownOpen" {{ $trigger->attributes->class(['list-none']) }}>
{{ $trigger }}
</summary>
@else
{{-- DEFAULT TRIGGER --}}
<summary
x-ref="button"
@click.prevent="dropdownOpen = !dropdownOpen"
{{ $attributes->class(["btn btn-ghost normal-case disabled:opacity-50 disabled:pointer-events-none"]) }}
>
{{ $label }}
<span class="transition-transform" :class="{'rotate-180': dropdownOpen }">
<x-ui.icon :name="$icon" />
</span>
</summary>
@endif
{{-- CONTENT --}}
<ul
@class([
'menu',
'absolute',
'top-0',
'p-2',
'shadow-lg',
'z-50',
'bg-base-100',
'rounded-box',
'w-auto',
'min-w-max',
])
x-anchor.bottom-start="$refs.button"
@click="dropdownOpen = false"
x-transition:enter="ease-out duration-200"
x-transition:enter-start="-translate-y-2"
x-transition:enter-end="translate-y-0"
x-cloak
>
<div wire:key="dropdown-slot-{{ $id }}">
{{ $slot }}
</div>
</ul>
</details>
@@ -0,0 +1,37 @@
@props([
'id' => null,
'title' => null,
'description' => null,
'icon' => 'o-x-circle',
'only' => []
])
<div>
@if ($errors->any())
<div {{ $attributes->class(["flex justify-start alert alert-error rounded rounded-md"]) }} >
<div class="grid gap-3">
<div class="flex gap-2">
@if($title)
<x-icon :name="$icon" class="w-6 h-6 mt-0.5" />
@endif
<div>
@if($title)
<div class="font-bold text-lg">{{ $title }}</div>
@endif
@if($description)
<div class="font-semibold">{{ $description }}</div>
@endif
</div>
</div>
<div>
<ul class="list-disc ms-3 space-y-2 sm:ms-6 pb-3">
@foreach ($errors->all() as $error)
<li>{{ $error }}</li>
@endforeach
</ul>
</div>
</div>
</div>
@endif
</div>

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