diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index 9df3e0d6d..a881e8fc8 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1 +1,2 @@ buy_me_a_coffee: ghostfolio +github: ghostfolio diff --git a/CHANGELOG.md b/CHANGELOG.md index be5c6544b..81d4bd0ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,10 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Added + +- Added support for configuring the safe withdrawal rate in the _FIRE_ section (experimental) + ### Changed +- Changed the _As seen in_ section on the landing page to an animated carousel - Refactored various components to use self-closing tags +### Fixed + +- Fixed the server startup message to properly display IPv6 addresses + ## 2.207.0 - 2025-10-08 ### Added diff --git a/apps/api/src/app/endpoints/public/public.controller.ts b/apps/api/src/app/endpoints/public/public.controller.ts index 0f3ba4682..b09ced4fb 100644 --- a/apps/api/src/app/endpoints/public/public.controller.ts +++ b/apps/api/src/app/endpoints/public/public.controller.ts @@ -40,7 +40,7 @@ export class PublicController { @UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor) public async getPublicPortfolio( - @Param('accessId') accessId + @Param('accessId') accessId: string ): Promise { const access = await this.accessService.access({ id: accessId }); diff --git a/apps/api/src/app/order/create-order.dto.ts b/apps/api/src/app/order/create-order.dto.ts index af87fd93e..ba7a1d868 100644 --- a/apps/api/src/app/order/create-order.dto.ts +++ b/apps/api/src/app/order/create-order.dto.ts @@ -44,8 +44,7 @@ export class CreateOrderDto { customCurrency?: string; @IsEnum(DataSource) - @IsOptional() - dataSource?: DataSource; + dataSource: DataSource; @IsISO8601() @Validate(IsAfter1970Constraint) diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 41f156cbf..a8de3dc5e 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -90,7 +90,21 @@ async function bootstrap() { await app.listen(PORT, HOST, () => { logLogo(); - Logger.log(`Listening at http://${HOST}:${PORT}`); + + let address = app.getHttpServer().address(); + + if (typeof address === 'object') { + const addressObject = address; + let host = addressObject.address; + + if (addressObject.family === 'IPv6') { + host = `[${addressObject.address}]`; + } + + address = `${host}:${addressObject.port}`; + } + + Logger.log(`Listening at http://${address}`); Logger.log(''); }); } diff --git a/apps/client/src/app/pages/landing/landing-page.component.ts b/apps/client/src/app/pages/landing/landing-page.component.ts index f16a9c14a..f755494e0 100644 --- a/apps/client/src/app/pages/landing/landing-page.component.ts +++ b/apps/client/src/app/pages/landing/landing-page.component.ts @@ -4,6 +4,7 @@ import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { publicRoutes } from '@ghostfolio/common/routes/routes'; import { GfCarouselComponent } from '@ghostfolio/ui/carousel'; import { GfLogoComponent } from '@ghostfolio/ui/logo'; +import { GfLogoCarouselComponent } from '@ghostfolio/ui/logo-carousel'; import { GfValueComponent } from '@ghostfolio/ui/value'; import { GfWorldMapChartComponent } from '@ghostfolio/ui/world-map-chart'; @@ -27,6 +28,7 @@ import { Subject } from 'rxjs'; imports: [ CommonModule, GfCarouselComponent, + GfLogoCarouselComponent, GfLogoComponent, GfValueComponent, GfWorldMapChartComponent, diff --git a/apps/client/src/app/pages/landing/landing-page.html b/apps/client/src/app/pages/landing/landing-page.html index 7202e2910..7f77c31a7 100644 --- a/apps/client/src/app/pages/landing/landing-page.html +++ b/apps/client/src/app/pages/landing/landing-page.html @@ -114,109 +114,8 @@
As seen in
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- +
+
diff --git a/apps/client/src/app/pages/landing/landing-page.scss b/apps/client/src/app/pages/landing/landing-page.scss index 4c0c14efd..35f362b0f 100644 --- a/apps/client/src/app/pages/landing/landing-page.scss +++ b/apps/client/src/app/pages/landing/landing-page.scss @@ -34,69 +34,6 @@ &.logo-agplv3 { mask-image: url('/assets/images/logo-AGPLv3.svg'); } - - &.logo-alternative-to { - mask-image: url('/assets/images/logo-alternative-to.svg'); - } - - &.logo-awesome { - background-image: url('/assets/images/logo-awesome.png'); - background-position: center; - background-repeat: no-repeat; - background-size: contain; - filter: grayscale(1); - } - - &.logo-dev-community { - mask-image: url('/assets/images/logo-dev-community.svg'); - } - - &.logo-hacker-news { - mask-image: url('/assets/images/logo-hacker-news.svg'); - } - - &.logo-openalternative { - mask-image: url('/assets/images/logo-openalternative.svg'); - } - - &.logo-privacy-tools { - mask-image: url('/assets/images/logo-privacy-tools.svg'); - } - - &.logo-product-hunt { - background-image: url('/assets/images/logo-product-hunt.png'); - background-position: center; - background-repeat: no-repeat; - background-size: contain; - filter: grayscale(1); - } - - &.logo-reddit { - mask-image: url('/assets/images/logo-reddit.svg'); - max-height: 1rem; - } - - &.logo-sackgeld { - mask-image: url('/assets/images/logo-sackgeld.png'); - } - - &.logo-selfh-st { - mask-image: url('/assets/images/logo-selfh-st.svg'); - max-height: 1.25rem; - } - - &.logo-sourceforge { - mask-image: url('/assets/images/logo-sourceforge.svg'); - } - - &.logo-umbrel { - mask-image: url('/assets/images/logo-umbrel.svg'); - max-height: 1.5rem; - } - - &.logo-unraid { - mask-image: url('/assets/images/logo-unraid.svg'); - } } .outro-inner-container { @@ -128,18 +65,7 @@ } .logo { - &.logo-agplv3, - &.logo-alternative-to, - &.logo-dev-community, - &.logo-hacker-news, - &.logo-openalternative, - &.logo-privacy-tools, - &.logo-reddit, - &.logo-sackgeld, - &.logo-selfh-st, - &.logo-sourceforge, - &.logo-umbrel, - &.logo-unraid { + &.logo-agplv3 { background-color: rgba(var(--light-primary-text)); } } diff --git a/apps/client/src/app/pages/portfolio/fire/fire-page.component.ts b/apps/client/src/app/pages/portfolio/fire/fire-page.component.ts index c80b55c45..63187c05c 100644 --- a/apps/client/src/app/pages/portfolio/fire/fire-page.component.ts +++ b/apps/client/src/app/pages/portfolio/fire/fire-page.component.ts @@ -7,8 +7,10 @@ import { GfFireCalculatorComponent } from '@ghostfolio/ui/fire-calculator'; import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator'; import { GfValueComponent } from '@ghostfolio/ui/value'; -import { NgStyle } from '@angular/common'; +import { CommonModule, NgStyle } from '@angular/common'; import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { FormControl } from '@angular/forms'; import { Big } from 'big.js'; import { DeviceDetectorService } from 'ngx-device-detector'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; @@ -17,11 +19,14 @@ import { takeUntil } from 'rxjs/operators'; @Component({ imports: [ + CommonModule, + FormsModule, GfFireCalculatorComponent, GfPremiumIndicatorComponent, GfValueComponent, NgStyle, - NgxSkeletonLoaderModule + NgxSkeletonLoaderModule, + ReactiveFormsModule ], selector: 'gf-fire-page', styleUrls: ['./fire-page.scss'], @@ -33,6 +38,8 @@ export class GfFirePageComponent implements OnDestroy, OnInit { public hasImpersonationId: boolean; public hasPermissionToUpdateUserSettings: boolean; public isLoading = false; + public safeWithdrawalRateControl = new FormControl(undefined); + public safeWithdrawalRateOptions = [0.025, 0.03, 0.035, 0.04, 0.045]; public user: User; public withdrawalRatePerMonth: Big; public withdrawalRatePerYear: Big; @@ -70,11 +77,7 @@ export class GfFirePageComponent implements OnDestroy, OnInit { }; } - this.withdrawalRatePerYear = Big( - this.fireWealth.today.valueInBaseCurrency - ).mul(this.user.settings.safeWithdrawalRate); - - this.withdrawalRatePerMonth = this.withdrawalRatePerYear.div(12); + this.calculateWithdrawalRates(); this.isLoading = false; @@ -88,6 +91,12 @@ export class GfFirePageComponent implements OnDestroy, OnInit { this.hasImpersonationId = !!impersonationId; }); + this.safeWithdrawalRateControl.valueChanges + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe((value) => { + this.onSafeWithdrawalRateChange(Number(value)); + }); + this.userService.stateChanged .pipe(takeUntil(this.unsubscribeSubject)) .subscribe((state) => { @@ -102,6 +111,13 @@ export class GfFirePageComponent implements OnDestroy, OnInit { permissions.updateUserSettings ); + this.safeWithdrawalRateControl.setValue( + this.user.settings.safeWithdrawalRate, + { emitEvent: false } + ); + + this.calculateWithdrawalRates(); + this.changeDetectorRef.markForCheck(); } }); @@ -141,6 +157,25 @@ export class GfFirePageComponent implements OnDestroy, OnInit { }); }); } + + public onSafeWithdrawalRateChange(safeWithdrawalRate: number) { + this.dataService + .putUserSetting({ safeWithdrawalRate }) + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe(() => { + this.userService + .get(true) + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe((user) => { + this.user = user; + + this.calculateWithdrawalRates(); + + this.changeDetectorRef.markForCheck(); + }); + }); + } + public onSavingsRateChange(savingsRate: number) { this.dataService .putUserSetting({ savingsRate }) @@ -180,4 +215,14 @@ export class GfFirePageComponent implements OnDestroy, OnInit { this.unsubscribeSubject.next(); this.unsubscribeSubject.complete(); } + + private calculateWithdrawalRates() { + if (this.fireWealth && this.user?.settings?.safeWithdrawalRate) { + this.withdrawalRatePerYear = new Big( + this.fireWealth.today.valueInBaseCurrency + ).mul(this.user.settings.safeWithdrawalRate); + + this.withdrawalRatePerMonth = this.withdrawalRatePerYear.div(12); + } + } } diff --git a/apps/client/src/app/pages/portfolio/fire/fire-page.html b/apps/client/src/app/pages/portfolio/fire/fire-page.html index d6548f761..ce51717fa 100644 --- a/apps/client/src/app/pages/portfolio/fire/fire-page.html +++ b/apps/client/src/app/pages/portfolio/fire/fire-page.html @@ -105,15 +105,32 @@   and a safe withdrawal rate (SWR) of -   - . + @if ( + !hasImpersonationId && + hasPermissionToUpdateUserSettings && + user?.settings?.isExperimentalFeatures + ) { + . + } @else { +   + . + } } diff --git a/apps/client/src/app/pages/portfolio/fire/fire-page.scss b/apps/client/src/app/pages/portfolio/fire/fire-page.scss index 5d4e87f30..2892885c9 100644 --- a/apps/client/src/app/pages/portfolio/fire/fire-page.scss +++ b/apps/client/src/app/pages/portfolio/fire/fire-page.scss @@ -1,3 +1,19 @@ :host { display: block; + + .safe-withdrawal-rate-select { + background-color: transparent; + color: rgb(var(--dark-primary-text)); + + &:focus { + box-shadow: none; + outline: 0; + } + } +} + +:host-context(.theme-dark) { + .safe-withdrawal-rate-select { + color: rgb(var(--light-primary-text)); + } } diff --git a/libs/ui/src/lib/logo-carousel/index.ts b/libs/ui/src/lib/logo-carousel/index.ts new file mode 100644 index 000000000..e69f6039c --- /dev/null +++ b/libs/ui/src/lib/logo-carousel/index.ts @@ -0,0 +1 @@ +export * from './logo-carousel.component'; diff --git a/libs/ui/src/lib/logo-carousel/interfaces/interfaces.ts b/libs/ui/src/lib/logo-carousel/interfaces/interfaces.ts new file mode 100644 index 000000000..43dcfeefd --- /dev/null +++ b/libs/ui/src/lib/logo-carousel/interfaces/interfaces.ts @@ -0,0 +1,7 @@ +export interface LogoItem { + className: string; + isMask?: boolean; + name: string; + title: string; + url: string; +} diff --git a/libs/ui/src/lib/logo-carousel/logo-carousel.component.html b/libs/ui/src/lib/logo-carousel/logo-carousel.component.html new file mode 100644 index 000000000..79b4ca11b --- /dev/null +++ b/libs/ui/src/lib/logo-carousel/logo-carousel.component.html @@ -0,0 +1,16 @@ + diff --git a/libs/ui/src/lib/logo-carousel/logo-carousel.component.scss b/libs/ui/src/lib/logo-carousel/logo-carousel.component.scss new file mode 100644 index 000000000..d8a8865f7 --- /dev/null +++ b/libs/ui/src/lib/logo-carousel/logo-carousel.component.scss @@ -0,0 +1,213 @@ +:host { + display: block; + overflow: hidden; + position: relative; + width: 100%; + + .logo-carousel-container { + &::before, + &::after { + content: ''; + height: 100%; + pointer-events: none; + position: absolute; + top: 0; + width: 100px; + z-index: 2; + } + + &::before { + background: linear-gradient( + to right, + var(--light-background) 0%, + rgba(var(--palette-background-background), 0) 100% + ); + left: 0; + } + + &::after { + background: linear-gradient( + to left, + var(--light-background) 0%, + rgba(var(--palette-background-background), 0) 100% + ); + right: 0; + } + + @media (max-width: 768px) { + &::before, + &::after { + width: 50px; + } + } + + @media (max-width: 576px) { + &::before, + &::after { + width: 30px; + } + } + + .logo-carousel-track { + animation: scroll 60s linear infinite; + width: fit-content; + + &:hover { + animation-play-state: paused; + } + + .logo-carousel-item { + flex-shrink: 0; + min-width: 200px; + padding: 0 2rem; + + @media (max-width: 768px) { + min-width: 150px; + padding: 0 1.5rem; + } + + @media (max-width: 576px) { + min-width: 120px; + padding: 0 1rem; + } + + .logo { + height: 3rem; + transition: + opacity 0.3s ease, + transform 0.3s ease; + width: 7.5rem; + + &:hover { + opacity: 0.8; + } + + &.mask { + background-color: rgba(var(--dark-secondary-text)); + mask-position: center; + mask-repeat: no-repeat; + mask-size: contain; + } + + &.logo-alternative-to { + mask-image: url('/assets/images/logo-alternative-to.svg'); + } + + &.logo-awesome { + background-image: url('/assets/images/logo-awesome.png'); + background-position: center; + background-repeat: no-repeat; + background-size: contain; + filter: grayscale(1); + } + + &.logo-dev-community { + mask-image: url('/assets/images/logo-dev-community.svg'); + } + + &.logo-hacker-news { + mask-image: url('/assets/images/logo-hacker-news.svg'); + } + + &.logo-openalternative { + mask-image: url('/assets/images/logo-openalternative.svg'); + } + + &.logo-privacy-tools { + mask-image: url('/assets/images/logo-privacy-tools.svg'); + } + + &.logo-product-hunt { + background-image: url('/assets/images/logo-product-hunt.png'); + background-position: center; + background-repeat: no-repeat; + background-size: contain; + filter: grayscale(1); + } + + &.logo-reddit { + mask-image: url('/assets/images/logo-reddit.svg'); + max-height: 1rem; + } + + &.logo-sackgeld { + mask-image: url('/assets/images/logo-sackgeld.png'); + } + + &.logo-selfh-st { + mask-image: url('/assets/images/logo-selfh-st.svg'); + max-height: 1.25rem; + } + + &.logo-sourceforge { + mask-image: url('/assets/images/logo-sourceforge.svg'); + } + + &.logo-umbrel { + mask-image: url('/assets/images/logo-umbrel.svg'); + max-height: 1.5rem; + } + + &.logo-unraid { + mask-image: url('/assets/images/logo-unraid.svg'); + } + + @media (max-width: 768px) { + height: 2.5rem; + width: 6rem; + } + + @media (max-width: 576px) { + height: 2rem; + width: 5rem; + } + } + } + + @keyframes scroll { + 0% { + transform: translateX(0); + } + 100% { + transform: translateX(-50%); + } + } + } + } +} + +:host-context(.theme-dark) { + .logo-carousel-container { + &::before { + background: linear-gradient( + to right, + var(--dark-background) 0%, + rgba(var(--palette-background-background-dark), 0) 100% + ); + } + + &::after { + background: linear-gradient( + to left, + var(--dark-background) 0%, + rgba(var(--palette-background-background-dark), 0) 100% + ); + } + + .logo { + &.logo-alternative-to, + &.logo-dev-community, + &.logo-hacker-news, + &.logo-openalternative, + &.logo-privacy-tools, + &.logo-reddit, + &.logo-sackgeld, + &.logo-selfh-st, + &.logo-sourceforge, + &.logo-umbrel, + &.logo-unraid { + background-color: rgba(var(--light-primary-text)); + } + } + } +} diff --git a/libs/ui/src/lib/logo-carousel/logo-carousel.component.stories.ts b/libs/ui/src/lib/logo-carousel/logo-carousel.component.stories.ts new file mode 100644 index 000000000..a4e88df98 --- /dev/null +++ b/libs/ui/src/lib/logo-carousel/logo-carousel.component.stories.ts @@ -0,0 +1,13 @@ +import type { Meta, StoryObj } from '@storybook/angular'; + +import { GfLogoCarouselComponent } from './logo-carousel.component'; + +const meta: Meta = { + title: 'Logo Carousel', + component: GfLogoCarouselComponent +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/libs/ui/src/lib/logo-carousel/logo-carousel.component.ts b/libs/ui/src/lib/logo-carousel/logo-carousel.component.ts new file mode 100644 index 000000000..d7d3fa6af --- /dev/null +++ b/libs/ui/src/lib/logo-carousel/logo-carousel.component.ts @@ -0,0 +1,110 @@ +import { CommonModule } from '@angular/common'; +import { ChangeDetectionStrategy, Component } from '@angular/core'; + +import { LogoItem } from './interfaces/interfaces'; + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [CommonModule], + selector: 'gf-logo-carousel', + styleUrls: ['./logo-carousel.component.scss'], + templateUrl: './logo-carousel.component.html' +}) +export class GfLogoCarouselComponent { + public readonly logos: LogoItem[] = [ + { + className: 'logo-alternative-to', + isMask: true, + name: 'AlternativeTo', + title: 'AlternativeTo - Crowdsourced software recommendations', + url: 'https://alternativeto.net' + }, + { + className: 'logo-awesome', + name: 'Awesome Selfhosted', + title: + 'Awesome-Selfhosted: A list of Free Software network services and web applications which can be hosted on your own servers', + url: 'https://github.com/awesome-selfhosted/awesome-selfhosted' + }, + { + className: 'logo-dev-community', + isMask: true, + name: 'DEV Community', + title: + 'DEV Community - A constructive and inclusive social network for software developers', + url: 'https://dev.to' + }, + { + className: 'logo-hacker-news', + isMask: true, + name: 'Hacker News', + title: 'Hacker News', + url: 'https://news.ycombinator.com' + }, + { + className: 'logo-openalternative', + isMask: true, + name: 'OpenAlternative', + title: 'OpenAlternative: Open Source Alternatives to Popular Software', + url: 'https://openalternative.co' + }, + { + className: 'logo-privacy-tools', + isMask: true, + name: 'Privacy Tools', + title: 'Privacy Tools: Software Alternatives and Encryption', + url: 'https://www.privacytools.io' + }, + { + className: 'logo-product-hunt', + name: 'Product Hunt', + title: 'Product Hunt – The best new products in tech.', + url: 'https://www.producthunt.com' + }, + { + className: 'logo-reddit', + isMask: true, + name: 'Reddit', + title: 'Reddit - Dive into anything', + url: 'https://www.reddit.com' + }, + { + className: 'logo-sackgeld', + isMask: true, + name: 'Sackgeld', + title: 'Sackgeld.com – Apps für ein höheres Sackgeld', + url: 'https://www.sackgeld.com' + }, + { + className: 'logo-selfh-st', + isMask: true, + name: 'selfh.st', + title: 'selfh.st — Self-hosted content and software', + url: 'https://selfh.st' + }, + { + className: 'logo-sourceforge', + isMask: true, + name: 'SourceForge', + title: + 'SourceForge: The Complete Open-Source and Business Software Platform', + url: 'https://sourceforge.net' + }, + { + className: 'logo-umbrel', + isMask: true, + name: 'Umbrel', + title: 'Umbrel — A personal server OS for self-hosting', + url: 'https://umbrel.com' + }, + { + className: 'logo-unraid', + isMask: true, + name: 'Unraid', + title: 'Unraid | Unleash Your Hardware', + url: 'https://unraid.net' + } + ]; + + public readonly logosRepeated = [...this.logos, ...this.logos]; +}