Compare commits

..

74 Commits

Author SHA1 Message Date
7c8530483c Release 1.183.0 (#1189) 2022-08-24 20:55:38 +02:00
539d3ff754 Feature/add asset sub class filter (#1188)
* Add asset sub class filter

* Update changelog
2022-08-24 20:53:50 +02:00
9d28b63da6 Release 1.182.0 (#1186) 2022-08-23 21:40:57 +02:00
24abbd85e6 Feature/move asset profile details to dialog (#1185)
* Introduce asset profile dialog

* Update changelog
2022-08-23 21:39:04 +02:00
b6f395fd3b Feature/improve i18n (#1183)
* Improve i18n

* Update changelog
2022-08-22 19:57:48 +02:00
04d894cf88 Release 1.181.2 (#1182) 2022-08-21 21:42:05 +02:00
b4d2c4109e Release 1.181.1 (#1181) 2022-08-21 20:58:51 +02:00
823093f4d7 Release 1.181.0 (#1180) 2022-08-21 18:08:54 +02:00
56bf422407 Consider language from user settings (#1179) 2022-08-21 18:06:31 +02:00
df0e9ad03b Bugfix/fix division by zero in benchmarks calculation (#1177)
* Fix division by zero error

* Update changelog
2022-08-21 17:03:03 +02:00
0e3702c2be Feature/improve german translation (#1178)
* Simplify and translate locales

* Add support for translated labels

* Update changelog
2022-08-21 17:02:43 +02:00
11136ae4f8 Eliminate duplicate locales (#1176) 2022-08-20 14:01:15 +02:00
2e6a7d5a91 Extract locales (#1175) 2022-08-20 11:00:53 +02:00
83845c256a Feature/add language selector (#1174)
* Add language selector

* Add translations (german)

* Update changelog
2022-08-20 10:55:27 +02:00
34c9703716 Remove locale (#1173) 2022-08-20 10:25:53 +02:00
48903238c5 Feature/improve documentation of database migration (#1172)
* Improve documentation

* Update changelog
2022-08-20 10:22:02 +02:00
57a14bd945 automate database setup and upgrade (#1163)
* Automate database setup and schema upgrade
2022-08-20 08:54:50 +02:00
4fd0622114 Fix build:dev script (#1171) 2022-08-19 20:39:21 +02:00
52f0fb5ab8 Release 1.180.1 (#1170) 2022-08-19 18:24:41 +02:00
20195b2b1a Release 1.180.0 (#1169) 2022-08-18 21:15:17 +02:00
7fa4e6ebd2 Feature/resolve feature graphic of blog post (#1168)
* Resolve feature graphic of blog post

* Update changelog
2022-08-18 21:13:39 +02:00
d8531ddfcb Bugfix/fix links to blog posts (#1167)
* Fix links

* Update changelog
2022-08-18 21:11:10 +02:00
70d670b711 Bugfix/fix license (#1160)
* Fix license

* Update changelog
2022-08-17 23:23:39 +02:00
27b0663a80 Add translations (#1159) 2022-08-16 22:16:15 +02:00
874dfb0235 Improve links (#1158) 2022-08-16 21:52:37 +02:00
072db0d558 Add translations (#1157) 2022-08-16 21:40:51 +02:00
12e692429a Regenerate xlf (#1156) 2022-08-16 21:30:12 +02:00
e22b8b78b8 Feature/tag route titles with template literal strings (#1155)
* Tagged template literal strings

* Update changelog
2022-08-16 21:03:05 +02:00
dc5052f7dc Feature/set up language localization for german (#1153)
* Set up language localization for German

* Update changelog
2022-08-16 20:58:08 +02:00
335553e891 Feature/tag template literal strings (#1152)
* Tagged template literal strings

* Update changelog
2022-08-16 20:53:14 +02:00
d480ad1023 Extract locales (#1151) 2022-08-15 19:56:42 +02:00
7320751056 Feature/set up ng extract i18n merge (#1149)
* Set up ng-extract-i18n-merge

* Update changelog
2022-08-15 19:52:43 +02:00
108c0c13c4 Release 1.179.5 (#1150) 2022-08-15 18:17:57 +02:00
053a5cc5b5 Release 1.179.4 (#1148) 2022-08-14 10:10:54 +02:00
c456a8bcfe Release/1.179.3 (#1147)
* Clean up

* Release 1.179.3
2022-08-13 20:33:43 +02:00
6fcecb5bc6 Release 1.179.2 (#1145) 2022-08-13 13:39:37 +02:00
e4e0a7d9f0 Release 1.179.1 (#1144) 2022-08-13 12:16:39 +02:00
c7173761a3 Release 1.179.0 (#1143) 2022-08-13 10:44:38 +02:00
185e130d9f Feature/add blog post 500 stars on GitHub (#1138)
* Add blog post

* Update changelog
2022-08-13 10:42:56 +02:00
81245635af Feature/setup i18n (#1139)
* Setup i18n

* Update changelog
2022-08-13 10:29:36 +02:00
55182ac1af Feature/reduce maximum width of performance chart (#1137)
* Reduce maximum width

* Update changelog
2022-08-10 17:26:34 +02:00
0b446a30ae Release 1.178.0 (#1136) 2022-08-09 21:28:02 +02:00
c5e6602102 Improve filter by asset class (#1135) 2022-08-09 21:25:07 +02:00
573038f407 Feature/add default values for countries and sectors (#1133)
* Add default values

* Add database validation script

* Update changelog
2022-08-09 19:31:13 +02:00
dbc38e705e Feature/add url to symbol profile overrides (#1132)
* Add url to symbol profile overrides

* Improve filter by asset class

* Update changelog
2022-08-09 19:29:26 +02:00
f127e7c61a Feature/improve styling of benchmarks (#1131)
* Harmonize benchmark table styling

* Update changelog
2022-08-09 19:28:13 +02:00
4ccabde251 Feature/simplify exchange rate service initialization (#1128)
* Simplify initialization

* Update changelog
2022-08-08 19:25:38 +02:00
86ae88f90f Release 1.177.0 (#1127) 2022-08-04 13:38:25 +02:00
69bc1d67e1 Bugfix/fix database connection error handling (#1125)
* Fix database connection error handling

* Update changelog
2022-08-04 13:36:32 +02:00
03942aecda Feature/upgrade to prisma 4.1.1 (#1126)
* Upgrade prisma to 4.1.1

* Update changelog
2022-08-04 13:35:58 +02:00
7ec9170c0d baseline prisma bdd at first setup (#1124)
* Baseline database (migrations) in setup

Co-authored-by: Thomas Kaul <4159106+dtslvr@users.noreply.github.com>
2022-08-04 13:34:32 +02:00
51431a7fb2 Feature/add ghostfolio as default to data sources (#1122)
* Add GHOSTFOLIO

* Update changelog
2022-08-03 21:36:12 +02:00
4adda6783d Feature/add agplv3 logo to landing page (#1121)
* Add AGPLv3 logo

* Update changelog
2022-08-02 22:14:27 +02:00
d5cd4c0dea Feature/upgrade nx to version 14.5.1 (#1120)
* Upgrade Nx including angular and nestjs

* Update changelog
2022-08-01 19:56:44 +02:00
34be10d755 Release 1.176.2 (#1119) 2022-07-31 08:16:30 +02:00
51f586e160 Feature/upgrade node from 14 to 16 (#1118)
* Upgrade to Node.js 16

* Update changelog
2022-07-31 08:14:52 +02:00
ff64a00196 Release 1.176.1 (#1117) 2022-07-31 00:19:40 +02:00
148f6f8762 Feature/refactor env variables access (#1116)
* Refactor env variables access

* Update changelog
2022-07-31 00:18:09 +02:00
bf2c4d1e9e Release 1.176.0 (#1115) 2022-07-30 21:27:35 +02:00
eee1f1c722 Feature/add page titles (#1114)
* Add page titles

* Update changelog
2022-07-30 21:26:08 +02:00
9f2a49a1c7 Feature/introduce max number of symbols per data provider request (#1111)
* Introduce maximum number of symbols per request

* Set log level settings

* Update changelog
2022-07-30 15:48:45 +02:00
44058b2d7a Add descriptions (#1113) 2022-07-30 15:46:58 +02:00
23634f3404 Add environment variables (#1112) 2022-07-30 15:21:14 +02:00
f93dab6086 Add FAQ (#1108) 2022-07-29 19:25:41 +02:00
207859cc22 Release 1.175.0 (#1107) 2022-07-29 18:34:05 +02:00
77181aaaff Feature/setup faq page (#1106)
* Set up FAQ page

* Update changelog
2022-07-29 18:32:26 +02:00
412039badf Bugfix/show symbols of activities in account detail dialog (#1105)
* Show symbols

* Update changelog
2022-07-29 17:04:30 +02:00
7619442895 Feature/add savings rate to investment timeline (#1104)
* Add line for savings rate

* Update changelog
2022-07-29 17:03:23 +02:00
61ecd66e0f Release 1.174.0 (#1098) 2022-07-27 21:09:03 +02:00
81217b35ef Feature/add comment to activity (#1097)
* Add comment to activity

* Update changelog
2022-07-27 21:07:27 +02:00
678f1f0051 Release 1.173.0 (#1095) 2022-07-23 20:38:53 +02:00
71c7e37b5a Bugfix/fix currency inconsistency with usx (#1094)
* Support USX

* Update changelog
2022-07-23 20:37:26 +02:00
80459371f3 Release 1.172.0 (#1093) 2022-07-23 12:09:38 +02:00
35f1f348a8 Feature/add blog post ghostfolio meets internet identity (#1092)
* Add blog post: Ghostfolio meets Internet Identity

* Update changelog
2022-07-23 12:06:49 +02:00
172 changed files with 10646 additions and 2055 deletions

View File

@ -2,7 +2,7 @@ language: node_js
git:
depth: false
node_js:
- 14
- 16
services:
- docker

View File

@ -5,6 +5,157 @@ 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/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## 1.183.0 - 24.08.2022
### Added
- Added a filter by asset sub class for the asset profiles in the admin control
### Changed
- Improved the language localization for German (`de`)
## 1.182.0 - 23.08.2022
### Changed
- Improved the language localization for German (`de`)
- Extended and made the columns of the asset profiles sortable in the admin control
- Moved the asset profile details in the admin control panel to a dialog
## 1.181.2 - 21.08.2022
### Added
- Added a language selector to the account page
- Added support for translated labels in the value component
### Changed
- Integrated the commands `database:setup` and `database:migrate` into the container start
### Fixed
- Fixed a division by zero error in the benchmarks calculation
### Todo
- Apply manual data migration (`yarn database:migrate`) is not needed anymore
## 1.180.1 - 18.08.2022
### Added
- Set up `ng-extract-i18n-merge` to improve the i18n extraction and merge workflow
- Set up language localization for German (`de`)
- Resolved the feature graphic of the blog post
### Changed
- Tagged template literal strings in components for localization with `$localize`
### Fixed
- Fixed the license component in the about page
- Fixed the links to the blog posts
## 1.179.5 - 15.08.2022
### Added
- Set up i18n support
- Added a blog post: _500 Stars on GitHub_
### Changed
- Reduced the maximum width of the performance chart on the home page
## 1.178.0 - 09.08.2022
### Added
- Added `url` to the symbol profile overrides model for manual adjustments
- Added default values for `countries` and `sectors` of the symbol profile overrides model
### Changed
- Simplified the initialization of the exchange rate service
- Improved the orders query for `assetClass` with symbol profile overrides
- Improved the styling of the benchmarks in the markets overview
### Todo
- Apply data migration (`yarn database:migrate`)
## 1.177.0 - 04.08.2022
### Added
- Added `GHOSTFOLIO` as a default to `DATA_SOURCES`
- Added the `AGPLv3` logo to the landing page
### Changed
- Refactored the initialization of the exchange rate service
- Upgraded `angular` from version `14.0.2` to `14.1.0`
- Upgraded `nestjs` from version `8.4.7` to `9.0.7`
- Upgraded `Nx` from version `14.3.5` to `14.5.1`
- Upgraded `prisma` from version `3.15.2` to `4.1.1`
### Fixed
- Handled database connection errors (do not exit process)
## 1.176.2 - 31.07.2022
### Added
- Added page titles
### Changed
- Improved the performance of data provider requests by introducing a maximum number of symbols per request (chunk size)
- Changed the log level settings
- Refactored the access of the environment variables in the bootstrap function (api)
- Upgraded `Node.js` from version `14` to `16` (`Dockerfile`)
### Todo
- Upgrade to `Node.js` 16+
## 1.175.0 - 29.07.2022
### Added
- Set up a Frequently Asked Questions (FAQ) page
- Added the savings rate to the investment timeline grouped by month
### Fixed
- Added the symbols to the activities in the account detail dialog
## 1.174.0 - 27.07.2022
### Added
- Support a note for activities
### Todo
- Apply data migration (`yarn database:migrate`)
## 1.173.0 - 23.07.2022
### Fixed
- Fixed an issue with the currency inconsistency in the _Yahoo Finance_ service (convert from `USX` to `USD`)
## 1.172.0 - 23.07.2022
### Added
- Added a blog post: _Ghostfolio meets Internet Identity_
## 1.171.0 - 22.07.2022
### Added

View File

@ -1,4 +1,4 @@
FROM node:14-alpine as builder
FROM node:16-alpine as builder
# Build application and add additional files
@ -45,8 +45,8 @@ COPY package.json /ghostfolio/dist/apps/api
RUN yarn database:generate-typings
# Image to run, copy everything needed from builder
FROM node:14-alpine
FROM node:16-alpine
COPY --from=builder /ghostfolio/dist/apps /ghostfolio/apps
WORKDIR /ghostfolio/apps/api
EXPOSE 3333
CMD [ "node", "main" ]
CMD [ "yarn", "start:prod" ]

View File

@ -12,7 +12,7 @@
<strong>Open Source Wealth Management Software</strong>
</p>
<p>
<a href="https://ghostfol.io"><strong>Ghostfol.io</strong></a> | <a href="https://ghostfol.io/demo"><strong>Live Demo</strong></a> | <a href="https://ghostfol.io/pricing"><strong>Ghostfolio Premium</strong></a> | <a href="https://ghostfol.io/blog"><strong>Blog</strong></a> | <a href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"><strong>Slack</strong></a> | <a href="https://twitter.com/ghostfolio_"><strong>Twitter</strong></a>
<a href="https://ghostfol.io"><strong>Ghostfol.io</strong></a> | <a href="https://ghostfol.io/demo"><strong>Live Demo</strong></a> | <a href="https://ghostfol.io/pricing"><strong>Ghostfolio Premium</strong></a> | <a href="https://ghostfol.io/faq"><strong>FAQ</strong></a> | <a href="https://ghostfol.io/blog"><strong>Blog</strong></a> | <a href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"><strong>Slack</strong></a> | <a href="https://twitter.com/ghostfolio_"><strong>Twitter</strong></a>
</p>
<p>
<a href="#contributing">
@ -81,6 +81,23 @@ The frontend is built with [Angular](https://angular.io) and uses [Angular Mater
## Self-hosting
### Supported Environment Variables
| Name | Default Value | Description |
| ------------------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `ACCESS_TOKEN_SALT` | | A random string used as salt for access tokens |
| `BASE_CURRENCY` | `USD` | The base currency of the Ghostfolio application. Caution: This cannot be changed later! |
| `DATABASE_URL` | | The database connection URL, e.g. `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer` |
| `HOST` | `0.0.0.0` | The host where the Ghostfolio application will run on |
| `JWT_SECRET_KEY` | | A random string used for _JSON Web Tokens_ (JWT) |
| `PORT` | `3333` | The port where the Ghostfolio application will run on |
| `POSTGRES_DB` | | The name of the _PostgreSQL_ database |
| `POSTGRES_PASSWORD` | | The password of the _PostgreSQL_ database |
| `POSTGRES_USER` | | The user of the _PostgreSQL_ database |
| `REDIS_HOST` | `localhost` | The host where _Redis_ is running |
| `REDIS_PASSWORD` | | The password of _Redis_ |
| `REDIS_PORT` | `6379` | The port where _Redis_ is running |
### Run with Docker Compose
#### Prerequisites
@ -97,14 +114,6 @@ Run the following command to start the Docker images from [Docker Hub](https://h
docker-compose --env-file ./.env -f docker/docker-compose.yml up -d
```
##### Setup Database
Run the following command to setup the database once Ghostfolio is running:
```bash
docker-compose --env-file ./.env -f docker/docker-compose.yml exec ghostfolio yarn database:setup
```
#### b. Build and run environment
Run the following commands to build and start the Docker images:
@ -114,14 +123,6 @@ docker-compose --env-file ./.env -f docker/docker-compose.build.yml build
docker-compose --env-file ./.env -f docker/docker-compose.build.yml up -d
```
##### Setup Database
Run the following command to setup the database once Ghostfolio is running:
```bash
docker-compose --env-file ./.env -f docker/docker-compose.build.yml exec ghostfolio yarn database:setup
```
#### Fetch Historical Data
Open http://localhost:3333 in your browser and accomplish these steps:
@ -133,8 +134,8 @@ Open http://localhost:3333 in your browser and accomplish these steps:
#### Upgrade Version
1. Increase the version of the `ghostfolio/ghostfolio` Docker image in `docker/docker-compose.yml`
1. Run the following command to start the new Docker image: `docker-compose --env-file ./.env -f docker/docker-compose.yml up -d`
1. Then, run the following command to keep your database schema in sync: `docker-compose --env-file ./.env -f docker/docker-compose.yml exec ghostfolio yarn database:migrate`
1. Run the following command to start the new Docker image: `docker-compose --env-file ./.env -f docker/docker-compose.yml up -d`
At each start, the container will automatically apply the database schema migrations if needed.
### Run with _Unraid_ (Community)
@ -145,7 +146,7 @@ Please follow the instructions of the Ghostfolio [Unraid Community App](https://
### Prerequisites
- [Docker](https://www.docker.com/products/docker-desktop)
- [Node.js](https://nodejs.org/en/download) (version 14+)
- [Node.js](https://nodejs.org/en/download) (version 16+)
- [Yarn](https://yarnpkg.com/en/docs/install)
- A local copy of this Git repository (clone)

View File

@ -77,41 +77,45 @@
"polyfills": "apps/client/src/polyfills.ts",
"tsConfig": "apps/client/tsconfig.app.json",
"assets": [
"apps/client/src/assets",
{
"glob": "assetlinks.json",
"input": "apps/client/src/assets",
"output": "./.well-known"
"output": "./../.well-known"
},
{
"glob": "CHANGELOG.md",
"input": "",
"output": "./assets"
"output": "./../assets"
},
{
"glob": "LICENSE",
"input": "",
"output": "./assets"
"output": "./../assets"
},
{
"glob": "robots.txt",
"input": "apps/client/src/assets",
"output": "./"
"output": "./../"
},
{
"glob": "sitemap.xml",
"input": "apps/client/src/assets",
"output": "./"
"output": "./../"
},
{
"glob": "**/*",
"input": "node_modules/ionicons/dist/ionicons",
"output": "./ionicons"
"output": "./../ionicons"
},
{
"glob": "**/*.js",
"input": "node_modules/ionicons/dist/",
"output": "./"
"output": "./../"
},
{
"glob": "**/*",
"input": "apps/client/src/assets",
"output": "./../assets/"
}
],
"styles": ["apps/client/src/styles.scss"],
@ -124,6 +128,14 @@
"namedChunks": true
},
"configurations": {
"development-de": {
"baseHref": "/de/",
"localize": ["de"]
},
"development-en": {
"baseHref": "/en/",
"localize": ["en"]
},
"production": {
"fileReplacements": [
{
@ -162,15 +174,24 @@
"proxyConfig": "apps/client/proxy.conf.json"
},
"configurations": {
"development-de": {
"browserTarget": "client:build:development-de"
},
"development-en": {
"browserTarget": "client:build:development-en"
},
"production": {
"browserTarget": "client:build:production"
}
}
},
"extract-i18n": {
"builder": "@angular-devkit/build-angular:extract-i18n",
"builder": "ng-extract-i18n-merge:ng-extract-i18n-merge",
"options": {
"browserTarget": "client:build"
"browserTarget": "client:build",
"includeContext": true,
"outputPath": "src/locales",
"targetFiles": ["messages.de.xlf"]
}
},
"lint": {
@ -188,6 +209,15 @@
"outputs": ["coverage/apps/client"]
}
},
"i18n": {
"locales": {
"de": {
"baseHref": "/de/",
"translation": "apps/client/src/locales/messages.de.xlf"
}
},
"sourceLocale": "en"
},
"tags": []
},
"client-e2e": {

View File

@ -8,7 +8,8 @@ import {
import {
AdminData,
AdminMarketData,
AdminMarketDataDetails
AdminMarketDataDetails,
Filter
} from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types';
@ -22,6 +23,7 @@ import {
Param,
Post,
Put,
Query,
UseGuards
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
@ -226,7 +228,9 @@ export class AdminController {
@Get('market-data')
@UseGuards(AuthGuard('jwt'))
public async getMarketData(): Promise<AdminMarketData> {
public async getMarketData(
@Query('assetSubClasses') filterByAssetSubClasses?: string
): Promise<AdminMarketData> {
if (
!hasPermission(
this.request.user.permissions,
@ -239,7 +243,18 @@ export class AdminController {
);
}
return this.adminService.getMarketData();
const assetSubClasses = filterByAssetSubClasses?.split(',') ?? [];
const filters: Filter[] = [
...assetSubClasses.map((assetSubClass) => {
return <Filter>{
id: assetSubClass,
type: 'ASSET_SUB_CLASS'
};
})
];
return this.adminService.getMarketData(filters);
}
@Get('market-data/:dataSource/:symbol')

View File

@ -11,11 +11,13 @@ import {
AdminMarketData,
AdminMarketDataDetails,
AdminMarketDataItem,
Filter,
UniqueAsset
} from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common';
import { Property } from '@prisma/client';
import { AssetSubClass, Prisma, Property } from '@prisma/client';
import { differenceInDays } from 'date-fns';
import { groupBy } from 'lodash';
@Injectable()
export class AdminService {
@ -63,14 +65,27 @@ export class AdminService {
};
}
public async getMarketData(): Promise<AdminMarketData> {
public async getMarketData(filters?: Filter[]): Promise<AdminMarketData> {
const where: Prisma.SymbolProfileWhereInput = {};
const { ASSET_SUB_CLASS: filtersByAssetSubClass } = groupBy(
filters,
(filter) => {
return filter.type;
}
);
const marketData = await this.prismaService.marketData.groupBy({
_count: true,
by: ['dataSource', 'symbol']
});
const currencyPairsToGather: AdminMarketDataItem[] =
this.exchangeRateDataService
let currencyPairsToGather: AdminMarketDataItem[] = [];
if (filtersByAssetSubClass) {
where.assetSubClass = AssetSubClass[filtersByAssetSubClass[0].id];
} else {
currencyPairsToGather = this.exchangeRateDataService
.getCurrencyPairs()
.map(({ dataSource, symbol }) => {
const marketDataItemCount =
@ -84,17 +99,24 @@ export class AdminService {
return {
dataSource,
marketDataItemCount,
symbol
symbol,
countriesCount: 0,
sectorsCount: 0
};
});
}
const symbolProfilesToGather: AdminMarketDataItem[] = (
await this.prismaService.symbolProfile.findMany({
where,
orderBy: [{ symbol: 'asc' }],
select: {
_count: {
select: { Order: true }
},
assetClass: true,
assetSubClass: true,
countries: true,
dataSource: true,
Order: {
orderBy: [{ date: 'asc' }],
@ -102,10 +124,14 @@ export class AdminService {
take: 1
},
scraperConfiguration: true,
sectors: true,
symbol: true
}
})
).map((symbolProfile) => {
const countriesCount = symbolProfile.countries
? Object.keys(symbolProfile.countries).length
: 0;
const marketDataItemCount =
marketData.find((marketDataItem) => {
return (
@ -113,10 +139,17 @@ export class AdminService {
marketDataItem.symbol === symbolProfile.symbol
);
})?._count ?? 0;
const sectorsCount = symbolProfile.sectors
? Object.keys(symbolProfile.sectors).length
: 0;
return {
countriesCount,
marketDataItemCount,
sectorsCount,
activityCount: symbolProfile._count.Order,
assetClass: symbolProfile.assetClass,
assetSubClass: symbolProfile.assetSubClass,
dataSource: symbolProfile.dataSource,
date: symbolProfile.Order?.[0]?.date,
symbol: symbolProfile.symbol

View File

@ -1,6 +1,17 @@
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { Controller } from '@nestjs/common';
@Controller()
export class AppController {
public constructor() {}
public constructor(
private readonly exchangeRateDataService: ExchangeRateDataService
) {
this.initialize();
}
private async initialize() {
try {
await this.exchangeRateDataService.initialize();
} catch {}
}
}

View File

@ -10,7 +10,7 @@ import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-d
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
import { TwitterBotModule } from '@ghostfolio/api/services/twitter-bot/twitter-bot.module';
import { BullModule } from '@nestjs/bull';
import { Module } from '@nestjs/common';
import { MiddlewareConsumer, Module, RequestMethod } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { ScheduleModule } from '@nestjs/schedule';
import { ServeStaticModule } from '@nestjs/serve-static';
@ -23,6 +23,7 @@ import { AuthModule } from './auth/auth.module';
import { BenchmarkModule } from './benchmark/benchmark.module';
import { CacheModule } from './cache/cache.module';
import { ExportModule } from './export/export.module';
import { FrontendMiddleware } from './frontend.middleware';
import { ImportModule } from './import/import.module';
import { InfoModule } from './info/info.module';
import { OrderModule } from './order/order.module';
@ -82,4 +83,10 @@ import { UserModule } from './user/user.module';
controllers: [AppController],
providers: [CronService]
})
export class AppModule {}
export class AppModule {
configure(consumer: MiddlewareConsumer) {
consumer
.apply(FrontendMiddleware)
.forRoutes({ path: '*', method: RequestMethod.ALL });
}
}

View File

@ -1,5 +1,6 @@
import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
import { OAuthResponse } from '@ghostfolio/common/interfaces';
import {
Body,
@ -62,9 +63,17 @@ export class AuthController {
const jwt: string = req.user.jwt;
if (jwt) {
res.redirect(`${this.configurationService.get('ROOT_URL')}/auth/${jwt}`);
res.redirect(
`${this.configurationService.get(
'ROOT_URL'
)}/${DEFAULT_LANGUAGE_CODE}/auth/${jwt}`
);
} else {
res.redirect(`${this.configurationService.get('ROOT_URL')}/auth`);
res.redirect(
`${this.configurationService.get(
'ROOT_URL'
)}/${DEFAULT_LANGUAGE_CODE}/auth`
);
}
}

View File

@ -48,9 +48,13 @@ export class BenchmarkService {
benchmarks = allTimeHighs.map((allTimeHigh, index) => {
const { marketPrice } = quotes[benchmarkAssets[index].symbol];
const performancePercentFromAllTimeHigh = new Big(marketPrice)
.div(allTimeHigh)
.minus(1);
let performancePercentFromAllTimeHigh = new Big(0);
if (allTimeHigh) {
performancePercentFromAllTimeHigh = new Big(marketPrice)
.div(allTimeHigh)
.minus(1);
}
return {
marketCondition: this.getMarketCondition(

View File

@ -18,6 +18,7 @@ export class ExportService {
orderBy: { date: 'desc' },
select: {
accountId: true,
comment: true,
date: true,
fee: true,
id: true,
@ -40,6 +41,7 @@ export class ExportService {
activities: activities.map(
({
accountId,
comment,
date,
fee,
id,
@ -50,6 +52,7 @@ export class ExportService {
}) => {
return {
accountId,
comment,
fee,
id,
quantity,

View File

@ -0,0 +1,81 @@
import * as fs from 'fs';
import * as path from 'path';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
import { Injectable, NestMiddleware } from '@nestjs/common';
import { NextFunction, Request, Response } from 'express';
@Injectable()
export class FrontendMiddleware implements NestMiddleware {
public indexHtmlDe = fs.readFileSync(
this.getPathOfIndexHtmlFile('de'),
'utf8'
);
public indexHtmlEn = fs.readFileSync(
this.getPathOfIndexHtmlFile(DEFAULT_LANGUAGE_CODE),
'utf8'
);
public constructor(
private readonly configurationService: ConfigurationService
) {}
public use(req: Request, res: Response, next: NextFunction) {
let featureGraphicPath = 'assets/cover.png';
if (
req.path === '/en/blog/2022/08/500-stars-on-github' ||
req.path === '/en/blog/2022/08/500-stars-on-github/'
) {
featureGraphicPath = 'assets/images/blog/500-stars-on-github.jpg';
}
if (req.path.startsWith('/api/') || this.isFileRequest(req.url)) {
// Skip
next();
} else if (req.path === '/de' || req.path.startsWith('/de/')) {
res.send(
this.interpolate(this.indexHtmlDe, {
featureGraphicPath,
languageCode: 'de',
path: req.path,
rootUrl: this.configurationService.get('ROOT_URL')
})
);
} else {
res.send(
this.interpolate(this.indexHtmlEn, {
featureGraphicPath,
languageCode: DEFAULT_LANGUAGE_CODE,
path: req.path,
rootUrl: this.configurationService.get('ROOT_URL')
})
);
}
}
private getPathOfIndexHtmlFile(aLocale: string) {
return path.join(__dirname, '..', 'client', aLocale, 'index.html');
}
private interpolate(template: string, context: any) {
return template.replace(/[$]{([^}]+)}/g, (_, objectPath) => {
const properties = objectPath.split('.');
return properties.reduce(
(previous, current) => previous?.[current],
context
);
});
}
private isFileRequest(filename: string) {
if (filename === '/assets/LICENSE') {
return true;
} else if (filename.includes('auth/ey')) {
return false;
}
return filename.split('.').pop() !== filename;
}
}

View File

@ -48,6 +48,7 @@ export class ImportService {
for (const {
accountId,
comment,
currency,
dataSource,
date,
@ -58,6 +59,7 @@ export class ImportService {
unitPrice
} of activities) {
await this.orderService.createOrder({
comment,
fee,
quantity,
type,

View File

@ -5,6 +5,7 @@ import {
Tag,
Type
} from '@prisma/client';
import { Transform, TransformFnParams } from 'class-transformer';
import {
IsArray,
IsEnum,
@ -13,25 +14,33 @@ import {
IsOptional,
IsString
} from 'class-validator';
import { isString } from 'lodash';
export class CreateOrderDto {
@IsString()
@IsOptional()
@IsString()
accountId?: string;
@IsEnum(AssetClass, { each: true })
@IsOptional()
@IsEnum(AssetClass, { each: true })
assetClass?: AssetClass;
@IsEnum(AssetSubClass, { each: true })
@IsOptional()
@IsEnum(AssetSubClass, { each: true })
assetSubClass?: AssetSubClass;
@IsOptional()
@IsString()
@Transform(({ value }: TransformFnParams) =>
isString(value) ? value.trim() : value
)
comment?: string;
@IsString()
currency: string;
@IsEnum(DataSource, { each: true })
@IsOptional()
@IsEnum(DataSource, { each: true })
dataSource?: DataSource;
@IsISO8601()

View File

@ -143,6 +143,11 @@ export class OrderService {
delete data.accountId;
delete data.assetClass;
delete data.assetSubClass;
if (!data.comment) {
delete data.comment;
}
delete data.currency;
delete data.dataSource;
delete data.symbol;
@ -225,9 +230,10 @@ export class OrderService {
})
},
{
SymbolProfileOverrides: {
is: null
}
OR: [
{ SymbolProfileOverrides: { is: null } },
{ SymbolProfileOverrides: { assetClass: null } }
]
}
]
},
@ -316,6 +322,10 @@ export class OrderService {
delete data.Account;
}
if (!data.comment) {
data.comment = null;
}
const tags = data.tags ?? [];
let isDraft = false;

View File

@ -5,6 +5,7 @@ import {
Tag,
Type
} from '@prisma/client';
import { Transform, TransformFnParams } from 'class-transformer';
import {
IsArray,
IsEnum,
@ -13,6 +14,7 @@ import {
IsOptional,
IsString
} from 'class-validator';
import { isString } from 'lodash';
export class UpdateOrderDto {
@IsOptional()
@ -27,6 +29,13 @@ export class UpdateOrderDto {
@IsOptional()
assetSubClass?: AssetSubClass;
@IsOptional()
@IsString()
@Transform(({ value }: TransformFnParams) =>
isString(value) ? value.trim() : value
)
comment?: string;
@IsString()
currency: string;

View File

@ -1,6 +1,9 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { PROPERTY_COUPONS } from '@ghostfolio/common/config';
import {
DEFAULT_LANGUAGE_CODE,
PROPERTY_COUPONS
} from '@ghostfolio/common/config';
import { Coupon } from '@ghostfolio/common/interfaces';
import type { RequestWithUser } from '@ghostfolio/common/types';
import {
@ -93,7 +96,11 @@ export class SubscriptionController {
'SubscriptionController'
);
res.redirect(`${this.configurationService.get('ROOT_URL')}/account`);
res.redirect(
`${this.configurationService.get(
'ROOT_URL'
)}/${DEFAULT_LANGUAGE_CODE}/account`
);
}
@Post('stripe/checkout-session')

View File

@ -1,5 +1,6 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
import { SubscriptionType } from '@ghostfolio/common/types/subscription.type';
import { Injectable, Logger } from '@nestjs/common';
import { Subscription } from '@prisma/client';
@ -33,7 +34,9 @@ export class SubscriptionService {
userId: string;
}) {
const checkoutSessionCreateParams: Stripe.Checkout.SessionCreateParams = {
cancel_url: `${this.configurationService.get('ROOT_URL')}/account`,
cancel_url: `${this.configurationService.get(
'ROOT_URL'
)}/${DEFAULT_LANGUAGE_CODE}/account`,
client_reference_id: userId,
line_items: [
{

View File

@ -9,6 +9,10 @@ export class UpdateUserSettingDto {
@IsOptional()
isRestrictedView?: boolean;
@IsString()
@IsOptional()
language?: string;
@IsString()
@IsOptional()
locale?: string;

View File

@ -1,11 +1,24 @@
import { Logger, ValidationPipe, VersioningType } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app/app.module';
import { environment } from './environments/environment';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const configApp = await NestFactory.create(AppModule);
const configService = configApp.get<ConfigService>(ConfigService);
const NODE_ENV =
configService.get<'development' | 'production'>('NODE_ENV') ??
'development';
const app = await NestFactory.create(AppModule, {
logger:
NODE_ENV === 'production'
? ['error', 'log', 'warn']
: ['debug', 'error', 'log', 'verbose', 'warn']
});
app.enableCors();
app.enableVersioning({
defaultVersion: '1',
@ -20,11 +33,11 @@ async function bootstrap() {
})
);
const host = process.env.HOST || '0.0.0.0';
const port = process.env.PORT || 3333;
await app.listen(port, host, () => {
const HOST = configService.get<string>('HOST') || '0.0.0.0';
const PORT = configService.get<number>('PORT') || 3333;
await app.listen(PORT, HOST, () => {
logLogo();
Logger.log(`Listening at http://${host}:${port}`);
Logger.log(`Listening at http://${HOST}:${PORT}`);
Logger.log('');
});
}

View File

@ -15,7 +15,9 @@ export class ConfigurationService {
BASE_CURRENCY: str({ default: 'USD' }),
CACHE_TTL: num({ default: 1 }),
DATA_SOURCE_PRIMARY: str({ default: DataSource.YAHOO }),
DATA_SOURCES: json({ default: [DataSource.YAHOO] }),
DATA_SOURCES: json({
default: [DataSource.GHOSTFOLIO, DataSource.YAHOO]
}),
ENABLE_FEATURE_BLOG: bool({ default: false }),
ENABLE_FEATURE_CUSTOM_SYMBOLS: bool({ default: false }),
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: bool({ default: false }),

View File

@ -168,6 +168,7 @@ export class DataProviderService {
const response: {
[symbol: string]: IDataProviderResponse;
} = {};
const startTimeTotal = performance.now();
const itemsGroupedByDataSource = groupBy(items, (item) => item.dataSource);
@ -176,25 +177,59 @@ export class DataProviderService {
for (const [dataSource, dataGatheringItems] of Object.entries(
itemsGroupedByDataSource
)) {
const dataProvider = this.getDataProvider(DataSource[dataSource]);
const symbols = dataGatheringItems.map((dataGatheringItem) => {
return dataGatheringItem.symbol;
});
const promise = Promise.resolve(
this.getDataProvider(DataSource[dataSource]).getQuotes(symbols)
);
const maximumNumberOfSymbolsPerRequest =
dataProvider.getMaxNumberOfSymbolsPerRequest?.() ??
Number.MAX_SAFE_INTEGER;
for (
let i = 0;
i < symbols.length;
i += maximumNumberOfSymbolsPerRequest
) {
const startTimeDataSource = performance.now();
promises.push(
promise.then((result) => {
for (const [symbol, dataProviderResponse] of Object.entries(result)) {
response[symbol] = dataProviderResponse;
}
})
);
const symbolsChunk = symbols.slice(
i,
i + maximumNumberOfSymbolsPerRequest
);
const promise = Promise.resolve(dataProvider.getQuotes(symbolsChunk));
promises.push(
promise.then((result) => {
for (const [symbol, dataProviderResponse] of Object.entries(
result
)) {
response[symbol] = dataProviderResponse;
}
Logger.debug(
`Fetched ${symbolsChunk.length} quotes from ${dataSource} in ${(
(performance.now() - startTimeDataSource) /
1000
).toFixed(3)} seconds`
);
})
);
}
}
await Promise.all(promises);
Logger.debug('------------------------------------------------');
Logger.debug(
`Fetched ${items.length} quotes in ${(
(performance.now() - startTimeTotal) /
1000
).toFixed(3)} seconds`
);
Logger.debug('================================================');
return response;
}

View File

@ -81,6 +81,12 @@ export class EodHistoricalDataService implements DataProviderInterface {
}
}
public getMaxNumberOfSymbolsPerRequest() {
// It is not recommended using more than 15-20 tickers per request
// https://eodhistoricaldata.com/financial-apis/live-realtime-stocks-api
return 20;
}
public getName(): DataSource {
return DataSource.EOD_HISTORICAL_DATA;
}

View File

@ -20,6 +20,8 @@ export interface DataProviderInterface {
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
}>; // TODO: Return only one symbol
getMaxNumberOfSymbolsPerRequest?(): number;
getName(): DataSource;
getQuotes(

View File

@ -208,6 +208,10 @@ export class YahooFinanceService implements DataProviderInterface {
}
}
public getMaxNumberOfSymbolsPerRequest() {
return 50;
}
public getName(): DataSource {
return DataSource.YAHOO;
}
@ -266,6 +270,16 @@ export class YahooFinanceService implements DataProviderInterface {
}
}
if (yahooFinanceSymbols.includes('USDUSX=X')) {
// Convert USD to USX (cent)
response['USDUSX'] = {
currency: 'USX',
dataSource: this.getName(),
marketPrice: new Big(1).mul(100).toNumber(),
marketState: 'open'
};
}
return response;
} catch (error) {
Logger.error(error, 'YahooFinanceService');

View File

@ -22,9 +22,7 @@ export class ExchangeRateDataService {
private readonly dataProviderService: DataProviderService,
private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService
) {
this.initialize();
}
) {}
public getCurrencies() {
return this.currencies?.length > 0 ? this.currencies : [this.baseCurrency];
@ -122,15 +120,6 @@ export class ExchangeRateDataService {
return 0;
}
const hasNaN = Object.values(this.exchangeRates).some((exchangeRate) => {
return isNaN(exchangeRate);
});
if (hasNaN) {
// Reinitialize if data is not loaded correctly
this.initialize();
}
let factor = 1;
if (aFromCurrency !== aToCurrency) {

View File

@ -1,15 +1,25 @@
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import {
Injectable,
Logger,
OnModuleDestroy,
OnModuleInit
} from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class PrismaService
extends PrismaClient
implements OnModuleInit, OnModuleDestroy {
async onModuleInit() {
await this.$connect();
implements OnModuleInit, OnModuleDestroy
{
public async onModuleInit() {
try {
await this.$connect();
} catch (error) {
Logger.error(error, 'PrismaService');
}
}
async onModuleDestroy() {
public async onModuleDestroy() {
await this.$disconnect();
}
}

View File

@ -115,9 +115,16 @@ export class SymbolProfileService {
}
item.name = item.SymbolProfileOverrides?.name ?? item.name;
item.sectors =
(item.SymbolProfileOverrides.sectors as unknown as Sector[]) ??
item.sectors;
if (
(item.SymbolProfileOverrides.sectors as unknown as Sector[])?.length >
0
) {
item.sectors = item.SymbolProfileOverrides
.sectors as unknown as Sector[];
}
item.url = item.SymbolProfileOverrides?.url ?? item.url;
delete item.SymbolProfileOverrides;
}

View File

@ -1,5 +1,6 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { RouterModule, Routes, TitleStrategy } from '@angular/router';
import { PageTitleStrategy } from '@ghostfolio/client/services/page-title.strategy';
import { ModulePreloadService } from './core/module-preload.service';
@ -53,37 +54,56 @@ const routes: Routes = [
import('./pages/blog/blog-page.module').then((m) => m.BlogPageModule)
},
{
path: 'de/blog/2021/07/hallo-ghostfolio',
path: 'blog/2021/07/hallo-ghostfolio',
loadChildren: () =>
import(
'./pages/blog/2021/07/hallo-ghostfolio/hallo-ghostfolio-page.module'
).then((m) => m.HalloGhostfolioPageModule)
},
{
path: 'blog/2021/07/hello-ghostfolio',
loadChildren: () =>
import(
'./pages/blog/2021/07/hello-ghostfolio/hello-ghostfolio-page.module'
).then((m) => m.HelloGhostfolioPageModule)
},
{
path: 'blog/2022/01/ghostfolio-first-months-in-open-source',
loadChildren: () =>
import(
'./pages/blog/2022/01/first-months-in-open-source/first-months-in-open-source-page.module'
).then((m) => m.FirstMonthsInOpenSourcePageModule)
},
{
path: 'blog/2022/07/ghostfolio-meets-internet-identity',
loadChildren: () =>
import(
'./pages/blog/2022/07/ghostfolio-meets-internet-identity/ghostfolio-meets-internet-identity-page.module'
).then((m) => m.GhostfolioMeetsInternetIdentityPageModule)
},
{
path: 'blog/2022/07/how-do-i-get-my-finances-in-order',
loadChildren: () =>
import(
'./pages/blog/2022/07/how-do-i-get-my-finances-in-order/how-do-i-get-my-finances-in-order-page.module'
).then((m) => m.HowDoIGetMyFinancesInOrderPageModule)
},
{
path: 'blog/2022/08/500-stars-on-github',
loadChildren: () =>
import(
'./pages/blog/2022/08/500-stars-on-github/500-stars-on-github-page.module'
).then((m) => m.FiveHundredStarsOnGitHubPageModule)
},
{
path: 'demo',
loadChildren: () =>
import('./pages/demo/demo-page.module').then((m) => m.DemoPageModule)
},
{
path: 'en/blog/2021/07/hello-ghostfolio',
path: 'faq',
loadChildren: () =>
import(
'./pages/blog/2021/07/hello-ghostfolio/hello-ghostfolio-page.module'
).then((m) => m.HelloGhostfolioPageModule)
},
{
path: 'en/blog/2022/01/ghostfolio-first-months-in-open-source',
loadChildren: () =>
import(
'./pages/blog/2022/01/first-months-in-open-source/first-months-in-open-source-page.module'
).then((m) => m.FirstMonthsInOpenSourcePageModule)
},
{
path: 'en/blog/2022/07/how-do-i-get-my-finances-in-order',
loadChildren: () =>
import(
'./pages/blog/2022/07/how-do-i-get-my-finances-in-order/how-do-i-get-my-finances-in-order-page.module'
).then((m) => m.HowDoIGetMyFinancesInOrderPageModule)
import('./pages/faq/faq-page.module').then((m) => m.FaqPageModule)
},
{
path: 'features',
@ -222,7 +242,10 @@ const routes: Routes = [
}
)
],
providers: [ModulePreloadService],
providers: [
ModulePreloadService,
{ provide: TitleStrategy, useClass: PageTitleStrategy }
],
exports: [RouterModule]
})
export class AppRoutingModule {}

View File

@ -24,8 +24,8 @@
class="cursor-pointer d-inline-block info-message px-3 py-2"
(click)="onCreateAccount()"
>
<span i18n>You are using the Live Demo.</span>
<span class="a ml-2" i18n>Create Account</span>
<span>You are using the Live Demo.</span>
<span class="a ml-2">Create Account</span>
</div></a
>
<div

View File

@ -21,8 +21,10 @@
<td *matCellDef="let element" class="px-1 text-nowrap" mat-cell>
<ng-container *ngIf="element.type === 'PUBLIC'">
<ion-icon class="mr-1" name="link-outline"></ion-icon>
<a href="{{ baseUrl }}/p/{{ element.id }}" target="_blank"
>{{ baseUrl }}/p/{{ element.id }}</a
<a
href="{{ baseUrl }}/{{ defaultLanguageCode }}/p/{{ element.id }}"
target="_blank"
>{{ baseUrl }}/{{ defaultLanguageCode }}/p/{{ element.id }}</a
>
</ng-container>
</td>
@ -41,8 +43,8 @@
<ion-icon name="ellipsis-vertical"></ion-icon>
</button>
<mat-menu #transactionMenu="matMenu" xPosition="before">
<button i18n mat-menu-item (click)="onDeleteAccess(element.id)">
Revoke
<button mat-menu-item (click)="onDeleteAccess(element.id)">
<ng-container i18n>Revoke</ng-container>
</button>
</mat-menu>
</td>

View File

@ -8,6 +8,7 @@ import {
Output
} from '@angular/core';
import { MatTableDataSource } from '@angular/material/table';
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
import { Access } from '@ghostfolio/common/interfaces';
@Component({
@ -24,6 +25,7 @@ export class AccessTableComponent implements OnChanges, OnInit {
public baseUrl = window.location.origin;
public dataSource: MatTableDataSource<Access>;
public defaultLanguageCode = DEFAULT_LANGUAGE_CODE;
public displayedColumns = [];
public constructor() {}
@ -44,7 +46,7 @@ export class AccessTableComponent implements OnChanges, OnInit {
public onDeleteAccess(aId: string) {
const confirmation = confirm(
'Do you really want to revoke this granted access?'
$localize`Do you really want to revoke this granted access?`
);
if (confirmation) {

View File

@ -21,18 +21,10 @@
<div class="row">
<div class="col-6 mb-3">
<gf-value
label="Account Type"
size="medium"
[value]="accountType"
></gf-value>
<gf-value size="medium" [value]="accountType">Account Type</gf-value>
</div>
<div class="col-6 mb-3">
<gf-value
label="Platform"
size="medium"
[value]="platformName"
></gf-value>
<gf-value size="medium" [value]="platformName">Platform</gf-value>
</div>
</div>
@ -50,7 +42,6 @@
[hasPermissionToOpenDetails]="false"
[locale]="user?.settings?.locale"
[showActions]="false"
[showSymbolColumn]="false"
(export)="onExport()"
></gf-activities-table>
</div>

View File

@ -19,13 +19,8 @@
</ng-container>
<ng-container matColumnDef="currency">
<th
*matHeaderCellDef
class="d-none d-lg-table-cell px-1"
i18n
mat-header-cell
>
Currency
<th *matHeaderCellDef class="d-none d-lg-table-cell px-1" mat-header-cell>
<ng-container i18n>Currency</ng-container>
</th>
<td *matCellDef="let element" class="d-none d-lg-table-cell px-1" mat-cell>
{{ element.currency }}
@ -36,13 +31,8 @@
</ng-container>
<ng-container matColumnDef="platform">
<th
*matHeaderCellDef
class="d-none d-lg-table-cell px-1"
i18n
mat-header-cell
>
Platform
<th *matHeaderCellDef class="d-none d-lg-table-cell px-1" mat-header-cell>
<ng-container i18n>Platform</ng-container>
</th>
<td *matCellDef="let element" class="d-none d-lg-table-cell px-1" mat-cell>
<div class="d-flex">
@ -81,10 +71,9 @@
<th
*matHeaderCellDef
class="d-none d-lg-table-cell px-1 text-right"
i18n
mat-header-cell
>
Cash Balance
<ng-container i18n>Cash Balance</ng-container>
</th>
<td
*matCellDef="let element"
@ -116,10 +105,9 @@
<th
*matHeaderCellDef
class="d-none d-lg-table-cell px-1 text-right"
i18n
mat-header-cell
>
Value
<ng-container i18n>Value</ng-container>
</th>
<td
*matCellDef="let element"
@ -151,10 +139,9 @@
<th
*matHeaderCellDef
class="d-lg-none d-xl-none px-1 text-right"
i18n
mat-header-cell
>
Value
<ng-container i18n>Value</ng-container>
</th>
<td
*matCellDef="let element"

View File

@ -69,7 +69,9 @@ export class AccountsTableComponent implements OnChanges, OnDestroy, OnInit {
}
public onDeleteAccount(aId: string) {
const confirmation = confirm('Do you really want to delete this account?');
const confirmation = confirm(
$localize`Do you really want to delete this account?`
);
if (confirmation) {
this.accountDeleted.emit(aId);

View File

@ -24,7 +24,7 @@
<table class="gf-table w-100">
<thead>
<tr class="mat-header-row">
<th class="mat-header-cell px-1 py-2 text-right" i18n>#</th>
<th class="mat-header-cell px-1 py-2 text-right">#</th>
<th class="mat-header-cell px-1 py-2" i18n>Type</th>
<th class="mat-header-cell px-1 py-2" i18n>Symbol</th>
<th class="mat-header-cell px-1 py-2" i18n>Data Source</th>
@ -105,19 +105,18 @@
<ion-icon name="ellipsis-vertical"></ion-icon>
</button>
<mat-menu #accountMenu="matMenu" xPosition="before">
<button i18n mat-menu-item (click)="onViewData(job.data)">
View Data
<button mat-menu-item (click)="onViewData(job.data)">
<ng-container i18n>View Data</ng-container>
</button>
<button
i18n
mat-menu-item
[disabled]="job.stacktrace?.length <= 0"
(click)="onViewStacktrace(job.stacktrace)"
>
View Stacktrace
<ng-container i18n>View Stacktrace</ng-container>
</button>
<button i18n mat-menu-item (click)="onDeleteJob(job.id)">
Delete Job
<button mat-menu-item (click)="onDeleteJob(job.id)">
<ng-container i18n>Delete Job</ng-container>
</button>
</mat-menu>
</td>

View File

@ -43,8 +43,8 @@
</div>
<div class="justify-content-end" mat-dialog-actions>
<button i18n mat-button (click)="onCancel()">Cancel</button>
<button color="primary" i18n mat-flat-button (click)="onUpdate()">
Save
<button color="primary" mat-flat-button (click)="onUpdate()">
<ng-container i18n>Save</ng-container>
</button>
</div>
</form>

View File

@ -3,17 +3,27 @@ import {
ChangeDetectorRef,
Component,
OnDestroy,
OnInit
OnInit,
ViewChild
} from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { MatSort } from '@angular/material/sort';
import { MatTableDataSource } from '@angular/material/table';
import { ActivatedRoute, Router } from '@angular/router';
import { AdminService } from '@ghostfolio/client/services/admin.service';
import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { getDateFormatString } from '@ghostfolio/common/helper';
import { UniqueAsset, User } from '@ghostfolio/common/interfaces';
import { DATE_FORMAT, getDateFormatString } from '@ghostfolio/common/helper';
import { Filter, UniqueAsset, User } from '@ghostfolio/common/interfaces';
import { AdminMarketDataItem } from '@ghostfolio/common/interfaces/admin-market-data.interface';
import { DataSource, MarketData } from '@prisma/client';
import { AssetSubClass, DataSource } from '@prisma/client';
import { format, parseISO } from 'date-fns';
import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { distinctUntilChanged, switchMap, takeUntil } from 'rxjs/operators';
import { AssetProfileDialog } from './asset-profile-dialog/asset-profile-dialog.component';
import { AssetProfileDialogParams } from './asset-profile-dialog/interfaces/interfaces';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
@ -22,11 +32,46 @@ import { takeUntil } from 'rxjs/operators';
templateUrl: './admin-market-data.html'
})
export class AdminMarketDataComponent implements OnDestroy, OnInit {
@ViewChild(MatSort) sort: MatSort;
public activeFilters: Filter[] = [];
public allFilters: Filter[] = [
AssetSubClass.BOND,
AssetSubClass.COMMODITY,
AssetSubClass.CRYPTOCURRENCY,
AssetSubClass.ETF,
AssetSubClass.MUTUALFUND,
AssetSubClass.PRECIOUS_METAL,
AssetSubClass.PRIVATE_EQUITY,
AssetSubClass.STOCK
].map((id) => {
return {
id,
label: id,
type: 'ASSET_SUB_CLASS'
};
});
public currentDataSource: DataSource;
public currentSymbol: string;
public dataSource: MatTableDataSource<AdminMarketDataItem> =
new MatTableDataSource();
public defaultDateFormat: string;
public marketData: AdminMarketDataItem[] = [];
public marketDataDetails: MarketData[] = [];
public deviceType: string;
public displayedColumns = [
'symbol',
'dataSource',
'assetClass',
'assetSubClass',
'date',
'activityCount',
'marketDataItemCount',
'countriesCount',
'sectorsCount',
'actions'
];
public filters$ = new Subject<Filter[]>();
public isLoading = false;
public placeholder = '';
public user: User;
private unsubscribeSubject = new Subject<void>();
@ -35,8 +80,29 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
private adminService: AdminService,
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
private deviceService: DeviceDetectorService,
private dialog: MatDialog,
private route: ActivatedRoute,
private router: Router,
private userService: UserService
) {
this.route.queryParams
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((params) => {
if (
params['assetProfileDialog'] &&
params['dataSource'] &&
params['dateOfFirstActivity'] &&
params['symbol']
) {
this.openAssetProfileDialog({
dataSource: params['dataSource'],
dateOfFirstActivity: params['dateOfFirstActivity'],
symbol: params['symbol']
});
}
});
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((state) => {
@ -51,7 +117,31 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
}
public ngOnInit() {
this.fetchAdminMarketData();
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
this.filters$
.pipe(
distinctUntilChanged(),
switchMap((filters) => {
this.isLoading = true;
this.activeFilters = filters;
this.placeholder =
this.activeFilters.length <= 0 ? $localize`Filter by...` : '';
return this.dataService.fetchAdminMarketData({
filters: this.activeFilters
});
}),
takeUntil(this.unsubscribeSubject)
)
.subscribe(({ marketData }) => {
this.dataSource = new MatTableDataSource(marketData);
this.dataSource.sort = this.sort;
this.isLoading = false;
this.changeDetectorRef.markForCheck();
});
}
public onDeleteProfileData({ dataSource, symbol }: UniqueAsset) {
@ -75,28 +165,19 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
.subscribe(() => {});
}
public onMarketDataChanged(withRefresh: boolean = false) {
if (withRefresh) {
this.fetchAdminMarketData();
this.fetchAdminMarketDataBySymbol({
dataSource: this.currentDataSource,
symbol: this.currentSymbol
});
}
}
public setCurrentProfile({ dataSource, symbol }: UniqueAsset) {
this.marketDataDetails = [];
if (this.currentSymbol === symbol) {
this.currentDataSource = undefined;
this.currentSymbol = '';
} else {
this.currentDataSource = dataSource;
this.currentSymbol = symbol;
this.fetchAdminMarketDataBySymbol({ dataSource, symbol });
}
public onOpenAssetProfileDialog({
dataSource,
dateOfFirstActivity,
symbol
}: UniqueAsset & { dateOfFirstActivity: string }) {
this.router.navigate([], {
queryParams: {
dataSource,
symbol,
assetProfileDialog: true,
dateOfFirstActivity: format(parseISO(dateOfFirstActivity), DATE_FORMAT)
}
});
}
public ngOnDestroy() {
@ -104,25 +185,40 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
this.unsubscribeSubject.complete();
}
private fetchAdminMarketData() {
this.dataService
.fetchAdminMarketData()
private openAssetProfileDialog({
dataSource,
dateOfFirstActivity,
symbol
}: {
dataSource: DataSource;
dateOfFirstActivity: string;
symbol: string;
}) {
this.userService
.get()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ marketData }) => {
this.marketData = marketData;
.subscribe((user) => {
this.user = user;
this.changeDetectorRef.markForCheck();
});
}
const dialogRef = this.dialog.open(AssetProfileDialog, {
autoFocus: false,
data: <AssetProfileDialogParams>{
dataSource,
dateOfFirstActivity,
symbol,
deviceType: this.deviceType,
locale: this.user?.settings?.locale
},
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
});
private fetchAdminMarketDataBySymbol({ dataSource, symbol }: UniqueAsset) {
this.adminService
.fetchAdminMarketDataBySymbol({ dataSource, symbol })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ marketData }) => {
this.marketDataDetails = marketData;
this.changeDetectorRef.markForCheck();
dialogRef
.afterClosed()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.router.navigate(['.'], { relativeTo: this.route });
});
});
}
}

View File

@ -1,79 +1,147 @@
<div class="container">
<div class="row">
<div class="col">
<table class="gf-table w-100">
<thead>
<tr class="mat-header-row">
<th class="mat-header-cell px-1 py-2" i18n>Symbol</th>
<th class="mat-header-cell px-1 py-2" i18n>Data Source</th>
<th class="mat-header-cell px-1 py-2" i18n>First Activity</th>
<th class="mat-header-cell px-1 py-2" i18n>Activity Count</th>
<th class="mat-header-cell px-1 py-2" i18n>Historical Data</th>
<th class="mat-header-cell px-1 py-2"></th>
</tr>
</thead>
<tbody>
<ng-container *ngFor="let item of marketData; let i = index">
<tr
class="cursor-pointer mat-row"
(click)="setCurrentProfile({ dataSource: item.dataSource, symbol: item.symbol })"
<gf-activities-filter
[allFilters]="allFilters"
[isLoading]="isLoading"
[placeholder]="placeholder"
(valueChanged)="filters$.next($event)"
></gf-activities-filter>
</div>
</div>
<div class="row">
<div class="col">
<table
class="gf-table w-100"
matSort
matSortActive="symbol"
matSortDirection="asc"
mat-table
[dataSource]="dataSource"
>
<ng-container matColumnDef="symbol">
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
<ng-container i18n>Symbol</ng-container>
</th>
<td *matCellDef="let element" class="px-1" mat-cell>
{{ element.symbol }}
</td>
</ng-container>
<ng-container matColumnDef="dataSource">
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
<ng-container i18n>Data Source</ng-container>
</th>
<td *matCellDef="let element" class="px-1" mat-cell>
{{ element.dataSource }}
</td>
</ng-container>
<ng-container matColumnDef="assetClass">
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
<ng-container i18n>Asset Class</ng-container>
</th>
<td *matCellDef="let element" class="px-1" mat-cell>
{{ element.assetClass }}
</td>
</ng-container>
<ng-container matColumnDef="assetSubClass">
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
<ng-container i18n>Asset Sub Class</ng-container>
</th>
<td *matCellDef="let element" class="px-1" mat-cell>
{{ element.assetSubClass }}
</td>
</ng-container>
<ng-container matColumnDef="date">
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
<ng-container i18n>First Activity</ng-container>
</th>
<td *matCellDef="let element" class="px-1" mat-cell>
{{ (element.date | date: defaultDateFormat) ?? '' }}
</td>
</ng-container>
<ng-container matColumnDef="activityCount">
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
<ng-container i18n>Activity Count</ng-container>
</th>
<td *matCellDef="let element" class="px-1 text-right" mat-cell>
{{ element.activityCount }}
</td>
</ng-container>
<ng-container matColumnDef="marketDataItemCount">
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
<ng-container i18n>Historical Data</ng-container>
</th>
<td *matCellDef="let element" class="px-1 text-right" mat-cell>
{{ element.marketDataItemCount }}
</td>
</ng-container>
<ng-container matColumnDef="countriesCount">
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
<ng-container i18n>Countries Count</ng-container>
</th>
<td *matCellDef="let element" class="px-1 text-right" mat-cell>
{{ element.countriesCount }}
</td>
</ng-container>
<ng-container matColumnDef="sectorsCount">
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
<ng-container i18n>Sectors Count</ng-container>
</th>
<td *matCellDef="let element" class="px-1 text-right" mat-cell>
{{ element.sectorsCount }}
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th *matHeaderCellDef class="px-1 text-center" mat-header-cell></th>
<td *matCellDef="let element" class="px-1 text-center" mat-cell>
<button
class="mx-1 no-min-width px-2"
mat-button
[matMenuTriggerFor]="accountMenu"
(click)="$event.stopPropagation()"
>
<td class="mat-cell px-1 py-2">{{ item.symbol }}</td>
<td class="mat-cell px-1 py-2">{{ item.dataSource }}</td>
<td class="mat-cell px-1 py-2">
{{ (item.date | date: defaultDateFormat) ?? '' }}
</td>
<td class="mat-cell px-1 py-2">{{ item.activityCount }}</td>
<td class="mat-cell px-1 py-2">{{ item.marketDataItemCount }}</td>
<td class="mat-cell px-1 py-2">
<button
class="mx-1 no-min-width px-2"
mat-button
[matMenuTriggerFor]="accountMenu"
(click)="$event.stopPropagation()"
>
<ion-icon name="ellipsis-vertical"></ion-icon>
</button>
<mat-menu #accountMenu="matMenu" xPosition="before">
<button
i18n
mat-menu-item
(click)="onGatherSymbol({dataSource: item.dataSource, symbol: item.symbol})"
>
Gather Data
</button>
<button
i18n
mat-menu-item
(click)="onGatherProfileDataBySymbol({dataSource: item.dataSource, symbol: item.symbol})"
>
Gather Profile Data
</button>
<button
i18n
mat-menu-item
[disabled]="item.activityCount !== 0"
(click)="onDeleteProfileData({dataSource: item.dataSource, symbol: item.symbol})"
>
Delete Profile Data
</button>
</mat-menu>
</td>
</tr>
<tr *ngIf="currentSymbol === item.symbol" class="mat-row">
<td class="p-1" colspan="6">
<gf-admin-market-data-detail
[dataSource]="item.dataSource"
[dateOfFirstActivity]="item.date"
[locale]="user?.settings?.locale"
[marketData]="marketDataDetails"
[symbol]="item.symbol"
(marketDataChanged)="onMarketDataChanged($event)"
></gf-admin-market-data-detail>
</td>
</tr>
</ng-container>
</tbody>
<ion-icon name="ellipsis-vertical"></ion-icon>
</button>
<mat-menu #accountMenu="matMenu" xPosition="before">
<button
mat-menu-item
(click)="onGatherSymbol({dataSource: element.dataSource, symbol: element.symbol})"
>
<ng-container i18n>Gather 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
mat-menu-item
[disabled]="element.activityCount !== 0"
(click)="onDeleteProfileData({dataSource: element.dataSource, symbol: element.symbol})"
>
<ng-container i18n>Delete</ng-container>
</button>
</mat-menu>
</td>
</ng-container>
<tr *matHeaderRowDef="displayedColumns" mat-header-row></tr>
<tr
*matRowDef="let row; columns: displayedColumns"
class="cursor-pointer"
mat-row
(click)="onOpenAssetProfileDialog({ dateOfFirstActivity: row.date, dataSource: row.dataSource, symbol: row.symbol })"
></tr>
</table>
</div>
</div>

View File

@ -2,17 +2,23 @@ import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatMenuModule } from '@angular/material/menu';
import { GfAdminMarketDataDetailModule } from '@ghostfolio/client/components/admin-market-data-detail/admin-market-data-detail.module';
import { MatSortModule } from '@angular/material/sort';
import { MatTableModule } from '@angular/material/table';
import { GfActivitiesFilterModule } from '@ghostfolio/ui/activities-filter/activities-filter.module';
import { AdminMarketDataComponent } from './admin-market-data.component';
import { GfAssetProfileDialogModule } from './asset-profile-dialog/assset-profile-dialog.module';
@NgModule({
declarations: [AdminMarketDataComponent],
imports: [
CommonModule,
GfAdminMarketDataDetailModule,
GfActivitiesFilterModule,
GfAssetProfileDialogModule,
MatButtonModule,
MatMenuModule
MatMenuModule,
MatSortModule,
MatTableModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})

View File

@ -0,0 +1,7 @@
:host {
display: block;
.mat-dialog-content {
max-height: unset;
}
}

View File

@ -0,0 +1,73 @@
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
Inject,
OnDestroy,
OnInit
} from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { AdminService } from '@ghostfolio/client/services/admin.service';
import { UniqueAsset } from '@ghostfolio/common/interfaces';
import { MarketData } from '@prisma/client';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { AssetProfileDialogParams } from './interfaces/interfaces';
@Component({
host: { class: 'd-flex flex-column h-100' },
selector: 'gf-asset-profile-dialog',
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: 'asset-profile-dialog.html',
styleUrls: ['./asset-profile-dialog.component.scss']
})
export class AssetProfileDialog implements OnDestroy, OnInit {
public marketDataDetails: MarketData[] = [];
private unsubscribeSubject = new Subject<void>();
public constructor(
private adminService: AdminService,
private changeDetectorRef: ChangeDetectorRef,
public dialogRef: MatDialogRef<AssetProfileDialog>,
@Inject(MAT_DIALOG_DATA) public data: AssetProfileDialogParams
) {}
public ngOnInit(): void {
this.initialize();
}
public onClose(): void {
this.dialogRef.close();
}
public onMarketDataChanged(withRefresh: boolean = false) {
if (withRefresh) {
this.initialize();
}
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
private fetchAdminMarketDataBySymbol({ dataSource, symbol }: UniqueAsset) {
this.adminService
.fetchAdminMarketDataBySymbol({ dataSource, symbol })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ marketData }) => {
this.marketDataDetails = marketData;
this.changeDetectorRef.markForCheck();
});
}
private initialize() {
this.fetchAdminMarketDataBySymbol({
dataSource: this.data.dataSource,
symbol: this.data.symbol
});
}
}

View File

@ -0,0 +1,24 @@
<gf-dialog-header
mat-dialog-title
position="center"
[deviceType]="data.deviceType"
[title]="data.symbol"
(closeButtonClicked)="onClose()"
></gf-dialog-header>
<div class="flex-grow-1" mat-dialog-content>
<gf-admin-market-data-detail
[dataSource]="data.dataSource"
[dateOfFirstActivity]="data.dateOfFirstActivity"
[locale]="data.locale"
[marketData]="marketDataDetails"
[symbol]="data.symbol"
(marketDataChanged)="onMarketDataChanged($event)"
></gf-admin-market-data-detail>
</div>
<gf-dialog-footer
mat-dialog-actions
[deviceType]="data.deviceType"
(closeButtonClicked)="onClose()"
></gf-dialog-footer>

View File

@ -0,0 +1,23 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatDialogModule } from '@angular/material/dialog';
import { GfAdminMarketDataDetailModule } from '@ghostfolio/client/components/admin-market-data-detail/admin-market-data-detail.module';
import { GfDialogFooterModule } from '@ghostfolio/client/components/dialog-footer/dialog-footer.module';
import { GfDialogHeaderModule } from '@ghostfolio/client/components/dialog-header/dialog-header.module';
import { AssetProfileDialog } from './asset-profile-dialog.component';
@NgModule({
declarations: [AssetProfileDialog],
imports: [
CommonModule,
GfAdminMarketDataDetailModule,
GfDialogFooterModule,
GfDialogHeaderModule,
MatButtonModule,
MatDialogModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class GfAssetProfileDialogModule {}

View File

@ -0,0 +1,9 @@
import { DataSource } from '@prisma/client';
export interface AssetProfileDialogParams {
dateOfFirstActivity: string;
dataSource: DataSource;
deviceType: string;
locale: string;
symbol: string;
}

View File

@ -103,7 +103,7 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
}
public onAddCurrency() {
const currency = prompt('Please add a currency:');
const currency = prompt($localize`Please add a currency:`);
if (currency) {
const currencies = uniq([...this.customCurrencies, currency]);
@ -116,7 +116,9 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
}
public onDeleteCoupon(aCouponCode: string) {
const confirmation = confirm('Do you really want to delete this coupon?');
const confirmation = confirm(
$localize`Do you really want to delete this coupon?`
);
if (confirmation === true) {
const coupons = this.coupons.filter((coupon) => {
@ -127,7 +129,9 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
}
public onDeleteCurrency(aCurrency: string) {
const confirmation = confirm('Do you really want to delete this currency?');
const confirmation = confirm(
$localize`Do you really want to delete this currency?`
);
if (confirmation === true) {
const currencies = this.customCurrencies.filter((currency) => {
@ -142,7 +146,9 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
}
public onFlushCache() {
const confirmation = confirm('Do you really want to flush the cache?');
const confirmation = confirm(
$localize`Do you really want to flush the cache?`
);
if (confirmation === true) {
this.cacheService
@ -190,7 +196,7 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
}
public onSetSystemMessage() {
const systemMessage = prompt('Please set your system message:');
const systemMessage = prompt($localize`Please set your system message:`);
if (systemMessage) {
this.putSystemMessage(systemMessage);

View File

@ -8,7 +8,7 @@
<div class="w-50">{{ userCount }}</div>
</div>
<div class="d-flex my-3">
<div class="w-50" i18n>Transaction Count</div>
<div class="w-50" i18n>Activity Count</div>
<div class="w-50">
<ng-container *ngIf="transactionCount">
{{ transactionCount }} ({{ transactionCount / userCount | number
@ -17,7 +17,7 @@
</div>
</div>
<div class="d-flex my-3">
<div class="w-50" i18n>Data Gathering</div>
<div class="w-50" i18n>Data Management</div>
<div class="w-50">
<div class="overflow-hidden">
<div class="mb-2">

View File

@ -55,7 +55,9 @@ export class AdminUsersComponent implements OnDestroy, OnInit {
}
public onDeleteUser(aId: string) {
const confirmation = confirm('Do you really want to delete this user?');
const confirmation = confirm(
$localize`Do you really want to delete this user?`
);
if (confirmation) {
this.dataService

View File

@ -7,17 +7,17 @@
<tr class="mat-header-row">
<th class="mat-header-cell px-1 py-2 text-right">#</th>
<th class="mat-header-cell px-1 py-2" i18n>User</th>
<th class="mat-header-cell px-1 py-2 text-right" i18n>
Registration
<th class="mat-header-cell px-1 py-2 text-right">
<ng-container i18n>Registration</ng-container>
</th>
<th class="mat-header-cell px-1 py-2 text-right" i18n>
Accounts
<th class="mat-header-cell px-1 py-2 text-right">
<ng-container i18n>Accounts</ng-container>
</th>
<th class="mat-header-cell px-1 py-2 text-right" i18n>
Activities
<th class="mat-header-cell px-1 py-2 text-right">
<ng-container i18n>Activities</ng-container>
</th>
<th class="mat-header-cell px-1 py-2 text-right" i18n>
Engagement per Day
<th class="mat-header-cell px-1 py-2 text-right">
<ng-container i18n>Engagement per Day</ng-container>
</th>
<th class="mat-header-cell px-1 py-2" i18n>Last Request</th>
<th class="mat-header-cell px-1 py-2"></th>

View File

@ -285,17 +285,16 @@
mat-flat-button
><ion-icon name="logo-github"></ion-icon
></a>
<button class="mx-1" i18n mat-flat-button (click)="openLoginDialog()">
Sign In
<button class="mx-1" mat-flat-button (click)="openLoginDialog()">
<ng-container i18n>Sign in</ng-container>
</button>
<a
*ngIf="currentRoute !== 'register' && !info?.isReadOnlyMode"
class="d-none d-sm-block"
color="primary"
i18n
mat-flat-button
[routerLink]="['/register']"
>Get Started
><ng-container i18n>Get started</ng-container>
</a>
</ng-container>
</mat-toolbar>

View File

@ -109,7 +109,7 @@ export class HeaderComponent implements OnChanges {
data: {
accessToken: '',
hasPermissionToUseSocialLogin: this.hasPermissionForSocialLogin,
title: 'Sign in'
title: $localize`Sign in`
},
width: '30rem'
});
@ -123,7 +123,7 @@ export class HeaderComponent implements OnChanges {
.loginAnonymous(data?.accessToken)
.pipe(
catchError(() => {
alert('Oops! Incorrect Security Token.');
alert($localize`Oops! Incorrect Security Token.`);
return EMPTY;
}),

View File

@ -2,6 +2,7 @@ import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { ActivatedRoute, Router } from '@angular/router';
import { PositionDetailDialog } from '@ghostfolio/client/components/position/position-detail-dialog/position-detail-dialog.component';
import { ToggleComponent } from '@ghostfolio/client/components/toggle/toggle.component';
import { DataService } from '@ghostfolio/client/services/data.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import {
@ -9,7 +10,6 @@ import {
SettingsStorageService
} from '@ghostfolio/client/services/settings-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { defaultDateRangeOptions } from '@ghostfolio/common/config';
import { Position, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { DateRange } from '@ghostfolio/common/types';
@ -27,7 +27,7 @@ import { PositionDetailDialogParams } from '../position/position-detail-dialog/i
})
export class HomeHoldingsComponent implements OnDestroy, OnInit {
public dateRange: DateRange;
public dateRangeOptions = defaultDateRangeOptions;
public dateRangeOptions = ToggleComponent.DEFAULT_DATE_RANGE_OPTIONS;
public deviceType: string;
public hasImpersonationId: boolean;
public hasPermissionToCreateOrder: boolean;
@ -47,7 +47,7 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
private settingsStorageService: SettingsStorageService,
private userService: UserService
) {
route.queryParams
this.route.queryParams
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((params) => {
if (

View File

@ -21,6 +21,8 @@ import { takeUntil } from 'rxjs/operators';
export class HomeMarketComponent implements OnDestroy, OnInit {
public benchmarks: Benchmark[];
public fearAndGreedIndex: number;
public fearLabel = $localize`Fear`;
public greedLabel = $localize`Greed`;
public hasPermissionToAccessFearAndGreedIndex: boolean;
public historicalData: HistoricalDataItem[];
public info: InfoItem;

View File

@ -9,13 +9,13 @@
class="mb-3"
symbol="Fear & Greed Index"
yMax="100"
yMaxLabel="Greed"
yMin="0"
yMinLabel="Fear"
[historicalDataItems]="historicalData"
[locale]="user?.settings?.locale"
[showXAxis]="true"
[showYAxis]="true"
[yMaxLabel]="greedLabel"
[yMinLabel]="fearLabel"
></gf-line-chart>
<gf-fear-and-greed-index
class="d-flex justify-content-center"
@ -34,6 +34,7 @@
<ngx-skeleton-loader
*ngIf="isLoading"
animation="pulse"
class="px-2 py-3"
[theme]="{
height: '1.5rem',
width: '100%'

View File

@ -1,4 +1,5 @@
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { ToggleComponent } from '@ghostfolio/client/components/toggle/toggle.component';
import { DataService } from '@ghostfolio/client/services/data.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import {
@ -6,7 +7,6 @@ import {
SettingsStorageService
} from '@ghostfolio/client/services/settings-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { defaultDateRangeOptions } from '@ghostfolio/common/config';
import {
PortfolioPerformance,
UniqueAsset,
@ -26,7 +26,7 @@ import { takeUntil } from 'rxjs/operators';
})
export class HomeOverviewComponent implements OnDestroy, OnInit {
public dateRange: DateRange;
public dateRangeOptions = defaultDateRangeOptions;
public dateRangeOptions = ToggleComponent.DEFAULT_DATE_RANGE_OPTIONS;
public deviceType: string;
public errors: UniqueAsset[];
public hasError: boolean;

View File

@ -6,7 +6,7 @@
.chart-container {
aspect-ratio: 16 / 9;
height: auto;
max-width: 67rem;
max-width: 50rem;
// Fallback for aspect-ratio (using padding hack)
@supports not (aspect-ratio: 16 / 9) {

View File

@ -13,7 +13,7 @@ import {
getTooltipPositionerMapTop,
getVerticalHoverLinePlugin
} from '@ghostfolio/common/chart-helper';
import { primaryColorRgb } from '@ghostfolio/common/config';
import { primaryColorRgb, secondaryColorRgb } from '@ghostfolio/common/config';
import {
getBackgroundColor,
getDateFormatString,
@ -34,6 +34,7 @@ import {
TimeScale,
Tooltip
} from 'chart.js';
import annotationPlugin from 'chartjs-plugin-annotation';
import { addDays, isAfter, parseISO, subDays } from 'date-fns';
@Component({
@ -49,6 +50,7 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
@Input() investments: InvestmentItem[];
@Input() isInPercent = false;
@Input() locale: string;
@Input() savingsRate = 0;
@ViewChild('chartCanvas') chartCanvas;
@ -57,6 +59,7 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
public constructor() {
Chart.register(
annotationPlugin,
BarController,
BarElement,
LinearScale,
@ -119,7 +122,7 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
data: this.investments.map((position) => {
return position.investment;
}),
label: 'Investment',
label: $localize`Deposit`,
segment: {
borderColor: (context: unknown) =>
this.isInFuture(
@ -158,6 +161,39 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
interaction: { intersect: false, mode: 'index' },
maintainAspectRatio: true,
plugins: <unknown>{
annotation: {
annotations: {
savingsRate: this.savingsRate
? {
borderColor: `rgba(${secondaryColorRgb.r}, ${secondaryColorRgb.g}, ${secondaryColorRgb.b}, 0.75)`,
borderWidth: 1,
label: {
backgroundColor: `rgb(${secondaryColorRgb.r}, ${secondaryColorRgb.g}, ${secondaryColorRgb.b})`,
borderRadius: 2,
color: 'white',
content: 'Savings Rate',
display: true,
font: { size: '10px', weight: 'normal' },
padding: {
x: 4,
y: 2
},
position: 'start'
},
scaleID: 'y',
type: 'line',
value: this.savingsRate
}
: undefined,
yAxis: {
borderColor: `rgba(${getTextColor()}, 0.1)`,
borderWidth: 1,
scaleID: 'y',
type: 'line',
value: 0
}
}
},
legend: {
display: false
},
@ -172,6 +208,7 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
display: true,
grid: {
borderColor: `rgba(${getTextColor()}, 0.1)`,
borderWidth: this.groupBy ? 0 : 1,
color: `rgba(${getTextColor()}, 0.8)`,
display: false
},

View File

@ -25,14 +25,14 @@
>
<img
class="mr-2"
src="./assets/icons/internet-computer.svg"
src="../assets/icons/internet-computer.svg"
style="height: 0.75rem"
/><span i18n>Sign in with Internet Identity</span>
</button>
<a href="/api/v1/auth/google" mat-stroked-button
<a href="../api/v1/auth/google" mat-stroked-button
><img
class="mr-2"
src="./assets/icons/google.svg"
src="../assets/icons/google.svg"
style="height: 1rem"
/><span i18n>Sign in with Google</span></a
>
@ -49,12 +49,11 @@
<div>
<button
color="primary"
i18n
mat-flat-button
[disabled]="!data.accessToken"
[mat-dialog-close]="data"
>
Sign in
<ng-container i18n>Sign in</ng-container>
</button>
</div>
</div>

View File

@ -45,7 +45,7 @@ export class PortfolioSummaryComponent implements OnChanges, OnInit {
public onEditEmergencyFund() {
const emergencyFundInput = prompt(
'Please enter the amount of your emergency fund:',
$localize`Please enter the amount of your emergency fund:`,
this.summary.emergencyFund.toString()
);
const emergencyFund = parseFloat(emergencyFundInput?.trim());

View File

@ -35,112 +35,124 @@
<div class="row">
<div class="col-6 mb-3">
<gf-value
label="Change"
i18n
size="medium"
[colorizeSign]="true"
[currency]="data.baseCurrency"
[locale]="data.locale"
[value]="netPerformance"
></gf-value>
>Change</gf-value
>
</div>
<div class="col-6 mb-3">
<gf-value
label="Performance"
i18n
size="medium"
[colorizeSign]="true"
[isPercent]="true"
[locale]="data.locale"
[value]="netPerformancePercent"
></gf-value>
>Performance</gf-value
>
</div>
<div class="col-6 mb-3">
<gf-value
label="Average Unit Price"
i18n
size="medium"
[currency]="SymbolProfile?.currency"
[locale]="data.locale"
[value]="averagePrice"
></gf-value>
>Average Unit Price</gf-value
>
</div>
<div class="col-6 mb-3">
<gf-value
label="Market Price"
i18n
size="medium"
[currency]="SymbolProfile?.currency"
[locale]="data.locale"
[value]="marketPrice"
></gf-value>
>Market Price</gf-value
>
</div>
<div class="col-6 mb-3">
<gf-value
label="Minimum Price"
i18n
size="medium"
[currency]="SymbolProfile?.currency"
[locale]="data.locale"
[ngClass]="{ 'text-danger': minPrice?.toFixed(2) === marketPrice?.toFixed(2) && maxPrice?.toFixed(2) !== minPrice?.toFixed(2) }"
[value]="minPrice"
></gf-value>
>Minimum Price</gf-value
>
</div>
<div class="col-6 mb-3">
<gf-value
label="Maximum Price"
i18n
size="medium"
[currency]="SymbolProfile?.currency"
[locale]="data.locale"
[ngClass]="{ 'text-success': maxPrice?.toFixed(2) === marketPrice?.toFixed(2) && maxPrice?.toFixed(2) !== minPrice?.toFixed(2) }"
[value]="maxPrice"
></gf-value>
>Maximum Price</gf-value
>
</div>
<div class="col-6 mb-3">
<gf-value
label="Quantity"
i18n
size="medium"
[locale]="data.locale"
[precision]="quantityPrecision"
[value]="quantity"
></gf-value>
>Quantity</gf-value
>
</div>
<div class="col-6 mb-3">
<gf-value
label="Investment"
i18n
size="medium"
[currency]="data.baseCurrency"
[locale]="data.locale"
[value]="investment"
></gf-value>
>Investment</gf-value
>
</div>
<div class="col-6 mb-3">
<gf-value
label="First Buy Date"
i18n
size="medium"
[isDate]="true"
[locale]="data.locale"
[value]="firstBuyDate"
></gf-value>
>First Buy Date</gf-value
>
</div>
<div class="col-6 mb-3">
<gf-value
i18n
size="medium"
[label]="transactionCount === 1 ? 'Transaction' : 'Transactions'"
[locale]="data.locale"
[value]="transactionCount"
></gf-value>
>Transactions</gf-value
>
</div>
<div class="col-6 mb-3">
<gf-value
label="Asset Class"
i18n
size="medium"
[hidden]="!SymbolProfile?.assetClass"
[value]="SymbolProfile?.assetClass"
></gf-value>
>Asset Class</gf-value
>
</div>
<div class="col-6 mb-3">
<gf-value
label="Asset Sub Class"
i18n
size="medium"
[hidden]="!SymbolProfile?.assetSubClass"
[value]="SymbolProfile?.assetSubClass"
></gf-value>
>Asset Sub Class</gf-value
>
</div>
<ng-container
*ngIf="SymbolProfile?.countries?.length > 0 || SymbolProfile?.sectors?.length > 0"
@ -150,22 +162,24 @@
>
<div *ngIf="SymbolProfile?.sectors?.length === 1" class="col-6 mb-3">
<gf-value
label="Sector"
i18n
size="medium"
[locale]="data.locale"
[value]="SymbolProfile.sectors[0].name"
></gf-value>
>Sector</gf-value
>
</div>
<div
*ngIf="SymbolProfile?.countries?.length === 1"
class="col-6 mb-3"
>
<gf-value
label="Country"
i18n
size="medium"
[locale]="data.locale"
[value]="SymbolProfile.countries[0].name"
></gf-value>
>Country</gf-value
>
</div>
</ng-container>
<ng-template #charts>

View File

@ -18,8 +18,8 @@
</ng-container>
<ng-container matColumnDef="symbol">
<th *matHeaderCellDef class="px-1" i18n mat-header-cell mat-sort-header>
Symbol
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
<ng-container i18n>Symbol</ng-container>
</th>
<td *matCellDef="let element" class="px-1" mat-cell>
<span [title]="element.name">{{ element.symbol | gfSymbol }}</span>
@ -30,11 +30,10 @@
<th
*matHeaderCellDef
class="d-none d-lg-table-cell px-1"
i18n
mat-header-cell
mat-sort-header
>
Name
<ng-container i18n>Name</ng-container>
</th>
<td *matCellDef="let element" class="d-none d-lg-table-cell px-1" mat-cell>
<ng-container *ngIf="element.name !== element.symbol">{{
@ -47,11 +46,10 @@
<th
*matHeaderCellDef
class="d-none d-lg-table-cell justify-content-end px-1"
i18n
mat-header-cell
mat-sort-header
>
Value
<ng-container i18n>Value</ng-container>
</th>
<td class="d-none d-lg-table-cell px-1" mat-cell *matCellDef="let element">
<div class="d-flex justify-content-end">
@ -68,11 +66,10 @@
<th
*matHeaderCellDef
class="justify-content-end px-1"
i18n
mat-header-cell
mat-sort-header
>
Allocation
<ng-container i18n>Allocation</ng-container>
</th>
<td *matCellDef="let element" class="px-1" mat-cell>
<div class="d-flex justify-content-end">
@ -89,10 +86,9 @@
<th
*matHeaderCellDef
class="d-none d-lg-table-cell px-1 text-right"
i18n
mat-header-cell
>
Performance
<ng-container i18n>Performance</ng-container>
</th>
<td *matCellDef="let element" class="d-none d-lg-table-cell px-1" mat-cell>
<div class="d-flex justify-content-end">
@ -137,8 +133,8 @@
*ngIf="dataSource.data.length > pageSize && !isLoading"
class="my-3 text-center"
>
<button i18n mat-stroked-button (click)="onShowAllPositions()">
Show all
<button mat-stroked-button (click)="onShowAllPositions()">
<ng-container i18n>Show all</ng-container>
</button>
</div>

View File

@ -17,6 +17,14 @@ import { ToggleOption } from '@ghostfolio/common/types';
styleUrls: ['./toggle.component.scss']
})
export class ToggleComponent implements OnChanges, OnInit {
public static DEFAULT_DATE_RANGE_OPTIONS: ToggleOption[] = [
{ label: $localize`Today`, value: '1d' },
{ label: $localize`YTD`, value: 'ytd' },
{ label: $localize`1Y`, value: '1y' },
{ label: $localize`5Y`, value: '5y' },
{ label: $localize`Max`, value: 'max' }
];
@Input() defaultValue: string;
@Input() isLoading: boolean;
@Input() options: ToggleOption[];

View File

@ -21,6 +21,7 @@ export class AuthGuard implements CanActivate {
'/de/blog',
'/demo',
'/en/blog',
'/faq',
'/features',
'/markets',
'/p',
@ -71,7 +72,13 @@ export class AuthGuard implements CanActivate {
})
)
.subscribe((user) => {
if (
const userLanguage = user?.settings?.language;
if (userLanguage && document.documentElement.lang !== userLanguage) {
window.location.href = `../${userLanguage}`;
resolve(false);
return;
} else if (
state.url.startsWith('/home') &&
user.settings.viewMode === ViewMode.ZEN
) {

View File

@ -56,14 +56,18 @@ export class HttpResponseInterceptor implements HttpInterceptor {
if (!this.snackBarRef) {
if (this.info.isReadOnlyMode) {
this.snackBarRef = this.snackBar.open(
'This feature is currently unavailable. Please try again later.',
$localize`This feature is currently unavailable.` +
' ' +
$localize`Please try again later.`,
undefined,
{ duration: 6000 }
);
} else {
this.snackBarRef = this.snackBar.open(
'This feature requires a subscription.',
this.hasPermissionForSubscription ? 'Upgrade Plan' : undefined,
$localize`This feature requires a subscription.`,
this.hasPermissionForSubscription
? $localize`Upgrade Plan`
: undefined,
{ duration: 6000 }
);
}
@ -79,8 +83,10 @@ export class HttpResponseInterceptor implements HttpInterceptor {
} else if (error.status === StatusCodes.INTERNAL_SERVER_ERROR) {
if (!this.snackBarRef) {
this.snackBarRef = this.snackBar.open(
'Oops! Something went wrong. Please try again later.',
'Okay',
$localize`Oops! Something went wrong.` +
' ' +
$localize`Please try again later.`,
$localize`Okay`,
{ duration: 6000 }
);

View File

@ -5,7 +5,12 @@ import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import { AboutPageComponent } from './about-page.component';
const routes: Routes = [
{ path: '', component: AboutPageComponent, canActivate: [AuthGuard] }
{
canActivate: [AuthGuard],
component: AboutPageComponent,
path: '',
title: $localize`About`
}
];
@NgModule({

View File

@ -1,7 +1,7 @@
<div class="container">
<div class="mb-5 row">
<div class="col">
<h3 class="d-flex justify-content-center mb-3" i18n>About Ghostfolio</h3>
<h3 class="d-flex justify-content-center mb-3">About Ghostfolio</h3>
<div class="about-container">
<p>
Ghostfolio is a lightweight wealth management application for
@ -21,7 +21,7 @@
<ng-container *ngIf="version">
This instance is running Ghostfolio {{ version }}.
</ng-container>
<ng-container *ngIf="hasPermissionForStatistics" i18n
<ng-container *ngIf="hasPermissionForStatistics"
>Check the system status at
<a href="https://status.ghostfol.io" title="Ghostfolio status"
>status.ghostfol.io</a
@ -102,33 +102,36 @@
<div *ngIf="hasPermissionForStatistics" class="mb-5 row">
<div class="col">
<h3 class="mb-3 text-center" i18n>Ghostfolio in Numbers</h3>
<h3 class="mb-3 text-center">Ghostfolio in Numbers</h3>
<mat-card>
<mat-card-content>
<div class="row">
<div class="col-xs-12 col-md-4 my-2">
<gf-value
label="Active Users"
i18n
size="large"
subLabel="(Last 24 hours)"
[value]="statistics?.activeUsers1d ?? '-'"
></gf-value>
>Active Users</gf-value
>
</div>
<div class="col-xs-12 col-md-4 my-2">
<gf-value
label="New Users"
i18n
size="large"
subLabel="(Last 30 days)"
[value]="statistics?.newUsers30d ?? '-'"
></gf-value>
>New Users</gf-value
>
</div>
<div class="col-xs-12 col-md-4 my-2">
<gf-value
label="Active Users"
i18n
size="large"
subLabel="(Last 30 days)"
[value]="statistics?.activeUsers30d ?? '-'"
></gf-value>
>Active Users</gf-value
>
</div>
<div class="col-xs-12 col-md-4 my-2">
<a
@ -136,10 +139,11 @@
href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"
>
<gf-value
label="Users in Slack community"
i18n
size="large"
[value]="statistics?.slackCommunityUsers ?? '-'"
></gf-value>
>Users in Slack community</gf-value
>
</a>
</div>
<div class="col-xs-12 col-md-4 my-2">
@ -148,10 +152,11 @@
href="https://github.com/ghostfolio/ghostfolio/graphs/contributors"
>
<gf-value
label="Contributors on GitHub"
i18n
size="large"
[value]="statistics?.gitHubContributors ?? '-'"
></gf-value>
>Contributors on GitHub</gf-value
>
</a>
</div>
<div class="col-xs-12 col-md-4 my-2">
@ -160,10 +165,11 @@
href="https://github.com/ghostfolio/ghostfolio/stargazers"
>
<gf-value
label="Stars on GitHub"
i18n
size="large"
[value]="statistics?.gitHubStargazers ?? '-'"
></gf-value>
>Stars on GitHub</gf-value
>
</a>
</div>
</div>
@ -173,34 +179,40 @@
</div>
<div class="row">
<div *ngIf="hasPermissionForSubscription" class="col-md-3 col-xs-12 my-2">
<a
class="py-2 w-100"
color="primary"
mat-stroked-button
[routerLink]="['/faq']"
>FAQ</a
>
</div>
<div
class="col-md-4 col-xs-12 my-2"
class="col-md-3 col-xs-12 my-2"
[ngClass]="{ 'offset-md-4': !hasPermissionForBlog }"
>
<a
class="py-2 w-100"
color="primary"
i18n
mat-stroked-button
[routerLink]="['/about', 'changelog']"
>Changelog & License</a
>
</div>
<div *ngIf="hasPermissionForSubscription" class="col-md-4 col-xs-12 my-2">
<div *ngIf="hasPermissionForSubscription" class="col-md-3 col-xs-12 my-2">
<a
class="py-2 w-100"
color="primary"
i18n
mat-stroked-button
[routerLink]="['/about', 'privacy-policy']"
>Privacy Policy</a
>
</div>
<div *ngIf="hasPermissionForBlog" class="col-md-4 col-xs-12 my-2">
<div *ngIf="hasPermissionForBlog" class="col-md-3 col-xs-12 my-2">
<a
class="py-2 w-100"
color="primary"
i18n
mat-flat-button
[routerLink]="['/blog']"
>Blog</a

View File

@ -5,7 +5,12 @@ import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import { ChangelogPageComponent } from './changelog-page.component';
const routes: Routes = [
{ path: '', component: ChangelogPageComponent, canActivate: [AuthGuard] }
{
canActivate: [AuthGuard],
component: ChangelogPageComponent,
path: '',
title: $localize`Changelog & License`
}
];
@NgModule({

View File

@ -4,7 +4,7 @@
<h3 class="mb-3 text-center" i18n>Changelog</h3>
<mat-card class="changelog">
<mat-card-content>
<markdown [src]="'assets/CHANGELOG.md'"></markdown>
<markdown [src]="'../assets/CHANGELOG.md'"></markdown>
</mat-card-content>
</mat-card>
</div>
@ -15,7 +15,7 @@
<h3 class="mb-3 text-center" i18n>License</h3>
<mat-card>
<mat-card-content>
<markdown [src]="'assets/LICENSE'"></markdown>
<markdown [src]="'../assets/LICENSE'"></markdown>
</mat-card-content>
</mat-card>
</div>

View File

@ -5,7 +5,12 @@ import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import { PrivacyPolicyPageComponent } from './privacy-policy-page.component';
const routes: Routes = [
{ path: '', component: PrivacyPolicyPageComponent, canActivate: [AuthGuard] }
{
canActivate: [AuthGuard],
component: PrivacyPolicyPageComponent,
path: '',
title: $localize`Privacy Policy`
}
];
@NgModule({

View File

@ -2,7 +2,7 @@
<div class="mb-5 row">
<div class="col">
<h3 class="mb-3 text-center" i18n>Privacy Policy</h3>
<markdown [src]="'assets/privacy-policy.md'"></markdown>
<markdown [src]="'../assets/privacy-policy.md'"></markdown>
</div>
</div>
</div>

View File

@ -5,7 +5,12 @@ import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import { AccountPageComponent } from './account-page.component';
const routes: Routes = [
{ path: '', component: AccountPageComponent, canActivate: [AuthGuard] }
{
canActivate: [AuthGuard],
component: AccountPageComponent,
path: '',
title: $localize`My Ghostfolio`
}
];
@NgModule({

View File

@ -53,6 +53,7 @@ export class AccountPageComponent implements OnDestroy, OnInit {
public hasPermissionToDeleteAccess: boolean;
public hasPermissionToUpdateViewMode: boolean;
public hasPermissionToUpdateUserSettings: boolean;
public language = document.documentElement.lang;
public locales = ['de', 'de-CH', 'en-GB', 'en-US'];
public price: number;
public priceId: string;
@ -162,6 +163,14 @@ export class AccountPageComponent implements OnDestroy, OnInit {
this.user = user;
this.changeDetectorRef.markForCheck();
if (aKey === 'language') {
if (aValue) {
window.location.href = `../${aValue}/account`;
} else {
window.location.href = `../`;
}
}
});
});
}
@ -218,7 +227,7 @@ export class AccountPageComponent implements OnDestroy, OnInit {
}
public onRedeemCoupon() {
let couponCode = prompt('Please enter your coupon code:');
let couponCode = prompt($localize`Please enter your coupon code:`);
couponCode = couponCode?.trim();
if (couponCode) {
@ -227,17 +236,21 @@ export class AccountPageComponent implements OnDestroy, OnInit {
.pipe(
takeUntil(this.unsubscribeSubject),
catchError(() => {
this.snackBar.open('😞 Could not redeem coupon code', undefined, {
duration: 3000
});
this.snackBar.open(
'😞 ' + $localize`Could not redeem coupon code`,
undefined,
{
duration: 3000
}
);
return EMPTY;
})
)
.subscribe(() => {
this.snackBarRef = this.snackBar.open(
'✅ Coupon code has been redeemed',
'Reload',
'✅' + $localize`Coupon code has been redeemed`,
$localize`Reload`,
{
duration: 3000
}
@ -283,7 +296,7 @@ export class AccountPageComponent implements OnDestroy, OnInit {
this.registerDevice();
} else {
const confirmation = confirm(
'Do you really want to remove this sign in method?'
$localize`Do you really want to remove this sign in method?`
);
if (confirmation) {

View File

@ -31,11 +31,10 @@
<ng-container *ngIf="hasPermissionForSubscription">
<button
color="primary"
i18n
mat-flat-button
(click)="onCheckout(priceId)"
>
Upgrade
<ng-container i18n>Upgrade</ng-container>
</button>
<div *ngIf="price" class="mt-1">
<ng-container *ngIf="coupon"
@ -91,8 +90,8 @@
<div class="d-flex mt-4 py-1">
<form #changeUserSettingsForm="ngForm" class="w-100">
<div class="d-flex mb-2">
<div class="align-items-center d-flex pt-1 pt-1 w-50" i18n>
Base Currency
<div class="align-items-center d-flex pt-1 pt-1 w-50">
<ng-container i18n>Base Currency</ng-container>
</div>
<div class="pl-1 w-50">
<mat-form-field appearance="outline" class="w-100">
@ -111,11 +110,30 @@
</mat-form-field>
</div>
</div>
<div class="align-items-center d-flex mb-2">
<div class="pr-1 w-50">
<div i18n>Language</div>
<div class="hint-text text-muted" i18n>Beta</div>
</div>
<div class="pl-1 w-50">
<mat-form-field appearance="outline" class="w-100">
<mat-select
name="language"
[value]="language"
(selectionChange)="onChangeUserSetting('language', $event.value)"
>
<mat-option [value]="null"></mat-option>
<mat-option value="de">Deutsch</mat-option>
<mat-option value="en">English</mat-option>
</mat-select>
</mat-form-field>
</div>
</div>
<div class="align-items-center d-flex mb-2">
<div class="pr-1 w-50">
<div i18n>Locale</div>
<div class="hint-text text-muted" i18n>
Date and number format
<div class="hint-text text-muted">
<ng-container i18n>Date and number format</ng-container>
</div>
</div>
<div class="pl-1 w-50">
@ -137,8 +155,8 @@
</div>
</div>
<div class="d-flex">
<div class="align-items-center d-flex pr-1 pt-1 w-50" i18n>
View Mode
<div class="align-items-center d-flex pr-1 pt-1 w-50">
<ng-container i18n>View Mode</ng-container>
</div>
<div class="pl-1 w-50">
<div class="align-items-center d-flex overflow-hidden">

View File

@ -14,12 +14,11 @@
<button i18n mat-button (click)="onCancel()">Cancel</button>
<button
color="primary"
i18n
mat-flat-button
[disabled]="!addAccessForm.form.valid"
[mat-dialog-close]="data"
>
Save
<ng-container i18n>Save</ng-container>
</button>
</div>
</form>

View File

@ -5,7 +5,12 @@ import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import { AccountsPageComponent } from './accounts-page.component';
const routes: Routes = [
{ path: '', component: AccountsPageComponent, canActivate: [AuthGuard] }
{
canActivate: [AuthGuard],
component: AccountsPageComponent,
path: '',
title: $localize`Accounts`
}
];
@NgModule({

View File

@ -66,12 +66,11 @@
<button i18n mat-button (click)="onCancel()">Cancel</button>
<button
color="primary"
i18n
mat-flat-button
[disabled]="!addAccountForm.form.valid"
[mat-dialog-close]="data"
>
Save
<ng-container i18n>Save</ng-container>
</button>
</div>
</form>

View File

@ -10,8 +10,6 @@ import { AdminPageComponent } from './admin-page.component';
const routes: Routes = [
{
path: '',
component: AdminPageComponent,
canActivate: [AuthGuard],
children: [
{ path: '', redirectTo: 'overview', pathMatch: 'full' },
@ -19,7 +17,10 @@ const routes: Routes = [
{ path: 'market-data', component: AdminMarketDataComponent },
{ path: 'overview', component: AdminOverviewComponent },
{ path: 'users', component: AdminUsersComponent }
]
],
component: AdminPageComponent,
path: '',
title: $localize`Admin Control`
}
];

View File

@ -4,8 +4,8 @@ import { RouterModule, Routes } from '@angular/router';
import { AuthPageComponent } from './auth-page.component';
const routes: Routes = [
{ path: '', component: AuthPageComponent },
{ path: ':jwt', component: AuthPageComponent }
{ component: AuthPageComponent, path: '' },
{ component: AuthPageComponent, path: ':jwt' }
];
@NgModule({

View File

@ -28,6 +28,7 @@ export class AuthPageComponent implements OnDestroy, OnInit {
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((params) => {
const jwt = params['jwt'];
this.tokenStorageService.saveToken(
jwt,
this.settingsStorageService.getSetting(STAY_SIGNED_IN) === 'true'

View File

@ -6,9 +6,10 @@ import { HalloGhostfolioPageComponent } from './hallo-ghostfolio-page.component'
const routes: Routes = [
{
path: '',
canActivate: [AuthGuard],
component: HalloGhostfolioPageComponent,
canActivate: [AuthGuard]
path: '',
title: 'Hallo Ghostfolio'
}
];

View File

@ -4,7 +4,7 @@
<article>
<div class="mb-4 text-center">
<h1 class="mb-1">Hallo Ghostfolio 👋</h1>
<div class="text-muted"><small>31.07.2021</small></div>
<div class="text-muted"><small>2021-07-31</small></div>
</div>
<section class="mb-4">
<p>
@ -68,7 +68,7 @@
<p class="my-5 text-center">
<img
alt="Ghostfol.io Screenshot"
src="./assets/images/screenshot.png"
src="../assets/images/screenshot.png"
style="max-width: 100%; width: 20rem"
title="Ghostfol.io Screenshot"
/>

View File

@ -6,9 +6,10 @@ import { HelloGhostfolioPageComponent } from './hello-ghostfolio-page.component'
const routes: Routes = [
{
path: '',
canActivate: [AuthGuard],
component: HelloGhostfolioPageComponent,
canActivate: [AuthGuard]
path: '',
title: 'Hello Ghostfolio'
}
];

View File

@ -4,7 +4,7 @@
<article>
<div class="mb-4 text-center">
<h1 class="mb-1">Hello Ghostfolio 👋</h1>
<div class="text-muted"><small>31.07.2021</small></div>
<div class="text-muted"><small>2021-07-31</small></div>
</div>
<section class="mb-4">
<p>
@ -66,7 +66,7 @@
<p class="my-5 text-center">
<img
alt="Ghostfol.io Screenshot"
src="./assets/images/screenshot.png"
src="../assets/images/screenshot.png"
style="max-width: 100%; width: 20rem"
title="Ghostfol.io Screenshot"
/>

View File

@ -6,9 +6,10 @@ import { FirstMonthsInOpenSourcePageComponent } from './first-months-in-open-sou
const routes: Routes = [
{
path: '',
canActivate: [AuthGuard],
component: FirstMonthsInOpenSourcePageComponent,
canActivate: [AuthGuard]
path: '',
title: 'First months in Open Source'
}
];

View File

@ -7,7 +7,7 @@
👻 Ghostfolio
<span class="text-nowrap">First months in Open Source</span>
</h1>
<div class="text-muted"><small>05.01.2022</small></div>
<div class="text-muted"><small>2022-01-05</small></div>
</div>
<section class="mb-4">
<p>
@ -20,9 +20,7 @@
<h2 class="h4">From 1* to 100 stars on GitHub</h2>
<p>
When I decided to
<a [routerLink]="['/en', 'blog', '2021', '07', 'hello-ghostfolio']"
>publish</a
>
<a href="../en/blog/2021/07/hello-ghostfolio">publish</a>
the project as
<a href="https://github.com/ghostfolio/ghostfolio"
>open source software</a

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 { GhostfolioMeetsInternetIdentityPageComponent } from './ghostfolio-meets-internet-identity-page.component';
const routes: Routes = [
{
canActivate: [AuthGuard],
component: GhostfolioMeetsInternetIdentityPageComponent,
path: '',
title: 'Ghostfolio meets Internet Identity'
}
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class GhostfolioMeetsInternetIdentityRoutingModule {}

View File

@ -0,0 +1,9 @@
import { Component } from '@angular/core';
@Component({
host: { class: 'page' },
selector: 'gf-ghostfolio-meets-internet-identity-page',
styleUrls: ['./ghostfolio-meets-internet-identity-page.scss'],
templateUrl: './ghostfolio-meets-internet-identity-page.html'
})
export class GhostfolioMeetsInternetIdentityPageComponent {}

View File

@ -0,0 +1,183 @@
<div class="blog container">
<div class="row">
<div class="col-md-8 offset-md-2">
<article>
<div class="mb-4 text-center">
<h1 class="mb-1">Ghostfolio meets Internet Identity</h1>
<div class="mb-3 text-muted"><small>2022-07-23</small></div>
<img
alt="Ghostfolio meets Internet Identity Teaser"
class="rounded w-100"
src="../assets/images/blog/ghostfolio-meets-internet-identity.png"
title="Ghostfolio meets Internet Identity"
/>
</div>
<section class="mb-4">
<p>
<a href="https://ghostfol.io">Ghostfolio</a>, the web-based personal
finance management software, supports passwordless authentication as
of now thanks to the integration of
<a href="https://identity.ic0.app">Internet Identity</a>. This
blockchain authentication system enables you to sign in securely and
anonymously to Ghostfolio. With this latest update, Ghostfolio is
ready for Web3.
</p>
<div class="container my-4">
<div class="row">
<div class="col-md-10 offset-md-1">
<blockquote class="blockquote m-0">
<p class="mb-0">Track your portfolio without being tracked</p>
</blockquote>
</div>
</div>
</div>
<p>
To avoid the security issues that arise with password authentication
on the traditional web, the
<a href="https://internetcomputer.org">Internet Computer</a>
blockchain by <a href="https://dfinity.org">dfinity</a> has
introduced a new cryptographic authentication system. It is called
<i>Internet Identity</i> and is as convenient to use as Web2
<a href="https://en.wikipedia.org/wiki/OAuth">OAuth</a> ("Open
Authorization") providers like <i>Google Sign-In</i> or
<i>Facebook Login</i>.
</p>
</section>
<section class="mb-4">
<h2 class="h4">How to use Internet Identity?</h2>
<p>
<i>Internet Identity</i> is based on the
<a href="https://en.wikipedia.org/wiki/WebAuthn"
>WebAuthn protocol</a
>
and uses secure cryptographic authentication. It provides three
options to authenticate yourself:
</p>
<ul>
<li>
The built-in biometric authentication methods of your smartphone
or laptop (fingerprint sensor, <i>Face ID</i>, <i>Touch ID</i>)
</li>
<li>The password or pin to unlock your computer or mobile phone</li>
<li>A security key plugged into the USB port of your computer</li>
</ul>
<p>
When you authenticate with <i>Internet Identity</i>, the service
only gets a dedicated pseudonym rather than sensitive user data like
the email address or phone number. This preserves your anonymity and
prevents you being tracked on the Internet.
</p>
</section>
<section class="mb-4">
<h2 class="h4">The key benefits in a nutshell</h2>
<ul>
<li>
Authenticate yourself securely without the need of an email
address, username, or a password: all you need is your device to
log in.
</li>
<li>
Built-in recovery mechanisms to ensure you are not locked out of
any service that requires the <i>Internet Identity</i>.
</li>
<li>
Log in to various Internet services without being tracked by big
tech companies.
</li>
</ul>
</section>
<section class="mb-4">
<p>
If you would like to provide feedback or get involved in further
development of Ghostfolio, please get in touch by email via
<a href="mailto:hi@ghostfol.io">hi@ghostfol.io</a> or on Twitter
<a href="https://twitter.com/ghostfolio_">@ghostfolio_</a>.
</p>
<p>
I look forward to hearing from you.<br />
Thomas from Ghostfolio
</p>
</section>
<section class="mb-4">
<ul class="list-inline">
<li class="list-inline-item">
<span class="badge badge-light">Anonymity</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">App</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Auth Provider</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Authentication</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Blockchain</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Cryptography</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">dfinity</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Face ID</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Fingerprint</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Ghostfolio</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Internet Computer</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Internet Identity</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">OAuth</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Open Source</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">OSS</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Password</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">passwordless</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Portfolio</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Security</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Software</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Technology</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Touch ID</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Wealth Management</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Web3</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">WebAuthn</span>
</li>
</ul>
</section>
</article>
</div>
</div>
</div>

View File

@ -0,0 +1,17 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { RouterModule } from '@angular/router';
import { GhostfolioMeetsInternetIdentityRoutingModule } from './ghostfolio-meets-internet-identity-page-routing.module';
import { GhostfolioMeetsInternetIdentityPageComponent } from './ghostfolio-meets-internet-identity-page.component';
@NgModule({
declarations: [GhostfolioMeetsInternetIdentityPageComponent],
imports: [
CommonModule,
GhostfolioMeetsInternetIdentityRoutingModule,
RouterModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class GhostfolioMeetsInternetIdentityPageModule {}

View File

@ -6,9 +6,10 @@ import { HowDoIGetMyFinancesInOrderPageComponent } from './how-do-i-get-my-finan
const routes: Routes = [
{
path: '',
canActivate: [AuthGuard],
component: HowDoIGetMyFinancesInOrderPageComponent,
canActivate: [AuthGuard]
path: '',
title: 'How do I get my finances in order?'
}
];

View File

@ -4,7 +4,7 @@
<article>
<div class="mb-4 text-center">
<h1 class="mb-1">How do I get my finances in order?</h1>
<div class="text-muted"><small>14.07.2022</small></div>
<div class="text-muted"><small>2022-07-14</small></div>
</div>
<section class="mb-4">
<p>

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 { FiveHundredStarsOnGitHubPageComponent } from './500-stars-on-github-page.component';
const routes: Routes = [
{
canActivate: [AuthGuard],
component: FiveHundredStarsOnGitHubPageComponent,
path: '',
title: '500 Stars on GitHub'
}
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class FiveHundredStarsOnGitHubRoutingModule {}

View File

@ -0,0 +1,9 @@
import { Component } from '@angular/core';
@Component({
host: { class: 'page' },
selector: 'gf-500-stars-on-github-page',
styleUrls: ['./500-stars-on-github-page.scss'],
templateUrl: './500-stars-on-github-page.html'
})
export class FiveHundredStarsOnGitHubPageComponent {}

View File

@ -0,0 +1,195 @@
<div class="blog container">
<div class="row">
<div class="col-md-8 offset-md-2">
<article>
<div class="mb-4 text-center">
<h1 class="mb-1">500 Stars</h1>
<div class="mb-3 text-muted"><small>2022-08-18</small></div>
<img
alt="500 Stars on GitHub Teaser"
class="rounded w-100"
src="../assets/images/blog/500-stars-on-github.jpg"
title="500 Stars on GitHub"
/>
</div>
<section class="mb-4">
<p>
<a href="https://ghostfol.io">Ghostfolio</a>, the web-based personal
finance management software, is celebrating 500 stars on
<a href="https://github.com/ghostfolio/ghostfolio">GitHub</a>. This
is a major milestone for this open source project and a good time
for another
<a href="../en/blog/2022/01/ghostfolio-first-months-in-open-source"
>recap</a
>.
</p>
</section>
<section class="mb-4">
<h2 class="h4">Growing Community</h2>
<p>
The Ghostfolio community is growing on various platforms and has
recently passed 100 members on
<a
href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"
>Slack</a
>
as well as 100 followers on
<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
on any future updates.
</p>
</section>
<section class="mb-4">
<h2 class="h4">Message Queue: Asynchronous Processing</h2>
<p>
Overall
<a href="https://status.ghostfol.io">stability and robustness</a>
has increased significantly since the introduction of a
<a href="https://github.com/OptimalBits/bull">message queue</a>. The
workers of this robust queue system process jobs, namely gathering
historical market data, asynchronously in the background to not
bother the main service.
</p>
</section>
<section class="mb-4">
<h2 class="h4">Ready for Web 3.0</h2>
<p>
The
<a href="../en/blog/2022/07/ghostfolio-meets-internet-identity"
>recent integration of Internet Identity</a
>, a blockchain authentication system, makes Ghostfolio ready for
Web3. This third iteration of the World Wide Web is the vision of a
new and better Internet based on decentralized blockchains to give
power back to the users. <i>Internet Identity</i> created by the
<a href="https://dfinity.org">Dfinity Foundation</a> enables you to
sign in securely and anonymously to Ghostfolio without an email
address, username, or a password. All you need is your device with
built-in biometric authentication.
</p>
</section>
<section class="mb-4">
<h2 class="h4">Break-even Point</h2>
<p>
Despite the complicated
<a [routerLink]="['/markets']">economic situation</a> at this time,
the goal set at the beginning of the year to build a sustainable
business and reach break-even with the SaaS offering (<a
[routerLink]="['/markets']"
>Ghostfolio Premium</a
>) has been achieved. We will continue to leverage the revenue to
further improve the fully managed cloud offering for our paying
customers. A new goal we have set for ourselves is to become
profitable.
</p>
</section>
<section class="mb-4">
<h2 class="h4">Outlook</h2>
<p>
Besides all the positive accomplishments during the last months,
there is still a lot of room for improvement. It would be great to
onboard more contributors who are actively involved in software
engineering to realize the full potential of open source software.
If you are a web developer and interested in personal finance,
please get in touch by email via
<a href="mailto:hi@ghostfol.io">hi@ghostfol.io</a> or on Twitter
<a href="https://twitter.com/ghostfolio_">@ghostfolio_</a>. We are
happy to discuss ideas.
</p>
<p>
We would like to say thank you for all your feedback and support
since the beginning of this project.
</p>
<p>
Off to the next 500 stars!<br />
Thomas from Ghostfolio
</p>
</section>
<section class="mb-4">
<ul class="list-inline">
<li class="list-inline-item">
<span class="badge badge-light">Blockchain</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">BuildInPublic</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Cloud</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Community</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Finance</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Fintech</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Future</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Goal</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Internet Identity</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Investment</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Message Queue</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">OpenSaaS</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Open Source</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">OSS</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Personal Finance</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Planning</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Portfolio</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Portfolio Tracker</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Progress</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">SaaS</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Software</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">User Feedback</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Wealth</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Wealth Management</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Web3</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Web 3.0</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Worker</span>
</li>
</ul>
</section>
</article>
</div>
</div>
</div>

View File

@ -0,0 +1,13 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { RouterModule } from '@angular/router';
import { FiveHundredStarsOnGitHubRoutingModule } from './500-stars-on-github-page-routing.module';
import { FiveHundredStarsOnGitHubPageComponent } from './500-stars-on-github-page.component';
@NgModule({
declarations: [FiveHundredStarsOnGitHubPageComponent],
imports: [CommonModule, FiveHundredStarsOnGitHubRoutingModule, RouterModule],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class FiveHundredStarsOnGitHubPageModule {}

View File

@ -0,0 +1,3 @@
:host {
display: block;
}

View File

@ -5,7 +5,12 @@ import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import { BlogPageComponent } from './blog-page.component';
const routes: Routes = [
{ path: '', component: BlogPageComponent, canActivate: [AuthGuard] }
{
canActivate: [AuthGuard],
component: BlogPageComponent,
path: '',
title: $localize`Blog`
}
];
@NgModule({

View File

@ -8,13 +8,63 @@
<div class="flex-nowrap no-gutters row">
<a
class="d-flex w-100"
[routerLink]="['/en', 'blog', '2022', '07', 'how-do-i-get-my-finances-in-order']"
href="../en/blog/2022/08/500-stars-on-github"
>
<div class="flex-grow-1">
<div class="h6 m-0 text-truncate">500 Stars on GitHub</div>
<div class="d-flex text-muted">2022-08-18</div>
</div>
<div class="align-items-center d-flex">
<ion-icon
class="chevron text-muted"
name="chevron-forward-outline"
size="small"
></ion-icon>
</div>
</a>
</div>
</div>
</mat-card-content>
</mat-card>
<mat-card class="mb-3">
<mat-card-content>
<div class="container p-0">
<div class="flex-nowrap no-gutters row">
<a
class="d-flex w-100"
href="../en/blog/2022/07/ghostfolio-meets-internet-identity"
>
<div class="flex-grow-1">
<div class="h6 m-0 text-truncate">
Ghostfolio meets Internet Identity
</div>
<div class="d-flex text-muted">2022-07-23</div>
</div>
<div class="align-items-center d-flex">
<ion-icon
class="chevron text-muted"
name="chevron-forward-outline"
size="small"
></ion-icon>
</div>
</a>
</div>
</div>
</mat-card-content>
</mat-card>
<mat-card class="mb-3">
<mat-card-content>
<div class="container p-0">
<div class="flex-nowrap no-gutters row">
<a
class="d-flex w-100"
href="../en/blog/2022/07/how-do-i-get-my-finances-in-order"
>
<div class="flex-grow-1">
<div class="h6 m-0 text-truncate">
How do I get my finances in order?
</div>
<div class="d-flex text-muted">14.07.2022</div>
<div class="d-flex text-muted">2022-07-14</div>
</div>
<div class="align-items-center d-flex">
<ion-icon
@ -34,13 +84,13 @@
<div class="flex-nowrap no-gutters row">
<a
class="d-flex w-100"
[routerLink]="['/en', 'blog', '2022', '01', 'ghostfolio-first-months-in-open-source']"
href="'../en/blog/2022/01/ghostfolio-first-months-in-open-source"
>
<div class="flex-grow-1">
<div class="h6 m-0 text-truncate">
First months in Open Source
</div>
<div class="d-flex text-muted">05.01.2022</div>
<div class="d-flex text-muted">2022-01-05</div>
</div>
<div class="align-items-center d-flex">
<ion-icon
@ -60,11 +110,11 @@
<div class="flex-nowrap no-gutters row">
<a
class="d-flex w-100"
[routerLink]="['/en', 'blog', '2021', '07', 'hello-ghostfolio']"
href="../en/blog/2021/07/hello-ghostfolio"
>
<div class="flex-grow-1">
<div class="h6 m-0 text-truncate">Hello Ghostfolio</div>
<div class="d-flex text-muted">31.07.2021</div>
<div class="d-flex text-muted">2021-07-31</div>
</div>
<div class="align-items-center d-flex">
<ion-icon
@ -84,11 +134,11 @@
<div class="flex-nowrap no-gutters row">
<a
class="d-flex w-100"
[routerLink]="['/de', 'blog', '2021', '07', 'hallo-ghostfolio']"
href="../de/blog/2021/07/hallo-ghostfolio"
>
<div class="flex-grow-1">
<div class="h6 m-0 text-truncate">Hallo Ghostfolio</div>
<div class="d-flex text-muted">31.07.2021</div>
<div class="d-flex text-muted">2021-07-31</div>
</div>
<div class="align-items-center d-flex">
<ion-icon

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