diff --git a/CHANGELOG.md b/CHANGELOG.md index d28e8145e..f1d8ca3b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,11 +5,22 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## Unreleased +## 3.29.0 - 2026-07-18 + +### Added + +- Added support for the _Fear & Greed Index_ (market mood) via the `GHOSTFOLIO` data provider in self-hosted environments +- Added a _Storybook_ story for the copy-to-clipboard functionality in the value component ### Changed +- Improved the copy-to-clipboard functionality in the value component by providing a visual confirmation - Improved the language localization for German (`de`) +- Upgraded `stripe` from version `22.2.3` to `22.3.2` + +### Fixed + +- Fixed an issue with the delete button in the tags selector component ## 3.28.0 - 2026-07-17 diff --git a/apps/api/src/app/subscription/subscription.service.ts b/apps/api/src/app/subscription/subscription.service.ts index 07c2e7dbb..83aee7c8e 100644 --- a/apps/api/src/app/subscription/subscription.service.ts +++ b/apps/api/src/app/subscription/subscription.service.ts @@ -38,7 +38,7 @@ export class SubscriptionService { this.stripe = new Stripe( this.configurationService.get('STRIPE_SECRET_KEY'), { - apiVersion: '2026-05-27.dahlia' + apiVersion: '2026-06-24.dahlia' } ); } diff --git a/apps/api/src/app/symbol/symbol.service.ts b/apps/api/src/app/symbol/symbol.service.ts index 2ace4feeb..98869797e 100644 --- a/apps/api/src/app/symbol/symbol.service.ts +++ b/apps/api/src/app/symbol/symbol.service.ts @@ -133,6 +133,12 @@ export class SymbolService { }: { includeHistoricalData: number; }): Promise { + if (await this.dataProviderService.isDataProviderGhostfolioConfigured()) { + return this.dataProviderService.getMarketDataOfMarkets({ + includeHistoricalData + }); + } + const [ marketDataFearAndGreedIndexCryptocurrencies, marketDataFearAndGreedIndexStocks diff --git a/apps/api/src/app/user/user.service.ts b/apps/api/src/app/user/user.service.ts index 4dcf16034..6304c81ea 100644 --- a/apps/api/src/app/user/user.service.ts +++ b/apps/api/src/app/user/user.service.ts @@ -29,6 +29,7 @@ import { DEFAULT_DATE_RANGE, DEFAULT_LANGUAGE_CODE, DEFAULT_LOCALE, + PROPERTY_API_KEY_GHOSTFOLIO, PROPERTY_IS_READ_ONLY_MODE, PROPERTY_REFERRAL_PARTNERS, PROPERTY_SYSTEM_MESSAGE, @@ -546,6 +547,12 @@ export class UserService { if (hasRole(user, Role.ADMIN)) { currentPermissions.push(permissions.syncDemoUserAccount); } + } else { + if ( + await this.propertyService.getByKey(PROPERTY_API_KEY_GHOSTFOLIO) + ) { + currentPermissions.push(permissions.readMarketDataOfMarkets); + } } if (this.configurationService.get('ENABLE_FEATURE_READ_ONLY_MODE')) { diff --git a/apps/api/src/services/data-provider/data-provider.service.ts b/apps/api/src/services/data-provider/data-provider.service.ts index 09bf1108e..35dc97d2f 100644 --- a/apps/api/src/services/data-provider/data-provider.service.ts +++ b/apps/api/src/services/data-provider/data-provider.service.ts @@ -27,7 +27,8 @@ import { DataProviderHistoricalResponse, DataProviderResponse, LookupItem, - LookupResponse + LookupResponse, + MarketDataOfMarketsResponse } from '@ghostfolio/common/interfaces'; import type { Granularity, UserWithSettings } from '@ghostfolio/common/types'; @@ -194,11 +195,7 @@ export class DataProviderService implements OnModuleInit { return DataSource[dataSource]; }); - const ghostfolioApiKey = await this.propertyService.getByKey( - PROPERTY_API_KEY_GHOSTFOLIO - ); - - if (ghostfolioApiKey) { + if (await this.isDataProviderGhostfolioConfigured()) { dataSources.push('GHOSTFOLIO'); } @@ -551,6 +548,22 @@ export class DataProviderService implements OnModuleInit { return result; } + public async getMarketDataOfMarkets({ + includeHistoricalData + }: { + includeHistoricalData: number; + }): Promise { + const dataProvider = this.getDataProvider(DataSource.GHOSTFOLIO); + + if (!dataProvider.getMarketDataOfMarkets) { + throw new Error( + `The data provider (${DataSource.GHOSTFOLIO}) does not support the market data of markets` + ); + } + + return dataProvider.getMarketDataOfMarkets({ includeHistoricalData }); + } + public async getQuotes({ items, requestTimeout, @@ -807,6 +820,12 @@ export class DataProviderService implements OnModuleInit { return response; } + public async isDataProviderGhostfolioConfigured(): Promise { + return !!(await this.propertyService.getByKey( + PROPERTY_API_KEY_GHOSTFOLIO + )); + } + public async search({ includeIndices = false, query, diff --git a/apps/api/src/services/data-provider/ghostfolio/ghostfolio.service.ts b/apps/api/src/services/data-provider/ghostfolio/ghostfolio.service.ts index 2b91855a6..81b997037 100644 --- a/apps/api/src/services/data-provider/ghostfolio/ghostfolio.service.ts +++ b/apps/api/src/services/data-provider/ghostfolio/ghostfolio.service.ts @@ -5,6 +5,7 @@ import { GetAssetProfileParams, GetDividendsParams, GetHistoricalParams, + GetMarketDataOfMarketsParams, GetQuotesParams, GetSearchParams } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface'; @@ -23,7 +24,9 @@ import { DividendsResponse, HistoricalResponse, LookupResponse, - QuotesResponse + MarketDataOfMarketsResponse, + QuotesResponse, + SymbolItem } from '@ghostfolio/common/interfaces'; import { Injectable, Logger } from '@nestjs/common'; @@ -224,6 +227,63 @@ export class GhostfolioService implements DataProviderInterface { } } + public async getMarketDataOfMarkets({ + includeHistoricalData = 0, + requestTimeout = this.configurationService.get('REQUEST_TIMEOUT') + }: GetMarketDataOfMarketsParams): Promise { + let marketDataOfMarkets: MarketDataOfMarketsResponse = { + fearAndGreedIndex: { + CRYPTOCURRENCIES: {} as SymbolItem, + STOCKS: {} as SymbolItem + } + }; + + try { + const queryParams = new URLSearchParams({ + includeHistoricalData: includeHistoricalData.toString() + }); + + const response = await this.fetchService.fetch( + `${this.URL}/v1/data-providers/ghostfolio/markets?${queryParams.toString()}`, + { + headers: await this.getRequestHeaders(), + signal: AbortSignal.timeout(requestTimeout) + } + ); + + if (!response.ok) { + throw new Response(await response.text(), { + status: response.status, + statusText: response.statusText + }); + } + + marketDataOfMarkets = + (await response.json()) as MarketDataOfMarketsResponse; + } catch (error) { + let message = error; + + if (['AbortError', 'TimeoutError'].includes(error?.name)) { + message = `RequestError: The operation to get the market data of markets was aborted because the request to the data provider took more than ${( + requestTimeout / 1000 + ).toFixed(3)} seconds`; + } else if (error?.status === StatusCodes.TOO_MANY_REQUESTS) { + message = 'RequestError: The daily request limit has been exceeded'; + } else if ( + [StatusCodes.FORBIDDEN, StatusCodes.UNAUTHORIZED].includes( + error?.status + ) + ) { + message = + 'RequestError: The API key is invalid. Please update it in the Settings section of the Admin Control panel.'; + } + + this.logger.error(message); + } + + return marketDataOfMarkets; + } + public getMaxNumberOfSymbolsPerRequest() { return 20; } diff --git a/apps/api/src/services/data-provider/interfaces/data-provider.interface.ts b/apps/api/src/services/data-provider/interfaces/data-provider.interface.ts index 5002fa87e..8c2fb64d8 100644 --- a/apps/api/src/services/data-provider/interfaces/data-provider.interface.ts +++ b/apps/api/src/services/data-provider/interfaces/data-provider.interface.ts @@ -2,7 +2,8 @@ import { DataProviderHistoricalResponse, DataProviderInfo, DataProviderResponse, - LookupResponse + LookupResponse, + MarketDataOfMarketsResponse } from '@ghostfolio/common/interfaces'; import { Granularity } from '@ghostfolio/common/types'; @@ -37,6 +38,11 @@ export interface DataProviderInterface { [symbol: string]: { [date: string]: DataProviderHistoricalResponse }; }>; // TODO: Return only one symbol + getMarketDataOfMarkets?({ + includeHistoricalData, + requestTimeout + }: GetMarketDataOfMarketsParams): Promise; + getMaxNumberOfSymbolsPerRequest?(): number; getName(): DataSource; @@ -72,6 +78,11 @@ export interface GetHistoricalParams { to: Date; } +export interface GetMarketDataOfMarketsParams { + includeHistoricalData?: number; + requestTimeout?: number; +} + export interface GetQuotesParams { requestTimeout?: number; symbols: string[]; diff --git a/apps/api/src/services/queues/statistics-gathering/interfaces/interfaces.ts b/apps/api/src/services/queues/statistics-gathering/interfaces/interfaces.ts new file mode 100644 index 000000000..3c316720f --- /dev/null +++ b/apps/api/src/services/queues/statistics-gathering/interfaces/interfaces.ts @@ -0,0 +1,7 @@ +export interface BetterStackUptimeSlaResponse { + data: { + attributes: { + availability: number; + }; + }; +} diff --git a/apps/api/src/services/queues/statistics-gathering/statistics-gathering.processor.ts b/apps/api/src/services/queues/statistics-gathering/statistics-gathering.processor.ts index 82f362d25..21d009805 100644 --- a/apps/api/src/services/queues/statistics-gathering/statistics-gathering.processor.ts +++ b/apps/api/src/services/queues/statistics-gathering/statistics-gathering.processor.ts @@ -25,6 +25,14 @@ import { Injectable, Logger } from '@nestjs/common'; import * as cheerio from 'cheerio'; import { format, subDays } from 'date-fns'; +import { BetterStackUptimeSlaResponse } from './interfaces/interfaces'; + +const GATHER_STATISTICS_CONCURRENCY = parseInt( + process.env.PROCESSOR_GATHER_STATISTICS_CONCURRENCY ?? + DEFAULT_PROCESSOR_GATHER_STATISTICS_CONCURRENCY.toString(), + 10 +); + @Injectable() @Processor(STATISTICS_GATHERING_QUEUE) export class StatisticsGatheringProcessor { @@ -37,11 +45,7 @@ export class StatisticsGatheringProcessor { ) {} @Process({ - concurrency: parseInt( - process.env.PROCESSOR_GATHER_STATISTICS_CONCURRENCY ?? - DEFAULT_PROCESSOR_GATHER_STATISTICS_CONCURRENCY.toString(), - 10 - ), + concurrency: GATHER_STATISTICS_CONCURRENCY, name: GATHER_STATISTICS_DOCKER_HUB_PULLS_PROCESS_JOB_NAME }) public async gatherDockerHubPullsStatistics() { @@ -58,11 +62,7 @@ export class StatisticsGatheringProcessor { } @Process({ - concurrency: parseInt( - process.env.PROCESSOR_GATHER_STATISTICS_CONCURRENCY ?? - DEFAULT_PROCESSOR_GATHER_STATISTICS_CONCURRENCY.toString(), - 10 - ), + concurrency: GATHER_STATISTICS_CONCURRENCY, name: GATHER_STATISTICS_GITHUB_CONTRIBUTORS_PROCESS_JOB_NAME }) public async gatherGitHubContributorsStatistics() { @@ -83,11 +83,7 @@ export class StatisticsGatheringProcessor { } @Process({ - concurrency: parseInt( - process.env.PROCESSOR_GATHER_STATISTICS_CONCURRENCY ?? - DEFAULT_PROCESSOR_GATHER_STATISTICS_CONCURRENCY.toString(), - 10 - ), + concurrency: GATHER_STATISTICS_CONCURRENCY, name: GATHER_STATISTICS_GITHUB_STARGAZERS_PROCESS_JOB_NAME }) public async gatherGitHubStargazersStatistics() { @@ -106,11 +102,7 @@ export class StatisticsGatheringProcessor { } @Process({ - concurrency: parseInt( - process.env.PROCESSOR_GATHER_STATISTICS_CONCURRENCY ?? - DEFAULT_PROCESSOR_GATHER_STATISTICS_CONCURRENCY.toString(), - 10 - ), + concurrency: GATHER_STATISTICS_CONCURRENCY, name: GATHER_STATISTICS_UPTIME_PROCESS_JOB_NAME }) public async gatherUptimeStatistics() { @@ -140,14 +132,14 @@ export class StatisticsGatheringProcessor { private async countDockerHubPulls(): Promise { try { - const { pull_count } = (await this.fetchService + const { pull_count } = await this.fetchService .fetch('https://hub.docker.com/v2/repositories/ghostfolio/ghostfolio', { headers: { 'User-Agent': 'request' }, signal: AbortSignal.timeout( this.configurationService.get('REQUEST_TIMEOUT') ) }) - .then((res) => res.json())) as { pull_count: number }; + .then<{ pull_count: number }>((res) => res.json()); return pull_count; } catch (error) { @@ -157,7 +149,7 @@ export class StatisticsGatheringProcessor { } } - private async countGitHubContributors(): Promise { + private async countGitHubContributors(): Promise { try { const body = await this.fetchService .fetch('https://github.com/ghostfolio/ghostfolio', { @@ -189,14 +181,14 @@ export class StatisticsGatheringProcessor { private async countGitHubStargazers(): Promise { try { - const { stargazers_count } = (await this.fetchService + const { stargazers_count } = await this.fetchService .fetch('https://api.github.com/repos/ghostfolio/ghostfolio', { headers: { 'User-Agent': 'request' }, signal: AbortSignal.timeout( this.configurationService.get('REQUEST_TIMEOUT') ) }) - .then((res) => res.json())) as { stargazers_count: number }; + .then<{ stargazers_count: number }>((res) => res.json()); return stargazers_count; } catch (error) { @@ -213,7 +205,7 @@ export class StatisticsGatheringProcessor { `https://uptime.betterstack.com/api/v2/monitors/${monitorId}/sla?from=${format( subDays(new Date(), 90), DATE_FORMAT - )}&to${format(new Date(), DATE_FORMAT)}`, + )}&to=${format(new Date(), DATE_FORMAT)}`, { headers: { [HEADER_KEY_TOKEN]: `Bearer ${this.configurationService.get( @@ -225,7 +217,7 @@ export class StatisticsGatheringProcessor { ) } ) - .then((res) => res.json()); + .then((res) => res.json()); return data.attributes.availability / 100; } catch (error) { diff --git a/apps/api/src/services/tag/tag.service.ts b/apps/api/src/services/tag/tag.service.ts index 38fb90a69..de052f9a1 100644 --- a/apps/api/src/services/tag/tag.service.ts +++ b/apps/api/src/services/tag/tag.service.ts @@ -20,7 +20,7 @@ export class TagService { public async getTag( tagWhereUniqueInput: Prisma.TagWhereUniqueInput - ): Promise { + ): Promise { return this.prismaService.tag.findUnique({ where: tagWhereUniqueInput }); diff --git a/apps/client/src/locales/messages.ca.xlf b/apps/client/src/locales/messages.ca.xlf index bed80cfe5..aa1734e32 100644 --- a/apps/client/src/locales/messages.ca.xlf +++ b/apps/client/src/locales/messages.ca.xlf @@ -2545,6 +2545,10 @@ libs/ui/src/lib/notifications/alert-dialog/alert-dialog.component.ts 46 + + libs/ui/src/lib/value/value.component.ts + 63 + Grant access @@ -5263,8 +5267,8 @@ 88 - libs/ui/src/lib/value/value.component.html - 18 + libs/ui/src/lib/value/value.component.ts + 64 @@ -7201,7 +7205,7 @@ libs/ui/src/lib/value/value.component.ts - 182 + 202 diff --git a/apps/client/src/locales/messages.de.xlf b/apps/client/src/locales/messages.de.xlf index a44212669..1aa95c70f 100644 --- a/apps/client/src/locales/messages.de.xlf +++ b/apps/client/src/locales/messages.de.xlf @@ -1432,6 +1432,10 @@ libs/ui/src/lib/notifications/alert-dialog/alert-dialog.component.ts 46 + + libs/ui/src/lib/value/value.component.ts + 63 + Grant access @@ -2353,8 +2357,8 @@ 88 - libs/ui/src/lib/value/value.component.html - 18 + libs/ui/src/lib/value/value.component.ts + 64 @@ -7225,7 +7229,7 @@ libs/ui/src/lib/value/value.component.ts - 182 + 202 diff --git a/apps/client/src/locales/messages.es.xlf b/apps/client/src/locales/messages.es.xlf index e05655a98..81f5ce42e 100644 --- a/apps/client/src/locales/messages.es.xlf +++ b/apps/client/src/locales/messages.es.xlf @@ -1417,6 +1417,10 @@ libs/ui/src/lib/notifications/alert-dialog/alert-dialog.component.ts 46 + + libs/ui/src/lib/value/value.component.ts + 63 + Grant access @@ -2338,8 +2342,8 @@ 88 - libs/ui/src/lib/value/value.component.html - 18 + libs/ui/src/lib/value/value.component.ts + 64 @@ -7202,7 +7206,7 @@ libs/ui/src/lib/value/value.component.ts - 182 + 202 diff --git a/apps/client/src/locales/messages.fr.xlf b/apps/client/src/locales/messages.fr.xlf index 565f18227..3637a997f 100644 --- a/apps/client/src/locales/messages.fr.xlf +++ b/apps/client/src/locales/messages.fr.xlf @@ -1800,6 +1800,10 @@ libs/ui/src/lib/notifications/alert-dialog/alert-dialog.component.ts 46 + + libs/ui/src/lib/value/value.component.ts + 63 + Grant access @@ -2857,8 +2861,8 @@ 88 - libs/ui/src/lib/value/value.component.html - 18 + libs/ui/src/lib/value/value.component.ts + 64 @@ -7201,7 +7205,7 @@ libs/ui/src/lib/value/value.component.ts - 182 + 202 diff --git a/apps/client/src/locales/messages.it.xlf b/apps/client/src/locales/messages.it.xlf index 2735f2d57..26f5752c8 100644 --- a/apps/client/src/locales/messages.it.xlf +++ b/apps/client/src/locales/messages.it.xlf @@ -1417,6 +1417,10 @@ libs/ui/src/lib/notifications/alert-dialog/alert-dialog.component.ts 46 + + libs/ui/src/lib/value/value.component.ts + 63 + Grant access @@ -2338,8 +2342,8 @@ 88 - libs/ui/src/lib/value/value.component.html - 18 + libs/ui/src/lib/value/value.component.ts + 64 @@ -7202,7 +7206,7 @@ libs/ui/src/lib/value/value.component.ts - 182 + 202 diff --git a/apps/client/src/locales/messages.ja.xlf b/apps/client/src/locales/messages.ja.xlf index 538c9f790..f584352a4 100644 --- a/apps/client/src/locales/messages.ja.xlf +++ b/apps/client/src/locales/messages.ja.xlf @@ -2342,6 +2342,10 @@ libs/ui/src/lib/notifications/alert-dialog/alert-dialog.component.ts 46 + + libs/ui/src/lib/value/value.component.ts + 63 + Grant access @@ -4863,8 +4867,8 @@ 88 - libs/ui/src/lib/value/value.component.html - 18 + libs/ui/src/lib/value/value.component.ts + 64 @@ -7242,7 +7246,7 @@ libs/ui/src/lib/value/value.component.ts - 182 + 202 diff --git a/apps/client/src/locales/messages.ko.xlf b/apps/client/src/locales/messages.ko.xlf index 6ef1b7fd5..6793e720e 100644 --- a/apps/client/src/locales/messages.ko.xlf +++ b/apps/client/src/locales/messages.ko.xlf @@ -2342,6 +2342,10 @@ libs/ui/src/lib/notifications/alert-dialog/alert-dialog.component.ts 46 + + libs/ui/src/lib/value/value.component.ts + 63 + Grant access @@ -4855,8 +4859,8 @@ 88 - libs/ui/src/lib/value/value.component.html - 18 + libs/ui/src/lib/value/value.component.ts + 64 @@ -7242,7 +7246,7 @@ libs/ui/src/lib/value/value.component.ts - 182 + 202 diff --git a/apps/client/src/locales/messages.nl.xlf b/apps/client/src/locales/messages.nl.xlf index 98fa88079..1f70d5518 100644 --- a/apps/client/src/locales/messages.nl.xlf +++ b/apps/client/src/locales/messages.nl.xlf @@ -1416,6 +1416,10 @@ libs/ui/src/lib/notifications/alert-dialog/alert-dialog.component.ts 46 + + libs/ui/src/lib/value/value.component.ts + 63 + Grant access @@ -2337,8 +2341,8 @@ 88 - libs/ui/src/lib/value/value.component.html - 18 + libs/ui/src/lib/value/value.component.ts + 64 @@ -7201,7 +7205,7 @@ libs/ui/src/lib/value/value.component.ts - 182 + 202 diff --git a/apps/client/src/locales/messages.pl.xlf b/apps/client/src/locales/messages.pl.xlf index 026d66974..5d18539aa 100644 --- a/apps/client/src/locales/messages.pl.xlf +++ b/apps/client/src/locales/messages.pl.xlf @@ -2309,6 +2309,10 @@ libs/ui/src/lib/notifications/alert-dialog/alert-dialog.component.ts 46 + + libs/ui/src/lib/value/value.component.ts + 63 + Grant access @@ -4822,8 +4826,8 @@ 88 - libs/ui/src/lib/value/value.component.html - 18 + libs/ui/src/lib/value/value.component.ts + 64 @@ -7201,7 +7205,7 @@ libs/ui/src/lib/value/value.component.ts - 182 + 202 diff --git a/apps/client/src/locales/messages.pt.xlf b/apps/client/src/locales/messages.pt.xlf index c68fd5da4..80f351a93 100644 --- a/apps/client/src/locales/messages.pt.xlf +++ b/apps/client/src/locales/messages.pt.xlf @@ -1800,6 +1800,10 @@ libs/ui/src/lib/notifications/alert-dialog/alert-dialog.component.ts 46 + + libs/ui/src/lib/value/value.component.ts + 63 + Grant access @@ -2785,8 +2789,8 @@ 88 - libs/ui/src/lib/value/value.component.html - 18 + libs/ui/src/lib/value/value.component.ts + 64 @@ -7201,7 +7205,7 @@ libs/ui/src/lib/value/value.component.ts - 182 + 202 diff --git a/apps/client/src/locales/messages.tr.xlf b/apps/client/src/locales/messages.tr.xlf index b2a1fd72a..85a1ce506 100644 --- a/apps/client/src/locales/messages.tr.xlf +++ b/apps/client/src/locales/messages.tr.xlf @@ -4246,8 +4246,8 @@ 88 - libs/ui/src/lib/value/value.component.html - 18 + libs/ui/src/lib/value/value.component.ts + 64 @@ -4574,6 +4574,10 @@ libs/ui/src/lib/notifications/alert-dialog/alert-dialog.component.ts 46 + + libs/ui/src/lib/value/value.component.ts + 63 + Grant access @@ -7201,7 +7205,7 @@ libs/ui/src/lib/value/value.component.ts - 182 + 202 diff --git a/apps/client/src/locales/messages.uk.xlf b/apps/client/src/locales/messages.uk.xlf index 6e07590bb..953b05b35 100644 --- a/apps/client/src/locales/messages.uk.xlf +++ b/apps/client/src/locales/messages.uk.xlf @@ -2773,6 +2773,10 @@ libs/ui/src/lib/notifications/alert-dialog/alert-dialog.component.ts 46 + + libs/ui/src/lib/value/value.component.ts + 63 + Grant access @@ -5600,7 +5604,7 @@ libs/ui/src/lib/value/value.component.ts - 182 + 202 @@ -5679,8 +5683,8 @@ 88 - libs/ui/src/lib/value/value.component.html - 18 + libs/ui/src/lib/value/value.component.ts + 64 diff --git a/apps/client/src/locales/messages.xlf b/apps/client/src/locales/messages.xlf index c1603bca8..5e26a30cb 100644 --- a/apps/client/src/locales/messages.xlf +++ b/apps/client/src/locales/messages.xlf @@ -2146,6 +2146,10 @@ libs/ui/src/lib/notifications/alert-dialog/alert-dialog.component.ts 46 + + libs/ui/src/lib/value/value.component.ts + 63 + Grant access @@ -4429,8 +4433,8 @@ 88 - libs/ui/src/lib/value/value.component.html - 18 + libs/ui/src/lib/value/value.component.ts + 64 @@ -6577,7 +6581,7 @@ libs/ui/src/lib/value/value.component.ts - 182 + 202 diff --git a/apps/client/src/locales/messages.zh.xlf b/apps/client/src/locales/messages.zh.xlf index 7cc82c8d3..be338019d 100644 --- a/apps/client/src/locales/messages.zh.xlf +++ b/apps/client/src/locales/messages.zh.xlf @@ -2318,6 +2318,10 @@ libs/ui/src/lib/notifications/alert-dialog/alert-dialog.component.ts 46 + + libs/ui/src/lib/value/value.component.ts + 63 + Grant access @@ -4839,8 +4843,8 @@ 88 - libs/ui/src/lib/value/value.component.html - 18 + libs/ui/src/lib/value/value.component.ts + 64 @@ -7202,7 +7206,7 @@ libs/ui/src/lib/value/value.component.ts - 182 + 202 diff --git a/libs/ui/src/lib/tags-selector/tags-selector.component.html b/libs/ui/src/lib/tags-selector/tags-selector.component.html index 92ea2b210..4f9d82ae9 100644 --- a/libs/ui/src/lib/tags-selector/tags-selector.component.html +++ b/libs/ui/src/lib/tags-selector/tags-selector.component.html @@ -32,13 +32,11 @@ } } @for (tag of tagsSelected(); track tag.id) { - + {{ tag.name }} - + } - + } diff --git a/libs/ui/src/lib/value/value.component.stories.ts b/libs/ui/src/lib/value/value.component.stories.ts index 214331efc..5a285e89b 100644 --- a/libs/ui/src/lib/value/value.component.stories.ts +++ b/libs/ui/src/lib/value/value.component.stories.ts @@ -1,5 +1,6 @@ import '@angular/localize/init'; -import { moduleMetadata } from '@storybook/angular'; +import { provideNoopAnimations } from '@angular/platform-browser/animations'; +import { applicationConfig, moduleMetadata } from '@storybook/angular'; import type { Meta, StoryObj } from '@storybook/angular'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; @@ -9,6 +10,9 @@ export default { title: 'Value', component: GfValueComponent, decorators: [ + applicationConfig({ + providers: [provideNoopAnimations()] + }), moduleMetadata({ imports: [NgxSkeletonLoaderModule] }) @@ -103,3 +107,12 @@ export const Precision: Story = { value: 7.2534802394809285309 } }; + +export const WithCopyButton: Story = { + args: { + enableCopyToClipboardButton: true, + locale: 'en-US', + value: 1234.56 + }, + name: 'With Copy Button' +}; diff --git a/libs/ui/src/lib/value/value.component.ts b/libs/ui/src/lib/value/value.component.ts index dffcb89e7..494f7a347 100644 --- a/libs/ui/src/lib/value/value.component.ts +++ b/libs/ui/src/lib/value/value.component.ts @@ -13,13 +13,14 @@ import { input, Input, OnChanges, + OnDestroy, ViewChild } from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; import { MatSnackBar } from '@angular/material/snack-bar'; import { IonIcon } from '@ionic/angular/standalone'; import { addIcons } from 'ionicons'; -import { copyOutline } from 'ionicons/icons'; +import { checkmarkOutline, copyOutline } from 'ionicons/icons'; import { isNumber } from 'lodash'; import ms from 'ms'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; @@ -32,7 +33,7 @@ import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; styleUrls: ['./value.component.scss'], templateUrl: './value.component.html' }) -export class GfValueComponent implements AfterViewInit, OnChanges { +export class GfValueComponent implements AfterViewInit, OnChanges, OnDestroy { @Input() colorizeSign = false; @Input() deviceType: string; @Input() enableCopyToClipboardButton = false; @@ -54,22 +55,29 @@ export class GfValueComponent implements AfterViewInit, OnChanges { public absoluteValue = 0; public formattedValue = ''; public hasLabel = false; + public isCopied = false; public isNumber = false; public isString = false; public useAbsoluteValue = false; + public readonly copiedTitle = $localize`The value has been copied to the clipboard`; + public readonly copyToClipboardTitle = $localize`Copy to clipboard`; + public constructor( private changeDetectorRef: ChangeDetectorRef, private clipboard: Clipboard, private snackBar: MatSnackBar ) { addIcons({ + checkmarkOutline, copyOutline }); } public readonly precision = input(); + private copyToClipboardTimeout: ReturnType; + private readonly formatOptions = computed(() => { const digits = this.hasPrecision ? this.precision() : 2; @@ -178,6 +186,18 @@ export class GfValueComponent implements AfterViewInit, OnChanges { public onCopyValueToClipboard() { this.clipboard.copy(String(this.value)); + this.isCopied = true; + + if (this.copyToClipboardTimeout) { + clearTimeout(this.copyToClipboardTimeout); + } + + this.copyToClipboardTimeout = setTimeout(() => { + this.isCopied = false; + + this.changeDetectorRef.markForCheck(); + }, ms('3 seconds')); + this.snackBar.open( '✅ ' + $localize`${this.value} has been copied to the clipboard`, undefined, @@ -187,12 +207,23 @@ export class GfValueComponent implements AfterViewInit, OnChanges { ); } + public ngOnDestroy() { + if (this.copyToClipboardTimeout) { + clearTimeout(this.copyToClipboardTimeout); + } + } + private initializeVariables() { this.absoluteValue = 0; this.formattedValue = ''; + this.isCopied = false; this.isNumber = false; this.isString = false; this.locale = this.locale || getLocale(); this.useAbsoluteValue = false; + + if (this.copyToClipboardTimeout) { + clearTimeout(this.copyToClipboardTimeout); + } } } diff --git a/package-lock.json b/package-lock.json index 85a19972d..ccd038814 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ghostfolio", - "version": "3.28.0", + "version": "3.29.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ghostfolio", - "version": "3.28.0", + "version": "3.29.0", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { @@ -92,7 +92,7 @@ "passport-openidconnect": "0.1.2", "reflect-metadata": "0.2.2", "rxjs": "7.8.1", - "stripe": "22.2.3", + "stripe": "22.3.2", "svgmap": "2.21.0", "tablemark": "4.1.0", "twitter-api-v2": "1.29.0", @@ -32428,9 +32428,9 @@ } }, "node_modules/stripe": { - "version": "22.2.3", - "resolved": "https://registry.npmjs.org/stripe/-/stripe-22.2.3.tgz", - "integrity": "sha512-9lrggvLtjO4Ef5WXH4t50ckHJcJgTD72xFB+KJgZCSszAH9zMV1BqU6PwmWbRLdcdX7LK6PbErBapiGK7pIb+g==", + "version": "22.3.2", + "resolved": "https://registry.npmjs.org/stripe/-/stripe-22.3.2.tgz", + "integrity": "sha512-O13QOvgEIQvDlTy6Ubb5kB980wpbhmoZNsgCXKILjCMZS67f+bW+6w99k3gnSi/N1lkryoj1WYdpGT5Wc5edjg==", "license": "MIT", "engines": { "node": ">=18" diff --git a/package.json b/package.json index 6caa1604c..14d4f5f11 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ghostfolio", - "version": "3.28.0", + "version": "3.29.0", "homepage": "https://ghostfol.io", "license": "AGPL-3.0", "repository": "https://github.com/ghostfolio/ghostfolio", @@ -136,7 +136,7 @@ "passport-openidconnect": "0.1.2", "reflect-metadata": "0.2.2", "rxjs": "7.8.1", - "stripe": "22.2.3", + "stripe": "22.3.2", "svgmap": "2.21.0", "tablemark": "4.1.0", "twitter-api-v2": "1.29.0",