Compare commits
15 Commits
Author | SHA1 | Date | |
---|---|---|---|
7d03c373ac | |||
edb66bb166 | |||
54bbc8446b | |||
9933967e42 | |||
5618513d07 | |||
1397cd62a8 | |||
e7fb31d1a6 | |||
51e7b94ad0 | |||
9133ea38f3 | |||
a864c617b9 | |||
442df9d6f8 | |||
2de0e75cb8 | |||
1296f95602 | |||
e98dff877a | |||
964b37af30 |
37
CHANGELOG.md
37
CHANGELOG.md
@ -5,11 +5,44 @@ 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.68.0 - 01.11.2021
|
||||
|
||||
### Changed
|
||||
|
||||
- Prettified the generic scraper symbols in the portfolio proportion chart component
|
||||
- Extended the statistics section on the about page by the active users count (7d)
|
||||
- Extended the statistics section on the about page by the new users count
|
||||
|
||||
## 1.67.0 - 31.10.2021
|
||||
|
||||
### Added
|
||||
|
||||
- Added more details to the public page (currencies, sectors, continents and regions)
|
||||
- Added a `Dockerfile` and documentation to build a _Docker_ image
|
||||
|
||||
## 1.66.0 - 30.10.2021
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the landing page
|
||||
- Ordered the granted accesses by type
|
||||
|
||||
## 1.65.0 - 25.10.2021
|
||||
|
||||
### Added
|
||||
|
||||
- Added the user interface for granting and revoking public access to share the portfolio
|
||||
|
||||
### Changed
|
||||
|
||||
- Moved the data enhancer calls from the data provider (`get()`) to the data gathering service to reduce traffic to 3rd party data providers
|
||||
- Changed the profile data gathering from every 12 hours to once every weekend
|
||||
|
||||
## 1.64.0 - 21.10.2021
|
||||
|
||||
### Added
|
||||
|
||||
- Added support for more cryptocurrency symbols like _Avalanche_, _Polygon_, _SHIBA INU_ etc.
|
||||
- Added support for more cryptocurrency symbols like _Avalanche_, _Polygon_, _Shiba Inu_ etc.
|
||||
|
||||
### Changed
|
||||
|
||||
@ -19,7 +52,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Added
|
||||
|
||||
- Added a public page to share your portfolio
|
||||
- Added a public page to share the portfolio
|
||||
|
||||
### Changed
|
||||
|
||||
|
51
Dockerfile
Normal file
51
Dockerfile
Normal file
@ -0,0 +1,51 @@
|
||||
FROM node:14-alpine as builder
|
||||
|
||||
# Build application and add additional files
|
||||
|
||||
WORKDIR /ghostfolio
|
||||
|
||||
# Only add basic files without the application itself to avoid rebuilding
|
||||
# layers when files (package.json etc.) have not changed
|
||||
COPY ./CHANGELOG.md CHANGELOG.md
|
||||
COPY ./LICENSE LICENSE
|
||||
COPY ./package.json package.json
|
||||
COPY ./yarn.lock yarn.lock
|
||||
COPY ./prisma/schema.prisma prisma/schema.prisma
|
||||
|
||||
RUN yarn
|
||||
|
||||
# See https://github.com/nrwl/nx/issues/6586 for further details
|
||||
COPY ./decorate-angular-cli.js decorate-angular-cli.js
|
||||
RUN node decorate-angular-cli.js
|
||||
|
||||
COPY ./angular.json angular.json
|
||||
COPY ./nx.json nx.json
|
||||
COPY ./replace.build.js replace.build.js
|
||||
COPY ./jest.preset.js jest.preset.js
|
||||
COPY ./jest.config.js jest.config.js
|
||||
COPY ./tsconfig.base.json tsconfig.base.json
|
||||
COPY ./libs libs
|
||||
COPY ./apps apps
|
||||
|
||||
RUN yarn build:all
|
||||
|
||||
# Prepare the dist image with additional node_modules
|
||||
WORKDIR /ghostfolio/dist/apps/api
|
||||
# package.json was generated by the build process, however the original
|
||||
# yarn.lock needs to be used to ensure the same versions
|
||||
COPY ./yarn.lock /ghostfolio/dist/apps/api/yarn.lock
|
||||
|
||||
RUN yarn
|
||||
COPY prisma /ghostfolio/dist/apps/api/prisma
|
||||
|
||||
# Overwrite the generated package.json with the original one to ensure having
|
||||
# all the scripts
|
||||
COPY package.json /ghostfolio/dist/apps/api
|
||||
RUN yarn database:generate-typings
|
||||
|
||||
# Image to run, copy everything needed from builder
|
||||
FROM node:14-alpine
|
||||
COPY --from=builder /ghostfolio/dist/apps /ghostfolio/apps
|
||||
WORKDIR /ghostfolio/apps/api
|
||||
EXPOSE 3333
|
||||
CMD [ "node", "main" ]
|
47
README.md
47
README.md
@ -34,7 +34,7 @@
|
||||
|
||||
Our official **[Ghostfolio Premium](https://ghostfol.io/pricing)** cloud offering is the easiest way to get started. Due to the time it saves, this will be the best option for most people. The revenue is used for covering the hosting costs.
|
||||
|
||||
If you prefer to run Ghostfolio on your own infrastructure, please find the source code and further instructions here on _GitHub_ or use the [setup](https://github.com/psychowood/ghostfolio-docker) by [psychowood](https://github.com/psychowood).
|
||||
If you prefer to run Ghostfolio on your own infrastructure, please find further instructions in the section [Run with Docker](#run-with-docker).
|
||||
|
||||
## Why Ghostfolio?
|
||||
|
||||
@ -81,13 +81,44 @@ The backend is based on [NestJS](https://nestjs.com) using [PostgreSQL](https://
|
||||
|
||||
The frontend is built with [Angular](https://angular.io) and uses [Angular Material](https://material.angular.io) with utility classes from [Bootstrap](https://getbootstrap.com).
|
||||
|
||||
## Getting Started
|
||||
## Run with Docker
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- [Docker](https://www.docker.com/products/docker-desktop)
|
||||
|
||||
### Setup Docker Image
|
||||
|
||||
Run the following commands to build and start the Docker image:
|
||||
|
||||
```bash
|
||||
docker-compose -f docker/docker-compose-build-local.yml build
|
||||
docker-compose -f docker/docker-compose-build-local.yml up
|
||||
```
|
||||
|
||||
### Setup Database
|
||||
|
||||
Run the following command to setup the database once Ghostfolio is running:
|
||||
|
||||
```bash
|
||||
docker-compose -f docker/docker-compose-build-local.yml exec ghostfolio yarn setup:database
|
||||
```
|
||||
|
||||
### Fetch Historical Data
|
||||
|
||||
Open http://localhost:3333 in your browser and accomplish these steps:
|
||||
|
||||
1. Login as _Admin_ with the following _Security Token_: `ae76872ae8f3419c6d6f64bf51888ecbcc703927a342d815fafe486acdb938da07d0cf44fca211a0be74a423238f535362d390a41e81e633a9ce668a6e31cdf9`
|
||||
1. Go to the _Admin Control Panel_ and click _Gather All Data_ to fetch historical data
|
||||
1. Click _Sign out_ and check out the _Live Demo_
|
||||
|
||||
## Development
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- [Docker](https://www.docker.com/products/docker-desktop)
|
||||
- [Node.js](https://nodejs.org/en/download) (version 14+)
|
||||
- [Yarn](https://yarnpkg.com/en/docs/install)
|
||||
- [Docker](https://www.docker.com/products/docker-desktop)
|
||||
|
||||
### Setup
|
||||
|
||||
@ -101,18 +132,14 @@ The frontend is built with [Angular](https://angular.io) and uses [Angular Mater
|
||||
1. Go to the _Admin Control Panel_ and click _Gather All Data_ to fetch historical data
|
||||
1. Click _Sign out_ and check out the _Live Demo_
|
||||
|
||||
## Development
|
||||
|
||||
Please make sure you have completed the instructions from [_Setup_](#Setup).
|
||||
|
||||
### Start server
|
||||
### Start Server
|
||||
|
||||
<ol type="a">
|
||||
<li>Debug: Run <code>yarn watch:server</code> and click "Launch Program" in <i>Visual Studio Code</i></li>
|
||||
<li>Debug: Run <code>yarn watch:server</code> and click "Launch Program" in <a href="https://code.visualstudio.com">Visual Studio Code</a></li>
|
||||
<li>Serve: Run <code>yarn start:server</code></li>
|
||||
</ol>
|
||||
|
||||
### Start client
|
||||
### Start Client
|
||||
|
||||
Run `yarn start:client`
|
||||
|
||||
|
@ -35,6 +35,7 @@
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"generatePackageJson": true,
|
||||
"optimization": true,
|
||||
"extractLicenses": true,
|
||||
"inspect": false,
|
||||
@ -99,12 +100,12 @@
|
||||
{
|
||||
"glob": "CHANGELOG.md",
|
||||
"input": "",
|
||||
"output": "./"
|
||||
"output": "./assets"
|
||||
},
|
||||
{
|
||||
"glob": "LICENSE",
|
||||
"input": "",
|
||||
"output": "./"
|
||||
"output": "./assets"
|
||||
},
|
||||
{
|
||||
"glob": "robots.txt",
|
||||
|
@ -1,10 +1,29 @@
|
||||
import { Access } from '@ghostfolio/common/interfaces';
|
||||
import {
|
||||
getPermissions,
|
||||
hasPermission,
|
||||
permissions
|
||||
} from '@ghostfolio/common/permissions';
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import { Controller, Get, Inject, UseGuards } from '@nestjs/common';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpException,
|
||||
Inject,
|
||||
Param,
|
||||
Post,
|
||||
UseGuards
|
||||
} from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { Access as AccessModel } from '@prisma/client';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
|
||||
import { AccessModule } from './access.module';
|
||||
import { AccessService } from './access.service';
|
||||
import { CreateAccessDto } from './create-access.dto';
|
||||
|
||||
@Controller('access')
|
||||
export class AccessController {
|
||||
@ -20,6 +39,7 @@ export class AccessController {
|
||||
include: {
|
||||
GranteeUser: true
|
||||
},
|
||||
orderBy: { granteeUserId: 'asc' },
|
||||
where: { userId: this.request.user.id }
|
||||
});
|
||||
|
||||
@ -39,4 +59,49 @@ export class AccessController {
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@Post()
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async createAccess(
|
||||
@Body() data: CreateAccessDto
|
||||
): Promise<AccessModel> {
|
||||
if (
|
||||
!hasPermission(
|
||||
getPermissions(this.request.user.role),
|
||||
permissions.createAccess
|
||||
)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
return this.accessService.createAccess({
|
||||
User: { connect: { id: this.request.user.id } }
|
||||
});
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async deleteAccess(@Param('id') id: string): Promise<AccessModule> {
|
||||
if (
|
||||
!hasPermission(
|
||||
getPermissions(this.request.user.role),
|
||||
permissions.deleteAccess
|
||||
)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
return this.accessService.deleteAccess({
|
||||
id_userId: {
|
||||
id,
|
||||
userId: this.request.user.id
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { AccessWithGranteeUser } from '@ghostfolio/common/types';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { Access, Prisma } from '@prisma/client';
|
||||
|
||||
@Injectable()
|
||||
export class AccessService {
|
||||
@ -37,4 +37,18 @@ export class AccessService {
|
||||
where
|
||||
});
|
||||
}
|
||||
|
||||
public async createAccess(data: Prisma.AccessCreateInput): Promise<Access> {
|
||||
return this.prismaService.access.create({
|
||||
data
|
||||
});
|
||||
}
|
||||
|
||||
public async deleteAccess(
|
||||
where: Prisma.AccessWhereUniqueInput
|
||||
): Promise<Access> {
|
||||
return this.prismaService.access.delete({
|
||||
where
|
||||
});
|
||||
}
|
||||
}
|
||||
|
1
apps/api/src/app/access/create-access.dto.ts
Normal file
1
apps/api/src/app/access/create-access.dto.ts
Normal file
@ -0,0 +1 @@
|
||||
export class CreateAccessDto {}
|
8
apps/api/src/app/cache/cache.module.ts
vendored
8
apps/api/src/app/cache/cache.module.ts
vendored
@ -1,6 +1,7 @@
|
||||
import { CacheService } from '@ghostfolio/api/app/cache/cache.service';
|
||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
|
||||
@ -10,7 +11,12 @@ import { Module } from '@nestjs/common';
|
||||
import { CacheController } from './cache.controller';
|
||||
|
||||
@Module({
|
||||
imports: [DataProviderModule, ExchangeRateDataModule, RedisCacheModule],
|
||||
imports: [
|
||||
DataGatheringModule,
|
||||
DataProviderModule,
|
||||
ExchangeRateDataModule,
|
||||
RedisCacheModule
|
||||
],
|
||||
controllers: [CacheController],
|
||||
providers: [
|
||||
CacheService,
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
|
||||
@ -11,6 +12,7 @@ import { InfoService } from './info.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
DataGatheringModule,
|
||||
DataProviderModule,
|
||||
ExchangeRateDataModule,
|
||||
JwtModule.register({
|
||||
|
@ -136,6 +136,28 @@ export class InfoService {
|
||||
}
|
||||
}
|
||||
|
||||
private async countNewUsers(aDays: number) {
|
||||
return await this.prismaService.user.count({
|
||||
orderBy: {
|
||||
createdAt: 'desc'
|
||||
},
|
||||
where: {
|
||||
AND: [
|
||||
{
|
||||
NOT: {
|
||||
Analytics: null
|
||||
}
|
||||
},
|
||||
{
|
||||
createdAt: {
|
||||
gt: subDays(new Date(), aDays)
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private getDemoAuthToken() {
|
||||
return this.jwtService.sign({
|
||||
id: InfoService.DEMO_USER_ID
|
||||
@ -155,15 +177,19 @@ export class InfoService {
|
||||
}
|
||||
|
||||
const activeUsers1d = await this.countActiveUsers(1);
|
||||
const activeUsers7d = await this.countActiveUsers(7);
|
||||
const activeUsers30d = await this.countActiveUsers(30);
|
||||
const newUsers30d = await this.countNewUsers(30);
|
||||
const gitHubContributors = await this.countGitHubContributors();
|
||||
const gitHubStargazers = await this.countGitHubStargazers();
|
||||
|
||||
return {
|
||||
activeUsers1d,
|
||||
activeUsers7d,
|
||||
activeUsers30d,
|
||||
gitHubContributors,
|
||||
gitHubStargazers
|
||||
gitHubStargazers,
|
||||
newUsers30d
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -72,7 +72,7 @@ describe('CurrentRateService', () => {
|
||||
let marketDataService: MarketDataService;
|
||||
|
||||
beforeAll(async () => {
|
||||
dataProviderService = new DataProviderService(null, [], [], null);
|
||||
dataProviderService = new DataProviderService(null, [], null);
|
||||
exchangeRateDataService = new ExchangeRateDataService(null, null);
|
||||
marketDataService = new MarketDataService(null);
|
||||
|
||||
|
@ -266,25 +266,30 @@ export class PortfolioController {
|
||||
@Res() res: Response
|
||||
): Promise<PortfolioPublicDetails> {
|
||||
const access = await this.accessService.access({ id: accessId });
|
||||
const user = await this.userService.user({
|
||||
id: access.userId
|
||||
});
|
||||
|
||||
if (!access) {
|
||||
res.status(StatusCodes.NOT_FOUND);
|
||||
return <any>res.json({ accounts: {}, holdings: {} });
|
||||
}
|
||||
|
||||
const { hasErrors, holdings } = await this.portfolioService.getDetails(
|
||||
let hasDetails = true;
|
||||
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||
hasDetails = user.subscription.type === 'Premium';
|
||||
}
|
||||
|
||||
const { holdings } = await this.portfolioService.getDetails(
|
||||
access.userId,
|
||||
access.userId
|
||||
);
|
||||
|
||||
const portfolioPublicDetails: PortfolioPublicDetails = {
|
||||
hasDetails,
|
||||
holdings: {}
|
||||
};
|
||||
|
||||
if (hasErrors || hasNotDefinedValuesInObject(holdings)) {
|
||||
res.status(StatusCodes.ACCEPTED);
|
||||
}
|
||||
|
||||
const totalValue = Object.values(holdings)
|
||||
.filter((holding) => {
|
||||
return holding.assetClass === 'EQUITY';
|
||||
@ -302,9 +307,10 @@ export class PortfolioController {
|
||||
if (portfolioPosition.assetClass === 'EQUITY') {
|
||||
portfolioPublicDetails.holdings[symbol] = {
|
||||
allocationCurrent: portfolioPosition.allocationCurrent,
|
||||
countries: [],
|
||||
countries: hasDetails ? portfolioPosition.countries : [],
|
||||
currency: portfolioPosition.currency,
|
||||
name: portfolioPosition.name,
|
||||
sectors: [],
|
||||
sectors: hasDetails ? portfolioPosition.sectors : [],
|
||||
value: portfolioPosition.value / totalValue
|
||||
};
|
||||
}
|
||||
|
@ -18,7 +18,11 @@ export class CronService {
|
||||
|
||||
@Cron(CronExpression.EVERY_12_HOURS)
|
||||
public async runEveryTwelveHours() {
|
||||
await this.dataGatheringService.gatherProfileData();
|
||||
await this.exchangeRateDataService.loadCurrencies();
|
||||
}
|
||||
|
||||
@Cron(CronExpression.EVERY_WEEKEND)
|
||||
public async runEveryWeekend() {
|
||||
await this.dataGatheringService.gatherProfileData();
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
||||
import { DataEnhancerModule } from '@ghostfolio/api/services/data-provider/data-enhancer/data-enhancer.module';
|
||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||
import { Module } from '@nestjs/common';
|
||||
@ -9,11 +10,12 @@ import { ExchangeRateDataModule } from './exchange-rate-data.module';
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigurationModule,
|
||||
DataEnhancerModule,
|
||||
DataProviderModule,
|
||||
ExchangeRateDataModule,
|
||||
PrismaModule
|
||||
],
|
||||
providers: [DataGatheringService],
|
||||
exports: [DataGatheringService]
|
||||
exports: [DataEnhancerModule, DataGatheringService]
|
||||
})
|
||||
export class DataGatheringModule {}
|
||||
|
@ -3,7 +3,7 @@ import {
|
||||
ghostfolioFearAndGreedIndexSymbol
|
||||
} from '@ghostfolio/common/config';
|
||||
import { DATE_FORMAT, resetHours } from '@ghostfolio/common/helper';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { DataSource } from '@prisma/client';
|
||||
import {
|
||||
differenceInHours,
|
||||
@ -18,6 +18,7 @@ import {
|
||||
import { ConfigurationService } from './configuration.service';
|
||||
import { DataProviderService } from './data-provider/data-provider.service';
|
||||
import { GhostfolioScraperApiService } from './data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
|
||||
import { DataEnhancerInterface } from './data-provider/interfaces/data-enhancer.interface';
|
||||
import { ExchangeRateDataService } from './exchange-rate-data.service';
|
||||
import { IDataGatheringItem } from './interfaces/interfaces';
|
||||
import { PrismaService } from './prisma.service';
|
||||
@ -26,6 +27,8 @@ import { PrismaService } from './prisma.service';
|
||||
export class DataGatheringService {
|
||||
public constructor(
|
||||
private readonly configurationService: ConfigurationService,
|
||||
@Inject('DataEnhancers')
|
||||
private readonly dataEnhancers: DataEnhancerInterface[],
|
||||
private readonly dataProviderService: DataProviderService,
|
||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||
private readonly ghostfolioScraperApi: GhostfolioScraperApiService,
|
||||
@ -130,9 +133,19 @@ export class DataGatheringService {
|
||||
|
||||
const currentData = await this.dataProviderService.get(dataGatheringItems);
|
||||
|
||||
for (const [
|
||||
symbol,
|
||||
{
|
||||
for (const [symbol, response] of Object.entries(currentData)) {
|
||||
for (const dataEnhancer of this.dataEnhancers) {
|
||||
try {
|
||||
currentData[symbol] = await dataEnhancer.enhance({
|
||||
response,
|
||||
symbol
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`Failed to enhance data for symbol ${symbol}`, error);
|
||||
}
|
||||
}
|
||||
|
||||
const {
|
||||
assetClass,
|
||||
assetSubClass,
|
||||
countries,
|
||||
@ -140,8 +153,8 @@ export class DataGatheringService {
|
||||
dataSource,
|
||||
name,
|
||||
sectors
|
||||
}
|
||||
] of Object.entries(currentData)) {
|
||||
} = currentData[symbol];
|
||||
|
||||
try {
|
||||
await this.prismaService.symbolProfile.upsert({
|
||||
create: {
|
||||
|
@ -0,0 +1,15 @@
|
||||
import { TrackinsightDataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/trackinsight/trackinsight.service';
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
@Module({
|
||||
exports: ['DataEnhancers', TrackinsightDataEnhancerService],
|
||||
providers: [
|
||||
{
|
||||
inject: [TrackinsightDataEnhancerService],
|
||||
provide: 'DataEnhancers',
|
||||
useFactory: (trackinsight) => [trackinsight]
|
||||
},
|
||||
TrackinsightDataEnhancerService
|
||||
]
|
||||
})
|
||||
export class DataEnhancerModule {}
|
@ -1,6 +1,5 @@
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||
import { CryptocurrencyModule } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.module';
|
||||
import { TrackinsightDataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/trackinsight/trackinsight.service';
|
||||
import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
|
||||
import { RakutenRapidApiService } from '@ghostfolio/api/services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service';
|
||||
import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service';
|
||||
@ -17,13 +16,7 @@ import { DataProviderService } from './data-provider.service';
|
||||
DataProviderService,
|
||||
GhostfolioScraperApiService,
|
||||
RakutenRapidApiService,
|
||||
TrackinsightDataEnhancerService,
|
||||
YahooFinanceService,
|
||||
{
|
||||
inject: [TrackinsightDataEnhancerService],
|
||||
provide: 'DataEnhancers',
|
||||
useFactory: (trackinsight) => [trackinsight]
|
||||
},
|
||||
{
|
||||
inject: [
|
||||
AlphaVantageService,
|
||||
|
@ -1,6 +1,5 @@
|
||||
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-enhancer.interface';
|
||||
import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
|
||||
import {
|
||||
IDataGatheringItem,
|
||||
@ -19,8 +18,6 @@ import { isEmpty } from 'lodash';
|
||||
export class DataProviderService {
|
||||
public constructor(
|
||||
private readonly configurationService: ConfigurationService,
|
||||
@Inject('DataEnhancers')
|
||||
private readonly dataEnhancers: DataEnhancerInterface[],
|
||||
@Inject('DataProviderInterfaces')
|
||||
private readonly dataProviderInterfaces: DataProviderInterface[],
|
||||
private readonly prismaService: PrismaService
|
||||
@ -42,20 +39,7 @@ export class DataProviderService {
|
||||
|
||||
const promises = [];
|
||||
for (const symbol of Object.keys(response)) {
|
||||
let promise = Promise.resolve(response[symbol]);
|
||||
for (const dataEnhancer of this.dataEnhancers) {
|
||||
promise = promise.then((currentResponse) =>
|
||||
dataEnhancer
|
||||
.enhance({ symbol, response: currentResponse })
|
||||
.catch((error) => {
|
||||
console.error(
|
||||
`Failed to enhance data for symbol ${symbol}`,
|
||||
error
|
||||
);
|
||||
return currentResponse;
|
||||
})
|
||||
);
|
||||
}
|
||||
const promise = Promise.resolve(response[symbol]);
|
||||
promises.push(
|
||||
promise.then((currentResponse) => (response[symbol] = currentResponse))
|
||||
);
|
||||
|
@ -1,6 +1,6 @@
|
||||
<header>
|
||||
<gf-header
|
||||
class="position-fixed px-2 w-100"
|
||||
class="position-fixed w-100"
|
||||
[currentRoute]="currentRoute"
|
||||
[info]="info"
|
||||
[user]="user"
|
||||
|
@ -1,24 +1,52 @@
|
||||
<table class="gf-table w-100" mat-table [dataSource]="dataSource">
|
||||
<ng-container matColumnDef="granteeAlias">
|
||||
<th *matHeaderCellDef class="px-1" i18n mat-header-cell>User</th>
|
||||
<th *matHeaderCellDef class="px-1" i18n mat-header-cell>Grantee</th>
|
||||
<td *matCellDef="let element" class="px-1" mat-cell>
|
||||
{{ element.granteeAlias }}
|
||||
</td></ng-container
|
||||
>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="type">
|
||||
<th *matHeaderCellDef class="px-1" i18n mat-header-cell>Type</th>
|
||||
<td *matCellDef="let element" class="px-1" mat-cell>
|
||||
<ng-container *ngIf="element.type === 'PUBLIC'">
|
||||
<ion-icon class="mr-1" name="link-outline"></ion-icon>
|
||||
{{ baseUrl }}/p/{{ element.id }}
|
||||
</ng-container>
|
||||
<ng-container *ngIf="element.type === 'RESTRICTED_VIEW'">
|
||||
<td *matCellDef="let element" class="px-1 text-nowrap" mat-cell>
|
||||
<ng-container>
|
||||
<ion-icon class="mr-1" name="lock-closed-outline"></ion-icon>
|
||||
Restricted View
|
||||
</ng-container>
|
||||
</td></ng-container
|
||||
>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="details">
|
||||
<th *matHeaderCellDef class="px-1" i18n mat-header-cell>Details</th>
|
||||
<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
|
||||
>
|
||||
</ng-container>
|
||||
</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]="transactionMenu"
|
||||
(click)="$event.stopPropagation()"
|
||||
>
|
||||
<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>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: displayedColumns"></tr>
|
||||
|
@ -2,4 +2,12 @@
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
|
||||
a {
|
||||
color: rgba(var(--palette-primary-500), 1);
|
||||
|
||||
&:hover {
|
||||
color: rgba(var(--palette-primary-300), 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,9 +1,11 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
EventEmitter,
|
||||
Input,
|
||||
OnChanges,
|
||||
OnInit
|
||||
OnInit,
|
||||
Output
|
||||
} from '@angular/core';
|
||||
import { MatTableDataSource } from '@angular/material/table';
|
||||
import { Access } from '@ghostfolio/common/interfaces';
|
||||
@ -16,18 +18,37 @@ import { Access } from '@ghostfolio/common/interfaces';
|
||||
})
|
||||
export class AccessTableComponent implements OnChanges, OnInit {
|
||||
@Input() accesses: Access[];
|
||||
@Input() showActions: boolean;
|
||||
|
||||
@Output() accessDeleted = new EventEmitter<string>();
|
||||
|
||||
public baseUrl = window.location.origin;
|
||||
public dataSource: MatTableDataSource<Access>;
|
||||
public displayedColumns = ['granteeAlias', 'type'];
|
||||
public displayedColumns = [];
|
||||
|
||||
public constructor() {}
|
||||
|
||||
public ngOnInit() {}
|
||||
|
||||
public ngOnChanges() {
|
||||
this.displayedColumns = ['granteeAlias', 'type', 'details'];
|
||||
|
||||
if (this.showActions) {
|
||||
this.displayedColumns.push('actions');
|
||||
}
|
||||
|
||||
if (this.accesses) {
|
||||
this.dataSource = new MatTableDataSource(this.accesses);
|
||||
}
|
||||
}
|
||||
|
||||
public onDeleteAccess(aId: string) {
|
||||
const confirmation = confirm(
|
||||
'Do you really want to revoke this granted access?'
|
||||
);
|
||||
|
||||
if (confirmation) {
|
||||
this.accessDeleted.emit(aId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,7 @@
|
||||
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 { MatTableModule } from '@angular/material/table';
|
||||
|
||||
import { AccessTableComponent } from './access-table.component';
|
||||
@ -7,7 +9,7 @@ import { AccessTableComponent } from './access-table.component';
|
||||
@NgModule({
|
||||
declarations: [AccessTableComponent],
|
||||
exports: [AccessTableComponent],
|
||||
imports: [CommonModule, MatTableModule],
|
||||
imports: [CommonModule, MatButtonModule, MatMenuModule, MatTableModule],
|
||||
providers: [],
|
||||
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||
})
|
||||
|
@ -1,6 +1,10 @@
|
||||
<mat-toolbar class="p-0">
|
||||
<mat-toolbar class="px-2">
|
||||
<ng-container *ngIf="user">
|
||||
<a [routerLink]="['/']" class="no-min-width px-2" mat-button>
|
||||
<a
|
||||
[routerLink]="['/']"
|
||||
class="align-items-center d-flex h-100 mx-2 no-min-width px-2 rounded-0"
|
||||
mat-button
|
||||
>
|
||||
<gf-logo></gf-logo>
|
||||
</a>
|
||||
<span class="spacer"></span>
|
||||
@ -221,12 +225,11 @@
|
||||
</ng-container>
|
||||
<ng-container *ngIf="user === null">
|
||||
<a
|
||||
*ngIf="currentRoute && currentRoute !== 'start'"
|
||||
class="mx-2 no-min-width px-2"
|
||||
class="align-items-center d-flex h-100 mx-2 no-min-width px-2 rounded-0"
|
||||
mat-button
|
||||
[routerLink]="['/']"
|
||||
>
|
||||
<gf-logo></gf-logo>
|
||||
<gf-logo [hideName]="!currentRoute || currentRoute === 'start'"></gf-logo>
|
||||
</a>
|
||||
<span class="spacer"></span>
|
||||
<a
|
||||
|
@ -107,7 +107,7 @@
|
||||
<mat-card>
|
||||
<mat-card-content>
|
||||
<div class="row">
|
||||
<div class="col-xs-12 col-md-3 my-2">
|
||||
<div class="col-xs-12 col-md-4 my-2">
|
||||
<h3 class="mb-0" [hidden]="!statistics?.activeUsers1d">
|
||||
{{ statistics?.activeUsers1d ?? '-' }}
|
||||
</h3>
|
||||
@ -117,7 +117,17 @@
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-xs-12 col-md-3 my-2">
|
||||
<div class="col-xs-12 col-md-4 my-2">
|
||||
<h3 class="mb-0" [hidden]="!statistics?.activeUsers7d">
|
||||
{{ statistics?.activeUsers7d ?? '-' }}
|
||||
</h3>
|
||||
<div class="h6 mb-0">
|
||||
<span i18n>Active Users</span> <small class="text-muted"
|
||||
>(Last 7 days)</small
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-xs-12 col-md-4 my-2">
|
||||
<h3 class="mb-0" [hidden]="!statistics?.activeUsers30d">
|
||||
{{ statistics?.activeUsers30d ?? '-' }}
|
||||
</h3>
|
||||
@ -127,13 +137,23 @@
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-xs-12 col-md-3 my-2">
|
||||
<div class="col-xs-12 col-md-4 my-2">
|
||||
<h3 class="mb-0" [hidden]="!statistics?.newUsers30d">
|
||||
{{ statistics?.newUsers30d ?? '-' }}
|
||||
</h3>
|
||||
<div class="h6 mb-0">
|
||||
<span i18n>New Users</span> <small class="text-muted"
|
||||
>(Last 30 days)</small
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-xs-12 col-md-4 my-2">
|
||||
<h3 class="mb-0" [hidden]="!statistics?.gitHubContributors">
|
||||
{{ statistics?.gitHubContributors ?? '-' }}
|
||||
</h3>
|
||||
<div class="h6 mb-0" i18n>Contributors on GitHub</div>
|
||||
</div>
|
||||
<div class="col-xs-12 col-md-3 my-2">
|
||||
<div class="col-xs-12 col-md-4 my-2">
|
||||
<h3 class="mb-0" [hidden]="!statistics?.gitHubStargazers">
|
||||
{{ statistics?.gitHubStargazers ?? '-' }}
|
||||
</h3>
|
||||
@ -198,7 +218,7 @@
|
||||
<h3 class="mb-3 text-center" i18n>Changelog</h3>
|
||||
<mat-card class="changelog">
|
||||
<mat-card-content>
|
||||
<markdown [src]="'CHANGELOG.md'"></markdown>
|
||||
<markdown [src]="'assets/CHANGELOG.md'"></markdown>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
</div>
|
||||
@ -209,7 +229,7 @@
|
||||
<h3 class="mb-3 text-center" i18n>License</h3>
|
||||
<mat-card>
|
||||
<mat-card-content>
|
||||
<markdown [src]="'LICENSE'"></markdown>
|
||||
<markdown [src]="'assets/LICENSE'"></markdown>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
</div>
|
||||
|
@ -5,20 +5,26 @@ import {
|
||||
OnInit,
|
||||
ViewChild
|
||||
} from '@angular/core';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import {
|
||||
MatSlideToggle,
|
||||
MatSlideToggleChange
|
||||
} from '@angular/material/slide-toggle';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { CreateAccessDto } from '@ghostfolio/api/app/access/create-access.dto';
|
||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||
import { WebAuthnService } from '@ghostfolio/client/services/web-authn.service';
|
||||
import { DEFAULT_DATE_FORMAT, baseCurrency } from '@ghostfolio/common/config';
|
||||
import { Access, User } from '@ghostfolio/common/interfaces';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import { DeviceDetectorService } from 'ngx-device-detector';
|
||||
import { StripeService } from 'ngx-stripe';
|
||||
import { EMPTY, Subject } from 'rxjs';
|
||||
import { catchError, switchMap, takeUntil } from 'rxjs/operators';
|
||||
|
||||
import { CreateOrUpdateAccessDialog } from './create-or-update-access-dialog/create-or-update-access-dialog.component';
|
||||
|
||||
@Component({
|
||||
host: { class: 'mb-5' },
|
||||
selector: 'gf-account-page',
|
||||
@ -35,7 +41,10 @@ export class AccountPageComponent implements OnDestroy, OnInit {
|
||||
public couponId: string;
|
||||
public currencies: string[] = [];
|
||||
public defaultDateFormat = DEFAULT_DATE_FORMAT;
|
||||
public deviceType: string;
|
||||
public hasPermissionForSubscription: boolean;
|
||||
public hasPermissionToCreateAccess: boolean;
|
||||
public hasPermissionToDeleteAccess: boolean;
|
||||
public hasPermissionToUpdateViewMode: boolean;
|
||||
public hasPermissionToUpdateUserSettings: boolean;
|
||||
public price: number;
|
||||
@ -50,6 +59,10 @@ export class AccountPageComponent implements OnDestroy, OnInit {
|
||||
public constructor(
|
||||
private changeDetectorRef: ChangeDetectorRef,
|
||||
private dataService: DataService,
|
||||
private deviceService: DeviceDetectorService,
|
||||
private dialog: MatDialog,
|
||||
private route: ActivatedRoute,
|
||||
private router: Router,
|
||||
private stripeService: StripeService,
|
||||
private userService: UserService,
|
||||
public webAuthnService: WebAuthnService
|
||||
@ -65,6 +78,11 @@ export class AccountPageComponent implements OnDestroy, OnInit {
|
||||
permissions.enableSubscription
|
||||
);
|
||||
|
||||
this.hasPermissionToDeleteAccess = hasPermission(
|
||||
globalPermissions,
|
||||
permissions.deleteAccess
|
||||
);
|
||||
|
||||
this.price = subscriptions?.[0]?.price;
|
||||
this.priceId = subscriptions?.[0]?.priceId;
|
||||
|
||||
@ -74,6 +92,16 @@ export class AccountPageComponent implements OnDestroy, OnInit {
|
||||
if (state?.user) {
|
||||
this.user = state.user;
|
||||
|
||||
this.hasPermissionToCreateAccess = hasPermission(
|
||||
this.user.permissions,
|
||||
permissions.createAccess
|
||||
);
|
||||
|
||||
this.hasPermissionToDeleteAccess = hasPermission(
|
||||
this.user.permissions,
|
||||
permissions.deleteAccess
|
||||
);
|
||||
|
||||
this.hasPermissionToUpdateUserSettings = hasPermission(
|
||||
this.user.permissions,
|
||||
permissions.updateUserSettings
|
||||
@ -87,12 +115,22 @@ export class AccountPageComponent implements OnDestroy, OnInit {
|
||||
this.changeDetectorRef.markForCheck();
|
||||
}
|
||||
});
|
||||
|
||||
this.route.queryParams
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe((params) => {
|
||||
if (params['createDialog']) {
|
||||
this.openCreateAccessDialog();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the controller
|
||||
*/
|
||||
public ngOnInit() {
|
||||
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
|
||||
|
||||
this.update();
|
||||
}
|
||||
|
||||
@ -136,6 +174,17 @@ export class AccountPageComponent implements OnDestroy, OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
public onDeleteAccess(aId: string) {
|
||||
this.dataService
|
||||
.deleteAccess(aId)
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.update();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public onRestrictedViewChange(aEvent: MatSlideToggleChange) {
|
||||
this.dataService
|
||||
.putUserSetting({ isRestrictedView: aEvent.checked })
|
||||
@ -175,6 +224,38 @@ export class AccountPageComponent implements OnDestroy, OnInit {
|
||||
this.unsubscribeSubject.complete();
|
||||
}
|
||||
|
||||
private openCreateAccessDialog(): void {
|
||||
const dialogRef = this.dialog.open(CreateOrUpdateAccessDialog, {
|
||||
data: {
|
||||
access: {
|
||||
type: 'PUBLIC'
|
||||
}
|
||||
},
|
||||
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
|
||||
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
|
||||
});
|
||||
|
||||
dialogRef
|
||||
.afterClosed()
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe((data: any) => {
|
||||
const access: CreateAccessDto = data?.access;
|
||||
|
||||
if (access) {
|
||||
this.dataService
|
||||
.postAccess({})
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.update();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
this.router.navigate(['.'], { relativeTo: this.route });
|
||||
});
|
||||
}
|
||||
|
||||
private deregisterDevice() {
|
||||
this.webAuthnService
|
||||
.deregister()
|
||||
|
@ -132,10 +132,26 @@
|
||||
</mat-card>
|
||||
</div>
|
||||
</div>
|
||||
<div *ngIf="accesses?.length > 0" class="row">
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<h3 class="mb-3 text-center" i18n>Granted Access</h3>
|
||||
<gf-access-table [accesses]="accesses"></gf-access-table>
|
||||
<gf-access-table
|
||||
[accesses]="accesses"
|
||||
[showActions]="hasPermissionToDeleteAccess"
|
||||
(accessDeleted)="onDeleteAccess($event)"
|
||||
></gf-access-table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div *ngIf="hasPermissionToCreateAccess" class="fab-container">
|
||||
<a
|
||||
class="align-items-center d-flex justify-content-center"
|
||||
color="primary"
|
||||
mat-fab
|
||||
[routerLink]="[]"
|
||||
[queryParams]="{ createDialog: true }"
|
||||
>
|
||||
<ion-icon name="add-outline" size="large"></ion-icon>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -12,6 +12,7 @@ import { GfPortfolioAccessTableModule } from '@ghostfolio/client/components/acce
|
||||
|
||||
import { AccountPageRoutingModule } from './account-page-routing.module';
|
||||
import { AccountPageComponent } from './account-page.component';
|
||||
import { GfCreateOrUpdateAccessDialogModule } from './create-or-update-access-dialog/create-or-update-access-dialog.module';
|
||||
|
||||
@NgModule({
|
||||
declarations: [AccountPageComponent],
|
||||
@ -20,6 +21,7 @@ import { AccountPageComponent } from './account-page.component';
|
||||
AccountPageRoutingModule,
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
GfCreateOrUpdateAccessDialogModule,
|
||||
GfPortfolioAccessTableModule,
|
||||
MatButtonModule,
|
||||
MatCardModule,
|
||||
|
@ -2,6 +2,26 @@
|
||||
color: rgb(var(--dark-primary-text));
|
||||
display: block;
|
||||
|
||||
gf-access-table {
|
||||
overflow-x: auto;
|
||||
|
||||
table {
|
||||
min-width: 100%;
|
||||
|
||||
.mat-row,
|
||||
.mat-header-row {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.fab-container {
|
||||
position: fixed;
|
||||
right: 2rem;
|
||||
bottom: 2rem;
|
||||
z-index: 999;
|
||||
}
|
||||
|
||||
.hint-text {
|
||||
font-size: 90%;
|
||||
line-height: 1.2;
|
||||
|
@ -0,0 +1,37 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
Inject,
|
||||
OnDestroy
|
||||
} from '@angular/core';
|
||||
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||
import { Subject } from 'rxjs';
|
||||
|
||||
import { CreateOrUpdateAccessDialogParams } from './interfaces/interfaces';
|
||||
|
||||
@Component({
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
host: { class: 'h-100' },
|
||||
selector: 'gf-create-or-update-access-dialog',
|
||||
styleUrls: ['./create-or-update-access-dialog.scss'],
|
||||
templateUrl: 'create-or-update-access-dialog.html'
|
||||
})
|
||||
export class CreateOrUpdateAccessDialog implements OnDestroy {
|
||||
private unsubscribeSubject = new Subject<void>();
|
||||
|
||||
public constructor(
|
||||
public dialogRef: MatDialogRef<CreateOrUpdateAccessDialog>,
|
||||
@Inject(MAT_DIALOG_DATA) public data: CreateOrUpdateAccessDialogParams
|
||||
) {}
|
||||
|
||||
ngOnInit() {}
|
||||
|
||||
public onCancel(): void {
|
||||
this.dialogRef.close();
|
||||
}
|
||||
|
||||
public ngOnDestroy() {
|
||||
this.unsubscribeSubject.next();
|
||||
this.unsubscribeSubject.complete();
|
||||
}
|
||||
}
|
@ -0,0 +1,25 @@
|
||||
<form #addAccessForm="ngForm" class="d-flex flex-column h-100">
|
||||
<h1 i18n mat-dialog-title>Grant access</h1>
|
||||
<div class="flex-grow-1" mat-dialog-content>
|
||||
<div>
|
||||
<mat-form-field appearance="outline" class="w-100">
|
||||
<mat-label i18n>Type</mat-label>
|
||||
<mat-select name="type" required [(value)]="data.access.type">
|
||||
<mat-option i18n value="PUBLIC">Public</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
<div class="justify-content-end" mat-dialog-actions>
|
||||
<button i18n mat-button (click)="onCancel()">Cancel</button>
|
||||
<button
|
||||
color="primary"
|
||||
i18n
|
||||
mat-flat-button
|
||||
[disabled]="!addAccessForm.form.valid"
|
||||
[mat-dialog-close]="data"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
@ -0,0 +1,25 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { NgModule } from '@angular/core';
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatDialogModule } from '@angular/material/dialog';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
|
||||
import { CreateOrUpdateAccessDialog } from './create-or-update-access-dialog.component';
|
||||
|
||||
@NgModule({
|
||||
declarations: [CreateOrUpdateAccessDialog],
|
||||
exports: [],
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
MatButtonModule,
|
||||
MatDialogModule,
|
||||
MatFormFieldModule,
|
||||
MatSelectModule,
|
||||
ReactiveFormsModule
|
||||
],
|
||||
providers: []
|
||||
})
|
||||
export class GfCreateOrUpdateAccessDialogModule {}
|
@ -0,0 +1,7 @@
|
||||
:host {
|
||||
display: block;
|
||||
|
||||
.mat-dialog-content {
|
||||
max-height: unset;
|
||||
}
|
||||
}
|
@ -0,0 +1,5 @@
|
||||
import { Access } from '@ghostfolio/common/interfaces';
|
||||
|
||||
export interface CreateOrUpdateAccessDialogParams {
|
||||
access: Access;
|
||||
}
|
@ -45,7 +45,7 @@ export class AccountsPageComponent implements OnDestroy, OnInit {
|
||||
private router: Router,
|
||||
private userService: UserService
|
||||
) {
|
||||
this.routeQueryParams = route.queryParams
|
||||
this.route.queryParams
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe((params) => {
|
||||
if (params['createDialog']) {
|
||||
|
@ -12,8 +12,8 @@
|
||||
<mat-form-field appearance="outline" class="w-100">
|
||||
<mat-label i18n>Type</mat-label>
|
||||
<mat-select name="type" required [(value)]="data.account.accountType">
|
||||
<mat-option value="CASH" i18n>Cash</mat-option>
|
||||
<mat-option value="SECURITIES" i18n>Securities</mat-option>
|
||||
<mat-option i18n value="CASH">Cash</mat-option>
|
||||
<mat-option i18n value="SECURITIES">Securities</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
@ -1,8 +1,7 @@
|
||||
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
|
||||
import { Component, OnDestroy, OnInit } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
|
||||
import { LineChartItem } from '@ghostfolio/ui/line-chart/interfaces/line-chart.interface';
|
||||
import { format } from 'date-fns';
|
||||
import { Subject } from 'rxjs';
|
||||
|
||||
@ -15,7 +14,6 @@ import { Subject } from 'rxjs';
|
||||
export class LandingPageComponent implements OnDestroy, OnInit {
|
||||
public currentYear = format(new Date(), 'yyyy');
|
||||
public demoAuthToken: string;
|
||||
public historicalDataItems: LineChartItem[];
|
||||
|
||||
private unsubscribeSubject = new Subject<void>();
|
||||
|
||||
@ -23,7 +21,6 @@ export class LandingPageComponent implements OnDestroy, OnInit {
|
||||
* @constructor
|
||||
*/
|
||||
public constructor(
|
||||
private changeDetectorRef: ChangeDetectorRef,
|
||||
private dataService: DataService,
|
||||
private router: Router,
|
||||
private tokenStorageService: TokenStorageService
|
||||
@ -36,221 +33,6 @@ export class LandingPageComponent implements OnDestroy, OnInit {
|
||||
const { demoAuthToken } = this.dataService.fetchInfo();
|
||||
|
||||
this.demoAuthToken = demoAuthToken;
|
||||
|
||||
this.initializeLineChart();
|
||||
}
|
||||
|
||||
public initializeLineChart() {
|
||||
this.historicalDataItems = [
|
||||
{
|
||||
date: '2017-01-01',
|
||||
value: 2561.60376
|
||||
},
|
||||
{
|
||||
date: '2017-02-01',
|
||||
value: 2261.60376
|
||||
},
|
||||
{
|
||||
date: '2017-03-01',
|
||||
value: 2268.68157074
|
||||
},
|
||||
{
|
||||
date: '2017-04-01',
|
||||
value: 2525.2942
|
||||
},
|
||||
{
|
||||
date: '2017-05-01',
|
||||
value: 2929.3595107399997
|
||||
},
|
||||
{
|
||||
date: '2017-06-01',
|
||||
value: 3088.5172438900004
|
||||
},
|
||||
{
|
||||
date: '2017-07-01',
|
||||
value: 3281.2490946300004
|
||||
},
|
||||
{
|
||||
date: '2017-08-01',
|
||||
value: 4714.57822537
|
||||
},
|
||||
{
|
||||
date: '2017-09-01',
|
||||
value: 5717.262455259565
|
||||
},
|
||||
{
|
||||
date: '2017-10-01',
|
||||
value: 5338.742482334544
|
||||
},
|
||||
{
|
||||
date: '2017-11-01',
|
||||
value: 6361.263771554509
|
||||
},
|
||||
{
|
||||
date: '2017-12-01',
|
||||
value: 8373.260491904595
|
||||
},
|
||||
{
|
||||
date: '2018-01-01',
|
||||
value: 9783.208968191562
|
||||
},
|
||||
{
|
||||
date: '2018-02-01',
|
||||
value: 7841.2667100173
|
||||
},
|
||||
{
|
||||
date: '2018-03-01',
|
||||
value: 8582.133039380678
|
||||
},
|
||||
{
|
||||
date: '2018-04-01',
|
||||
value: 5901.8362986766715
|
||||
},
|
||||
{
|
||||
date: '2018-05-01',
|
||||
value: 7367.392976151925
|
||||
},
|
||||
{
|
||||
date: '2018-06-01',
|
||||
value: 6490.164314049853
|
||||
},
|
||||
{
|
||||
date: '2018-07-01',
|
||||
value: 6365.351621654618
|
||||
},
|
||||
{
|
||||
date: '2018-08-01',
|
||||
value: 6614.532706931272
|
||||
},
|
||||
{
|
||||
date: '2018-09-01',
|
||||
value: 6402.052882414409
|
||||
},
|
||||
{
|
||||
date: '2018-10-01',
|
||||
value: 15270.327917651943
|
||||
},
|
||||
{
|
||||
date: '2018-11-01',
|
||||
value: 13929.833891940816
|
||||
},
|
||||
{
|
||||
date: '2018-12-01',
|
||||
value: 12995.832254431414
|
||||
},
|
||||
{
|
||||
date: '2019-01-01',
|
||||
value: 11792.447013828998
|
||||
},
|
||||
{
|
||||
date: '2019-02-01',
|
||||
value: 11988.224284346446
|
||||
},
|
||||
{
|
||||
date: '2019-03-01',
|
||||
value: 13536.39667099519
|
||||
},
|
||||
{
|
||||
date: '2019-04-01',
|
||||
value: 14301.83740090022
|
||||
},
|
||||
{
|
||||
date: '2019-05-01',
|
||||
value: 14902.994910520581
|
||||
},
|
||||
{
|
||||
date: '2019-06-01',
|
||||
value: 15373.418326284132
|
||||
},
|
||||
{
|
||||
date: '2019-07-01',
|
||||
value: 17545.70705465703
|
||||
},
|
||||
{
|
||||
date: '2019-08-01',
|
||||
value: 17206.28500223782
|
||||
},
|
||||
{
|
||||
date: '2019-09-01',
|
||||
value: 17782.445200108898
|
||||
},
|
||||
{
|
||||
date: '2019-10-01',
|
||||
value: 17050.25875252655
|
||||
},
|
||||
{
|
||||
date: '2019-11-01',
|
||||
value: 18517.053521416237
|
||||
},
|
||||
{
|
||||
date: '2019-12-01',
|
||||
value: 17850.649021651363
|
||||
},
|
||||
{
|
||||
date: '2020-01-01',
|
||||
value: 18817.269786559067
|
||||
},
|
||||
{
|
||||
date: '2020-02-01',
|
||||
value: 22769.842312027653
|
||||
},
|
||||
{
|
||||
date: '2020-03-01',
|
||||
value: 23065.56002316582
|
||||
},
|
||||
{
|
||||
date: '2020-04-01',
|
||||
value: 19738.122641884733
|
||||
},
|
||||
{
|
||||
date: '2020-05-01',
|
||||
value: 25112.281463810643
|
||||
},
|
||||
{
|
||||
date: '2020-06-01',
|
||||
value: 28753.054132147452
|
||||
},
|
||||
{
|
||||
date: '2020-07-01',
|
||||
value: 32207.62827031543
|
||||
},
|
||||
{
|
||||
date: '2020-08-01',
|
||||
value: 37837.88816828611
|
||||
},
|
||||
{
|
||||
date: '2020-09-01',
|
||||
value: 50018.740185519295
|
||||
},
|
||||
{
|
||||
date: '2020-10-01',
|
||||
value: 46593.322295801525
|
||||
},
|
||||
{
|
||||
date: '2020-11-01',
|
||||
value: 44440.18743231742
|
||||
},
|
||||
{
|
||||
date: '2020-12-01',
|
||||
value: 57582.23077536893
|
||||
},
|
||||
{
|
||||
date: '2021-01-01',
|
||||
value: 68823.02446077733
|
||||
},
|
||||
{
|
||||
date: '2021-02-01',
|
||||
value: 64516.42092139593
|
||||
},
|
||||
{
|
||||
date: '2021-03-01',
|
||||
value: 82465.97581106682
|
||||
},
|
||||
{
|
||||
date: '2021-03-18',
|
||||
value: 86666.03082624623
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
public setToken(aToken: string) {
|
||||
|
@ -1,14 +1,13 @@
|
||||
<div class="intro-container mb-5">
|
||||
<div class="intro-inner-container mx-auto">
|
||||
<div class="h-100 intro w-100"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div
|
||||
class="
|
||||
align-items-center
|
||||
d-flex
|
||||
flex-column
|
||||
justify-content-center
|
||||
mb-4
|
||||
w-100
|
||||
"
|
||||
class="align-items-center d-flex flex-column justify-content-center w-100"
|
||||
>
|
||||
<gf-logo size="large"></gf-logo>
|
||||
<p class="lead m-0">Wealth Management Software</p>
|
||||
@ -16,15 +15,7 @@
|
||||
</div>
|
||||
|
||||
<div class="button-container row">
|
||||
<div
|
||||
class="
|
||||
align-items-center
|
||||
col
|
||||
d-flex
|
||||
justify-content-center
|
||||
position-relative
|
||||
"
|
||||
>
|
||||
<div class="align-items-center col d-flex justify-content-center">
|
||||
<div class="py-5 text-center">
|
||||
<a
|
||||
class="d-inline-block"
|
||||
@ -46,15 +37,6 @@
|
||||
<span i18n>Live Demo</span>
|
||||
</button>
|
||||
</div>
|
||||
<gf-line-chart
|
||||
class="position-absolute"
|
||||
symbol="Performance"
|
||||
[historicalDataItems]="historicalDataItems"
|
||||
[showGradient]="true"
|
||||
[showLoader]="false"
|
||||
[showXAxis]="false"
|
||||
[showYAxis]="false"
|
||||
></gf-line-chart>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
@ -2,7 +2,6 @@ import { CommonModule } from '@angular/common';
|
||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { GfLineChartModule } from '@ghostfolio/ui/line-chart/line-chart.module';
|
||||
import { GfLogoModule } from '@ghostfolio/ui/logo';
|
||||
|
||||
import { LandingPageRoutingModule } from './landing-page-routing.module';
|
||||
@ -13,7 +12,6 @@ import { LandingPageComponent } from './landing-page.component';
|
||||
exports: [],
|
||||
imports: [
|
||||
CommonModule,
|
||||
GfLineChartModule,
|
||||
GfLogoModule,
|
||||
LandingPageRoutingModule,
|
||||
MatButtonModule,
|
||||
|
@ -2,17 +2,6 @@
|
||||
display: block;
|
||||
|
||||
.button-container {
|
||||
aspect-ratio: 16 / 9;
|
||||
|
||||
gf-line-chart {
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
opacity: 0.67;
|
||||
right: 0;
|
||||
top: 0;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.mat-stroked-button {
|
||||
background-color: var(--light-background);
|
||||
}
|
||||
@ -23,6 +12,23 @@
|
||||
height: 2.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.intro-container {
|
||||
background-color: #ffffff;
|
||||
margin-top: -5rem;
|
||||
|
||||
.intro-inner-container {
|
||||
aspect-ratio: 16 / 9;
|
||||
max-height: 66vh;
|
||||
|
||||
.intro {
|
||||
background-image: url('/assets/intro.jpg');
|
||||
background-position: top left;
|
||||
background-repeat: no-repeat;
|
||||
background-size: contain;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:host-context(.is-dark-theme) {
|
||||
|
@ -4,6 +4,7 @@ import { DataService } from '@ghostfolio/client/services/data.service';
|
||||
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
|
||||
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||
import { UNKNOWN_KEY } from '@ghostfolio/common/config';
|
||||
import { prettifySymbol } from '@ghostfolio/common/helper';
|
||||
import {
|
||||
PortfolioDetails,
|
||||
PortfolioPosition,
|
||||
@ -246,9 +247,9 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
|
||||
}
|
||||
|
||||
if (position.assetClass === AssetClass.EQUITY) {
|
||||
this.symbols[symbol] = {
|
||||
symbol,
|
||||
this.symbols[prettifySymbol(symbol)] = {
|
||||
name: position.name,
|
||||
symbol: prettifySymbol(symbol),
|
||||
value: aPeriod === 'original' ? position.investment : position.value
|
||||
};
|
||||
}
|
||||
|
@ -159,9 +159,9 @@
|
||||
</mat-card-header>
|
||||
<mat-card-content>
|
||||
<gf-portfolio-proportion-chart
|
||||
[keys]="['name']"
|
||||
[baseCurrency]="user?.settings?.baseCurrency"
|
||||
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView"
|
||||
[keys]="['name']"
|
||||
[locale]="user?.settings?.locale"
|
||||
[maxItems]="10"
|
||||
[positions]="countries"
|
||||
|
@ -2,6 +2,7 @@ import { ChangeDetectorRef, Component, OnInit } from '@angular/core';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||
import { UNKNOWN_KEY } from '@ghostfolio/common/config';
|
||||
import { prettifySymbol } from '@ghostfolio/common/helper';
|
||||
import {
|
||||
PortfolioPosition,
|
||||
PortfolioPublicDetails
|
||||
@ -27,7 +28,7 @@ export class PublicPageComponent implements OnInit {
|
||||
public deviceType: string;
|
||||
public portfolioPublicDetails: PortfolioPublicDetails;
|
||||
public positions: {
|
||||
[symbol: string]: Pick<PortfolioPosition, 'name' | 'value'>;
|
||||
[symbol: string]: Pick<PortfolioPosition, 'currency' | 'name' | 'value'>;
|
||||
};
|
||||
public sectors: {
|
||||
[name: string]: { name: string; value: number };
|
||||
@ -117,6 +118,7 @@ export class PublicPageComponent implements OnInit {
|
||||
|
||||
this.positions[symbol] = {
|
||||
value,
|
||||
currency: position.currency,
|
||||
name: position.name
|
||||
};
|
||||
|
||||
@ -168,9 +170,9 @@ export class PublicPageComponent implements OnInit {
|
||||
this.portfolioPublicDetails.holdings[symbol].value;
|
||||
}
|
||||
|
||||
this.symbols[symbol] = {
|
||||
symbol,
|
||||
this.symbols[prettifySymbol(symbol)] = {
|
||||
name: position.name,
|
||||
symbol: prettifySymbol(symbol),
|
||||
value: position.value
|
||||
};
|
||||
}
|
||||
|
@ -1,12 +1,17 @@
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<h3 class="d-flex justify-content-center mb-3" i18n>Portfolio</h3>
|
||||
<h3 class="h4 mb-3 text-center" i18n>
|
||||
Hello, someone has shared a <strong>Portfolio</strong> with you!
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div class="proportion-charts row">
|
||||
<div class="col-md-12 allocations-by-symbol">
|
||||
<mat-card class="mb-3">
|
||||
<mat-card-header class="overflow-hidden w-100">
|
||||
<mat-card-title class="text-truncate" i18n>Symbols</mat-card-title>
|
||||
</mat-card-header>
|
||||
<mat-card-content>
|
||||
<gf-portfolio-proportion-chart
|
||||
class="mx-auto"
|
||||
@ -18,6 +23,65 @@
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
</div>
|
||||
<div *ngIf="portfolioPublicDetails?.hasDetails" class="col-md-4">
|
||||
<mat-card class="mb-3">
|
||||
<mat-card-header class="overflow-hidden w-100">
|
||||
<mat-card-title class="text-truncate" i18n>Currencies</mat-card-title>
|
||||
</mat-card-header>
|
||||
<mat-card-content>
|
||||
<gf-portfolio-proportion-chart
|
||||
[isInPercent]="true"
|
||||
[keys]="['currency']"
|
||||
[maxItems]="10"
|
||||
[positions]="positions"
|
||||
></gf-portfolio-proportion-chart>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
</div>
|
||||
<div *ngIf="portfolioPublicDetails?.hasDetails" class="col-md-4">
|
||||
<mat-card class="mb-3">
|
||||
<mat-card-header class="overflow-hidden w-100">
|
||||
<mat-card-title class="text-truncate" i18n>Sectors</mat-card-title>
|
||||
</mat-card-header>
|
||||
<mat-card-content>
|
||||
<gf-portfolio-proportion-chart
|
||||
[isInPercent]="true"
|
||||
[keys]="['name']"
|
||||
[maxItems]="10"
|
||||
[positions]="sectors"
|
||||
></gf-portfolio-proportion-chart>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
</div>
|
||||
<div *ngIf="portfolioPublicDetails?.hasDetails" class="col-md-4">
|
||||
<mat-card class="mb-3">
|
||||
<mat-card-header class="overflow-hidden w-100">
|
||||
<mat-card-title class="text-truncate" i18n>Continents</mat-card-title>
|
||||
</mat-card-header>
|
||||
<mat-card-content>
|
||||
<gf-portfolio-proportion-chart
|
||||
[isInPercent]="true"
|
||||
[keys]="['name']"
|
||||
[positions]="continents"
|
||||
></gf-portfolio-proportion-chart>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
</div>
|
||||
</div>
|
||||
<div *ngIf="portfolioPublicDetails?.hasDetails" class="row world-map-chart">
|
||||
<div class="col-lg">
|
||||
<mat-card class="mb-3">
|
||||
<mat-card-header class="overflow-hidden w-100">
|
||||
<mat-card-title class="text-truncate" i18n>Regions</mat-card-title>
|
||||
</mat-card-header>
|
||||
<mat-card-content>
|
||||
<gf-world-map-chart
|
||||
[countries]="countries"
|
||||
[isInPercent]="true"
|
||||
></gf-world-map-chart>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row my-5">
|
||||
<div class="col-md-8 offset-md-2">
|
||||
|
@ -2,6 +2,7 @@ import { CommonModule } from '@angular/common';
|
||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { GfWorldMapChartModule } from '@ghostfolio/client/components/world-map-chart/world-map-chart.module';
|
||||
import { GfPortfolioProportionChartModule } from '@ghostfolio/ui/portfolio-proportion-chart/portfolio-proportion-chart.module';
|
||||
|
||||
import { PublicPageRoutingModule } from './public-page-routing.module';
|
||||
@ -13,6 +14,7 @@ import { PublicPageComponent } from './public-page.component';
|
||||
imports: [
|
||||
CommonModule,
|
||||
GfPortfolioProportionChartModule,
|
||||
GfWorldMapChartModule,
|
||||
MatButtonModule,
|
||||
MatCardModule,
|
||||
PublicPageRoutingModule
|
||||
|
@ -2,8 +2,18 @@
|
||||
color: rgb(var(--dark-primary-text));
|
||||
display: block;
|
||||
|
||||
gf-portfolio-proportion-chart {
|
||||
max-width: 80vh;
|
||||
.allocations-by-symbol {
|
||||
gf-portfolio-proportion-chart {
|
||||
max-width: 80vh;
|
||||
}
|
||||
}
|
||||
|
||||
.world-map-chart {
|
||||
.mat-card {
|
||||
.mat-card-content {
|
||||
aspect-ratio: 16 / 9;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1,11 +1,11 @@
|
||||
import { Pipe, PipeTransform } from '@angular/core';
|
||||
import { ghostfolioScraperApiSymbolPrefix } from '@ghostfolio/common/config';
|
||||
import { prettifySymbol } from '@ghostfolio/common/helper';
|
||||
|
||||
@Pipe({ name: 'gfSymbol' })
|
||||
export class SymbolPipe implements PipeTransform {
|
||||
public constructor() {}
|
||||
|
||||
public transform(aSymbol: string): string {
|
||||
return aSymbol?.replace(ghostfolioScraperApiSymbolPrefix, '');
|
||||
public transform(aSymbol: string) {
|
||||
return prettifySymbol(aSymbol);
|
||||
}
|
||||
}
|
||||
|
@ -1,8 +1,8 @@
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Injectable } from '@angular/core';
|
||||
import { CreateAccessDto } from '@ghostfolio/api/app/access/create-access.dto';
|
||||
import { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto';
|
||||
import { UpdateAccountDto } from '@ghostfolio/api/app/account/update-account.dto';
|
||||
import { ImportDataDto } from '@ghostfolio/api/app/import/import-data.dto';
|
||||
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
|
||||
import { UpdateOrderDto } from '@ghostfolio/api/app/order/update-order.dto';
|
||||
import {
|
||||
@ -69,6 +69,10 @@ export class DataService {
|
||||
return this.http.get<AdminData>('/api/admin');
|
||||
}
|
||||
|
||||
public deleteAccess(aId: string) {
|
||||
return this.http.delete<any>(`/api/access/${aId}`);
|
||||
}
|
||||
|
||||
public deleteAccount(aId: string) {
|
||||
return this.http.delete<any>(`/api/account/${aId}`);
|
||||
}
|
||||
@ -197,6 +201,10 @@ export class DataService {
|
||||
return this.http.get<any>(`/api/auth/anonymous/${accessToken}`);
|
||||
}
|
||||
|
||||
public postAccess(aAccess: CreateAccessDto) {
|
||||
return this.http.post<OrderModel>(`/api/access`, aAccess);
|
||||
}
|
||||
|
||||
public postAccount(aAccount: CreateAccountDto) {
|
||||
return this.http.post<OrderModel>(`/api/account`, aAccount);
|
||||
}
|
||||
|
Binary file not shown.
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 289 KiB |
BIN
apps/client/src/assets/intro.jpg
Normal file
BIN
apps/client/src/assets/intro.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 140 KiB |
24
docker/docker-compose-build-local.yml
Normal file
24
docker/docker-compose-build-local.yml
Normal file
@ -0,0 +1,24 @@
|
||||
version: '3.7'
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:12
|
||||
env_file:
|
||||
- ../.env
|
||||
volumes:
|
||||
- postgres:/var/lib/postgresql/data
|
||||
|
||||
redis:
|
||||
image: 'redis:alpine'
|
||||
|
||||
ghostfolio:
|
||||
build: ../
|
||||
env_file:
|
||||
- ../.env
|
||||
environment:
|
||||
REDIS_HOST: 'redis'
|
||||
DATABASE_URL: postgresql://user:password@postgres:5432/ghostfolio-db?sslmode=prefer
|
||||
ports:
|
||||
- 3333:3333
|
||||
|
||||
volumes:
|
||||
postgres:
|
@ -116,3 +116,7 @@ export const DATE_FORMAT = 'yyyy-MM-dd';
|
||||
export function parseDate(date: string) {
|
||||
return parse(date, DATE_FORMAT, new Date());
|
||||
}
|
||||
|
||||
export function prettifySymbol(aSymbol: string): string {
|
||||
return aSymbol?.replace(ghostfolioScraperApiSymbolPrefix, '');
|
||||
}
|
||||
|
@ -1,10 +1,16 @@
|
||||
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
|
||||
|
||||
export interface PortfolioPublicDetails {
|
||||
hasDetails: boolean;
|
||||
holdings: {
|
||||
[symbol: string]: Pick<
|
||||
PortfolioPosition,
|
||||
'allocationCurrent' | 'countries' | 'name' | 'sectors' | 'value'
|
||||
| 'allocationCurrent'
|
||||
| 'countries'
|
||||
| 'currency'
|
||||
| 'name'
|
||||
| 'sectors'
|
||||
| 'value'
|
||||
>;
|
||||
};
|
||||
}
|
||||
|
@ -1,6 +1,8 @@
|
||||
export interface Statistics {
|
||||
activeUsers1d: number;
|
||||
activeUsers7d: number;
|
||||
activeUsers30d: number;
|
||||
gitHubContributors: number;
|
||||
gitHubStargazers: number;
|
||||
newUsers30d: number;
|
||||
}
|
||||
|
@ -7,9 +7,11 @@ export function isApiTokenAuthorized(aApiToken: string) {
|
||||
export const permissions = {
|
||||
accessAdminControl: 'accessAdminControl',
|
||||
accessFearAndGreedIndex: 'accessFearAndGreedIndex',
|
||||
createAccess: 'createAccess',
|
||||
createAccount: 'createAccount',
|
||||
createOrder: 'createOrder',
|
||||
createUserAccount: 'createUserAccount',
|
||||
deleteAccess: 'deleteAccess',
|
||||
deleteAccount: 'deleteAcccount',
|
||||
deleteAuthDevice: 'deleteAuthDevice',
|
||||
deleteOrder: 'deleteOrder',
|
||||
@ -38,8 +40,10 @@ export function getPermissions(aRole: Role): string[] {
|
||||
case 'ADMIN':
|
||||
return [
|
||||
permissions.accessAdminControl,
|
||||
permissions.createAccess,
|
||||
permissions.createAccount,
|
||||
permissions.createOrder,
|
||||
permissions.deleteAccess,
|
||||
permissions.deleteAccount,
|
||||
permissions.deleteAuthDevice,
|
||||
permissions.deleteOrder,
|
||||
@ -56,8 +60,10 @@ export function getPermissions(aRole: Role): string[] {
|
||||
|
||||
case 'USER':
|
||||
return [
|
||||
permissions.createAccess,
|
||||
permissions.createAccount,
|
||||
permissions.createOrder,
|
||||
permissions.deleteAccess,
|
||||
permissions.deleteAccount,
|
||||
permissions.deleteAuthDevice,
|
||||
permissions.deleteOrder,
|
||||
|
@ -5,7 +5,7 @@
|
||||
}
|
||||
|
||||
.name {
|
||||
font-weight: 500;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -318,9 +318,9 @@ export class PortfolioProportionChartComponent
|
||||
type: 'doughnut'
|
||||
});
|
||||
}
|
||||
|
||||
this.isLoading = false;
|
||||
}
|
||||
|
||||
this.isLoading = false;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ghostfolio",
|
||||
"version": "1.64.0",
|
||||
"version": "1.68.0",
|
||||
"homepage": "https://ghostfol.io",
|
||||
"license": "AGPL-3.0",
|
||||
"scripts": {
|
||||
@ -175,5 +175,8 @@
|
||||
"parser": "typescript",
|
||||
"style": "module"
|
||||
}
|
||||
},
|
||||
"prisma": {
|
||||
"seed": "node prisma/seed.js"
|
||||
}
|
||||
}
|
||||
|
@ -1,10 +1,10 @@
|
||||
import {
|
||||
const {
|
||||
AccountType,
|
||||
DataSource,
|
||||
PrismaClient,
|
||||
Role,
|
||||
Type
|
||||
} from '@prisma/client';
|
||||
} = require('@prisma/client');
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
Reference in New Issue
Block a user