Compare commits
17 Commits
Author | SHA1 | Date | |
---|---|---|---|
71feb531e8 | |||
ec3552d7f6 | |||
41875e70d6 | |||
5fa0540936 | |||
5b69dee246 | |||
19b0fe04a6 | |||
19ea4479ff | |||
0b2f6a312c | |||
f79d60014b | |||
5b7409d08e | |||
6230aa87e2 | |||
8b615d2f56 | |||
4100446cac | |||
ad3e6d637c | |||
aa87262954 | |||
01b6bb5b99 | |||
884b7f4de7 |
28
CHANGELOG.md
28
CHANGELOG.md
@ -5,6 +5,34 @@ All notable changes to this project will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## 2.7.0 - 2023-09-30
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added a new static portfolio analysis rule: Emergency fund setup
|
||||||
|
- Added tabs to the user account page
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Set up the _Inter_ font family
|
||||||
|
- Upgraded `yahoo-finance2` from version `2.7.0` to `2.8.0`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed a link on the features page
|
||||||
|
|
||||||
|
## 2.6.0 - 2023-09-26
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added the management of tags in the admin control panel
|
||||||
|
- Added a blog post: _Hacktoberfest 2023_
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Upgraded `prettier` from version `3.0.2` to `3.0.3`
|
||||||
|
- Upgraded `yahoo-finance2` from version `2.5.0` to `2.7.0`
|
||||||
|
|
||||||
## 2.5.0 - 2023-09-23
|
## 2.5.0 - 2023-09-23
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
@ -39,6 +39,7 @@ import { RedisCacheModule } from './redis-cache/redis-cache.module';
|
|||||||
import { SitemapModule } from './sitemap/sitemap.module';
|
import { SitemapModule } from './sitemap/sitemap.module';
|
||||||
import { SubscriptionModule } from './subscription/subscription.module';
|
import { SubscriptionModule } from './subscription/subscription.module';
|
||||||
import { SymbolModule } from './symbol/symbol.module';
|
import { SymbolModule } from './symbol/symbol.module';
|
||||||
|
import { TagModule } from './tag/tag.module';
|
||||||
import { UserModule } from './user/user.module';
|
import { UserModule } from './user/user.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@ -101,6 +102,7 @@ import { UserModule } from './user/user.module';
|
|||||||
SitemapModule,
|
SitemapModule,
|
||||||
SubscriptionModule,
|
SubscriptionModule,
|
||||||
SymbolModule,
|
SymbolModule,
|
||||||
|
TagModule,
|
||||||
TwitterBotModule,
|
TwitterBotModule,
|
||||||
UserModule
|
UserModule
|
||||||
],
|
],
|
||||||
|
@ -47,6 +47,7 @@ export class PlatformController {
|
|||||||
StatusCodes.FORBIDDEN
|
StatusCodes.FORBIDDEN
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.platformService.createPlatform(data);
|
return this.platformService.createPlatform(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -6,6 +6,18 @@ import { Platform, Prisma } from '@prisma/client';
|
|||||||
export class PlatformService {
|
export class PlatformService {
|
||||||
public constructor(private readonly prismaService: PrismaService) {}
|
public constructor(private readonly prismaService: PrismaService) {}
|
||||||
|
|
||||||
|
public async createPlatform(data: Prisma.PlatformCreateInput) {
|
||||||
|
return this.prismaService.platform.create({
|
||||||
|
data
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async deletePlatform(
|
||||||
|
where: Prisma.PlatformWhereUniqueInput
|
||||||
|
): Promise<Platform> {
|
||||||
|
return this.prismaService.platform.delete({ where });
|
||||||
|
}
|
||||||
|
|
||||||
public async getPlatform(
|
public async getPlatform(
|
||||||
platformWhereUniqueInput: Prisma.PlatformWhereUniqueInput
|
platformWhereUniqueInput: Prisma.PlatformWhereUniqueInput
|
||||||
): Promise<Platform> {
|
): Promise<Platform> {
|
||||||
@ -56,12 +68,6 @@ export class PlatformService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async createPlatform(data: Prisma.PlatformCreateInput) {
|
|
||||||
return this.prismaService.platform.create({
|
|
||||||
data
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public async updatePlatform({
|
public async updatePlatform({
|
||||||
data,
|
data,
|
||||||
where
|
where
|
||||||
@ -74,10 +80,4 @@ export class PlatformService {
|
|||||||
where
|
where
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async deletePlatform(
|
|
||||||
where: Prisma.PlatformWhereUniqueInput
|
|
||||||
): Promise<Platform> {
|
|
||||||
return this.prismaService.platform.delete({ where });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -10,6 +10,7 @@ import { AccountClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rule
|
|||||||
import { AccountClusterRiskSingleAccount } from '@ghostfolio/api/models/rules/account-cluster-risk/single-account';
|
import { AccountClusterRiskSingleAccount } from '@ghostfolio/api/models/rules/account-cluster-risk/single-account';
|
||||||
import { CurrencyClusterRiskBaseCurrencyCurrentInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/base-currency-current-investment';
|
import { CurrencyClusterRiskBaseCurrencyCurrentInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/base-currency-current-investment';
|
||||||
import { CurrencyClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/current-investment';
|
import { CurrencyClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/current-investment';
|
||||||
|
import { EmergencyFundSetup } from '@ghostfolio/api/models/rules/emergency-fund/emergency-fund-setup';
|
||||||
import { FeeRatioInitialInvestment } from '@ghostfolio/api/models/rules/fees/fee-ratio-initial-investment';
|
import { FeeRatioInitialInvestment } from '@ghostfolio/api/models/rules/fees/fee-ratio-initial-investment';
|
||||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
@ -50,13 +51,13 @@ import { Inject, Injectable } from '@nestjs/common';
|
|||||||
import { REQUEST } from '@nestjs/core';
|
import { REQUEST } from '@nestjs/core';
|
||||||
import {
|
import {
|
||||||
Account,
|
Account,
|
||||||
|
Type as ActivityType,
|
||||||
AssetClass,
|
AssetClass,
|
||||||
DataSource,
|
DataSource,
|
||||||
Order,
|
Order,
|
||||||
Platform,
|
Platform,
|
||||||
Prisma,
|
Prisma,
|
||||||
Tag,
|
Tag
|
||||||
Type as ActivityType
|
|
||||||
} from '@prisma/client';
|
} from '@prisma/client';
|
||||||
import Big from 'big.js';
|
import Big from 'big.js';
|
||||||
import {
|
import {
|
||||||
@ -1214,12 +1215,6 @@ export class PortfolioService {
|
|||||||
userId
|
userId
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isEmpty(orders)) {
|
|
||||||
return {
|
|
||||||
rules: {}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const portfolioCalculator = new PortfolioCalculator({
|
const portfolioCalculator = new PortfolioCalculator({
|
||||||
currency: userCurrency,
|
currency: userCurrency,
|
||||||
currentRateService: this.currentRateService,
|
currentRateService: this.currentRateService,
|
||||||
@ -1228,7 +1223,9 @@ export class PortfolioService {
|
|||||||
|
|
||||||
portfolioCalculator.setTransactionPoints(transactionPoints);
|
portfolioCalculator.setTransactionPoints(transactionPoints);
|
||||||
|
|
||||||
const portfolioStart = parseDate(transactionPoints[0].date);
|
const portfolioStart = parseDate(
|
||||||
|
transactionPoints[0]?.date ?? format(new Date(), DATE_FORMAT)
|
||||||
|
);
|
||||||
const currentPositions =
|
const currentPositions =
|
||||||
await portfolioCalculator.getCurrentPositions(portfolioStart);
|
await portfolioCalculator.getCurrentPositions(portfolioStart);
|
||||||
|
|
||||||
@ -1249,9 +1246,13 @@ export class PortfolioService {
|
|||||||
userId
|
userId
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const userSettings = <UserSettings>this.request.user.Settings.settings;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
rules: {
|
rules: {
|
||||||
accountClusterRisk: await this.rulesService.evaluate(
|
accountClusterRisk: isEmpty(orders)
|
||||||
|
? undefined
|
||||||
|
: await this.rulesService.evaluate(
|
||||||
[
|
[
|
||||||
new AccountClusterRiskCurrentInvestment(
|
new AccountClusterRiskCurrentInvestment(
|
||||||
this.exchangeRateDataService,
|
this.exchangeRateDataService,
|
||||||
@ -1262,9 +1263,11 @@ export class PortfolioService {
|
|||||||
accounts
|
accounts
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
<UserSettings>this.request.user.Settings.settings
|
userSettings
|
||||||
),
|
),
|
||||||
currencyClusterRisk: await this.rulesService.evaluate(
|
currencyClusterRisk: isEmpty(orders)
|
||||||
|
? undefined
|
||||||
|
: await this.rulesService.evaluate(
|
||||||
[
|
[
|
||||||
new CurrencyClusterRiskBaseCurrencyCurrentInvestment(
|
new CurrencyClusterRiskBaseCurrencyCurrentInvestment(
|
||||||
this.exchangeRateDataService,
|
this.exchangeRateDataService,
|
||||||
@ -1275,7 +1278,16 @@ export class PortfolioService {
|
|||||||
positions
|
positions
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
<UserSettings>this.request.user.Settings.settings
|
userSettings
|
||||||
|
),
|
||||||
|
emergencyFund: await this.rulesService.evaluate(
|
||||||
|
[
|
||||||
|
new EmergencyFundSetup(
|
||||||
|
this.exchangeRateDataService,
|
||||||
|
userSettings.emergencyFund
|
||||||
|
)
|
||||||
|
],
|
||||||
|
userSettings
|
||||||
),
|
),
|
||||||
fees: await this.rulesService.evaluate(
|
fees: await this.rulesService.evaluate(
|
||||||
[
|
[
|
||||||
@ -1285,7 +1297,7 @@ export class PortfolioService {
|
|||||||
this.getFees({ userCurrency, activities: orders }).toNumber()
|
this.getFees({ userCurrency, activities: orders }).toNumber()
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
<UserSettings>this.request.user.Settings.settings
|
userSettings
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
6
apps/api/src/app/tag/create-tag.dto.ts
Normal file
6
apps/api/src/app/tag/create-tag.dto.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import { IsString } from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateTagDto {
|
||||||
|
@IsString()
|
||||||
|
name: string;
|
||||||
|
}
|
104
apps/api/src/app/tag/tag.controller.ts
Normal file
104
apps/api/src/app/tag/tag.controller.ts
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||||
|
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
HttpException,
|
||||||
|
Inject,
|
||||||
|
Param,
|
||||||
|
Post,
|
||||||
|
Put,
|
||||||
|
UseGuards
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { REQUEST } from '@nestjs/core';
|
||||||
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
import { Tag } from '@prisma/client';
|
||||||
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
|
|
||||||
|
import { CreateTagDto } from './create-tag.dto';
|
||||||
|
import { TagService } from './tag.service';
|
||||||
|
import { UpdateTagDto } from './update-tag.dto';
|
||||||
|
|
||||||
|
@Controller('tag')
|
||||||
|
export class TagController {
|
||||||
|
public constructor(
|
||||||
|
@Inject(REQUEST) private readonly request: RequestWithUser,
|
||||||
|
private readonly tagService: TagService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
public async getTags() {
|
||||||
|
return this.tagService.getTagsWithActivityCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
public async createTag(@Body() data: CreateTagDto): Promise<Tag> {
|
||||||
|
if (!hasPermission(this.request.user.permissions, permissions.createTag)) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.tagService.createTag(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put(':id')
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
public async updateTag(@Param('id') id: string, @Body() data: UpdateTagDto) {
|
||||||
|
if (!hasPermission(this.request.user.permissions, permissions.updateTag)) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalTag = await this.tagService.getTag({
|
||||||
|
id
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!originalTag) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.tagService.updateTag({
|
||||||
|
data: {
|
||||||
|
...data
|
||||||
|
},
|
||||||
|
where: {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
public async deleteTag(@Param('id') id: string) {
|
||||||
|
if (!hasPermission(this.request.user.permissions, permissions.deleteTag)) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalTag = await this.tagService.getTag({
|
||||||
|
id
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!originalTag) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.tagService.deleteTag({ id });
|
||||||
|
}
|
||||||
|
}
|
13
apps/api/src/app/tag/tag.module.ts
Normal file
13
apps/api/src/app/tag/tag.module.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { TagController } from './tag.controller';
|
||||||
|
import { TagService } from './tag.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [TagController],
|
||||||
|
exports: [TagService],
|
||||||
|
imports: [PrismaModule],
|
||||||
|
providers: [TagService]
|
||||||
|
})
|
||||||
|
export class TagModule {}
|
79
apps/api/src/app/tag/tag.service.ts
Normal file
79
apps/api/src/app/tag/tag.service.ts
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { Prisma, Tag } from '@prisma/client';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class TagService {
|
||||||
|
public constructor(private readonly prismaService: PrismaService) {}
|
||||||
|
|
||||||
|
public async createTag(data: Prisma.TagCreateInput) {
|
||||||
|
return this.prismaService.tag.create({
|
||||||
|
data
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async deleteTag(where: Prisma.TagWhereUniqueInput): Promise<Tag> {
|
||||||
|
return this.prismaService.tag.delete({ where });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getTag(
|
||||||
|
tagWhereUniqueInput: Prisma.TagWhereUniqueInput
|
||||||
|
): Promise<Tag> {
|
||||||
|
return this.prismaService.tag.findUnique({
|
||||||
|
where: tagWhereUniqueInput
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getTags({
|
||||||
|
cursor,
|
||||||
|
orderBy,
|
||||||
|
skip,
|
||||||
|
take,
|
||||||
|
where
|
||||||
|
}: {
|
||||||
|
cursor?: Prisma.TagWhereUniqueInput;
|
||||||
|
orderBy?: Prisma.TagOrderByWithRelationInput;
|
||||||
|
skip?: number;
|
||||||
|
take?: number;
|
||||||
|
where?: Prisma.TagWhereInput;
|
||||||
|
} = {}) {
|
||||||
|
return this.prismaService.tag.findMany({
|
||||||
|
cursor,
|
||||||
|
orderBy,
|
||||||
|
skip,
|
||||||
|
take,
|
||||||
|
where
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getTagsWithActivityCount() {
|
||||||
|
const tagsWithOrderCount = await this.prismaService.tag.findMany({
|
||||||
|
include: {
|
||||||
|
_count: {
|
||||||
|
select: { orders: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return tagsWithOrderCount.map(({ _count, id, name }) => {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
activityCount: _count.orders
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async updateTag({
|
||||||
|
data,
|
||||||
|
where
|
||||||
|
}: {
|
||||||
|
data: Prisma.TagUpdateInput;
|
||||||
|
where: Prisma.TagWhereUniqueInput;
|
||||||
|
}): Promise<Tag> {
|
||||||
|
return this.prismaService.tag.update({
|
||||||
|
data,
|
||||||
|
where
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
9
apps/api/src/app/tag/update-tag.dto.ts
Normal file
9
apps/api/src/app/tag/update-tag.dto.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { IsString } from 'class-validator';
|
||||||
|
|
||||||
|
export class UpdateTagDto {
|
||||||
|
@IsString()
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
name: string;
|
||||||
|
}
|
@ -78,6 +78,10 @@
|
|||||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-exirio</loc>
|
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-exirio</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
</url>
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-finary</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-folishare</loc>
|
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-folishare</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
@ -146,6 +150,10 @@
|
|||||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-snowball-analytics</loc>
|
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-snowball-analytics</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
</url>
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-stockle</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-stockmarketeye</loc>
|
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-stockmarketeye</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
@ -262,6 +270,10 @@
|
|||||||
<loc>https://ghostfol.io/en/blog/2023/09/ghostfolio-2</loc>
|
<loc>https://ghostfol.io/en/blog/2023/09/ghostfolio-2</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
</url>
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/blog/2023/09/hacktoberfest-2023</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://ghostfol.io/en/faq</loc>
|
<loc>https://ghostfol.io/en/faq</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
@ -320,6 +332,10 @@
|
|||||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-exirio</loc>
|
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-exirio</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
</url>
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-finary</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-folishare</loc>
|
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-folishare</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
@ -388,6 +404,10 @@
|
|||||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-snowball-analytics</loc>
|
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-snowball-analytics</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
</url>
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-stockle</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-stockmarketeye</loc>
|
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-stockmarketeye</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
@ -594,6 +614,10 @@
|
|||||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-exirio</loc>
|
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-exirio</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
</url>
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-finary</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-folishare</loc>
|
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-folishare</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
@ -662,6 +686,10 @@
|
|||||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-snowball-analytics</loc>
|
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-snowball-analytics</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
</url>
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-stockle</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-stockmarketeye</loc>
|
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-stockmarketeye</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
@ -714,6 +742,10 @@
|
|||||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-exirio</loc>
|
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-exirio</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
</url>
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-finary</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-folishare</loc>
|
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-folishare</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
@ -782,6 +814,10 @@
|
|||||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-snowball-analytics</loc>
|
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-snowball-analytics</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
</url>
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-stockle</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-stockmarketeye</loc>
|
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-stockmarketeye</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
@ -80,6 +80,10 @@ const locales = {
|
|||||||
'/en/blog/2023/09/ghostfolio-2': {
|
'/en/blog/2023/09/ghostfolio-2': {
|
||||||
featureGraphicPath: 'assets/images/blog/ghostfolio-2.jpg',
|
featureGraphicPath: 'assets/images/blog/ghostfolio-2.jpg',
|
||||||
title: `Announcing Ghostfolio 2.0 - ${titleShort}`
|
title: `Announcing Ghostfolio 2.0 - ${titleShort}`
|
||||||
|
},
|
||||||
|
'/en/blog/2023/09/hacktoberfest-2023': {
|
||||||
|
featureGraphicPath: 'assets/images/blog/hacktoberfest-2023.png',
|
||||||
|
title: `Hacktoberfest 2023 - ${titleShort}`
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
||||||
|
import { Rule } from '@ghostfolio/api/models/rule';
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
import {
|
import {
|
||||||
PortfolioDetails,
|
PortfolioDetails,
|
||||||
@ -6,16 +7,18 @@ import {
|
|||||||
UserSettings
|
UserSettings
|
||||||
} from '@ghostfolio/common/interfaces';
|
} from '@ghostfolio/common/interfaces';
|
||||||
|
|
||||||
import { Rule } from '../../rule';
|
|
||||||
|
|
||||||
export class AccountClusterRiskCurrentInvestment extends Rule<Settings> {
|
export class AccountClusterRiskCurrentInvestment extends Rule<Settings> {
|
||||||
|
private accounts: PortfolioDetails['accounts'];
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
protected exchangeRateDataService: ExchangeRateDataService,
|
protected exchangeRateDataService: ExchangeRateDataService,
|
||||||
private accounts: PortfolioDetails['accounts']
|
accounts: PortfolioDetails['accounts']
|
||||||
) {
|
) {
|
||||||
super(exchangeRateDataService, {
|
super(exchangeRateDataService, {
|
||||||
name: 'Investment'
|
name: 'Investment'
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.accounts = accounts;
|
||||||
}
|
}
|
||||||
|
|
||||||
public evaluate(ruleSettings: Settings) {
|
public evaluate(ruleSettings: Settings) {
|
||||||
|
@ -1,17 +1,20 @@
|
|||||||
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
||||||
|
import { Rule } from '@ghostfolio/api/models/rule';
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
import { PortfolioDetails, UserSettings } from '@ghostfolio/common/interfaces';
|
import { PortfolioDetails, UserSettings } from '@ghostfolio/common/interfaces';
|
||||||
|
|
||||||
import { Rule } from '../../rule';
|
|
||||||
|
|
||||||
export class AccountClusterRiskSingleAccount extends Rule<RuleSettings> {
|
export class AccountClusterRiskSingleAccount extends Rule<RuleSettings> {
|
||||||
|
private accounts: PortfolioDetails['accounts'];
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
protected exchangeRateDataService: ExchangeRateDataService,
|
protected exchangeRateDataService: ExchangeRateDataService,
|
||||||
private accounts: PortfolioDetails['accounts']
|
accounts: PortfolioDetails['accounts']
|
||||||
) {
|
) {
|
||||||
super(exchangeRateDataService, {
|
super(exchangeRateDataService, {
|
||||||
name: 'Single Account'
|
name: 'Single Account'
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.accounts = accounts;
|
||||||
}
|
}
|
||||||
|
|
||||||
public evaluate() {
|
public evaluate() {
|
||||||
|
@ -1,17 +1,20 @@
|
|||||||
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
||||||
|
import { Rule } from '@ghostfolio/api/models/rule';
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
import { TimelinePosition, UserSettings } from '@ghostfolio/common/interfaces';
|
import { TimelinePosition, UserSettings } from '@ghostfolio/common/interfaces';
|
||||||
|
|
||||||
import { Rule } from '../../rule';
|
|
||||||
|
|
||||||
export class CurrencyClusterRiskBaseCurrencyCurrentInvestment extends Rule<Settings> {
|
export class CurrencyClusterRiskBaseCurrencyCurrentInvestment extends Rule<Settings> {
|
||||||
|
private positions: TimelinePosition[];
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
protected exchangeRateDataService: ExchangeRateDataService,
|
protected exchangeRateDataService: ExchangeRateDataService,
|
||||||
private positions: TimelinePosition[]
|
positions: TimelinePosition[]
|
||||||
) {
|
) {
|
||||||
super(exchangeRateDataService, {
|
super(exchangeRateDataService, {
|
||||||
name: 'Investment: Base Currency'
|
name: 'Investment: Base Currency'
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.positions = positions;
|
||||||
}
|
}
|
||||||
|
|
||||||
public evaluate(ruleSettings: Settings) {
|
public evaluate(ruleSettings: Settings) {
|
||||||
|
@ -1,17 +1,20 @@
|
|||||||
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
||||||
|
import { Rule } from '@ghostfolio/api/models/rule';
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
import { TimelinePosition, UserSettings } from '@ghostfolio/common/interfaces';
|
import { TimelinePosition, UserSettings } from '@ghostfolio/common/interfaces';
|
||||||
|
|
||||||
import { Rule } from '../../rule';
|
|
||||||
|
|
||||||
export class CurrencyClusterRiskCurrentInvestment extends Rule<Settings> {
|
export class CurrencyClusterRiskCurrentInvestment extends Rule<Settings> {
|
||||||
|
private positions: TimelinePosition[];
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
protected exchangeRateDataService: ExchangeRateDataService,
|
protected exchangeRateDataService: ExchangeRateDataService,
|
||||||
private positions: TimelinePosition[]
|
positions: TimelinePosition[]
|
||||||
) {
|
) {
|
||||||
super(exchangeRateDataService, {
|
super(exchangeRateDataService, {
|
||||||
name: 'Investment'
|
name: 'Investment'
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.positions = positions;
|
||||||
}
|
}
|
||||||
|
|
||||||
public evaluate(ruleSettings: Settings) {
|
public evaluate(ruleSettings: Settings) {
|
||||||
|
@ -0,0 +1,46 @@
|
|||||||
|
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
||||||
|
import { Rule } from '@ghostfolio/api/models/rule';
|
||||||
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
|
import { UserSettings } from '@ghostfolio/common/interfaces';
|
||||||
|
|
||||||
|
export class EmergencyFundSetup extends Rule<Settings> {
|
||||||
|
private emergencyFund: number;
|
||||||
|
|
||||||
|
public constructor(
|
||||||
|
protected exchangeRateDataService: ExchangeRateDataService,
|
||||||
|
emergencyFund: number
|
||||||
|
) {
|
||||||
|
super(exchangeRateDataService, {
|
||||||
|
name: 'Emergency Fund: Set up'
|
||||||
|
});
|
||||||
|
|
||||||
|
this.emergencyFund = emergencyFund;
|
||||||
|
}
|
||||||
|
|
||||||
|
public evaluate(ruleSettings: Settings) {
|
||||||
|
if (this.emergencyFund > ruleSettings.threshold) {
|
||||||
|
return {
|
||||||
|
evaluation: 'An emergency fund has been set up',
|
||||||
|
value: true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
evaluation: 'No emergency fund has been set up',
|
||||||
|
value: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public getSettings(aUserSettings: UserSettings): Settings {
|
||||||
|
return {
|
||||||
|
baseCurrency: aUserSettings.baseCurrency,
|
||||||
|
isActive: true,
|
||||||
|
threshold: 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Settings extends RuleSettings {
|
||||||
|
baseCurrency: string;
|
||||||
|
threshold: number;
|
||||||
|
}
|
@ -1,22 +1,29 @@
|
|||||||
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
||||||
|
import { Rule } from '@ghostfolio/api/models/rule';
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
import { UserSettings } from '@ghostfolio/common/interfaces';
|
import { UserSettings } from '@ghostfolio/common/interfaces';
|
||||||
|
|
||||||
import { Rule } from '../../rule';
|
|
||||||
|
|
||||||
export class FeeRatioInitialInvestment extends Rule<Settings> {
|
export class FeeRatioInitialInvestment extends Rule<Settings> {
|
||||||
|
private fees: number;
|
||||||
|
private totalInvestment: number;
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
protected exchangeRateDataService: ExchangeRateDataService,
|
protected exchangeRateDataService: ExchangeRateDataService,
|
||||||
private totalInvestment: number,
|
totalInvestment: number,
|
||||||
private fees: number
|
fees: number
|
||||||
) {
|
) {
|
||||||
super(exchangeRateDataService, {
|
super(exchangeRateDataService, {
|
||||||
name: 'Investment'
|
name: 'Fee Ratio'
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.fees = fees;
|
||||||
|
this.totalInvestment = totalInvestment;
|
||||||
}
|
}
|
||||||
|
|
||||||
public evaluate(ruleSettings: Settings) {
|
public evaluate(ruleSettings: Settings) {
|
||||||
const feeRatio = this.fees / this.totalInvestment;
|
const feeRatio = this.totalInvestment
|
||||||
|
? this.fees / this.totalInvestment
|
||||||
|
: 0;
|
||||||
|
|
||||||
if (feeRatio > ruleSettings.threshold) {
|
if (feeRatio > ruleSettings.threshold) {
|
||||||
return {
|
return {
|
||||||
|
@ -21,6 +21,7 @@
|
|||||||
"tsConfig": "apps/client/tsconfig.app.json",
|
"tsConfig": "apps/client/tsconfig.app.json",
|
||||||
"assets": [],
|
"assets": [],
|
||||||
"styles": [
|
"styles": [
|
||||||
|
"apps/client/src/assets/fonts/inter.css",
|
||||||
"apps/client/src/styles/theme.scss",
|
"apps/client/src/styles/theme.scss",
|
||||||
"apps/client/src/styles.scss"
|
"apps/client/src/styles.scss"
|
||||||
],
|
],
|
||||||
|
@ -72,19 +72,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
|
||||||
*ngIf="info?.tags?.length > 0"
|
|
||||||
class="align-items-start d-flex my-3"
|
|
||||||
>
|
|
||||||
<div class="w-50" i18n>Tags</div>
|
|
||||||
<div class="w-50">
|
|
||||||
<table>
|
|
||||||
<tr *ngFor="let tag of info.tags">
|
|
||||||
<td class="pl-1">{{ tag.name }}</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="d-flex my-3">
|
<div class="d-flex my-3">
|
||||||
<div class="w-50" i18n>User Signup</div>
|
<div class="w-50" i18n>User Signup</div>
|
||||||
<div class="w-50">
|
<div class="w-50">
|
||||||
|
@ -19,7 +19,7 @@ import { get } from 'lodash';
|
|||||||
import { DeviceDetectorService } from 'ngx-device-detector';
|
import { DeviceDetectorService } from 'ngx-device-detector';
|
||||||
import { Subject, takeUntil } from 'rxjs';
|
import { Subject, takeUntil } from 'rxjs';
|
||||||
|
|
||||||
import { CreateOrUpdatePlatformDialog } from './create-or-update-platform-dialog/create-or-update-account-platform.component';
|
import { CreateOrUpdatePlatformDialog } from './create-or-update-platform-dialog/create-or-update-platform-dialog.component';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
@ -114,6 +114,7 @@ export class AdminPlatformComponent implements OnInit, OnDestroy {
|
|||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
.subscribe((platforms) => {
|
.subscribe((platforms) => {
|
||||||
this.platforms = platforms;
|
this.platforms = platforms;
|
||||||
|
|
||||||
this.dataSource = new MatTableDataSource(platforms);
|
this.dataSource = new MatTableDataSource(platforms);
|
||||||
this.dataSource.sort = this.sort;
|
this.dataSource.sort = this.sort;
|
||||||
this.dataSource.sortingDataAccessor = get;
|
this.dataSource.sortingDataAccessor = get;
|
||||||
@ -130,7 +131,6 @@ export class AdminPlatformComponent implements OnInit, OnDestroy {
|
|||||||
url: null
|
url: null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
|
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
|
||||||
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
|
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
|
||||||
});
|
});
|
||||||
@ -170,7 +170,6 @@ export class AdminPlatformComponent implements OnInit, OnDestroy {
|
|||||||
url
|
url
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
|
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
|
||||||
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
|
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
|
||||||
});
|
});
|
||||||
|
@ -15,8 +15,8 @@ export class CreateOrUpdatePlatformDialog {
|
|||||||
private unsubscribeSubject = new Subject<void>();
|
private unsubscribeSubject = new Subject<void>();
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
public dialogRef: MatDialogRef<CreateOrUpdatePlatformDialog>,
|
@Inject(MAT_DIALOG_DATA) public data: CreateOrUpdatePlatformDialogParams,
|
||||||
@Inject(MAT_DIALOG_DATA) public data: CreateOrUpdatePlatformDialogParams
|
public dialogRef: MatDialogRef<CreateOrUpdatePlatformDialog>
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public onCancel() {
|
public onCancel() {
|
@ -6,7 +6,7 @@ import { MatDialogModule } from '@angular/material/dialog';
|
|||||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||||
import { MatInputModule } from '@angular/material/input';
|
import { MatInputModule } from '@angular/material/input';
|
||||||
|
|
||||||
import { CreateOrUpdatePlatformDialog } from './create-or-update-account-platform.component';
|
import { CreateOrUpdatePlatformDialog } from './create-or-update-platform-dialog.component';
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
declarations: [CreateOrUpdatePlatformDialog],
|
declarations: [CreateOrUpdatePlatformDialog],
|
||||||
|
@ -2,14 +2,13 @@
|
|||||||
<div class="mb-5 row">
|
<div class="mb-5 row">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<h2 class="text-center" i18n>Platforms</h2>
|
<h2 class="text-center" i18n>Platforms</h2>
|
||||||
<gf-admin-platform></gf-admin-platform>
|
<gf-admin-platform />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!--
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<h2 class="text-center" i18n>Tags</h2>
|
<h2 class="text-center" i18n>Tags</h2>
|
||||||
|
<gf-admin-tag />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
-->
|
|
||||||
</div>
|
</div>
|
||||||
|
@ -2,12 +2,18 @@ import { CommonModule } from '@angular/common';
|
|||||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||||
import { RouterModule } from '@angular/router';
|
import { RouterModule } from '@angular/router';
|
||||||
import { GfAdminPlatformModule } from '@ghostfolio/client/components/admin-platform/admin-platform.module';
|
import { GfAdminPlatformModule } from '@ghostfolio/client/components/admin-platform/admin-platform.module';
|
||||||
|
import { GfAdminTagModule } from '@ghostfolio/client/components/admin-tag/admin-tag.module';
|
||||||
|
|
||||||
import { AdminSettingsComponent } from './admin-settings.component';
|
import { AdminSettingsComponent } from './admin-settings.component';
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
declarations: [AdminSettingsComponent],
|
declarations: [AdminSettingsComponent],
|
||||||
imports: [CommonModule, GfAdminPlatformModule, RouterModule],
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
GfAdminPlatformModule,
|
||||||
|
GfAdminTagModule,
|
||||||
|
RouterModule
|
||||||
|
],
|
||||||
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||||
})
|
})
|
||||||
export class GfAdminSettingsModule {}
|
export class GfAdminSettingsModule {}
|
||||||
|
@ -0,0 +1,85 @@
|
|||||||
|
<div class="container">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col">
|
||||||
|
<div class="d-flex justify-content-end">
|
||||||
|
<a
|
||||||
|
color="primary"
|
||||||
|
i18n
|
||||||
|
mat-flat-button
|
||||||
|
[queryParams]="{ createTagDialog: true }"
|
||||||
|
[routerLink]="[]"
|
||||||
|
>
|
||||||
|
Add Tag
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<table
|
||||||
|
class="gf-table w-100"
|
||||||
|
mat-table
|
||||||
|
matSort
|
||||||
|
matSortActive="name"
|
||||||
|
matSortDirection="asc"
|
||||||
|
[dataSource]="dataSource"
|
||||||
|
>
|
||||||
|
<ng-container matColumnDef="name">
|
||||||
|
<th
|
||||||
|
*matHeaderCellDef
|
||||||
|
class="px-1"
|
||||||
|
mat-header-cell
|
||||||
|
mat-sort-header="name"
|
||||||
|
>
|
||||||
|
<ng-container i18n>Name</ng-container>
|
||||||
|
</th>
|
||||||
|
<td *matCellDef="let element" class="px-1" mat-cell>
|
||||||
|
{{ element.name }}
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="activities">
|
||||||
|
<th
|
||||||
|
*matHeaderCellDef
|
||||||
|
class="px-1"
|
||||||
|
mat-header-cell
|
||||||
|
mat-sort-header="activityCount"
|
||||||
|
>
|
||||||
|
<ng-container i18n>Activities</ng-container>
|
||||||
|
</th>
|
||||||
|
<td *matCellDef="let element" class="px-1" mat-cell>
|
||||||
|
{{ element.activityCount }}
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="actions">
|
||||||
|
<th
|
||||||
|
*matHeaderCellDef
|
||||||
|
class="px-1 text-center"
|
||||||
|
i18n
|
||||||
|
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]="tagMenu"
|
||||||
|
(click)="$event.stopPropagation()"
|
||||||
|
>
|
||||||
|
<ion-icon name="ellipsis-horizontal"></ion-icon>
|
||||||
|
</button>
|
||||||
|
<mat-menu #tagMenu="matMenu" xPosition="before">
|
||||||
|
<button mat-menu-item (click)="onUpdateTag(element)">
|
||||||
|
<ion-icon class="mr-2" name="create-outline"></ion-icon>
|
||||||
|
<span i18n>Edit</span>
|
||||||
|
</button>
|
||||||
|
<button mat-menu-item (click)="onDeleteTag(element.id)">
|
||||||
|
<ion-icon class="mr-2" name="trash-outline"></ion-icon>
|
||||||
|
<span i18n>Delete</span>
|
||||||
|
</button>
|
||||||
|
</mat-menu>
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<tr *matHeaderRowDef="displayedColumns" mat-header-row></tr>
|
||||||
|
<tr *matRowDef="let row; columns: displayedColumns" mat-row></tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
@ -0,0 +1,5 @@
|
|||||||
|
@import 'apps/client/src/styles/ghostfolio-style';
|
||||||
|
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
}
|
199
apps/client/src/app/components/admin-tag/admin-tag.component.ts
Normal file
199
apps/client/src/app/components/admin-tag/admin-tag.component.ts
Normal file
@ -0,0 +1,199 @@
|
|||||||
|
import {
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
ChangeDetectorRef,
|
||||||
|
Component,
|
||||||
|
OnDestroy,
|
||||||
|
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 { CreateTagDto } from '@ghostfolio/api/app/tag/create-tag.dto';
|
||||||
|
import { UpdateTagDto } from '@ghostfolio/api/app/tag/update-tag.dto';
|
||||||
|
import { AdminService } from '@ghostfolio/client/services/admin.service';
|
||||||
|
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||||
|
import { Tag } from '@prisma/client';
|
||||||
|
import { get } from 'lodash';
|
||||||
|
import { DeviceDetectorService } from 'ngx-device-detector';
|
||||||
|
import { Subject, takeUntil } from 'rxjs';
|
||||||
|
|
||||||
|
import { CreateOrUpdateTagDialog } from './create-or-update-tag-dialog/create-or-update-tag-dialog.component';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
selector: 'gf-admin-tag',
|
||||||
|
styleUrls: ['./admin-tag.component.scss'],
|
||||||
|
templateUrl: './admin-tag.component.html'
|
||||||
|
})
|
||||||
|
export class AdminTagComponent implements OnInit, OnDestroy {
|
||||||
|
@ViewChild(MatSort) sort: MatSort;
|
||||||
|
|
||||||
|
public dataSource: MatTableDataSource<Tag> = new MatTableDataSource();
|
||||||
|
public deviceType: string;
|
||||||
|
public displayedColumns = ['name', 'activities', 'actions'];
|
||||||
|
public tags: Tag[];
|
||||||
|
|
||||||
|
private unsubscribeSubject = new Subject<void>();
|
||||||
|
|
||||||
|
public constructor(
|
||||||
|
private adminService: AdminService,
|
||||||
|
private changeDetectorRef: ChangeDetectorRef,
|
||||||
|
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['createTagDialog']) {
|
||||||
|
this.openCreateTagDialog();
|
||||||
|
} else if (params['editTagDialog']) {
|
||||||
|
if (this.tags) {
|
||||||
|
const tag = this.tags.find(({ id }) => {
|
||||||
|
return id === params['tagId'];
|
||||||
|
});
|
||||||
|
|
||||||
|
this.openUpdateTagDialog(tag);
|
||||||
|
} else {
|
||||||
|
this.router.navigate(['.'], { relativeTo: this.route });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public ngOnInit() {
|
||||||
|
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
|
||||||
|
|
||||||
|
this.fetchTags();
|
||||||
|
}
|
||||||
|
|
||||||
|
public onDeleteTag(aId: string) {
|
||||||
|
const confirmation = confirm(
|
||||||
|
$localize`Do you really want to delete this tag?`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (confirmation) {
|
||||||
|
this.deleteTag(aId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public onUpdateTag({ id }: Tag) {
|
||||||
|
this.router.navigate([], {
|
||||||
|
queryParams: { editTagDialog: true, tagId: id }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public ngOnDestroy() {
|
||||||
|
this.unsubscribeSubject.next();
|
||||||
|
this.unsubscribeSubject.complete();
|
||||||
|
}
|
||||||
|
|
||||||
|
private deleteTag(aId: string) {
|
||||||
|
this.adminService
|
||||||
|
.deleteTag(aId)
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.userService
|
||||||
|
.get(true)
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe();
|
||||||
|
|
||||||
|
this.fetchTags();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private fetchTags() {
|
||||||
|
this.adminService
|
||||||
|
.fetchTags()
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe((tags) => {
|
||||||
|
this.tags = tags;
|
||||||
|
this.dataSource = new MatTableDataSource(this.tags);
|
||||||
|
this.dataSource.sort = this.sort;
|
||||||
|
this.dataSource.sortingDataAccessor = get;
|
||||||
|
|
||||||
|
this.changeDetectorRef.markForCheck();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private openCreateTagDialog() {
|
||||||
|
const dialogRef = this.dialog.open(CreateOrUpdateTagDialog, {
|
||||||
|
data: {
|
||||||
|
tag: {
|
||||||
|
name: null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
|
||||||
|
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
|
||||||
|
});
|
||||||
|
|
||||||
|
dialogRef
|
||||||
|
.afterClosed()
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe((data) => {
|
||||||
|
const tag: CreateTagDto = data?.tag;
|
||||||
|
|
||||||
|
if (tag) {
|
||||||
|
this.adminService
|
||||||
|
.postTag(tag)
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.userService
|
||||||
|
.get(true)
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe();
|
||||||
|
|
||||||
|
this.fetchTags();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.router.navigate(['.'], { relativeTo: this.route });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private openUpdateTagDialog({ id, name }) {
|
||||||
|
const dialogRef = this.dialog.open(CreateOrUpdateTagDialog, {
|
||||||
|
data: {
|
||||||
|
tag: {
|
||||||
|
id,
|
||||||
|
name
|
||||||
|
}
|
||||||
|
},
|
||||||
|
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
|
||||||
|
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
|
||||||
|
});
|
||||||
|
|
||||||
|
dialogRef
|
||||||
|
.afterClosed()
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe((data) => {
|
||||||
|
const tag: UpdateTagDto = data?.tag;
|
||||||
|
|
||||||
|
if (tag) {
|
||||||
|
this.adminService
|
||||||
|
.putTag(tag)
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.userService
|
||||||
|
.get(true)
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe();
|
||||||
|
|
||||||
|
this.fetchTags();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.router.navigate(['.'], { relativeTo: this.route });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
26
apps/client/src/app/components/admin-tag/admin-tag.module.ts
Normal file
26
apps/client/src/app/components/admin-tag/admin-tag.module.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
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 { MatSortModule } from '@angular/material/sort';
|
||||||
|
import { MatTableModule } from '@angular/material/table';
|
||||||
|
import { RouterModule } from '@angular/router';
|
||||||
|
|
||||||
|
import { AdminTagComponent } from './admin-tag.component';
|
||||||
|
import { GfCreateOrUpdateTagDialogModule } from './create-or-update-tag-dialog/create-or-update-tag-dialog.module';
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
declarations: [AdminTagComponent],
|
||||||
|
exports: [AdminTagComponent],
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
GfCreateOrUpdateTagDialogModule,
|
||||||
|
MatButtonModule,
|
||||||
|
MatMenuModule,
|
||||||
|
MatSortModule,
|
||||||
|
MatTableModule,
|
||||||
|
RouterModule
|
||||||
|
],
|
||||||
|
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||||
|
})
|
||||||
|
export class GfAdminTagModule {}
|
@ -0,0 +1,30 @@
|
|||||||
|
import { ChangeDetectionStrategy, Component, Inject } from '@angular/core';
|
||||||
|
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||||
|
import { Subject } from 'rxjs';
|
||||||
|
|
||||||
|
import { CreateOrUpdateTagDialogParams } from './interfaces/interfaces';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
host: { class: 'h-100' },
|
||||||
|
selector: 'gf-create-or-update-tag-dialog',
|
||||||
|
styleUrls: ['./create-or-update-tag-dialog.scss'],
|
||||||
|
templateUrl: 'create-or-update-tag-dialog.html'
|
||||||
|
})
|
||||||
|
export class CreateOrUpdateTagDialog {
|
||||||
|
private unsubscribeSubject = new Subject<void>();
|
||||||
|
|
||||||
|
public constructor(
|
||||||
|
@Inject(MAT_DIALOG_DATA) public data: CreateOrUpdateTagDialogParams,
|
||||||
|
public dialogRef: MatDialogRef<CreateOrUpdateTagDialog>
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public onCancel() {
|
||||||
|
this.dialogRef.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
public ngOnDestroy() {
|
||||||
|
this.unsubscribeSubject.next();
|
||||||
|
this.unsubscribeSubject.complete();
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,23 @@
|
|||||||
|
<form #addTagForm="ngForm" class="d-flex flex-column h-100">
|
||||||
|
<h1 *ngIf="data.tag.id" i18n mat-dialog-title>Update tag</h1>
|
||||||
|
<h1 *ngIf="!data.tag.id" i18n mat-dialog-title>Add tag</h1>
|
||||||
|
<div class="flex-grow-1 py-3" mat-dialog-content>
|
||||||
|
<div>
|
||||||
|
<mat-form-field appearance="outline" class="w-100">
|
||||||
|
<mat-label i18n>Name</mat-label>
|
||||||
|
<input matInput name="name" required [(ngModel)]="data.tag.name" />
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="justify-content-end" mat-dialog-actions>
|
||||||
|
<button i18n mat-button (click)="onCancel()">Cancel</button>
|
||||||
|
<button
|
||||||
|
color="primary"
|
||||||
|
mat-flat-button
|
||||||
|
[disabled]="!addTagForm.form.valid"
|
||||||
|
[mat-dialog-close]="data"
|
||||||
|
>
|
||||||
|
<ng-container i18n>Save</ng-container>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
@ -0,0 +1,23 @@
|
|||||||
|
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 { MatInputModule } from '@angular/material/input';
|
||||||
|
|
||||||
|
import { CreateOrUpdateTagDialog } from './create-or-update-tag-dialog.component';
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
declarations: [CreateOrUpdateTagDialog],
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
FormsModule,
|
||||||
|
MatButtonModule,
|
||||||
|
MatDialogModule,
|
||||||
|
MatFormFieldModule,
|
||||||
|
MatInputModule,
|
||||||
|
ReactiveFormsModule
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class GfCreateOrUpdateTagDialogModule {}
|
@ -0,0 +1,5 @@
|
|||||||
|
import { Tag } from '@prisma/client';
|
||||||
|
|
||||||
|
export interface CreateOrUpdateTagDialogParams {
|
||||||
|
tag: Tag;
|
||||||
|
}
|
@ -0,0 +1,7 @@
|
|||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
|
||||||
|
.mat-mdc-dialog-content {
|
||||||
|
max-height: unset;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,146 @@
|
|||||||
|
import {
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
ChangeDetectorRef,
|
||||||
|
Component,
|
||||||
|
OnDestroy,
|
||||||
|
OnInit
|
||||||
|
} from '@angular/core';
|
||||||
|
import { MatDialog } from '@angular/material/dialog';
|
||||||
|
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 { Access, User } from '@ghostfolio/common/interfaces';
|
||||||
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||||
|
import { DeviceDetectorService } from 'ngx-device-detector';
|
||||||
|
import { Subject } from 'rxjs';
|
||||||
|
import { takeUntil } from 'rxjs/operators';
|
||||||
|
|
||||||
|
import { CreateOrUpdateAccessDialog } from './create-or-update-access-dialog/create-or-update-access-dialog.component';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
selector: 'gf-user-account-access',
|
||||||
|
styleUrls: ['./user-account-access.scss'],
|
||||||
|
templateUrl: './user-account-access.html'
|
||||||
|
})
|
||||||
|
export class UserAccountAccessComponent implements OnDestroy, OnInit {
|
||||||
|
public accesses: Access[];
|
||||||
|
public deviceType: string;
|
||||||
|
public hasPermissionToCreateAccess: boolean;
|
||||||
|
public hasPermissionToDeleteAccess: boolean;
|
||||||
|
public user: User;
|
||||||
|
|
||||||
|
private unsubscribeSubject = new Subject<void>();
|
||||||
|
|
||||||
|
public constructor(
|
||||||
|
private changeDetectorRef: ChangeDetectorRef,
|
||||||
|
private dataService: DataService,
|
||||||
|
private deviceService: DeviceDetectorService,
|
||||||
|
private dialog: MatDialog,
|
||||||
|
private route: ActivatedRoute,
|
||||||
|
private router: Router,
|
||||||
|
private userService: UserService
|
||||||
|
) {
|
||||||
|
const { globalPermissions } = this.dataService.fetchInfo();
|
||||||
|
|
||||||
|
this.hasPermissionToDeleteAccess = hasPermission(
|
||||||
|
globalPermissions,
|
||||||
|
permissions.deleteAccess
|
||||||
|
);
|
||||||
|
|
||||||
|
this.userService.stateChanged
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe((state) => {
|
||||||
|
if (state?.user) {
|
||||||
|
this.user = state.user;
|
||||||
|
|
||||||
|
this.hasPermissionToCreateAccess = hasPermission(
|
||||||
|
this.user.permissions,
|
||||||
|
permissions.createAccess
|
||||||
|
);
|
||||||
|
|
||||||
|
this.hasPermissionToDeleteAccess = hasPermission(
|
||||||
|
this.user.permissions,
|
||||||
|
permissions.deleteAccess
|
||||||
|
);
|
||||||
|
|
||||||
|
this.changeDetectorRef.markForCheck();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.route.queryParams
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe((params) => {
|
||||||
|
if (params['createDialog']) {
|
||||||
|
this.openCreateAccessDialog();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public ngOnInit() {
|
||||||
|
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
|
||||||
|
|
||||||
|
this.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
public onDeleteAccess(aId: string) {
|
||||||
|
this.dataService
|
||||||
|
.deleteAccess(aId)
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.update();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public ngOnDestroy() {
|
||||||
|
this.unsubscribeSubject.next();
|
||||||
|
this.unsubscribeSubject.complete();
|
||||||
|
}
|
||||||
|
|
||||||
|
private openCreateAccessDialog(): void {
|
||||||
|
const dialogRef = this.dialog.open(CreateOrUpdateAccessDialog, {
|
||||||
|
data: {
|
||||||
|
access: {
|
||||||
|
alias: '',
|
||||||
|
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({ alias: access.alias })
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.update();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.router.navigate(['.'], { relativeTo: this.route });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private update() {
|
||||||
|
this.dataService
|
||||||
|
.fetchAccesses()
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe((accesses) => {
|
||||||
|
this.accesses = accesses;
|
||||||
|
|
||||||
|
this.changeDetectorRef.markForCheck();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,17 @@
|
|||||||
|
<div class="container">
|
||||||
|
<h1
|
||||||
|
class="align-items-center d-none d-sm-flex h3 justify-content-center mb-3 text-center"
|
||||||
|
>
|
||||||
|
<span i18n>Granted Access</span>
|
||||||
|
<gf-premium-indicator
|
||||||
|
*ngIf="user?.subscription?.type === 'Basic'"
|
||||||
|
class="ml-1"
|
||||||
|
></gf-premium-indicator>
|
||||||
|
</h1>
|
||||||
|
<gf-access-table
|
||||||
|
[accesses]="accesses"
|
||||||
|
[hasPermissionToCreateAccess]="hasPermissionToCreateAccess"
|
||||||
|
[showActions]="hasPermissionToDeleteAccess"
|
||||||
|
(accessDeleted)="onDeleteAccess($event)"
|
||||||
|
></gf-access-table>
|
||||||
|
</div>
|
@ -0,0 +1,23 @@
|
|||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { NgModule } from '@angular/core';
|
||||||
|
import { MatDialogModule } from '@angular/material/dialog';
|
||||||
|
import { RouterModule } from '@angular/router';
|
||||||
|
import { GfPortfolioAccessTableModule } from '@ghostfolio/client/components/access-table/access-table.module';
|
||||||
|
import { GfPremiumIndicatorModule } from '@ghostfolio/ui/premium-indicator';
|
||||||
|
|
||||||
|
import { GfCreateOrUpdateAccessDialogModule } from './create-or-update-access-dialog/create-or-update-access-dialog.module';
|
||||||
|
import { UserAccountAccessComponent } from './user-account-access.component';
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
declarations: [UserAccountAccessComponent],
|
||||||
|
exports: [UserAccountAccessComponent],
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
GfCreateOrUpdateAccessDialogModule,
|
||||||
|
GfPortfolioAccessTableModule,
|
||||||
|
GfPremiumIndicatorModule,
|
||||||
|
MatDialogModule,
|
||||||
|
RouterModule
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class GfUserAccountAccessModule {}
|
@ -0,0 +1,12 @@
|
|||||||
|
:host {
|
||||||
|
color: rgb(var(--dark-primary-text));
|
||||||
|
display: block;
|
||||||
|
|
||||||
|
gf-access-table {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:host-context(.is-dark-theme) {
|
||||||
|
color: rgb(var(--light-primary-text));
|
||||||
|
}
|
@ -0,0 +1,160 @@
|
|||||||
|
import {
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
ChangeDetectorRef,
|
||||||
|
Component,
|
||||||
|
OnDestroy,
|
||||||
|
OnInit
|
||||||
|
} from '@angular/core';
|
||||||
|
import {
|
||||||
|
MatSnackBar,
|
||||||
|
MatSnackBarRef,
|
||||||
|
TextOnlySnackBar
|
||||||
|
} from '@angular/material/snack-bar';
|
||||||
|
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||||
|
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||||
|
import { getDateFormatString } from '@ghostfolio/common/helper';
|
||||||
|
import { User } from '@ghostfolio/common/interfaces';
|
||||||
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||||
|
import { StripeService } from 'ngx-stripe';
|
||||||
|
import { EMPTY, Subject } from 'rxjs';
|
||||||
|
import { catchError, switchMap, takeUntil } from 'rxjs/operators';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
selector: 'gf-user-account-membership',
|
||||||
|
styleUrls: ['./user-account-membership.scss'],
|
||||||
|
templateUrl: './user-account-membership.html'
|
||||||
|
})
|
||||||
|
export class UserAccountMembershipComponent implements OnDestroy, OnInit {
|
||||||
|
public baseCurrency: string;
|
||||||
|
public coupon: number;
|
||||||
|
public couponId: string;
|
||||||
|
public defaultDateFormat: string;
|
||||||
|
public hasPermissionForSubscription: boolean;
|
||||||
|
public hasPermissionToUpdateUserSettings: boolean;
|
||||||
|
public price: number;
|
||||||
|
public priceId: string;
|
||||||
|
public routerLinkPricing = ['/' + $localize`pricing`];
|
||||||
|
public snackBarRef: MatSnackBarRef<TextOnlySnackBar>;
|
||||||
|
public trySubscriptionMail =
|
||||||
|
'mailto:hi@ghostfol.io?Subject=Ghostfolio Premium Trial&body=Hello%0D%0DI am interested in Ghostfolio Premium. Can you please send me a coupon code to try it for some time?%0D%0DKind regards';
|
||||||
|
public user: User;
|
||||||
|
|
||||||
|
private unsubscribeSubject = new Subject<void>();
|
||||||
|
|
||||||
|
public constructor(
|
||||||
|
private changeDetectorRef: ChangeDetectorRef,
|
||||||
|
private dataService: DataService,
|
||||||
|
private snackBar: MatSnackBar,
|
||||||
|
private stripeService: StripeService,
|
||||||
|
private userService: UserService
|
||||||
|
) {
|
||||||
|
const { baseCurrency, globalPermissions, subscriptions } =
|
||||||
|
this.dataService.fetchInfo();
|
||||||
|
|
||||||
|
this.baseCurrency = baseCurrency;
|
||||||
|
|
||||||
|
this.hasPermissionForSubscription = hasPermission(
|
||||||
|
globalPermissions,
|
||||||
|
permissions.enableSubscription
|
||||||
|
);
|
||||||
|
|
||||||
|
this.userService.stateChanged
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe((state) => {
|
||||||
|
if (state?.user) {
|
||||||
|
this.user = state.user;
|
||||||
|
|
||||||
|
this.defaultDateFormat = getDateFormatString(
|
||||||
|
this.user.settings.locale
|
||||||
|
);
|
||||||
|
|
||||||
|
this.hasPermissionToUpdateUserSettings = hasPermission(
|
||||||
|
this.user.permissions,
|
||||||
|
permissions.updateUserSettings
|
||||||
|
);
|
||||||
|
|
||||||
|
this.coupon = subscriptions?.[this.user.subscription.offer]?.coupon;
|
||||||
|
this.couponId =
|
||||||
|
subscriptions?.[this.user.subscription.offer]?.couponId;
|
||||||
|
this.price = subscriptions?.[this.user.subscription.offer]?.price;
|
||||||
|
this.priceId = subscriptions?.[this.user.subscription.offer]?.priceId;
|
||||||
|
|
||||||
|
this.changeDetectorRef.markForCheck();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public ngOnInit() {}
|
||||||
|
|
||||||
|
public onCheckout() {
|
||||||
|
this.dataService
|
||||||
|
.createCheckoutSession({ couponId: this.couponId, priceId: this.priceId })
|
||||||
|
.pipe(
|
||||||
|
switchMap(({ sessionId }: { sessionId: string }) => {
|
||||||
|
return this.stripeService.redirectToCheckout({ sessionId });
|
||||||
|
}),
|
||||||
|
catchError((error) => {
|
||||||
|
alert(error.message);
|
||||||
|
throw error;
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.subscribe((result) => {
|
||||||
|
if (result.error) {
|
||||||
|
alert(result.error.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public onRedeemCoupon() {
|
||||||
|
let couponCode = prompt($localize`Please enter your coupon code:`);
|
||||||
|
couponCode = couponCode?.trim();
|
||||||
|
|
||||||
|
if (couponCode) {
|
||||||
|
this.dataService
|
||||||
|
.redeemCoupon(couponCode)
|
||||||
|
.pipe(
|
||||||
|
takeUntil(this.unsubscribeSubject),
|
||||||
|
catchError(() => {
|
||||||
|
this.snackBar.open(
|
||||||
|
'😞 ' + $localize`Could not redeem coupon code`,
|
||||||
|
undefined,
|
||||||
|
{
|
||||||
|
duration: 3000
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return EMPTY;
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.subscribe(() => {
|
||||||
|
this.snackBarRef = this.snackBar.open(
|
||||||
|
'✅ ' + $localize`Coupon code has been redeemed`,
|
||||||
|
$localize`Reload`,
|
||||||
|
{
|
||||||
|
duration: 3000
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
this.snackBarRef
|
||||||
|
.afterDismissed()
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe(() => {
|
||||||
|
window.location.reload();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.snackBarRef
|
||||||
|
.onAction()
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe(() => {
|
||||||
|
window.location.reload();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public ngOnDestroy() {
|
||||||
|
this.unsubscribeSubject.next();
|
||||||
|
this.unsubscribeSubject.complete();
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,69 @@
|
|||||||
|
<div class="container">
|
||||||
|
<h1 class="d-none d-sm-block h3 mb-3 text-center" i18n>Membership</h1>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col">
|
||||||
|
<div class="d-flex">
|
||||||
|
<div class="mx-auto">
|
||||||
|
<div class="align-items-center d-flex mb-1">
|
||||||
|
<a [routerLink]="routerLinkPricing"
|
||||||
|
>{{ user?.subscription?.type }}</a
|
||||||
|
>
|
||||||
|
<gf-premium-indicator
|
||||||
|
*ngIf="user?.subscription?.type === 'Premium'"
|
||||||
|
class="ml-1"
|
||||||
|
></gf-premium-indicator>
|
||||||
|
</div>
|
||||||
|
<div *ngIf="user?.subscription?.type === 'Premium'">
|
||||||
|
<ng-container i18n>Valid until</ng-container> {{
|
||||||
|
user?.subscription?.expiresAt | date: defaultDateFormat }}
|
||||||
|
</div>
|
||||||
|
<div *ngIf="user?.subscription?.type === 'Basic'">
|
||||||
|
<ng-container
|
||||||
|
*ngIf="hasPermissionForSubscription && hasPermissionToUpdateUserSettings"
|
||||||
|
>
|
||||||
|
<button color="primary" mat-flat-button (click)="onCheckout()">
|
||||||
|
<ng-container *ngIf="user.subscription.offer === 'default'" i18n
|
||||||
|
>Upgrade</ng-container
|
||||||
|
>
|
||||||
|
<ng-container *ngIf="user.subscription.offer === 'renewal'" i18n
|
||||||
|
>Renew</ng-container
|
||||||
|
>
|
||||||
|
</button>
|
||||||
|
<div *ngIf="price" class="mt-1">
|
||||||
|
<ng-container *ngIf="coupon"
|
||||||
|
><del class="text-muted"
|
||||||
|
>{{ baseCurrency }} {{ price }}</del
|
||||||
|
> {{ baseCurrency }} {{ price - coupon
|
||||||
|
}}</ng-container
|
||||||
|
>
|
||||||
|
<ng-container *ngIf="!coupon"
|
||||||
|
>{{ baseCurrency }} {{ price }}</ng-container
|
||||||
|
> <span i18n>per year</span>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
<a
|
||||||
|
*ngIf="!user?.subscription?.expiresAt"
|
||||||
|
class="mr-2 my-2"
|
||||||
|
mat-stroked-button
|
||||||
|
[href]="trySubscriptionMail"
|
||||||
|
><span i18n>Try Premium</span>
|
||||||
|
<gf-premium-indicator
|
||||||
|
class="d-inline-block ml-1"
|
||||||
|
[enableLink]="false"
|
||||||
|
></gf-premium-indicator
|
||||||
|
></a>
|
||||||
|
<a
|
||||||
|
*ngIf="hasPermissionToUpdateUserSettings"
|
||||||
|
class="mr-2 my-2"
|
||||||
|
i18n
|
||||||
|
mat-stroked-button
|
||||||
|
[routerLink]=""
|
||||||
|
(click)="onRedeemCoupon()"
|
||||||
|
>Redeem Coupon</a
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
@ -0,0 +1,23 @@
|
|||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { NgModule } from '@angular/core';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatCardModule } from '@angular/material/card';
|
||||||
|
import { RouterModule } from '@angular/router';
|
||||||
|
import { GfPremiumIndicatorModule } from '@ghostfolio/ui/premium-indicator';
|
||||||
|
import { GfValueModule } from '@ghostfolio/ui/value';
|
||||||
|
|
||||||
|
import { UserAccountMembershipComponent } from './user-account-membership.component';
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
declarations: [UserAccountMembershipComponent],
|
||||||
|
exports: [UserAccountMembershipComponent],
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
GfPremiumIndicatorModule,
|
||||||
|
GfValueModule,
|
||||||
|
MatButtonModule,
|
||||||
|
MatCardModule,
|
||||||
|
RouterModule
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class GfUserAccountMembershipModule {}
|
@ -0,0 +1,8 @@
|
|||||||
|
:host {
|
||||||
|
color: rgb(var(--dark-primary-text));
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host-context(.is-dark-theme) {
|
||||||
|
color: rgb(var(--light-primary-text));
|
||||||
|
}
|
@ -0,0 +1,258 @@
|
|||||||
|
import {
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
ChangeDetectorRef,
|
||||||
|
Component,
|
||||||
|
OnDestroy,
|
||||||
|
OnInit,
|
||||||
|
ViewChild
|
||||||
|
} from '@angular/core';
|
||||||
|
import { MatCheckbox, MatCheckboxChange } from '@angular/material/checkbox';
|
||||||
|
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||||
|
import {
|
||||||
|
STAY_SIGNED_IN,
|
||||||
|
SettingsStorageService
|
||||||
|
} from '@ghostfolio/client/services/settings-storage.service';
|
||||||
|
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||||
|
import { WebAuthnService } from '@ghostfolio/client/services/web-authn.service';
|
||||||
|
import { downloadAsFile } from '@ghostfolio/common/helper';
|
||||||
|
import { User } from '@ghostfolio/common/interfaces';
|
||||||
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||||
|
import { format, parseISO } from 'date-fns';
|
||||||
|
import { uniq } from 'lodash';
|
||||||
|
import { EMPTY, Subject } from 'rxjs';
|
||||||
|
import { catchError, takeUntil } from 'rxjs/operators';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
selector: 'gf-user-account-settings',
|
||||||
|
styleUrls: ['./user-account-settings.scss'],
|
||||||
|
templateUrl: './user-account-settings.html'
|
||||||
|
})
|
||||||
|
export class UserAccountSettingsComponent implements OnDestroy, OnInit {
|
||||||
|
@ViewChild('toggleSignInWithFingerprintEnabledElement')
|
||||||
|
signInWithFingerprintElement: MatCheckbox;
|
||||||
|
|
||||||
|
public appearancePlaceholder = $localize`Auto`;
|
||||||
|
public baseCurrency: string;
|
||||||
|
public currencies: string[] = [];
|
||||||
|
public hasPermissionToUpdateViewMode: boolean;
|
||||||
|
public hasPermissionToUpdateUserSettings: boolean;
|
||||||
|
public language = document.documentElement.lang;
|
||||||
|
public locales = [
|
||||||
|
'de',
|
||||||
|
'de-CH',
|
||||||
|
'en-GB',
|
||||||
|
'en-US',
|
||||||
|
'es',
|
||||||
|
'fr',
|
||||||
|
'it',
|
||||||
|
'nl',
|
||||||
|
'pt',
|
||||||
|
'tr'
|
||||||
|
];
|
||||||
|
public user: User;
|
||||||
|
|
||||||
|
private unsubscribeSubject = new Subject<void>();
|
||||||
|
|
||||||
|
public constructor(
|
||||||
|
private changeDetectorRef: ChangeDetectorRef,
|
||||||
|
private dataService: DataService,
|
||||||
|
private settingsStorageService: SettingsStorageService,
|
||||||
|
private userService: UserService,
|
||||||
|
public webAuthnService: WebAuthnService
|
||||||
|
) {
|
||||||
|
const { baseCurrency, currencies } = this.dataService.fetchInfo();
|
||||||
|
|
||||||
|
this.baseCurrency = baseCurrency;
|
||||||
|
this.currencies = currencies;
|
||||||
|
|
||||||
|
this.userService.stateChanged
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe((state) => {
|
||||||
|
if (state?.user) {
|
||||||
|
this.user = state.user;
|
||||||
|
|
||||||
|
this.hasPermissionToUpdateUserSettings = hasPermission(
|
||||||
|
this.user.permissions,
|
||||||
|
permissions.updateUserSettings
|
||||||
|
);
|
||||||
|
|
||||||
|
this.hasPermissionToUpdateViewMode = hasPermission(
|
||||||
|
this.user.permissions,
|
||||||
|
permissions.updateViewMode
|
||||||
|
);
|
||||||
|
|
||||||
|
this.locales.push(this.user.settings.locale);
|
||||||
|
this.locales = uniq(this.locales.sort());
|
||||||
|
|
||||||
|
this.changeDetectorRef.markForCheck();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public ngOnInit() {
|
||||||
|
this.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
public onChangeUserSetting(aKey: string, aValue: string) {
|
||||||
|
this.dataService
|
||||||
|
.putUserSetting({ [aKey]: aValue })
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe(() => {
|
||||||
|
this.userService.remove();
|
||||||
|
|
||||||
|
this.userService
|
||||||
|
.get()
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe((user) => {
|
||||||
|
this.user = user;
|
||||||
|
|
||||||
|
this.changeDetectorRef.markForCheck();
|
||||||
|
|
||||||
|
if (aKey === 'language') {
|
||||||
|
if (aValue) {
|
||||||
|
window.location.href = `../${aValue}/account`;
|
||||||
|
} else {
|
||||||
|
window.location.href = `../`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public onExperimentalFeaturesChange(aEvent: MatCheckboxChange) {
|
||||||
|
this.dataService
|
||||||
|
.putUserSetting({ isExperimentalFeatures: aEvent.checked })
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe(() => {
|
||||||
|
this.userService.remove();
|
||||||
|
|
||||||
|
this.userService
|
||||||
|
.get()
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe((user) => {
|
||||||
|
this.user = user;
|
||||||
|
|
||||||
|
this.changeDetectorRef.markForCheck();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public onExport() {
|
||||||
|
this.dataService
|
||||||
|
.fetchExport()
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe((data) => {
|
||||||
|
for (const activity of data.activities) {
|
||||||
|
delete activity.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadAsFile({
|
||||||
|
content: data,
|
||||||
|
fileName: `ghostfolio-export-${format(
|
||||||
|
parseISO(data.meta.date),
|
||||||
|
'yyyyMMddHHmm'
|
||||||
|
)}.json`,
|
||||||
|
format: 'json'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public onRestrictedViewChange(aEvent: MatCheckboxChange) {
|
||||||
|
this.dataService
|
||||||
|
.putUserSetting({ isRestrictedView: aEvent.checked })
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe(() => {
|
||||||
|
this.userService.remove();
|
||||||
|
|
||||||
|
this.userService
|
||||||
|
.get()
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe((user) => {
|
||||||
|
this.user = user;
|
||||||
|
|
||||||
|
this.changeDetectorRef.markForCheck();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public onSignInWithFingerprintChange(aEvent: MatCheckboxChange) {
|
||||||
|
if (aEvent.checked) {
|
||||||
|
this.registerDevice();
|
||||||
|
} else {
|
||||||
|
const confirmation = confirm(
|
||||||
|
$localize`Do you really want to remove this sign in method?`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (confirmation) {
|
||||||
|
this.deregisterDevice();
|
||||||
|
} else {
|
||||||
|
this.update();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public onViewModeChange(aEvent: MatCheckboxChange) {
|
||||||
|
this.dataService
|
||||||
|
.putUserSetting({ viewMode: aEvent.checked === true ? 'ZEN' : 'DEFAULT' })
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe(() => {
|
||||||
|
this.userService.remove();
|
||||||
|
|
||||||
|
this.userService
|
||||||
|
.get()
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe((user) => {
|
||||||
|
this.user = user;
|
||||||
|
|
||||||
|
this.changeDetectorRef.markForCheck();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public ngOnDestroy() {
|
||||||
|
this.unsubscribeSubject.next();
|
||||||
|
this.unsubscribeSubject.complete();
|
||||||
|
}
|
||||||
|
|
||||||
|
private deregisterDevice() {
|
||||||
|
this.webAuthnService
|
||||||
|
.deregister()
|
||||||
|
.pipe(
|
||||||
|
takeUntil(this.unsubscribeSubject),
|
||||||
|
catchError(() => {
|
||||||
|
this.update();
|
||||||
|
|
||||||
|
return EMPTY;
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.subscribe(() => {
|
||||||
|
this.update();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private registerDevice() {
|
||||||
|
this.webAuthnService
|
||||||
|
.register()
|
||||||
|
.pipe(
|
||||||
|
takeUntil(this.unsubscribeSubject),
|
||||||
|
catchError(() => {
|
||||||
|
this.update();
|
||||||
|
|
||||||
|
return EMPTY;
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.subscribe(() => {
|
||||||
|
this.settingsStorageService.removeSetting(STAY_SIGNED_IN);
|
||||||
|
|
||||||
|
this.update();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private update() {
|
||||||
|
if (this.signInWithFingerprintElement) {
|
||||||
|
this.signInWithFingerprintElement.checked =
|
||||||
|
this.webAuthnService.isEnabled() ?? false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,197 @@
|
|||||||
|
<div class="container">
|
||||||
|
<h1 class="d-none d-sm-block h3 mb-3 text-center" i18n>Settings</h1>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col">
|
||||||
|
<div class="align-items-center d-flex py-1">
|
||||||
|
<div class="pr-1 w-50">
|
||||||
|
<div i18n>Presenter View</div>
|
||||||
|
<div class="hint-text text-muted" i18n>
|
||||||
|
Protection for sensitive information like absolute performances and
|
||||||
|
quantity values
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="pl-1 w-50">
|
||||||
|
<mat-checkbox
|
||||||
|
color="primary"
|
||||||
|
[checked]="user.settings.isRestrictedView"
|
||||||
|
[disabled]="!hasPermissionToUpdateUserSettings"
|
||||||
|
(change)="onRestrictedViewChange($event)"
|
||||||
|
></mat-checkbox>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<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">
|
||||||
|
<ng-container i18n>Base Currency</ng-container>
|
||||||
|
</div>
|
||||||
|
<div class="pl-1 w-50">
|
||||||
|
<mat-form-field appearance="outline" class="w-100 without-hint">
|
||||||
|
<mat-select
|
||||||
|
name="baseCurrency"
|
||||||
|
[disabled]="!hasPermissionToUpdateUserSettings"
|
||||||
|
[value]="user.settings.baseCurrency"
|
||||||
|
(selectionChange)="onChangeUserSetting('baseCurrency', $event.value)"
|
||||||
|
>
|
||||||
|
<mat-option
|
||||||
|
*ngFor="let currency of currencies"
|
||||||
|
[value]="currency"
|
||||||
|
>{{ currency }}</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>Language</div>
|
||||||
|
</div>
|
||||||
|
<div class="pl-1 w-50">
|
||||||
|
<mat-form-field appearance="outline" class="w-100 without-hint">
|
||||||
|
<mat-select
|
||||||
|
name="language"
|
||||||
|
[disabled]="!hasPermissionToUpdateUserSettings"
|
||||||
|
[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-option value="es"
|
||||||
|
>Español (<ng-container i18n>Community</ng-container
|
||||||
|
>)</mat-option
|
||||||
|
>
|
||||||
|
<mat-option value="fr"
|
||||||
|
>Français (<ng-container i18n>Community</ng-container
|
||||||
|
>)</mat-option
|
||||||
|
>
|
||||||
|
<mat-option value="it"
|
||||||
|
>Italiano (<ng-container i18n>Community</ng-container
|
||||||
|
>)</mat-option
|
||||||
|
>
|
||||||
|
<mat-option value="nl"
|
||||||
|
>Nederlands (<ng-container i18n>Community</ng-container
|
||||||
|
>)</mat-option
|
||||||
|
>
|
||||||
|
<mat-option value="pt"
|
||||||
|
>Português (<ng-container i18n>Community</ng-container
|
||||||
|
>)</mat-option
|
||||||
|
>
|
||||||
|
<mat-option value="tr"
|
||||||
|
>Türkçe (<ng-container i18n>Community</ng-container
|
||||||
|
>)</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">
|
||||||
|
<ng-container i18n>Date and number format</ng-container>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="pl-1 w-50">
|
||||||
|
<mat-form-field appearance="outline" class="w-100 without-hint">
|
||||||
|
<mat-select
|
||||||
|
name="locale"
|
||||||
|
[disabled]="!hasPermissionToUpdateUserSettings"
|
||||||
|
[value]="user.settings.locale"
|
||||||
|
(selectionChange)="onChangeUserSetting('locale', $event.value)"
|
||||||
|
>
|
||||||
|
<mat-option [value]="null"></mat-option>
|
||||||
|
<mat-option *ngFor="let locale of locales" [value]="locale"
|
||||||
|
>{{ locale }}</mat-option
|
||||||
|
>
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex">
|
||||||
|
<div class="align-items-center d-flex pr-1 pt-1 w-50">
|
||||||
|
<ng-container i18n>Appearance</ng-container>
|
||||||
|
</div>
|
||||||
|
<div class="pl-1 w-50">
|
||||||
|
<mat-form-field appearance="outline" class="w-100 without-hint">
|
||||||
|
<mat-select
|
||||||
|
class="with-placeholder-as-option"
|
||||||
|
name="colorScheme"
|
||||||
|
[disabled]="!hasPermissionToUpdateUserSettings"
|
||||||
|
[placeholder]="appearancePlaceholder"
|
||||||
|
[value]="user?.settings?.colorScheme"
|
||||||
|
(selectionChange)="onChangeUserSetting('colorScheme', $event.value)"
|
||||||
|
>
|
||||||
|
<mat-option i18n [value]="null">Auto</mat-option>
|
||||||
|
<mat-option i18n value="LIGHT">Light</mat-option>
|
||||||
|
<mat-option i18n value="DARK">Dark</mat-option>
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex mt-4 py-1">
|
||||||
|
<div class="pr-1 w-50">
|
||||||
|
<div i18n>Zen Mode</div>
|
||||||
|
<div class="hint-text text-muted" i18n>
|
||||||
|
Distraction-free experience for turbulent times
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="pl-1 w-50">
|
||||||
|
<mat-checkbox
|
||||||
|
color="primary"
|
||||||
|
[checked]="user.settings.viewMode === 'ZEN'"
|
||||||
|
[disabled]="!hasPermissionToUpdateViewMode"
|
||||||
|
(change)="onViewModeChange($event)"
|
||||||
|
></mat-checkbox>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="align-items-center d-flex mt-4 py-1">
|
||||||
|
<div class="pr-1 w-50">
|
||||||
|
<div i18n>Biometric Authentication</div>
|
||||||
|
<div class="hint-text text-muted" i18n>Sign in with fingerprint</div>
|
||||||
|
</div>
|
||||||
|
<div class="pl-1 w-50">
|
||||||
|
<mat-checkbox
|
||||||
|
#toggleSignInWithFingerprintEnabledElement
|
||||||
|
color="primary"
|
||||||
|
[disabled]="!hasPermissionToUpdateUserSettings"
|
||||||
|
(change)="onSignInWithFingerprintChange($event)"
|
||||||
|
></mat-checkbox>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
*ngIf="hasPermissionToUpdateUserSettings"
|
||||||
|
class="align-items-center d-flex mt-4 py-1"
|
||||||
|
>
|
||||||
|
<div class="pr-1 w-50">
|
||||||
|
<div i18n>Experimental Features</div>
|
||||||
|
<div class="hint-text text-muted" i18n>
|
||||||
|
Sneak peek at upcoming functionality
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="pl-1 w-50">
|
||||||
|
<mat-checkbox
|
||||||
|
color="primary"
|
||||||
|
[checked]="user.settings.isExperimentalFeatures"
|
||||||
|
[disabled]="!hasPermissionToUpdateUserSettings"
|
||||||
|
(change)="onExperimentalFeaturesChange($event)"
|
||||||
|
></mat-checkbox>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="align-items-center d-flex mt-4 py-1">
|
||||||
|
<div class="pr-1 w-50" i18n>User ID</div>
|
||||||
|
<div class="pl-1 text-monospace w-50">{{ user?.id }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="align-items-center d-flex py-1">
|
||||||
|
<div class="pr-1 w-50"></div>
|
||||||
|
<div class="pl-1 text-monospace w-50">
|
||||||
|
<button color="primary" mat-flat-button (click)="onExport()">
|
||||||
|
<span i18n>Export Data</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
@ -0,0 +1,30 @@
|
|||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { NgModule } from '@angular/core';
|
||||||
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatCardModule } from '@angular/material/card';
|
||||||
|
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||||
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||||
|
import { MatSelectModule } from '@angular/material/select';
|
||||||
|
import { RouterModule } from '@angular/router';
|
||||||
|
import { GfValueModule } from '@ghostfolio/ui/value';
|
||||||
|
|
||||||
|
import { UserAccountSettingsComponent } from './user-account-settings.component';
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
declarations: [UserAccountSettingsComponent],
|
||||||
|
exports: [UserAccountSettingsComponent],
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
FormsModule,
|
||||||
|
GfValueModule,
|
||||||
|
MatButtonModule,
|
||||||
|
MatCardModule,
|
||||||
|
MatCheckboxModule,
|
||||||
|
MatFormFieldModule,
|
||||||
|
MatSelectModule,
|
||||||
|
ReactiveFormsModule,
|
||||||
|
RouterModule
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class GfUserAccountSettingsModule {}
|
@ -0,0 +1,13 @@
|
|||||||
|
:host {
|
||||||
|
color: rgb(var(--dark-primary-text));
|
||||||
|
display: block;
|
||||||
|
|
||||||
|
.hint-text {
|
||||||
|
font-size: 90%;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:host-context(.is-dark-theme) {
|
||||||
|
color: rgb(var(--light-primary-text));
|
||||||
|
}
|
@ -1,4 +1,4 @@
|
|||||||
import { Directive, HostListener, Output, EventEmitter } from '@angular/core';
|
import { Directive, EventEmitter, HostListener, Output } from '@angular/core';
|
||||||
|
|
||||||
@Directive({
|
@Directive({
|
||||||
selector: '[gfFileDrop]'
|
selector: '[gfFileDrop]'
|
||||||
|
@ -0,0 +1,14 @@
|
|||||||
|
import { Component } from '@angular/core';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { RouterModule } from '@angular/router';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
host: { class: 'page' },
|
||||||
|
imports: [MatButtonModule, RouterModule],
|
||||||
|
selector: 'gf-hacktoberfest-2023-page',
|
||||||
|
standalone: true,
|
||||||
|
templateUrl: './hacktoberfest-2023-page.html'
|
||||||
|
})
|
||||||
|
export class Hacktoberfest2023PageComponent {
|
||||||
|
public routerLinkAbout = ['/' + $localize`about`];
|
||||||
|
}
|
@ -0,0 +1,194 @@
|
|||||||
|
<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">Hacktoberfest 2023</h1>
|
||||||
|
<div class="mb-3 text-muted"><small>2023-09-26</small></div>
|
||||||
|
<img
|
||||||
|
alt="Hacktoberfest 2023 with Ghostfolio Teaser"
|
||||||
|
class="rounded w-100"
|
||||||
|
src="../assets/images/blog/hacktoberfest-2023.png"
|
||||||
|
title="Hacktoberfest 2023 with Ghostfolio"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<section class="mb-4">
|
||||||
|
<p>
|
||||||
|
At Ghostfolio, <a [routerLink]="routerLinkAbout">we</a> are very
|
||||||
|
excited to participate in
|
||||||
|
<a href="https://hacktoberfest.com">Hacktoberfest</a> for the second
|
||||||
|
time, looking forward to connecting with new and enthusiastic
|
||||||
|
open-source contributors. Hacktoberfest is a month-long celebration
|
||||||
|
of open-source projects, their maintainers, and the entire community
|
||||||
|
of contributors. Each October, open source maintainers from all over
|
||||||
|
the world give extra attention to new contributors while guiding
|
||||||
|
them through their first pull requests on
|
||||||
|
<a href="https://github.com/ghostfolio/ghostfolio">GitHub</a>. This
|
||||||
|
year the event celebrates its 10th anniversary.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
<section class="mb-4">
|
||||||
|
<h2 class="h4">About Ghostfolio</h2>
|
||||||
|
<p>
|
||||||
|
<a href="https://ghostfol.io">Ghostfolio</a> is a modern web
|
||||||
|
application for managing personal finances. The software aggregates
|
||||||
|
your assets and empowers informed decision-making to help you
|
||||||
|
balance your portfolio or plan for future investments.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Ghostfolio is written in
|
||||||
|
<a href="https://www.typescriptlang.org">TypeScript</a> and
|
||||||
|
organized as an <a href="https://nx.dev">Nx</a> workspace, utilizing
|
||||||
|
the latest framework releases. The backend is based on
|
||||||
|
<a href="https://nestjs.com">NestJS</a> in combination with
|
||||||
|
<a href="https://www.postgresql.org">PostgreSQL</a> as a database
|
||||||
|
together with <a href="https://www.prisma.io">Prisma</a> and
|
||||||
|
<a href="https://redis.io">Redis</a> for caching. The frontend is
|
||||||
|
built with <a href="https://angular.io">Angular</a>.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
The software is used daily by a thriving global community. With over
|
||||||
|
<a [routerLink]="['/open']">2’600 stars on GitHub</a> and
|
||||||
|
<a [routerLink]="['/open']">300’000+ pulls on Docker Hub</a>,
|
||||||
|
Ghostfolio has gained widespread recognition for its user-friendly
|
||||||
|
experience and simplicity.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
<section class="mb-4">
|
||||||
|
<h2 class="h4">How to contribute?</h2>
|
||||||
|
<p>
|
||||||
|
Each contribution can make a meaningful impact. Whether it involves
|
||||||
|
implementing new features, resolving bugs, refactoring code,
|
||||||
|
enhancing documentation, adding unit tests, or translating content
|
||||||
|
into another language, you can actively shape our project.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Are you not yet familiar with our code base? That is not a problem.
|
||||||
|
We have applied the label <code>hacktoberfest</code> to a few
|
||||||
|
<a
|
||||||
|
href="https://github.com/ghostfolio/ghostfolio/issues?q=is%3Aissue+is%3Aopen+label%3Ahacktoberfest"
|
||||||
|
>issues</a
|
||||||
|
>
|
||||||
|
that are well suited for newcomers.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
The official Hacktoberfest website provides some valuable
|
||||||
|
<a
|
||||||
|
href="https://hacktoberfest.com/participation/#beginner-resources"
|
||||||
|
>resources for beginners</a
|
||||||
|
>
|
||||||
|
to start contributing in open source.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
<section class="mb-4">
|
||||||
|
<h2 class="h4">Get support</h2>
|
||||||
|
<p>
|
||||||
|
If you have further questions or ideas, please join our
|
||||||
|
<a
|
||||||
|
href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"
|
||||||
|
>Slack</a
|
||||||
|
>
|
||||||
|
community or get in touch on X
|
||||||
|
<a href="https://twitter.com/ghostfolio_">@ghostfolio_</a>.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
We 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">Angular</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">Docker</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">Ghostfolio</span>
|
||||||
|
</li>
|
||||||
|
<li class="list-inline-item">
|
||||||
|
<span class="badge badge-light">GitHub</span>
|
||||||
|
</li>
|
||||||
|
<li class="list-inline-item">
|
||||||
|
<span class="badge badge-light">Hacktoberfest</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">NestJS</span>
|
||||||
|
</li>
|
||||||
|
<li class="list-inline-item">
|
||||||
|
<span class="badge badge-light">Nx</span>
|
||||||
|
</li>
|
||||||
|
<li class="list-inline-item">
|
||||||
|
<span class="badge badge-light">October</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">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">Prisma</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">TypeScript</span>
|
||||||
|
</li>
|
||||||
|
<li class="list-inline-item">
|
||||||
|
<span class="badge badge-light">UX</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>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
<nav aria-label="breadcrumb">
|
||||||
|
<ol class="breadcrumb">
|
||||||
|
<li class="breadcrumb-item">
|
||||||
|
<a i18n [routerLink]="['/blog']">Blog</a>
|
||||||
|
</li>
|
||||||
|
<li
|
||||||
|
aria-current="page"
|
||||||
|
class="active breadcrumb-item text-truncate"
|
||||||
|
>
|
||||||
|
Hacktoberfest 2023
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
@ -154,6 +154,15 @@ const routes: Routes = [
|
|||||||
(c) => c.Ghostfolio2PageComponent
|
(c) => c.Ghostfolio2PageComponent
|
||||||
),
|
),
|
||||||
title: 'Ghostfolio 2.0'
|
title: 'Ghostfolio 2.0'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
canActivate: [AuthGuard],
|
||||||
|
path: '2023/09/hacktoberfest-2023',
|
||||||
|
loadComponent: () =>
|
||||||
|
import(
|
||||||
|
'./2023/09/hacktoberfest-2023/hacktoberfest-2023-page.component'
|
||||||
|
).then((c) => c.Hacktoberfest2023PageComponent),
|
||||||
|
title: 'Hacktoberfest 2023'
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
@ -8,6 +8,30 @@
|
|||||||
finance</small
|
finance</small
|
||||||
>
|
>
|
||||||
</h1>
|
</h1>
|
||||||
|
<mat-card appearance="outlined" class="mb-3">
|
||||||
|
<mat-card-content>
|
||||||
|
<div class="container p-0">
|
||||||
|
<div class="flex-nowrap no-gutters row">
|
||||||
|
<a
|
||||||
|
class="d-flex overflow-hidden w-100"
|
||||||
|
href="../en/blog/2023/09/hacktoberfest-2023"
|
||||||
|
>
|
||||||
|
<div class="flex-grow-1 overflow-hidden">
|
||||||
|
<div class="h6 m-0 text-truncate">Hacktoberfest 2023</div>
|
||||||
|
<div class="d-flex text-muted">2023-09-26</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 appearance="outlined" class="mb-3">
|
<mat-card appearance="outlined" class="mb-3">
|
||||||
<mat-card-content>
|
<mat-card-content>
|
||||||
<div class="container p-0">
|
<div class="container p-0">
|
||||||
|
@ -15,6 +15,7 @@ export class FeaturesPageComponent implements OnDestroy {
|
|||||||
public hasPermissionForSubscription: boolean;
|
public hasPermissionForSubscription: boolean;
|
||||||
public info: InfoItem;
|
public info: InfoItem;
|
||||||
public routerLinkRegister = ['/' + $localize`register`];
|
public routerLinkRegister = ['/' + $localize`register`];
|
||||||
|
public routerLinkResources = ['/' + $localize`resources`];
|
||||||
public user: User;
|
public user: User;
|
||||||
|
|
||||||
private unsubscribeSubject = new Subject<void>();
|
private unsubscribeSubject = new Subject<void>();
|
||||||
|
@ -80,8 +80,6 @@ export class ActivitiesPageComponent implements OnDestroy, OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public ngOnInit() {
|
public ngOnInit() {
|
||||||
const { globalPermissions } = this.dataService.fetchInfo();
|
|
||||||
|
|
||||||
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
|
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
|
||||||
|
|
||||||
this.impersonationStorageService
|
this.impersonationStorageService
|
||||||
|
@ -18,6 +18,7 @@ export class FirePageComponent implements OnDestroy, OnInit {
|
|||||||
public accountClusterRiskRules: PortfolioReportRule[];
|
public accountClusterRiskRules: PortfolioReportRule[];
|
||||||
public currencyClusterRiskRules: PortfolioReportRule[];
|
public currencyClusterRiskRules: PortfolioReportRule[];
|
||||||
public deviceType: string;
|
public deviceType: string;
|
||||||
|
public emergencyFundRules: PortfolioReportRule[];
|
||||||
public feeRules: PortfolioReportRule[];
|
public feeRules: PortfolioReportRule[];
|
||||||
public fireWealth: Big;
|
public fireWealth: Big;
|
||||||
public hasImpersonationId: boolean;
|
public hasImpersonationId: boolean;
|
||||||
@ -67,6 +68,8 @@ export class FirePageComponent implements OnDestroy, OnInit {
|
|||||||
portfolioReport.rules['accountClusterRisk'] || null;
|
portfolioReport.rules['accountClusterRisk'] || null;
|
||||||
this.currencyClusterRiskRules =
|
this.currencyClusterRiskRules =
|
||||||
portfolioReport.rules['currencyClusterRisk'] || null;
|
portfolioReport.rules['currencyClusterRisk'] || null;
|
||||||
|
this.emergencyFundRules =
|
||||||
|
portfolioReport.rules['emergencyFund'] || null;
|
||||||
this.feeRules = portfolioReport.rules['fees'] || null;
|
this.feeRules = portfolioReport.rules['fees'] || null;
|
||||||
|
|
||||||
this.changeDetectorRef.markForCheck();
|
this.changeDetectorRef.markForCheck();
|
||||||
|
@ -96,8 +96,10 @@
|
|||||||
<div class="col">
|
<div class="col">
|
||||||
<h2 class="h3 mb-3 text-center">X-ray</h2>
|
<h2 class="h3 mb-3 text-center">X-ray</h2>
|
||||||
<p class="mb-4">
|
<p class="mb-4">
|
||||||
Ghostfolio X-ray uses static analysis to identify potential issues and
|
<span i18n
|
||||||
risks in your portfolio.
|
>Ghostfolio X-ray uses static analysis to identify potential issues
|
||||||
|
and risks in your portfolio.</span
|
||||||
|
>
|
||||||
<span class="d-none"
|
<span class="d-none"
|
||||||
>It will be highly configurable in the future: activate / deactivate
|
>It will be highly configurable in the future: activate / deactivate
|
||||||
rules and customize the thresholds to match your personal investment
|
rules and customize the thresholds to match your personal investment
|
||||||
@ -106,7 +108,20 @@
|
|||||||
</p>
|
</p>
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<h4 class="align-items-center d-flex m-0">
|
<h4 class="align-items-center d-flex m-0">
|
||||||
<span>Currency Cluster Risks</span
|
<span i18n>Emergency Fund</span
|
||||||
|
><gf-premium-indicator
|
||||||
|
*ngIf="user?.subscription?.type === 'Basic'"
|
||||||
|
class="ml-1"
|
||||||
|
></gf-premium-indicator>
|
||||||
|
</h4>
|
||||||
|
<gf-rules
|
||||||
|
[hasPermissionToCreateOrder]="hasPermissionToCreateOrder"
|
||||||
|
[rules]="emergencyFundRules"
|
||||||
|
></gf-rules>
|
||||||
|
</div>
|
||||||
|
<div class="mb-4">
|
||||||
|
<h4 class="align-items-center d-flex m-0">
|
||||||
|
<span i18n>Currency Cluster Risks</span
|
||||||
><gf-premium-indicator
|
><gf-premium-indicator
|
||||||
*ngIf="user?.subscription?.type === 'Basic'"
|
*ngIf="user?.subscription?.type === 'Basic'"
|
||||||
class="ml-1"
|
class="ml-1"
|
||||||
@ -119,7 +134,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<h4 class="align-items-center d-flex m-0">
|
<h4 class="align-items-center d-flex m-0">
|
||||||
<span>Account Cluster Risks</span
|
<span i18n>Account Cluster Risks</span
|
||||||
><gf-premium-indicator
|
><gf-premium-indicator
|
||||||
*ngIf="user?.subscription?.type === 'Basic'"
|
*ngIf="user?.subscription?.type === 'Basic'"
|
||||||
class="ml-1"
|
class="ml-1"
|
||||||
@ -132,7 +147,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h4 class="align-items-center d-flex m-0">
|
<h4 class="align-items-center d-flex m-0">
|
||||||
<span>Fees</span
|
<span i18n>Fees</span
|
||||||
><gf-premium-indicator
|
><gf-premium-indicator
|
||||||
*ngIf="user?.subscription?.type === 'Basic'"
|
*ngIf="user?.subscription?.type === 'Basic'"
|
||||||
class="ml-1"
|
class="ml-1"
|
||||||
|
@ -6,6 +6,7 @@ import { CopilotMoneyPageComponent } from './products/copilot-money-page.compone
|
|||||||
import { DeltaPageComponent } from './products/delta-page.component';
|
import { DeltaPageComponent } from './products/delta-page.component';
|
||||||
import { DivvyDiaryPageComponent } from './products/divvydiary-page.component';
|
import { DivvyDiaryPageComponent } from './products/divvydiary-page.component';
|
||||||
import { ExirioPageComponent } from './products/exirio-page.component';
|
import { ExirioPageComponent } from './products/exirio-page.component';
|
||||||
|
import { FinaryPageComponent } from './products/finary-page.component';
|
||||||
import { FolisharePageComponent } from './products/folishare-page.component';
|
import { FolisharePageComponent } from './products/folishare-page.component';
|
||||||
import { GetquinPageComponent } from './products/getquin-page.component';
|
import { GetquinPageComponent } from './products/getquin-page.component';
|
||||||
import { GoSpatzPageComponent } from './products/gospatz-page.component';
|
import { GoSpatzPageComponent } from './products/gospatz-page.component';
|
||||||
@ -23,6 +24,7 @@ import { SeekingAlphaPageComponent } from './products/seeking-alpha-page.compone
|
|||||||
import { SharesightPageComponent } from './products/sharesight-page.component';
|
import { SharesightPageComponent } from './products/sharesight-page.component';
|
||||||
import { SimplePortfolioPageComponent } from './products/simple-portfolio-page.component';
|
import { SimplePortfolioPageComponent } from './products/simple-portfolio-page.component';
|
||||||
import { SnowballAnalyticsPageComponent } from './products/snowball-analytics-page.component';
|
import { SnowballAnalyticsPageComponent } from './products/snowball-analytics-page.component';
|
||||||
|
import { StocklePageComponent } from './products/stockle-page.component';
|
||||||
import { StockMarketEyePageComponent } from './products/stockmarketeye-page.component';
|
import { StockMarketEyePageComponent } from './products/stockmarketeye-page.component';
|
||||||
import { SumioPageComponent } from './products/sumio-page.component';
|
import { SumioPageComponent } from './products/sumio-page.component';
|
||||||
import { UtlunaPageComponent } from './products/utluna-page.component';
|
import { UtlunaPageComponent } from './products/utluna-page.component';
|
||||||
@ -115,6 +117,15 @@ export const products: Product[] = [
|
|||||||
pricingPerYear: '$100',
|
pricingPerYear: '$100',
|
||||||
slogan: 'All your wealth, in one place.'
|
slogan: 'All your wealth, in one place.'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
component: FinaryPageComponent,
|
||||||
|
founded: 2020,
|
||||||
|
key: 'finary',
|
||||||
|
languages: ['Deutsch', 'English', 'Français'],
|
||||||
|
name: 'Finary',
|
||||||
|
origin: $localize`United States`,
|
||||||
|
slogan: 'Real-Time Portfolio Tracker & Stock Tracker'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
component: FolisharePageComponent,
|
component: FolisharePageComponent,
|
||||||
hasFreePlan: true,
|
hasFreePlan: true,
|
||||||
@ -304,6 +315,13 @@ export const products: Product[] = [
|
|||||||
pricingPerYear: '$80',
|
pricingPerYear: '$80',
|
||||||
slogan: 'Simple and powerful portfolio tracker'
|
slogan: 'Simple and powerful portfolio tracker'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
component: StocklePageComponent,
|
||||||
|
key: 'stockle',
|
||||||
|
name: 'Stockle',
|
||||||
|
origin: $localize`Finland`,
|
||||||
|
slogan: 'Supercharge your investments tracking experience'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
component: StockMarketEyePageComponent,
|
component: StockMarketEyePageComponent,
|
||||||
founded: 2008,
|
founded: 2008,
|
||||||
|
@ -0,0 +1,31 @@
|
|||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { Component } from '@angular/core';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { RouterModule } from '@angular/router';
|
||||||
|
|
||||||
|
import { products } from '../products';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
host: { class: 'page' },
|
||||||
|
imports: [CommonModule, MatButtonModule, RouterModule],
|
||||||
|
selector: 'gf-finary-page',
|
||||||
|
standalone: true,
|
||||||
|
styleUrls: ['../product-page-template.scss'],
|
||||||
|
templateUrl: '../product-page-template.html'
|
||||||
|
})
|
||||||
|
export class FinaryPageComponent {
|
||||||
|
public product1 = products.find(({ key }) => {
|
||||||
|
return key === 'ghostfolio';
|
||||||
|
});
|
||||||
|
|
||||||
|
public product2 = products.find(({ key }) => {
|
||||||
|
return key === 'finary';
|
||||||
|
});
|
||||||
|
|
||||||
|
public routerLinkAbout = ['/' + $localize`about`];
|
||||||
|
public routerLinkFeatures = ['/' + $localize`features`];
|
||||||
|
public routerLinkResourcesPersonalFinanceTools = [
|
||||||
|
'/' + $localize`resources`,
|
||||||
|
'personal-finance-tools'
|
||||||
|
];
|
||||||
|
}
|
@ -0,0 +1,31 @@
|
|||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { Component } from '@angular/core';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { RouterModule } from '@angular/router';
|
||||||
|
|
||||||
|
import { products } from '../products';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
host: { class: 'page' },
|
||||||
|
imports: [CommonModule, MatButtonModule, RouterModule],
|
||||||
|
selector: 'gf-stockle-page',
|
||||||
|
standalone: true,
|
||||||
|
styleUrls: ['../product-page-template.scss'],
|
||||||
|
templateUrl: '../product-page-template.html'
|
||||||
|
})
|
||||||
|
export class StocklePageComponent {
|
||||||
|
public product1 = products.find(({ key }) => {
|
||||||
|
return key === 'ghostfolio';
|
||||||
|
});
|
||||||
|
|
||||||
|
public product2 = products.find(({ key }) => {
|
||||||
|
return key === 'stockle';
|
||||||
|
});
|
||||||
|
|
||||||
|
public routerLinkAbout = ['/' + $localize`about`];
|
||||||
|
public routerLinkFeatures = ['/' + $localize`features`];
|
||||||
|
public routerLinkResourcesPersonalFinanceTools = [
|
||||||
|
'/' + $localize`resources`,
|
||||||
|
'personal-finance-tools'
|
||||||
|
];
|
||||||
|
}
|
@ -1,5 +1,8 @@
|
|||||||
import { NgModule } from '@angular/core';
|
import { NgModule } from '@angular/core';
|
||||||
import { RouterModule, Routes } from '@angular/router';
|
import { RouterModule, Routes } from '@angular/router';
|
||||||
|
import { UserAccountAccessComponent } from '@ghostfolio/client/components/user-account-access/user-account-access.component';
|
||||||
|
import { UserAccountMembershipComponent } from '@ghostfolio/client/components/user-account-membership/user-account-membership.component';
|
||||||
|
import { UserAccountSettingsComponent } from '@ghostfolio/client/components/user-account-settings/user-account-settings.component';
|
||||||
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
|
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
|
||||||
|
|
||||||
import { UserAccountPageComponent } from './user-account-page.component';
|
import { UserAccountPageComponent } from './user-account-page.component';
|
||||||
@ -7,6 +10,23 @@ import { UserAccountPageComponent } from './user-account-page.component';
|
|||||||
const routes: Routes = [
|
const routes: Routes = [
|
||||||
{
|
{
|
||||||
canActivate: [AuthGuard],
|
canActivate: [AuthGuard],
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
component: UserAccountSettingsComponent,
|
||||||
|
title: $localize`Settings`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'membership',
|
||||||
|
component: UserAccountMembershipComponent,
|
||||||
|
title: $localize`Membership`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'access',
|
||||||
|
component: UserAccountAccessComponent,
|
||||||
|
title: $localize`Access`
|
||||||
|
}
|
||||||
|
],
|
||||||
component: UserAccountPageComponent,
|
component: UserAccountPageComponent,
|
||||||
path: '',
|
path: '',
|
||||||
title: $localize`My Ghostfolio`
|
title: $localize`My Ghostfolio`
|
||||||
|
@ -1,448 +1,63 @@
|
|||||||
import {
|
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
|
||||||
ChangeDetectorRef,
|
|
||||||
Component,
|
|
||||||
OnDestroy,
|
|
||||||
OnInit,
|
|
||||||
ViewChild
|
|
||||||
} from '@angular/core';
|
|
||||||
import { MatCheckbox, MatCheckboxChange } from '@angular/material/checkbox';
|
|
||||||
import { MatDialog } from '@angular/material/dialog';
|
|
||||||
import {
|
|
||||||
MatSnackBar,
|
|
||||||
MatSnackBarRef,
|
|
||||||
TextOnlySnackBar
|
|
||||||
} from '@angular/material/snack-bar';
|
|
||||||
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 {
|
|
||||||
STAY_SIGNED_IN,
|
|
||||||
SettingsStorageService
|
|
||||||
} from '@ghostfolio/client/services/settings-storage.service';
|
|
||||||
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||||
import { WebAuthnService } from '@ghostfolio/client/services/web-authn.service';
|
import { TabConfiguration, User } from '@ghostfolio/common/interfaces';
|
||||||
import { downloadAsFile, getDateFormatString } from '@ghostfolio/common/helper';
|
|
||||||
import { Access, User } from '@ghostfolio/common/interfaces';
|
|
||||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
|
||||||
import { format, parseISO } from 'date-fns';
|
|
||||||
import { uniq } from 'lodash';
|
|
||||||
import { DeviceDetectorService } from 'ngx-device-detector';
|
import { DeviceDetectorService } from 'ngx-device-detector';
|
||||||
import { StripeService } from 'ngx-stripe';
|
import { Subject, takeUntil } from 'rxjs';
|
||||||
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({
|
@Component({
|
||||||
host: { class: 'page' },
|
host: { class: 'page has-tabs' },
|
||||||
selector: 'gf-user-account-page',
|
selector: 'gf-user-account-page',
|
||||||
styleUrls: ['./user-account-page.scss'],
|
styleUrls: ['./user-account-page.scss'],
|
||||||
templateUrl: './user-account-page.html'
|
templateUrl: './user-account-page.html'
|
||||||
})
|
})
|
||||||
export class UserAccountPageComponent implements OnDestroy, OnInit {
|
export class UserAccountPageComponent implements OnDestroy, OnInit {
|
||||||
@ViewChild('toggleSignInWithFingerprintEnabledElement')
|
|
||||||
signInWithFingerprintElement: MatCheckbox;
|
|
||||||
|
|
||||||
public accesses: Access[];
|
|
||||||
public appearancePlaceholder = $localize`Auto`;
|
|
||||||
public baseCurrency: string;
|
|
||||||
public coupon: number;
|
|
||||||
public couponId: string;
|
|
||||||
public currencies: string[] = [];
|
|
||||||
public defaultDateFormat: string;
|
|
||||||
public deviceType: string;
|
public deviceType: string;
|
||||||
public hasPermissionForSubscription: boolean;
|
public tabs: TabConfiguration[] = [];
|
||||||
public hasPermissionToCreateAccess: boolean;
|
|
||||||
public hasPermissionToDeleteAccess: boolean;
|
|
||||||
public hasPermissionToUpdateViewMode: boolean;
|
|
||||||
public hasPermissionToUpdateUserSettings: boolean;
|
|
||||||
public language = document.documentElement.lang;
|
|
||||||
public locales = [
|
|
||||||
'de',
|
|
||||||
'de-CH',
|
|
||||||
'en-GB',
|
|
||||||
'en-US',
|
|
||||||
'es',
|
|
||||||
'fr',
|
|
||||||
'it',
|
|
||||||
'nl',
|
|
||||||
'pt',
|
|
||||||
'tr'
|
|
||||||
];
|
|
||||||
public price: number;
|
|
||||||
public priceId: string;
|
|
||||||
public snackBarRef: MatSnackBarRef<TextOnlySnackBar>;
|
|
||||||
public trySubscriptionMail =
|
|
||||||
'mailto:hi@ghostfol.io?Subject=Ghostfolio Premium Trial&body=Hello%0D%0DI am interested in Ghostfolio Premium. Can you please send me a coupon code to try it for some time?%0D%0DKind regards';
|
|
||||||
public user: User;
|
public user: User;
|
||||||
|
|
||||||
private unsubscribeSubject = new Subject<void>();
|
private unsubscribeSubject = new Subject<void>();
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private changeDetectorRef: ChangeDetectorRef,
|
private changeDetectorRef: ChangeDetectorRef,
|
||||||
private dataService: DataService,
|
|
||||||
private deviceService: DeviceDetectorService,
|
private deviceService: DeviceDetectorService,
|
||||||
private dialog: MatDialog,
|
private userService: UserService
|
||||||
private snackBar: MatSnackBar,
|
|
||||||
private route: ActivatedRoute,
|
|
||||||
private router: Router,
|
|
||||||
private settingsStorageService: SettingsStorageService,
|
|
||||||
private stripeService: StripeService,
|
|
||||||
private userService: UserService,
|
|
||||||
public webAuthnService: WebAuthnService
|
|
||||||
) {
|
) {
|
||||||
const { baseCurrency, currencies, globalPermissions, subscriptions } =
|
|
||||||
this.dataService.fetchInfo();
|
|
||||||
|
|
||||||
this.baseCurrency = baseCurrency;
|
|
||||||
this.currencies = currencies;
|
|
||||||
|
|
||||||
this.hasPermissionForSubscription = hasPermission(
|
|
||||||
globalPermissions,
|
|
||||||
permissions.enableSubscription
|
|
||||||
);
|
|
||||||
|
|
||||||
this.hasPermissionToDeleteAccess = hasPermission(
|
|
||||||
globalPermissions,
|
|
||||||
permissions.deleteAccess
|
|
||||||
);
|
|
||||||
|
|
||||||
this.userService.stateChanged
|
this.userService.stateChanged
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
.subscribe((state) => {
|
.subscribe((state) => {
|
||||||
if (state?.user) {
|
if (state?.user) {
|
||||||
this.user = state.user;
|
this.user = state.user;
|
||||||
|
|
||||||
this.defaultDateFormat = getDateFormatString(
|
this.tabs = [
|
||||||
this.user.settings.locale
|
{
|
||||||
);
|
iconName: 'cog-outline',
|
||||||
|
label: $localize`Settings`,
|
||||||
this.hasPermissionToCreateAccess = hasPermission(
|
path: ['/account']
|
||||||
this.user.permissions,
|
},
|
||||||
permissions.createAccess
|
{
|
||||||
);
|
iconName: 'diamond-outline',
|
||||||
|
label: $localize`Membership`,
|
||||||
this.hasPermissionToDeleteAccess = hasPermission(
|
path: ['/account/membership'],
|
||||||
this.user.permissions,
|
showCondition: !!this.user?.subscription
|
||||||
permissions.deleteAccess
|
},
|
||||||
);
|
{
|
||||||
|
iconName: 'share-social-outline',
|
||||||
this.hasPermissionToUpdateUserSettings = hasPermission(
|
label: $localize`Access`,
|
||||||
this.user.permissions,
|
path: ['/account', 'access']
|
||||||
permissions.updateUserSettings
|
}
|
||||||
);
|
];
|
||||||
|
|
||||||
this.hasPermissionToUpdateViewMode = hasPermission(
|
|
||||||
this.user.permissions,
|
|
||||||
permissions.updateViewMode
|
|
||||||
);
|
|
||||||
|
|
||||||
this.locales.push(this.user.settings.locale);
|
|
||||||
this.locales = uniq(this.locales.sort());
|
|
||||||
|
|
||||||
this.coupon = subscriptions?.[this.user.subscription.offer]?.coupon;
|
|
||||||
this.couponId =
|
|
||||||
subscriptions?.[this.user.subscription.offer]?.couponId;
|
|
||||||
this.price = subscriptions?.[this.user.subscription.offer]?.price;
|
|
||||||
this.priceId = subscriptions?.[this.user.subscription.offer]?.priceId;
|
|
||||||
|
|
||||||
this.changeDetectorRef.markForCheck();
|
this.changeDetectorRef.markForCheck();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
this.route.queryParams
|
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
|
||||||
.subscribe((params) => {
|
|
||||||
if (params['createDialog']) {
|
|
||||||
this.openCreateAccessDialog();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public ngOnInit() {
|
public ngOnInit() {
|
||||||
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
|
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
|
||||||
|
|
||||||
this.update();
|
|
||||||
}
|
|
||||||
|
|
||||||
public onChangeUserSetting(aKey: string, aValue: string) {
|
|
||||||
this.dataService
|
|
||||||
.putUserSetting({ [aKey]: aValue })
|
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
|
||||||
.subscribe(() => {
|
|
||||||
this.userService.remove();
|
|
||||||
|
|
||||||
this.userService
|
|
||||||
.get()
|
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
|
||||||
.subscribe((user) => {
|
|
||||||
this.user = user;
|
|
||||||
|
|
||||||
this.changeDetectorRef.markForCheck();
|
|
||||||
|
|
||||||
if (aKey === 'language') {
|
|
||||||
if (aValue) {
|
|
||||||
window.location.href = `../${aValue}/account`;
|
|
||||||
} else {
|
|
||||||
window.location.href = `../`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public onCheckout() {
|
|
||||||
this.dataService
|
|
||||||
.createCheckoutSession({ couponId: this.couponId, priceId: this.priceId })
|
|
||||||
.pipe(
|
|
||||||
switchMap(({ sessionId }: { sessionId: string }) => {
|
|
||||||
return this.stripeService.redirectToCheckout({ sessionId });
|
|
||||||
}),
|
|
||||||
catchError((error) => {
|
|
||||||
alert(error.message);
|
|
||||||
throw error;
|
|
||||||
})
|
|
||||||
)
|
|
||||||
.subscribe((result) => {
|
|
||||||
if (result.error) {
|
|
||||||
alert(result.error.message);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public onDeleteAccess(aId: string) {
|
|
||||||
this.dataService
|
|
||||||
.deleteAccess(aId)
|
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
|
||||||
.subscribe({
|
|
||||||
next: () => {
|
|
||||||
this.update();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public onExperimentalFeaturesChange(aEvent: MatCheckboxChange) {
|
|
||||||
this.dataService
|
|
||||||
.putUserSetting({ isExperimentalFeatures: aEvent.checked })
|
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
|
||||||
.subscribe(() => {
|
|
||||||
this.userService.remove();
|
|
||||||
|
|
||||||
this.userService
|
|
||||||
.get()
|
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
|
||||||
.subscribe((user) => {
|
|
||||||
this.user = user;
|
|
||||||
|
|
||||||
this.changeDetectorRef.markForCheck();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public onExport() {
|
|
||||||
this.dataService
|
|
||||||
.fetchExport()
|
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
|
||||||
.subscribe((data) => {
|
|
||||||
for (const activity of data.activities) {
|
|
||||||
delete activity.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
downloadAsFile({
|
|
||||||
content: data,
|
|
||||||
fileName: `ghostfolio-export-${format(
|
|
||||||
parseISO(data.meta.date),
|
|
||||||
'yyyyMMddHHmm'
|
|
||||||
)}.json`,
|
|
||||||
format: 'json'
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public onRedeemCoupon() {
|
|
||||||
let couponCode = prompt($localize`Please enter your coupon code:`);
|
|
||||||
couponCode = couponCode?.trim();
|
|
||||||
|
|
||||||
if (couponCode) {
|
|
||||||
this.dataService
|
|
||||||
.redeemCoupon(couponCode)
|
|
||||||
.pipe(
|
|
||||||
takeUntil(this.unsubscribeSubject),
|
|
||||||
catchError(() => {
|
|
||||||
this.snackBar.open(
|
|
||||||
'😞 ' + $localize`Could not redeem coupon code`,
|
|
||||||
undefined,
|
|
||||||
{
|
|
||||||
duration: 3000
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
return EMPTY;
|
|
||||||
})
|
|
||||||
)
|
|
||||||
.subscribe(() => {
|
|
||||||
this.snackBarRef = this.snackBar.open(
|
|
||||||
'✅ ' + $localize`Coupon code has been redeemed`,
|
|
||||||
$localize`Reload`,
|
|
||||||
{
|
|
||||||
duration: 3000
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
this.snackBarRef
|
|
||||||
.afterDismissed()
|
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
|
||||||
.subscribe(() => {
|
|
||||||
window.location.reload();
|
|
||||||
});
|
|
||||||
|
|
||||||
this.snackBarRef
|
|
||||||
.onAction()
|
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
|
||||||
.subscribe(() => {
|
|
||||||
window.location.reload();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public onRestrictedViewChange(aEvent: MatCheckboxChange) {
|
|
||||||
this.dataService
|
|
||||||
.putUserSetting({ isRestrictedView: aEvent.checked })
|
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
|
||||||
.subscribe(() => {
|
|
||||||
this.userService.remove();
|
|
||||||
|
|
||||||
this.userService
|
|
||||||
.get()
|
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
|
||||||
.subscribe((user) => {
|
|
||||||
this.user = user;
|
|
||||||
|
|
||||||
this.changeDetectorRef.markForCheck();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public onSignInWithFingerprintChange(aEvent: MatCheckboxChange) {
|
|
||||||
if (aEvent.checked) {
|
|
||||||
this.registerDevice();
|
|
||||||
} else {
|
|
||||||
const confirmation = confirm(
|
|
||||||
$localize`Do you really want to remove this sign in method?`
|
|
||||||
);
|
|
||||||
|
|
||||||
if (confirmation) {
|
|
||||||
this.deregisterDevice();
|
|
||||||
} else {
|
|
||||||
this.update();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public onViewModeChange(aEvent: MatCheckboxChange) {
|
|
||||||
this.dataService
|
|
||||||
.putUserSetting({ viewMode: aEvent.checked === true ? 'ZEN' : 'DEFAULT' })
|
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
|
||||||
.subscribe(() => {
|
|
||||||
this.userService.remove();
|
|
||||||
|
|
||||||
this.userService
|
|
||||||
.get()
|
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
|
||||||
.subscribe((user) => {
|
|
||||||
this.user = user;
|
|
||||||
|
|
||||||
this.changeDetectorRef.markForCheck();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public ngOnDestroy() {
|
public ngOnDestroy() {
|
||||||
this.unsubscribeSubject.next();
|
this.unsubscribeSubject.next();
|
||||||
this.unsubscribeSubject.complete();
|
this.unsubscribeSubject.complete();
|
||||||
}
|
}
|
||||||
|
|
||||||
private openCreateAccessDialog(): void {
|
|
||||||
const dialogRef = this.dialog.open(CreateOrUpdateAccessDialog, {
|
|
||||||
data: {
|
|
||||||
access: {
|
|
||||||
alias: '',
|
|
||||||
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({ alias: access.alias })
|
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
|
||||||
.subscribe({
|
|
||||||
next: () => {
|
|
||||||
this.update();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
this.router.navigate(['.'], { relativeTo: this.route });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private deregisterDevice() {
|
|
||||||
this.webAuthnService
|
|
||||||
.deregister()
|
|
||||||
.pipe(
|
|
||||||
takeUntil(this.unsubscribeSubject),
|
|
||||||
catchError(() => {
|
|
||||||
this.update();
|
|
||||||
|
|
||||||
return EMPTY;
|
|
||||||
})
|
|
||||||
)
|
|
||||||
.subscribe(() => {
|
|
||||||
this.update();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private registerDevice() {
|
|
||||||
this.webAuthnService
|
|
||||||
.register()
|
|
||||||
.pipe(
|
|
||||||
takeUntil(this.unsubscribeSubject),
|
|
||||||
catchError(() => {
|
|
||||||
this.update();
|
|
||||||
|
|
||||||
return EMPTY;
|
|
||||||
})
|
|
||||||
)
|
|
||||||
.subscribe(() => {
|
|
||||||
this.settingsStorageService.removeSetting(STAY_SIGNED_IN);
|
|
||||||
|
|
||||||
this.update();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private update() {
|
|
||||||
this.dataService
|
|
||||||
.fetchAccesses()
|
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
|
||||||
.subscribe((response) => {
|
|
||||||
this.accesses = response;
|
|
||||||
|
|
||||||
if (this.signInWithFingerprintElement) {
|
|
||||||
this.signInWithFingerprintElement.checked =
|
|
||||||
this.webAuthnService.isEnabled() ?? false;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.changeDetectorRef.markForCheck();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -1,309 +1,29 @@
|
|||||||
<div class="container">
|
<mat-tab-nav-panel #tabPanel class="flex-grow-1 overflow-auto">
|
||||||
<div class="row">
|
<router-outlet></router-outlet>
|
||||||
<div class="col">
|
</mat-tab-nav-panel>
|
||||||
<h2 class="h3 mb-3 text-center" i18n>Account</h2>
|
|
||||||
</div>
|
<nav
|
||||||
</div>
|
mat-align-tabs="center"
|
||||||
<div *ngIf="user?.settings" class="mb-5 row">
|
mat-tab-nav-bar
|
||||||
<div class="col">
|
[disablePagination]="true"
|
||||||
<mat-card appearance="outlined" class="mb-3">
|
[tabPanel]="tabPanel"
|
||||||
<mat-card-content>
|
>
|
||||||
<div *ngIf="user?.subscription" class="d-flex py-1">
|
<ng-container *ngFor="let tab of tabs">
|
||||||
<div class="pr-1 w-50" i18n>Membership</div>
|
<a
|
||||||
<div class="pl-1 w-50">
|
#rla="routerLinkActive"
|
||||||
<div class="align-items-center d-flex mb-1">
|
*ngIf="tab.showCondition !== false"
|
||||||
<a [routerLink]="routerLinkPricing"
|
class="px-3"
|
||||||
>{{ user?.subscription?.type }}</a
|
mat-tab-link
|
||||||
|
routerLinkActive
|
||||||
|
[active]="rla.isActive"
|
||||||
|
[routerLink]="tab.path"
|
||||||
|
[routerLinkActiveOptions]="{ exact: true }"
|
||||||
>
|
>
|
||||||
<gf-premium-indicator
|
<ion-icon
|
||||||
*ngIf="user?.subscription?.type === 'Premium'"
|
[name]="tab.iconName"
|
||||||
class="ml-1"
|
[size]="deviceType === 'mobile' ? 'large': 'small'"
|
||||||
></gf-premium-indicator>
|
></ion-icon>
|
||||||
</div>
|
<div class="d-none d-sm-block ml-2">{{ tab.label }}</div>
|
||||||
<div *ngIf="user?.subscription?.type === 'Premium'">
|
</a>
|
||||||
<ng-container i18n>Valid until</ng-container> {{
|
|
||||||
user?.subscription?.expiresAt | date: defaultDateFormat }}
|
|
||||||
</div>
|
|
||||||
<div *ngIf="user?.subscription?.type === 'Basic'">
|
|
||||||
<ng-container
|
|
||||||
*ngIf="hasPermissionForSubscription && hasPermissionToUpdateUserSettings"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
color="primary"
|
|
||||||
mat-flat-button
|
|
||||||
(click)="onCheckout()"
|
|
||||||
>
|
|
||||||
<ng-container
|
|
||||||
*ngIf="user.subscription.offer === 'default'"
|
|
||||||
i18n
|
|
||||||
>Upgrade</ng-container
|
|
||||||
>
|
|
||||||
<ng-container
|
|
||||||
*ngIf="user.subscription.offer === 'renewal'"
|
|
||||||
i18n
|
|
||||||
>Renew</ng-container
|
|
||||||
>
|
|
||||||
</button>
|
|
||||||
<div *ngIf="price" class="mt-1">
|
|
||||||
<ng-container *ngIf="coupon"
|
|
||||||
><del class="text-muted"
|
|
||||||
>{{ baseCurrency }} {{ price }}</del
|
|
||||||
> {{ baseCurrency }} {{ price - coupon
|
|
||||||
}}</ng-container
|
|
||||||
>
|
|
||||||
<ng-container *ngIf="!coupon"
|
|
||||||
>{{ baseCurrency }} {{ price }}</ng-container
|
|
||||||
> <span i18n>per year</span>
|
|
||||||
</div>
|
|
||||||
</ng-container>
|
</ng-container>
|
||||||
<a
|
</nav>
|
||||||
*ngIf="!user?.subscription?.expiresAt"
|
|
||||||
class="mr-2 my-2"
|
|
||||||
mat-stroked-button
|
|
||||||
[href]="trySubscriptionMail"
|
|
||||||
><span i18n>Try Premium</span>
|
|
||||||
<gf-premium-indicator
|
|
||||||
class="d-inline-block ml-1"
|
|
||||||
[enableLink]="false"
|
|
||||||
></gf-premium-indicator
|
|
||||||
></a>
|
|
||||||
<a
|
|
||||||
*ngIf="hasPermissionToUpdateUserSettings"
|
|
||||||
class="mr-2 my-2"
|
|
||||||
i18n
|
|
||||||
mat-stroked-button
|
|
||||||
[routerLink]=""
|
|
||||||
(click)="onRedeemCoupon()"
|
|
||||||
>Redeem Coupon</a
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="align-items-center d-flex mt-4 py-1">
|
|
||||||
<div class="pr-1 w-50">
|
|
||||||
<div i18n>Presenter View</div>
|
|
||||||
<div class="hint-text text-muted" i18n>
|
|
||||||
Protection for sensitive information like absolute performances
|
|
||||||
and quantity values
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="pl-1 w-50">
|
|
||||||
<mat-checkbox
|
|
||||||
color="primary"
|
|
||||||
[checked]="user.settings.isRestrictedView"
|
|
||||||
[disabled]="!hasPermissionToUpdateUserSettings"
|
|
||||||
(change)="onRestrictedViewChange($event)"
|
|
||||||
></mat-checkbox>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<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">
|
|
||||||
<ng-container i18n>Base Currency</ng-container>
|
|
||||||
</div>
|
|
||||||
<div class="pl-1 w-50">
|
|
||||||
<mat-form-field
|
|
||||||
appearance="outline"
|
|
||||||
class="w-100 without-hint"
|
|
||||||
>
|
|
||||||
<mat-select
|
|
||||||
name="baseCurrency"
|
|
||||||
[disabled]="!hasPermissionToUpdateUserSettings"
|
|
||||||
[value]="user.settings.baseCurrency"
|
|
||||||
(selectionChange)="onChangeUserSetting('baseCurrency', $event.value)"
|
|
||||||
>
|
|
||||||
<mat-option
|
|
||||||
*ngFor="let currency of currencies"
|
|
||||||
[value]="currency"
|
|
||||||
>{{ currency }}</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>Language</div>
|
|
||||||
</div>
|
|
||||||
<div class="pl-1 w-50">
|
|
||||||
<mat-form-field
|
|
||||||
appearance="outline"
|
|
||||||
class="w-100 without-hint"
|
|
||||||
>
|
|
||||||
<mat-select
|
|
||||||
name="language"
|
|
||||||
[disabled]="!hasPermissionToUpdateUserSettings"
|
|
||||||
[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-option value="es"
|
|
||||||
>Español (<ng-container i18n>Community</ng-container
|
|
||||||
>)</mat-option
|
|
||||||
>
|
|
||||||
<mat-option value="fr"
|
|
||||||
>Français (<ng-container i18n>Community</ng-container
|
|
||||||
>)</mat-option
|
|
||||||
>
|
|
||||||
<mat-option value="it"
|
|
||||||
>Italiano (<ng-container i18n>Community</ng-container
|
|
||||||
>)</mat-option
|
|
||||||
>
|
|
||||||
<mat-option value="nl"
|
|
||||||
>Nederlands (<ng-container i18n>Community</ng-container
|
|
||||||
>)</mat-option
|
|
||||||
>
|
|
||||||
<mat-option value="pt"
|
|
||||||
>Português (<ng-container i18n>Community</ng-container
|
|
||||||
>)</mat-option
|
|
||||||
>
|
|
||||||
<mat-option value="tr"
|
|
||||||
>Türkçe (<ng-container i18n>Community</ng-container
|
|
||||||
>)</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">
|
|
||||||
<ng-container i18n>Date and number format</ng-container>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="pl-1 w-50">
|
|
||||||
<mat-form-field
|
|
||||||
appearance="outline"
|
|
||||||
class="w-100 without-hint"
|
|
||||||
>
|
|
||||||
<mat-select
|
|
||||||
name="locale"
|
|
||||||
[disabled]="!hasPermissionToUpdateUserSettings"
|
|
||||||
[value]="user.settings.locale"
|
|
||||||
(selectionChange)="onChangeUserSetting('locale', $event.value)"
|
|
||||||
>
|
|
||||||
<mat-option [value]="null"></mat-option>
|
|
||||||
<mat-option
|
|
||||||
*ngFor="let locale of locales"
|
|
||||||
[value]="locale"
|
|
||||||
>{{ locale }}</mat-option
|
|
||||||
>
|
|
||||||
</mat-select>
|
|
||||||
</mat-form-field>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="d-flex">
|
|
||||||
<div class="align-items-center d-flex pr-1 pt-1 w-50">
|
|
||||||
<ng-container i18n>Appearance</ng-container>
|
|
||||||
</div>
|
|
||||||
<div class="pl-1 w-50">
|
|
||||||
<mat-form-field
|
|
||||||
appearance="outline"
|
|
||||||
class="w-100 without-hint"
|
|
||||||
>
|
|
||||||
<mat-select
|
|
||||||
class="with-placeholder-as-option"
|
|
||||||
name="colorScheme"
|
|
||||||
[disabled]="!hasPermissionToUpdateUserSettings"
|
|
||||||
[placeholder]="appearancePlaceholder"
|
|
||||||
[value]="user?.settings?.colorScheme"
|
|
||||||
(selectionChange)="onChangeUserSetting('colorScheme', $event.value)"
|
|
||||||
>
|
|
||||||
<mat-option i18n [value]="null">Auto</mat-option>
|
|
||||||
<mat-option i18n value="LIGHT">Light</mat-option>
|
|
||||||
<mat-option i18n value="DARK">Dark</mat-option>
|
|
||||||
</mat-select>
|
|
||||||
</mat-form-field>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
<div class="d-flex mt-4 py-1">
|
|
||||||
<div class="pr-1 w-50">
|
|
||||||
<div i18n>Zen Mode</div>
|
|
||||||
<div class="hint-text text-muted" i18n>
|
|
||||||
Distraction-free experience for turbulent times
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="pl-1 w-50">
|
|
||||||
<mat-checkbox
|
|
||||||
color="primary"
|
|
||||||
[checked]="user.settings.viewMode === 'ZEN'"
|
|
||||||
[disabled]="!hasPermissionToUpdateViewMode"
|
|
||||||
(change)="onViewModeChange($event)"
|
|
||||||
></mat-checkbox>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="align-items-center d-flex mt-4 py-1">
|
|
||||||
<div class="pr-1 w-50">
|
|
||||||
<div i18n>Biometric Authentication</div>
|
|
||||||
<div class="hint-text text-muted" i18n>
|
|
||||||
Sign in with fingerprint
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="pl-1 w-50">
|
|
||||||
<mat-checkbox
|
|
||||||
#toggleSignInWithFingerprintEnabledElement
|
|
||||||
color="primary"
|
|
||||||
[disabled]="!hasPermissionToUpdateUserSettings"
|
|
||||||
(change)="onSignInWithFingerprintChange($event)"
|
|
||||||
></mat-checkbox>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
*ngIf="hasPermissionToUpdateUserSettings"
|
|
||||||
class="align-items-center d-flex mt-4 py-1"
|
|
||||||
>
|
|
||||||
<div class="pr-1 w-50">
|
|
||||||
<div i18n>Experimental Features</div>
|
|
||||||
<div class="hint-text text-muted" i18n>
|
|
||||||
Sneak peek at upcoming functionality
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="pl-1 w-50">
|
|
||||||
<mat-checkbox
|
|
||||||
color="primary"
|
|
||||||
[checked]="user.settings.isExperimentalFeatures"
|
|
||||||
[disabled]="!hasPermissionToUpdateUserSettings"
|
|
||||||
(change)="onExperimentalFeaturesChange($event)"
|
|
||||||
></mat-checkbox>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="align-items-center d-flex mt-4 py-1">
|
|
||||||
<div class="pr-1 w-50" i18n>User ID</div>
|
|
||||||
<div class="pl-1 text-monospace w-50">{{ user?.id }}</div>
|
|
||||||
</div>
|
|
||||||
<div class="align-items-center d-flex py-1">
|
|
||||||
<div class="pr-1 w-50"></div>
|
|
||||||
<div class="pl-1 text-monospace w-50">
|
|
||||||
<button color="primary" mat-flat-button (click)="onExport()">
|
|
||||||
<span i18n>Export Data</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</mat-card-content>
|
|
||||||
</mat-card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="row">
|
|
||||||
<div class="col">
|
|
||||||
<h2 class="align-items-center d-flex h3 justify-content-center mb-3">
|
|
||||||
<span i18n>Granted Access</span>
|
|
||||||
<gf-premium-indicator
|
|
||||||
*ngIf="user?.subscription?.type === 'Basic'"
|
|
||||||
class="ml-1"
|
|
||||||
></gf-premium-indicator>
|
|
||||||
</h2>
|
|
||||||
<gf-access-table
|
|
||||||
[accesses]="accesses"
|
|
||||||
[hasPermissionToCreateAccess]="hasPermissionToCreateAccess"
|
|
||||||
[showActions]="hasPermissionToDeleteAccess"
|
|
||||||
(accessDeleted)="onDeleteAccess($event)"
|
|
||||||
></gf-access-table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
@ -1,18 +1,10 @@
|
|||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { NgModule } from '@angular/core';
|
import { NgModule } from '@angular/core';
|
||||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
import { MatTabsModule } from '@angular/material/tabs';
|
||||||
import { MatButtonModule } from '@angular/material/button';
|
import { GfUserAccountAccessModule } from '@ghostfolio/client/components/user-account-access/user-account-access.module';
|
||||||
import { MatCardModule } from '@angular/material/card';
|
import { GfUserAccountMembershipModule } from '@ghostfolio/client/components/user-account-membership/user-account-membership.module';
|
||||||
import { MatCheckboxModule } from '@angular/material/checkbox';
|
import { GfUserAccountSettingsModule } from '@ghostfolio/client/components/user-account-settings/user-account-settings.module';
|
||||||
import { MatDialogModule } from '@angular/material/dialog';
|
|
||||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
|
||||||
import { MatSelectModule } from '@angular/material/select';
|
|
||||||
import { RouterModule } from '@angular/router';
|
|
||||||
import { GfPortfolioAccessTableModule } from '@ghostfolio/client/components/access-table/access-table.module';
|
|
||||||
import { GfPremiumIndicatorModule } from '@ghostfolio/ui/premium-indicator';
|
|
||||||
import { GfValueModule } from '@ghostfolio/ui/value';
|
|
||||||
|
|
||||||
import { GfCreateOrUpdateAccessDialogModule } from './create-or-update-access-dialog/create-or-update-access-dialog.module';
|
|
||||||
import { UserAccountPageRoutingModule } from './user-account-page-routing.module';
|
import { UserAccountPageRoutingModule } from './user-account-page-routing.module';
|
||||||
import { UserAccountPageComponent } from './user-account-page.component';
|
import { UserAccountPageComponent } from './user-account-page.component';
|
||||||
|
|
||||||
@ -20,19 +12,10 @@ import { UserAccountPageComponent } from './user-account-page.component';
|
|||||||
declarations: [UserAccountPageComponent],
|
declarations: [UserAccountPageComponent],
|
||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
FormsModule,
|
GfUserAccountAccessModule,
|
||||||
GfCreateOrUpdateAccessDialogModule,
|
GfUserAccountMembershipModule,
|
||||||
GfPortfolioAccessTableModule,
|
GfUserAccountSettingsModule,
|
||||||
GfPremiumIndicatorModule,
|
MatTabsModule,
|
||||||
GfValueModule,
|
|
||||||
MatButtonModule,
|
|
||||||
MatCardModule,
|
|
||||||
MatCheckboxModule,
|
|
||||||
MatDialogModule,
|
|
||||||
MatFormFieldModule,
|
|
||||||
MatSelectModule,
|
|
||||||
ReactiveFormsModule,
|
|
||||||
RouterModule,
|
|
||||||
UserAccountPageRoutingModule
|
UserAccountPageRoutingModule
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
@ -1,15 +1,7 @@
|
|||||||
|
@import 'apps/client/src/styles/ghostfolio-style';
|
||||||
|
|
||||||
:host {
|
:host {
|
||||||
color: rgb(var(--dark-primary-text));
|
color: rgb(var(--dark-primary-text));
|
||||||
display: block;
|
|
||||||
|
|
||||||
gf-access-table {
|
|
||||||
overflow-x: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hint-text {
|
|
||||||
font-size: 90%;
|
|
||||||
line-height: 1.2;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
:host-context(.is-dark-theme) {
|
:host-context(.is-dark-theme) {
|
||||||
|
@ -4,6 +4,8 @@ import { UpdateAssetProfileDto } from '@ghostfolio/api/app/admin/update-asset-pr
|
|||||||
import { UpdateMarketDataDto } from '@ghostfolio/api/app/admin/update-market-data.dto';
|
import { UpdateMarketDataDto } from '@ghostfolio/api/app/admin/update-market-data.dto';
|
||||||
import { CreatePlatformDto } from '@ghostfolio/api/app/platform/create-platform.dto';
|
import { CreatePlatformDto } from '@ghostfolio/api/app/platform/create-platform.dto';
|
||||||
import { UpdatePlatformDto } from '@ghostfolio/api/app/platform/update-platform.dto';
|
import { UpdatePlatformDto } from '@ghostfolio/api/app/platform/update-platform.dto';
|
||||||
|
import { CreateTagDto } from '@ghostfolio/api/app/tag/create-tag.dto';
|
||||||
|
import { UpdateTagDto } from '@ghostfolio/api/app/tag/update-tag.dto';
|
||||||
import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces';
|
import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||||
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
||||||
import {
|
import {
|
||||||
@ -15,7 +17,7 @@ import {
|
|||||||
Filter,
|
Filter,
|
||||||
UniqueAsset
|
UniqueAsset
|
||||||
} from '@ghostfolio/common/interfaces';
|
} from '@ghostfolio/common/interfaces';
|
||||||
import { DataSource, MarketData, Platform, Prisma } from '@prisma/client';
|
import { DataSource, MarketData, Platform, Prisma, Tag } from '@prisma/client';
|
||||||
import { JobStatus } from 'bull';
|
import { JobStatus } from 'bull';
|
||||||
import { format, parseISO } from 'date-fns';
|
import { format, parseISO } from 'date-fns';
|
||||||
import { Observable, map } from 'rxjs';
|
import { Observable, map } from 'rxjs';
|
||||||
@ -64,6 +66,10 @@ export class AdminService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public deleteTag(aId: string) {
|
||||||
|
return this.http.delete<void>(`/api/v1/tag/${aId}`);
|
||||||
|
}
|
||||||
|
|
||||||
public fetchAdminData() {
|
public fetchAdminData() {
|
||||||
return this.http.get<AdminData>('/api/v1/admin');
|
return this.http.get<AdminData>('/api/v1/admin');
|
||||||
}
|
}
|
||||||
@ -139,6 +145,10 @@ export class AdminService {
|
|||||||
return this.http.get<Platform[]>('/api/v1/platform');
|
return this.http.get<Platform[]>('/api/v1/platform');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public fetchTags() {
|
||||||
|
return this.http.get<Tag[]>('/api/v1/tag');
|
||||||
|
}
|
||||||
|
|
||||||
public gather7Days() {
|
public gather7Days() {
|
||||||
return this.http.post<void>('/api/v1/admin/gather', {});
|
return this.http.post<void>('/api/v1/admin/gather', {});
|
||||||
}
|
}
|
||||||
@ -208,6 +218,10 @@ export class AdminService {
|
|||||||
return this.http.post<Platform>(`/api/v1/platform`, aPlatform);
|
return this.http.post<Platform>(`/api/v1/platform`, aPlatform);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public postTag(aTag: CreateTagDto) {
|
||||||
|
return this.http.post<Tag>(`/api/v1/tag`, aTag);
|
||||||
|
}
|
||||||
|
|
||||||
public putMarketData({
|
public putMarketData({
|
||||||
dataSource,
|
dataSource,
|
||||||
date,
|
date,
|
||||||
@ -233,4 +247,8 @@ export class AdminService {
|
|||||||
aPlatform
|
aPlatform
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public putTag(aTag: UpdateTagDto) {
|
||||||
|
return this.http.put<Tag>(`/api/v1/tag/${aTag.id}`, aTag);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
BIN
apps/client/src/assets/fonts/Inter-Black.woff
Normal file
BIN
apps/client/src/assets/fonts/Inter-Black.woff
Normal file
Binary file not shown.
BIN
apps/client/src/assets/fonts/Inter-Black.woff2
Normal file
BIN
apps/client/src/assets/fonts/Inter-Black.woff2
Normal file
Binary file not shown.
BIN
apps/client/src/assets/fonts/Inter-BlackItalic.woff
Normal file
BIN
apps/client/src/assets/fonts/Inter-BlackItalic.woff
Normal file
Binary file not shown.
BIN
apps/client/src/assets/fonts/Inter-BlackItalic.woff2
Normal file
BIN
apps/client/src/assets/fonts/Inter-BlackItalic.woff2
Normal file
Binary file not shown.
BIN
apps/client/src/assets/fonts/Inter-Bold.woff
Normal file
BIN
apps/client/src/assets/fonts/Inter-Bold.woff
Normal file
Binary file not shown.
BIN
apps/client/src/assets/fonts/Inter-Bold.woff2
Normal file
BIN
apps/client/src/assets/fonts/Inter-Bold.woff2
Normal file
Binary file not shown.
BIN
apps/client/src/assets/fonts/Inter-BoldItalic.woff
Normal file
BIN
apps/client/src/assets/fonts/Inter-BoldItalic.woff
Normal file
Binary file not shown.
BIN
apps/client/src/assets/fonts/Inter-BoldItalic.woff2
Normal file
BIN
apps/client/src/assets/fonts/Inter-BoldItalic.woff2
Normal file
Binary file not shown.
BIN
apps/client/src/assets/fonts/Inter-ExtraBold.woff
Normal file
BIN
apps/client/src/assets/fonts/Inter-ExtraBold.woff
Normal file
Binary file not shown.
BIN
apps/client/src/assets/fonts/Inter-ExtraBold.woff2
Normal file
BIN
apps/client/src/assets/fonts/Inter-ExtraBold.woff2
Normal file
Binary file not shown.
BIN
apps/client/src/assets/fonts/Inter-ExtraBoldItalic.woff
Normal file
BIN
apps/client/src/assets/fonts/Inter-ExtraBoldItalic.woff
Normal file
Binary file not shown.
BIN
apps/client/src/assets/fonts/Inter-ExtraBoldItalic.woff2
Normal file
BIN
apps/client/src/assets/fonts/Inter-ExtraBoldItalic.woff2
Normal file
Binary file not shown.
BIN
apps/client/src/assets/fonts/Inter-ExtraLight.woff
Normal file
BIN
apps/client/src/assets/fonts/Inter-ExtraLight.woff
Normal file
Binary file not shown.
BIN
apps/client/src/assets/fonts/Inter-ExtraLight.woff2
Normal file
BIN
apps/client/src/assets/fonts/Inter-ExtraLight.woff2
Normal file
Binary file not shown.
BIN
apps/client/src/assets/fonts/Inter-ExtraLightItalic.woff
Normal file
BIN
apps/client/src/assets/fonts/Inter-ExtraLightItalic.woff
Normal file
Binary file not shown.
BIN
apps/client/src/assets/fonts/Inter-ExtraLightItalic.woff2
Normal file
BIN
apps/client/src/assets/fonts/Inter-ExtraLightItalic.woff2
Normal file
Binary file not shown.
BIN
apps/client/src/assets/fonts/Inter-Italic.woff
Normal file
BIN
apps/client/src/assets/fonts/Inter-Italic.woff
Normal file
Binary file not shown.
BIN
apps/client/src/assets/fonts/Inter-Italic.woff2
Normal file
BIN
apps/client/src/assets/fonts/Inter-Italic.woff2
Normal file
Binary file not shown.
BIN
apps/client/src/assets/fonts/Inter-Light.woff
Normal file
BIN
apps/client/src/assets/fonts/Inter-Light.woff
Normal file
Binary file not shown.
BIN
apps/client/src/assets/fonts/Inter-Light.woff2
Normal file
BIN
apps/client/src/assets/fonts/Inter-Light.woff2
Normal file
Binary file not shown.
BIN
apps/client/src/assets/fonts/Inter-LightItalic.woff
Normal file
BIN
apps/client/src/assets/fonts/Inter-LightItalic.woff
Normal file
Binary file not shown.
BIN
apps/client/src/assets/fonts/Inter-LightItalic.woff2
Normal file
BIN
apps/client/src/assets/fonts/Inter-LightItalic.woff2
Normal file
Binary file not shown.
BIN
apps/client/src/assets/fonts/Inter-Medium.woff
Normal file
BIN
apps/client/src/assets/fonts/Inter-Medium.woff
Normal file
Binary file not shown.
BIN
apps/client/src/assets/fonts/Inter-Medium.woff2
Normal file
BIN
apps/client/src/assets/fonts/Inter-Medium.woff2
Normal file
Binary file not shown.
BIN
apps/client/src/assets/fonts/Inter-MediumItalic.woff
Normal file
BIN
apps/client/src/assets/fonts/Inter-MediumItalic.woff
Normal file
Binary file not shown.
BIN
apps/client/src/assets/fonts/Inter-MediumItalic.woff2
Normal file
BIN
apps/client/src/assets/fonts/Inter-MediumItalic.woff2
Normal file
Binary file not shown.
BIN
apps/client/src/assets/fonts/Inter-Regular.woff
Normal file
BIN
apps/client/src/assets/fonts/Inter-Regular.woff
Normal file
Binary file not shown.
BIN
apps/client/src/assets/fonts/Inter-Regular.woff2
Normal file
BIN
apps/client/src/assets/fonts/Inter-Regular.woff2
Normal file
Binary file not shown.
BIN
apps/client/src/assets/fonts/Inter-SemiBold.woff
Normal file
BIN
apps/client/src/assets/fonts/Inter-SemiBold.woff
Normal file
Binary file not shown.
BIN
apps/client/src/assets/fonts/Inter-SemiBold.woff2
Normal file
BIN
apps/client/src/assets/fonts/Inter-SemiBold.woff2
Normal file
Binary file not shown.
BIN
apps/client/src/assets/fonts/Inter-SemiBoldItalic.woff
Normal file
BIN
apps/client/src/assets/fonts/Inter-SemiBoldItalic.woff
Normal file
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user