Compare commits

...

71 Commits

Author SHA1 Message Date
382fe24f29 Release 1.281.0 (#2080) 2023-06-17 17:38:30 +02:00
908876ca6e Feature/setup language localization for portuguese (#2076)
* Set up Portuguese

* Update changelog
2023-06-17 17:26:40 +02:00
99cf9f8802 Feature/translation pt 2 (#2074)
* Add more Portuguese translations
2023-06-16 20:47:06 +02:00
7444ff97fc Feature/translation pt (#2073)
* Complete Portuguese translations for home screen and various other Portuguese translations
2023-06-15 08:34:47 +02:00
834a48466e Feature/add liabilities to feature page (#2072)
* Add section for liabilities

* Update changelog
2023-06-14 20:08:04 +02:00
a9526430c2 Improve column headers in holdings table for mobile (#2071)
* Improve column headers in holdings table for mobile

* Update changelog
2023-06-13 21:00:56 +02:00
fce3b2084e Feature/extract symbol search to component (#2003) (#2056)
* Extract symbol search to component (#2003)

* Update changelog
2023-06-13 20:36:16 +02:00
f5a50a95de Feature/upgrade prisma to version 4.15.0 (#2070)
* Upgrade prisma to version 4.15.0

* Update changelog
2023-06-12 15:16:43 +02:00
06dfb91f82 Release 1.280.1 (#2069) 2023-06-10 21:41:39 +02:00
be36050d76 Release 1.280.0 (#2068) 2023-06-10 17:18:28 +02:00
7931e6950d Feature/add support for liabilities (#1789)
* Add support for liabilities

* Update changelog
2023-06-10 16:17:11 +02:00
04eb452e04 Add missing guards (#2067) 2023-06-10 16:16:27 +02:00
6f7e370fca Release 1.279.0 (#2066) 2023-06-10 12:21:11 +02:00
b4a126280f Bugfix/fix public page (#2065)
* Check for user in request because of public page

* Update changelog
2023-06-10 12:19:34 +02:00
2d009aacc4 Bugfix/handle value nullifcation for undefined object (#2064)
* Handle undefined object

* Update changelog
2023-06-10 12:01:26 +02:00
9116443305 Feature/support note in accounts (#2063)
* Add support for a note in accounts

* Update changelog
2023-06-10 12:01:13 +02:00
0adaf12a01 Add new French translations (#2057)
* Add new French translations

* Update changelog

Signed-off-by: Martin Vandenbussche <vandenbusschemartin@gmail.com>
2023-06-10 11:19:33 +02:00
b6562b6e2c Release 1.278.0 (#2062) 2023-06-09 21:14:38 +02:00
b0a4b09ef5 Feature/extract license to dedicated tab (#2061)
* Extract license to tab on about page

* Update changelog
2023-06-09 21:13:05 +02:00
ad8b9ad333 Bugfix/improve spacing in bechmark comparator (#2060)
* Improve spacing

* Update changelog
2023-06-09 20:52:51 +02:00
809956f210 Feature/display markets overview link in footer based on permission (#2059)
* Add check for permission

* Update changelog
2023-06-09 20:01:24 +02:00
6077bfa754 Feature/change direction of ellipsis icon to horizontal in tables (#2055)
* Change direction of ellipsis icon to horizontal

* Update changelog
2023-06-09 19:39:14 +02:00
09498bd804 Feature/refresh cryptocurrencies list 20230606 (#2049)
* Update cryptocurrencies.json

* Update changelog
2023-06-09 18:55:24 +02:00
fd84f4ec14 Change to routerLink (#2058) 2023-06-09 17:37:52 +02:00
c711a11d6e Feature/upgrade node.js from version 16 to 18 (#2053)
* Upgrade to Node.js 18

* Update changelog
2023-06-09 17:28:05 +02:00
8232b05f62 Feature/extend activity cloning by quantity (#2054)
* Allow to clone quantity

* Update changelog
2023-06-09 16:14:40 +02:00
0ea66aebcb Release 1.277.0 (#2052) 2023-06-07 17:35:19 +02:00
64087de3fc Bugfix/fix date format parsing in activities import (#2051)
* Fix date format parsing

* Update changelog
2023-06-07 17:33:35 +02:00
7082ff12f8 Feature/add semantic list structure to header navigation (#2044)
* Add semantic list structure (ul and li elements)

* Update changelog
2023-06-07 17:22:09 +02:00
1c7d92e15e Harmonize Slack community links (#2047) 2023-06-06 09:23:32 +02:00
a53461d257 Feature/migrate currency to unit in value component (#2043)
* Migrate currency to unit

* Update locales
2023-06-04 21:46:49 +02:00
d630fb900d Feature/add investment streaks (#2042)
* Add investment streaks

* Current streak
* Longest streak

* Add unit to value component

* Update changelog
2023-06-04 09:35:58 +02:00
51e8555fa5 Update link (#2040) 2023-06-04 09:30:35 +02:00
9db675b955 Feature/improve get symbol data endpoint (#2041)
* Add default value to query parameter

* Update changelog
2023-06-03 19:32:48 +02:00
45bd8ed029 Release 1.276.0 (#2039) 2023-06-03 13:39:30 +02:00
707fd31550 Feature/add changefreq to sitemap.xml (#2038)
* Add changefreq to /markets and /open

* Update changelog
2023-06-03 13:37:50 +02:00
6e5f0086a1 Bugfix/fix price when creating subscription (#2037)
* Fix price

* Update changelog
2023-06-03 13:26:05 +02:00
97bcd8ff49 Feature/enforce yyyy instead of yy as date format in activities import (#2036)
* Enforce yyyy (instead of yy)

* Update changelog
2023-06-03 10:19:50 +02:00
1809fc8a80 Feature/update url of ghostfolio slack channel (#2035)
* Update Slack url

* Update changelog
2023-06-03 08:28:36 +02:00
beb24f9bd4 Revert "Update Slack url" (#2034)
* Revert "Update Slack url (#2025)"

This reverts commit c48670ccdc.
2023-06-03 08:28:07 +02:00
ae57a188f5 Feature/improve tab navigations (#2031)
* Improve tab navigations

* Update changelog
2023-06-03 07:44:59 +02:00
23db85e940 Feature/add tabs to about page (#2030)
* Add tabs to about page

* Update changelog
2023-06-02 21:23:57 +02:00
bd8bb1a36a Feature/remove ghostfolio in numbers section from about page (#2029)
* Remove Ghostfolio in Numbers section

* Update changelog
2023-06-01 19:38:38 +02:00
c48670ccdc Update Slack url (#2025) 2023-05-31 17:10:59 +02:00
fc019002e2 Release 1.275.0 (#2028) 2023-05-30 21:22:25 +02:00
4282cb66b8 Feature/add localized versions to footer (#2027)
* Add links to localized versions

* Update changelog
2023-05-30 21:20:44 +02:00
1d0ba5fe4b Bugfix/fix indirect calculation in exchange rate service for specific date (#2026)
* Fix indirect calculation

* Update changelog
2023-05-30 21:05:53 +02:00
24cfb26c5b Feature/improve language localization for german 20230529 2 (#2022)
* Improve locales

* Update changelog
2023-05-30 20:48:09 +02:00
26a70aa208 Release 1.274.0 (#2021) 2023-05-29 19:43:52 +02:00
ab7e050066 Feature/extend footer by navigation (#2020)
* Extend footer

* Update changelog
2023-05-29 19:41:21 +02:00
26b1fd6572 Feature/localize meta description (#2019)
* Add localized meta description

* Update changelog
2023-05-29 18:02:18 +02:00
d7e682b65a Feature/extend testimonial section (#2018)
* Add testimonials

* Update changelog
2023-05-29 14:16:17 +02:00
f589ccb775 Feature/improve language localization for german 20230529 (#2017)
* Improve locales

* Update changelog
2023-05-29 13:24:18 +02:00
206b6567fd Feature/improve activities import dialog (#2016)
* Improve activities import dialog

* Update changelog
2023-05-29 07:45:55 +02:00
6857e0314f Feature/add support for localized routes in spanish (#2015)
* Add support for localized routes in Spanish

* Update changelog
2023-05-28 22:48:27 +02:00
c8682a7393 Release 1.273.0 (#2014) 2023-05-28 10:31:37 +02:00
144b6b2211 Bugfix/handle undefined in decode data source (#2013)
* Handle undefined data source

* Update changelog
2023-05-28 10:29:52 +02:00
16a5ace4be Introduced stepper to import activities (#1990)
* Introduced stepper to import activities

* Update changelog
2023-05-28 10:21:07 +02:00
b24ddc30c9 Add localized routes for fr, it and nl (#2012) 2023-05-28 10:18:39 +02:00
19333ab084 Fix warnings (#2011) 2023-05-27 10:48:10 +02:00
7529a7a26c Feature/support localized routes (#2009)
* Support localized routes

* Update changelog
2023-05-27 09:53:48 +02:00
21ebaae6ef Add Cloud vs. Self-hosted (#2010) 2023-05-27 09:47:58 +02:00
3bc8b3c836 Feature/add link to manage benchmarks in benchmark comparator component (#2007)
* Add link to manage benchmarks

* Update changelog
2023-05-27 09:34:02 +02:00
bb9415cc15 Release 1.272.0 (#2005) 2023-05-26 20:43:09 +02:00
b3baeb8a5d Feature/support case insensitive names in portfolio proportion chart (#2004)
* Sum up case insensitive names

* Update changelog
2023-05-26 20:41:33 +02:00
1f393e78f6 Feature/improve error handling in delete user endpoint (#2000)
* Improve error handling

* Update changelog
2023-05-25 17:27:33 +02:00
215f5eafa6 Feature/set asset profile as benchmark (#2002)
* Set asset profile as benchmark

* Update changelog

Co-authored-by: Arghya Ghosh <arghyag5@gmail.com>
2023-05-24 21:22:32 +02:00
1916e5343d Feature/decrease table density (#2001)
* Decrease table density

* Update changelog
2023-05-24 19:34:12 +02:00
fa9863fc54 Feature/upgrade ionicons to version 7.1.0 (#1997)
* Upgrade ionicons to version 7.1.0

* Update changelog
2023-05-23 14:45:21 +02:00
7bf48ef351 Improve style on about page (#1998)
* Center changelog button

* Update changelog
2023-05-22 20:20:16 +02:00
faef3606fd Feature/improve breadcrumb navigation style in blog posts for mobile (#1996)
* Improve style for mobile

* Update changelog
2023-05-21 07:50:45 +02:00
160 changed files with 6761 additions and 3440 deletions

View File

@ -36,6 +36,9 @@ The Issue tracker is **ONLY** used for reporting bugs. New features should be di
<!-- Please complete the following information --> <!-- Please complete the following information -->
- [ ] Cloud
- [ ] Self-hosted
- Ghostfolio Version X.Y.Z - Ghostfolio Version X.Y.Z
- Browser - Browser
- OS - OS

View File

@ -10,7 +10,7 @@ jobs:
strategy: strategy:
matrix: matrix:
node_version: node_version:
- 16 - 18
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v3 uses: actions/checkout@v3

2
.nvmrc
View File

@ -1 +1 @@
v16 v18

View File

@ -5,6 +5,135 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## 1.281.0 - 2023-06-17
### Added
- Extended the feature overview page by liabilities
- Set up the language localization for Português (`pt`)
### Changed
- Extracted the symbol search to a dedicated component
- Improved the column headers in the holdings table for mobile
- Upgraded `prisma` from version `4.14.1` to `4.15.0`
## 1.280.1 - 2023-06-10
### Added
- Added support for liabilities
## 1.279.0 - 2023-06-10
### Added
- Supported a note for accounts
### Changed
- Improved the language localization for French (`fr`)
### Fixed
- Fixed an issue with the value nullification related to the investment streaks
- Fixed an issue in the public page related to the impersonation service
## 1.278.0 - 2023-06-09
### Changed
- Extended the clone functionality of a transaction by the quantity
- Changed the direction of the ellipsis icon in various tables
- Extracted the license to a dedicated tab on the about page
- Displayed the link to the markets overview in the footer based on a permission
- Improved the spacing in the benchmark comparator
- Refreshed the cryptocurrencies list
- Upgraded `Node.js` from version `16` to `18` (`Dockerfile`)
## 1.277.0 - 2023-06-07
### Added
- Added the investment streaks to the analysis page
- Added support for a unit in the value component
- Added a semantic list structure to the header navigation
- Added a default value for the `includeHistoricalData` attribute in the symbol data endpoint
### Fixed
- Fixed an issue with the date format parsing in the activities import
## 1.276.0 - 2023-06-03
### Added
- Added tabs to the about page
- Added the `changefreq` attribute to the sitemap
### Changed
- Improved the routes of the tabs
- Enforced a stricter date format in the activities import: `dd-MM-yyyy` instead of `dd-MM-yy`
- Updated the URL of the Ghostfolio Slack channel
- Removed the _Ghostfolio in Numbers_ section from the about page
### Fixed
- Fixed an issue with the price when creating a `Subscription`
## 1.275.0 - 2023-05-30
### Changed
- Extended the footer navigation by the localized Ghostfolio versions
- Improved the language localization for German (`de`)
### Fixed
- Fixed the exchange rate service for a specific date (indirect calculation via base currency) used in activities with a manual currency
## 1.274.0 - 2023-05-29
### Added
- Extended the footer by a navigation
- Extended the testimonial section on the landing page
- Added localized meta descriptions
- Added support for localized routes in Spanish (`es`)
### Changed
- Improved the activities import dialog
- Improved the language localization for German (`de`)
## 1.273.0 - 2023-05-28
### Added
- Added a stepper to the activities import dialog
- Added a link to manage the benchmarks to the benchmark comparator
- Added support for localized routes
### Fixed
- Fixed an issue in the data source transformation
## 1.272.0 - 2023-05-26
### Added
- Added support to set an asset profile as a benchmark
### Changed
- Decreased the density of the `@angular/material` tables
- Improved the portfolio proportion chart component by supporting case insensitive names
- Improved the breadcrumb navigation style in the blog post pages for mobile
- Improved the error handling in the delete user endpoint
- Improved the style of the _Changelog & License_ button on the about page
- Upgraded `ionicons` from version `6.1.2` to `7.1.0`
## 1.271.0 - 2023-05-20 ## 1.271.0 - 2023-05-20
### Added ### Added
@ -263,7 +392,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Changed the slide toggles to checkboxes on the account page - Changed the slide toggles to checkboxes on the account page
- Changed the slide toggles to checkboxes in the admin control panel - Changed the slide toggles to checkboxes in the admin control panel
- Decreased the density of the theme - Increased the density of the theme
- Migrated the style of various components to `@angular/material` `15` (mdc) - Migrated the style of various components to `@angular/material` `15` (mdc)
- Upgraded `@angular/cdk` and `@angular/material` from version `15.2.5` to `15.2.6` - Upgraded `@angular/cdk` and `@angular/material` from version `15.2.5` to `15.2.6`
- Upgraded `bull` from version `4.10.2` to `4.10.4` - Upgraded `bull` from version `4.10.2` to `4.10.4`
@ -683,7 +812,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Added support for the dividend timeline grouped by year - Added support for the dividend timeline grouped by year
- Added support for the investment timeline grouped by year - Added support for the investment timeline grouped by year
- Set up the language localization for Français (`fr`) - Set up the language localization for Français (`fr`)
- Set up the language localization for Português (`pt`)
### Changed ### Changed

View File

@ -1,4 +1,4 @@
FROM --platform=$BUILDPLATFORM node:16-slim as builder FROM --platform=$BUILDPLATFORM node:18-slim as builder
# Build application and add additional files # Build application and add additional files
WORKDIR /ghostfolio WORKDIR /ghostfolio
@ -50,7 +50,7 @@ COPY package.json /ghostfolio/dist/apps/api
RUN yarn database:generate-typings RUN yarn database:generate-typings
# Image to run, copy everything needed from builder # Image to run, copy everything needed from builder
FROM node:16-slim FROM node:18-slim
RUN apt update && apt install -y \ RUN apt update && apt install -y \
openssl \ openssl \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*

View File

@ -145,7 +145,7 @@ Please follow the instructions of the Ghostfolio [Unraid Community App](https://
### Prerequisites ### Prerequisites
- [Docker](https://www.docker.com/products/docker-desktop) - [Docker](https://www.docker.com/products/docker-desktop)
- [Node.js](https://nodejs.org/en/download) (version 16) - [Node.js](https://nodejs.org/en/download) (version 18+)
- [Yarn](https://yarnpkg.com/en/docs/install) - [Yarn](https://yarnpkg.com/en/docs/install)
- Create a local copy of this Git repository (clone) - Create a local copy of this Git repository (clone)
- Copy the file `.env.example` to `.env` and populate it with your data (`cp .env.example .env`) - Copy the file `.env.example` to `.env` and populate it with your data (`cp .env.example .env`)
@ -269,7 +269,7 @@ Deprecated: `GET http://localhost:3333/api/v1/auth/anonymous/<INSERT_SECURITY_TO
Ghostfolio is **100% free** and **open source**. We encourage and support an active and healthy community that accepts contributions from the public - including you. Ghostfolio is **100% free** and **open source**. We encourage and support an active and healthy community that accepts contributions from the public - including you.
Not sure what to work on? We have got some ideas. Please join the Ghostfolio [Slack channel](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) or tweet to [@ghostfolio\_](https://twitter.com/ghostfolio_). We would love to hear from you. Not sure what to work on? We have got some ideas. Please join the Ghostfolio [Slack](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) channel or tweet to [@ghostfolio\_](https://twitter.com/ghostfolio_). We would love to hear from you.
If you like to support this project, get [**Ghostfolio Premium**](https://ghostfol.io/en/pricing) or [**Buy me a coffee**](https://www.buymeacoffee.com/ghostfolio). If you like to support this project, get [**Ghostfolio Premium**](https://ghostfol.io/en/pricing) or [**Buy me a coffee**](https://www.buymeacoffee.com/ghostfolio).

View File

@ -1,4 +1,5 @@
import { AccountType } from '@prisma/client'; import { AccountType } from '@prisma/client';
import { Transform, TransformFnParams } from 'class-transformer';
import { import {
IsBoolean, IsBoolean,
IsNumber, IsNumber,
@ -6,6 +7,7 @@ import {
IsString, IsString,
ValidateIf ValidateIf
} from 'class-validator'; } from 'class-validator';
import { isString } from 'lodash';
export class CreateAccountDto { export class CreateAccountDto {
@IsString() @IsString()
@ -14,6 +16,13 @@ export class CreateAccountDto {
@IsNumber() @IsNumber()
balance: number; balance: number;
@IsOptional()
@IsString()
@Transform(({ value }: TransformFnParams) =>
isString(value) ? value.trim() : value
)
comment?: string;
@IsString() @IsString()
currency: string; currency: string;

View File

@ -1,4 +1,5 @@
import { AccountType } from '@prisma/client'; import { AccountType } from '@prisma/client';
import { Transform, TransformFnParams } from 'class-transformer';
import { import {
IsBoolean, IsBoolean,
IsNumber, IsNumber,
@ -6,6 +7,7 @@ import {
IsString, IsString,
ValidateIf ValidateIf
} from 'class-validator'; } from 'class-validator';
import { isString } from 'lodash';
export class UpdateAccountDto { export class UpdateAccountDto {
@IsString() @IsString()
@ -14,6 +16,13 @@ export class UpdateAccountDto {
@IsNumber() @IsNumber()
balance: number; balance: number;
@IsOptional()
@IsString()
@Transform(({ value }: TransformFnParams) =>
isString(value) ? value.trim() : value
)
comment?: string;
@IsString() @IsString()
currency: string; currency: string;

View File

@ -1,24 +1,36 @@
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor'; import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor'; import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
import { import type {
BenchmarkMarketDataDetails, BenchmarkMarketDataDetails,
BenchmarkResponse BenchmarkResponse,
UniqueAsset
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Body,
Controller, Controller,
Get, Get,
HttpException,
Inject,
Param, Param,
Post,
UseGuards, UseGuards,
UseInterceptors UseInterceptors
} from '@nestjs/common'; } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { BenchmarkService } from './benchmark.service'; import { BenchmarkService } from './benchmark.service';
@Controller('benchmark') @Controller('benchmark')
export class BenchmarkController { export class BenchmarkController {
public constructor(private readonly benchmarkService: BenchmarkService) {} public constructor(
private readonly benchmarkService: BenchmarkService,
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
@Get() @Get()
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
@ -45,4 +57,41 @@ export class BenchmarkController {
symbol symbol
}); });
} }
@Post()
@UseGuards(AuthGuard('jwt'))
public async addBenchmark(@Body() { dataSource, symbol }: UniqueAsset) {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
try {
const benchmark = await this.benchmarkService.addBenchmark({
dataSource,
symbol
});
if (!benchmark) {
throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND
);
}
return benchmark;
} catch {
throw new HttpException(
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),
StatusCodes.INTERNAL_SERVER_ERROR
);
}
}
} }

View File

@ -3,6 +3,7 @@ import { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module'; import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
@ -17,6 +18,7 @@ import { BenchmarkService } from './benchmark.service';
ConfigurationModule, ConfigurationModule,
DataProviderModule, DataProviderModule,
MarketDataModule, MarketDataModule,
PrismaModule,
PropertyModule, PropertyModule,
RedisCacheModule, RedisCacheModule,
SymbolModule, SymbolModule,

View File

@ -4,7 +4,15 @@ describe('BenchmarkService', () => {
let benchmarkService: BenchmarkService; let benchmarkService: BenchmarkService;
beforeAll(async () => { beforeAll(async () => {
benchmarkService = new BenchmarkService(null, null, null, null, null, null); benchmarkService = new BenchmarkService(
null,
null,
null,
null,
null,
null,
null
);
}); });
it('calculateChangeInPercentage', async () => { it('calculateChangeInPercentage', async () => {

View File

@ -2,6 +2,7 @@ import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.s
import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service'; import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { import {
@ -11,6 +12,7 @@ import {
import { DATE_FORMAT } from '@ghostfolio/common/helper'; import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { import {
BenchmarkMarketDataDetails, BenchmarkMarketDataDetails,
BenchmarkProperty,
BenchmarkResponse, BenchmarkResponse,
UniqueAsset UniqueAsset
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
@ -18,6 +20,7 @@ import { Injectable } from '@nestjs/common';
import { SymbolProfile } from '@prisma/client'; import { SymbolProfile } from '@prisma/client';
import Big from 'big.js'; import Big from 'big.js';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { uniqBy } from 'lodash';
import ms from 'ms'; import ms from 'ms';
@Injectable() @Injectable()
@ -27,6 +30,7 @@ export class BenchmarkService {
public constructor( public constructor(
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
private readonly marketDataService: MarketDataService, private readonly marketDataService: MarketDataService,
private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService, private readonly propertyService: PropertyService,
private readonly redisCacheService: RedisCacheService, private readonly redisCacheService: RedisCacheService,
private readonly symbolProfileService: SymbolProfileService, private readonly symbolProfileService: SymbolProfileService,
@ -116,9 +120,9 @@ export class BenchmarkService {
public async getBenchmarkAssetProfiles(): Promise<Partial<SymbolProfile>[]> { public async getBenchmarkAssetProfiles(): Promise<Partial<SymbolProfile>[]> {
const symbolProfileIds: string[] = ( const symbolProfileIds: string[] = (
((await this.propertyService.getByKey(PROPERTY_BENCHMARKS)) as { ((await this.propertyService.getByKey(
symbolProfileId: string; PROPERTY_BENCHMARKS
}[]) ?? [] )) as BenchmarkProperty[]) ?? []
).map(({ symbolProfileId }) => { ).map(({ symbolProfileId }) => {
return symbolProfileId; return symbolProfileId;
}); });
@ -204,6 +208,43 @@ export class BenchmarkService {
return response; return response;
} }
public async addBenchmark({
dataSource,
symbol
}: UniqueAsset): Promise<Partial<SymbolProfile>> {
const assetProfile = await this.prismaService.symbolProfile.findFirst({
where: {
dataSource,
symbol
}
});
if (!assetProfile) {
return;
}
let benchmarks =
((await this.propertyService.getByKey(
PROPERTY_BENCHMARKS
)) as BenchmarkProperty[]) ?? [];
benchmarks.push({ symbolProfileId: assetProfile.id });
benchmarks = uniqBy(benchmarks, 'symbolProfileId');
await this.propertyService.put({
key: PROPERTY_BENCHMARKS,
value: JSON.stringify(benchmarks)
});
return {
dataSource,
symbol,
id: assetProfile.id,
name: assetProfile.name
};
}
private getMarketCondition(aPerformanceInPercent: number) { private getMarketCondition(aPerformanceInPercent: number) {
return aPerformanceInPercent <= -0.2 ? 'BEAR_MARKET' : 'NEUTRAL_MARKET'; return aPerformanceInPercent <= -0.2 ? 'BEAR_MARKET' : 'NEUTRAL_MARKET';
} }

View File

@ -21,6 +21,7 @@ export class ExportService {
select: { select: {
accountType: true, accountType: true,
balance: true, balance: true,
comment: true,
currency: true, currency: true,
id: true, id: true,
isExcluded: true, isExcluded: true,

View File

@ -19,6 +19,9 @@ export class FrontendMiddleware implements NestMiddleware {
public indexHtmlNl = ''; public indexHtmlNl = '';
public indexHtmlPt = ''; public indexHtmlPt = '';
private static readonly DEFAULT_DESCRIPTION =
'Ghostfolio is a personal finance dashboard to keep track of your assets like stocks, ETFs or cryptocurrencies across multiple platforms.';
public constructor( public constructor(
private readonly configurationService: ConfigurationService private readonly configurationService: ConfigurationService
) { ) {
@ -116,6 +119,8 @@ export class FrontendMiddleware implements NestMiddleware {
currentDate, currentDate,
featureGraphicPath, featureGraphicPath,
title, title,
description:
'Mit dem Finanz-Dashboard Ghostfolio können Sie Ihr Vermögen in Form von Aktien, ETFs oder Kryptowährungen verteilt über mehrere Finanzinstitute überwachen.',
languageCode: 'de', languageCode: 'de',
path: request.path, path: request.path,
rootUrl: this.configurationService.get('ROOT_URL') rootUrl: this.configurationService.get('ROOT_URL')
@ -127,6 +132,8 @@ export class FrontendMiddleware implements NestMiddleware {
currentDate, currentDate,
featureGraphicPath, featureGraphicPath,
title, title,
description:
'Ghostfolio es un dashboard de finanzas personales para hacer un seguimiento de tus activos como acciones, ETFs o criptodivisas a través de múltiples plataformas.',
languageCode: 'es', languageCode: 'es',
path: request.path, path: request.path,
rootUrl: this.configurationService.get('ROOT_URL') rootUrl: this.configurationService.get('ROOT_URL')
@ -135,7 +142,11 @@ export class FrontendMiddleware implements NestMiddleware {
} else if (request.path === '/fr' || request.path.startsWith('/fr/')) { } else if (request.path === '/fr' || request.path.startsWith('/fr/')) {
response.send( response.send(
this.interpolate(this.indexHtmlFr, { this.interpolate(this.indexHtmlFr, {
currentDate,
featureGraphicPath, featureGraphicPath,
title,
description:
'Ghostfolio est un dashboard de finances personnelles qui permet de suivre vos actifs comme les actions, les ETF ou les crypto-monnaies sur plusieurs plateformes.',
languageCode: 'fr', languageCode: 'fr',
path: request.path, path: request.path,
rootUrl: this.configurationService.get('ROOT_URL') rootUrl: this.configurationService.get('ROOT_URL')
@ -147,6 +158,8 @@ export class FrontendMiddleware implements NestMiddleware {
currentDate, currentDate,
featureGraphicPath, featureGraphicPath,
title, title,
description:
'Ghostfolio è un dashboard di finanza personale per tenere traccia delle vostre attività come azioni, ETF o criptovalute su più piattaforme.',
languageCode: 'it', languageCode: 'it',
path: request.path, path: request.path,
rootUrl: this.configurationService.get('ROOT_URL') rootUrl: this.configurationService.get('ROOT_URL')
@ -158,6 +171,8 @@ export class FrontendMiddleware implements NestMiddleware {
currentDate, currentDate,
featureGraphicPath, featureGraphicPath,
title, title,
description:
'Ghostfolio is een persoonlijk financieel dashboard om uw activa zoals aandelen, ETFs of cryptocurrencies over meerdere platforms bij te houden.',
languageCode: 'nl', languageCode: 'nl',
path: request.path, path: request.path,
rootUrl: this.configurationService.get('ROOT_URL') rootUrl: this.configurationService.get('ROOT_URL')
@ -166,7 +181,11 @@ export class FrontendMiddleware implements NestMiddleware {
} else if (request.path === '/pt' || request.path.startsWith('/pt/')) { } else if (request.path === '/pt' || request.path.startsWith('/pt/')) {
response.send( response.send(
this.interpolate(this.indexHtmlPt, { this.interpolate(this.indexHtmlPt, {
currentDate,
featureGraphicPath, featureGraphicPath,
title,
description:
'Ghostfolio é um dashboard de finanças pessoais para acompanhar os seus activos como acções, ETFs ou criptomoedas em múltiplas plataformas.',
languageCode: 'pt', languageCode: 'pt',
path: request.path, path: request.path,
rootUrl: this.configurationService.get('ROOT_URL') rootUrl: this.configurationService.get('ROOT_URL')
@ -178,6 +197,7 @@ export class FrontendMiddleware implements NestMiddleware {
currentDate, currentDate,
featureGraphicPath, featureGraphicPath,
title, title,
description: FrontendMiddleware.DEFAULT_DESCRIPTION,
languageCode: DEFAULT_LANGUAGE_CODE, languageCode: DEFAULT_LANGUAGE_CODE,
path: request.path, path: request.path,
rootUrl: this.configurationService.get('ROOT_URL') rootUrl: this.configurationService.get('ROOT_URL')

View File

@ -202,7 +202,7 @@ export class ImportService {
for (const activity of activitiesDto) { for (const activity of activitiesDto) {
if (!activity.dataSource) { if (!activity.dataSource) {
if (activity.type === 'ITEM') { if (activity.type === 'ITEM' || activity.type === 'LIABILITY') {
activity.dataSource = DataSource.MANUAL; activity.dataSource = DataSource.MANUAL;
} else { } else {
activity.dataSource = activity.dataSource =

View File

@ -96,7 +96,7 @@ export class OrderService {
const updateAccountBalance = data.updateAccountBalance ?? false; const updateAccountBalance = data.updateAccountBalance ?? false;
const userId = data.userId; const userId = data.userId;
if (data.type === 'ITEM') { if (data.type === 'ITEM' || data.type === 'LIABILITY') {
const assetClass = data.assetClass; const assetClass = data.assetClass;
const assetSubClass = data.assetSubClass; const assetSubClass = data.assetSubClass;
currency = data.SymbolProfile.connectOrCreate.create.currency; currency = data.SymbolProfile.connectOrCreate.create.currency;
@ -129,7 +129,10 @@ export class OrderService {
} }
}); });
const isDraft = isAfter(data.date as Date, endOfToday()); const isDraft =
data.type === 'LIABILITY'
? false
: isAfter(data.date as Date, endOfToday());
if (!isDraft) { if (!isDraft) {
// Gather symbol data of order in the background, if not draft // Gather symbol data of order in the background, if not draft
@ -201,7 +204,7 @@ export class OrderService {
where where
}); });
if (order.type === 'ITEM') { if (order.type === 'ITEM' || order.type === 'LIABILITY') {
await this.symbolProfileService.deleteById(order.symbolProfileId); await this.symbolProfileService.deleteById(order.symbolProfileId);
} }
@ -320,7 +323,11 @@ export class OrderService {
}) })
) )
.filter((order) => { .filter((order) => {
return withExcludedAccounts || order.Account?.isExcluded === false; return (
withExcludedAccounts ||
!order.Account ||
order.Account?.isExcluded === false
);
}) })
.map((order) => { .map((order) => {
const value = new Big(order.quantity).mul(order.unitPrice).toNumber(); const value = new Big(order.quantity).mul(order.unitPrice).toNumber();
@ -368,7 +375,7 @@ export class OrderService {
let isDraft = false; let isDraft = false;
if (data.type === 'ITEM') { if (data.type === 'ITEM' || data.type === 'LIABILITY') {
delete data.SymbolProfile.connect; delete data.SymbolProfile.connect;
} else { } else {
delete data.SymbolProfile.update; delete data.SymbolProfile.update;

View File

@ -105,6 +105,40 @@ describe('PortfolioCalculator', () => {
expect(investmentsByMonth).toEqual([ expect(investmentsByMonth).toEqual([
{ date: '2015-01-01', investment: new Big('640.86') }, { date: '2015-01-01', investment: new Big('640.86') },
{ date: '2015-02-01', investment: new Big('0') },
{ date: '2015-03-01', investment: new Big('0') },
{ date: '2015-04-01', investment: new Big('0') },
{ date: '2015-05-01', investment: new Big('0') },
{ date: '2015-06-01', investment: new Big('0') },
{ date: '2015-07-01', investment: new Big('0') },
{ date: '2015-08-01', investment: new Big('0') },
{ date: '2015-09-01', investment: new Big('0') },
{ date: '2015-10-01', investment: new Big('0') },
{ date: '2015-11-01', investment: new Big('0') },
{ date: '2015-12-01', investment: new Big('0') },
{ date: '2016-01-01', investment: new Big('0') },
{ date: '2016-02-01', investment: new Big('0') },
{ date: '2016-03-01', investment: new Big('0') },
{ date: '2016-04-01', investment: new Big('0') },
{ date: '2016-05-01', investment: new Big('0') },
{ date: '2016-06-01', investment: new Big('0') },
{ date: '2016-07-01', investment: new Big('0') },
{ date: '2016-08-01', investment: new Big('0') },
{ date: '2016-09-01', investment: new Big('0') },
{ date: '2016-10-01', investment: new Big('0') },
{ date: '2016-11-01', investment: new Big('0') },
{ date: '2016-12-01', investment: new Big('0') },
{ date: '2017-01-01', investment: new Big('0') },
{ date: '2017-02-01', investment: new Big('0') },
{ date: '2017-03-01', investment: new Big('0') },
{ date: '2017-04-01', investment: new Big('0') },
{ date: '2017-05-01', investment: new Big('0') },
{ date: '2017-06-01', investment: new Big('0') },
{ date: '2017-07-01', investment: new Big('0') },
{ date: '2017-08-01', investment: new Big('0') },
{ date: '2017-09-01', investment: new Big('0') },
{ date: '2017-10-01', investment: new Big('0') },
{ date: '2017-11-01', investment: new Big('0') },
{ date: '2017-12-01', investment: new Big('-14156.4') } { date: '2017-12-01', investment: new Big('-14156.4') }
]); ]);
}); });

View File

@ -544,7 +544,7 @@ export class PortfolioCalculator {
return []; return [];
} }
const investments = []; const investments: { date: string; investment: Big }[] = [];
let currentDate: Date; let currentDate: Date;
let investmentByGroup = new Big(0); let investmentByGroup = new Big(0);
@ -554,13 +554,11 @@ export class PortfolioCalculator {
(groupBy === 'year' || isSameMonth(parseDate(order.date), currentDate)) (groupBy === 'year' || isSameMonth(parseDate(order.date), currentDate))
) { ) {
// Same group: Add up investments // Same group: Add up investments
investmentByGroup = investmentByGroup.plus( investmentByGroup = investmentByGroup.plus(
order.quantity.mul(order.unitPrice).mul(this.getFactor(order.type)) order.quantity.mul(order.unitPrice).mul(this.getFactor(order.type))
); );
} else { } else {
// New group: Store previous group and reset // New group: Store previous group and reset
if (currentDate) { if (currentDate) {
investments.push({ investments.push({
date: format( date: format(
@ -595,7 +593,39 @@ export class PortfolioCalculator {
} }
} }
return investments; // Fill in the missing dates with investment = 0
const startDate = parseDate(first(this.orders).date);
const endDate = parseDate(last(this.orders).date);
const allDates: string[] = [];
currentDate = startDate;
while (currentDate <= endDate) {
allDates.push(
format(
set(currentDate, {
date: 1,
month: groupBy === 'year' ? 0 : currentDate.getMonth()
}),
DATE_FORMAT
)
);
currentDate.setMonth(currentDate.getMonth() + 1);
}
for (const date of allDates) {
const existingInvestment = investments.find((investment) => {
return investment.date === date;
});
if (!existingInvestment) {
investments.push({ date, investment: new Big(0) });
}
}
return sortBy(investments, (investment) => {
return investment.date;
});
} }
public async calculateTimeline( public async calculateTimeline(

View File

@ -162,6 +162,7 @@ export class PortfolioController {
'excludedAccountsAndActivities', 'excludedAccountsAndActivities',
'fees', 'fees',
'items', 'items',
'liabilities',
'netWorth', 'netWorth',
'totalBuy', 'totalBuy',
'totalSell' 'totalSell'
@ -258,11 +259,12 @@ export class PortfolioController {
filterByTags filterByTags
}); });
let investments = await this.portfolioService.getInvestments({ let { investments, streaks } = await this.portfolioService.getInvestments({
dateRange, dateRange,
filters, filters,
groupBy, groupBy,
impersonationId impersonationId,
savingsRate: this.request.user?.Settings?.settings.savingsRate
}); });
if ( if (
@ -278,6 +280,11 @@ export class PortfolioController {
date: item.date, date: item.date,
investment: item.investment / maxInvestment investment: item.investment / maxInvestment
})); }));
streaks = nullifyValuesInObject(streaks, [
'currentStreak',
'longestStreak'
]);
} }
if ( if (
@ -287,9 +294,14 @@ export class PortfolioController {
investments = investments.map((item) => { investments = investments.map((item) => {
return nullifyValuesInObject(item, ['investment']); return nullifyValuesInObject(item, ['investment']);
}); });
streaks = nullifyValuesInObject(streaks, [
'currentStreak',
'longestStreak'
]);
} }
return { investments }; return { investments, streaks };
} }
@Get('performance') @Get('performance')

View File

@ -28,6 +28,7 @@ import {
Filter, Filter,
HistoricalDataItem, HistoricalDataItem,
PortfolioDetails, PortfolioDetails,
PortfolioInvestments,
PortfolioPerformanceResponse, PortfolioPerformanceResponse,
PortfolioPosition, PortfolioPosition,
PortfolioReport, PortfolioReport,
@ -252,13 +253,15 @@ export class PortfolioService {
dateRange, dateRange,
filters, filters,
groupBy, groupBy,
impersonationId impersonationId,
savingsRate
}: { }: {
dateRange: DateRange; dateRange: DateRange;
filters?: Filter[]; filters?: Filter[];
groupBy?: GroupBy; groupBy?: GroupBy;
impersonationId: string; impersonationId: string;
}): Promise<InvestmentItem[]> { savingsRate: number;
}): Promise<PortfolioInvestments> {
const userId = await this.getUserId(impersonationId, this.request.user.id); const userId = await this.getUserId(impersonationId, this.request.user.id);
const { portfolioOrders, transactionPoints } = const { portfolioOrders, transactionPoints } =
@ -276,7 +279,10 @@ export class PortfolioService {
portfolioCalculator.setTransactionPoints(transactionPoints); portfolioCalculator.setTransactionPoints(transactionPoints);
if (transactionPoints.length === 0) { if (transactionPoints.length === 0) {
return []; return {
investments: [],
streaks: { currentStreak: 0, longestStreak: 0 }
};
} }
let investments: InvestmentItem[]; let investments: InvestmentItem[];
@ -346,9 +352,23 @@ export class PortfolioService {
parseDate(investments[0]?.date) parseDate(investments[0]?.date)
); );
return investments.filter(({ date }) => { investments = investments.filter(({ date }) => {
return !isBefore(parseDate(date), startDate); return !isBefore(parseDate(date), startDate);
}); });
let streaks: PortfolioInvestments['streaks'];
if (savingsRate) {
streaks = this.getStreaks({
investments,
savingsRate: groupBy === 'year' ? 12 * savingsRate : savingsRate
});
}
return {
investments,
streaks
};
} }
public async getChart({ public async getChart({
@ -1282,12 +1302,11 @@ export class PortfolioService {
}: { }: {
activities: OrderWithAccount[]; activities: OrderWithAccount[];
date?: Date; date?: Date;
userCurrency: string; userCurrency: string;
}) { }) {
return activities return activities
.filter((activity) => { .filter((activity) => {
// Filter out all activities before given date and type dividend // Filter out all activities before given date (drafts) and type dividend
return ( return (
isBefore(date, new Date(activity.date)) && isBefore(date, new Date(activity.date)) &&
activity.type === TypeOfOrder.DIVIDEND activity.type === TypeOfOrder.DIVIDEND
@ -1411,7 +1430,7 @@ export class PortfolioService {
}) { }) {
return activities return activities
.filter((activity) => { .filter((activity) => {
// Filter out all activities before given date // Filter out all activities before given date (drafts)
return isBefore(date, new Date(activity.date)); return isBefore(date, new Date(activity.date));
}) })
.map(({ fee, SymbolProfile }) => { .map(({ fee, SymbolProfile }) => {
@ -1458,19 +1477,37 @@ export class PortfolioService {
}; };
} }
private getItems(orders: OrderWithAccount[], date = new Date(0)) { private getItems(activities: OrderWithAccount[], date = new Date(0)) {
return orders return activities
.filter((order) => { .filter((activity) => {
// Filter out all orders before given date and type item // Filter out all activities before given date (drafts) and type item
return ( return (
isBefore(date, new Date(order.date)) && isBefore(date, new Date(activity.date)) &&
order.type === TypeOfOrder.ITEM activity.type === TypeOfOrder.ITEM
); );
}) })
.map((order) => { .map(({ quantity, SymbolProfile, unitPrice }) => {
return this.exchangeRateDataService.toCurrency( return this.exchangeRateDataService.toCurrency(
new Big(order.quantity).mul(order.unitPrice).toNumber(), new Big(quantity).mul(unitPrice).toNumber(),
order.SymbolProfile.currency, SymbolProfile.currency,
this.request.user.Settings.settings.baseCurrency
);
})
.reduce(
(previous, current) => new Big(previous).plus(current),
new Big(0)
);
}
private getLiabilities(activities: OrderWithAccount[]) {
return activities
.filter(({ type }) => {
return type === TypeOfOrder.LIABILITY;
})
.map(({ quantity, SymbolProfile, unitPrice }) => {
return this.exchangeRateDataService.toCurrency(
new Big(quantity).mul(unitPrice).toNumber(),
SymbolProfile.currency,
this.request.user.Settings.settings.baseCurrency this.request.user.Settings.settings.baseCurrency
); );
}) })
@ -1510,6 +1547,28 @@ export class PortfolioService {
return portfolioStart; return portfolioStart;
} }
private getStreaks({
investments,
savingsRate
}: {
investments: InvestmentItem[];
savingsRate: number;
}) {
let currentStreak = 0;
let longestStreak = 0;
for (const { investment } of investments) {
if (investment >= savingsRate) {
currentStreak++;
longestStreak = Math.max(longestStreak, currentStreak);
} else {
currentStreak = 0;
}
}
return { currentStreak, longestStreak };
}
private async getSummary({ private async getSummary({
balanceInBaseCurrency, balanceInBaseCurrency,
emergencyFundPositionsValueInBaseCurrency, emergencyFundPositionsValueInBaseCurrency,
@ -1559,6 +1618,7 @@ export class PortfolioService {
const fees = this.getFees({ activities, userCurrency }).toNumber(); const fees = this.getFees({ activities, userCurrency }).toNumber();
const firstOrderDate = activities[0]?.date; const firstOrderDate = activities[0]?.date;
const items = this.getItems(activities).toNumber(); const items = this.getItems(activities).toNumber();
const liabilities = this.getLiabilities(activities).toNumber();
const totalBuy = this.getTotalByType(activities, userCurrency, 'BUY'); const totalBuy = this.getTotalByType(activities, userCurrency, 'BUY');
const totalSell = this.getTotalByType(activities, userCurrency, 'SELL'); const totalSell = this.getTotalByType(activities, userCurrency, 'SELL');
@ -1591,6 +1651,7 @@ export class PortfolioService {
.plus(performanceInformation.performance.currentValue) .plus(performanceInformation.performance.currentValue)
.plus(items) .plus(items)
.plus(excludedAccountsAndActivities) .plus(excludedAccountsAndActivities)
.minus(liabilities)
.toNumber(); .toNumber();
const daysInMarket = differenceInDays(new Date(), firstOrderDate); const daysInMarket = differenceInDays(new Date(), firstOrderDate);
@ -1617,6 +1678,7 @@ export class PortfolioService {
fees, fees,
firstOrderDate, firstOrderDate,
items, items,
liabilities,
netWorth, netWorth,
totalBuy, totalBuy,
totalSell, totalSell,
@ -1841,13 +1903,6 @@ export class PortfolioService {
return { accounts, platforms }; return { accounts, platforms };
} }
private async getUserId(aImpersonationId: string, aUserId: string) {
const impersonationUserId =
await this.impersonationService.validateImpersonationId(aImpersonationId);
return impersonationUserId || aUserId;
}
private getTotalByType( private getTotalByType(
orders: OrderWithAccount[], orders: OrderWithAccount[],
currency: string, currency: string,
@ -1874,4 +1929,11 @@ export class PortfolioService {
this.baseCurrency this.baseCurrency
); );
} }
private async getUserId(aImpersonationId: string, aUserId: string) {
const impersonationUserId =
await this.impersonationService.validateImpersonationId(aImpersonationId);
return impersonationUserId || aUserId;
}
} }

View File

@ -1,10 +1,6 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
DEFAULT_LANGUAGE_CODE,
PROPERTY_STRIPE_CONFIG
} from '@ghostfolio/common/config';
import { Subscription as SubscriptionInterface } from '@ghostfolio/common/interfaces';
import { UserWithSettings } from '@ghostfolio/common/types'; import { UserWithSettings } from '@ghostfolio/common/types';
import { SubscriptionType } from '@ghostfolio/common/types/subscription-type.type'; import { SubscriptionType } from '@ghostfolio/common/types/subscription-type.type';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
@ -101,19 +97,8 @@ export class SubscriptionService {
aCheckoutSessionId aCheckoutSessionId
); );
let subscriptions: SubscriptionInterface[] = [];
const stripeConfig = (await this.prismaService.property.findUnique({
where: { key: PROPERTY_STRIPE_CONFIG }
})) ?? { value: '{}' };
subscriptions = [JSON.parse(stripeConfig.value)];
const coupon = subscriptions[0]?.coupon ?? 0;
const price = subscriptions[0]?.price ?? 0;
await this.createSubscription({ await this.createSubscription({
price: price - coupon, price: session.amount_total / 100,
userId: session.client_reference_id userId: session.client_reference_id
}); });

View File

@ -60,7 +60,7 @@ export class SymbolController {
public async getSymbolData( public async getSymbolData(
@Param('dataSource') dataSource: DataSource, @Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string, @Param('symbol') symbol: string,
@Query('includeHistoricalData') includeHistoricalData?: number @Query('includeHistoricalData') includeHistoricalData = 0
): Promise<SymbolItem> { ): Promise<SymbolItem> {
if (!DataSource[dataSource]) { if (!DataSource[dataSource]) {
throw new HttpException( throw new HttpException(

View File

@ -304,21 +304,29 @@ export class UserService {
} }
public async deleteUser(where: Prisma.UserWhereUniqueInput): Promise<User> { public async deleteUser(where: Prisma.UserWhereUniqueInput): Promise<User> {
await this.prismaService.access.deleteMany({ try {
where: { OR: [{ granteeUserId: where.id }, { userId: where.id }] } await this.prismaService.access.deleteMany({
}); where: { OR: [{ granteeUserId: where.id }, { userId: where.id }] }
});
} catch {}
await this.prismaService.account.deleteMany({ try {
where: { userId: where.id } await this.prismaService.account.deleteMany({
}); where: { userId: where.id }
});
} catch {}
await this.prismaService.analytics.delete({ try {
where: { userId: where.id } await this.prismaService.analytics.delete({
}); where: { userId: where.id }
});
} catch {}
await this.prismaService.order.deleteMany({ try {
where: { userId: where.id } await this.prismaService.order.deleteMany({
}); where: { userId: where.id }
});
} catch {}
try { try {
await this.prismaService.settings.delete({ await this.prismaService.settings.delete({

File diff suppressed because it is too large Load Diff

View File

@ -16,9 +16,11 @@ export function hasNotDefinedValuesInObject(aObject: Object): boolean {
export function nullifyValuesInObject<T>(aObject: T, keys: string[]): T { export function nullifyValuesInObject<T>(aObject: T, keys: string[]): T {
const object = cloneDeep(aObject); const object = cloneDeep(aObject);
keys.forEach((key) => { if (object) {
object[key] = null; keys.forEach((key) => {
}); object[key] = null;
});
}
return object; return object;
} }

View File

@ -11,8 +11,7 @@ import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { PROPERTY_DATA_SOURCE_MAPPING } from '@ghostfolio/common/config'; import { PROPERTY_DATA_SOURCE_MAPPING } from '@ghostfolio/common/config';
import { DATE_FORMAT, getStartOfUtcDate } from '@ghostfolio/common/helper'; import { DATE_FORMAT, getStartOfUtcDate } from '@ghostfolio/common/helper';
import { UserWithSettings } from '@ghostfolio/common/types'; import type { Granularity, UserWithSettings } from '@ghostfolio/common/types';
import { Granularity } from '@ghostfolio/common/types';
import { Inject, Injectable, Logger } from '@nestjs/common'; import { Inject, Injectable, Logger } from '@nestjs/common';
import { DataSource, MarketData, SymbolProfile } from '@prisma/client'; import { DataSource, MarketData, SymbolProfile } from '@prisma/client';
import { format, isValid } from 'date-fns'; import { format, isValid } from 'date-fns';

View File

@ -186,28 +186,42 @@ export class ExchangeRateDataService {
factor = marketData?.marketPrice; factor = marketData?.marketPrice;
} else { } else {
// Calculate indirectly via base currency // Calculate indirectly via base currency
try {
const [
{ marketPrice: marketPriceBaseCurrencyFromCurrency },
{ marketPrice: marketPriceBaseCurrencyToCurrency }
] = await Promise.all([
this.marketDataService.get({
dataSource,
date: aDate,
symbol: `${this.baseCurrency}${aFromCurrency}`
}),
this.marketDataService.get({
dataSource,
date: aDate,
symbol: `${this.baseCurrency}${aToCurrency}`
})
]);
// Calculate the opposite direction let marketPriceBaseCurrencyFromCurrency: number;
factor = let marketPriceBaseCurrencyToCurrency: number;
(1 / marketPriceBaseCurrencyFromCurrency) *
marketPriceBaseCurrencyToCurrency; try {
if (this.baseCurrency === aFromCurrency) {
marketPriceBaseCurrencyFromCurrency = 1;
} else {
marketPriceBaseCurrencyFromCurrency = (
await this.marketDataService.get({
dataSource,
date: aDate,
symbol: `${this.baseCurrency}${aFromCurrency}`
})
)?.marketPrice;
}
} catch {} } catch {}
try {
if (this.baseCurrency === aToCurrency) {
marketPriceBaseCurrencyToCurrency = 1;
} else {
marketPriceBaseCurrencyToCurrency = (
await this.marketDataService.get({
dataSource,
date: aDate,
symbol: `${this.baseCurrency}${aToCurrency}`
})
)?.marketPrice;
}
} catch {}
// Calculate the opposite direction
factor =
(1 / marketPriceBaseCurrencyFromCurrency) *
marketPriceBaseCurrencyToCurrency;
} }
} }

View File

@ -12,22 +12,36 @@ export class ImpersonationService {
) {} ) {}
public async validateImpersonationId(aId = '') { public async validateImpersonationId(aId = '') {
const accessObject = await this.prismaService.access.findFirst({ if (this.request.user) {
where: { const accessObject = await this.prismaService.access.findFirst({
GranteeUser: { id: this.request.user.id }, where: {
id: aId GranteeUser: { id: this.request.user.id },
} id: aId
}); }
});
if (accessObject?.userId) { if (accessObject?.userId) {
return accessObject?.userId; return accessObject.userId;
} else if ( } else if (
hasPermission( hasPermission(
this.request.user.permissions, this.request.user.permissions,
permissions.impersonateAllUsers permissions.impersonateAllUsers
) )
) { ) {
return aId; return aId;
}
} else {
// Public access
const accessObject = await this.prismaService.access.findFirst({
where: {
GranteeUser: null,
User: { id: aId }
}
});
if (accessObject?.userId) {
return accessObject.userId;
}
} }
return null; return null;

View File

@ -5,25 +5,19 @@ import { PageTitleStrategy } from '@ghostfolio/client/services/page-title.strate
import { ModulePreloadService } from './core/module-preload.service'; import { ModulePreloadService } from './core/module-preload.service';
const routes: Routes = [ const routes: Routes = [
{ ...[
path: 'about', 'about',
/////
'a-propos',
'informazioni-su',
'over',
'sobre',
'ueber-uns'
].map((path) => ({
path,
loadChildren: () => loadChildren: () =>
import('./pages/about/about-page.module').then((m) => m.AboutPageModule) import('./pages/about/about-page.module').then((m) => m.AboutPageModule)
}, })),
{
path: 'about/changelog',
loadChildren: () =>
import('./pages/about/changelog/changelog-page.module').then(
(m) => m.ChangelogPageModule
)
},
{
path: 'about/privacy-policy',
loadChildren: () =>
import('./pages/about/privacy-policy/privacy-policy-page.module').then(
(m) => m.PrivacyPolicyPageModule
)
},
{ {
path: 'account', path: 'account',
loadChildren: () => loadChildren: () =>
@ -48,11 +42,11 @@ const routes: Routes = [
loadChildren: () => loadChildren: () =>
import('./pages/auth/auth-page.module').then((m) => m.AuthPageModule) import('./pages/auth/auth-page.module').then((m) => m.AuthPageModule)
}, },
{ ...['blog'].map((path) => ({
path: 'blog', path,
loadChildren: () => loadChildren: () =>
import('./pages/blog/blog-page.module').then((m) => m.BlogPageModule) import('./pages/blog/blog-page.module').then((m) => m.BlogPageModule)
}, })),
{ {
path: 'blog/2021/07/hallo-ghostfolio', path: 'blog/2021/07/hallo-ghostfolio',
loadChildren: () => loadChildren: () =>
@ -149,30 +143,54 @@ const routes: Routes = [
loadChildren: () => loadChildren: () =>
import('./pages/demo/demo-page.module').then((m) => m.DemoPageModule) import('./pages/demo/demo-page.module').then((m) => m.DemoPageModule)
}, },
{ ...[
path: 'faq', 'faq',
/////
'domande-piu-frequenti',
'foire-aux-questions',
'haeufig-gestellte-fragen',
'perguntas-mais-frequentes',
'preguntas-mas-frecuentes',
'vaak-gestelde-vragen'
].map((path) => ({
path,
loadChildren: () => loadChildren: () =>
import('./pages/faq/faq-page.module').then((m) => m.FaqPageModule) import('./pages/faq/faq-page.module').then((m) => m.FaqPageModule)
}, })),
{ ...[
path: 'features', 'features',
/////
'fonctionnalites',
'funcionalidades',
'funzionalita',
'kenmerken'
].map((path) => ({
path,
loadChildren: () => loadChildren: () =>
import('./pages/features/features-page.module').then( import('./pages/features/features-page.module').then(
(m) => m.FeaturesPageModule (m) => m.FeaturesPageModule
) )
}, })),
{ {
path: 'home', path: 'home',
loadChildren: () => loadChildren: () =>
import('./pages/home/home-page.module').then((m) => m.HomePageModule) import('./pages/home/home-page.module').then((m) => m.HomePageModule)
}, },
{ ...[
path: 'markets', 'markets',
/////
'maerkte',
'marches',
'markten',
'mercados',
'mercati'
].map((path) => ({
path,
loadChildren: () => loadChildren: () =>
import('./pages/markets/markets-page.module').then( import('./pages/markets/markets-page.module').then(
(m) => m.MarketsPageModule (m) => m.MarketsPageModule
) )
}, })),
{ {
path: 'open', path: 'open',
loadChildren: () => loadChildren: () =>
@ -192,27 +210,53 @@ const routes: Routes = [
(m) => m.PortfolioPageModule (m) => m.PortfolioPageModule
) )
}, },
{ ...[
path: 'pricing', 'pricing',
/////
'precios',
'precos',
'preise',
'prezzi',
'prijzen',
'prix'
].map((path) => ({
path,
loadChildren: () => loadChildren: () =>
import('./pages/pricing/pricing-page.module').then( import('./pages/pricing/pricing-page.module').then(
(m) => m.PricingPageModule (m) => m.PricingPageModule
) )
}, })),
{ ...[
path: 'register', 'register',
/////
'enregistrement',
'iscrizione',
'registo',
'registratie',
'registrierung',
'registro'
].map((path) => ({
path,
loadChildren: () => loadChildren: () =>
import('./pages/register/register-page.module').then( import('./pages/register/register-page.module').then(
(m) => m.RegisterPageModule (m) => m.RegisterPageModule
) )
}, })),
{ ...[
path: 'resources', 'resources',
/////
'bronnen',
'recursos',
'ressourcen',
'ressources',
'risorse'
].map((path) => ({
path,
loadChildren: () => loadChildren: () =>
import('./pages/resources/resources-page.module').then( import('./pages/resources/resources-page.module').then(
(m) => m.ResourcesPageModule (m) => m.ResourcesPageModule
) )
}, })),
{ {
path: 'start', path: 'start',
loadChildren: () => loadChildren: () =>

View File

@ -44,19 +44,133 @@
</main> </main>
<footer <footer
*ngIf="currentRoute === 'start'" *ngIf="
class="footer d-flex justify-content-center w-100" (currentRoute === 'blog' ||
currentRoute === 'faq' ||
currentRoute === 'features' ||
currentRoute === 'markets' ||
currentRoute === 'open' ||
currentRoute === 'pricing' ||
currentRoute === 'resources' ||
currentRoute === 'register' ||
currentRoute === 'start') &&
deviceType !== 'mobile'
"
class="d-flex justify-content-center py-4 w-100"
> >
<div class="container text-center"> <div class="container">
<div> <div class="mb-3 row">
© 2021 - {{ currentYear }} <a href="https://ghostfol.io">Ghostfolio</a> <div class="col-sm">
{{ version }} <a [routerLink]="['/']"><gf-logo /></a>
</div>
<div class="col-sm">
<div class="h6 mt-2" i18n>Personal Finance</div>
<ul class="list-unstyled">
<li *ngIf="hasPermissionToAccessFearAndGreedIndex">
<a i18n [routerLink]="['/markets']">Markets</a>
</li>
<li><a i18n [routerLink]="['/resources']">Resources</a></li>
</ul>
</div>
<div class="col-sm">
<div class="h6 mt-2">Ghostfolio</div>
<ul class="list-unstyled">
<li><a i18n [routerLink]="['/about']">About</a></li>
<li *ngIf="hasPermissionForBlog">
<a i18n [routerLink]="['/blog']">Blog</a>
</li>
<li>
<a i18n [routerLink]="['/about', 'changelog']">Changelog</a>
</li>
<li><a i18n [routerLink]="['/features']">Features</a></li>
<li *ngIf="hasPermissionForSubscription">
<a i18n [routerLink]="['/faq']">Frequently Asked Questions (FAQ)</a>
</li>
<li>
<a i18n [routerLink]="['/about', 'license']">License</a>
</li>
<li *ngIf="hasPermissionForSubscription">
<a [routerLink]="['/open']">Open Startup</a>
</li>
<li *ngIf="hasPermissionForSubscription">
<a i18n [routerLink]="['/pricing']">Pricing</a>
</li>
<li *ngIf="hasPermissionForSubscription">
<a i18n [routerLink]="['/about', 'privacy-policy']"
>Privacy Policy</a
>
</li>
<li *ngIf="hasPermissionForSubscription">
<a href="https://status.ghostfol.io" title="Ghostfolio Status"
>Status</a
>
</li>
</ul>
</div>
<div class="col-sm">
<div class="h6 mt-2" i18n>Community</div>
<ul class="list-unstyled">
<li>
<a
href="https://github.com/ghostfolio/ghostfolio"
title="Find Ghostfolio on GitHub"
>GitHub</a
>
</li>
<li>
<a
href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"
title="Join the Ghostfolio Slack community"
>Slack</a
>
</li>
<li>
<a
href="https://twitter.com/ghostfolio_"
title="Follow Ghostfolio on Twitter"
>Twitter</a
>
</li>
<li>&nbsp;</li>
<li>
<a href="../de" title="Ghostfolio in Deutsch">Deutsch</a>
</li>
<li>
<a href="../en" title="Ghostfolio in English">English</a>
</li>
<li>
<a href="../es" title="Ghostfolio in Español">Español</a>
</li>
<li>
<a href="../fr" title="Ghostfolio en Français">Français</a>
</li>
<li>
<a href="../it" title="Ghostfolio in Italiano">Italiano</a>
</li>
<li>
<a href="../nl" title="Ghostfolio in Nederlands">Nederlands</a>
</li>
<li>
<a href="../pt" title="Ghostfolio in Português">Português</a>
</li>
</ul>
</div>
</div> </div>
<div class="py-2 text-muted">
<small i18n <div class="row text-center">
>The risk of loss in trading can be substantial. It is not advisable to <div class="col">
invest money you may need in the short term.</small © 2021 - {{ currentYear }} <a href="https://ghostfol.io">Ghostfolio</a>
> {{ version }}
</div>
</div>
<div class="row text-center text-muted">
<div class="col">
<small i18n
>The risk of loss in trading can be substantial. It is not advisable
to invest money you may need in the short term.</small
>
</div>
</div> </div>
</div> </div>
</footer> </footer>

View File

@ -4,6 +4,11 @@
display: block; display: block;
min-height: 100vh; min-height: 100vh;
footer {
background-color: rgba(var(--palette-foreground-text), 0.05);
font-size: 90%;
}
main { main {
min-height: 100vh; min-height: 100vh;
padding-top: 5rem; padding-top: 5rem;
@ -25,14 +30,13 @@
} }
} }
} }
.footer {
height: 5rem;
line-height: 1;
}
} }
:host-context(.is-dark-theme) { :host-context(.is-dark-theme) {
footer {
background-color: rgba(var(--palette-foreground-text-dark), 0.05);
}
main { main {
.info-message-container { .info-message-container {
.info-message { .info-message {

View File

@ -32,6 +32,9 @@ export class AppComponent implements OnDestroy, OnInit {
public currentRoute: string; public currentRoute: string;
public currentYear = new Date().getFullYear(); public currentYear = new Date().getFullYear();
public deviceType: string; public deviceType: string;
public hasPermissionForBlog: boolean;
public hasPermissionForSubscription: boolean;
public hasPermissionToAccessFearAndGreedIndex: boolean;
public info: InfoItem; public info: InfoItem;
public pageTitle: string; public pageTitle: string;
public user: User; public user: User;
@ -55,6 +58,22 @@ export class AppComponent implements OnDestroy, OnInit {
public ngOnInit() { public ngOnInit() {
this.deviceType = this.deviceService.getDeviceInfo().deviceType; this.deviceType = this.deviceService.getDeviceInfo().deviceType;
this.info = this.dataService.fetchInfo();
this.hasPermissionForBlog = hasPermission(
this.info?.globalPermissions,
permissions.enableBlog
);
this.hasPermissionForSubscription = hasPermission(
this.info?.globalPermissions,
permissions.enableSubscription
);
this.hasPermissionToAccessFearAndGreedIndex = hasPermission(
this.info?.globalPermissions,
permissions.enableFearAndGreedIndex
);
this.router.events this.router.events
.pipe(filter((event) => event instanceof NavigationEnd)) .pipe(filter((event) => event instanceof NavigationEnd))
@ -64,8 +83,6 @@ export class AppComponent implements OnDestroy, OnInit {
const urlSegments = urlSegmentGroup.segments; const urlSegments = urlSegmentGroup.segments;
this.currentRoute = urlSegments[0].path; this.currentRoute = urlSegments[0].path;
this.info = this.dataService.fetchInfo();
if (this.deviceType === 'mobile') { if (this.deviceType === 'mobile') {
setTimeout(() => { setTimeout(() => {
const index = this.title.getTitle().indexOf(''); const index = this.title.getTitle().indexOf('');

View File

@ -14,6 +14,7 @@ import { MatTooltipModule } from '@angular/material/tooltip';
import { BrowserModule } from '@angular/platform-browser'; import { BrowserModule } from '@angular/platform-browser';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { ServiceWorkerModule } from '@angular/service-worker'; import { ServiceWorkerModule } from '@angular/service-worker';
import { GfLogoModule } from '@ghostfolio/ui/logo';
import { MarkdownModule } from 'ngx-markdown'; import { MarkdownModule } from 'ngx-markdown';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { NgxStripeModule, STRIPE_PUBLISHABLE_KEY } from 'ngx-stripe'; import { NgxStripeModule, STRIPE_PUBLISHABLE_KEY } from 'ngx-stripe';
@ -40,6 +41,7 @@ export function NgxStripeFactory(): string {
BrowserAnimationsModule, BrowserAnimationsModule,
BrowserModule, BrowserModule,
GfHeaderModule, GfHeaderModule,
GfLogoModule,
GfSubscriptionInterstitialDialogModule, GfSubscriptionInterstitialDialogModule,
HttpClientModule, HttpClientModule,
MarkdownModule.forRoot(), MarkdownModule.forRoot(),

View File

@ -47,7 +47,7 @@
[matMenuTriggerFor]="transactionMenu" [matMenuTriggerFor]="transactionMenu"
(click)="$event.stopPropagation()" (click)="$event.stopPropagation()"
> >
<ion-icon name="ellipsis-vertical"></ion-icon> <ion-icon name="ellipsis-horizontal"></ion-icon>
</button> </button>
<mat-menu #transactionMenu="matMenu" xPosition="before"> <mat-menu #transactionMenu="matMenu" xPosition="before">
<button mat-menu-item (click)="onDeleteAccess(element.id)"> <button mat-menu-item (click)="onDeleteAccess(element.id)">
@ -57,6 +57,6 @@
</td> </td>
</ng-container> </ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr> <tr *matHeaderRowDef="displayedColumns" mat-header-row></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns"></tr> <tr *matRowDef="let row; columns: displayedColumns" mat-row></tr>
</table> </table>

View File

@ -12,8 +12,9 @@
<div class="col-12 d-flex justify-content-center mb-3"> <div class="col-12 d-flex justify-content-center mb-3">
<gf-value <gf-value
size="large" size="large"
[currency]="user?.settings?.baseCurrency" [isCurrency]="true"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[unit]="user?.settings?.baseCurrency"
[value]="valueInBaseCurrency" [value]="valueInBaseCurrency"
></gf-value> ></gf-value>
</div> </div>
@ -24,8 +25,9 @@
<gf-value <gf-value
i18n i18n
size="medium" size="medium"
[currency]="currency" [isCurrency]="true"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[unit]="currency"
[value]="balance" [value]="balance"
>Cash Balance</gf-value >Cash Balance</gf-value
> >
@ -34,8 +36,9 @@
<gf-value <gf-value
i18n i18n
size="medium" size="medium"
[currency]="currency" [isCurrency]="true"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[unit]="currency"
[value]="equity" [value]="equity"
>Equity</gf-value >Equity</gf-value
> >

View File

@ -207,6 +207,30 @@
</td> </td>
</ng-container> </ng-container>
<ng-container matColumnDef="comment">
<th
*matHeaderCellDef
class="d-none d-lg-table-cell px-1"
mat-header-cell
></th>
<td *matCellDef="let element" class="d-none d-lg-table-cell px-1" mat-cell>
<button
*ngIf="element.comment"
class="mx-1 no-min-width px-2"
mat-button
title="Note"
(click)="onOpenComment(element.comment); $event.stopPropagation()"
>
<ion-icon name="document-text-outline"></ion-icon>
</button>
</td>
<td
*matFooterCellDef
class="d-none d-lg-table-cell px-1"
mat-footer-cell
></td>
</ng-container>
<ng-container matColumnDef="actions"> <ng-container matColumnDef="actions">
<th *matHeaderCellDef class="px-1 text-center" i18n mat-header-cell></th> <th *matHeaderCellDef class="px-1 text-center" i18n mat-header-cell></th>
<td *matCellDef="let element" class="px-1 text-center" mat-cell> <td *matCellDef="let element" class="px-1 text-center" mat-cell>
@ -216,7 +240,7 @@
[matMenuTriggerFor]="accountMenu" [matMenuTriggerFor]="accountMenu"
(click)="$event.stopPropagation()" (click)="$event.stopPropagation()"
> >
<ion-icon name="ellipsis-vertical"></ion-icon> <ion-icon name="ellipsis-horizontal"></ion-icon>
</button> </button>
<mat-menu #accountMenu="matMenu" xPosition="before"> <mat-menu #accountMenu="matMenu" xPosition="before">
<button mat-menu-item (click)="onUpdateAccount(element)"> <button mat-menu-item (click)="onUpdateAccount(element)">

View File

@ -58,7 +58,8 @@ export class AccountsTableComponent implements OnChanges, OnDestroy, OnInit {
'balance', 'balance',
'value', 'value',
'currency', 'currency',
'valueInBaseCurrency' 'valueInBaseCurrency',
'comment'
]; ];
if (this.showActions) { if (this.showActions) {
@ -92,6 +93,10 @@ export class AccountsTableComponent implements OnChanges, OnDestroy, OnInit {
}); });
} }
public onOpenComment(aComment: string) {
alert(aComment);
}
public onUpdateAccount(aAccount: AccountModel) { public onUpdateAccount(aAccount: AccountModel) {
this.accountToUpdate.emit(aAccount); this.accountToUpdate.emit(aAccount);
} }

View File

@ -108,7 +108,7 @@
[matMenuTriggerFor]="jobActionsMenu" [matMenuTriggerFor]="jobActionsMenu"
(click)="$event.stopPropagation()" (click)="$event.stopPropagation()"
> >
<ion-icon name="ellipsis-vertical"></ion-icon> <ion-icon name="ellipsis-horizontal"></ion-icon>
</button> </button>
<mat-menu #jobActionsMenu="matMenu" xPosition="before"> <mat-menu #jobActionsMenu="matMenu" xPosition="before">
<button mat-menu-item (click)="onViewData(job.data)"> <button mat-menu-item (click)="onViewData(job.data)">

View File

@ -140,21 +140,9 @@
[matMenuTriggerFor]="assetProfileActionsMenu" [matMenuTriggerFor]="assetProfileActionsMenu"
(click)="$event.stopPropagation()" (click)="$event.stopPropagation()"
> >
<ion-icon name="ellipsis-vertical"></ion-icon> <ion-icon name="ellipsis-horizontal"></ion-icon>
</button> </button>
<mat-menu #assetProfileActionsMenu="matMenu" xPosition="before"> <mat-menu #assetProfileActionsMenu="matMenu" xPosition="before">
<button
mat-menu-item
(click)="onGatherSymbol({dataSource: element.dataSource, symbol: element.symbol})"
>
<ng-container i18n>Gather Historical Data</ng-container>
</button>
<button
mat-menu-item
(click)="onGatherProfileDataBySymbol({dataSource: element.dataSource, symbol: element.symbol})"
>
<ng-container i18n>Gather Profile Data</ng-container>
</button>
<button <button
mat-menu-item mat-menu-item
[disabled]="element.activitiesCount !== 0" [disabled]="element.activitiesCount !== 0"

View File

@ -10,13 +10,13 @@ import { FormBuilder } from '@angular/forms';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { UpdateAssetProfileDto } from '@ghostfolio/api/app/admin/update-asset-profile.dto'; import { UpdateAssetProfileDto } from '@ghostfolio/api/app/admin/update-asset-profile.dto';
import { AdminService } from '@ghostfolio/client/services/admin.service'; import { AdminService } from '@ghostfolio/client/services/admin.service';
import { DataService } from '@ghostfolio/client/services/data.service';
import { import {
AdminMarketDataDetails, AdminMarketDataDetails,
EnhancedSymbolProfile,
UniqueAsset UniqueAsset
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { translate } from '@ghostfolio/ui/i18n'; import { translate } from '@ghostfolio/ui/i18n';
import { MarketData } from '@prisma/client'; import { MarketData, SymbolProfile } from '@prisma/client';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators'; import { takeUntil } from 'rxjs/operators';
@ -37,9 +37,11 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
symbolMapping: '' symbolMapping: ''
}); });
public assetSubClass: string; public assetSubClass: string;
public benchmarks: Partial<SymbolProfile>[];
public countries: { public countries: {
[code: string]: { name: string; value: number }; [code: string]: { name: string; value: number };
}; };
public isBenchmark = false;
public marketDataDetails: MarketData[] = []; public marketDataDetails: MarketData[] = [];
public sectors: { public sectors: {
[name: string]: { name: string; value: number }; [name: string]: { name: string; value: number };
@ -51,11 +53,14 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
private adminService: AdminService, private adminService: AdminService,
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
@Inject(MAT_DIALOG_DATA) public data: AssetProfileDialogParams, @Inject(MAT_DIALOG_DATA) public data: AssetProfileDialogParams,
private dataService: DataService,
public dialogRef: MatDialogRef<AssetProfileDialog>, public dialogRef: MatDialogRef<AssetProfileDialog>,
private formBuilder: FormBuilder private formBuilder: FormBuilder
) {} ) {}
public ngOnInit(): void { public ngOnInit(): void {
this.benchmarks = this.dataService.fetchInfo().benchmarks;
this.initialize(); this.initialize();
} }
@ -72,6 +77,9 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
this.assetClass = translate(this.assetProfile?.assetClass); this.assetClass = translate(this.assetProfile?.assetClass);
this.assetSubClass = translate(this.assetProfile?.assetSubClass); this.assetSubClass = translate(this.assetProfile?.assetSubClass);
this.countries = {}; this.countries = {};
this.isBenchmark = this.benchmarks.some(({ id }) => {
return id === this.assetProfile.id;
});
this.marketDataDetails = marketData; this.marketDataDetails = marketData;
this.sectors = {}; this.sectors = {};
@ -128,6 +136,17 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
} }
} }
public onSetBenchmark({ dataSource, symbol }: UniqueAsset) {
this.dataService
.postBenchmark({ dataSource, symbol })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
setTimeout(() => {
window.location.reload();
}, 300);
});
}
public onSubmit() { public onSubmit() {
let symbolMapping = {}; let symbolMapping = {};

View File

@ -37,6 +37,13 @@
> >
<ng-container i18n>Gather Profile Data</ng-container> <ng-container i18n>Gather Profile Data</ng-container>
</button> </button>
<button
mat-menu-item
[disabled]="isBenchmark"
(click)="onSetBenchmark({dataSource: data.dataSource, symbol: data.symbol})"
>
<ng-container i18n>Set as Benchmark</ng-container>
</button>
</mat-menu> </mat-menu>
</div> </div>

View File

@ -72,19 +72,6 @@
</div> </div>
</div> </div>
</div> </div>
<div
*ngIf="info?.benchmarks?.length > 0"
class="align-items-start d-flex my-3"
>
<div class="w-50" i18n>Benchmarks</div>
<div class="w-50">
<table>
<tr *ngFor="let benchmark of info.benchmarks">
<td class="pl-1">{{ benchmark.symbol }}</td>
</tr>
</table>
</div>
</div>
<div <div
*ngIf="info?.tags?.length > 0" *ngIf="info?.tags?.length > 0"
class="align-items-start d-flex my-3" class="align-items-start d-flex my-3"

View File

@ -82,7 +82,7 @@
[matMenuTriggerFor]="platformMenu" [matMenuTriggerFor]="platformMenu"
(click)="$event.stopPropagation()" (click)="$event.stopPropagation()"
> >
<ion-icon name="ellipsis-vertical"></ion-icon> <ion-icon name="ellipsis-horizontal"></ion-icon>
</button> </button>
<mat-menu #platformMenu="matMenu" xPosition="before"> <mat-menu #platformMenu="matMenu" xPosition="before">
<button mat-menu-item (click)="onUpdatePlatform(element)"> <button mat-menu-item (click)="onUpdatePlatform(element)">

View File

@ -109,7 +109,7 @@
[matMenuTriggerFor]="userMenu" [matMenuTriggerFor]="userMenu"
(click)="$event.stopPropagation()" (click)="$event.stopPropagation()"
> >
<ion-icon name="ellipsis-vertical"></ion-icon> <ion-icon name="ellipsis-horizontal"></ion-icon>
</button> </button>
<mat-menu #userMenu="matMenu" xPosition="before"> <mat-menu #userMenu="matMenu" xPosition="before">
<button <button

View File

@ -1,6 +1,8 @@
<div class="mb-2 row"> <div class="mb-2 row">
<div class="col-md-6 col-xs-12 d-flex"> <div class="col-md-6 col-xs-12 d-flex">
<div class="align-items-center d-flex flex-grow-1 h5 mb-0 text-truncate"> <div
class="align-items-center d-flex flex-grow-1 h5 mb-0 py-2 text-truncate"
>
<span i18n>Performance</span> <span i18n>Performance</span>
<gf-premium-indicator <gf-premium-indicator
*ngIf="user?.subscription?.type === 'Basic'" *ngIf="user?.subscription?.type === 'Basic'"
@ -13,7 +15,6 @@
appearance="outline" appearance="outline"
class="w-100 without-hint" class="w-100 without-hint"
color="accent" color="accent"
[hidden]="benchmarks?.length === 0"
> >
<mat-label i18n>Compare with...</mat-label> <mat-label i18n>Compare with...</mat-label>
<mat-select <mat-select
@ -28,6 +29,12 @@
[value]="symbolProfile.id" [value]="symbolProfile.id"
>{{ symbolProfile.name }}</mat-option >{{ symbolProfile.name }}</mat-option
> >
<mat-option
*ngIf="hasPermissionToAccessAdminControl"
i18n
[routerLink]="['/admin', 'market-data']"
>Manage Benchmarks</mat-option
>
</mat-select> </mat-select>
</mat-form-field> </mat-form-field>
</div> </div>

View File

@ -23,6 +23,7 @@ import {
parseDate parseDate
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
import { LineChartItem, User } from '@ghostfolio/common/interfaces'; import { LineChartItem, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { ColorScheme } from '@ghostfolio/common/types'; import { ColorScheme } from '@ghostfolio/common/types';
import { SymbolProfile } from '@prisma/client'; import { SymbolProfile } from '@prisma/client';
import { import {
@ -59,6 +60,7 @@ export class BenchmarkComparatorComponent implements OnChanges, OnDestroy {
@ViewChild('chartCanvas') chartCanvas; @ViewChild('chartCanvas') chartCanvas;
public chart: Chart<'line'>; public chart: Chart<'line'>;
public hasPermissionToAccessAdminControl: boolean;
public constructor() { public constructor() {
Chart.register( Chart.register(
@ -76,6 +78,11 @@ export class BenchmarkComparatorComponent implements OnChanges, OnDestroy {
} }
public ngOnChanges() { public ngOnChanges() {
this.hasPermissionToAccessAdminControl = hasPermission(
this.user?.permissions,
permissions.accessAdminControl
);
if (this.performanceDataItems) { if (this.performanceDataItems) {
this.initialize(); this.initialize();
} }

View File

@ -2,6 +2,7 @@ import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core'; import { NgModule } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatSelectModule } from '@angular/material/select'; import { MatSelectModule } from '@angular/material/select';
import { RouterModule } from '@angular/router';
import { GfPremiumIndicatorModule } from '@ghostfolio/ui/premium-indicator'; import { GfPremiumIndicatorModule } from '@ghostfolio/ui/premium-indicator';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
@ -16,7 +17,8 @@ import { BenchmarkComparatorComponent } from './benchmark-comparator.component';
GfPremiumIndicatorModule, GfPremiumIndicatorModule,
MatSelectModule, MatSelectModule,
NgxSkeletonLoaderModule, NgxSkeletonLoaderModule,
ReactiveFormsModule ReactiveFormsModule,
RouterModule
] ]
}) })
export class GfBenchmarkComparatorModule {} export class GfBenchmarkComparatorModule {}

View File

@ -8,216 +8,238 @@
<gf-logo [label]="pageTitle"></gf-logo> <gf-logo [label]="pageTitle"></gf-logo>
</a> </a>
<span class="spacer"></span> <span class="spacer"></span>
<a <ul class="alig-items-center d-flex list-inline m-0">
class="d-none d-sm-block" <li class="list-inline-item">
i18n <a
mat-flat-button class="d-none d-sm-block"
[ngClass]="{ i18n
'font-weight-bold': currentRoute === 'home' || currentRoute === 'zen', mat-flat-button
'text-decoration-underline': [ngClass]="{
currentRoute === 'home' || currentRoute === 'zen' 'font-weight-bold':
}" currentRoute === 'home' || currentRoute === 'zen',
[routerLink]="['/']" 'text-decoration-underline':
>Overview</a currentRoute === 'home' || currentRoute === 'zen'
> }"
<a [routerLink]="['/']"
class="d-none d-sm-block mx-1" >Overview</a
i18n
mat-flat-button
[ngClass]="{
'font-weight-bold': currentRoute === 'portfolio',
'text-decoration-underline': currentRoute === 'portfolio'
}"
[routerLink]="['/portfolio']"
>Portfolio</a
>
<a
class="d-none d-sm-block mx-1"
i18n
mat-flat-button
[ngClass]="{
'font-weight-bold': currentRoute === 'accounts',
'text-decoration-underline': currentRoute === 'accounts'
}"
[routerLink]="['/accounts']"
>Accounts</a
>
<a
*ngIf="hasPermissionToAccessAdminControl"
class="d-none d-sm-block mx-1"
i18n
mat-flat-button
[ngClass]="{
'font-weight-bold': currentRoute === 'admin',
'text-decoration-underline': currentRoute === 'admin'
}"
[routerLink]="['/admin']"
>Admin Control</a
>
<a
class="d-none d-sm-block mx-1"
i18n
mat-flat-button
[ngClass]="{
'font-weight-bold': currentRoute === 'resources',
'text-decoration-underline': currentRoute === 'resources'
}"
[routerLink]="['/resources']"
>Resources</a
>
<a
*ngIf="
hasPermissionForSubscription && user?.subscription?.type === 'Basic'
"
class="d-none d-sm-block mx-1"
i18n
mat-flat-button
[ngClass]="{
'font-weight-bold': currentRoute === 'pricing',
'text-decoration-underline': currentRoute === 'pricing'
}"
[routerLink]="['/pricing']"
>Pricing</a
>
<a
class="d-none d-sm-block mx-1"
i18n
mat-flat-button
[ngClass]="{
'font-weight-bold': currentRoute === 'about',
'text-decoration-underline': currentRoute === 'about'
}"
[routerLink]="['/about']"
>About</a
>
<button
class="no-min-width px-1"
mat-flat-button
[matMenuTriggerFor]="accountMenu"
(menuClosed)="onMenuClosed()"
(menuOpened)="onMenuOpened()"
>
<ion-icon
class="d-none d-sm-block"
name="person-circle-outline"
size="large"
></ion-icon>
<ion-icon
class="d-block d-sm-none"
size="large"
[name]="isMenuOpen ? 'close-outline' : 'menu-outline'"
></ion-icon>
</button>
<mat-menu #accountMenu="matMenu" xPosition="before">
<ng-container *ngIf="user?.access?.length > 0">
<button mat-menu-item (click)="impersonateAccount(null)">
<ion-icon
*ngIf="user?.access?.length > 0"
class="mr-2"
[name]="
impersonationId
? 'radio-button-off-outline'
: 'radio-button-on-outline'
"
></ion-icon>
<span i18n>Me</span>
</button>
<button
*ngFor="let accessItem of user?.access"
mat-menu-item
(click)="impersonateAccount(accessItem.id)"
> >
<ion-icon </li>
class="mr-2" <li class="list-inline-item">
name="square-outline" <a
[name]=" class="d-none d-sm-block mx-1"
accessItem.id === impersonationId i18n
? 'radio-button-on-outline' mat-flat-button
: 'radio-button-off-outline' [ngClass]="{
" 'font-weight-bold': currentRoute === 'portfolio',
></ion-icon> 'text-decoration-underline': currentRoute === 'portfolio'
<span *ngIf="accessItem.alias">{{ accessItem.alias }}</span> }"
<span *ngIf="!accessItem.alias" i18n>User</span> [routerLink]="['/portfolio']"
</button> >Portfolio</a
<hr class="m-0" /> >
</ng-container> </li>
<a <li class="list-inline-item">
class="d-flex d-sm-none" <a
i18n class="d-none d-sm-block mx-1"
mat-menu-item i18n
[ngClass]="{ mat-flat-button
'font-weight-bold': currentRoute === 'home' || currentRoute === 'zen' [ngClass]="{
}" 'font-weight-bold': currentRoute === 'accounts',
[routerLink]="['/']" 'text-decoration-underline': currentRoute === 'accounts'
>Overview</a }"
> [routerLink]="['/accounts']"
<a >Accounts</a
class="d-flex d-sm-none" >
i18n </li>
mat-menu-item <li *ngIf="hasPermissionToAccessAdminControl" class="list-inline-item">
[ngClass]="{ <a
'font-weight-bold': currentRoute === 'portfolio' class="d-none d-sm-block mx-1"
}" i18n
[routerLink]="['/portfolio']" mat-flat-button
>Portfolio</a [ngClass]="{
> 'font-weight-bold': currentRoute === 'admin',
<a 'text-decoration-underline': currentRoute === 'admin'
class="d-flex d-sm-none" }"
i18n [routerLink]="['/admin']"
mat-menu-item >Admin Control</a
[ngClass]="{ 'font-weight-bold': currentRoute === 'accounts' }" >
[routerLink]="['/accounts']" </li>
>Accounts</a <li class="list-inline-item">
> <a
<a class="d-none d-sm-block mx-1"
i18n i18n
mat-menu-item mat-flat-button
[ngClass]="{ 'font-weight-bold': currentRoute === 'account' }" [ngClass]="{
[routerLink]="['/account']" 'font-weight-bold': currentRoute === 'resources',
>My Ghostfolio</a 'text-decoration-underline': currentRoute === 'resources'
> }"
<a [routerLink]="['/resources']"
*ngIf="hasPermissionToAccessAdminControl" >Resources</a
class="d-flex d-sm-none" >
i18n </li>
mat-menu-item <li
[ngClass]="{ 'font-weight-bold': currentRoute === 'admin' }"
[routerLink]="['/admin']"
>Admin Control</a
>
<hr class="m-0" />
<a
class="d-flex d-sm-none"
i18n
mat-menu-item
[ngClass]="{
'font-weight-bold': currentRoute === 'resources'
}"
[routerLink]="['/resources']"
>Resources</a
>
<a
*ngIf=" *ngIf="
hasPermissionForSubscription && user?.subscription?.type === 'Basic' hasPermissionForSubscription && user?.subscription?.type === 'Basic'
" "
class="d-flex d-sm-none" class="list-inline-item"
i18n
mat-menu-item
[ngClass]="{ 'font-weight-bold': currentRoute === 'pricing' }"
[routerLink]="['/pricing']"
>Pricing</a
> >
<a <a
class="d-flex d-sm-none" class="d-none d-sm-block mx-1"
i18n i18n
mat-menu-item mat-flat-button
[ngClass]="{ 'font-weight-bold': currentRoute === 'about' }" [ngClass]="{
[routerLink]="['/about']" 'font-weight-bold': currentRoute === 'pricing',
>About Ghostfolio</a 'text-decoration-underline': currentRoute === 'pricing'
> }"
<hr class="d-flex d-sm-none m-0" /> [routerLink]="['/pricing']"
<button mat-menu-item (click)="onSignOut()">Logout</button> >Pricing</a
</mat-menu> >
</li>
<li class="list-inline-item">
<a
class="d-none d-sm-block mx-1"
i18n
mat-flat-button
[ngClass]="{
'font-weight-bold': currentRoute === 'about',
'text-decoration-underline': currentRoute === 'about'
}"
[routerLink]="['/about']"
>About</a
>
</li>
<li class="list-inline-item">
<button
class="no-min-width px-1"
mat-flat-button
[matMenuTriggerFor]="accountMenu"
(menuClosed)="onMenuClosed()"
(menuOpened)="onMenuOpened()"
>
<ion-icon
class="d-none d-sm-block"
name="person-circle-outline"
size="large"
></ion-icon>
<ion-icon
class="d-block d-sm-none"
size="large"
[name]="isMenuOpen ? 'close-outline' : 'menu-outline'"
></ion-icon>
</button>
<mat-menu #accountMenu="matMenu" xPosition="before">
<ng-container *ngIf="user?.access?.length > 0">
<button mat-menu-item (click)="impersonateAccount(null)">
<ion-icon
*ngIf="user?.access?.length > 0"
class="mr-2"
[name]="
impersonationId
? 'radio-button-off-outline'
: 'radio-button-on-outline'
"
></ion-icon>
<span i18n>Me</span>
</button>
<button
*ngFor="let accessItem of user?.access"
mat-menu-item
(click)="impersonateAccount(accessItem.id)"
>
<ion-icon
class="mr-2"
name="square-outline"
[name]="
accessItem.id === impersonationId
? 'radio-button-on-outline'
: 'radio-button-off-outline'
"
></ion-icon>
<span *ngIf="accessItem.alias">{{ accessItem.alias }}</span>
<span *ngIf="!accessItem.alias" i18n>User</span>
</button>
<hr class="m-0" />
</ng-container>
<a
class="d-flex d-sm-none"
i18n
mat-menu-item
[ngClass]="{
'font-weight-bold':
currentRoute === 'home' || currentRoute === 'zen'
}"
[routerLink]="['/']"
>Overview</a
>
<a
class="d-flex d-sm-none"
i18n
mat-menu-item
[ngClass]="{
'font-weight-bold': currentRoute === 'portfolio'
}"
[routerLink]="['/portfolio']"
>Portfolio</a
>
<a
class="d-flex d-sm-none"
i18n
mat-menu-item
[ngClass]="{ 'font-weight-bold': currentRoute === 'accounts' }"
[routerLink]="['/accounts']"
>Accounts</a
>
<a
i18n
mat-menu-item
[ngClass]="{ 'font-weight-bold': currentRoute === 'account' }"
[routerLink]="['/account']"
>My Ghostfolio</a
>
<a
*ngIf="hasPermissionToAccessAdminControl"
class="d-flex d-sm-none"
i18n
mat-menu-item
[ngClass]="{ 'font-weight-bold': currentRoute === 'admin' }"
[routerLink]="['/admin']"
>Admin Control</a
>
<hr class="m-0" />
<a
class="d-flex d-sm-none"
i18n
mat-menu-item
[ngClass]="{
'font-weight-bold': currentRoute === 'resources'
}"
[routerLink]="['/resources']"
>Resources</a
>
<a
*ngIf="
hasPermissionForSubscription &&
user?.subscription?.type === 'Basic'
"
class="d-flex d-sm-none"
i18n
mat-menu-item
[ngClass]="{ 'font-weight-bold': currentRoute === 'pricing' }"
[routerLink]="['/pricing']"
>Pricing</a
>
<a
class="d-flex d-sm-none"
i18n
mat-menu-item
[ngClass]="{ 'font-weight-bold': currentRoute === 'about' }"
[routerLink]="['/about']"
>About Ghostfolio</a
>
<hr class="d-flex d-sm-none m-0" />
<button mat-menu-item (click)="onSignOut()">Logout</button>
</mat-menu>
</li>
</ul>
</ng-container> </ng-container>
<ng-container *ngIf="user === null"> <ng-container *ngIf="user === null">
<a <a
@ -231,67 +253,86 @@
></gf-logo> ></gf-logo>
</a> </a>
<span class="spacer"></span> <span class="spacer"></span>
<a <ul class="alig-items-center d-flex list-inline m-0">
class="d-none d-sm-block mx-1" <li class="list-inline-item">
i18n <a
mat-flat-button class="d-none d-sm-block mx-1"
[ngClass]="{ i18n
'font-weight-bold': currentRoute === 'features', mat-flat-button
'text-decoration-underline': currentRoute === 'features' [ngClass]="{
}" 'font-weight-bold': currentRoute === 'features',
[routerLink]="['/features']" 'text-decoration-underline': currentRoute === 'features'
>Features</a }"
> [routerLink]="['/features']"
<a >Features</a
class="d-none d-sm-block mx-1" >
i18n </li>
mat-flat-button <li class="list-inline-item">
[ngClass]="{ <a
'font-weight-bold': currentRoute === 'about', class="d-none d-sm-block mx-1"
'text-decoration-underline': currentRoute === 'about' i18n
}" mat-flat-button
[routerLink]="['/about']" [ngClass]="{
>About</a 'font-weight-bold': currentRoute === 'about',
> 'text-decoration-underline': currentRoute === 'about'
<a }"
*ngIf="hasPermissionForSubscription" [routerLink]="['/about']"
i18n >About</a
mat-flat-button >
[ngClass]="{ </li>
'font-weight-bold': currentRoute === 'pricing', <li *ngIf="hasPermissionForSubscription" class="list-inline-item">
'text-decoration-underline': currentRoute === 'pricing' <a
}" i18n
[routerLink]="['/pricing']" mat-flat-button
>Pricing</a [ngClass]="{
> 'font-weight-bold': currentRoute === 'pricing',
<a 'text-decoration-underline': currentRoute === 'pricing'
*ngIf="hasPermissionToAccessFearAndGreedIndex" }"
class="d-none d-sm-block mx-1" [routerLink]="['/pricing']"
i18n >Pricing</a
mat-flat-button >
[ngClass]="{ </li>
'font-weight-bold': currentRoute === 'markets', <li
'text-decoration-underline': currentRoute === 'markets' *ngIf="hasPermissionToAccessFearAndGreedIndex"
}" class="list-inline-item"
[routerLink]="['/markets']" >
>Markets</a <a
> class="d-none d-sm-block mx-1"
<a i18n
class="d-none d-sm-block no-min-width" mat-flat-button
href="https://github.com/ghostfolio/ghostfolio" [ngClass]="{
mat-icon-button 'font-weight-bold': currentRoute === 'markets',
><ion-icon name="logo-github"></ion-icon 'text-decoration-underline': currentRoute === 'markets'
></a> }"
<button class="mx-1" mat-flat-button (click)="openLoginDialog()"> [routerLink]="['/markets']"
<ng-container i18n>Sign in</ng-container> >Markets</a
</button> >
<a </li>
*ngIf="currentRoute !== 'register' && hasPermissionToCreateUser" <li class="list-inline-item">
class="d-none d-sm-block" <a
color="primary" class="d-none d-sm-block no-min-width p-1"
mat-flat-button href="https://github.com/ghostfolio/ghostfolio"
[routerLink]="['/register']" mat-flat-button
><ng-container i18n>Get started</ng-container> ><ion-icon name="logo-github"></ion-icon
</a> ></a>
</li>
<li class="list-inline-item">
<button class="mx-1" mat-flat-button (click)="openLoginDialog()">
<ng-container i18n>Sign in</ng-container>
</button>
</li>
<li
*ngIf="currentRoute !== 'register' && hasPermissionToCreateUser"
class="list-inline-item"
>
<a
class="d-none d-sm-block"
color="primary"
mat-flat-button
[routerLink]="['/register']"
><ng-container i18n>Get started</ng-container>
</a>
</li>
</ul>
</ng-container> </ng-container>
</mat-toolbar> </mat-toolbar>

View File

@ -13,8 +13,9 @@
<div class="justify-content-end"> <div class="justify-content-end">
<gf-value <gf-value
class="justify-content-end" class="justify-content-end"
[currency]="baseCurrency" [isCurrency]="true"
[locale]="locale" [locale]="locale"
[unit]="baseCurrency"
[value]="isLoading ? undefined : summary?.totalBuy" [value]="isLoading ? undefined : summary?.totalBuy"
></gf-value> ></gf-value>
</div> </div>
@ -24,8 +25,9 @@
<div class="justify-content-end"> <div class="justify-content-end">
<gf-value <gf-value
class="justify-content-end" class="justify-content-end"
[currency]="baseCurrency" [isCurrency]="true"
[locale]="locale" [locale]="locale"
[unit]="baseCurrency"
[value]="isLoading ? undefined : summary?.totalSell" [value]="isLoading ? undefined : summary?.totalSell"
></gf-value> ></gf-value>
</div> </div>
@ -38,8 +40,9 @@
<div class="justify-content-end"> <div class="justify-content-end">
<gf-value <gf-value
class="justify-content-end" class="justify-content-end"
[currency]="baseCurrency" [isCurrency]="true"
[locale]="locale" [locale]="locale"
[unit]="baseCurrency"
[value]="isLoading ? undefined : summary?.committedFunds" [value]="isLoading ? undefined : summary?.committedFunds"
></gf-value> ></gf-value>
</div> </div>
@ -49,8 +52,9 @@
<div class="justify-content-end"> <div class="justify-content-end">
<gf-value <gf-value
class="justify-content-end" class="justify-content-end"
[currency]="baseCurrency" [isCurrency]="true"
[locale]="locale" [locale]="locale"
[unit]="baseCurrency"
[value]="isLoading ? undefined : summary?.currentGrossPerformance" [value]="isLoading ? undefined : summary?.currentGrossPerformance"
></gf-value> ></gf-value>
</div> </div>
@ -79,8 +83,9 @@
<span *ngIf="summary?.fees || summary?.fees === 0" class="mr-1">-</span> <span *ngIf="summary?.fees || summary?.fees === 0" class="mr-1">-</span>
<gf-value <gf-value
class="justify-content-end" class="justify-content-end"
[currency]="baseCurrency" [isCurrency]="true"
[locale]="locale" [locale]="locale"
[unit]="baseCurrency"
[value]="isLoading ? undefined : summary?.fees" [value]="isLoading ? undefined : summary?.fees"
></gf-value> ></gf-value>
</div> </div>
@ -93,8 +98,9 @@
<div class="justify-content-end"> <div class="justify-content-end">
<gf-value <gf-value
class="justify-content-end" class="justify-content-end"
[currency]="baseCurrency" [isCurrency]="true"
[locale]="locale" [locale]="locale"
[unit]="baseCurrency"
[value]="isLoading ? undefined : summary?.currentNetPerformance" [value]="isLoading ? undefined : summary?.currentNetPerformance"
></gf-value> ></gf-value>
</div> </div>
@ -121,8 +127,9 @@
<gf-value <gf-value
class="justify-content-end" class="justify-content-end"
position="end" position="end"
[currency]="baseCurrency" [isCurrency]="true"
[locale]="locale" [locale]="locale"
[unit]="baseCurrency"
[value]="isLoading ? undefined : summary?.currentValue" [value]="isLoading ? undefined : summary?.currentValue"
></gf-value> ></gf-value>
</div> </div>
@ -132,8 +139,9 @@
<div class="justify-content-end"> <div class="justify-content-end">
<gf-value <gf-value
class="justify-content-end" class="justify-content-end"
[currency]="baseCurrency" [isCurrency]="true"
[locale]="locale" [locale]="locale"
[unit]="baseCurrency"
[value]="isLoading ? undefined : summary?.items" [value]="isLoading ? undefined : summary?.items"
></gf-value> ></gf-value>
</div> </div>
@ -152,8 +160,9 @@
></ion-icon> ></ion-icon>
<gf-value <gf-value
class="justify-content-end" class="justify-content-end"
[currency]="baseCurrency" [isCurrency]="true"
[locale]="locale" [locale]="locale"
[unit]="baseCurrency"
[value]="isLoading ? undefined : summary?.emergencyFund" [value]="isLoading ? undefined : summary?.emergencyFund"
></gf-value> ></gf-value>
</div> </div>
@ -163,8 +172,9 @@
<div class="justify-content-end"> <div class="justify-content-end">
<gf-value <gf-value
class="justify-content-end" class="justify-content-end"
[currency]="baseCurrency" [isCurrency]="true"
[locale]="locale" [locale]="locale"
[unit]="baseCurrency"
[value]="isLoading ? undefined : summary?.cash" [value]="isLoading ? undefined : summary?.cash"
></gf-value> ></gf-value>
</div> </div>
@ -174,8 +184,9 @@
<div class="justify-content-end"> <div class="justify-content-end">
<gf-value <gf-value
class="justify-content-end" class="justify-content-end"
[currency]="baseCurrency" [isCurrency]="true"
[locale]="locale" [locale]="locale"
[unit]="baseCurrency"
[value]="isLoading ? undefined : summary?.excludedAccountsAndActivities" [value]="isLoading ? undefined : summary?.excludedAccountsAndActivities"
></gf-value> ></gf-value>
</div> </div>
@ -183,13 +194,34 @@
<div class="row"> <div class="row">
<div class="col"><hr /></div> <div class="col"><hr /></div>
</div> </div>
<div class="flex-nowrap px-3 py-1 row">
<div class="flex-grow-1 text-truncate" i18n>Liabilities</div>
<div class="d-flex justify-content-end">
<span
*ngIf="summary?.liabilities || summary?.liabilities === 0"
class="mr-1"
>-</span
>
<gf-value
class="justify-content-end"
[isCurrency]="true"
[locale]="locale"
[unit]="baseCurrency"
[value]="isLoading ? undefined : summary?.liabilities"
></gf-value>
</div>
</div>
<div class="row">
<div class="col"><hr /></div>
</div>
<div class="flex-nowrap px-3 py-1 row"> <div class="flex-nowrap px-3 py-1 row">
<div class="flex-grow-1 font-weight-bold text-truncate" i18n>Net Worth</div> <div class="flex-grow-1 font-weight-bold text-truncate" i18n>Net Worth</div>
<div class="justify-content-end"> <div class="justify-content-end">
<gf-value <gf-value
class="justify-content-end" class="justify-content-end"
[currency]="baseCurrency" [isCurrency]="true"
[locale]="locale" [locale]="locale"
[unit]="baseCurrency"
[value]="isLoading ? undefined : summary?.netWorth" [value]="isLoading ? undefined : summary?.netWorth"
></gf-value> ></gf-value>
</div> </div>
@ -217,8 +249,9 @@
<div class="justify-content-end"> <div class="justify-content-end">
<gf-value <gf-value
class="justify-content-end" class="justify-content-end"
[currency]="baseCurrency" [isCurrency]="true"
[locale]="locale" [locale]="locale"
[unit]="baseCurrency"
[value]="isLoading ? undefined : summary?.dividend" [value]="isLoading ? undefined : summary?.dividend"
></gf-value> ></gf-value>
</div> </div>

View File

@ -12,8 +12,9 @@
<div class="col-12 d-flex justify-content-center mb-3"> <div class="col-12 d-flex justify-content-center mb-3">
<gf-value <gf-value
size="large" size="large"
[currency]="data.baseCurrency" [isCurrency]="true"
[locale]="data.locale" [locale]="data.locale"
[unit]="data.baseCurrency"
[value]="value" [value]="value"
></gf-value> ></gf-value>
</div> </div>
@ -40,8 +41,9 @@
i18n i18n
size="medium" size="medium"
[colorizeSign]="true" [colorizeSign]="true"
[currency]="data.baseCurrency" [isCurrency]="true"
[locale]="data.locale" [locale]="data.locale"
[unit]="data.baseCurrency"
[value]="netPerformance" [value]="netPerformance"
>Change</gf-value >Change</gf-value
> >
@ -61,8 +63,9 @@
<gf-value <gf-value
i18n i18n
size="medium" size="medium"
[currency]="SymbolProfile?.currency" [isCurrency]="true"
[locale]="data.locale" [locale]="data.locale"
[unit]="SymbolProfile?.currency"
[value]="averagePrice" [value]="averagePrice"
>Average Unit Price</gf-value >Average Unit Price</gf-value
> >
@ -71,8 +74,9 @@
<gf-value <gf-value
i18n i18n
size="medium" size="medium"
[currency]="SymbolProfile?.currency" [isCurrency]="true"
[locale]="data.locale" [locale]="data.locale"
[unit]="SymbolProfile?.currency"
[value]="marketPrice" [value]="marketPrice"
>Market Price</gf-value >Market Price</gf-value
> >
@ -81,9 +85,10 @@
<gf-value <gf-value
i18n i18n
size="medium" size="medium"
[currency]="SymbolProfile?.currency" [isCurrency]="true"
[locale]="data.locale" [locale]="data.locale"
[ngClass]="{ 'text-danger': minPrice?.toFixed(2) === marketPrice?.toFixed(2) && maxPrice?.toFixed(2) !== minPrice?.toFixed(2) }" [ngClass]="{ 'text-danger': minPrice?.toFixed(2) === marketPrice?.toFixed(2) && maxPrice?.toFixed(2) !== minPrice?.toFixed(2) }"
[unit]="SymbolProfile?.currency"
[value]="minPrice" [value]="minPrice"
>Minimum Price</gf-value >Minimum Price</gf-value
> >
@ -92,9 +97,10 @@
<gf-value <gf-value
i18n i18n
size="medium" size="medium"
[currency]="SymbolProfile?.currency" [isCurrency]="true"
[locale]="data.locale" [locale]="data.locale"
[ngClass]="{ 'text-success': maxPrice?.toFixed(2) === marketPrice?.toFixed(2) && maxPrice?.toFixed(2) !== minPrice?.toFixed(2) }" [ngClass]="{ 'text-success': maxPrice?.toFixed(2) === marketPrice?.toFixed(2) && maxPrice?.toFixed(2) !== minPrice?.toFixed(2) }"
[unit]="SymbolProfile?.currency"
[value]="maxPrice" [value]="maxPrice"
>Maximum Price</gf-value >Maximum Price</gf-value
> >
@ -113,8 +119,9 @@
<gf-value <gf-value
i18n i18n
size="medium" size="medium"
[currency]="data.baseCurrency" [isCurrency]="true"
[locale]="data.locale" [locale]="data.locale"
[unit]="data.baseCurrency"
[value]="investment" [value]="investment"
>Investment</gf-value >Investment</gf-value
> >
@ -123,8 +130,9 @@
<gf-value <gf-value
i18n i18n
size="medium" size="medium"
[currency]="data.baseCurrency" [isCurrency]="true"
[locale]="data.locale" [locale]="data.locale"
[unit]="data.baseCurrency"
[value]="dividendInBaseCurrency" [value]="dividendInBaseCurrency"
>Dividend</gf-value >Dividend</gf-value
> >
@ -133,8 +141,9 @@
<gf-value <gf-value
i18n i18n
size="medium" size="medium"
[currency]="data.baseCurrency" [isCurrency]="true"
[locale]="data.locale" [locale]="data.locale"
[unit]="data.baseCurrency"
[value]="feeInBaseCurrency" [value]="feeInBaseCurrency"
>Fees</gf-value >Fees</gf-value
> >

View File

@ -45,8 +45,9 @@
<gf-value <gf-value
class="mr-3" class="mr-3"
[colorizeSign]="true" [colorizeSign]="true"
[currency]="baseCurrency" [isCurrency]="true"
[locale]="locale" [locale]="locale"
[unit]="baseCurrency"
[value]="position?.netPerformance" [value]="position?.netPerformance"
></gf-value> ></gf-value>
<gf-value <gf-value

View File

@ -7,6 +7,54 @@ import { AboutPageComponent } from './about-page.component';
const routes: Routes = [ const routes: Routes = [
{ {
canActivate: [AuthGuard], canActivate: [AuthGuard],
children: [
{
path: '',
loadChildren: () =>
import('./overview/about-overview-page.module').then(
(m) => m.AboutOverviewPageModule
)
},
{
path: 'changelog',
loadChildren: () =>
import('./changelog/changelog-page.module').then(
(m) => m.ChangelogPageModule
)
},
...[
'license',
/////
'licenca',
'licence',
'licencia',
'licentie',
'lizenz',
'licenza'
].map((path) => ({
path,
loadChildren: () =>
import('./license/license-page.module').then(
(m) => m.LicensePageModule
)
})),
...[
'privacy-policy',
/////
'datenschutzbestimmungen',
'informativa-sulla-privacy',
'politique-de-confidentialite',
'politica-de-privacidad',
'politica-de-privacidade',
'privacybeleid'
].map((path) => ({
path,
loadChildren: () =>
import('./privacy-policy/privacy-policy-page.module').then(
(m) => m.PrivacyPolicyPageModule
)
}))
],
component: AboutPageComponent, component: AboutPageComponent,
path: '', path: '',
title: $localize`About` title: $localize`About`

View File

@ -1,28 +1,31 @@
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; import {
import { environment } from '@ghostfolio/client/../environments/environment'; ChangeDetectorRef,
Component,
HostBinding,
OnDestroy,
OnInit
} from '@angular/core';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config'; import { TabConfiguration, User } from '@ghostfolio/common/interfaces';
import { Statistics, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators'; import { takeUntil } from 'rxjs/operators';
@Component({ @Component({
host: { class: 'page' },
selector: 'gf-about-page', selector: 'gf-about-page',
styleUrls: ['./about-page.scss'], styleUrls: ['./about-page.scss'],
templateUrl: './about-page.html' templateUrl: './about-page.html'
}) })
export class AboutPageComponent implements OnDestroy, OnInit { export class AboutPageComponent implements OnDestroy, OnInit {
public defaultLanguageCode = DEFAULT_LANGUAGE_CODE; @HostBinding('class.with-info-message') get getHasMessage() {
public hasPermissionForBlog: boolean; return this.hasMessage;
public hasPermissionForStatistics: boolean; }
public hasMessage: boolean;
public hasPermissionForSubscription: boolean; public hasPermissionForSubscription: boolean;
public isLoggedIn: boolean; public tabs: TabConfiguration[] = [];
public statistics: Statistics;
public user: User; public user: User;
public version = environment.version;
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
@ -31,38 +34,55 @@ export class AboutPageComponent implements OnDestroy, OnInit {
private dataService: DataService, private dataService: DataService,
private userService: UserService private userService: UserService
) { ) {
const { globalPermissions, statistics } = this.dataService.fetchInfo(); const { globalPermissions, systemMessage } = this.dataService.fetchInfo();
this.hasPermissionForBlog = hasPermission(
globalPermissions,
permissions.enableBlog
);
this.hasPermissionForStatistics = hasPermission(
globalPermissions,
permissions.enableStatistics
);
this.hasPermissionForSubscription = hasPermission( this.hasPermissionForSubscription = hasPermission(
globalPermissions, globalPermissions,
permissions.enableSubscription permissions.enableSubscription
); );
this.statistics = statistics;
}
public ngOnInit() {
this.userService.stateChanged this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe((state) => { .subscribe((state) => {
if (state?.user) { if (state?.user) {
this.tabs = [
{
iconName: 'reader-outline',
label: $localize`About`,
path: ['/about']
},
{
iconName: 'sparkles-outline',
label: $localize`Changelog`,
path: ['/about', 'changelog']
},
{
iconName: 'ribbon-outline',
label: $localize`License`,
path: ['/about', 'license']
},
{
iconName: 'shield-checkmark-outline',
label: $localize`Privacy Policy`,
path: ['/about', 'privacy-policy'],
showCondition: this.hasPermissionForSubscription
}
];
this.user = state.user; this.user = state.user;
this.hasMessage =
hasPermission(
this.user?.permissions,
permissions.createUserAccount
) || !!systemMessage;
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
} }
}); });
} }
public ngOnInit() {}
public ngOnDestroy() { public ngOnDestroy() {
this.unsubscribeSubject.next(); this.unsubscribeSubject.next();
this.unsubscribeSubject.complete(); this.unsubscribeSubject.complete();

View File

@ -1,253 +1,21 @@
<div class="container"> <mat-tab-nav-panel #tabPanel class="flex-grow-1 overflow-auto">
<div class="mb-5 row"> <router-outlet></router-outlet>
<div class="col"> </mat-tab-nav-panel>
<h3 class="d-none d-sm-block mb-3 text-center">About Ghostfolio</h3>
<div class="about-container">
<p>
Ghostfolio is a lightweight wealth management application for
individuals to keep track of stocks, ETFs or cryptocurrencies and make
solid, data-driven investment decisions. The source code is fully
available as
<a
href="https://github.com/ghostfolio/ghostfolio"
title="Find Ghostfolio on GitHub"
>open source software</a
>
(OSS) under the
<a
href="https://www.gnu.org/licenses/agpl-3.0.html"
title="GNU Affero General Public License"
>AGPL-3.0 license</a
>
and we share aggregated
<a
href="https://ghostfol.io/{{ defaultLanguageCode }}/open"
title="Open Startup"
>key metrics</a
>
of the platforms performance. The project has been initiated by
<a href="https://dotsilver.ch" title="Website of Thomas Kaul"
>Thomas Kaul</a
>
and is driven by the efforts of its
<a
href="https://github.com/ghostfolio/ghostfolio/graphs/contributors"
title="Contributors to Ghostfolio"
>contributors</a
>.
<ng-container *ngIf="version">
This instance is running Ghostfolio {{ version }}.
</ng-container>
<ng-container *ngIf="hasPermissionForStatistics"
>Check the system status at
<a href="https://status.ghostfol.io" title="Ghostfolio status"
>status.ghostfol.io</a
>.</ng-container
>
</p>
<p>
If you encounter a bug or would like to suggest an improvement or a
new
<a [routerLink]="['/features']">feature</a>, please join the
Ghostfolio
<a
href="https://ghostfolio.slack.com"
title="Join the Ghostfolio Slack community"
>Slack community</a
>, tweet to
<a
href="https://twitter.com/ghostfolio_"
title="Tweet to Ghostfolio on Twitter"
>@ghostfolio_</a
><ng-container *ngIf="user?.subscription?.type === 'Premium'"
>, send an e-mail to
<a href="mailto:hi@ghostfol.io" title="Send an e-mail"
>hi@ghostfol.io</a
></ng-container
>
or start a discussion at
<a
href="https://github.com/ghostfolio/ghostfolio"
title="Find Ghostfolio on GitHub"
>GitHub</a
>.
</p>
<p class="text-center">
<a
class="mx-2"
href="https://twitter.com/ghostfolio_"
mat-icon-button
title="Follow Ghostfolio on Twitter"
>
<ion-icon name="logo-twitter"></ion-icon>
</a>
<a
*ngIf="user?.subscription?.type === 'Premium'"
class="mx-2"
href="mailto:hi@ghostfol.io"
mat-icon-button
title="Send an e-mail"
>
<ion-icon name="mail"></ion-icon>
</a>
<a
class="mx-2"
href="https://ghostfolio.slack.com"
mat-icon-button
title="Join the Ghostfolio Slack channel"
>
<ion-icon name="logo-slack"></ion-icon>
</a>
<a
class="mx-2"
href="https://github.com/ghostfolio/ghostfolio"
mat-icon-button
title="Find Ghostfolio on GitHub"
>
<ion-icon name="logo-github"></ion-icon>
</a>
</p>
<div
*ngIf="hasPermissionForSubscription"
class="d-flex justify-content-center"
>
<div
class="independent-and-bootstrapped-logo mb-2"
title="Ghostfolio is an independent & bootstrapped business"
></div>
</div>
<div
*ngIf="!hasPermissionForSubscription"
class="d-flex justify-content-center"
>
<a
href="https://www.buymeacoffee.com/ghostfolio"
target="_blank"
title="Support Ghostfolio"
><img
class="mb-2"
src="../assets/images/button-buy-me-a-coffee.png"
width="180"
/></a>
</div>
</div>
</div>
</div>
<div *ngIf="hasPermissionForStatistics" class="mb-5 row"> <nav mat-align-tabs="center" mat-tab-nav-bar [tabPanel]="tabPanel">
<div class="col"> <ng-container *ngFor="let tab of tabs">
<h3 class="mb-3 text-center">Ghostfolio in Numbers</h3> <a
<mat-card appearance="outlined"> #rla="routerLinkActive"
<mat-card-content> *ngIf="tab.showCondition !== false"
<div class="row"> class="px-3"
<div class="col-xs-12 col-md-4 my-2"> mat-tab-link
<gf-value routerLinkActive
size="large" [active]="rla.isActive"
subLabel="(Last 24 hours)" [routerLink]="tab.path"
[locale]="user?.settings?.locale" [routerLinkActiveOptions]="{ exact: true }"
[value]="statistics?.activeUsers1d ?? '-'"
>Active Users</gf-value
>
</div>
<div class="col-xs-12 col-md-4 my-2">
<gf-value
size="large"
subLabel="(Last 30 days)"
[locale]="user?.settings?.locale"
[value]="statistics?.newUsers30d ?? '-'"
>New Users</gf-value
>
</div>
<div class="col-xs-12 col-md-4 my-2">
<gf-value
size="large"
subLabel="(Last 30 days)"
[locale]="user?.settings?.locale"
[value]="statistics?.activeUsers30d ?? '-'"
>Active Users</gf-value
>
</div>
<div class="col-xs-12 col-md-4 my-2">
<a class="d-block" href="https://ghostfolio.slack.com">
<gf-value
size="large"
[locale]="user?.settings?.locale"
[value]="statistics?.slackCommunityUsers ?? '-'"
>Users in Slack community</gf-value
>
</a>
</div>
<div class="col-xs-12 col-md-4 my-2">
<a
class="d-block"
href="https://github.com/ghostfolio/ghostfolio/graphs/contributors"
>
<gf-value
size="large"
[locale]="user?.settings?.locale"
[value]="statistics?.gitHubContributors ?? '-'"
>Contributors on GitHub</gf-value
>
</a>
</div>
<div class="col-xs-12 col-md-4 my-2">
<a
class="d-block"
href="https://github.com/ghostfolio/ghostfolio/stargazers"
>
<gf-value
size="large"
[locale]="user?.settings?.locale"
[value]="statistics?.gitHubStargazers ?? '-'"
>Stars on GitHub</gf-value
>
</a>
</div>
</div>
</mat-card-content>
</mat-card>
</div>
</div>
<div class="row">
<div *ngIf="hasPermissionForSubscription" class="col-md-3 col-xs-12 my-2">
<a
class="py-4 w-100"
color="primary"
mat-flat-button
[routerLink]="['/faq']"
>FAQ</a
>
</div>
<div
class="col-md-3 col-xs-12 my-2"
[ngClass]="{ 'offset-md-4': !hasPermissionForBlog }"
> >
<a <ion-icon size="large" [name]="tab.iconName"></ion-icon>
class="py-4 w-100" <div class="d-none d-sm-block ml-2">{{ tab.label }}</div>
color="primary" </a>
mat-flat-button </ng-container>
[routerLink]="['/about', 'changelog']" </nav>
>Changelog & License</a
>
</div>
<div *ngIf="hasPermissionForSubscription" class="col-md-3 col-xs-12 my-2">
<a
class="py-4 w-100"
color="primary"
mat-flat-button
[routerLink]="['/about', 'privacy-policy']"
>Privacy Policy</a
>
</div>
<div *ngIf="hasPermissionForBlog" class="col-md-3 col-xs-12 my-2">
<a
class="py-4 w-100"
color="primary"
mat-flat-button
[routerLink]="['/blog']"
>Blog</a
>
</div>
</div>
</div>

View File

@ -1,21 +1,14 @@
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button'; import { MatTabsModule } from '@angular/material/tabs';
import { MatCardModule } from '@angular/material/card'; import { RouterModule } from '@angular/router';
import { GfValueModule } from '@ghostfolio/ui/value';
import { AboutPageRoutingModule } from './about-page-routing.module'; import { AboutPageRoutingModule } from './about-page-routing.module';
import { AboutPageComponent } from './about-page.component'; import { AboutPageComponent } from './about-page.component';
@NgModule({ @NgModule({
declarations: [AboutPageComponent], declarations: [AboutPageComponent],
imports: [ imports: [CommonModule, MatTabsModule, AboutPageRoutingModule, RouterModule],
AboutPageRoutingModule,
CommonModule,
GfValueModule,
MatButtonModule,
MatCardModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA] schemas: [CUSTOM_ELEMENTS_SCHEMA]
}) })
export class AboutPageModule {} export class AboutPageModule {}

View File

@ -1,36 +1,35 @@
@import 'apps/client/src/styles/ghostfolio-style';
:host { :host {
color: rgb(var(--dark-primary-text)); color: rgb(var(--dark-primary-text));
display: block; display: flex;
flex-direction: column;
height: calc(100vh - 5rem);
overflow-y: auto;
.about-container { padding-bottom: env(safe-area-inset-bottom);
a { padding-bottom: constant(safe-area-inset-bottom);
color: rgba(var(--palette-primary-500), 1);
font-weight: 500;
&:hover { ::ng-deep {
color: rgba(var(--palette-primary-300), 1); gf-about-page,
} gf-changelog-page,
gf-privacy-policy-page {
flex: 1 1 auto;
overflow-y: auto;
} }
.independent-and-bootstrapped-logo { .mat-mdc-tab-link-container {
background-image: url('/assets/bootstrapped-dark.svg'); --mdc-tab-indicator-active-indicator-color: transparent;
background-position: center;
background-repeat: no-repeat; .mat-mdc-tab-link {
background-size: contain; &:hover {
height: 2rem; opacity: 0.75;
opacity: 0.87; }
width: 10rem; }
} }
} }
} }
:host-context(.is-dark-theme) { :host-context(.is-dark-theme) {
color: rgb(var(--light-primary-text)); color: rgb(var(--light-primary-text));
.about-container {
.independent-and-bootstrapped-logo {
background-image: url('/assets/bootstrapped-light.svg');
opacity: 1;
}
}
} }

View File

@ -9,7 +9,7 @@ const routes: Routes = [
canActivate: [AuthGuard], canActivate: [AuthGuard],
component: ChangelogPageComponent, component: ChangelogPageComponent,
path: '', path: '',
title: $localize`Changelog & License` title: $localize`Changelog`
} }
]; ];

View File

@ -1,23 +1,10 @@
<div class="container"> <div class="container">
<div class="mb-5 row"> <div class="mb-5 row">
<div class="col"> <div class="col">
<h3 class="mb-3 text-center" i18n>Changelog</h3> <h1 class="d-none d-sm-block h3 mb-3 text-center" i18n>Changelog</h1>
<mat-card appearance="outlined" class="changelog"> <div class="changelog">
<mat-card-content> <markdown [src]="'../assets/CHANGELOG.md'"></markdown>
<markdown [src]="'../assets/CHANGELOG.md'"></markdown> </div>
</mat-card-content>
</mat-card>
</div>
</div>
<div class="row">
<div class="col">
<h3 class="mb-3 text-center" i18n>License</h3>
<mat-card appearance="outlined">
<mat-card-content>
<markdown [src]="'../assets/LICENSE'"></markdown>
</mat-card-content>
</mat-card>
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,6 +1,5 @@
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatCardModule } from '@angular/material/card';
import { MarkdownModule } from 'ngx-markdown'; import { MarkdownModule } from 'ngx-markdown';
import { ChangelogPageRoutingModule } from './changelog-page-routing.module'; import { ChangelogPageRoutingModule } from './changelog-page-routing.module';
@ -11,8 +10,7 @@ import { ChangelogPageComponent } from './changelog-page.component';
imports: [ imports: [
ChangelogPageRoutingModule, ChangelogPageRoutingModule,
CommonModule, CommonModule,
MarkdownModule.forChild(), MarkdownModule.forChild()
MatCardModule
], ],
schemas: [CUSTOM_ELEMENTS_SCHEMA] schemas: [CUSTOM_ELEMENTS_SCHEMA]
}) })

View File

@ -2,35 +2,33 @@
color: rgb(var(--dark-primary-text)); color: rgb(var(--dark-primary-text));
display: block; display: block;
.mat-mdc-card { .changelog {
&.changelog { ::ng-deep {
::ng-deep { a {
a { color: rgba(var(--palette-primary-500), 1);
color: rgba(var(--palette-primary-500), 1); font-weight: 500;
font-weight: 500;
&:hover { &:hover {
color: rgba(var(--palette-primary-300), 1); color: rgba(var(--palette-primary-300), 1);
}
}
markdown {
h1,
p {
display: none;
}
h2 {
font-size: 18px;
&:not(:first-of-type) {
margin-top: 2rem;
} }
} }
markdown { h3 {
h1, font-size: 15px;
p {
display: none;
}
h2 {
font-size: 18px;
&:not(:first-of-type) {
margin-top: 2rem;
}
}
h3 {
font-size: 15px;
}
} }
} }
} }

View File

@ -0,0 +1,20 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import { LicensePageComponent } from './license-page.component';
const routes: Routes = [
{
canActivate: [AuthGuard],
component: LicensePageComponent,
path: '',
title: $localize`License`
}
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class LicensePageRoutingModule {}

View File

@ -0,0 +1,19 @@
import { Component, OnDestroy } from '@angular/core';
import { Subject } from 'rxjs';
@Component({
host: { class: 'page' },
selector: 'gf-license-page',
styleUrls: ['./license-page.scss'],
templateUrl: './license-page.html'
})
export class LicensePageComponent implements OnDestroy {
private unsubscribeSubject = new Subject<void>();
public constructor() {}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
}

View File

@ -0,0 +1,10 @@
<div class="container">
<div class="mb-5 row">
<div class="col">
<h1 class="d-none d-sm-block h3 mb-3 text-center" i18n>License</h1>
<div>
<markdown [src]="'../assets/LICENSE'"></markdown>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,13 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MarkdownModule } from 'ngx-markdown';
import { LicensePageRoutingModule } from './license-page-routing.module';
import { LicensePageComponent } from './license-page.component';
@NgModule({
declarations: [LicensePageComponent],
imports: [LicensePageRoutingModule, CommonModule, MarkdownModule.forChild()],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class LicensePageModule {}

View File

@ -0,0 +1,8 @@
:host {
color: rgb(var(--dark-primary-text));
display: block;
}
:host-context(.is-dark-theme) {
color: rgb(var(--light-primary-text));
}

View File

@ -0,0 +1,20 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import { AboutOverviewPageComponent } from './about-overview-page.component';
const routes: Routes = [
{
canActivate: [AuthGuard],
component: AboutOverviewPageComponent,
path: '',
title: $localize`About`
}
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class AboutOverviewPageRoutingModule {}

View File

@ -0,0 +1,59 @@
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { environment } from '@ghostfolio/client/../environments/environment';
import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
@Component({
host: { class: 'page' },
selector: 'gf-about-overview-page',
styleUrls: ['./about-overview-page.scss'],
templateUrl: './about-overview-page.html'
})
export class AboutOverviewPageComponent implements OnDestroy, OnInit {
public hasPermissionForBlog: boolean;
public hasPermissionForSubscription: boolean;
public isLoggedIn: boolean;
public user: User;
public version = environment.version;
private unsubscribeSubject = new Subject<void>();
public constructor(
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
private userService: UserService
) {
const { globalPermissions } = this.dataService.fetchInfo();
this.hasPermissionForBlog = hasPermission(
globalPermissions,
permissions.enableBlog
);
this.hasPermissionForSubscription = hasPermission(
globalPermissions,
permissions.enableSubscription
);
}
public ngOnInit() {
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((state) => {
if (state?.user) {
this.user = state.user;
this.changeDetectorRef.markForCheck();
}
});
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
}

View File

@ -0,0 +1,154 @@
<div class="container">
<div class="mb-5 row">
<div class="col">
<h3 class="d-none d-sm-block mb-3 text-center">About Ghostfolio</h3>
<div class="about-container">
<p>
Ghostfolio is a lightweight wealth management application for
individuals to keep track of stocks, ETFs or cryptocurrencies and make
solid, data-driven investment decisions. The source code is fully
available as
<a
href="https://github.com/ghostfolio/ghostfolio"
title="Find Ghostfolio on GitHub"
>open source software</a
>
(OSS) under the
<a
href="https://www.gnu.org/licenses/agpl-3.0.html"
title="GNU Affero General Public License"
>AGPL-3.0 license</a
>
and we share aggregated
<a title="Open Startup" [routerLink]="['/open']">key metrics</a>
of the platforms performance. The project has been initiated by
<a href="https://dotsilver.ch" title="Website of Thomas Kaul"
>Thomas Kaul</a
>
and is driven by the efforts of its
<a
href="https://github.com/ghostfolio/ghostfolio/graphs/contributors"
title="Contributors to Ghostfolio"
>contributors</a
>.
<ng-container *ngIf="version">
This instance is running Ghostfolio {{ version }}.
</ng-container>
<ng-container *ngIf="hasPermissionForSubscription"
>Check the system status at
<a href="https://status.ghostfol.io" title="Ghostfolio Status"
>status.ghostfol.io</a
>.</ng-container
>
</p>
<p>
If you encounter a bug or would like to suggest an improvement or a
new
<a [routerLink]="['/features']">feature</a>, please join the
Ghostfolio
<a
href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"
title="Join the Ghostfolio Slack community"
>Slack</a
>
community, tweet to
<a
href="https://twitter.com/ghostfolio_"
title="Tweet to Ghostfolio on Twitter"
>@ghostfolio_</a
><ng-container *ngIf="user?.subscription?.type === 'Premium'"
>, send an e-mail to
<a href="mailto:hi@ghostfol.io" title="Send an e-mail"
>hi@ghostfol.io</a
></ng-container
>
or start a discussion at
<a
href="https://github.com/ghostfolio/ghostfolio"
title="Find Ghostfolio on GitHub"
>GitHub</a
>.
</p>
<p class="text-center">
<a
class="mx-2"
href="https://twitter.com/ghostfolio_"
mat-icon-button
title="Follow Ghostfolio on Twitter"
>
<ion-icon name="logo-twitter"></ion-icon>
</a>
<a
*ngIf="user?.subscription?.type === 'Premium'"
class="mx-2"
href="mailto:hi@ghostfol.io"
mat-icon-button
title="Send an e-mail"
>
<ion-icon name="mail"></ion-icon>
</a>
<a
class="mx-2"
href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"
mat-icon-button
title="Join the Ghostfolio Slack community"
>
<ion-icon name="logo-slack"></ion-icon>
</a>
<a
class="mx-2"
href="https://github.com/ghostfolio/ghostfolio"
mat-icon-button
title="Find Ghostfolio on GitHub"
>
<ion-icon name="logo-github"></ion-icon>
</a>
</p>
<div
*ngIf="hasPermissionForSubscription"
class="d-flex justify-content-center"
>
<div
class="independent-and-bootstrapped-logo mb-2"
title="Ghostfolio is an independent & bootstrapped business"
></div>
</div>
<div
*ngIf="!hasPermissionForSubscription"
class="d-flex justify-content-center"
>
<a
href="https://www.buymeacoffee.com/ghostfolio"
target="_blank"
title="Support Ghostfolio"
><img
class="mb-2"
src="../assets/images/button-buy-me-a-coffee.png"
width="180"
/></a>
</div>
</div>
</div>
</div>
<div class="row">
<div *ngIf="hasPermissionForSubscription" class="col-md-6 col-xs-12 my-2">
<a
class="py-4 w-100"
color="primary"
mat-flat-button
[routerLink]="['/faq']"
>Frequently Asked Questions (FAQ)</a
>
</div>
<div *ngIf="hasPermissionForBlog" class="col-md-6 col-xs-12 my-2">
<a
class="py-4 w-100"
color="primary"
mat-flat-button
[routerLink]="['/blog']"
>Blog</a
>
</div>
</div>
</div>

View File

@ -0,0 +1,19 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router';
import { AboutOverviewPageRoutingModule } from './about-overview-page-routing.module';
import { AboutOverviewPageComponent } from './about-overview-page.component';
@NgModule({
declarations: [AboutOverviewPageComponent],
imports: [
AboutOverviewPageRoutingModule,
CommonModule,
MatButtonModule,
RouterModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class AboutOverviewPageModule {}

View File

@ -0,0 +1,36 @@
:host {
color: rgb(var(--dark-primary-text));
display: block;
.about-container {
a {
color: rgba(var(--palette-primary-500), 1);
font-weight: 500;
&:hover {
color: rgba(var(--palette-primary-300), 1);
}
}
.independent-and-bootstrapped-logo {
background-image: url('/assets/bootstrapped-dark.svg');
background-position: center;
background-repeat: no-repeat;
background-size: contain;
height: 2rem;
opacity: 0.87;
width: 10rem;
}
}
}
:host-context(.is-dark-theme) {
color: rgb(var(--light-primary-text));
.about-container {
.independent-and-bootstrapped-logo {
background-image: url('/assets/bootstrapped-light.svg');
opacity: 1;
}
}
}

View File

@ -156,10 +156,10 @@
>Nederlands (<ng-container i18n>Community</ng-container >Nederlands (<ng-container i18n>Community</ng-container
>)</mat-option >)</mat-option
> >
<!--<mat-option value="pt" <mat-option value="pt"
>Português (<ng-container i18n>Community</ng-container >Português (<ng-container i18n>Community</ng-container
>)</mat-option >)</mat-option
>--> >
</mat-select> </mat-select>
</mat-form-field> </mat-form-field>
</div> </div>

View File

@ -153,6 +153,7 @@ export class AccountsPageComponent implements OnDestroy, OnInit {
public openUpdateAccountDialog({ public openUpdateAccountDialog({
accountType, accountType,
balance, balance,
comment,
currency, currency,
id, id,
isExcluded, isExcluded,
@ -164,6 +165,7 @@ export class AccountsPageComponent implements OnDestroy, OnInit {
account: { account: {
accountType, accountType,
balance, balance,
comment,
currency, currency,
id, id,
isExcluded, isExcluded,
@ -232,6 +234,7 @@ export class AccountsPageComponent implements OnDestroy, OnInit {
account: { account: {
accountType: AccountType.SECURITIES, accountType: AccountType.SECURITIES,
balance: 0, balance: 0,
comment: null,
currency: this.user?.settings?.baseCurrency, currency: this.user?.settings?.baseCurrency,
isExcluded: false, isExcluded: false,
name: null, name: null,

View File

@ -50,6 +50,19 @@
</mat-select> </mat-select>
</mat-form-field> </mat-form-field>
</div> </div>
<div>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Note</mat-label>
<textarea
cdkAutosizeMinRows="2"
cdkTextareaAutosize
matInput
name="comment"
[(ngModel)]="data.account.comment"
(keyup.enter)="$event.stopPropagation()"
></textarea>
</mat-form-field>
</div>
<div class="mb-3 px-2"> <div class="mb-3 px-2">
<mat-checkbox <mat-checkbox
color="primary" color="primary"

View File

@ -13,18 +13,17 @@ const routes: Routes = [
{ {
canActivate: [AuthGuard], canActivate: [AuthGuard],
children: [ children: [
{ path: '', redirectTo: 'overview', pathMatch: 'full' }, {
path: '',
component: AdminOverviewComponent,
title: $localize`Admin Control`
},
{ path: 'jobs', component: AdminJobsComponent, title: $localize`Jobs` }, { path: 'jobs', component: AdminJobsComponent, title: $localize`Jobs` },
{ {
path: 'market-data', path: 'market-data',
component: AdminMarketDataComponent, component: AdminMarketDataComponent,
title: $localize`Market Data` title: $localize`Market Data`
}, },
{
path: 'overview',
component: AdminOverviewComponent,
title: $localize`Admin Control`
},
{ {
path: 'settings', path: 'settings',
component: AdminSettingsComponent, component: AdminSettingsComponent,

View File

@ -1,5 +1,6 @@
import { Component, HostBinding, OnDestroy, OnInit } from '@angular/core'; import { Component, HostBinding, OnDestroy, OnInit } from '@angular/core';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { TabConfiguration } from '@ghostfolio/common/interfaces';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
@Component({ @Component({
@ -13,7 +14,7 @@ export class AdminPageComponent implements OnDestroy, OnInit {
} }
public hasMessage: boolean; public hasMessage: boolean;
public tabs: { iconName: string; label: string; path: string }[] = []; public tabs: TabConfiguration[] = [];
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
@ -28,20 +29,28 @@ export class AdminPageComponent implements OnDestroy, OnInit {
{ {
iconName: 'reader-outline', iconName: 'reader-outline',
label: $localize`Overview`, label: $localize`Overview`,
path: 'overview' path: ['/admin']
}, },
{ {
iconName: 'settings-outline', iconName: 'settings-outline',
label: $localize`Settings`, label: $localize`Settings`,
path: 'settings' path: ['/admin', 'settings']
}, },
{ {
iconName: 'server-outline', iconName: 'server-outline',
label: $localize`Market Data`, label: $localize`Market Data`,
path: 'market-data' path: ['/admin', 'market-data']
}, },
{ iconName: 'flash-outline', label: $localize`Jobs`, path: 'jobs' }, {
{ iconName: 'people-outline', label: $localize`Users`, path: 'users' } iconName: 'flash-outline',
label: $localize`Jobs`,
path: ['/admin', 'jobs']
},
{
iconName: 'people-outline',
label: $localize`Users`,
path: ['/admin', 'users']
}
]; ];
} }

View File

@ -3,16 +3,19 @@
</mat-tab-nav-panel> </mat-tab-nav-panel>
<nav mat-align-tabs="center" mat-tab-nav-bar [tabPanel]="tabPanel"> <nav mat-align-tabs="center" mat-tab-nav-bar [tabPanel]="tabPanel">
<a <ng-container *ngFor="let tab of tabs">
#rla="routerLinkActive" <a
*ngFor="let tab of tabs" #rla="routerLinkActive"
class="px-3" *ngIf="tab.showCondition !== false"
mat-tab-link class="px-3"
routerLinkActive mat-tab-link
[active]="rla.isActive" routerLinkActive
[routerLink]="tab.path" [active]="rla.isActive"
> [routerLink]="tab.path"
<ion-icon size="large" [name]="tab.iconName"></ion-icon> [routerLinkActiveOptions]="{ exact: true }"
<div class="d-none d-sm-block ml-2">{{ tab.label }}</div> >
</a> <ion-icon size="large" [name]="tab.iconName"></ion-icon>
<div class="d-none d-sm-block ml-2">{{ tab.label }}</div>
</a>
</ng-container>
</nav> </nav>

View File

@ -202,7 +202,10 @@
<li class="breadcrumb-item"> <li class="breadcrumb-item">
<a i18n [routerLink]="['/blog']">Blog</a> <a i18n [routerLink]="['/blog']">Blog</a>
</li> </li>
<li aria-current="page" class="breadcrumb-item active"> <li
aria-current="page"
class="active breadcrumb-item text-truncate"
>
Hallo Ghostfolio Hallo Ghostfolio
</li> </li>
</ol> </ol>

View File

@ -182,7 +182,10 @@
<li class="breadcrumb-item"> <li class="breadcrumb-item">
<a i18n [routerLink]="['/blog']">Blog</a> <a i18n [routerLink]="['/blog']">Blog</a>
</li> </li>
<li aria-current="page" class="breadcrumb-item active"> <li
aria-current="page"
class="active breadcrumb-item text-truncate"
>
Hello Ghostfolio Hello Ghostfolio
</li> </li>
</ol> </ol>

View File

@ -93,7 +93,10 @@
</p> </p>
<p> <p>
I have already started to build a I have already started to build a
<a href="https://ghostfolio.slack.com">community</a> <a
href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"
>community</a
>
of users. In the future, I would like to involve more contributors of users. In the future, I would like to involve more contributors
to further extend the functionality of Ghostfolio (e.g. with new to further extend the functionality of Ghostfolio (e.g. with new
reports). Get in touch with me by e-mail at reports). Get in touch with me by e-mail at
@ -179,7 +182,10 @@
<li class="breadcrumb-item"> <li class="breadcrumb-item">
<a i18n [routerLink]="['/blog']">Blog</a> <a i18n [routerLink]="['/blog']">Blog</a>
</li> </li>
<li aria-current="page" class="breadcrumb-item active"> <li
aria-current="page"
class="active breadcrumb-item text-truncate"
>
Ghostfolio: First months in Open Source Ghostfolio: First months in Open Source
</li> </li>
</ol> </ol>

View File

@ -182,7 +182,10 @@
<li class="breadcrumb-item"> <li class="breadcrumb-item">
<a i18n [routerLink]="['/blog']">Blog</a> <a i18n [routerLink]="['/blog']">Blog</a>
</li> </li>
<li aria-current="page" class="breadcrumb-item active"> <li
aria-current="page"
class="active breadcrumb-item text-truncate"
>
Ghostfolio meets Internet Identity Ghostfolio meets Internet Identity
</li> </li>
</ol> </ol>

View File

@ -208,7 +208,10 @@
<li class="breadcrumb-item"> <li class="breadcrumb-item">
<a i18n [routerLink]="['/blog']">Blog</a> <a i18n [routerLink]="['/blog']">Blog</a>
</li> </li>
<li aria-current="page" class="breadcrumb-item active"> <li
aria-current="page"
class="active breadcrumb-item text-truncate"
>
How do I get my finances in order? How do I get my finances in order?
</li> </li>
</ol> </ol>

View File

@ -29,7 +29,10 @@
<p> <p>
The Ghostfolio community is growing on various platforms and has The Ghostfolio community is growing on various platforms and has
recently passed 100 members on recently passed 100 members on
<a href="https://ghostfolio.slack.com">Slack</a> <a
href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"
>Slack</a
>
as well as 100 followers on as well as 100 followers on
<a href="https://twitter.com/ghostfolio_">Twitter</a>. If you have <a href="https://twitter.com/ghostfolio_">Twitter</a>. If you have
not joined yet, this is a good time to make sure you do not miss out not joined yet, this is a good time to make sure you do not miss out
@ -191,7 +194,10 @@
<li class="breadcrumb-item"> <li class="breadcrumb-item">
<a i18n [routerLink]="['/blog']">Blog</a> <a i18n [routerLink]="['/blog']">Blog</a>
</li> </li>
<li aria-current="page" class="breadcrumb-item active"> <li
aria-current="page"
class="active breadcrumb-item text-truncate"
>
500 Stars on GitHub 500 Stars on GitHub
</li> </li>
</ol> </ol>

View File

@ -80,8 +80,11 @@
<h2 class="h4">Get support</h2> <h2 class="h4">Get support</h2>
<p> <p>
If you have further questions or ideas, please join our growing If you have further questions or ideas, please join our growing
<a href="https://ghostfolio.slack.com">Slack community</a> or get in <a
touch on Twitter href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"
>Slack</a
>
community or get in touch on Twitter
<a href="https://twitter.com/ghostfolio_">@ghostfolio_</a> or by <a href="https://twitter.com/ghostfolio_">@ghostfolio_</a> or by
e-mail via <a href="mailto:hi@ghostfol.io">hi@ghostfol.io</a>. e-mail via <a href="mailto:hi@ghostfol.io">hi@ghostfol.io</a>.
</p> </p>
@ -177,7 +180,10 @@
<li class="breadcrumb-item"> <li class="breadcrumb-item">
<a i18n [routerLink]="['/blog']">Blog</a> <a i18n [routerLink]="['/blog']">Blog</a>
</li> </li>
<li aria-current="page" class="breadcrumb-item active"> <li
aria-current="page"
class="active breadcrumb-item text-truncate"
>
Hacktoberfest 2022 Hacktoberfest 2022
</li> </li>
</ol> </ol>

View File

@ -137,7 +137,10 @@
<li class="breadcrumb-item"> <li class="breadcrumb-item">
<a i18n [routerLink]="['/blog']">Blog</a> <a i18n [routerLink]="['/blog']">Blog</a>
</li> </li>
<li aria-current="page" class="breadcrumb-item active"> <li
aria-current="page"
class="active breadcrumb-item text-truncate"
>
Black Friday 2022 Black Friday 2022
</li> </li>
</ol> </ol>

View File

@ -167,7 +167,10 @@
<li class="breadcrumb-item"> <li class="breadcrumb-item">
<a i18n [routerLink]="['/blog']">Blog</a> <a i18n [routerLink]="['/blog']">Blog</a>
</li> </li>
<li aria-current="page" class="breadcrumb-item active"> <li
aria-current="page"
class="active breadcrumb-item text-truncate"
>
The importance of tracking your personal finances The importance of tracking your personal finances
</li> </li>
</ol> </ol>

View File

@ -177,7 +177,10 @@
<li class="breadcrumb-item"> <li class="breadcrumb-item">
<a i18n [routerLink]="['/blog']">Blog</a> <a i18n [routerLink]="['/blog']">Blog</a>
</li> </li>
<li aria-current="page" class="breadcrumb-item active"> <li
aria-current="page"
class="active breadcrumb-item text-truncate"
>
Ghostfolio auf Sackgeld.com vorgestellt Ghostfolio auf Sackgeld.com vorgestellt
</li> </li>
</ol> </ol>

View File

@ -85,7 +85,9 @@
<p> <p>
To participate in the ongoing development of Ghostfolio, please feel To participate in the ongoing development of Ghostfolio, please feel
free to reach out to us on our free to reach out to us on our
<a href="https://ghostfolio.slack.com" target="_blank" <a
href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"
target="_blank"
>Slack channel</a >Slack channel</a
> >
or via Twitter or via Twitter
@ -199,7 +201,10 @@
<li class="breadcrumb-item"> <li class="breadcrumb-item">
<a i18n [routerLink]="['/blog']">Blog</a> <a i18n [routerLink]="['/blog']">Blog</a>
</li> </li>
<li aria-current="page" class="breadcrumb-item active"> <li
aria-current="page"
class="active breadcrumb-item text-truncate"
>
Ghostfolio meets Umbrel Ghostfolio meets Umbrel
</li> </li>
</ol> </ol>

View File

@ -37,8 +37,11 @@
empowers busy people to take control of their personal finances. In empowers busy people to take control of their personal finances. In
addition, Ghostfolio has attracted over 250 members from around the addition, Ghostfolio has attracted over 250 members from around the
world to its world to its
<a href="https://ghostfolio.slack.com">Slack</a> community, where <a
they can connect and share ideas about investing. href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"
>Slack</a
>
community, where they can connect and share ideas about investing.
</p> </p>
<p> <p>
<figure class="figure"> <figure class="figure">
@ -114,8 +117,12 @@
and we look forward to collaborating and learning together. If you and we look forward to collaborating and learning together. If you
are a web developer and interested in personal finance, please join are a web developer and interested in personal finance, please join
our our
<a href="https://ghostfolio.slack.com">Slack</a> channel or connect <a
with <a href="https://twitter.com/ghostfolio_">@ghostfolio_</a> on href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"
>Slack</a
>
community or connect with
<a href="https://twitter.com/ghostfolio_">@ghostfolio_</a> on
Twitter. We are happy to discuss ideas and get you involved. Twitter. We are happy to discuss ideas and get you involved.
</p> </p>
<p>Thank you for all your feedback and support.</p> <p>Thank you for all your feedback and support.</p>
@ -244,7 +251,10 @@
<li class="breadcrumb-item"> <li class="breadcrumb-item">
<a i18n [routerLink]="['/blog']">Blog</a> <a i18n [routerLink]="['/blog']">Blog</a>
</li> </li>
<li aria-current="page" class="breadcrumb-item active"> <li
aria-current="page"
class="active breadcrumb-item text-truncate"
>
Ghostfolio reaches 1000 Stars on GitHub Ghostfolio reaches 1000 Stars on GitHub
</li> </li>
</ol> </ol>

View File

@ -15,8 +15,8 @@
<section class="mb-4"> <section class="mb-4">
<p> <p>
Managing personal finances effectively is crucial for those striving Managing personal finances effectively is crucial for those striving
for financial independence and a secure future. In todays digital for a secure future and financial independence. In todays digital
age, having a reliable personal finance software can greatly age, having a reliable wealth management software can greatly
simplify the process. Ghostfolio is a powerful simplify the process. Ghostfolio is a powerful
<a <a
href="https://github.com/ghostfolio/ghostfolio" href="https://github.com/ghostfolio/ghostfolio"
@ -25,15 +25,15 @@
> >
for individuals trading stocks, ETFs, or cryptocurrencies on for individuals trading stocks, ETFs, or cryptocurrencies on
multiple platforms. This article explores the key reasons why multiple platforms. This article explores the key reasons why
Ghostfolio is the ideal choice for those embracing minimalism, Ghostfolio is the ideal choice for those embracing diversification,
pursuing a buy & hold strategy, seeking portfolio insights, and pursuing a buy & hold strategy, and seeking portfolio insights while
diversifying financial resources while valuing privacy. valuing privacy.
</p> </p>
</section> </section>
<section class="mb-4"> <section class="mb-4">
<h2 class="h4">Effortless Management for Multi-Platform Investors</h2> <h2 class="h4">Effortless Management for Multi-Platform Investors</h2>
<p> <p>
Ghostfolio offers a consolidated solution to efficiently monitor and Ghostfolio offers a holistic solution to efficiently monitor and
manage investment portfolios across multiple platforms. By manage investment portfolios across multiple platforms. By
consolidating data from various accounts, Ghostfolio eliminates the consolidating data from various accounts, Ghostfolio eliminates the
need to switch between platforms, saving users valuable time and need to switch between platforms, saving users valuable time and
@ -61,7 +61,7 @@
asset allocation, sector exposure, geographical diversification, and asset allocation, sector exposure, geographical diversification, and
individual asset performance. These detailed analytics empower users individual asset performance. These detailed analytics empower users
to assess portfolio strengths and weaknesses, making necessary to assess portfolio strengths and weaknesses, making necessary
adjustments to optimize their diversification. adjustments to optimize their allocation.
</p> </p>
</section> </section>
<section class="mb-4"> <section class="mb-4">
@ -79,7 +79,7 @@
<section class="mb-4"> <section class="mb-4">
<h2 class="h4">Streamlined Minimalism for Financial Efficiency</h2> <h2 class="h4">Streamlined Minimalism for Financial Efficiency</h2>
<p> <p>
Ghostfolio embraces a minimalist approach to personal finance Ghostfolio embraces a lightweight approach to personal finance
management, focusing on essential features without overwhelming management, focusing on essential features without overwhelming
users. Its streamlined user interface and clean design provide a users. Its streamlined user interface and clean design provide a
seamless and clutter-free experience. This minimalist approach seamless and clutter-free experience. This minimalist approach
@ -172,6 +172,9 @@
<li class="list-inline-item"> <li class="list-inline-item">
<span class="badge badge-light">Fintech</span> <span class="badge badge-light">Fintech</span>
</li> </li>
<li class="list-inline-item">
<span class="badge badge-light">FIRE</span>
</li>
<li class="list-inline-item"> <li class="list-inline-item">
<span class="badge badge-light">Ghostfolio</span> <span class="badge badge-light">Ghostfolio</span>
</li> </li>
@ -227,7 +230,10 @@
<li class="breadcrumb-item"> <li class="breadcrumb-item">
<a i18n [routerLink]="['/blog']">Blog</a> <a i18n [routerLink]="['/blog']">Blog</a>
</li> </li>
<li aria-current="page" class="breadcrumb-item active"> <li
aria-current="page"
class="active breadcrumb-item text-truncate"
>
Unlock your Financial Potential with Ghostfolio Unlock your Financial Potential with Ghostfolio
</li> </li>
</ol> </ol>

View File

@ -9,7 +9,7 @@ const routes: Routes = [
canActivate: [AuthGuard], canActivate: [AuthGuard],
component: FaqPageComponent, component: FaqPageComponent,
path: '', path: '',
title: $localize`FAQ` title: $localize`Frequently Asked Questions (FAQ)`
} }
]; ];

View File

@ -183,10 +183,11 @@
feedback, bug reports, feature requests and of course contributions! feedback, bug reports, feature requests and of course contributions!
You can reach us via Ghostfolio You can reach us via Ghostfolio
<a <a
href="https://ghostfolio.slack.com" href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"
title="Join the Ghostfolio Slack community" title="Join the Ghostfolio Slack community"
>Slack community</a >Slack</a
>, >
community,
<a <a
href="https://twitter.com/ghostfolio_" href="https://twitter.com/ghostfolio_"
title="Tweet to Ghostfolio on Twitter" title="Tweet to Ghostfolio on Twitter"
@ -212,10 +213,10 @@
<mat-card-content <mat-card-content
>Please join the Ghostfolio >Please join the Ghostfolio
<a <a
href="https://ghostfolio.slack.com" href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"
title="Join the Ghostfolio Slack community" title="Join the Ghostfolio Slack community"
>Slack community</a >Slack </a
>, tweet to >community, tweet to
<a <a
href="https://twitter.com/ghostfolio_" href="https://twitter.com/ghostfolio_"
title="Tweet to Ghostfolio on Twitter" title="Tweet to Ghostfolio on Twitter"

View File

@ -1,7 +1,7 @@
<div class="container"> <div class="container">
<div class="row"> <div class="row">
<div class="col"> <div class="col">
<h3 class="d-none d-sm-block mb-3 text-center">Features</h3> <h3 class="d-none d-sm-block mb-3 text-center" i18n>Features</h3>
<div class="mb-4"> <div class="mb-4">
<p> <p>
Check out the numerous features of <strong>Ghostfolio</strong> to Check out the numerous features of <strong>Ghostfolio</strong> to
@ -13,7 +13,7 @@
<mat-card appearance="outlined" class="d-flex flex-column h-100"> <mat-card appearance="outlined" class="d-flex flex-column h-100">
<mat-card-content> <mat-card-content>
<div class="flex-grow-1"> <div class="flex-grow-1">
<h4>Stocks</h4> <h4 i18n>Stocks</h4>
<p class="m-0">Keep track of your stock purchases and sales.</p> <p class="m-0">Keep track of your stock purchases and sales.</p>
</div> </div>
</mat-card-content> </mat-card-content>
@ -23,7 +23,7 @@
<mat-card appearance="outlined" class="d-flex flex-column h-100"> <mat-card appearance="outlined" class="d-flex flex-column h-100">
<mat-card-content> <mat-card-content>
<div class="flex-grow-1"> <div class="flex-grow-1">
<h4>ETFs</h4> <h4 i18n>ETFs</h4>
<p class="m-0"> <p class="m-0">
Are you into ETFs (Exchange Traded Funds)? Track your ETF Are you into ETFs (Exchange Traded Funds)? Track your ETF
investments. investments.
@ -36,7 +36,7 @@
<mat-card appearance="outlined" class="d-flex flex-column h-100"> <mat-card appearance="outlined" class="d-flex flex-column h-100">
<mat-card-content> <mat-card-content>
<div class="flex-grow-1"> <div class="flex-grow-1">
<h4>Bonds</h4> <h4 i18n>Bonds</h4>
<p class="m-0"> <p class="m-0">
Manage your investment in bonds and other assets with fixed Manage your investment in bonds and other assets with fixed
income. income.
@ -49,7 +49,7 @@
<mat-card appearance="outlined" class="d-flex flex-column h-100"> <mat-card appearance="outlined" class="d-flex flex-column h-100">
<mat-card-content> <mat-card-content>
<div class="flex-grow-1"> <div class="flex-grow-1">
<h4>Cryptocurrencies</h4> <h4 i18n>Cryptocurrencies</h4>
<p class="m-0"> <p class="m-0">
Keep track of your Bitcoin and Altcoin holdings. Keep track of your Bitcoin and Altcoin holdings.
</p> </p>
@ -61,7 +61,7 @@
<mat-card appearance="outlined" class="d-flex flex-column h-100"> <mat-card appearance="outlined" class="d-flex flex-column h-100">
<mat-card-content> <mat-card-content>
<div class="flex-grow-1"> <div class="flex-grow-1">
<h4>Dividend</h4> <h4 i18n>Dividend</h4>
<p class="m-0"> <p class="m-0">
Are you building a dividend portfolio? Track your dividend in Are you building a dividend portfolio? Track your dividend in
Ghostfolio. Ghostfolio.
@ -74,7 +74,7 @@
<mat-card appearance="outlined" class="d-flex flex-column h-100"> <mat-card appearance="outlined" class="d-flex flex-column h-100">
<mat-card-content> <mat-card-content>
<div class="flex-grow-1"> <div class="flex-grow-1">
<h4 class="align-items-center d-flex">Wealth Items</h4> <h4 class="align-items-center d-flex" i18n>Wealth Items</h4>
<p class="m-0"> <p class="m-0">
Track all your treasuries, be it your luxury watch or rare Track all your treasuries, be it your luxury watch or rare
trading cards. trading cards.
@ -87,7 +87,7 @@
<mat-card appearance="outlined" class="d-flex flex-column h-100"> <mat-card appearance="outlined" class="d-flex flex-column h-100">
<mat-card-content> <mat-card-content>
<div class="flex-grow-1"> <div class="flex-grow-1">
<h4 class="align-items-center d-flex">Emergency Fund</h4> <h4 class="align-items-center d-flex" i18n>Emergency Fund</h4>
<p class="m-0"> <p class="m-0">
Define your emergency fund you are comfortable with for Define your emergency fund you are comfortable with for
difficult times. difficult times.
@ -100,7 +100,22 @@
<mat-card appearance="outlined" class="d-flex flex-column h-100"> <mat-card appearance="outlined" class="d-flex flex-column h-100">
<mat-card-content> <mat-card-content>
<div class="flex-grow-1"> <div class="flex-grow-1">
<h4 class="align-items-center d-flex">Import and Export</h4> <h4 class="align-items-center d-flex" i18n>Liabilities</h4>
<p class="m-0">
Manage your financial liabilities, such as your student loan,
to stay ahead of your financial obligations.
</p>
</div>
</mat-card-content>
</mat-card>
</div>
<div class="col-xs-12 col-md-4 mb-3">
<mat-card appearance="outlined" class="d-flex flex-column h-100">
<mat-card-content>
<div class="flex-grow-1">
<h4 class="align-items-center d-flex" i18n>
Import and Export
</h4>
<p class="m-0">Import and export your investment activities.</p> <p class="m-0">Import and export your investment activities.</p>
</div> </div>
</mat-card-content> </mat-card-content>
@ -110,7 +125,7 @@
<mat-card appearance="outlined" class="d-flex flex-column h-100"> <mat-card appearance="outlined" class="d-flex flex-column h-100">
<mat-card-content> <mat-card-content>
<div class="flex-grow-1"> <div class="flex-grow-1">
<h4>Multi-Accounts</h4> <h4 i18n>Multi-Accounts</h4>
<p class="m-0"> <p class="m-0">
Keep an eye on all your accounts across multiple platforms Keep an eye on all your accounts across multiple platforms
(multi-banking). (multi-banking).
@ -124,7 +139,7 @@
<mat-card-content> <mat-card-content>
<div class="flex-grow-1"> <div class="flex-grow-1">
<h4 class="align-items-center d-flex"> <h4 class="align-items-center d-flex">
<span>Portfolio Calculations</span> <span i18n>Portfolio Calculations</span>
<gf-premium-indicator <gf-premium-indicator
*ngIf="hasPermissionForSubscription" *ngIf="hasPermissionForSubscription"
class="ml-1" class="ml-1"
@ -144,7 +159,7 @@
<mat-card-content> <mat-card-content>
<div class="flex-grow-1"> <div class="flex-grow-1">
<h4 class="align-items-center d-flex"> <h4 class="align-items-center d-flex">
<span>Portfolio Allocations</span> <span i18n>Portfolio Allocations</span>
<gf-premium-indicator <gf-premium-indicator
*ngIf="hasPermissionForSubscription" *ngIf="hasPermissionForSubscription"
class="ml-1" class="ml-1"
@ -162,7 +177,7 @@
<mat-card appearance="outlined" class="d-flex flex-column h-100"> <mat-card appearance="outlined" class="d-flex flex-column h-100">
<mat-card-content> <mat-card-content>
<div class="flex-grow-1"> <div class="flex-grow-1">
<h4 class="align-items-center d-flex">Dark Mode</h4> <h4 class="align-items-center d-flex" i18n>Dark Mode</h4>
<p class="m-0"> <p class="m-0">
Ghostfolio automatically switches to a dark color theme based Ghostfolio automatically switches to a dark color theme based
on your operating system's preferences. on your operating system's preferences.
@ -175,7 +190,7 @@
<mat-card appearance="outlined" class="d-flex flex-column h-100"> <mat-card appearance="outlined" class="d-flex flex-column h-100">
<mat-card-content> <mat-card-content>
<div class="flex-grow-1"> <div class="flex-grow-1">
<h4 class="align-items-center d-flex">Zen Mode</h4> <h4 class="align-items-center d-flex" i18n>Zen Mode</h4>
<p class="m-0"> <p class="m-0">
Keep calm and activate Zen Mode if the markets are going Keep calm and activate Zen Mode if the markets are going
crazy. crazy.
@ -192,7 +207,7 @@
<mat-card-content> <mat-card-content>
<div class="flex-grow-1"> <div class="flex-grow-1">
<h4 class="align-items-center d-flex"> <h4 class="align-items-center d-flex">
<span>Market Mood</span> <span i18n>Market Mood</span>
<gf-premium-indicator class="ml-1"></gf-premium-indicator> <gf-premium-indicator class="ml-1"></gf-premium-indicator>
</h4> </h4>
<p class="m-0"> <p class="m-0">
@ -210,7 +225,7 @@
<mat-card-content> <mat-card-content>
<div class="flex-grow-1"> <div class="flex-grow-1">
<h4 class="align-items-center d-flex"> <h4 class="align-items-center d-flex">
<span>Static Analysis</span> <span i18n>Static Analysis</span>
<gf-premium-indicator <gf-premium-indicator
*ngIf="hasPermissionForSubscription" *ngIf="hasPermissionForSubscription"
class="ml-1" class="ml-1"
@ -228,13 +243,11 @@
<mat-card appearance="outlined" class="d-flex flex-column h-100"> <mat-card appearance="outlined" class="d-flex flex-column h-100">
<mat-card-content> <mat-card-content>
<div class="flex-grow-1"> <div class="flex-grow-1">
<h4>Multi-Language</h4> <h4 i18n>Multi-Language</h4>
<p class="m-0"> <p class="m-0">
Use Ghostfolio in multiple languages: English, Dutch, French, Use Ghostfolio in multiple languages: English, Dutch, French,
German, Italian<ng-container *ngIf="false" German, Italian, Portuguese and Spanish are currently
>, Portuguese</ng-container supported.
>
and Spanish are currently supported.
</p> </p>
</div> </div>
</mat-card-content> </mat-card-content>
@ -244,16 +257,16 @@
<mat-card appearance="outlined" class="d-flex flex-column h-100"> <mat-card appearance="outlined" class="d-flex flex-column h-100">
<mat-card-content> <mat-card-content>
<div class="flex-grow-1"> <div class="flex-grow-1">
<h4>Community</h4> <h4 i18n>Community</h4>
<p class="m-0"> <p class="m-0">
Join the Ghostfolio Join the Ghostfolio
<a <a
href="https://ghostfolio.slack.com" href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"
title="Join the Ghostfolio Slack community" title="Join the Ghostfolio Slack community"
>Slack channel</a >Slack</a
> >
full of enthusiastic investors and discuss the latest market community full of enthusiastic investors and discuss the
trends. latest market trends.
</p> </p>
</div> </div>
</mat-card-content> </mat-card-content>
@ -263,7 +276,7 @@
<mat-card appearance="outlined" class="d-flex flex-column h-100"> <mat-card appearance="outlined" class="d-flex flex-column h-100">
<mat-card-content> <mat-card-content>
<div class="flex-grow-1"> <div class="flex-grow-1">
<h4>Open Source Software</h4> <h4 i18n>Open Source Software</h4>
<p class="m-0"> <p class="m-0">
The source code is fully available as The source code is fully available as
<a <a
@ -282,9 +295,9 @@
</div> </div>
<div *ngIf="!user" class="row"> <div *ngIf="!user" class="row">
<div class="col mt-3 text-center"> <div class="col mt-3 text-center">
<a color="primary" mat-flat-button [routerLink]="['/register']"> <a color="primary" i18n mat-flat-button [routerLink]="['/register']"
Get Started >Get Started</a
</a> >
</div> </div>
</div> </div>
</div> </div>

View File

@ -12,9 +12,8 @@ const routes: Routes = [
{ {
canActivate: [AuthGuard], canActivate: [AuthGuard],
children: [ children: [
{ path: '', redirectTo: 'overview', pathMatch: 'full' },
{ {
path: 'overview', path: '',
component: HomeOverviewComponent component: HomeOverviewComponent
}, },
{ {

View File

@ -7,7 +7,7 @@ import {
} from '@angular/core'; } from '@angular/core';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { InfoItem, User } from '@ghostfolio/common/interfaces'; import { TabConfiguration, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators'; import { takeUntil } from 'rxjs/operators';
@ -24,8 +24,7 @@ export class HomePageComponent implements OnDestroy, OnInit {
public hasMessage: boolean; public hasMessage: boolean;
public hasPermissionToAccessFearAndGreedIndex: boolean; public hasPermissionToAccessFearAndGreedIndex: boolean;
public info: InfoItem; public tabs: TabConfiguration[] = [];
public tabs: { iconName: string; label: string; path: string }[] = [];
public user: User; public user: User;
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
@ -35,7 +34,12 @@ export class HomePageComponent implements OnDestroy, OnInit {
private dataService: DataService, private dataService: DataService,
private userService: UserService private userService: UserService
) { ) {
this.info = this.dataService.fetchInfo(); const { globalPermissions, systemMessage } = this.dataService.fetchInfo();
this.hasPermissionToAccessFearAndGreedIndex = hasPermission(
globalPermissions,
permissions.enableFearAndGreedIndex
);
this.userService.stateChanged this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
@ -45,17 +49,23 @@ export class HomePageComponent implements OnDestroy, OnInit {
{ {
iconName: 'analytics-outline', iconName: 'analytics-outline',
label: $localize`Overview`, label: $localize`Overview`,
path: 'overview' path: ['/home']
}, },
{ {
iconName: 'wallet-outline', iconName: 'wallet-outline',
label: $localize`Holdings`, label: $localize`Holdings`,
path: 'holdings' path: ['/home', 'holdings']
}, },
{ {
iconName: 'reader-outline', iconName: 'reader-outline',
label: $localize`Summary`, label: $localize`Summary`,
path: 'summary' path: ['/home', 'summary']
},
{
iconName: 'newspaper-outline',
label: $localize`Markets`,
path: ['/home', 'market'],
showCondition: this.hasPermissionToAccessFearAndGreedIndex
} }
]; ];
this.user = state.user; this.user = state.user;
@ -64,20 +74,7 @@ export class HomePageComponent implements OnDestroy, OnInit {
hasPermission( hasPermission(
this.user?.permissions, this.user?.permissions,
permissions.createUserAccount permissions.createUserAccount
) || !!this.info.systemMessage; ) || !!systemMessage;
this.hasPermissionToAccessFearAndGreedIndex = hasPermission(
this.info?.globalPermissions,
permissions.enableFearAndGreedIndex
);
if (this.hasPermissionToAccessFearAndGreedIndex) {
this.tabs.push({
iconName: 'newspaper-outline',
label: $localize`Markets`,
path: 'market'
});
}
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
} }

View File

@ -3,15 +3,19 @@
</mat-tab-nav-panel> </mat-tab-nav-panel>
<nav mat-align-tabs="center" mat-tab-nav-bar [tabPanel]="tabPanel"> <nav mat-align-tabs="center" mat-tab-nav-bar [tabPanel]="tabPanel">
<a <ng-container *ngFor="let tab of tabs">
#rla="routerLinkActive" <a
*ngFor="let tab of tabs" #rla="routerLinkActive"
mat-tab-link *ngIf="tab.showCondition !== false"
routerLinkActive class="px-3"
[active]="rla.isActive" mat-tab-link
[routerLink]="tab.path" routerLinkActive
> [active]="rla.isActive"
<ion-icon size="large" [name]="tab.iconName"></ion-icon> [routerLink]="tab.path"
<div class="d-none d-sm-block ml-2">{{ tab.label }}</div> [routerLinkActiveOptions]="{ exact: true }"
</a> >
<ion-icon size="large" [name]="tab.iconName"></ion-icon>
<div class="d-none d-sm-block ml-2">{{ tab.label }}</div>
</a>
</ng-container>
</nav> </nav>

View File

@ -59,6 +59,19 @@ export class LandingPageComponent implements OnDestroy, OnInit {
country: 'Germany 🇩🇪', country: 'Germany 🇩🇪',
quote: quote:
'Super slim app with a great user interface. On top of that, its open source.' 'Super slim app with a great user interface. On top of that, its open source.'
},
{
author: 'Sal',
country: 'Canada 🇨🇦',
quote:
'Ghostfolio is one of the best tools I have used for tracking my investments. I intend to spread the word to all my friends.'
},
{
author: 'Thomas',
country: 'Creator of Ghostfolio, Switzerland 🇨🇭',
quote:
'My investment strategy has become more structured through the daily use of Ghostfolio.',
url: 'https://dotsilver.ch'
} }
]; ];

View File

@ -57,7 +57,10 @@
> >
</div> </div>
<div class="col-xs-12 col-md-4 my-2"> <div class="col-xs-12 col-md-4 my-2">
<a class="d-block" href="https://ghostfolio.slack.com"> <a
class="d-block"
href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"
>
<gf-value <gf-value
size="large" size="large"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"

View File

@ -291,7 +291,6 @@ export class ActivitiesPageComponent implements OnDestroy, OnInit {
date: new Date(), date: new Date(),
id: null, id: null,
fee: 0, fee: 0,
quantity: null,
type: aActivity?.type ?? 'BUY', type: aActivity?.type ?? 'BUY',
unitPrice: null unitPrice: null
}, },

View File

@ -55,8 +55,6 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
public currencies: string[] = []; public currencies: string[] = [];
public currentMarketPrice = null; public currentMarketPrice = null;
public defaultDateFormat: string; public defaultDateFormat: string;
public filteredLookupItems: LookupItem[] = [];
public filteredLookupItemsObservable: Observable<LookupItem[]> = of([]);
public filteredTagsObservable: Observable<Tag[]> = of([]); public filteredTagsObservable: Observable<Tag[]> = of([]);
public isLoading = false; public isLoading = false;
public platforms: { id: string; name: string }[]; public platforms: { id: string; name: string }[];
@ -120,10 +118,12 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
name: [this.data.activity?.SymbolProfile?.name, Validators.required], name: [this.data.activity?.SymbolProfile?.name, Validators.required],
quantity: [this.data.activity?.quantity, Validators.required], quantity: [this.data.activity?.quantity, Validators.required],
searchSymbol: [ searchSymbol: [
{ !!this.data.activity?.SymbolProfile
dataSource: this.data.activity?.SymbolProfile?.dataSource, ? {
symbol: this.data.activity?.SymbolProfile?.symbol dataSource: this.data.activity?.SymbolProfile?.dataSource,
}, symbol: this.data.activity?.SymbolProfile?.symbol
}
: null,
Validators.required Validators.required
], ],
tags: [ tags: [
@ -238,28 +238,19 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
}); });
this.filteredLookupItemsObservable = this.activityForm.controls[ this.activityForm.controls['searchSymbol'].valueChanges.subscribe(() => {
'searchSymbol' if (this.activityForm.controls['searchSymbol'].invalid) {
].valueChanges.pipe( this.data.activity.SymbolProfile = null;
debounceTime(400), } else {
distinctUntilChanged(), this.activityForm.controls['dataSource'].setValue(
switchMap((query: string) => { this.activityForm.controls['searchSymbol'].value.dataSource
if (isString(query) && query.length > 1) { );
const filteredLookupItemsObservable =
this.dataService.fetchSymbols(query);
filteredLookupItemsObservable this.updateSymbol();
.pipe(takeUntil(this.unsubscribeSubject)) }
.subscribe((filteredLookupItems) => {
this.filteredLookupItems = filteredLookupItems;
});
return filteredLookupItemsObservable; this.changeDetectorRef.markForCheck();
} });
return [];
})
);
this.filteredTagsObservable = this.activityForm.controls[ this.filteredTagsObservable = this.activityForm.controls[
'tags' 'tags'
@ -300,6 +291,33 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
this.activityForm.controls['searchSymbol'].updateValueAndValidity(); this.activityForm.controls['searchSymbol'].updateValueAndValidity();
this.activityForm.controls['updateAccountBalance'].disable(); this.activityForm.controls['updateAccountBalance'].disable();
this.activityForm.controls['updateAccountBalance'].setValue(false); this.activityForm.controls['updateAccountBalance'].setValue(false);
} else if (type === 'LIABILITY') {
this.activityForm.controls['accountId'].removeValidators(
Validators.required
);
this.activityForm.controls['accountId'].updateValueAndValidity();
this.activityForm.controls['currency'].setValue(
this.data.user.settings.baseCurrency
);
this.activityForm.controls['currencyOfFee'].setValue(
this.data.user.settings.baseCurrency
);
this.activityForm.controls['currencyOfUnitPrice'].setValue(
this.data.user.settings.baseCurrency
);
this.activityForm.controls['dataSource'].removeValidators(
Validators.required
);
this.activityForm.controls['dataSource'].updateValueAndValidity();
this.activityForm.controls['name'].setValidators(Validators.required);
this.activityForm.controls['name'].updateValueAndValidity();
this.activityForm.controls['quantity'].setValue(1);
this.activityForm.controls['searchSymbol'].removeValidators(
Validators.required
);
this.activityForm.controls['searchSymbol'].updateValueAndValidity();
this.activityForm.controls['updateAccountBalance'].disable();
this.activityForm.controls['updateAccountBalance'].setValue(false);
} else { } else {
this.activityForm.controls['accountId'].setValidators( this.activityForm.controls['accountId'].setValidators(
Validators.required Validators.required
@ -366,25 +384,6 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
this.tagInput.nativeElement.value = ''; this.tagInput.nativeElement.value = '';
} }
public onBlurSymbol() {
const currentLookupItem = this.filteredLookupItems.find((lookupItem) => {
return (
lookupItem.symbol ===
this.activityForm.controls['searchSymbol'].value.symbol
);
});
if (currentLookupItem) {
this.updateSymbol(currentLookupItem.symbol);
} else {
this.activityForm.controls['searchSymbol'].setErrors({ incorrect: true });
this.data.activity.SymbolProfile = null;
}
this.changeDetectorRef.markForCheck();
}
public onCancel() { public onCancel() {
this.dialogRef.close(); this.dialogRef.close();
} }
@ -428,13 +427,6 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
this.dialogRef.close({ activity }); this.dialogRef.close({ activity });
} }
public onUpdateSymbol(event: MatAutocompleteSelectedEvent) {
this.activityForm.controls['dataSource'].setValue(
event.option.value.dataSource
);
this.updateSymbol(event.option.value.symbol);
}
public ngOnDestroy() { public ngOnDestroy() {
this.unsubscribeSubject.next(); this.unsubscribeSubject.next();
this.unsubscribeSubject.complete(); this.unsubscribeSubject.complete();
@ -450,12 +442,8 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
}); });
} }
private updateSymbol(symbol: string) { private updateSymbol() {
this.isLoading = true; this.isLoading = true;
this.activityForm.controls['searchSymbol'].setErrors(null);
this.activityForm.controls['searchSymbol'].setValue({ symbol });
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
this.dataService this.dataService

View File

@ -14,6 +14,7 @@
<mat-option i18n value="BUY">Buy</mat-option> <mat-option i18n value="BUY">Buy</mat-option>
<mat-option i18n value="DIVIDEND">Dividend</mat-option> <mat-option i18n value="DIVIDEND">Dividend</mat-option>
<mat-option i18n value="ITEM">Item</mat-option> <mat-option i18n value="ITEM">Item</mat-option>
<mat-option i18n value="LIABILITY">Liability</mat-option>
<mat-option i18n value="SELL">Sell</mat-option> <mat-option i18n value="SELL">Sell</mat-option>
</mat-select> </mat-select>
</mat-form-field> </mat-form-field>
@ -47,36 +48,10 @@
> >
<mat-form-field appearance="outline" class="w-100"> <mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Name, symbol or ISIN</mat-label> <mat-label i18n>Name, symbol or ISIN</mat-label>
<input <gf-symbol-autocomplete
autocapitalize="off"
autocomplete="off"
autocorrect="off"
formControlName="searchSymbol" formControlName="searchSymbol"
matInput [isLoading]="isLoading"
[matAutocomplete]="symbolAutocomplete"
(blur)="onBlurSymbol()"
/> />
<mat-autocomplete
#symbolAutocomplete="matAutocomplete"
[displayWith]="displayFn"
(optionSelected)="onUpdateSymbol($event)"
>
<ng-container>
<mat-option
*ngFor="let lookupItem of filteredLookupItemsObservable | async"
class="line-height-1"
[value]="lookupItem"
>
<span><b>{{ lookupItem.name }}</b></span>
<br />
<small class="text-muted"
>{{ lookupItem.symbol | gfSymbol }} · {{ lookupItem.currency
}}</small
>
</mat-option>
</ng-container>
</mat-autocomplete>
<mat-spinner *ngIf="isLoading" matSuffix [diameter]="20"></mat-spinner>
</mat-form-field> </mat-form-field>
</div> </div>
<div <div
@ -118,7 +93,10 @@
<mat-datepicker #date disabled="false"></mat-datepicker> <mat-datepicker #date disabled="false"></mat-datepicker>
</mat-form-field> </mat-form-field>
</div> </div>
<div class="mb-3"> <div
class="mb-3"
[ngClass]="{ 'd-none': activityForm.controls['type']?.value === 'ITEM' || activityForm.controls['type']?.value === 'LIABILITY' }"
>
<mat-form-field appearance="outline" class="w-100"> <mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Quantity</mat-label> <mat-label i18n>Quantity</mat-label>
<input formControlName="quantity" matInput type="number" /> <input formControlName="quantity" matInput type="number" />
@ -132,6 +110,7 @@
>Dividend</ng-container >Dividend</ng-container
> >
<ng-container *ngSwitchCase="'ITEM'" i18n>Value</ng-container> <ng-container *ngSwitchCase="'ITEM'" i18n>Value</ng-container>
<ng-container *ngSwitchCase="'LIABILITY'" i18n>Value</ng-container>
<ng-container *ngSwitchDefault i18n>Unit Price</ng-container> <ng-container *ngSwitchDefault i18n>Unit Price</ng-container>
</ng-container> </ng-container>
</mat-label> </mat-label>
@ -179,6 +158,7 @@
>Dividend</ng-container >Dividend</ng-container
> >
<ng-container *ngSwitchCase="'ITEM'" i18n>Value</ng-container> <ng-container *ngSwitchCase="'ITEM'" i18n>Value</ng-container>
<ng-container *ngSwitchCase="'LIABILITY'" i18n>Value</ng-container>
<ng-container *ngSwitchDefault i18n>Unit Price</ng-container> <ng-container *ngSwitchDefault i18n>Unit Price</ng-container>
</ng-container> </ng-container>
</mat-label> </mat-label>
@ -188,7 +168,10 @@
> >
</mat-form-field> </mat-form-field>
</div> </div>
<div class="mb-3"> <div
class="mb-3"
[ngClass]="{ 'd-none': activityForm.controls['type']?.value === 'ITEM' || activityForm.controls['type']?.value === 'LIABILITY' }"
>
<mat-form-field appearance="outline" class="w-100"> <mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Fee</mat-label> <mat-label i18n>Fee</mat-label>
<input formControlName="feeInCustomCurrency" matInput type="number" /> <input formControlName="feeInCustomCurrency" matInput type="number" />
@ -304,8 +287,9 @@
<div class="d-flex" mat-dialog-actions> <div class="d-flex" mat-dialog-actions>
<gf-value <gf-value
class="flex-grow-1" class="flex-grow-1"
[currency]="activityForm.controls['currency']?.value ?? data.user?.settings?.baseCurrency" [isCurrency]="true"
[locale]="data.user?.settings?.locale" [locale]="data.user?.settings?.locale"
[unit]="activityForm.controls['currency']?.value ?? data.user?.settings?.baseCurrency"
[value]="total" [value]="total"
></gf-value> ></gf-value>
<div> <div>

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